Files
ai-agent/backend/conversations/routes.py
Gabriel Vidal 81c455defa feat(users): scope every guest-reachable route to the caller
A guest sees the conversations they started or were shared (list,
detail, summary, search index, subagents, raw meta, diffs), the forms,
asks and pushes of those, and the projects they were granted. Their
ui-state lives under u.<id>.<key>; the SSE hub filters per subscriber.
Forms record answeredBy. Pushes from a guest's session skip the owner's
webhooks and go to the guest's channels (in-app, email).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:27:23 +02:00

394 lines
17 KiB
Python

"""Conversation list / detail / search / scaffold / metadata routes."""
import copy
import datetime
import pathlib
import threading
from fastapi import APIRouter, HTTPException
from fastapi.responses import PlainTextResponse
from pydantic import BaseModel
import accounts as accounts_mod
import conversations
import memories as memories_mod
import plans as plans_mod
import schemas
from conversations import scaffold as scaffold_mod
from conversations.cards import _conv_meta, _conv_title
from conversations.catalog import (
_CARD_KEYS,
_agents_by_conversation,
_conversation_cards,
_meta_key,
_resolve_transcript,
_skills_used,
_summary_and_meta,
)
from conversations.tree import (
_attach_subagents,
_conv_id,
_is_deploying,
_parent_path_of,
_rollup_children,
_root_path_of,
_running_agents,
_sidechain_state,
_subagent_descendants,
_subagent_meta_cached,
_with_child_models,
)
from core.http import _json_in_worker, _r
from core.state import State
from indexer import INPUT_PRICE_PER_MTOK, MODEL
from projects import artefacts as artefacts_mod
from users import access
from users.caller import CallerDep
router = APIRouter()
@router.get("/api/conversations", responses=_r(schemas.ConversationsResponse))
@_json_in_worker
def conversations_list(deps: State, caller: CallerDep, limit: int = 50,
before: str | None = None,
archived: str = "false", full: bool = False,
sessions: str = "", account: str | None = None):
"""List parsed transcripts (newest first) for the conversation viewer.
Paginated: ``limit`` caps the page (0 = everything), ``before`` is the
``endedAt`` cursor of the previous page's last card, and ``archived``
filters hidden conversations server-side (``false`` — the default view —
excludes them; ``all`` includes them). ``full=1`` adds the per-card
``usage``/``byToolSub`` blocks the analytics dashboard aggregates.
``sessions`` (comma-separated session ids) resolves a specific handful of
conversations instead of a page — what the home page's "waiting for
feedback" cards use to join a pending form/ask back to the session that
posted it, without pulling the whole (~MB) list for one title. An explicit
id lookup ignores the archived filter and the cursor.
``account`` keeps one Claude account's conversations (``totals`` = the
ones counted in totals). Unlike the cost endpoints, a *list* is unfiltered
by default — every conversation is listed; only the sums leave work out."""
cards = list(_conversation_cards(deps, full)) # shared memo — sorted below
cards = access.filter_cards(caller, cards) # a guest: own + shared only
if sessions:
want = {s.strip() for s in sessions.split(",") if s.strip()}
cards = [c for c in cards if c.get("sessionId") in want]
return {"conversations": cards, "count": len(cards),
"nextBefore": None,
"pricing": {"model": MODEL,
"inputPerMTok": INPUT_PRICE_PER_MTOK}}
if archived != "all":
cards = [c for c in cards if not c["meta"].get("archived")]
if account:
sel = accounts_mod.selector(account)
cards = [c for c in cards if sel(c["meta"].get("account"))]
cards.sort(key=lambda c: c.get("endedAt") or "", reverse=True)
total = len(cards)
if before:
cards = [c for c in cards if (c.get("endedAt") or "") < before]
next_before = None
if limit and limit > 0 and len(cards) > limit:
cards = cards[:limit]
next_before = cards[-1].get("endedAt")
return {"conversations": cards, "count": total, "nextBefore": next_before,
"pricing": {"model": MODEL, "inputPerMTok": INPUT_PRICE_PER_MTOK}}
@router.get("/api/subagents", responses=_r(schemas.SubagentsResponse))
@_json_in_worker
def subagents_list(deps: State, caller: CallerDep):
"""Every subagent (sidechain) transcript as a flat lite ref carrying its
parent conversation id. The top-level conversation list hides sidechains;
this is the one place they're enumerated — the graph view's node source.
All from the in-process summaries cache (+ cached meta.json reads)."""
out = []
open_by_parent: dict[str, set] = {}
for path, s in deps.store.all_summaries():
if not s.get("isSidechain") or not s.get("messages"):
continue
if not access.visible(deps, caller, _conv_id(path)):
continue
pp = _parent_path_of(path)
rp = _root_path_of(path)
if not pp or not rp:
continue
# One open-Task lookup per parent, shared by all its children. A nested
# agent's parent is a sidechain whose state comes from *its* parent's
# open Task calls (`_sidechain_state`), not from the shared meta state.
if pp not in open_by_parent:
psum = deps.store.get_summary(pp) or {}
pstate = _sidechain_state(deps, pathlib.Path(pp))
open_by_parent[pp] = {a.get("toolUseId")
for a in _running_agents(deps, psum, pstate)}
mj = _subagent_meta_cached(pathlib.Path(path))
tuid = mj.get("toolUseId")
out.append({
"id": _conv_id(path),
"parentId": _conv_id(pp),
"rootId": _conv_id(rp),
"depth": mj.get("spawnDepth") or (1 if pp == rp else 2),
"agentType": mj.get("agentType"),
"description": mj.get("description"),
"title": s.get("title"),
"tokens": s.get("tokens"),
"cost": s.get("cost"),
"state": "running" if tuid and tuid in open_by_parent[pp]
else "finished",
})
return {"subagents": out}
@router.get("/api/conversation-summary",
responses=_r(schemas.ConversationSummary))
def conversation_summary(deps: State, caller: CallerDep, id: str):
"""One conversation's list card — what an SSE `transcript` ping patches
into the cached list, so a live turn costs a few KB instead of a full
list refetch."""
access.ensure_visible(deps, caller, id)
ap = _resolve_transcript(id)
s = deps.store.get_summary(str(ap))
if not s:
raise HTTPException(404, "summary not indexed yet")
children: list[dict] = []
paths = [str(ap)]
for child in _subagent_descendants(ap): # the whole tree, any depth
paths.append(str(child))
cs = deps.store.get_summary(str(child))
if cs:
children.append(cs)
skills_used = _skills_used(deps, paths)
s = _rollup_children(s, children)
m = _conv_meta(deps, s)
return {"id": id, **{k: s.get(k) for k in _CARD_KEYS},
"title": _conv_title(deps, s),
"runningAgents": _running_agents(deps, s, m.get("state")),
"deploying": _is_deploying(deps, s, m.get("state")),
"skillsUsed": skills_used,
"agentsUsed": _agents_by_conversation(deps, ).get(id, []),
"meta": m}
@router.get("/api/search-index", responses=_r(schemas.SearchIndexResponse))
def search_index(deps: State, caller: CallerDep):
"""Full-text search corpus: every conversation's user/assistant text blocks.
Shipped once to the client, which runs a fuzzy search (Fuse.js) over it. Each
message carries its ordinal `i`, which matches the `mi` on the detail thread's
text items, so a hit can deep-link to `/conversation/<id>?m=<i>` and scroll to
the exact message."""
out = []
for path, s in deps.store.all_summaries():
if not s.get("messages"):
continue
if s.get("isSidechain"):
continue # subagent messages are reachable via the parent, not listed
if not access.visible(deps, caller, _conv_id(path)):
continue
msgs = s.get("searchMsgs") or []
if not msgs:
continue
out.append({
"id": _conv_id(path),
"title": _conv_title(deps, s),
"project": s.get("project"),
"model": s.get("model"),
"endedAt": s.get("endedAt"),
# serve-time clip: search + snippets don't need more, and this
# corpus ships to the client in one payload
"msgs": [{**m, "text": (m.get("text") or "")[:500]} for m in msgs],
})
out.sort(key=lambda c: c.get("endedAt") or "", reverse=True)
return {"conversations": out, "count": len(out)}
# Recently parsed full threads, keyed by path and validated on (mtime, size).
# A parse of a large transcript costs hundreds of ms; re-opening a conversation
# (or a meta-only SSE ping refetching the open one) shouldn't pay it again.
# Served as a deep copy — the endpoint decorates the dict in place.
_detail_cache: dict[str, tuple[tuple[float, int], dict]] = {}
_DETAIL_CACHE_CAP = 4
_detail_lock = threading.Lock()
def _parse_full_cached(ap: pathlib.Path) -> dict:
key = str(ap)
try:
st = ap.stat()
sig = (st.st_mtime, st.st_size)
except OSError:
sig = None
with _detail_lock:
hit = _detail_cache.get(key)
if hit and sig and hit[0] == sig:
_detail_cache[key] = _detail_cache.pop(key) # LRU bump
return copy.deepcopy(hit[1])
data = conversations.parse_conversation(ap, full=True)
if sig:
with _detail_lock:
_detail_cache[key] = (sig, copy.deepcopy(data))
while len(_detail_cache) > _DETAIL_CACHE_CAP:
_detail_cache.pop(next(iter(_detail_cache)))
return data
@router.get("/api/conversation", responses=_r(schemas.ConversationDetail))
@_json_in_worker
def conversation_detail(deps: State, caller: CallerDep, id: str):
"""Full parsed thread (tools collapsible client-side) + per-turn usage."""
access.ensure_visible(deps, caller, id)
ap = _resolve_transcript(id)
data = _parse_full_cached(ap)
# The raw `{skill: {count, last}}` map is indexer-internal; the thread only
# needs the names it used, and those roll the subagents' calls in. This
# parse is fresher than the store (a live turn re-parses here first), so the
# conversation's own map comes off `data` and only the children are joined.
kid_paths = [str(c) for c in _subagent_descendants(ap)] # any depth
data["skillsUsed"] = _skills_used(deps, kid_paths, extra=data.get("skills"))
data["agentsUsed"] = _agents_by_conversation(deps, ).get(id, [])
data["models"] = _with_child_models(
data.get("models"), [c for c in map(deps.store.get_summary, kid_paths) if c])
data.pop("skills", None)
data["id"] = id
data["title"] = _conv_title(deps, data)
data["meta"] = _conv_meta(deps, data)
sub_state = _sidechain_state(deps, ap)
if sub_state:
data["meta"]["state"] = sub_state
# Resolve the state first: which Task calls count as running depends on it.
data["runningAgents"] = _running_agents(deps, data, data["meta"].get("state"))
data["deploying"] = _is_deploying(deps, data, data["meta"].get("state"))
_attach_subagents(deps, ap, data)
# Cross-link the memories this conversation read (recalled) and created.
read_slugs = set(data.get("memoriesRead") or [])
data["memoriesRead"] = [m for m in memories_mod.list_memories()
if m["slug"] in read_slugs]
data["memoriesCreated"] = memories_mod.by_origin(data.get("sessionId"))
# Plans this conversation authored (the `plan` skill stamps the planning
# session id / conversationIds into each plan's frontmatter).
sid = data.get("sessionId")
data["plans"] = ([p for p in plans_mod.list_plans()
if sid in plans_mod.linked_session_ids(p)] if sid else [])
# Generated artefacts (screenshots, renders…) the conversation's projects/
# services hold for this session — <dir>/.ai/artefacts/<date>/<sid>/<file>.
data["artefacts"] = artefacts_mod.list_for_conversation(
sid or "", data["meta"].get("projects") or [],
data["meta"].get("services") or [])
if not caller.is_owner:
# The owner's memory and plans aren't a guest's to browse.
data["memoriesRead"], data["memoriesCreated"], data["plans"] = [], [], []
return data
# ── conversation scaffold + published summary (see scaffold.py) ─────────────
# The algorithmic half of the `complete` skill: everything about a conversation
# can be aggregated rather than reasoned about, handed to the model as markdown
# with two sections left blank. `conv-scaffold` (scripts/) is the CLI wrapper.
@router.get("/api/conversations/{id:path}/scaffold",
response_class=PlainTextResponse)
def conversation_scaffold(deps: State, id: str):
"""The completion scaffold for one conversation, as markdown."""
sid, _, meta = _summary_and_meta(deps, id)
path = _resolve_transcript(id)
# A full parse: the merged tool sections need the thread, which the cached
# rollup summary deliberately doesn't carry.
full = conversations.parse_conversation(path, full=True)
return scaffold_mod.build(_conv_id(str(path)), sid, full, meta, deps.store)
class SummaryBody(BaseModel):
markdown: str
@router.put("/api/conversations/{id:path}/summary", responses=_r(schemas.OkResponse))
def conversation_summary_put(deps: State, id: str, body: SummaryBody):
"""Publish a filled-in scaffold as the conversation's summary. Rejects an
untouched one — an unedited scaffold is a report of nothing."""
sid, _, _ = _summary_and_meta(deps, id)
md = (body.markdown or "").strip()
if not md:
raise HTTPException(400, "markdown is required")
if not scaffold_mod.is_filled(md):
raise HTTPException(422, "the Summary section is still the scaffold's "
"placeholder — fill it in before publishing")
now = datetime.datetime.now(datetime.timezone.utc) \
.strftime("%Y-%m-%dT%H:%M:%SZ")
deps.meta_store.update(sid, {"summary": {
"markdown": scaffold_mod.clean_for_publish(md), "at": now}})
deps.hub.publish({"type": "meta", "id": sid})
return {"ok": True}
# ── conversation action metadata (edited by skills, streamed to the UI) ──────
class MetaPatch(BaseModel):
id: str # Claude Code session id (or "<proj>/<sid>.jsonl")
# Display title the session chose for itself (`conv-meta title "…"`), used
# instead of the parsed one. Lets an agent spawned with a generic prompt
# rename its card once it knows what it's actually building.
title: str | None = None
projects: list[str] | str | None = None
services: list[str] | str | None = None
state: str | None = None
committed: str | None = None
pushed: str | None = None
merged: str | None = None
deployed: str | None = None
notified: str | None = None
notification: dict | None = None
worktree: dict | None = None # a single worktree create/remove event to fold in
archived: bool | None = None # hide/unhide from the default conversation list
# Pin the conversation's Claude account (accounts.py) — the explicit stamp
# that outranks the import source and the cwd rule.
account: str | None = None
@router.get("/api/conversation-meta", responses=_r(schemas.ConversationMetaResponse))
def conversation_meta(deps: State, caller: CallerDep):
"""The raw action-metadata sidecar (keyed by session id)."""
meta = deps.meta_store.all()
if not caller.is_owner:
meta = {k: v for k, v in meta.items() if access.meta_visible(caller, v)}
return {"meta": meta}
@router.post("/api/conversation-meta", responses=_r(schemas.MetaUpdateResult))
def update_conversation_meta(deps: State, patch: MetaPatch):
body = patch.model_dump(exclude_none=True)
cid = _meta_key(body.pop("id"))
if not cid:
raise HTTPException(400, "missing id")
if "account" in body and not accounts_mod.valid(body["account"]):
raise HTTPException(400, f"unknown account: {body['account']!r}")
merged = deps.meta_store.update(cid, body)
deps.hub.publish({"type": "meta", "id": cid})
return {"id": cid, "meta": merged}
@router.post("/api/conversations/archive-old",
responses=_r(schemas.ArchiveOldResult))
def archive_old_conversations(deps: State):
"""Archive (hide) every conversation that ended before the start of yesterday.
Bulk cleanup for the drawer: keeps today's and yesterday's transcripts
visible and folds everything older behind the "show archived" toggle."""
# endedAt is a UTC ISO timestamp; compare against a UTC start-of-yesterday so
# the day boundary lines up (lexicographic works at day granularity).
today = datetime.datetime.now(datetime.timezone.utc).date()
cutoff = datetime.datetime.combine(
today - datetime.timedelta(days=1), datetime.time.min).isoformat()
n = 0
for path, s in deps.store.all_summaries():
if not s.get("messages") or s.get("isSidechain"):
continue
ended = s.get("endedAt") or ""
if ended and ended < cutoff:
sid = s.get("sessionId") or _meta_key(_conv_id(path))
if sid and not deps.meta_store.get(sid).get("archived"):
deps.meta_store.update(sid, {"archived": True})
n += 1
deps.hub.publish({"type": "meta"})
return {"archived": n, "cutoff": cutoff}