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): PromiseReading the context…
+ {:else} +{promptText ?? "…"}
+ {/if}
+ None for this kind.
+ {:else} ++ 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} +No flags set.
- {:else} -- {info.session_id ?? "no session yet"} -
-