diff --git a/src/beaver_gateway/conversations/context.py b/src/beaver_gateway/conversations/context.py new file mode 100644 index 0000000..aa2458f --- /dev/null +++ b/src/beaver_gateway/conversations/context.py @@ -0,0 +1,201 @@ +"""What a conversation's model holds: prompt granules, skills, tools, files touched.""" + +from __future__ import annotations + +import re +from collections import Counter +from pathlib import Path +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Iterable, Mapping + + from beaver_gateway.agents.claude import ClaudeAgent + from beaver_gateway.conversations.kinds import Kind + +__all__ = ["compose", "files_touched", "granules", "skill_sets"] + +CHARS_PER_TOKEN = 3.2 +READ_TOOLS = frozenset({"Read", "NotebookRead"}) +WRITE_TOOLS = frozenset({"Write", "Edit", "MultiEdit", "NotebookEdit"}) +SEARCH_TOOLS = frozenset({"Glob", "Grep"}) +PATH_KEYS = ("file_path", "notebook_path", "path", "file", "filename") +_FRONTMATTER = re.compile(r"^---\s*\n(.*?)\n---", re.DOTALL) + + +def _tokens(chars: int) -> int: + return round(chars / CHARS_PER_TOKEN) + + +def _relative(path: str, cwd: Path | None) -> str: + if cwd is None: + return path + try: + return str(Path(path).relative_to(cwd)) + except ValueError: + return path + + +def _tool_uses(entries: Iterable[Mapping[str, Any]]) -> Iterable[tuple[str, Any, str]]: + for entry in entries: + message = entry.get("message") + if entry.get("type") != "assistant" or not isinstance(message, dict): + continue + content = message.get("content") + if not isinstance(content, list): + continue + stamp = str(entry.get("timestamp") or "") + for block in content: + if isinstance(block, dict) and block.get("type") == "tool_use": + yield str(block.get("name") or "?"), block.get("input"), stamp + + +def files_touched( + entries: Iterable[Mapping[str, Any]], *, cwd: Path | None = None +) -> tuple[list[dict[str, Any]], dict[str, int]]: + """Files named in tool inputs, with how they were touched, plus tool counts.""" + files: dict[str, dict[str, Any]] = {} + counts: Counter[str] = Counter() + for name, tool_input, stamp in _tool_uses(entries): + counts[name] += 1 + if not isinstance(tool_input, dict): + continue + raw = next( + (v for k in PATH_KEYS if isinstance(v := tool_input.get(k), str) and v), + None, + ) + if raw is None or (name in SEARCH_TOOLS and "file_path" not in tool_input): + continue + key = _relative(raw, cwd) + row = files.setdefault( + key, {"path": key, "reads": 0, "writes": 0, "other": 0, "last_at": stamp} + ) + if name in READ_TOOLS: + row["reads"] += 1 + elif name in WRITE_TOOLS: + row["writes"] += 1 + else: + row["other"] += 1 + row["last_at"] = max(row["last_at"], stamp) + ordered = sorted(files.values(), key=lambda r: r["last_at"], reverse=True) + return ordered, dict(counts) + + +def granules(agent: ClaudeAgent, kind: Kind) -> dict[str, Any]: + """The system prompt as its sources: tag, path, size, token estimate.""" + sources = agent.prompt_for(kind) if agent.serves(kind) else None + rows: list[dict[str, Any]] = [] + if sources is None: + chars = len(agent.system_prompt) + if chars: + rows.append( + { + "tag": None, + "path": None, + "bytes": chars, + "tokens_est": _tokens(chars), + } + ) + else: + for source in sources: + tag, raw = source if isinstance(source, tuple) else (None, source) + path = Path(raw) + try: + chars = len(path.read_text(encoding="utf-8")) + except OSError: + chars = 0 + rows.append( + { + "tag": tag, + "path": _relative(str(path), agent.cwd), + "bytes": chars, + "tokens_est": _tokens(chars), + } + ) + total = sum(r["bytes"] for r in rows) + return {"bytes": total, "tokens_est": _tokens(total), "granules": rows} + + +def _skill_meta(skill_md: Path) -> dict[str, str]: + try: + text = skill_md.read_text(encoding="utf-8") + except OSError: + return {} + match = _FRONTMATTER.match(text) + meta: dict[str, str] = {} + if match: + for line in match.group(1).splitlines(): + key, sep, value = line.partition(":") + if sep: + meta[key.strip()] = value.strip() + return meta + + +def skill_sets(agent: ClaudeAgent, kind: Kind) -> list[dict[str, Any]]: + """Skill-set directories the kind loads, with each skill's name and description.""" + out: list[dict[str, Any]] = [] + for directory in agent.skills_for(kind): + skills = [] + try: + children = sorted(p for p in directory.iterdir() if p.is_dir()) + except OSError: + children = [] + for child in children: + skill_md = child / "SKILL.md" + if skill_md.is_file(): + meta = _skill_meta(skill_md) + skills.append( + { + "name": meta.get("name") or child.name, + "description": meta.get("description", ""), + "path": _relative(str(child), agent.cwd), + } + ) + out.append( + { + "set": directory.name, + "path": _relative(str(directory), agent.cwd), + "skills": skills, + } + ) + return out + + +def compose( + agent: ClaudeAgent, + kind: Kind, + entries: Iterable[Mapping[str, Any]], + *, + context_tokens: int, +) -> dict[str, Any]: + files, counts = files_touched(entries, cwd=agent.cwd) + prompt = granules(agent, kind) + skills = skill_sets(agent, kind) + skills_chars = sum( + len(s["name"]) + len(s["description"]) + for group in skills + for s in group["skills"] + ) + return { + "agent": { + "name": agent.name, + "model": agent.model, + "effort": agent.options.effort, + }, + "kind": kind, + "context_tokens": context_tokens, + "prompt": prompt, + "skills": skills, + "skills_tokens_est": _tokens(skills_chars), + "tools": { + "allowed": list(agent.options.tools) if agent.options.tools else None, + "disallowed": list(agent.options.disallowed_tools), + "gateway": list(agent.gateway_tools), + "mcps": [m.name for m in agent.expose_mcps], + }, + "files": files, + "tool_counts": counts, + "history_tokens_est": max( + 0, context_tokens - prompt["tokens_est"] - _tokens(skills_chars) + ), + } diff --git a/src/beaver_gateway/conversations/rows.py b/src/beaver_gateway/conversations/rows.py index d42fcec..7f3e845 100644 --- a/src/beaver_gateway/conversations/rows.py +++ b/src/beaver_gateway/conversations/rows.py @@ -8,6 +8,8 @@ from datetime import UTC, datetime from typing import TYPE_CHECKING, Any, cast from uuid import uuid4 +from sqlalchemy import String, func +from sqlalchemy import cast as sa_cast from sqlmodel import col, select from beaver_gateway.backends.transcript import ( @@ -23,6 +25,7 @@ from beaver_gateway.storage.models import ( ConversationBinding, ConversationMessage, RateLimit, + TranscriptEntry, Usage, ) @@ -132,6 +135,30 @@ class Rows(State): async with self._db.session() as session: return list((await session.exec(stmt)).all()) + async def search(self, query: str, *, limit: int = 20) -> list[Conversation]: + needle = f"%{query}%" + by_title = select(Conversation.id).where(col(Conversation.title).ilike(needle)) + by_first = select(ConversationMessage.conversation_id).where( + ConversationMessage.seq == 0, + col(ConversationMessage.content_json).ilike(needle), + ) + by_entry = ( + select(Conversation.id) + .join( + TranscriptEntry, + col(TranscriptEntry.session_id) == col(Conversation.session_id), + ) + .where(sa_cast(col(TranscriptEntry.entry), String).ilike(needle)) + ) + stmt = ( + select(Conversation) + .where(col(Conversation.id).in_(by_title.union(by_first, by_entry))) + .order_by(col(Conversation.id).desc()) + .limit(limit) + ) + async with self._db.session() as session: + return list((await session.exec(stmt)).all()) + async def bindings(self, conv: Conversation) -> list[ConversationBinding]: async with self._db.session() as session: result = await session.exec( @@ -317,6 +344,7 @@ class Rows(State): async def describe(self, conv: Conversation) -> dict[str, Any]: out = self.public(conv) out["title"] = await self.implied_title(conv) + out["context_tokens"] = await self.context_tokens(conv) parent = await self.get_row(conv.parent_id) if conv.parent_id else None out["parent"] = parent.external_id if parent is not None else None out["bindings"] = [ @@ -359,6 +387,25 @@ class Rows(State): ).first() return context_of(row) + async def context_tokens_by_id(self, ids: Iterable[str]) -> dict[str, int]: + wanted = list(ids) + if not wanted: + return {} + latest = ( + select(func.max(Usage.id)) + .where(col(Usage.conversation_id).in_(wanted)) + .group_by(Usage.conversation_id) + ) + async with self._db.session() as session: + rows = ( + await session.exec(select(Usage).where(col(Usage.id).in_(latest))) + ).all() + return { + cast("str", row.conversation_id): context_of(row) + for row in rows + if row.conversation_id + } + async def usage_tokens(self, since: datetime) -> int: async with self._db.session() as session: rows = ( diff --git a/src/beaver_gateway/frontends/api/frontend.py b/src/beaver_gateway/frontends/api/frontend.py index 440cccd..c271f52 100644 --- a/src/beaver_gateway/frontends/api/frontend.py +++ b/src/beaver_gateway/frontends/api/frontend.py @@ -18,6 +18,10 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse, StreamingResponse from sqlmodel import col, select +from beaver_gateway.agents.claude import ClaudeAgent +from beaver_gateway.agents.prompts import assemble +from beaver_gateway.backends.transcript import prompt_count +from beaver_gateway.conversations.context import compose from beaver_gateway.conversations.injects import URGENCY from beaver_gateway.conversations.kinds import Kind, as_kind from beaver_gateway.conversations.service import SEEDS, implied_title @@ -45,6 +49,7 @@ from beaver_gateway.storage.models import ( Token, Usage, ) +from beaver_gateway.vault.links import LinkIndex if TYPE_CHECKING: from collections.abc import AsyncIterator, Iterable, Sequence @@ -70,6 +75,10 @@ WINDOWS: dict[str, timedelta] = { MEMORY_MAX_DEPTH = 12 MEMORY_MAX_FILE = 2_000_000 MEMORY_MAX_ENTRIES = 5000 +SEARCH_MIN = 2 +GRAPH_LIMIT = 120 +SEARCH_LIMIT = 20 +SNIPPET_MAX = 160 class ApiFrontend(Frontend): @@ -85,12 +94,14 @@ class ApiFrontend(Frontend): deep_agent: str | None = None, job_agent: str | None = None, memory_root: Path | None = None, + vault_root: Path | None = None, ) -> None: self.master_agent = master_agent self.branch_agent = branch_agent self.deep_agent = deep_agent self.job_agent = job_agent self.memory_root = memory_root.resolve() if memory_root is not None else None + self.vault_root = vault_root.resolve() if vault_root is not None else None self._app: FastAPI | None = None def agent_for(self, kind: Kind) -> str | None: @@ -105,14 +116,22 @@ class ApiFrontend(Frontend): if runtime.conversations is None or runtime.bus is None: msg = "ApiFrontend needs runtime.conversations and runtime.bus" raise RuntimeError(msg) - self._app = build_app(runtime, memory_root=self.memory_root) + self._app = build_app( + runtime, memory_root=self.memory_root, vault_root=self.vault_root + ) def app(self) -> FastAPI | None: return self._app -def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> FastAPI: # noqa: PLR0915 +def build_app( # noqa: PLR0915 + runtime: GatewayRuntime, + *, + memory_root: Path | None = None, + vault_root: Path | None = None, +) -> FastAPI: app = FastAPI(title="beaver-gateway / API") + links = LinkIndex(vault_root) if vault_root is not None else None app.add_middleware( CORSMiddleware, allow_origins=["*"], @@ -218,17 +237,49 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa firsts = await conversations.first_user_texts( cast("int", r.id) for r in rows if not r.title ) + contexts = await conversations.context_tokens_by_id(r.external_id for r in rows) + public_ids = {r.id: r.external_id for r in rows} return { "conversations": [ { **conversations.public(r), "title": r.title or implied_title(firsts.get(cast("int", r.id))), "last_item": _queue_item(latest.get(cast("int", r.id))), + "context_tokens": contexts.get(r.external_id, 0), + "parent": public_ids.get(r.parent_id) if r.parent_id else None, } for r in rows ] } + @app.get("/search") + async def search(request: Request) -> dict[str, Any]: + await require_token(request, runtime, scope=SCOPE) + query = (request.query_params.get("q") or "").strip() + if len(query) < SEARCH_MIN: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, f"q must be at least {SEARCH_MIN} chars" + ) + limit = min(query_int(request, "limit", SEARCH_LIMIT), SEARCH_LIMIT) + rows = await conversations.search(query, limit=limit) + firsts = await conversations.first_user_texts( + cast("int", r.id) for r in rows if not r.title + ) + contexts = await conversations.context_tokens_by_id(r.external_id for r in rows) + files = _grep_memory(memory_root, query, limit=limit) if memory_root else [] + return { + "query": query, + "conversations": [ + { + **conversations.public(r), + "title": r.title or implied_title(firsts.get(cast("int", r.id))), + "context_tokens": contexts.get(r.external_id, 0), + } + for r in rows + ], + "files": files, + } + @app.post("/conversations", status_code=status.HTTP_201_CREATED) async def create_conversation(request: Request) -> dict[str, Any]: token = await require_token(request, runtime, scope=SCOPE) @@ -301,6 +352,45 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa conv = await conv_of(public_id) return {"id": conv.external_id, "messages": await conversations.history(conv)} + @app.get("/conversations/{public_id}/context") + async def get_context(public_id: str, request: Request) -> dict[str, Any]: + await require_token(request, runtime, scope=SCOPE) + conv = await conv_of(public_id) + agent = runtime.agents.get(conv.agent_name) + if not isinstance(agent, ClaudeAgent): + raise HTTPException( + status.HTTP_404_NOT_FOUND, f"no claude agent behind {public_id}" + ) + entries = list(await conversations.entries(conv)) + for sub in await conversations.subpaths(conv): + entries.extend(await conversations.entries(conv, subpath=sub)) + out = compose( + agent, + as_kind(conv.kind), + entries, + context_tokens=await conversations.context_tokens(conv), + ) + out["id"] = conv.external_id + out["turns"] = prompt_count(entries) + if request.query_params.get("prompt"): + sources = agent.prompt_for(as_kind(conv.kind)) + out["prompt_text"] = ( + assemble(sources) if sources is not None else agent.system_prompt + ) + return out + + @app.get("/vault/graph") + async def vault_graph(request: Request) -> dict[str, Any]: + await require_token(request, runtime, scope=SCOPE) + if links is None: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + "no vault root: set ApiFrontend(vault_root=...)", + ) + paths = [p for p in request.query_params.getlist("path") if p] + limit = min(query_int(request, "limit", GRAPH_LIMIT), GRAPH_LIMIT) + return {"root": str(links.root), **links.neighbours(paths, limit=limit)} + @app.get("/conversations/{public_id}/entries") async def get_entries(public_id: str, request: Request) -> dict[str, Any]: await require_token(request, runtime, scope=SCOPE) @@ -993,6 +1083,35 @@ def _memory_path(root: Path, raw: str) -> Path: return target +def _grep_memory(root: Path, query: str, *, limit: int) -> list[dict[str, Any]]: + needle = query.casefold() + hits: list[dict[str, Any]] = [] + for path in sorted(root.rglob("*")): + if len(hits) >= limit: + break + if not path.is_file() or any(part.startswith(".") for part in path.parts): + continue + if path.suffix not in (".md", ".txt", ".json", ".yaml", ".yml"): + continue + try: + if path.stat().st_size > MEMORY_MAX_FILE: + continue + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + rel = str(path.relative_to(root)) + if needle in rel.casefold(): + hits.append({"path": rel, "line": 0, "snippet": ""}) + continue + for number, line in enumerate(text.splitlines(), 1): + if needle in line.casefold(): + hits.append( + {"path": rel, "line": number, "snippet": line.strip()[:SNIPPET_MAX]} + ) + break + return hits + + def _tree(root: Path, directory: Path, *, depth: int) -> list[dict[str, Any]]: if depth > MEMORY_MAX_DEPTH: return [] diff --git a/src/beaver_gateway/vault/links.py b/src/beaver_gateway/vault/links.py new file mode 100644 index 0000000..2ad22ca --- /dev/null +++ b/src/beaver_gateway/vault/links.py @@ -0,0 +1,136 @@ +"""Wikilink index over a vault: which note links to which, refreshed by mtime.""" + +from __future__ import annotations + +import os +import re +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +__all__ = ["LinkIndex"] + +_WIKILINK = re.compile(r"\[\[([^\]|#\n]+)(?:#[^\]|\n]*)?(?:\|[^\]\n]*)?\]\]") +_MD = ".md" + + +@dataclass(slots=True) +class _Note: + mtime: float + links: set[str] = field(default_factory=set) + + +class LinkIndex: + """Forward and backward links between the vault's markdown notes.""" + + def __init__(self, root: Path, *, max_age: float = 20.0) -> None: + self.root = root.resolve() + self.max_age = max_age + self._notes: dict[str, _Note] = {} + self._raw: dict[str, set[str]] = {} + self._scanned_at = 0.0 + + def refresh(self, *, force: bool = False) -> None: + if not force and time.monotonic() - self._scanned_at < self.max_age: + return + seen: set[str] = set() + for dirpath, dirnames, filenames in os.walk(self.root): + dirnames[:] = [d for d in dirnames if not d.startswith(".")] + for name in filenames: + if not name.endswith(_MD): + continue + path = Path(dirpath) / name + rel = str(path.relative_to(self.root)) + seen.add(rel) + try: + mtime = path.stat().st_mtime + except OSError: + continue + note = self._notes.get(rel) + if note is None or note.mtime != mtime: + self._notes[rel] = _Note(mtime=mtime) + self._raw[rel] = self._targets(path) + for rel in list(self._notes): + if rel not in seen: + self._notes.pop(rel, None) + self._raw.pop(rel, None) + self._resolve() + self._scanned_at = time.monotonic() + + @staticmethod + def _targets(path: Path) -> set[str]: + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + return set() + return {m.group(1).strip() for m in _WIKILINK.finditer(text)} + + def _resolve(self) -> None: + by_stem: dict[str, str] = {} + for rel in sorted(self._notes): + by_stem.setdefault(Path(rel).stem.casefold(), rel) + by_path = {rel.casefold(): rel for rel in self._notes} + for rel, targets in self._raw.items(): + links: set[str] = set() + for target in targets: + candidate = target if target.endswith(_MD) else f"{target}{_MD}" + found = by_path.get(candidate.casefold()) or by_stem.get( + Path(target).stem.casefold() + ) + if found and found != rel: + links.add(found) + self._notes[rel].links = links + + def resolve(self, raw: str) -> str: + """A path as a tool named it (absolute or relative) as an index key.""" + path = Path(raw) + if path.is_absolute(): + try: + return str(path.resolve().relative_to(self.root)) + except (ValueError, OSError): + return raw + return raw + + def neighbours(self, paths: list[str], *, limit: int = 80) -> dict[str, Any]: + """Nodes for ``paths`` plus every note one link away, and their edges.""" + self.refresh() + touched = [self.resolve(p) for p in paths] + backlinks: dict[str, set[str]] = {} + for rel, note in self._notes.items(): + for target in note.links: + backlinks.setdefault(target, set()).add(rel) + nodes: dict[str, dict[str, Any]] = {} + for rel in touched: + nodes[rel] = { + "path": rel, + "title": Path(rel).stem, + "touched": True, + "exists": rel in self._notes, + } + for rel in touched: + note = self._notes.get(rel) + around = (note.links if note else set()) | backlinks.get(rel, set()) + for other in sorted(around): + if len(nodes) >= limit: + break + nodes.setdefault( + other, + { + "path": other, + "title": Path(other).stem, + "touched": False, + "exists": True, + }, + ) + edges: list[dict[str, str]] = [] + for rel in nodes: + note = self._notes.get(rel) + if note is None: + continue + edges.extend( + {"from": rel, "to": target} + for target in sorted(note.links) + if target in nodes + ) + return {"nodes": list(nodes.values()), "edges": edges} diff --git a/tests/test_api.py b/tests/test_api.py index 19521c9..fefa132 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -29,7 +29,7 @@ from beaver_gateway.frontends.api import ApiFrontend from beaver_gateway.frontends.api.frontend import build_app as build_api from beaver_gateway.frontends.base import Frontend, GatewayRuntime from beaver_gateway.frontends.root import build_root_app -from beaver_gateway.storage.models import RateLimit, Usage +from beaver_gateway.storage.models import RateLimit, TranscriptEntry, Usage TOKEN = "tok" HEADERS = {"Authorization": f"Bearer {TOKEN}"} @@ -525,3 +525,91 @@ async def test_bind_without_a_window_materializes_one(world: World) -> None: headers=HEADERS, ) assert unknown.status_code == 400 + + +async def test_search_finds_conversations_and_memory_files(world: World) -> None: + root = Path(tempfile.mkdtemp(prefix="beaver-mem-")) + (root / "notes").mkdir() + (root / "notes" / "studio.md").write_text("# studio\nthe kiln is warm\n") + api = Api(world, memory_root=root) + conv = await world.conversations.create(kind="master", agent="a", origin="test") + await world.conversations.set_title(conv, "kiln schedule") + other = await world.conversations.create(kind="deep", agent="d", origin="test") + await world.conversations.post(other, "go") + await world.settle(other, 1) + other = await world.conversations.get(other.external_id) + assert other is not None and other.session_id is not None + async with world.db.session() as db: + db.add( + TranscriptEntry( + project_key="p", + session_id=other.session_id, + seq=0, + entry={"type": "assistant", "message": {"content": "warm kiln"}}, + ) + ) + await db.commit() + found = await api.get("/search", {"q": "kiln"}) + assert {c["id"] for c in found["conversations"]} == { + conv.external_id, + other.external_id, + } + assert found["files"] == [ + {"path": "notes/studio.md", "line": 2, "snippet": "the kiln is warm"} + ] + short = await api.http.get("/search", params={"q": "k"}, headers=HEADERS) + assert short.status_code == 400 + + +async def test_context_endpoint_reports_prompt_and_files(world: World) -> None: + api = Api(world) + conv = await world.conversations.create(kind="master", agent="a", origin="test") + await world.conversations.post(conv, "go") + await world.settle(conv, 1) + conv = await world.conversations.get(conv.external_id) + assert conv is not None and conv.session_id is not None + await world.store.append( + world.key(conv.session_id), + build_entries( + [ + {"role": "user", "content": "go"}, + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "t1", + "name": "Read", + "input": {"file_path": str(world.root / "note.md")}, + } + ], + }, + { + "role": "user", + "content": [ + {"type": "tool_result", "tool_use_id": "t1", "content": "x"} + ], + }, + ], + session_id=conv.session_id, + cwd=str(world.root), + model="m", + ), + ) + ctx = await api.get(f"/conversations/{conv.external_id}/context", {"prompt": 1}) + assert ctx["agent"]["name"] == "a" + assert ctx["prompt"]["granules"][0]["bytes"] == len("hi") + assert ctx["prompt_text"] == "hi" + assert ctx["files"] == [ + { + "path": "note.md", + "reads": 1, + "writes": 0, + "other": 0, + "last_at": ctx["files"][0]["last_at"], + } + ] + assert ctx["tool_counts"] == {"Read": 1} + assert ctx["turns"] == 1 + graph = await api.http.get("/vault/graph", headers=HEADERS) + assert graph.status_code == 404 diff --git a/tests/test_context.py b/tests/test_context.py new file mode 100644 index 0000000..fc3684c --- /dev/null +++ b/tests/test_context.py @@ -0,0 +1,104 @@ +import tempfile +from pathlib import Path + +from beaver_gateway.agents.claude import ClaudeAgent, Prompts +from beaver_gateway.conversations.context import compose, files_touched, granules +from beaver_gateway.vault.links import LinkIndex + + +def entry(name: str, tool_input: dict, stamp: str = "2026-09-02T10:00:00Z") -> dict: + return { + "type": "assistant", + "timestamp": stamp, + "message": { + "content": [{"type": "tool_use", "name": name, "input": tool_input}] + }, + } + + +def test_files_touched_groups_by_path_relative_to_cwd() -> None: + cwd = Path("/vault") + files, counts = files_touched( + [ + entry("Read", {"file_path": "/vault/a.md"}, "2026-09-02T10:00:00Z"), + entry("Edit", {"file_path": "/vault/a.md"}, "2026-09-02T10:05:00Z"), + entry("Grep", {"pattern": "x", "path": "/vault"}), + entry("Bash", {"command": "ls"}), + entry("Read", {"file_path": "/elsewhere/b.md"}, "2026-09-02T09:00:00Z"), + ], + cwd=cwd, + ) + assert [f["path"] for f in files] == ["a.md", "/elsewhere/b.md"] + assert files[0] == { + "path": "a.md", + "reads": 1, + "writes": 1, + "other": 0, + "last_at": "2026-09-02T10:05:00Z", + } + assert counts == {"Read": 2, "Edit": 1, "Grep": 1, "Bash": 1} + + +def test_compose_reads_granules_and_skills() -> None: + root = Path(tempfile.mkdtemp(prefix="beaver-ctx-")) + (root / "voice.md").write_text("be brief " * 40) + (root / "env.md").write_text("you are in the master thread") + skills = root / "skills" / "common" + (skills / "ops").mkdir(parents=True) + (skills / "ops" / "SKILL.md").write_text( + "---\nname: ops\ndescription: servers\n---\n" + ) + agent = ClaudeAgent( + name="a", + model="m", + cwd=root, + prompts=Prompts(master=(("voice", root / "voice.md"), root / "env.md")), + skill_sets=(skills,), + gateway_tools=("say",), + ) + out = compose( + agent, + "master", + [entry("Read", {"file_path": str(root / "env.md")})], + context_tokens=50_000, + ) + assert [g["path"] for g in out["prompt"]["granules"]] == ["voice.md", "env.md"] + assert out["prompt"]["granules"][0]["tag"] == "voice" + assert out["prompt"]["tokens_est"] > 0 + assert out["skills"] == [ + { + "set": "common", + "path": "skills/common", + "skills": [ + {"name": "ops", "description": "servers", "path": "skills/common/ops"} + ], + } + ] + assert out["tools"]["gateway"] == ["say"] + assert out["files"][0]["path"] == "env.md" + assert out["history_tokens_est"] < 50_000 + assert granules(agent, "deep")["granules"] == [] + + +def test_link_index_resolves_stems_and_neighbours() -> None: + root = Path(tempfile.mkdtemp(prefix="beaver-links-")) + (root / "people").mkdir() + (root / "people" / "Marta.md").write_text("works at [[Studio]] with [[Ilya|him]]") + (root / "Studio.md").write_text("see [[people/Marta]] and [[nowhere]]") + (root / "Ilya.md").write_text("plain") + (root / ".obsidian").mkdir() + (root / ".obsidian" / "x.md").write_text("[[Studio]]") + index = LinkIndex(root) + graph = index.neighbours([str(root / "people" / "Marta.md")]) + nodes = {n["path"]: n for n in graph["nodes"]} + assert nodes["people/Marta.md"]["touched"] is True + assert set(nodes) == {"people/Marta.md", "Studio.md", "Ilya.md"} + assert {(e["from"], e["to"]) for e in graph["edges"]} == { + ("people/Marta.md", "Studio.md"), + ("people/Marta.md", "Ilya.md"), + ("Studio.md", "people/Marta.md"), + } + missing = index.neighbours(["gone.md"]) + assert missing["nodes"] == [ + {"path": "gone.md", "title": "gone", "touched": True, "exists": False} + ] diff --git a/ui/src/app.html b/ui/src/app.html index 52146a4..0786ad2 100644 --- a/ui/src/app.html +++ b/ui/src/app.html @@ -10,18 +10,21 @@ diff --git a/ui/src/lib/api/client.ts b/ui/src/lib/api/client.ts index 72a9a10..961c84a 100644 --- a/ui/src/lib/api/client.ts +++ b/ui/src/lib/api/client.ts @@ -3,6 +3,7 @@ import type { AgentsResponse, AuditPage, BusEvent, + ContextResponse, ConversationInfo, ConversationSummary, EntriesPage, @@ -11,10 +12,12 @@ import type { LimitsResponse, MemoryFile, MemoryTree, + SearchResponse, SessionsResponse, TokenRow, UsageGroup, UsageResponse, + VaultGraph, } from "./types"; const TRAILING_SLASHES = /\/+$/; @@ -182,6 +185,23 @@ export class ApiClient { return this.get(`/api/conversations/${id}/history`); } + context(id: string, prompt = false): Promise { + return this.get(`/api/conversations/${id}/context`, { + prompt: prompt ? 1 : undefined, + }); + } + + vaultGraph(paths: string[], limit?: number): Promise { + const search = new URLSearchParams(); + for (const path of paths) { + search.append("path", path); + } + if (limit) { + search.set("limit", String(limit)); + } + return this.get(`/api/vault/graph?${search.toString()}`); + } + entries( id: string, params?: { subpath?: string; offset?: number; limit?: number } @@ -283,6 +303,10 @@ export class ApiClient { return this.post("/api/conversations", body); } + search(q: string, limit?: number): Promise { + return this.get("/api/search", { limit, q }); + } + sessions(): Promise { return this.get("/api/sessions"); } diff --git a/ui/src/lib/api/types.ts b/ui/src/lib/api/types.ts index 77dc380..aaa7a90 100644 --- a/ui/src/lib/api/types.ts +++ b/ui/src/lib/api/types.ts @@ -54,6 +54,7 @@ export interface QueueItem { export interface ConversationSummary { agent: string; + context_tokens?: number; created_at: string | null; flags: Record; id: string; @@ -62,6 +63,7 @@ export interface ConversationSummary { last_item?: QueueItem | null; last_user_activity_at: string | null; origin: string; + parent?: string | null; parent_row: number | null; pending_question: boolean; running_turn: string | null; @@ -310,3 +312,76 @@ export interface TurnUsage { input?: number; output?: number; } + +export interface SearchFile { + line: number; + path: string; + snippet: string; +} + +export interface SearchResponse { + conversations: ConversationSummary[]; + files: SearchFile[]; + query: string; +} + +export interface Granule { + bytes: number; + path: string | null; + tag: string | null; + tokens_est: number; +} + +export interface SkillInfo { + description: string; + name: string; + path: string; +} + +export interface SkillSetInfo { + path: string; + set: string; + skills: SkillInfo[]; +} + +export interface TouchedFile { + last_at: string; + other: number; + path: string; + reads: number; + writes: number; +} + +export interface ContextResponse { + agent: { effort: string | null; model: string; name: string }; + context_tokens: number; + files: TouchedFile[]; + history_tokens_est: number; + id: string; + kind: Kind; + prompt: { bytes: number; granules: Granule[]; tokens_est: number }; + prompt_text?: string; + skills: SkillSetInfo[]; + skills_tokens_est: number; + tool_counts: Record; + tools: { + allowed: string[] | null; + disallowed: string[]; + gateway: string[]; + mcps: string[]; + }; + turns: number; +} + +export interface VaultNode { + exists: boolean; + path: string; + title: string; + touched: boolean; +} + +export interface VaultGraph { + edges: { from: string; to: string }[]; + nodes: VaultNode[]; + root: string; +} diff --git a/ui/src/lib/components/app-sidebar.svelte b/ui/src/lib/components/app-sidebar.svelte deleted file mode 100644 index 7c7fba7..0000000 --- a/ui/src/lib/components/app-sidebar.svelte +++ /dev/null @@ -1,121 +0,0 @@ - - - diff --git a/ui/src/lib/components/bottom-nav.svelte b/ui/src/lib/components/bottom-nav.svelte index 71f814e..3d9625b 100644 --- a/ui/src/lib/components/bottom-nav.svelte +++ b/ui/src/lib/components/bottom-nav.svelte @@ -1,79 +1,42 @@ - - - - More - {#each secondary as item (item.href)} - { - moreOpen = false; - }} - > - - {item.label} - - {/each} -
- -
- - -
-
-
-
diff --git a/ui/src/lib/components/conversation-list.svelte b/ui/src/lib/components/conversation-list.svelte deleted file mode 100644 index 871c036..0000000 --- a/ui/src/lib/components/conversation-list.svelte +++ /dev/null @@ -1,323 +0,0 @@ - - -
-
- { - kind = value; - }} - type="single" - value={kind} - > - - {kind === "all" ? "any kind" : kind} - - - {#each KINDS as option (option)} - - {/each} - - - { - statusFilter = value; - }} - type="single" - value={statusFilter} - > - - {statusFilter === "all" ? "any status" : statusFilter} - - - {#each STATUSES as option (option)} - - {/each} - - - - - -
-
- {#if gateway.live.state === "failed"} -
- gateway.start()} - /> -
- {:else if !gateway.loaded} -
- - - -
- {:else if rows.length === 0} -
- - - -
- {:else} - - {/if} -
-
- - - - - New conversation - - It opens in the home window of its kind (a vault file, a Telegram topic) - and stays silent until someone speaks. - - -
-
- - { - form.kind = value; - form.agent = ""; - }} - type="single" - value={form.kind} - > - {form.kind} - - {#each ["deep", "branch", "master", "job"] as option (option)} - - {/each} - - -
-
- - { - form.agent = value === "default" ? "" : value; - }} - type="single" - value={form.agent || "default"} - > - - {form.agent || "frontend default"} - - - - {#each agentsForKind as agent (agent.name)} - - {/each} - - -
-
- - -
-
- - -
-
- - - - -
-
diff --git a/ui/src/lib/components/page-header.svelte b/ui/src/lib/components/page-header.svelte index b7c53d8..88e0f81 100644 --- a/ui/src/lib/components/page-header.svelte +++ b/ui/src/lib/components/page-header.svelte @@ -1,7 +1,5 @@

{title}

@@ -30,15 +28,9 @@ {@render children()}
{/if} -
- {#if actions} + {#if actions} +
{@render actions()} - {/if} - -
+
+ {/if}
diff --git a/ui/src/lib/format.ts b/ui/src/lib/format.ts index 1dbac20..746b1ac 100644 --- a/ui/src/lib/format.ts +++ b/ui/src/lib/format.ts @@ -123,19 +123,17 @@ export function fmtRelative( if (!date) { return "–"; } - const diff = now - date.getTime(); - const abs = Math.abs(diff); - const suffix = diff >= 0 ? "ago" : "from now"; - if (abs < MINUTE_MS) { - return diff >= 0 ? "just now" : "in <1m"; + const diff = Math.max(0, now - date.getTime()); + if (diff < MINUTE_MS) { + return "just now"; } - if (abs < HOUR_MS) { - return `${Math.round(abs / MINUTE_MS)}m ${suffix}`; + if (diff < HOUR_MS) { + return `${Math.round(diff / MINUTE_MS)}m ago`; } - if (abs < DAY_MS) { - return `${Math.round(abs / HOUR_MS)}h ${suffix}`; + if (diff < DAY_MS) { + return `${Math.round(diff / HOUR_MS)}h ago`; } - return `${Math.round(abs / DAY_MS)}d ${suffix}`; + return `${Math.round(diff / DAY_MS)}d ago`; } export function fmtCountdown(iso: string | null | undefined, now = Date.now()) { diff --git a/ui/src/lib/nav.ts b/ui/src/lib/nav.ts index 7299407..c0ed949 100644 --- a/ui/src/lib/nav.ts +++ b/ui/src/lib/nav.ts @@ -1,35 +1,38 @@ -import ActivityIcon from "@lucide/svelte/icons/activity"; -import BrainIcon from "@lucide/svelte/icons/brain"; -import ClockIcon from "@lucide/svelte/icons/clock"; -import GaugeIcon from "@lucide/svelte/icons/gauge"; -import KeyRoundIcon from "@lucide/svelte/icons/key-round"; -import MessagesSquareIcon from "@lucide/svelte/icons/messages-square"; -import ScrollTextIcon from "@lucide/svelte/icons/scroll-text"; -import type { Component } from "svelte"; - export interface NavItem { href: string; - icon: Component<{ class?: string }>; + key: string; label: string; - mobile: boolean; } -export const NAV: NavItem[] = [ - { href: "/", icon: ActivityIcon, label: "Now", mobile: true }, - { - href: "/conversations", - icon: MessagesSquareIcon, - label: "Conversations", - mobile: true, - }, - { href: "/usage", icon: GaugeIcon, label: "Usage", mobile: true }, - { href: "/memory", icon: BrainIcon, label: "Memory", mobile: true }, - { href: "/tokens", icon: KeyRoundIcon, label: "Tokens", mobile: false }, - { href: "/jobs", icon: ClockIcon, label: "Jobs", mobile: false }, - { href: "/audit", icon: ScrollTextIcon, label: "Audit", mobile: false }, +export const SECTIONS: NavItem[] = [ + { href: "/", key: "1", label: "Now" }, + { href: "/conversations", key: "2", label: "Conversations" }, + { href: "/memory", key: "3", label: "Memory" }, + { href: "/usage", key: "4", label: "Usage" }, +]; + +export const SYSTEM: NavItem = { href: "/system", key: "5", label: "System" }; + +export const SYSTEM_PAGES: NavItem[] = [ + { href: "/system", key: "", label: "Sessions" }, + { href: "/system/agents", key: "", label: "Agents & endpoints" }, + { href: "/system/jobs", key: "", label: "Jobs" }, + { href: "/system/tokens", key: "", label: "Tokens" }, + { href: "/system/audit", key: "", label: "Audit" }, ]; export function isActive(pathname: string, base: string, href: string) { + const path = pathname.startsWith(base) + ? pathname.slice(base.length) + : pathname; + const current = path || "/"; + if (href === "/") { + return current === "/"; + } + return current === href || current.startsWith(`${href}/`); +} + +export function isSectionActive(pathname: string, base: string, href: string) { const path = pathname.startsWith(base) ? pathname.slice(base.length) : pathname; diff --git a/ui/src/lib/panel/cache.ts b/ui/src/lib/panel/cache.ts new file mode 100644 index 0000000..9ddf090 --- /dev/null +++ b/ui/src/lib/panel/cache.ts @@ -0,0 +1,49 @@ +// A small keyed cache behind the panel: the conversation index and the +// last threads land here so the next open paints before the network +// answers. localStorage when it exists, memory otherwise. +export interface PanelCache { + get: (key: string) => T | null; + set: (key: string, value: unknown) => void; +} + +const memory = new Map(); + +function storage(): Pick { + try { + if (typeof localStorage !== "undefined") { + return localStorage; + } + } catch { + /* sandboxed */ + } + return { + getItem: (key) => memory.get(key) ?? null, + removeItem: (key) => { + memory.delete(key); + }, + setItem: (key, value) => { + memory.set(key, value); + }, + }; +} + +export function panelCache(prefix: string): PanelCache { + const store = storage(); + return { + get(key: string): T | null { + try { + const raw = store.getItem(`${prefix}:${key}`); + return raw ? (JSON.parse(raw) as T) : null; + } catch { + return null; + } + }, + set(key: string, value: unknown): void { + try { + store.setItem(`${prefix}:${key}`, JSON.stringify(value)); + } catch { + store.removeItem(`${prefix}:${key}`); + } + }, + }; +} diff --git a/ui/src/lib/panel/chat-view.svelte b/ui/src/lib/panel/chat-view.svelte index 50e33a2..707c16d 100644 --- a/ui/src/lib/panel/chat-view.svelte +++ b/ui/src/lib/panel/chat-view.svelte @@ -1,15 +1,14 @@
{ event.preventDefault(); send(); }} > - -
- { - mode = value as Mode; - }} - type="single" - value={mode} - > - {current.label} - - {#each MODES as option (option.value)} - - - {option.label} - {option.hint} - - - {/each} - - - - + + +
+
+ {#each MODES as option (option.value)} + + {/each} +
diff --git a/ui/src/lib/panel/context-view.svelte b/ui/src/lib/panel/context-view.svelte new file mode 100644 index 0000000..93c3903 --- /dev/null +++ b/ui/src/lib/panel/context-view.svelte @@ -0,0 +1,395 @@ + + +
+ {#if failure} + + {:else if !data} +

Reading the context…

+ {:else} +
+
+ + {fmtTokens(total)} + + + of {fmtTokens(window)} · {data.turns} + {data.turns === 1 ? "turn" : "turns"} + · {data.agent.model} + {data.agent.effort + ? ` · ${data.agent.effort}` + : ""} + +
+
+ {#each layers as layer (layer.label)} + {#if layer.tokens > 0} + + {/if} + {/each} +
+
+ {#each layers as layer (layer.label)} + + + {layer.label} + {fmtTokens(layer.tokens)} + + {/each} + estimates, except the total +
+
+ +
+
+
+
+

System prompt

+ + {fmtTokens(data.prompt.tokens_est)} + · {data.prompt.granules.length} granules + + +
+
+ {#each data.prompt.granules as g, index (index)} +
+ + + + {#if g.tag} + + <{g.tag}> + + {/if} + + + {fmtTokens(g.tokens_est)} + +
+ {/each} +
+ {#if promptOpen} +
{promptText ?? "…"}
+ {/if} +
+ +
+
+

Skills

+ + {data.skills.reduce((n, s) => n + s.skills.length, 0)} + loaded + +
+ {#if data.skills.length === 0} +

None for this kind.

+ {:else} +
+ {#each data.skills as set (set.path)} + {#each set.skills as skill (skill.path)} +
+ + + {skill.name} + {#if skill.description} + + {skill.description} + + {/if} + + {set.set} +
+ {/each} + {/each} +
+ {/if} +
+ +
+

Tools

+
+ {#if Object.keys(data.tool_counts).length > 0} +
used
+
+ {Object.entries(data.tool_counts) + .sort((a, b) => b[1] - a[1]) + .map(([name, n]) => `${name.replace("mcp__", "")} ×${n}`) + .join(" ")} +
+ {/if} + {#if data.tools.gateway.length} +
gateway
+
{data.tools.gateway.join(", ")}
+ {/if} + {#if data.tools.mcps.length} +
mcp
+
{data.tools.mcps.join(", ")}
+ {/if} + {#if data.tools.allowed} +
allowed
+
{data.tools.allowed.join(", ")}
+ {/if} + {#if data.tools.disallowed.length} +
off
+
+ {data.tools.disallowed.join(", ")} +
+ {/if} +
+
+
+ +
+
+
+

Notes it reached

+ {data.files.length} +
+ {#if graph && graph.nodes.length > 0} +
+ +
+

+ Solid: read or written by this thread. Hollow: one link away, not + reached. +

+ {:else if graphError} +

{graphError}

+ {/if} + {#if data.files.length === 0} +

No files touched yet.

+ {:else} +
+ {#each files as file (file.path)} + + {/each} +
+ {#if data.files.length > FILES_SHOWN} + + {/if} + {/if} +
+
+
+ {/if} +
diff --git a/ui/src/lib/panel/conversation-header.svelte b/ui/src/lib/panel/conversation-header.svelte index 191ecee..167cd79 100644 --- a/ui/src/lib/panel/conversation-header.svelte +++ b/ui/src/lib/panel/conversation-header.svelte @@ -1,15 +1,16 @@ -
-
+
+
{#if showTitle} - +

{info.title || `${info.kind} ${shortId(info.id)}`}

{/if} - - {#if info.pending_question} - question pending + + + + + {#if queued > 0} + + +{queued} + {/if} -
-
- -
- - - {#if info.parent} - {#if href} - - parent {shortId(info.parent)} - - {:else} - - {/if} - {/if} - - - - last activity {fmtRelative(info.last_activity_at)} - - {#if queued > 0} - + {#each VIEWS as item (item.value)} + + {/each}
- {#if windows.length > 0} + {#if detailsOpen} {/if}
diff --git a/ui/src/lib/panel/conversation-picker.svelte b/ui/src/lib/panel/conversation-picker.svelte deleted file mode 100644 index 95c433f..0000000 --- a/ui/src/lib/panel/conversation-picker.svelte +++ /dev/null @@ -1,219 +0,0 @@ - - - -
-
- - - {#if showFollow} - - {/if} -
-
- {#if index.live.state === "failed"} -
- index.start()} - /> -
- {:else if !index.loaded} -
- - - -
- {:else if rows.length === 0} -
- -
- {:else} - {#each groups as group (group.kind)} -
-

- {group.label} - - {group.rows.length} - -

-
    - {#each group.rows as row (row.id)} -
  • - -
  • - {/each} -
-
- {/each} - {/if} -
-
diff --git a/ui/src/lib/panel/conversation-row.svelte b/ui/src/lib/panel/conversation-row.svelte deleted file mode 100644 index 0b09583..0000000 --- a/ui/src/lib/panel/conversation-row.svelte +++ /dev/null @@ -1,91 +0,0 @@ - - - -
- - -
-
diff --git a/ui/src/lib/panel/conversation-view.svelte b/ui/src/lib/panel/conversation-view.svelte index 5ca9f7e..e294ce6 100644 --- a/ui/src/lib/panel/conversation-view.svelte +++ b/ui/src/lib/panel/conversation-view.svelte @@ -2,15 +2,12 @@ import { onMount, untrack } from "svelte"; import type { ApiClient } from "$lib/api/client"; import ErrorNote from "$lib/components/error-note.svelte"; - import { Skeleton } from "$lib/components/ui/skeleton"; - import * as Tabs from "$lib/components/ui/tabs"; import ActivityFeed from "./activity-feed.svelte"; - import BindingsList from "./bindings-list.svelte"; import ChatView from "./chat-view.svelte"; import Composer from "./composer.svelte"; + import ContextView from "./context-view.svelte"; import { ConversationFeed } from "./conversation.svelte"; import ConversationHeader from "./conversation-header.svelte"; - import QueueList from "./queue-list.svelte"; import RawEntries from "./raw-entries.svelte"; let { @@ -19,6 +16,7 @@ href = null, onOpen, showTitle = true, + initialView = "chat", }: { client: ApiClient; id: string; @@ -26,13 +24,15 @@ onOpen: (id: string) => void; // Off when a switcher above the thread already names it. showTitle?: boolean; + // "activity" beside a deep chat's own note: the file is the thread. + initialView?: string; } = $props(); const feed = new ConversationFeed( () => client, untrack(() => id) ); - let tab = $state("chat"); + let view = $state(untrack(() => initialView)); let historyKey = $state(0); onMount(() => { @@ -61,9 +61,10 @@ {#if feed.error && !info}
{:else if !info} -
- - +
+
{:else}
- -
- - Chat - Activity - Raw - Meta - -
- - - - + {#if view === "chat"} + + {:else if view === "activity"} +
- - +
+ {:else if view === "context"} +
+ {#key historyKey} + + {/key} +
+ {:else} +
- - - {@render meta()} - - +
+ {/if} {/if}
- -{#snippet meta()} - {#if info} -
-
-

- Queue -

- -
-
-

- Windows -

- -
-
-

- Flags -

- {#if Object.keys(info.flags).length === 0} -

No flags set.

- {:else} -
- {#each Object.entries(info.flags) as [key, value] (key)} -
{key}
-
{JSON.stringify(value)}
- {/each} -
- {/if} -
-
-

- Session -

-

- {info.session_id ?? "no session yet"} -

-
-
- {/if} -{/snippet} diff --git a/ui/src/lib/panel/host.ts b/ui/src/lib/panel/host.ts index ae02c80..eac9242 100644 --- a/ui/src/lib/panel/host.ts +++ b/ui/src/lib/panel/host.ts @@ -1,4 +1,5 @@ import { getContext, setContext } from "svelte"; +import { type PanelCache, panelCache } from "./cache"; export type LinkKind = "internal" | "external"; @@ -7,6 +8,8 @@ export type LinkKind = "internal" | "external"; // Obsidian plugin renders through MarkdownRenderer and opens notes in // place. Anything the host leaves undefined falls back to the browser way. export interface PanelHost { + // Where the index and the last threads persist between opens. + cache?: PanelCache; // Renders ``text`` into ``node``; returns the cleanup. Absent: marked + DOMPurify. markdown?: (node: HTMLElement, text: string) => (() => void) | undefined; name: "browser" | "obsidian"; @@ -35,6 +38,7 @@ export function browserHost(): PanelHost { return browser; } browser = { + cache: panelCache("beaver.panel"), name: "browser", openLink(target, kind) { if (kind === "internal") { diff --git a/ui/src/lib/panel/index.svelte.ts b/ui/src/lib/panel/index.svelte.ts index 39af635..c48d503 100644 --- a/ui/src/lib/panel/index.svelte.ts +++ b/ui/src/lib/panel/index.svelte.ts @@ -1,9 +1,12 @@ import type { ApiClient } from "$lib/api/client"; import { LiveStream } from "$lib/api/live.svelte"; import type { BusEvent, ConversationSummary } from "$lib/api/types"; +import type { PanelCache } from "./cache"; const RELOAD_DEBOUNCE_MS = 1500; const PAGE = 500; +const CACHED_ROWS = 200; +const INDEX_KEY = "index"; // The conversation index kept fresh by ``/api/events``: rows land as // ``conversation.*`` events arrive, turn markers flip ``running_turn`` @@ -14,10 +17,17 @@ export class ConversationIndex { loaded = $state(false); readonly live: LiveStream; protected readonly client: () => ApiClient | null; + private readonly cache: PanelCache | null; private reloadTimer: ReturnType | null = null; - constructor(client: () => ApiClient | null) { + constructor(client: () => ApiClient | null, cache: PanelCache | null = null) { this.client = client; + this.cache = cache; + const cached = cache?.get(INDEX_KEY); + if (cached && cached.length > 0) { + this.conversations = cached; + this.loaded = true; + } this.live = new LiveStream(client, "/api/events", { onEvent: (event) => this.apply(event), prepare: () => this.load(), @@ -44,6 +54,7 @@ export class ConversationIndex { const list = await client.conversations({ limit: PAGE }); this.conversations = list.conversations; this.loaded = true; + this.cache?.set(INDEX_KEY, list.conversations.slice(0, CACHED_ROWS)); } apply(event: BusEvent): void { diff --git a/ui/src/lib/panel/panel-bar.svelte b/ui/src/lib/panel/panel-bar.svelte index 0085ecc..c73f87e 100644 --- a/ui/src/lib/panel/panel-bar.svelte +++ b/ui/src/lib/panel/panel-bar.svelte @@ -3,19 +3,20 @@ import Link2Icon from "@lucide/svelte/icons/link-2"; import Link2OffIcon from "@lucide/svelte/icons/link-2-off"; import type { ApiClient } from "$lib/api/client"; - import KindBadge from "$lib/components/kind-badge.svelte"; - import LiveDot from "$lib/components/live-dot.svelte"; - import { Button } from "$lib/components/ui/button"; import * as Popover from "$lib/components/ui/popover"; import { clip, shortId } from "$lib/format"; - import ConversationPicker from "./conversation-picker.svelte"; + import Rail from "$lib/rail/rail.svelte"; + import KindMark from "$lib/shell/kind-mark.svelte"; + import { cn } from "$lib/utils"; import type { ConversationIndex } from "./index.svelte"; + import PanelIsland from "./panel-island.svelte"; let { client, index, selected = null, onOpen, + onOpenFile, follow = $bindable(false), showFollow = false, }: { @@ -23,6 +24,7 @@ index: ConversationIndex; selected?: string | null; onOpen: (id: string) => void; + onOpenFile?: (path: string) => void; follow?: boolean; showFollow?: boolean; } = $props(); @@ -30,10 +32,10 @@ const TITLE_MAX = 48; let switcherOpen = $state(false); const current = $derived(selected ? index.byId(selected) : undefined); - const running = $derived(index.running.length); -
+
+ {#snippet child({ props })} @@ -44,7 +46,7 @@ type="button" > {#if current} - + {current.title ? clip(current.title, TITLE_MAX) @@ -59,14 +61,6 @@ Pick a conversation {/if} - {#if running > 0 && !current?.running_turn} - - {running} - - {/if} {/snippet} @@ -76,44 +70,44 @@ class="w-[min(24rem,calc(100vw-1rem))] gap-0 overflow-hidden p-0" sideOffset={6} > -
- + { switcherOpen = false; onOpen(id); }} + onOpenFile={(path) => { + switcherOpen = false; + onOpenFile?.(path); + }} {selected} />
- {#if showFollow} - + {/if}
diff --git a/ui/src/lib/panel/panel-island.svelte b/ui/src/lib/panel/panel-island.svelte new file mode 100644 index 0000000..e7b94fe --- /dev/null +++ b/ui/src/lib/panel/panel-island.svelte @@ -0,0 +1,70 @@ + + + diff --git a/ui/src/lib/panel/panel-shell.svelte b/ui/src/lib/panel/panel-shell.svelte index 0393da4..cb0ea0d 100644 --- a/ui/src/lib/panel/panel-shell.svelte +++ b/ui/src/lib/panel/panel-shell.svelte @@ -1,11 +1,11 @@ @@ -59,15 +62,11 @@ the verdict, and DESIGN.md >
{#if wide} -