Compare commits

..
56 Commits
Author SHA1 Message Date
hh c9c2e94954 refactor: no comments left - one-line module docstrings, contracts on public fields only; jobs/job.py; example config and README 2026-09-02 00:33:04 +02:00
hh 90d0fb0f00 refactor(telegram): every user-facing string lives in TelegramTexts with English defaults 2026-09-02 00:16:38 +02:00
hh cae2ed4161 refactor: split flat core into capability packages, layer the conversations service, English defaults for every model-facing text 2026-09-02 00:13:20 +02:00
hh b96714338f fix(telegram,conversations): attachments by day with sweep in both modes, merge renames the topic, gone topics unbind, failures visible 2026-09-01 23:13:22 +02:00
hh 7853d61b94 feat(telegram): block-level markdown to html, wikilinks, tables, quotes, fence-safe chunking 2026-09-01 23:13:21 +02:00
hh f6a7ea8f29 feat(agents,claude_sdk): skill sets per conversation kind 2026-09-01 23:13:21 +02:00
hh 1954a6e9f2 feat(scheduler,conversations,api): cron and schedule in the gateway tz, job runs persisted 2026-09-01 23:13:17 +02:00
hh 9b1127acff chore(deps): claude-agent-sdk 0.2.150 bundles cli 2.1.257 2026-09-01 22:00:41 +02:00
hh 1e08dfb9da fix(mcp,redact,gateway_tools): mask credentials in tool results, names kept and values gone 2026-09-01 14:12:49 +00:00
hh 8e7db6baa4 fix(redact,cli): mask credentials in every log line, silence httpx request urls 2026-09-01 14:12:49 +00:00
hh acc79c2e42 feat(envelope,conversations): recall hook under the user text, user_sink for the setup's reply log 2026-09-01 16:08:53 +02:00
hh 63014a487d fix(storage,api): usage cost as per-turn delta of the sdk running total, backfill 2026-09-01 15:37:53 +02:00
hh 3406e2e178 feat(injects,conversations,scheduler,gateway_tools,api): wake priority, master and parent aliases, scheduled injects start a turn 2026-08-30 20:34:12 +02:00
hh 878f7d6473 fix(cli): mask query tokens in uvicorn access log 2026-08-30 18:30:26 +02:00
hh c438d4cb92 feat(auth): bootstrap token entries carry an optional scope 2026-08-30 18:25:48 +02:00
hh ba9d63667e feat(auth): query-string token for headerless webhook senders 2026-08-30 18:10:00 +02:00
hh d57f660a30 fix(gateway_tools,conversations): say refused in user turns, the reply reaches the human itself 2026-08-30 02:26:35 +02:00
hh cc32307921 feat(scheduler): job dedupe flag for webhooks that must not collapse 2026-08-29 22:15:15 +02:00
hh 811035ba4f fix(ui): chat keeps the bottom pinned while async markdown lands 2026-08-29 19:26:45 +02:00
hh 9efff6e814 feat(ui,api,telegram): shared conversation panel for obsidian and /admin/panel, bind materializes a window, inbox survives link previews 2026-08-29 18:30:33 +02:00
hh a989d2909d feat(injects,conversations): configurable inject header, urgent inject marks the turn it cut 2026-08-29 17:07:43 +02:00
hh 8d252f9867 fix(claude_sdk,conversations,rotation): context size from the last api call, new-day text by rotation reason 2026-08-29 16:54:29 +02:00
hh 2895e677c0 feat(policy,claude_sdk,cli,config_loader): pretooluse policy rules per agent, tool call audit, sibling imports for config 2026-08-29 15:27:26 +02:00
hh 23cfb16228 feat(distill,conversations,scheduler,api,ui): close deep chats with a distiller fork, digest and index, idle and weekly state jobs 2026-08-29 03:15:39 +02:00
hh ab9ce50b82 refactor(envelope): drop the reply counter and the injects line, keep time and vault changes 2026-08-29 02:38:39 +02:00
hh 662bdcdfda feat(scheduler,rotation,envelope,api,ui): pgqueuer jobs and deferred injects, master rotation with handout, vault envelope, jobs page 2026-08-29 02:29:05 +02:00
hh 0479d7cdda feat(conversations): pre-SDK rows read from canonical messages, implied titles, markdown adopts them by first prompt 2026-08-29 01:44:05 +02:00
hh 0cc2c5291f fix(markdown): a rewritten assistant reply reseeds the session instead of resuming it 2026-08-29 01:37:52 +02:00
hh 5cd18fa2d7 fix(ui): sidebar footer in one row, no user name 2026-08-29 01:19:58 +02:00
hh e45e9960ed fix(ui): thread header counts only queued items; label the live dot 2026-08-29 01:08:44 +02:00
hh 16b6bbddda feat: one gateway port with path-mounted frontends, markdown chat, collapsible sidebars 2026-08-29 00:49:06 +02:00
hh 540327efa1 feat(ui,api): chat view with live tail and autoscroll, conversation rail with last message previews 2026-08-29 00:18:08 +02:00
hh 645df6f2b4 feat(ui): endpoint catalog with copyable urls on the now page 2026-08-28 22:30:11 +02:00
hh b577034c83 chore(ui): beaver favicon from beaver-land and impeccable design sidecar 2026-08-28 22:20:36 +02:00
hh 00618ab7ad feat(ui,api,admin): sveltekit admin spa, usage by day/agent/conversation/model, rate limits, memory tree, turn snapshot 2026-08-28 22:14:15 +02:00
hh aaaf459e6d fix(backends): remember the session id from the first frame so a cut turn stays resumable 2026-08-28 19:38:47 +02:00
hh 4ad014726b fix(core): a turn cut by shutdown keeps running_turn for recover 2026-08-28 19:33:26 +02:00
hh 8c9c6eee69 fix(telegram): brief-seeded branches reply and stream in their topic 2026-08-28 19:21:03 +02:00
hh 21766fe2d7 feat(core): seeds without text wait for the first turn 2026-08-28 19:03:08 +02:00
hh 1f8dae57ac fix(telegram,core): no draft for seed turns 2026-08-28 18:59:00 +02:00
hh 66325c2879 fix(telegram): last draft push is the final text so the client folds it into the message 2026-08-28 18:51:16 +02:00
hh 5a44a16ba3 fix(telegram,core): bare seeds stay silent in telegram 2026-08-28 18:42:35 +02:00
hh c711dcab13 fix(telegram): final message waits for the draft push in flight 2026-08-28 18:32:45 +02:00
hh 3995741cd5 fix(telegram,core): first message seeds the branch, registered commands, no ellipses, html drafts 2026-08-28 18:25:46 +02:00
hh b0f280b3ca feat(telegram,core): master topic instead of general in private chats 2026-08-28 18:12:58 +02:00
hh 45a5eb4a94 feat(telegram,core,backends,storage): telegram frontend with inbox, outbox, drafts and question buttons 2026-08-28 17:57:09 +02:00
hh 7ae87aeeb8 fix(backends,markdown): stream turn events live, no blank lines for tool blocks 2026-08-28 17:27:35 +02:00
hh fd8cbacd7b feat(core): publish tool.result on the bus for the activity panel 2026-08-28 17:01:00 +02:00
hh bd2d2368e1 refactor(agents,core,frontends): typed kinds, prompts per kind, frontend agents as parameters 2026-08-28 16:36:44 +02:00
hh 1d8d65b69a feat(core,frontends,agents): agent kinds, frontend routing, anthropic on conversations, vault mirror 2026-08-28 03:50:33 +02:00
hh 33ccc78fec feat(core,backends,frontends,storage): conversations, inject queue, session pool, gateway mcp tools, api frontend 2026-08-28 03:08:30 +02:00
hh 50b7057fa4 fix(backends,storage,core): setuid in exec wrapper, missing-column migration, runner config dir for mirror, tagged prompt sources 2026-08-28 02:10:19 +02:00
hh e1f242a87a feat(backends,storage,core,markdown,infra): claude agent sdk backend, session store, transcript seeding, runner isolation 2026-08-28 01:56:39 +02:00
hhandClaude Fable 5 ee2dc918ee docs(env): .env.example for local development
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 23:45:53 +02:00
hhandClaude Fable 5 33cc769ca2 chore: make ruff format and ty clean
Format two mcp modules with the current ruff, and narrow the text-block
check in conversation_store through a typed helper so ty stops flagging
dict.get on an unnarrowed block.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 23:22:23 +02:00
hhandClaude Fable 5 e4282b38cd feat(ui,infra): SvelteKit ui scaffold, Makefile, pre-commit, claude hooks
ui/: SvelteKit 2 + Svelte 5 + Tailwind 4 + shadcn-svelte (luma, lucide)
on bun with ultracite; adapter-static with SPA fallback; theme tokens
taken from msos. Dockerfile gains a bun stage that builds ui/build.
Makefile and pre-commit cover ruff, ty, ultracite and svelte-check;
.claude settings run the matching fixer after edits. docs/PRODUCT.md
captures product truth for the UI work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 23:22:18 +02:00
111 changed files with 2642 additions and 8740 deletions

No files matched your search

-4
View File
@@ -28,7 +28,3 @@ RAYCAST_BEARER=
# токен от @BotFather, свой user id - @userinfobot
TELEGRAM_BOT_TOKEN=
TELEGRAM_USER_ID=
# Откуда тулза send_file имеет право брать файлы (через ':').
# Папка входящих вложений разрешена всегда, её дописывает сам фронтенд.
SEND_FILE_ROOTS=/mnt/hole/shared
+9 -23
View File
@@ -7,8 +7,6 @@ boundary - without an opinion about what the agent is for. A setup is one
holds the primitives, [beaver-agent](https://git.kotikot.com/beaver/beaver-agent)
is a full setup built on them.
![The admin: what is in motion, what waits for an answer, the context and the quotas, the graph around the master](preview/shots/admin-now.webp)
## What it gives a setup
- **agents/** - what an agent is: a Claude agent with a prompt per
@@ -22,13 +20,9 @@ is a full setup built on them.
the envelope, the in-process gateway tools.
- **jobs/** - cron, webhook and event jobs on pgqueuer, deferred injects,
the subscription budget.
- **frontends/** - the windows: Telegram (master = General, topic = branch,
files out of the agent through `send_file`),
- **frontends/** - the windows: Telegram (master = General, topic = branch),
markdown files in a vault, `/api` + the admin SPA, an Anthropic-compatible
`/anthropic/v1/messages`, MCP re-exposure at `/mcp/<name>`, and
`WebhookFrontend` for a window a setup declares itself - its own request
and response schema, its own agent, its own token scope
(`docs/FRONTEND-PLUGINS.md`).
`/anthropic/v1/messages`, MCP re-exposure at `/mcp/<name>`.
- **mcp/** - MCP servers a setup declares (stdio, http, python tools),
aggregated in-process and handed to agents by name.
- **vault/** - watching a directory of notes for the envelope.
@@ -38,29 +32,21 @@ is a full setup built on them.
- **storage/** - SQLModel tables and the Postgres session store.
`config.py` defines `Gateway` and loads a setup; `app.py` builds the runtime
from it; `cli.py` is the entrypoint. The admin SPA in `ui/` is the browser
window on all of it: conversations by day, the thread with the tree of tool
calls, the context of the last turn, memory, jobs, usage, tokens and the audit.
![A conversation in the admin: the thread, the queue, the details](preview/shots/admin-thread.webp)
![The Context tab: what the model held on the last turn - harness, prompt granules, skills, the notes the thread reached](preview/shots/admin-context.webp) Every string the gateway puts in front of
from it; `cli.py` is the entrypoint. Every string the gateway puts in front of
a model or a user has an English default and is overridable
(`ConversationTexts`, `TelegramTexts`).
## Run
```sh
cd examples
CLAUDE_CODE_OAUTH_TOKEN=$(claude setup-token) docker compose up --build
uv sync # python
cd ui && bun install && cd .. # admin + panel SPA, built into ui/build
cp examples/config.py /config/config.py
DATABASE_URL=postgresql+psycopg://... CONFIG_PATH=/config/config.py uv run beaver-gateway
```
`examples/config.py` is the smallest setup that starts: one agent, one
python tool, one cron job, the built-in frontends on port 8000; the compose
file next to it adds Postgres. Without Docker: `uv sync`, build the SPA with
`cd ui && bun install && bun run build`, then run `uv run beaver-gateway` with
`DATABASE_URL` and `CONFIG_PATH` set. A setup's own secrets come from `.env`
next to its config (`load_dotenv` runs before the config is executed).
`examples/config.py` is the smallest setup that starts. The setup's own
secrets come from `.env` (`load_dotenv` runs before the config is executed).
## Develop
+22 -27
View File
@@ -1,36 +1,31 @@
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: beaver
POSTGRES_PASSWORD: beaver
POSTGRES_DB: beaver
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U beaver -d beaver"]
interval: 5s
timeout: 3s
retries: 20
# Minimal compose for the Phase 0 DoD: build the image, mount a
# single-file user config, watch the process print its summary line
# and exit cleanly.
#
# Real deployments add a postgres service, raycast-config.json bind,
# ~/.config/claude bind, etc. — Phase 5 lives in a separate example
# repo (see PLAN §5.1).
services:
gateway:
build:
context: ..
depends_on:
postgres:
condition: service_healthy
dockerfile: Dockerfile
environment:
CONFIG_PATH: /config/config.py
DATABASE_URL: postgresql+psycopg://beaver:beaver@postgres:5432/beaver
ADMIN_USER: admin
ADMIN_PASS: change-me
SESSION_SECRET: change-me-too
BOOTSTRAP_TOKENS: dev:change-me:*
CLAUDE_CODE_OAUTH_TOKEN: ${CLAUDE_CODE_OAUTH_TOKEN}
DATABASE_URL: "sqlite:///./gateway.db"
ADMIN_USER: "admin"
ADMIN_PASS: "change-me"
SESSION_SECRET: "dev-secret-change-me"
# Phase 1.4 — comma-separated `name:value` pairs. The frontend
# 401s every request until at least one token is set.
BOOTSTRAP_TOKENS: "cursor:dev-token-change-me"
# RAYCAST_BEARER / RAYCAST_DEVICE_ID + a raycast.json bind go
# here once you wire up an actual RaycastAgent. The example
# config.py declares one, so set these (or remove the agent)
# before exposing port 8000.
ports:
# The one gateway port: /anthropic, /mcp, /admin, /md under it
# (change ADMIN_USER/ADMIN_PASS/SESSION_SECRET before exposing).
- "8000:8000"
volumes:
- ./config.py:/config/config.py:ro
volumes:
postgres-data:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

+1 -8
View File
@@ -6,7 +6,6 @@ import asyncio
import functools
import logging
from contextlib import AsyncExitStack
from pathlib import Path
from typing import TYPE_CHECKING, Any
import psycopg
@@ -34,7 +33,7 @@ from beaver_gateway.frontends.bearer import require_token
from beaver_gateway.frontends.root import build_root_app
from beaver_gateway.jobs.scheduler import Scheduler
from beaver_gateway.mcp.internal_app import build_internal_app
from beaver_gateway.security.auth import TokenStore, scopes_with
from beaver_gateway.security.auth import TokenStore
from beaver_gateway.storage import (
Database,
PostgresSessionStore,
@@ -195,8 +194,6 @@ async def run(gateway: Gateway, settings: Settings) -> None:
pool=pool,
scheduler=scheduler,
public_url=gateway.public_url.rstrip("/") if gateway.public_url else None,
scopes=scopes_with(fe.scope for fe in gateway.frontends),
send_file_roots=_roots(settings.send_file_roots),
)
for fe in gateway.frontends:
fe.configure(runtime)
@@ -299,10 +296,6 @@ async def _serve_internal_mcp(app: Starlette, *, settings: Settings) -> None:
await uvicorn.Server(config).serve()
def _roots(value: str) -> tuple[Path, ...]:
return tuple(Path(p) for p in value.split(":") if p.strip())
class _LateConversations:
conversations: Conversations | None = None
+54 -249
View File
@@ -37,7 +37,6 @@ from claude_agent_sdk import (
PermissionResultDeny,
ResultMessage,
StreamEvent,
TaskNotificationMessage,
TextBlock,
ThinkingBlock,
ToolResultBlock,
@@ -73,14 +72,7 @@ from beaver_gateway.events.stream import (
)
if TYPE_CHECKING:
from collections.abc import (
AsyncIterator,
Awaitable,
Callable,
Coroutine,
Iterable,
Sequence,
)
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Sequence
from anthropic.types import MessageParam
from claude_agent_sdk import (
@@ -209,16 +201,8 @@ class ClaudeSdkBackend:
tool_server: Callable[[str, str], McpSdkServerConfig | None] | None = None,
asker: Callable[[str, dict[str, Any]], Awaitable[str]] | None = None,
audit_sink: Callable[[policy_mod.ToolAudit], Awaitable[None]] | None = None,
waker: Callable[[str, str], Coroutine[Any, Any, None]] | None = None,
) -> None:
"""One backend per Claude agent.
``waker(key, note)`` is called when a session starts a turn by itself
(a background task reported back); the caller then attaches.
"""
self._agent = agent
self.waker = waker
self._tasks: set[asyncio.Task[None]] = set()
self._audit_sink = audit_sink
self._store = session_store
self._runner = runner or RunnerConfig()
@@ -272,51 +256,6 @@ class ClaudeSdkBackend:
def live(self, key: str) -> Session | None:
return self._pool.get(key)
def has_pending(self, key: str) -> bool:
"""A turn the session started on its own is waiting for ``attach``."""
live = self._pool.get(key)
return live is not None and bool(live.pending)
def pending_note(self, key: str) -> str | None:
live = self._pool.get(key)
return live.note if live is not None else None
async def attach(
self,
*,
key: str,
capture: TurnCapture | None = None,
observer: Callable[[Any], None] | None = None,
turn_id: str | None = None,
) -> AsyncIterator[MessageStreamEvent]:
"""Consume the turn a live session started by itself as a normal turn.
The CLI runs one when a background task (a subagent) reports back:
no prompt goes out, the events are what the model said in reply to
the report, the turn ends on that result.
"""
live = self._pool.get(key)
if live is None or not live.pending:
return
message_id = f"msg_{uuid.uuid4().hex}"
yield build_message_start(message_id=message_id, model=self._agent.model)
turn = _Turn()
async with live.lock:
live.running_turn = turn_id or message_id
live.last_used = time.monotonic()
async for event in self._run_turn(live, None, turn, observer, capture):
yield event
live.turns += 1
live.last_used = time.monotonic()
live.running_turn = None
usage, _ = await self._after_turn(
live, turn, conversation_id=key, history=[], capture=capture
)
yield build_message_delta(
stop_reason=turn.stop_reason, usage=_wire_usage(usage)
)
yield build_message_stop()
async def repair_session(
self, session_id: str, *, text: str = "interrupted"
) -> int:
@@ -374,7 +313,7 @@ class ClaudeSdkBackend:
if not history or history[-1].get("role") != "user":
msg = "the last message must be a user turn"
raise ValueError(msg)
prompt = _prompt(history[-1].get("content"))
prompt = _prompt_text(history[-1].get("content"))
prior = history[:-1]
key = conversation_id or fingerprint(prior)
spec = _SessionSpec(kind=kind, pinned=pinned, tools=tools)
@@ -465,71 +404,70 @@ class ClaudeSdkBackend:
async def _run_turn(
self,
live: Session,
prompt: str | list[dict[str, Any]] | None,
prompt: str,
turn: _Turn,
observer: Callable[[Any], None] | None = None,
capture: TurnCapture | None = None,
) -> AsyncIterator[MessageStreamEvent]:
"""Run one prompt, or attach to a self-started one, yielding events.
"""Run one prompt, yielding wire events as they arrive.
``turn`` is filled in place (result, synthesized history, count of
events already yielded) so the caller can finish bookkeeping - and
decide whether a retry is still possible - after a failure. The
session id lands in ``capture`` with the first frame, so a turn cut
by a restart still leaves a resumable session behind.
A result whose ``origin`` is not the human's ends a turn the CLI ran
on its own; inside a prompted turn it is passed over, the events it
came with stay in this turn rather than getting lost.
"""
streaming = self._agent.options.include_partial_messages
raw: list[Any] = []
cursor = _Cursor()
live.observer = observer
live.attached = True
for queued in live.pending:
live.inbox.put_nowait(queued)
live.pending.clear()
try:
if prompt is not None:
await live.client.query(
prompt if isinstance(prompt, str) else _stream(prompt)
next_index = 0
offset = 0
await live.client.query(prompt)
async for message in live.client.receive_response():
if observer is not None:
observer(message)
session_id = getattr(message, "session_id", None)
if isinstance(session_id, str) and session_id and live.session_id is None:
live.session_id = session_id
if capture is not None:
capture.session_id = session_id
if isinstance(message, MirrorErrorMessage):
live.dirty = True
_log.error(
"session %s: mirror error, marked dirty: %s",
live.session_id,
message.error,
)
while True:
message = await live.inbox.get()
if message is _END:
if prompt is not None:
msg = f"claude exited before answering ({live.session_id})"
raise RuntimeError(msg)
break
if isinstance(message, BaseException):
raise message
if observer is not None:
observer(message)
if not self._note(live, message, capture):
continue
if getattr(message, "parent_tool_use_id", None) is not None:
continue
if isinstance(message, StreamEvent):
event = message.event
if event.get("type") == "message_start":
offset = next_index
turn.context_tokens = _context_of(event.get("message"))
continue
if isinstance(message, ResultMessage):
if prompt is not None and _self_started(message):
_log.info(
"session %s: a self-started turn ended inside a "
"prompted one, folded in",
live.session_id,
)
continue
turn.result = message
turn.stop_reason = _STOP_REASONS.get(
message.stop_reason or "", "end_turn"
)
break
for out in self._emit(message, turn, cursor, raw):
turn.events += 1
yield out
if turn.events == 0:
for out in _emit_whole(raw, cursor):
turn.events += 1
yield out
finally:
live.attached = False
live.woken = False
index = event.get("index")
if isinstance(index, int):
next_index = max(next_index, offset + index + 1)
if streaming:
for out in _emit_stream_event(event, offset + index):
turn.events += 1
yield out
elif isinstance(message, AssistantMessage):
raw.append(message)
if not streaming:
for block in message.content:
for out in _emit_block(block, next_index):
turn.events += 1
yield out
next_index += 1
elif isinstance(message, UserMessage):
raw.append(message)
elif isinstance(message, ResultMessage):
turn.result = message
turn.stop_reason = _STOP_REASONS.get(
message.stop_reason or "", "end_turn"
)
turn.synthesized = synthesize_turn_messages(raw)
_log.info(
"turn: agent=%s session=%s events=%d synthesized=%d stop=%s",
@@ -540,88 +478,6 @@ class ClaudeSdkBackend:
turn.stop_reason,
)
@staticmethod
def _note(live: Session, message: Any, capture: TurnCapture | None) -> bool:
"""Session bookkeeping for one message; False if not the main thread's."""
session_id = getattr(message, "session_id", None)
if isinstance(session_id, str) and session_id and live.session_id is None:
live.session_id = session_id
if capture is not None:
capture.session_id = session_id
if isinstance(message, MirrorErrorMessage):
live.dirty = True
_log.error(
"session %s: mirror error, marked dirty: %s",
live.session_id,
message.error,
)
return False
if isinstance(message, TaskNotificationMessage):
live.note = _note_of(message)
return False
return getattr(message, "parent_tool_use_id", None) is None
def _emit(
self, message: Any, turn: _Turn, cursor: _Cursor, raw: list[Any]
) -> Iterable[MessageStreamEvent]:
streaming = self._agent.options.include_partial_messages
if isinstance(message, StreamEvent):
event = message.event
if event.get("type") == "message_start":
cursor.offset = cursor.next_index
turn.context_tokens = _context_of(event.get("message"))
return
index = event.get("index")
if isinstance(index, int):
cursor.next_index = max(cursor.next_index, cursor.offset + index + 1)
if streaming:
yield from _emit_stream_event(event, cursor.offset + index)
elif isinstance(message, AssistantMessage):
raw.append(message)
if not streaming:
for block in message.content:
yield from _emit_block(block, cursor.next_index)
cursor.next_index += 1
elif isinstance(message, UserMessage):
raw.append(message)
async def _read(self, live: Session) -> None:
"""Read the client for the session's whole life.
During a turn every message goes to the turn's inbox. Between turns
a subagent still running is shown to the last observer, and the
first frame of the main thread means the CLI started a turn by
itself: it is buffered in ``pending`` and the waker is told once.
"""
try:
async for message in live.client.receive_messages():
if live.attached:
live.inbox.put_nowait(message)
continue
if isinstance(message, TaskNotificationMessage):
live.note = _note_of(message)
if _main_thread(message):
live.pending.append(message)
if not live.woken:
live.woken = True
self._wake(live)
elif live.observer is not None:
live.observer(message)
live.inbox.put_nowait(_END)
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001
_log.error("session %s: reader died: %s", live.session_id, exc)
live.dirty = True
live.inbox.put_nowait(exc)
def _wake(self, live: Session) -> None:
if self.waker is None:
return
task = asyncio.create_task(self.waker(live.key, live.note or ""))
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
async def _acquire(
self,
key: str,
@@ -685,7 +541,7 @@ class ClaudeSdkBackend:
spec.tools,
self._runner.user,
)
live = Session(
return Session(
key=key,
agent=self._agent.name,
kind=spec.kind,
@@ -694,8 +550,6 @@ class ClaudeSdkBackend:
resumed=resume is not None,
pinned=spec.pinned,
)
live.reader = asyncio.create_task(self._read(live))
return live
def _default_factory(self, options: ClaudeAgentOptions) -> SessionClient:
return _RunnerClient(options, uid=self._uid)
@@ -885,15 +739,6 @@ class _SessionSpec:
tools: bool
_END = object()
@dataclass
class _Cursor:
next_index: int = 0
offset: int = 0
@dataclass
class _Turn:
events: int = 0
@@ -1000,54 +845,14 @@ def _mcp_disallowed(
return out
def _prompt(content: Any) -> str | list[dict[str, Any]]:
"""Plain text when the turn is text only; the block list when images ride along."""
def _prompt_text(content: Any) -> str:
text = text_of(content)
if not text:
msg = "user message has no text content"
raise ValueError(msg)
if isinstance(content, list) and any(
isinstance(b, dict) and b.get("type") != "text" for b in content
):
return [dict(b) for b in content if isinstance(b, dict)]
return text
async def _stream(content: list[dict[str, Any]]) -> AsyncIterator[dict[str, Any]]:
yield {
"type": "user",
"message": {"role": "user", "content": content},
"parent_tool_use_id": None,
}
def _emit_whole(raw: Iterable[Any], cursor: _Cursor) -> Iterable[MessageStreamEvent]:
"""Blocks of a turn that streamed no deltas, e.g. one reported whole."""
for message in raw:
if isinstance(message, AssistantMessage):
for block in message.content:
yield from _emit_block(block, cursor.next_index)
cursor.next_index += 1
def _self_started(result: ResultMessage) -> bool:
origin = result.origin
return origin is not None and origin.get("kind") != "human"
def _main_thread(message: Any) -> bool:
if getattr(message, "parent_tool_use_id", None) is not None:
return False
return isinstance(
message, (StreamEvent, AssistantMessage, UserMessage, ResultMessage)
)
def _note_of(message: TaskNotificationMessage) -> str:
summary = message.summary or message.data.get("description") or message.task_id
return f"{summary} ({message.status})"
def synthesize_turn_messages(raw: Iterable[Any]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for message in raw:
+3 -26
View File
@@ -20,13 +20,7 @@ from typing import TYPE_CHECKING, Any, Protocol
import psutil
if TYPE_CHECKING:
from collections.abc import (
AsyncIterable,
AsyncIterator,
Callable,
Iterator,
Mapping,
)
from collections.abc import AsyncIterator, Iterator, Mapping
__all__ = ["DEFAULT_TTL", "Session", "SessionClient", "SessionPool", "cgroup_limit"]
@@ -47,8 +41,8 @@ _RSS_HEADROOM = 0.8
class SessionClient(Protocol):
async def connect(self) -> None: ...
async def query(self, prompt: str | AsyncIterable[dict[str, Any]]) -> None: ...
def receive_messages(self) -> AsyncIterator[Any]: ...
async def query(self, prompt: str) -> None: ...
def receive_response(self) -> AsyncIterator[Any]: ...
async def interrupt(self) -> None: ...
async def disconnect(self) -> None: ...
@@ -73,20 +67,6 @@ class Session:
state: dict[str, Any] = field(default_factory=dict)
"""Scratch for policy rules (``agents/policy.py``); dies with the process."""
reader: asyncio.Task[None] | None = None
"""Reads the client for the session's whole life, not only during a turn."""
inbox: asyncio.Queue[Any] = field(default_factory=asyncio.Queue)
"""Where the reader puts messages while a turn is attached."""
pending: list[Any] = field(default_factory=list)
"""A turn the CLI started on its own (a background task reported back),
buffered until a consumer attaches."""
attached: bool = False
woken: bool = False
note: str | None = None
"""Summary of the last task notification, the pending turn's prompt."""
observer: Callable[[Any], None] | None = None
"""The last turn's observer; sees subagent traffic between turns."""
@property
def busy(self) -> bool:
return self.lock.locked() or self.running_turn is not None
@@ -212,8 +192,6 @@ class SessionPool:
if session is None:
return
_log.info("closing session %s (%s, %s)", session.session_id, session.kind, key)
if session.reader is not None and session.reader is not asyncio.current_task():
session.reader.cancel()
try:
await session.client.disconnect()
except Exception: # noqa: BLE001
@@ -260,7 +238,6 @@ class SessionPool:
"turns": s.turns,
"busy": s.busy,
"running_turn": s.running_turn,
"pending": len(s.pending),
"pending_question": s.pending_question,
"pinned": s.pinned,
"dirty": s.dirty,
+3 -15
View File
@@ -161,10 +161,7 @@ def build_entries(
return entries
def messages_from_entries(
entries: Iterable[Mapping[str, Any]], *, with_ts: bool = False
) -> list[dict[str, Any]]:
"""Messages as the model saw them; ``with_ts`` adds when each one was written."""
def messages_from_entries(entries: Iterable[Mapping[str, Any]]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
last_message_id: str | None = None
for entry in entries:
@@ -183,13 +180,7 @@ def messages_from_entries(
):
out[-1]["content"].extend(results)
else:
message_out: dict[str, Any] = {
"role": "user",
"content": _user_content(content),
}
if with_ts:
message_out["ts"] = entry.get("timestamp")
out.append(message_out)
out.append({"role": "user", "content": _user_content(content)})
last_message_id = None
continue
blocks = _assistant_blocks(content)
@@ -202,10 +193,7 @@ def messages_from_entries(
):
out[-1]["content"].extend(blocks)
else:
message_out: dict[str, Any] = {"role": "assistant", "content": blocks}
if with_ts:
message_out["ts"] = entry.get("timestamp")
out.append(message_out)
out.append({"role": "assistant", "content": blocks})
last_message_id = message_id
return out
-9
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import re
import sys
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
@@ -31,8 +30,6 @@ if TYPE_CHECKING:
__all__ = ["ConfigError", "Gateway", "load"]
_SCOPE = re.compile(r"[a-z][a-z0-9_-]*")
@dataclass(slots=True)
class Gateway:
@@ -138,12 +135,6 @@ def _validate(gw: Gateway, path: Path) -> None:
f"got {type(f).__name__}"
)
raise ConfigError(msg)
if f.scope is not None and not _SCOPE.fullmatch(f.scope):
msg = (
f"{path}: gateway.frontends[{i}] has scope {f.scope!r}; "
f"a frontend scope is a lowercase name like 'voice', never '*'"
)
raise ConfigError(msg)
names: set[str] = set()
for i, j in enumerate(gw.jobs):
if not isinstance(j, Job):
-246
View File
@@ -1,246 +0,0 @@
"""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__ = ["bash_paths", "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)
# A shell command names files as quoted strings or bare words; only notes
# and plain text files count, code and binaries are the agent's own business.
_SHELL_TOKEN = re.compile(r'"([^"\n]+)"|\'([^\'\n]+)\'|([^\s"\'`;|&()<>]+)')
_NOTE_SUFFIXES = (".md", ".canvas", ".txt", ".csv", ".json", ".yaml", ".yml")
_SHELL_CD = re.compile(r'(?:^|&&|;|\|\|)\s*cd\s+("([^"]+)"|\'([^\']+)\'|(\S+))')
_SHELL_WRITES = re.compile(
r"write_text|write_bytes|>>?\s*[\"\']?[^\s&|]|\btee\b|\bsed\s+-i|\bmv\b|\brm\b"
r"|\btouch\b|\bcp\b|open\([^)]*[\"\'][wa]"
)
def bash_paths(command: str, cwd: Path | None) -> tuple[list[str], Path | None, bool]:
"""Files a shell command names, where it leaves the shell, whether it writes.
Relative names resolve against ``cwd``, or against the directory of a
leading ``cd`` in the same command. Absolute names stay as they are.
"""
here = cwd
for match in _SHELL_CD.finditer(command):
target = match.group(2) or match.group(3) or match.group(4) or ""
target = target.strip()
if not target:
continue
here = Path(target) if target.startswith("/") else (here or Path()) / target
found: list[str] = []
for match in _SHELL_TOKEN.finditer(command):
raw = (match.group(1) or match.group(2) or match.group(3) or "").strip()
if not raw.lower().endswith(_NOTE_SUFFIXES) or "*" in raw:
continue
path = Path(raw) if raw.startswith("/") else (here or Path()) / raw
text = str(path)
if text not in found:
found.append(text)
return found, here, bool(_SHELL_WRITES.search(command))
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()
# The shell keeps its directory between calls; follow it.
shell_cwd = cwd
def touch(raw: str, how: str, stamp: str) -> None:
key = _relative(raw, cwd)
row = files.setdefault(
key, {"path": key, "reads": 0, "writes": 0, "other": 0, "last_at": stamp}
)
row[how] += 1
row["last_at"] = max(row["last_at"], stamp)
for name, tool_input, stamp in _tool_uses(entries):
counts[name] += 1
if not isinstance(tool_input, dict):
continue
if name == "Bash" and isinstance(tool_input.get("command"), str):
paths, shell_cwd, writes = bash_paths(tool_input["command"], shell_cwd)
for raw in paths:
touch(raw, "writes" if writes else "reads", stamp)
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
if name in READ_TOOLS:
touch(raw, "reads", stamp)
elif name in WRITE_TOOLS:
touch(raw, "writes", stamp)
else:
touch(raw, "other", 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)
),
}
+6 -19
View File
@@ -25,8 +25,6 @@ class RecallContext:
text: str
kind: str
now: datetime
agent: str = ""
"""Agent whose turn it is; a setup with several tells them apart by it."""
@dataclass(slots=True)
@@ -42,12 +40,7 @@ class Envelope:
texts: EnvelopeTexts = field(default_factory=EnvelopeTexts)
def build(
self,
*,
now: datetime | None = None,
text: str = "",
kind: str = "master",
agent: str = "",
self, *, now: datetime | None = None, text: str = "", kind: str = "master"
) -> str:
now = now or datetime.now(UTC)
changes = self.watch.take() if self.watch is not None else []
@@ -66,26 +59,20 @@ class Envelope:
texts=self.texts,
)
self.last_at = now
block = self.recall_block(text=text, kind=kind, now=now, agent=agent)
block = self.recall_block(text=text, kind=kind, now=now)
return f"{out}\n{block}" if block else out
def recall_only(
self, *, text: str, kind: str, now: datetime | None = None, agent: str = ""
self, *, text: str, kind: str, now: datetime | None = None
) -> str | None:
block = self.recall_block(
text=text, kind=kind, now=now or datetime.now(UTC), agent=agent
)
block = self.recall_block(text=text, kind=kind, now=now or datetime.now(UTC))
return f"{self.texts.header}\n{block}" if block else None
def recall_block(
self, *, text: str, kind: str, now: datetime, agent: str = ""
) -> str | None:
def recall_block(self, *, text: str, kind: str, now: datetime) -> str | None:
if self.recall is None or not text.strip():
return None
try:
block = self.recall(
RecallContext(text=text, kind=kind, now=now, agent=agent)
)
block = self.recall(RecallContext(text=text, kind=kind, now=now))
except Exception: # noqa: BLE001
_log.exception("recall hook failed")
return None
+3 -13
View File
@@ -9,7 +9,7 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, Literal, cast
from typing import TYPE_CHECKING, Literal, cast
from sqlalchemy import func
from sqlmodel import col, select
@@ -70,23 +70,13 @@ class InjectQueue:
self._db = db
async def push(
self,
*,
conversation_id: int,
priority: Priority,
origin: str,
text: str,
attachments: list[dict[str, Any]] | None = None,
self, *, conversation_id: int, priority: Priority, origin: str, text: str
) -> InjectQueueItem:
if priority not in PRIORITY_RANK:
msg = f"unknown priority {priority!r}"
raise ValueError(msg)
row = InjectQueueItem(
conversation_id=conversation_id,
priority=priority,
origin=origin,
text=text,
attachments=attachments or None,
conversation_id=conversation_id, priority=priority, origin=origin, text=text
)
async with self._db.session() as session:
session.add(row)
+2 -42
View File
@@ -1,4 +1,4 @@
"""Putting words into a conversation: a message, an inject, ``say``, a file."""
"""Putting words into a conversation: a message, an inject, ``say``, ``schedule``."""
from __future__ import annotations
@@ -6,7 +6,6 @@ import logging
from typing import TYPE_CHECKING, Any, cast
from beaver_gateway.conversations.turns import Turns
from beaver_gateway.frontends.files import SendFileError
if TYPE_CHECKING:
from datetime import datetime
@@ -21,19 +20,13 @@ _log = logging.getLogger(__name__)
class Messaging(Turns):
async def post(
self,
conv: Conversation,
text: str,
*,
origin: str = "user",
attachments: list[dict[str, Any]] | None = None,
self, conv: Conversation, text: str, *, origin: str = "user"
) -> InjectQueueItem:
item = await self._queue.push(
conversation_id=cast("int", conv.id),
priority="user",
origin=origin,
text=text,
attachments=attachments,
)
await self.touch_user(conv)
self._bus.publish(
@@ -93,14 +86,6 @@ class Messaging(Turns):
self._ensure_worker(cast("int", conv.id))
return item
async def interrupt(self, conv: Conversation) -> bool:
"""Cut the running turn; the reply so far still lands."""
try:
backend = self._backend(conv.agent_name)
except LookupError:
return False
return await backend.interrupt(conv.external_id)
async def say(self, conv: Conversation, text: str) -> dict[str, Any]:
runner = self._runners.get(cast("int", conv.id))
_log.info("say[%s]: %s", conv.external_id, text[:200])
@@ -111,31 +96,6 @@ class Messaging(Turns):
turn_id=runner.turn_id if runner is not None else None,
)
async def send_file(
self, conv: Conversation, path: str, *, caption: str = "", method: str = "auto"
) -> str:
"""Hand a file to the human: this conversation's window, else the master's."""
for target in (conv, await self.open_master()):
if target is None:
continue
for fe in self._frontends:
if target.kind in fe.kinds and fe.sends_files:
note = await fe.send_file(
target, path, caption=caption, method=method
)
_log.info("send_file[%s]: %s", target.external_id, note)
return note
msg = (
"nowhere to send: no frontend that sends files shows this "
"conversation, and there is no open master"
)
raise SendFileError(msg)
def file_note(self) -> str:
"""What the ``send_file`` tool tells the model about roots and size."""
sender = next((f for f in self._frontends if f.sends_files), None)
return sender.file_note() if sender else "no frontend here sends files"
async def schedule(
self,
conv: Conversation,
+1 -50
View File
@@ -8,8 +8,6 @@ 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 (
@@ -25,7 +23,6 @@ from beaver_gateway.storage.models import (
ConversationBinding,
ConversationMessage,
RateLimit,
TranscriptEntry,
Usage,
)
@@ -135,30 +132,6 @@ 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(
@@ -344,7 +317,6 @@ 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"] = [
@@ -387,25 +359,6 @@ 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 = (
@@ -474,9 +427,7 @@ class Rows(State):
return await load_messages(
session, conversation_id=cast("int", conv.id)
)
return messages_from_entries(
cast("Any", await self.entries(conv)), with_ts=True
)
return messages_from_entries(cast("Any", await self.entries(conv)))
async def entries(self, conv: Conversation, *, subpath: str = "") -> list[Any]:
if conv.session_id is None:
@@ -44,7 +44,6 @@ class Spawning(Messaging):
origin: str = "api",
binding: tuple[str, str] | None = None,
flags: dict[str, Any] | None = None,
attachments: list[dict[str, Any]] | None = None,
) -> Conversation:
"""Create a conversation in a window and queue its seed.
@@ -96,7 +95,6 @@ class Spawning(Messaging):
priority="user",
origin=f"seed:{seed}" if seed == "brief" else origin,
text=await self.seed_text(ctx, window=window),
attachments=attachments,
)
self._ensure_worker(cast("int", conv.id))
return conv
@@ -127,12 +127,6 @@ class State:
self._tasks = set()
self._idle_task = None
self.scheduler = None
for backend in backends.values():
if hasattr(backend, "waker"):
backend.waker = self._woken
async def _woken(self, key: str, note: str) -> None:
raise NotImplementedError
@property
def db(self) -> Database:
@@ -51,8 +51,6 @@ class UserSaid:
title: str | None
text: str
at: datetime
agent: str = ""
"""Agent whose turn it is; a setup with several tells them apart by it."""
@dataclass(frozen=True, slots=True)
@@ -79,8 +77,6 @@ class ConversationTexts:
)
inject_header: Callable[[injects.InjectContext], str] = injects.inject_header
bundle_header: str = "[injects accumulated since {since}; not the user]"
task_prompt: str = "[subagent reported back: {note}]"
"""Stands in for the user's words in mirrors of a turn a subagent started."""
interrupted: str = "interrupted"
answered: str = "The user answered: {answer}"
unanswered: str = (
+2 -46
View File
@@ -14,7 +14,6 @@ from claude_agent_sdk import create_sdk_mcp_server, tool
from beaver_gateway.conversations.injects import URGENCY
from beaver_gateway.conversations.kinds import as_kind
from beaver_gateway.frontends.files import AUTO, METHODS, SendFileError
from beaver_gateway.security.redact import redact_data
URGENCY_HELP = (
@@ -43,23 +42,7 @@ SAY_IN_USER_TURN = (
"reply text reaches them by itself - put what you wanted to say into the "
"reply instead of repeating it here"
)
TOOL_NAMES = (
"read_conversation",
"spawn",
"say",
"schedule",
"inject",
"close_chat",
"send_file",
)
SEND_FILE_HELP = (
"Send a file from disk to the human, into this conversation's window (the "
"master's if it has none): {note}. `method` is how Telegram renders it; "
"`auto` reads the extension - image is a photo, mp3/flac/m4a audio, ogg "
"voice, mp4/mov video, anything else a document. Photo and video are "
"recompressed, so pass `document` when the bytes must arrive untouched. "
"`caption` takes the same markdown as your replies."
)
TOOL_NAMES = ("read_conversation", "spawn", "say", "schedule", "inject", "close_chat")
def build_tool_server(
@@ -249,33 +232,6 @@ def _tools(conversations: Conversations, key: str) -> list[SdkMcpTool[Any]]:
)
return _text(f"queued #{item.id}")
@tool(
"send_file",
SEND_FILE_HELP.format(note=conversations.file_note()),
{
"type": "object",
"properties": {
"path": {"type": "string", "description": "absolute path on disk"},
"caption": {"type": "string"},
"method": {"type": "string", "enum": [AUTO, *METHODS], "default": AUTO},
},
"required": ["path"],
},
)
async def send_file(args: dict[str, Any]) -> dict[str, Any]:
conv = await current()
try:
return _text(
await conversations.send_file(
conv,
str(args["path"]),
caption=str(args.get("caption") or ""),
method=str(args.get("method") or AUTO),
)
)
except SendFileError as exc:
return _error(str(exc))
@tool(
"close_chat",
"Close this deep chat once the current reply is finished: the "
@@ -292,7 +248,7 @@ def _tools(conversations: Conversations, key: str) -> list[SdkMcpTool[Any]]:
return _error(str(exc))
return _text("ok: the chat closes after this reply")
return [read_conversation, spawn, say, schedule, inject, send_file, close_chat]
return [read_conversation, spawn, say, schedule, inject, close_chat]
def _text(text: str) -> dict[str, Any]:
+19 -130
View File
@@ -3,12 +3,10 @@
from __future__ import annotations
import asyncio
import base64
import contextlib
import inspect
import logging
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
from uuid import uuid4
@@ -36,7 +34,7 @@ if TYPE_CHECKING:
from beaver_gateway.events.stream import MessageStreamEvent
__all__ = ["Turns", "content_of", "image_block"]
__all__ = ["Turns"]
_log = logging.getLogger(__name__)
@@ -54,15 +52,8 @@ class Turns(Seeds):
tools: bool = True,
turn_id: str | None = None,
item_origin: str | None = None,
shown: str | None = None,
attach: bool = False,
) -> AsyncIterator[MessageStreamEvent]:
"""Run one turn under the conversation's lock; the only path to the backend.
``shown`` is what frontends display as the turn's prompt (the user's
words, not the envelope). ``attach`` sends nothing: the turn is one
the live session started by itself, a subagent reporting back.
"""
"""Run one turn under the conversation's lock; the only path to the backend."""
row_id = cast("int", conv.id)
runner = self._runner(row_id)
backend = self._backend(conv.agent_name)
@@ -72,7 +63,7 @@ class Turns(Seeds):
async with runner.lock:
runner.turn_id = turn_id
runner.origin = origin
runner.text = shown if shown is not None else _prompt_preview(messages)
runner.text = _prompt_preview(messages)
runner.started_at = datetime.now(UTC)
runner.tools = {}
await self._mark_running(conv, turn_id)
@@ -88,28 +79,18 @@ class Turns(Seeds):
stop = "error"
cut = False
try:
observer = self._observer(conv, runner, turn_id, origin)
events = (
backend.attach(
key=conv.external_id,
capture=capture,
observer=observer,
turn_id=turn_id,
)
if attach
else backend.complete(
agent=self._claude_agent(conv.agent_name),
messages=messages,
conversation_id=conv.external_id,
session_id=resume if use_session else None,
reseed=not use_session,
capture=capture,
kind=conv.kind,
pinned=conv.kind == "master",
tools=tools,
observer=observer,
turn_id=turn_id,
)
events = backend.complete(
agent=self._claude_agent(conv.agent_name),
messages=messages,
conversation_id=conv.external_id,
session_id=resume if use_session else None,
reseed=not use_session,
capture=capture,
kind=conv.kind,
pinned=conv.kind == "master",
tools=tools,
observer=self._observer(conv, runner, turn_id, origin),
turn_id=turn_id,
)
async for event in events:
yield event
@@ -144,23 +125,18 @@ class Turns(Seeds):
tools: bool = True,
turn_id: str | None = None,
item_origin: str | None = None,
attachments: Sequence[dict[str, Any]] | None = None,
shown: str | None = None,
attach: bool = False,
) -> tuple[str, TurnCapture]:
capture = TurnCapture()
acc = StreamAccumulator()
agent = self._claude_agent(conv.agent_name)
async for event in self.turn(
conv,
messages=[{"role": "user", "content": content_of(text, attachments)}],
messages=[{"role": "user", "content": text}],
origin=origin,
capture=capture,
tools=tools,
turn_id=turn_id,
item_origin=item_origin,
shown=shown,
attach=attach,
):
acc.feed(event)
message = acc.finalize(model=agent.model)
@@ -181,47 +157,6 @@ class Turns(Seeds):
runner = self._runners.get(cast("int", conv.id))
return runner.origin if runner is not None and runner.turn_id else None
async def _woken(self, key: str, note: str) -> None:
"""The live session of ``key`` started a turn by itself; run it next."""
conv = await self.get(key)
if conv is None:
_log.warning("self-started turn on unknown conversation %s: %s", key, note)
return
_log.info("conversation %s woke by itself: %s", key, note)
self._ensure_worker(cast("int", conv.id))
async def _run_pending(self, conv: Conversation) -> None:
turn_id = f"turn_{uuid4().hex[:12]}"
note = self._backend(conv.agent_name).pending_note(conv.external_id) or ""
try:
text, _ = await self.run_text_turn(
conv,
note,
origin="task",
turn_id=turn_id,
item_origin="task",
shown=note,
attach=True,
)
except Exception: # noqa: BLE001
_log.exception(
"self-started turn %s on %s failed", turn_id, conv.external_id
)
return
if not text.strip():
return
self._bus.publish(
"reply",
conversation_id=conv.external_id,
turn_id=turn_id,
item=None,
item_origin="task",
source="task",
prompt=self._texts.task_prompt.format(note=note),
user_text=None,
text=text,
)
def _ensure_worker(self, row_id: int) -> None:
runner = self._runner(row_id)
runner.wake.set()
@@ -232,10 +167,6 @@ class Turns(Seeds):
async def _worker(self, row_id: int) -> None:
runner = self._runner(row_id)
while True:
conv = await self.get_row(row_id)
if conv is not None and self._has_pending(conv):
await self._run_pending(conv)
continue
items = await self._queue.pending(row_id)
batch, wait = self._pick(items)
if batch is None:
@@ -293,13 +224,7 @@ class Turns(Seeds):
prompt = f"{seed}\n\n{prompt}"
try:
text, capture = await self.run_text_turn(
conv,
prompt,
origin=origin,
turn_id=turn_id,
item_origin=head.origin,
attachments=head.attachments if origin == "user" else None,
shown=head.text if origin == "user" else None,
conv, prompt, origin=origin, turn_id=turn_id, item_origin=head.origin
)
except Exception: # noqa: BLE001
_log.exception("turn %s on %s failed", turn_id, conv.external_id)
@@ -321,12 +246,6 @@ class Turns(Seeds):
text=text,
)
def _has_pending(self, conv: Conversation) -> bool:
try:
return self._backend(conv.agent_name).has_pending(conv.external_id)
except LookupError:
return False
def _bundle(self, items: Sequence[InjectQueueItem]) -> str:
lines = [self._texts.bundle_header.format(since=iso(items[0].created_at))]
lines.extend(f"- [{i.origin}] {i.text}" for i in items)
@@ -336,11 +255,9 @@ class Turns(Seeds):
if self._envelope is None:
return None
if conv.kind == "master":
return self._envelope.build(text=text, kind="master", agent=conv.agent_name)
return self._envelope.build(text=text, kind="master")
if conv.kind == "branch":
return self._envelope.recall_only(
text=text, kind="branch", agent=conv.agent_name
)
return self._envelope.recall_only(text=text, kind="branch")
return None
async def _note_user(self, conv: Conversation, text: str) -> None:
@@ -352,7 +269,6 @@ class Turns(Seeds):
title=conv.title,
text=text,
at=datetime.now(UTC),
agent=conv.agent_name,
)
try:
result = self._user_sink(message)
@@ -486,33 +402,6 @@ class Turns(Seeds):
await self._update(conv, apply)
IMAGE_MAX_BYTES = 5 * 1024 * 1024
def content_of(
text: str, attachments: Sequence[dict[str, Any]] | None
) -> str | list[dict[str, Any]]:
"""Plain text, or text plus the images that ride with it."""
blocks = [b for a in attachments or () if (b := image_block(a)) is not None]
if not blocks:
return text
return [{"type": "text", "text": text}, *blocks]
def image_block(attachment: dict[str, Any]) -> dict[str, Any] | None:
media_type = str(attachment.get("media_type") or "")
path = Path(str(attachment.get("path") or ""))
if not media_type.startswith("image/") or not path.is_file():
return None
if path.stat().st_size > IMAGE_MAX_BYTES:
return None
data = base64.b64encode(path.read_bytes()).decode("ascii")
return {
"type": "image",
"source": {"type": "base64", "media_type": media_type, "data": data},
}
def _prompt_preview(messages: Sequence[Any], limit: int = 400) -> str | None:
if not messages:
return None
+13 -228
View File
@@ -18,10 +18,6 @@ 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
@@ -35,7 +31,7 @@ from beaver_gateway.frontends.sse import (
)
from beaver_gateway.frontends.urls import frontend_url
from beaver_gateway.security import audit
from beaver_gateway.security.auth import hash_token
from beaver_gateway.security.auth import VALID_SCOPES, hash_token
from beaver_gateway.storage import (
create_token,
list_audit_records,
@@ -49,7 +45,6 @@ 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
@@ -75,10 +70,6 @@ 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):
@@ -94,14 +85,12 @@ 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:
@@ -116,22 +105,14 @@ 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, vault_root=self.vault_root
)
self._app = build_app(runtime, memory_root=self.memory_root)
def app(self) -> FastAPI | None:
return self._app
def build_app( # noqa: PLR0915
runtime: GatewayRuntime,
*,
memory_root: Path | None = None,
vault_root: Path | None = None,
) -> FastAPI:
def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> FastAPI: # noqa: PLR0915
app = FastAPI(title="beaver-gateway / API")
links = LinkIndex(vault_root) if vault_root is not None else None
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
@@ -217,7 +198,6 @@ def build_app( # noqa: PLR0915
k: fe.agent_for(k) for k in fe.kinds if fe.agent_for(k)
},
"path": fe.path,
"scope": fe.scope,
"url": frontend_url(request, runtime, fe),
}
for fe in runtime.frontends
@@ -238,49 +218,17 @@ def build_app( # noqa: PLR0915
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)
@@ -353,45 +301,6 @@ def build_app( # noqa: PLR0915
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)
@@ -738,51 +647,22 @@ def build_app( # noqa: PLR0915
await require_token(request, runtime, scope=SCOPE)
rows = await conversations.rate_limits(limit=200)
latest: dict[str, RateLimit] = {}
unified: dict[str, tuple[RateLimit, dict[str, Any]]] = {}
for row in rows:
latest.setdefault(row.window, row)
for name, info in _unified_windows(row).items():
unified.setdefault(name, (row, info))
now = datetime.now(UTC)
windows = []
for window in dict.fromkeys([*latest, *unified]):
row = latest.get(window)
seen = unified.get(window)
public = _limit_public(row) if row else _limit_blank(window, seen)
resets = row.resets_at if row else None
if seen and _newer_figure(seen, row):
carrier, info = seen
public["utilization"] = info.get("utilization")
public["reported_at"] = _iso(carrier.ts)
resets = _unix_dt(info.get("resetsAt")) or resets
public["resets_at"] = _iso(resets)
length = _window_length(window)
anchor = row if row is not None else (seen[0] if seen else None)
if length is not None and resets is not None:
since = _aware(resets) - length
else:
since = _aware(anchor.ts) if anchor else now
for window, row in latest.items():
length = WINDOWS.get(window)
since = (
_aware(row.resets_at) - length
if length is not None and row.resets_at is not None
else _aware(row.ts)
)
gateway = _sum_usage(await _usage_rows(runtime, since, now))
gateway["since"] = since.isoformat(timespec="seconds")
known = next(
(
r
for r in rows
if r.window == window
and r.utilization is not None
and _aware(r.ts) >= since
),
None,
)
windows.append(
{
**public,
"gateway": gateway,
"last_known": _limit_public(known) if known else None,
}
)
windows.append({**_limit_public(row), "gateway": gateway})
windows.sort(
key=lambda w: (_window_length(w["window"]) or timedelta.max, w["window"])
key=lambda w: (WINDOWS.get(w["window"], timedelta.max), w["window"])
)
return {"windows": windows, "history": [_limit_public(r) for r in rows[:50]]}
@@ -815,19 +695,6 @@ def build_app( # noqa: PLR0915
"content": content,
}
@app.get("/scopes")
async def scopes(request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=ADMIN_SCOPE)
by_frontend = {
fe.scope: fe.name or fe.path or "" for fe in runtime.frontends if fe.scope
}
return {
"scopes": [
{"scope": s, "frontend": by_frontend.get(s)}
for s in sorted(runtime.scopes)
]
}
@app.get("/tokens")
async def tokens(request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=ADMIN_SCOPE)
@@ -842,7 +709,7 @@ def build_app( # noqa: PLR0915
data = await body_of(request)
name = text_of(data, "name").strip()
scope = str(data.get("scope") or "*")
if scope not in runtime.scopes:
if scope not in VALID_SCOPES:
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"invalid scope {scope!r}")
plaintext = secrets.token_urlsafe(32)
async with runtime.db.session() as session:
@@ -1077,59 +944,6 @@ def _queue_item(item: InjectQueueItem | None) -> dict[str, Any] | None:
}
def _window_length(name: str) -> timedelta | None:
if name.startswith("five_hour"):
return timedelta(hours=5)
if "seven_day" in name:
return timedelta(days=7)
return WINDOWS.get(name)
def _unix_dt(value: Any) -> datetime | None:
if isinstance(value, int | float) and value > 0:
return datetime.fromtimestamp(value, tz=UTC)
return None
def _unified_windows(row: RateLimit) -> dict[str, dict[str, Any]]:
"""Every window the event carried a figure for, not just the one it was about."""
raw = row.raw if isinstance(row.raw, dict) else {}
found = raw.get("unifiedWindows")
if not isinstance(found, dict):
return {}
return {
str(name): info
for name, info in found.items()
if isinstance(info, dict) and info.get("utilization") is not None
}
def _newer_figure(
seen: tuple[RateLimit, dict[str, Any]], row: RateLimit | None
) -> bool:
if row is None or row.utilization is None:
return True
return seen[0].id != row.id and _aware(seen[0].ts) > _aware(row.ts)
def _limit_blank(
window: str, seen: tuple[RateLimit, dict[str, Any]] | None
) -> dict[str, Any]:
carrier = seen[0] if seen else None
return {
"id": None,
"ts": _iso(carrier.ts) if carrier else None,
"window": window,
"status": "allowed",
"utilization": None,
"resets_at": None,
"overage_status": None,
"overage_resets_at": None,
"agent": carrier.agent_name if carrier else None,
"session_id": carrier.session_id if carrier else None,
}
def _limit_public(row: RateLimit) -> dict[str, Any]:
return {
"id": row.id,
@@ -1179,35 +993,6 @@ 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 []
-31
View File
@@ -10,12 +10,8 @@ from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
from beaver_gateway.frontends.files import SendFileError
from beaver_gateway.security.auth import BUILTIN_SCOPES
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable, Mapping, Sequence
from pathlib import Path
from starlette.types import ASGIApp
@@ -58,10 +54,6 @@ class GatewayRuntime:
scheduler: Any = None
public_url: str | None = None
"""``Gateway.public_url``; ``None`` derives the origin from the request."""
scopes: frozenset[str] = BUILTIN_SCOPES
"""Every token scope this gateway knows: the builtins plus each frontend's."""
send_file_roots: tuple[Path, ...] = ()
"""Directories ``send_file`` may read, from ``SEND_FILE_ROOTS``."""
class Frontend(ABC):
@@ -72,10 +64,6 @@ class Frontend(ABC):
is for non-HTTP work (polling, vault mirrors) and defaults to nothing.
``landing`` marks the app that ``/`` redirects to.
``scope`` is the token scope this frontend's routes are gated by; a
frontend that names its own scope gets keys nobody else's token opens,
and ``POST /api/tokens`` will mint them (see ``security/auth.py``).
A frontend that shows conversations declares ``name`` (the binding
key) and ``kinds`` (which conversation kinds it shows). The first
frontend whose ``materialize`` returns a binding is the *home* of
@@ -88,9 +76,6 @@ class Frontend(ABC):
kinds: tuple[Kind, ...] = ()
path: str | None = None
landing: bool = False
scope: str | None = None
sends_files: bool = False
"""Whether ``send_file`` reaches a human through this frontend."""
@abstractmethod
def configure(self, runtime: GatewayRuntime) -> None: ...
@@ -110,19 +95,3 @@ class Frontend(ABC):
async def mark_closed(self, conv: Conversation) -> bool: # noqa: ARG002
"""Show in the window that the conversation is over (a renamed topic)."""
return False
async def send_file(
self,
conv: Conversation, # noqa: ARG002
path: str, # noqa: ARG002
*,
caption: str = "", # noqa: ARG002
method: str = "auto", # noqa: ARG002
) -> str:
"""Put a file in this conversation's window; the answer goes to the model."""
msg = f"frontend {self.name!r} cannot send files"
raise SendFileError(msg)
def file_note(self) -> str:
"""Roots and ceiling, for the tool description the model reads."""
return ""
-95
View File
@@ -1,95 +0,0 @@
"""What an agent may hand to a human: an allowed path, a ceiling, a method.
The ceiling and the method names belong to the frontend's API; the check
lives here so every frontend refuses the same way, in words the model reads.
"""
from __future__ import annotations
import mimetypes
from dataclasses import dataclass, replace
from pathlib import Path
__all__ = ["AUTO", "DEFAULTS", "METHODS", "SendFileError", "SendFiles", "method_for"]
AUTO = "auto"
METHODS = ("document", "photo", "audio", "voice", "video", "video_note", "animation")
_BY_SUFFIX = {
".jpg": "photo",
".jpeg": "photo",
".png": "photo",
".webp": "photo",
".gif": "animation",
".mp3": "audio",
".flac": "audio",
".m4a": "audio",
".wav": "audio",
".aac": "audio",
".ogg": "voice",
".oga": "voice",
".opus": "voice",
".mp4": "video",
".m4v": "video",
".mov": "video",
}
_BY_TYPE = {"image": "photo", "audio": "audio", "video": "video"}
class SendFileError(Exception):
"""Why a file was not sent, worded for the model that asked."""
@dataclass(frozen=True, slots=True)
class SendFiles:
"""Which files a frontend accepts from an agent and how big they may be."""
roots: tuple[Path, ...] = ()
"""Directories a path must resolve inside; nothing outside is sendable."""
max_bytes: int = 50 * 1024 * 1024
"""Upload ceiling of the API behind the frontend; the Bot API gives 50 MB."""
def with_roots(self, *roots: Path) -> SendFiles:
return replace(self, roots=tuple(dict.fromkeys((*self.roots, *roots))))
@property
def note(self) -> str:
"""Roots and ceiling as one line for the tool description."""
where = ", ".join(str(r) for r in self.roots) or "none, so nothing is sendable"
return f"paths under {where}, up to {mb(self.max_bytes)} MB"
def resolve(self, raw: str) -> Path:
path = Path(raw.strip()).expanduser().resolve()
if not any(path.is_relative_to(r.expanduser().resolve()) for r in self.roots):
msg = f"{path} is outside the allowed roots ({self.note}); copy it there"
raise SendFileError(msg)
if not path.is_file():
msg = f"{path} is not a file"
raise SendFileError(msg)
size = path.stat().st_size
if size > self.max_bytes:
msg = (
f"{path.name} is {mb(size)} MB, over the {mb(self.max_bytes)} MB limit"
)
raise SendFileError(msg)
return path
DEFAULTS = SendFiles()
def method_for(path: Path, method: str = AUTO) -> str:
if method and method != AUTO:
if method not in METHODS:
msg = f"unknown method {method!r}; one of: {', '.join((AUTO, *METHODS))}"
raise SendFileError(msg)
return method
guessed = _BY_SUFFIX.get(path.suffix.lower())
if guessed:
return guessed
mime, _ = mimetypes.guess_type(path.name)
return _BY_TYPE.get(mime.split("/")[0], "document") if mime else "document"
def mb(size: int) -> str:
return f"{round(size / 1024 / 1024, 2):g}"
@@ -48,19 +48,14 @@ def render_assistant_text(text: str) -> str:
def render_assistant_message(message: Message) -> str:
"""Render an assistant ``Message`` into a turn block.
Tool-use blocks render to nothing; of the thinking blocks between two
texts only the first one reaches the file.
Tool-use blocks render to nothing; only text and thinking content reach
the file (see :mod:`.parser` for how the reverse strip works).
"""
parts: list[str] = ["### Assistant:", ""]
after_thinking = False
for block in message.content:
thinking = isinstance(block, ThinkingBlock)
if thinking and after_thinking:
continue
lines = list(_render_block(block))
if not lines:
continue
after_thinking = thinking
parts.extend(lines)
parts.append("")
return "\n".join(parts).rstrip() + "\n"
@@ -47,17 +47,12 @@ class Draft:
self._interval = interval
self.status = status
self.text = ""
self._paused = False
self._dirty = True
self._broken = False
self._last_sent = 0.0
self._task: asyncio.Task[None] | None = None
self._inflight: asyncio.Future[None] | None = None
@property
def draft_id(self) -> int:
return self._draft_id
def start(self) -> None:
if self._task is None:
self._task = asyncio.create_task(self._run())
@@ -66,22 +61,11 @@ class Draft:
if status != self.status:
self.status = status
self._dirty = True
self._paused = bool(self.text.strip())
def append(self, text: str) -> None:
"""Add streamed text; a tool call in between starts a new paragraph.
The model ends a sentence, calls a tool, then goes on without a
break - the same text the final message shows as paragraphs.
"""
if not text:
return
if self._paused and not self.text.endswith("\n"):
self.text = self.text.rstrip() + "\n\n" + text.lstrip()
else:
if text:
self.text += text
self._paused = False
self._dirty = True
self._dirty = True
async def stop(self) -> None:
"""Stop pushing and wait for the push in flight.
@@ -142,7 +126,6 @@ class Draft:
message_thread_id=self._thread_id,
text=text,
parse_mode=parse_mode,
can_stop=True,
)
def _render(self) -> tuple[str, str]:
+65 -322
View File
@@ -21,13 +21,10 @@ from zoneinfo import ZoneInfo
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.client.session.aiohttp import AiohttpSession
from aiogram.client.telegram import TelegramAPIServer
from aiogram.exceptions import TelegramAPIError, TelegramNetworkError
from aiogram.exceptions import TelegramAPIError
from aiogram.types import (
BotCommand,
CallbackQuery,
FSInputFile,
InlineKeyboardButton,
InlineKeyboardMarkup,
Message,
@@ -36,17 +33,10 @@ from aiogram.types import (
)
from beaver_gateway.frontends.base import Frontend
from beaver_gateway.frontends.files import (
DEFAULTS,
SendFileError,
SendFiles,
mb,
method_for,
)
from beaver_gateway.frontends.telegram.drafts import Draft
from beaver_gateway.frontends.telegram.inbox import Inbox
from beaver_gateway.frontends.telegram.outbox import Outbox
from beaver_gateway.frontends.telegram.render import chunks, status_label, to_html
from beaver_gateway.frontends.telegram.render import chunks, status_label
from beaver_gateway.frontends.telegram.texts import TelegramTexts
if TYPE_CHECKING:
@@ -63,8 +53,6 @@ _log = logging.getLogger("beaver_gateway.frontends.telegram")
FRONTEND = "telegram"
_DONE = "done"
_COMMANDS = ("merge", "new", "chat", "status", "help", "start")
_CAPTION = 1024
_NO_CAPTION = ("video_note",)
@dataclass(frozen=True, slots=True)
@@ -107,46 +95,10 @@ class _Ask:
picked: dict[int, list[str]] = field(default_factory=dict)
done: set[int] = field(default_factory=set)
def to_flags(self, question_id: str) -> dict[str, Any]:
return {
"id": question_id,
"conversation_id": self.conversation_id,
"chat_id": self.chat_id,
"thread_id": self.thread_id,
"questions": self.questions,
"messages": self.messages,
"picked": {str(k): v for k, v in self.picked.items()},
"done": sorted(self.done),
}
@classmethod
def from_flags(cls, data: dict[str, Any]) -> _Ask:
return cls(
conversation_id=str(data["conversation_id"]),
chat_id=int(data["chat_id"]),
thread_id=data.get("thread_id"),
questions=list(data.get("questions") or []),
messages=list(data.get("messages") or []),
picked={int(k): list(v) for k, v in (data.get("picked") or {}).items()},
done=set(data.get("done") or []),
)
@dataclass
class _Album:
message: Message
thread_id: int | None
is_master: bool
texts: list[str] = field(default_factory=list)
notes: list[str] = field(default_factory=list)
attachments: list[dict[str, Any]] = field(default_factory=list)
task: asyncio.Task[None] | None = None
class TelegramFrontend(Frontend):
name = FRONTEND
kinds = ("master", "branch")
sends_files = True
def __init__(
self,
@@ -157,15 +109,12 @@ class TelegramFrontend(Frontend):
branch_agent: str | None = None,
chat_id: int | None = None,
attachments: Attachments = EPHEMERAL,
send_files: SendFiles = DEFAULTS,
api_base_url: str | None = None,
draft_interval: float = 0.7,
master_topic: str = "🦫 General",
queued_reaction: str = "👀",
poll_timeout: int = 30,
outbox_backoff: float = 2.0,
texts: TelegramTexts | None = None,
album_delay: float = 1.0,
) -> None:
self._token = token
self.texts = texts or TelegramTexts()
@@ -174,8 +123,6 @@ class TelegramFrontend(Frontend):
self.master_agent = master_agent
self.branch_agent = branch_agent
self.attachments = attachments
self.send_files = send_files
self.api_base_url = api_base_url
self.master_topic = master_topic
self.draft_interval = draft_interval
self.queued_reaction = queued_reaction
@@ -189,9 +136,7 @@ class TelegramFrontend(Frontend):
self._master_window: str | None = None
self._topic_names: dict[int, str] = {}
self._drafts: dict[str, Draft] = {}
self.album_delay = album_delay
self._asks: dict[str, _Ask] = {}
self._albums: dict[str, _Album] = {}
self._reactions: dict[int, tuple[int, int]] = {}
self._tasks: set[asyncio.Task[None]] = set()
@@ -204,14 +149,7 @@ class TelegramFrontend(Frontend):
raise RuntimeError(msg)
self._runtime = runtime
if self._bot is None:
self._bot = Bot(
self._token,
session=self._session(),
default=DefaultBotProperties(parse_mode=None),
)
self.send_files = self.send_files.with_roots(
*runtime.send_file_roots, self.attachments.root
)
self._bot = Bot(self._token, default=DefaultBotProperties(parse_mode=None))
self._inbox = Inbox(
runtime.db, self._bot, handler=self._handle, poll_timeout=self.poll_timeout
)
@@ -219,16 +157,6 @@ class TelegramFrontend(Frontend):
runtime.db, self._bot, bus=runtime.bus, backoff=self.outbox_backoff
)
def _session(self) -> AiohttpSession | None:
"""A local Bot API server instead of api.telegram.org (2 GB uploads)."""
if not self.api_base_url:
return None
return AiohttpSession(
api=TelegramAPIServer.from_base(
self.api_base_url.rstrip("/"), is_local=True
)
)
async def serve(self) -> None:
me = await self.bot.get_me()
_log.info(
@@ -285,44 +213,6 @@ class TelegramFrontend(Frontend):
self._topic_names[target[1]] = f"{prefix}{name}"
return True
def file_note(self) -> str:
return self.send_files.note
async def send_file(
self, conv: Conversation, path: str, *, caption: str = "", method: str = "auto"
) -> str:
"""Upload the file into this conversation's topic, caption and all."""
target = await self._target_of(conv)
if target is None:
msg = f"conversation {conv.external_id} has no Telegram window"
raise SendFileError(msg)
file = self.send_files.resolve(path)
how = method_for(file, method)
rest = caption.strip()
payload: dict[str, Any] = {
"message_thread_id": target[1],
how: FSInputFile(file),
}
if how == "audio":
payload["title"] = file.stem
if rest and how not in _NO_CAPTION and len(rest) <= _CAPTION:
payload |= {"caption": to_html(rest), "parse_mode": "HTML"}
rest = ""
try:
await getattr(self.bot, f"send_{how}")(target[0], **payload)
except TelegramNetworkError as exc:
msg = f"Telegram did not answer while sending {file.name}: {exc}"
raise SendFileError(msg) from exc
except TelegramAPIError as exc:
msg = f"Telegram refused {file.name} as {how}: {exc}"
raise SendFileError(msg) from exc
except (OSError, TimeoutError) as exc:
msg = f"sending {file.name} failed: {exc}"
raise SendFileError(msg) from exc
if rest:
await self._deliver(conv, rest)
return f"sent {file.name} as {how}, {mb(file.stat().st_size)} MB"
@property
def bot(self) -> Bot:
if self._bot is None:
@@ -412,9 +302,7 @@ class TelegramFrontend(Frontend):
self._master_window = self._ext(topic.message_thread_id)
return self._master_window
async def _master(
self, text: str | None = None, attachments: list[dict[str, Any]] | None = None
) -> tuple[Conversation, bool]:
async def _master(self, text: str | None = None) -> tuple[Conversation, bool]:
"""The open master behind its window, spawning one when there is none.
``text`` rides with the seed of a fresh master; the second value says
@@ -436,17 +324,11 @@ class TelegramFrontend(Frontend):
text=text,
origin=FRONTEND,
binding=(FRONTEND, ext),
attachments=attachments,
)
return conv, text is not None
async def _branch(
self,
thread_id: int,
*,
title: str | None,
text: str | None,
attachments: list[dict[str, Any]] | None = None,
self, thread_id: int, *, title: str | None, text: str | None
) -> Conversation:
master, _ = await self._master()
return await self.conversations.spawn(
@@ -459,7 +341,6 @@ class TelegramFrontend(Frontend):
text=text,
origin=FRONTEND,
binding=(FRONTEND, self._ext(thread_id)),
attachments=attachments,
)
async def _handle(self, update: Update) -> None:
@@ -467,8 +348,6 @@ class TelegramFrontend(Frontend):
await self._on_message(update.message)
elif update.callback_query is not None:
await self._on_callback(update.callback_query)
elif update.stopped_message_generation is not None:
await self._on_stopped(update.stopped_message_generation.draft_id)
async def _on_message(self, message: Message) -> None:
created = message.forum_topic_created
@@ -503,79 +382,9 @@ class TelegramFrontend(Frontend):
)
return
text = (message.text or message.caption or "").strip()
note, attachment = await self._save_attachment(message)
if message.media_group_id:
self._collect_album(
message,
thread_id,
is_master=is_master,
text=text,
note=note,
attachment=attachment,
)
return
await self._dispatch(
message,
thread_id,
is_master=is_master,
text=text,
notes=[note] if note else [],
attachments=[attachment] if attachment else [],
)
def _collect_album(
self,
message: Message,
thread_id: int | None,
*,
is_master: bool,
text: str,
note: str | None,
attachment: dict[str, Any] | None,
) -> None:
"""An album arrives as separate messages; it becomes one turn."""
group = str(message.media_group_id)
album = self._albums.get(group)
if album is None:
album = _Album(message=message, thread_id=thread_id, is_master=is_master)
self._albums[group] = album
if text:
album.texts.append(text)
if note:
album.notes.append(note)
attachment = await self._save_attachment(message)
if attachment:
album.attachments.append(attachment)
if album.task is not None:
album.task.cancel()
album.task = asyncio.create_task(self._flush_album(group))
self._tasks.add(album.task)
album.task.add_done_callback(self._tasks.discard)
async def _flush_album(self, group: str) -> None:
await asyncio.sleep(self.album_delay)
album = self._albums.pop(group, None)
if album is None:
return
await self._dispatch(
album.message,
album.thread_id,
is_master=album.is_master,
text="\n\n".join(album.texts),
notes=album.notes,
attachments=album.attachments,
)
async def _dispatch(
self,
message: Message,
thread_id: int | None,
*,
is_master: bool,
text: str,
notes: list[str],
attachments: list[dict[str, Any]],
) -> None:
text = "\n\n".join(part for part in (text, *notes) if part).strip()
text = f"{text}\n\n{attachment}".strip()
if not text:
return
if message.text and message.text.startswith("/"):
@@ -585,17 +394,14 @@ class TelegramFrontend(Frontend):
await self._command(command, args.strip(), message, thread_id)
return
if is_master:
conv, consumed = await self._master(text, attachments or None)
conv, consumed = await self._master(text)
if consumed:
return
else:
topic = cast("int", thread_id)
conv = await self._live(topic)
conv = await self._live(thread_id)
if conv is None:
title = self._topic_names.get(topic) or self._title_from(text)
await self._branch(
topic, title=title, text=text, attachments=attachments or None
)
title = self._topic_names.get(thread_id) or self._title_from(text)
await self._branch(thread_id, title=title, text=text)
return
pending = self.conversations.pending_question(conv.external_id)
if pending is not None and self.conversations.answer(pending[0], text):
@@ -603,9 +409,7 @@ class TelegramFrontend(Frontend):
pending[0], self.texts.question_typed.format(text=text)
)
return
item = await self.conversations.post(
conv, text, origin=FRONTEND, attachments=attachments or None
)
item = await self.conversations.post(conv, text, origin=FRONTEND)
if conv.running_turn or conv.pending_question:
await self._react(message, item.id)
@@ -649,25 +453,55 @@ class TelegramFrontend(Frontend):
with contextlib.suppress(TelegramAPIError):
await self.bot.set_message_reaction(target[0], target[1], reaction=[])
async def _save_attachment(
self, message: Message
) -> tuple[str | None, dict[str, Any] | None]:
"""The note for the model and, for images, the file it gets to see inline."""
async def _save_attachment(self, message: Message) -> str | None:
file_id: str | None = None
name: str | None = None
kind = ""
size: int | None = None
texts = self.texts
if message.sticker:
return texts.attachment_sticker, None
return texts.attachment_sticker
if message.animation:
return texts.attachment_animation, None
file_id, kind, size, name, media_type = _media(message)
return texts.attachment_animation
if message.photo:
photo = message.photo[-1]
file_id, kind, size = photo.file_id, "photo", photo.file_size
name = f"{photo.file_unique_id}.jpg"
elif message.document:
doc = message.document
file_id, kind, size = doc.file_id, "document", doc.file_size
name = doc.file_name or f"{doc.file_unique_id}.bin"
elif message.voice:
file_id, kind, size = (
message.voice.file_id,
"voice",
message.voice.file_size,
)
name = f"{message.voice.file_unique_id}.ogg"
elif message.audio:
file_id, kind, size = (
message.audio.file_id,
"audio",
message.audio.file_size,
)
name = message.audio.file_name or f"{message.audio.file_unique_id}.mp3"
elif message.video:
file_id, kind, size = (
message.video.file_id,
"video",
message.video.file_size,
)
name = message.video.file_name or f"{message.video.file_unique_id}.mp4"
elif message.video_note:
note = message.video_note
file_id, kind, size = note.file_id, "video_note", note.file_size
name = f"{note.file_unique_id}.mp4"
if file_id is None or name is None:
return None, None
return None
kind = texts.attachment_kinds.get(kind, kind)
if size and size > 20 * 1024 * 1024:
return (
texts.attachment_too_big.format(
kind=kind, name=name, mb=size // 1024 // 1024
),
None,
return texts.attachment_too_big.format(
kind=kind, name=name, mb=size // 1024 // 1024
)
now = time.time()
folder = self.attachments.root / self.attachments.day(now)
@@ -681,20 +515,12 @@ class TelegramFrontend(Frontend):
path.chmod(0o644)
except (TelegramAPIError, OSError) as exc:
_log.warning("attachment download failed: %s", exc)
return texts.attachment_failed.format(kind=kind, name=name, error=exc), None
return texts.attachment_failed.format(kind=kind, name=name, error=exc)
shown = texts.attachment_size.format(kb=size // 1024) if size else ""
keep = self.attachments.keep_days
seen = (
{"path": str(path), "media_type": media_type}
if media_type and media_type.startswith("image/")
else None
)
if keep is None:
return texts.attachment.format(kind=kind, path=path, size=shown), seen
return (
texts.attachment_kept.format(kind=kind, path=path, size=shown, days=keep),
seen,
)
return texts.attachment.format(kind=kind, path=path, size=shown)
return texts.attachment_kept.format(kind=kind, path=path, size=shown, days=keep)
async def _sweep_loop(self, interval: float = 6 * 3600) -> None:
while True:
@@ -849,7 +675,7 @@ class TelegramFrontend(Frontend):
return
match kind:
case "turn.start":
if event.get("origin") in ("user", "task"):
if event.get("origin") == "user":
await self._open_draft(key, event, target)
case "stream":
self._feed_draft(key, event)
@@ -895,14 +721,6 @@ class TelegramFrontend(Frontend):
case "conversation.merged":
await self._deliver(conv, self.texts.merged, key=f"{key}:merged")
async def _on_stopped(self, draft_id: int) -> None:
key = next((k for k, d in self._drafts.items() if d.draft_id == draft_id), None)
if key is None:
return
conv = await self.conversations.get(key)
if conv is not None:
await self.conversations.interrupt(conv)
async def _on_delivery_failed(self, event: Event) -> None:
row = event.get("conversation_row")
_log.error(
@@ -1030,27 +848,6 @@ class TelegramFrontend(Frontend):
_log.exception("question %s could not be sent", question_id)
continue
ask.messages.append(sent.message_id)
await self._persist_ask(question_id, ask)
async def _persist_ask(self, question_id: str, ask: _Ask | None) -> None:
"""The open question lives in the conversation's flags across restarts."""
conv = await self.conversations.get(
ask.conversation_id if ask is not None else question_id
)
if conv is None:
return
await self.conversations.set_flags(
conv, {"ask": ask.to_flags(question_id) if ask is not None else None}
)
async def _restore_ask(self, question_id: str) -> _Ask | None:
for conv in await self.conversations.find(status="open", limit=500):
data = conv.flags.get("ask")
if isinstance(data, dict) and data.get("id") == question_id:
ask = _Ask.from_flags(data)
self._asks[question_id] = ask
return ask
return None
async def _on_callback(self, query: CallbackQuery) -> None:
if query.from_user.id != self.user_id or not query.data:
@@ -1059,7 +856,7 @@ class TelegramFrontend(Frontend):
if len(parts) != 4 or parts[0] != "q":
return
_, question_id, qi_raw, choice = parts
ask = self._asks.get(question_id) or await self._restore_ask(question_id)
ask = self._asks.get(question_id)
if ask is None or not qi_raw.isdigit():
await self._callback_reply(query, self.texts.question_closed)
await self._strip_keyboard(query)
@@ -1099,20 +896,11 @@ class TelegramFrontend(Frontend):
reply_markup=self._keyboard(question_id, qi, question, picked),
)
await self._callback_reply(query, None)
if len(ask.done) < len(ask.questions):
await self._persist_ask(question_id, ask)
return
answer = _answer_text(ask)
self._asks.pop(question_id, None)
conv = await self.conversations.get(ask.conversation_id)
if conv is not None:
await self.conversations.set_flags(conv, {"ask": None})
if self.conversations.answer(question_id, answer):
return
if conv is not None and conv.status == "open":
await self.conversations.post(conv, answer, origin=FRONTEND)
else:
await self._edit_asks(ask, self.texts.question_timeout)
if len(ask.done) == len(ask.questions):
answer = _answer_text(ask)
self._asks.pop(question_id, None)
if not self.conversations.answer(question_id, answer):
await self._edit_asks(ask, self.texts.question_timeout)
async def _strip_keyboard(self, query: CallbackQuery) -> None:
message = query.message if isinstance(query.message, Message) else None
@@ -1132,7 +920,6 @@ class TelegramFrontend(Frontend):
async def _close_ask(self, question_id: str, note: str) -> None:
ask = self._asks.pop(question_id, None)
if ask is not None:
await self._persist_ask(question_id, None)
await self._edit_asks(ask, note)
async def _edit_asks(self, ask: _Ask, note: str) -> None:
@@ -1148,50 +935,6 @@ class TelegramFrontend(Frontend):
)
def _media(
message: Message,
) -> tuple[str | None, str, int | None, str | None, str | None]:
"""``(file_id, kind, size, name, media_type)`` of the message's media, if any."""
if message.photo:
photo = message.photo[-1]
return (
photo.file_id,
"photo",
photo.file_size,
f"{photo.file_unique_id}.jpg",
"image/jpeg",
)
if message.document:
doc = message.document
name = doc.file_name or f"{doc.file_unique_id}.bin"
return doc.file_id, "document", doc.file_size, name, doc.mime_type
if message.voice:
v = message.voice
return v.file_id, "voice", v.file_size, f"{v.file_unique_id}.ogg", None
if message.audio:
a = message.audio
return (
a.file_id,
"audio",
a.file_size,
a.file_name or f"{a.file_unique_id}.mp3",
None,
)
if message.video:
v = message.video
return (
v.file_id,
"video",
v.file_size,
v.file_name or f"{v.file_unique_id}.mp4",
None,
)
if message.video_note:
n = message.video_note
return n.file_id, "video_note", n.file_size, f"{n.file_unique_id}.mp4", None
return None, "", None, None, None
def _question_html(question: dict[str, Any]) -> str:
header = html.escape(str(question.get("header") or "").strip())
body = html.escape(str(question.get("question") or "").strip())
@@ -31,7 +31,7 @@ __all__ = ["Inbox"]
_log = logging.getLogger("beaver_gateway.frontends.telegram.inbox")
ALLOWED_UPDATES = ("message", "callback_query", "stopped_message_generation")
ALLOWED_UPDATES = ("message", "callback_query")
def _unset(_value: object) -> None:
@@ -26,7 +26,6 @@ TOOL_LABELS: dict[str, str] = {
"mcp__gateway__say": "speaking",
"mcp__gateway__schedule": "setting a reminder",
"mcp__gateway__inject": "passing to another conversation",
"mcp__gateway__send_file": "sending a file",
}
+7 -16
View File
@@ -21,7 +21,7 @@ from argon2.exceptions import InvalidHashError, VerifyMismatchError
from beaver_gateway.storage import list_active_tokens, touch_token
if TYPE_CHECKING:
from collections.abc import Iterable, Mapping
from collections.abc import Mapping
from beaver_gateway.storage import Database
@@ -35,8 +35,8 @@ class TokenStoreError(ValueError):
"""Malformed ``BOOTSTRAP_TOKENS`` value or duplicate token."""
BUILTIN_SCOPES: frozenset[str] = frozenset({"*", "messages", "mcp", "admin", "api"})
"""The scopes the gateway's own frontends define.
VALID_SCOPES: frozenset[str] = frozenset({"*", "messages", "mcp", "admin", "api"})
"""The scopes a ``Token.scope`` may hold.
* ``*`` — wildcard, may use any frontend
* ``messages`` — Anthropic Messages frontend only
@@ -45,24 +45,16 @@ BUILTIN_SCOPES: frozenset[str] = frozenset({"*", "messages", "mcp", "admin", "ap
* ``admin`` — reserved for programmatic admin access; the AdminFrontend
itself authenticates via session cookies, not bearer tokens, so this
scope is unused today.
A setup's own frontend adds its scope on top; see :func:`scopes_with` and
``GatewayRuntime.scopes``, which is what ``POST /api/tokens`` validates.
"""
def scopes_with(extra: Iterable[str | None]) -> frozenset[str]:
"""The builtin scopes plus every frontend's own; ``None`` entries drop out."""
return BUILTIN_SCOPES | {s for s in extra if s}
@dataclass(frozen=True, slots=True)
class TokenIdentity:
"""What :meth:`TokenStore.verify` resolves to on success.
``token_id`` is the DB row id for persisted tokens, or ``None`` for
an env-bootstrap match (those have no DB row to touch). ``scope``
gates which frontend the token may hit (see :data:`BUILTIN_SCOPES`);
gates which frontend the token may hit (see :data:`VALID_SCOPES`);
bootstrap tokens implicitly get ``"*"``.
"""
@@ -75,8 +67,8 @@ class TokenIdentity:
``"*"`` is the wildcard; an exact match satisfies a single scope.
Unknown ``required`` values intentionally fall through to a
strict equality check — callers should pass a scope the runtime
knows (:data:`BUILTIN_SCOPES` plus every frontend's own).
strict equality check — callers should pass one of
:data:`VALID_SCOPES`.
"""
return self.scope in ("*", required)
@@ -333,12 +325,11 @@ class TokenStore:
__all__ = [
"BUILTIN_SCOPES",
"VALID_SCOPES",
"TokenIdentity",
"TokenStore",
"TokenStoreError",
"hash_token",
"scopes_with",
]
-4
View File
@@ -57,10 +57,6 @@ class Settings(BaseSettings):
"""``Accept-Language`` sent to the Raycast API and the default locale for
auto ``UserPreferences``. One value per gateway (one shared Client)."""
send_file_roots: str = "/mnt/hole/shared"
"""Directories the ``send_file`` tool may read, separated by ``:``. A
frontend always adds the inbox its own attachments land in."""
bootstrap_tokens: str = ""
"""Out-of-band token seed: ``name1:value1,name2:value2``. Layers
alongside DB-issued tokens; used for first-run setup and recovery."""
-5
View File
@@ -144,11 +144,6 @@ class InjectQueueItem(SQLModel, table=True):
priority: str = Field(index=True)
origin: str = Field(default="system")
text: str
attachments: list[dict[str, Any]] | None = Field(
default=None,
sa_column=Column(JSON().with_variant(JSONB(), "postgresql"), nullable=True),
)
"""Files that ride with a user message: ``{"path", "media_type"}`` each."""
status: str = Field(default="queued", index=True)
turn_id: str | None = Field(default=None)
interrupted_turn: bool | None = Field(default=None)
-136
View File
@@ -1,136 +0,0 @@
"""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}
+4 -113
View File
@@ -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, TranscriptEntry, Usage
from beaver_gateway.storage.models import RateLimit, Usage
TOKEN = "tok"
HEADERS = {"Authorization": f"Bearer {TOKEN}"}
@@ -247,7 +247,6 @@ async def test_usage_groups_by_agent_day_and_model(world: World) -> None:
async def test_limits_report_latest_window_with_gateway_spend(world: World) -> None:
api = Api(world)
now = datetime.now(UTC)
resets = int((now + timedelta(hours=4)).timestamp())
async with world.db.session() as session:
session.add(
RateLimit(
@@ -265,20 +264,7 @@ async def test_limits_report_latest_window_with_gateway_spend(world: World) -> N
resets_at=now + timedelta(hours=4),
)
)
session.add(
RateLimit(
window="seven_day",
status="allowed",
utilization=0.3,
raw={
"unifiedWindows": {
"five_hour": {"resetsAt": resets, "utilization": 0.72},
"seven_day": {"resetsAt": resets, "utilization": 0.25},
"seven_day_fable": {"resetsAt": resets, "utilization": 0.1},
}
},
)
)
session.add(RateLimit(window="seven_day", status="allowed", utilization=0.3))
await session.commit()
await seed_usage(
world,
@@ -294,19 +280,12 @@ async def test_limits_report_latest_window_with_gateway_spend(world: World) -> N
)
out = await api.get("/limits")
windows = {w["window"]: w for w in out["windows"]}
assert windows["five_hour"]["utilization"] == 0.72
assert windows["five_hour"]["reported_at"] is not None
assert windows["five_hour"]["utilization"] == 0.9
assert windows["five_hour"]["status"] == "allowed_warning"
assert windows["five_hour"]["gateway"]["output"] == 7
assert windows["five_hour"]["gateway"]["cost_usd"] == 0.25
assert windows["seven_day"]["utilization"] == 0.3
assert windows["seven_day_fable"]["utilization"] == 0.1
assert windows["seven_day_fable"]["status"] == "allowed"
assert [w["window"] for w in out["windows"]] == [
"five_hour",
"seven_day",
"seven_day_fable",
]
assert [w["window"] for w in out["windows"]] == ["five_hour", "seven_day"]
assert len(out["history"]) == 3
@@ -546,91 +525,3 @@ 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
-12
View File
@@ -86,7 +86,6 @@ class FakeClient:
self.prompts: list[str] = []
self.connected = False
self.session_id = options.resume or "fresh-session"
self.asked = asyncio.Event()
FakeClient.instances.append(self)
async def connect(self) -> None:
@@ -94,17 +93,6 @@ class FakeClient:
async def query(self, prompt: str) -> None:
self.prompts.append(prompt)
self.asked.set()
async def receive_messages(self):
served = 0
while True:
while len(self.prompts) <= served:
self.asked.clear()
await self.asked.wait()
served += 1
async for message in self.receive_response():
yield message
async def interrupt(self) -> None:
self.interrupted = True
-142
View File
@@ -1,142 +0,0 @@
import tempfile
from pathlib import Path
from beaver_gateway.agents.claude import ClaudeAgent, Prompts
from beaver_gateway.conversations.context import (
bash_paths,
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_bash_paths_follow_cd_and_quotes() -> None:
paths, here, writes = bash_paths(
"cd /vault/мета && cat 'заметка.md' | head; sed -n 1,5p \"📆 доски/работа.md\"",
Path("/elsewhere"),
)
assert paths == ["/vault/мета/заметка.md", "/vault/мета/📆 доски/работа.md"]
assert here == Path("/vault/мета")
assert writes is False
_, _, writes = bash_paths(
'python3 - <<PY\np = pathlib.Path("поправки.md")\np.write_text(s)\nPY',
Path("/vault"),
)
assert writes is True
assert bash_paths("ls *.md; grep -c x notes.md", None)[0] == ["notes.md"]
def test_files_touched_reads_shell_commands_with_a_sticky_cwd() -> None:
cwd = Path("/vault")
files, counts = files_touched(
[
entry("Bash", {"command": "cd /vault/мета && cat a.md"}, "1"),
entry("Bash", {"command": "python3 - <<PY\nopen('b.md','w')\nPY"}, "2"),
entry("Bash", {"command": "ls"}, "3"),
],
cwd=cwd,
)
assert [(f["path"], f["reads"], f["writes"]) for f in files] == [
("мета/b.md", 0, 1),
("мета/a.md", 1, 0),
]
assert counts == {"Bash": 3}
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}
]
+1 -109
View File
@@ -8,7 +8,6 @@ from zoneinfo import ZoneInfo
import pytest
from claude_agent_sdk import (
TaskNotificationMessage,
AssistantMessage,
InMemorySessionStore,
ResultMessage,
@@ -89,46 +88,16 @@ class ScriptedClient:
def __init__(self, options: Any) -> None:
self.options = options
self.prompts: list[str] = []
self.contents: list[list[dict[str, Any]]] = []
self.session_id = options.resume or str(uuid.uuid4())
self.interrupted = False
self.connected = False
self.asked = asyncio.Event()
self.extra: list[Any] = []
ScriptedClient.instances.append(self)
async def connect(self) -> None:
self.connected = True
async def query(self, prompt) -> None:
if not isinstance(prompt, str):
messages = [m async for m in prompt]
content = messages[-1]["message"]["content"]
self.contents.append(content)
prompt = "\n".join(
b.get("text", "") for b in content if b.get("type") == "text"
)
async def query(self, prompt: str) -> None:
self.prompts.append(prompt)
self.asked.set()
async def receive_messages(self):
"""One response per prompt, forever; ``extra`` is what the CLI sends
on its own between prompts (a subagent reporting back)."""
served = 0
while True:
while len(self.prompts) <= served:
self.asked.clear()
if self.extra:
for message in self.extra:
if isinstance(message, asyncio.Event):
await message.wait()
continue
yield message
self.extra = []
await self.asked.wait()
served += 1
async for message in self.receive_response():
yield message
async def receive_response(self):
prompt = self.prompts[-1]
@@ -881,80 +850,3 @@ async def test_inject_into_a_closed_master_lands_in_the_open_one(world: World) -
assert item.conversation_id == new.id
assert await world.statuses(old) == []
assert await world.statuses(new) == [("normal", "queued")]
def _self_started(session_id: str, text: str) -> list[Any]:
"""What the CLI sends by itself once a background subagent reports back."""
return [
AssistantMessage(
content=[ToolUseBlock(id="tu_dig", name="Grep", input={"pattern": "au"})],
model="m",
parent_tool_use_id="tu_bg",
),
TaskNotificationMessage(
subtype="task_notification",
data={},
task_id="t1",
status="completed",
output_file="",
summary='Agent "dig" finished',
uuid="n1",
session_id=session_id,
),
AssistantMessage(content=[TextBlock(text=text)], model="m"),
ResultMessage(
subtype="success",
duration_ms=1,
duration_api_ms=1,
is_error=False,
num_turns=1,
session_id=session_id,
stop_reason="end_turn",
total_cost_usd=0.0,
usage={"input_tokens": 1, "output_tokens": 1},
origin={"kind": "task-notification"},
),
]
async def test_subagent_report_between_turns_is_its_own_turn(world: World) -> None:
conv = await world.conversations.create(kind="master", agent="a", origin="test")
seen: list[dict[str, Any]] = []
async def collect() -> None:
async for event in world.bus.stream(conversation_id=conv.external_id):
seen.append(event)
task = asyncio.create_task(collect())
await world.conversations.post(conv, "go")
await world.settle(conv, 1)
client = ScriptedClient.instances[0]
client.extra = _self_started(client.session_id, "the agent found gold")
client.asked.set()
await asyncio.sleep(0.3)
task.cancel()
replies = [e for e in seen if e["type"] == "reply"]
assert [r["text"] for r in replies] == ["ok:go", "the agent found gold"]
assert replies[1]["item_origin"] == "task"
starts = [e for e in seen if e["type"] == "turn.start"]
assert [s["origin"] for s in starts] == ["user", "task"]
assert starts[1]["text"] == 'Agent "dig" finished (completed)'
assert len(client.prompts) == 1
first_end = next(i for i, e in enumerate(seen) if e["type"] == "turn.end")
later = [e for e in seen[first_end + 1 :] if e["type"] in ("stream", "tool")]
assert later and all(e["turn_id"] == starts[0]["turn_id"] for e in later[:1])
assert all(e["turn_id"] == starts[1]["turn_id"] for e in later[1:])
assert (await world.conversations.get(conv.external_id)).running_turn is None
async def test_subagent_result_inside_a_turn_does_not_end_it(world: World) -> None:
conv = await world.conversations.create(kind="master", agent="a", origin="test")
await world.conversations.post(conv, "one")
await world.settle(conv, 1)
client = ScriptedClient.instances[0]
client.extra = _self_started(client.session_id, "late report")
await world.conversations.post(conv, "two")
await world.settle(conv, 2)
assert client.prompts[-1].startswith("two")
rows = await world.conversations.queue.recent(conv.id)
assert [r.status for r in rows] == ["done", "done"]
-22
View File
@@ -219,25 +219,3 @@ async def test_failing_recall_or_sink_never_blocks_the_turn(world: World) -> Non
assert prompt.startswith("hello\n\n" + HEADER)
assert root.exists()
await asyncio.sleep(0)
async def test_the_recall_hook_and_the_sink_see_whose_turn_it_is(world: World) -> None:
root, watch = vault()
seen: list[RecallContext] = []
def recall(ctx: RecallContext) -> str | None:
seen.append(ctx)
return None if ctx.agent == "a" else "👤 указатель"
noted: list[UserSaid] = []
world.conversations._envelope = Envelope(watch=watch, tz="UTC", recall=recall) # noqa: SLF001
world.conversations._user_sink = noted.append # noqa: SLF001
master = await world.conversations.create(kind="master", agent="a", origin="test")
append(root / "люди" / "Прохор.md", "новое\n")
watch.note(root / "люди" / "Прохор.md")
await world.conversations.post(master, "что там у Прохор")
await world.settle(master, 1)
assert [ctx.agent for ctx in seen] == ["a"]
assert [m.agent for m in noted] == ["a"]
assert "👤" not in ScriptedClient.instances[0].prompts[0]
await asyncio.sleep(0)
-17
View File
@@ -39,23 +39,6 @@ def test_thinking_renders_as_collapsed_callout() -> None:
)
def test_thinking_between_hidden_tool_calls_collapses_to_the_first() -> None:
message = _message(
ThinkingBlock(type="thinking", thinking="one", signature="s"),
ToolUseBlock(type="tool_use", id="t1", name="Read", input={}),
ThinkingBlock(type="thinking", thinking="two", signature="s"),
ToolUseBlock(type="tool_use", id="t2", name="Read", input={}),
ThinkingBlock(type="thinking", thinking="three", signature="s"),
TextBlock(type="text", text="answer"),
ThinkingBlock(type="thinking", thinking="four", signature="s"),
TextBlock(type="text", text="more"),
)
assert renderer.render_assistant_message(message) == (
"### Assistant:\n\n> [!thinking]-\n> one\n\nanswer\n\n"
"> [!thinking]-\n> four\n\nmore\n"
)
def test_user_scaffold_matches_rendered_user_turn_shape() -> None:
body = renderer.append_to_body("### Assistant:\n\nhi\n", renderer.USER_SCAFFOLD)
assert body.endswith("\n\n---\n\n### User:\n\n")
-133
View File
@@ -1,133 +0,0 @@
import tempfile
from pathlib import Path
import httpx
import pytest
from fastapi import FastAPI, Request
from beaver_gateway.app import AgentRegistry, McpRegistry
from beaver_gateway.frontends.api.frontend import build_app as build_api
from beaver_gateway.frontends.base import Frontend, GatewayRuntime
from beaver_gateway.frontends.bearer import require_token
from beaver_gateway.security.auth import BUILTIN_SCOPES, TokenStore, scopes_with
from beaver_gateway.storage import Database
ROOT = {"Authorization": "Bearer root-key"}
VOICE = {"Authorization": "Bearer voice-key"}
OPS = {"Authorization": "Bearer ops-key"}
class Speaker(Frontend):
"""A frontend a setup declares, gated by a scope of its own."""
name = "voice"
path = "/voice"
scope = "voice"
def configure(self, runtime: GatewayRuntime) -> None:
self.runtime = runtime
def app(self) -> FastAPI:
app = FastAPI()
@app.post("/")
async def ask(request: Request) -> dict[str, str]:
return {"by": await require_token(request, self.runtime, scope=self.scope)}
return app
@pytest.fixture
async def runtime() -> GatewayRuntime:
root = Path(tempfile.mkdtemp(prefix="beaver-scopes-"))
db = Database(f"sqlite:///{root / 's.db'}")
await db.create_all()
speaker = Speaker()
rt = GatewayRuntime(
agents=AgentRegistry([]),
mcps=McpRegistry([]),
backends={},
token_store=TokenStore(
db,
bootstrap={"root": "root-key", "voice": "voice-key", "ops": "ops-key"},
bootstrap_scopes={"voice": "voice", "ops": "api"},
),
db=db,
frontends=(speaker,),
scopes=scopes_with(fe.scope for fe in (speaker,)),
)
speaker.configure(rt)
yield rt
await db.dispose()
def api(runtime: GatewayRuntime) -> httpx.AsyncClient:
return httpx.AsyncClient(
transport=httpx.ASGITransport(app=build_api(runtime)), base_url="http://t"
)
def frontend(runtime: GatewayRuntime) -> httpx.AsyncClient:
fe = runtime.frontends[0]
return httpx.AsyncClient(
transport=httpx.ASGITransport(app=fe.app()), base_url="http://t"
)
def test_a_frontend_scope_joins_the_builtin_vocabulary() -> None:
assert "voice" not in BUILTIN_SCOPES
assert scopes_with(["voice", None, "api"]) == BUILTIN_SCOPES | {"voice"}
async def test_the_scope_of_a_frontend_is_listed_with_its_name(
runtime: GatewayRuntime,
) -> None:
async with api(runtime) as client:
listed = await client.get("/scopes", headers=ROOT)
scopes = listed.json()["scopes"]
assert {"scope": "voice", "frontend": "voice"} in scopes
assert {"scope": "api", "frontend": None} in scopes
async def test_tokens_mint_for_a_declared_scope_and_not_for_an_unknown_one(
runtime: GatewayRuntime,
) -> None:
async with api(runtime) as client:
minted = await client.post(
"/tokens", json={"name": "esp32-hall", "scope": "voice"}, headers=ROOT
)
unknown = await client.post(
"/tokens", json={"name": "nope", "scope": "kitchen"}, headers=ROOT
)
assert minted.status_code == 201
assert minted.json()["token"]["scope"] == "voice"
assert unknown.status_code == 400
assert "kitchen" in unknown.json()["error"]
async def test_the_route_opens_for_its_own_scope_only(runtime: GatewayRuntime) -> None:
async with frontend(runtime) as client:
own = await client.post("/", headers=VOICE)
other = await client.post("/", headers=OPS)
wildcard = await client.post("/", headers=ROOT)
anonymous = await client.post("/")
assert own.status_code == 200
assert own.json()["by"] == "voice"
assert other.status_code == 403
assert wildcard.status_code == 200
assert anonymous.status_code == 401
async def test_a_minted_token_reaches_the_frontend_it_was_minted_for(
runtime: GatewayRuntime,
) -> None:
async with api(runtime) as client:
plaintext = (
await client.post(
"/tokens", json={"name": "esp32-hall", "scope": "voice"}, headers=ROOT
)
).json()["plaintext"]
async with frontend(runtime) as client:
opened = await client.post("/", headers={"X-Api-Key": plaintext})
assert opened.status_code == 200
assert opened.json()["by"] == "esp32-hall"
+1 -276
View File
@@ -1,11 +1,8 @@
import asyncio
import base64
import contextlib
import functools
import tempfile
from pathlib import Path
from types import SimpleNamespace
from typing import Any, cast
from typing import Any
import pytest
from aiogram.exceptions import TelegramBadRequest, TelegramNetworkError
@@ -15,11 +12,8 @@ from claude_agent_sdk import PermissionResultAllow, PermissionResultDeny
from beaver_gateway.app import McpRegistry
from beaver_gateway.backends.transcript import build_entries
from beaver_gateway.conversations.tools import _tools
from beaver_gateway.frontends.base import GatewayRuntime
from beaver_gateway.frontends.files import SendFileError, SendFiles, method_for
from beaver_gateway.frontends.telegram import Attachments, TelegramFrontend
from beaver_gateway.frontends.telegram.drafts import Draft
from beaver_gateway.frontends.telegram.render import (
LIMIT,
chunks,
@@ -39,8 +33,6 @@ class FakeBot:
def __init__(self) -> None:
self.updates: list[dict[str, Any]] = []
self.sent: list[dict[str, Any]] = []
self.files: list[dict[str, Any]] = []
self.reject_files = False
self.drafts: list[dict[str, Any]] = []
self.edits: list[dict[str, Any]] = []
self.topics: list[str] = []
@@ -137,21 +129,6 @@ class FakeBot:
async def download(self, file_id, destination=None) -> None:
Path(destination).write_bytes(b"data")
async def _upload(self, method: str, chat_id: int, **kwargs: Any) -> Any:
if self.reject_files:
raise TelegramBadRequest(
method=SendMessage(chat_id=0, text="x"),
message="Bad Request: PHOTO_INVALID_DIMENSIONS",
)
self._message_id += 1
self.files.append({"method": method, "chat_id": chat_id, **kwargs})
return SimpleNamespace(message_id=self._message_id)
def __getattr__(self, name: str) -> Any:
if name.startswith("send_"):
return functools.partial(self._upload, name.removeprefix("send_"))
raise AttributeError(name)
# helpers for tests
def push(self, payload: dict[str, Any]) -> None:
payload["update_id"] = 1000 + len(self.updates)
@@ -740,22 +717,6 @@ def test_chunks_split_blockquote_and_stay_under_limit() -> None:
assert " ".join(parts).split() == long_line.split()
def test_draft_breaks_a_paragraph_after_a_tool_call() -> None:
draft = Draft(cast("Any", None), chat_id=1, thread_id=None, turn_id="t")
draft.append("Checking the balance.")
draft.set_status("⏳ Bash")
draft.set_status("✍️ writing")
draft.append("It is 20 378.")
assert draft.text == "Checking the balance.\n\nIt is 20 378."
draft.set_status("⏳ Read")
draft.append("\n\nAnd a list:")
assert draft.text.endswith("It is 20 378.\n\nAnd a list:")
fresh = Draft(cast("Any", None), chat_id=1, thread_id=None, turn_id="u")
fresh.set_status("⏳ Bash")
fresh.append("First words")
assert fresh.text == "First words"
def test_to_html_tail_inside_fence() -> None:
full = (
"intro\n```py\n" + "\n".join(f"code {i} <" for i in range(200)) + "\n```\nafter"
@@ -958,239 +919,3 @@ async def test_gone_topic_unbinds_and_the_next_message_starts_afresh(
frontend="telegram", external_id=f"{USER}/7"
)
assert fresh is not None and fresh.id != branch.id and fresh.status == "open"
async def test_photo_reaches_the_model_as_an_image_block(stack: Stack) -> None:
stack.bot.media(_photo("что на фото"))
await stack.until(lambda: stack.sent_with("что на фото"), what="reply")
client = next(c for c in ScriptedClient.instances if c.contents)
content = client.contents[-1]
assert content[0]["type"] == "text" and "что на фото" in content[0]["text"]
image = content[1]
assert image["type"] == "image"
assert image["source"]["media_type"] == "image/jpeg"
assert base64.b64decode(image["source"]["data"]) == b"data"
async def test_album_becomes_one_turn(stack: Stack) -> None:
stack.tg.album_delay = 0.2
stack.bot.media({**_photo("альбом"), "media_group_id": "g1"})
second = _photo("")
second["photo"][0].update(file_id="f2", file_unique_id="u2")
del second["caption"]
stack.bot.media({**second, "media_group_id": "g1"})
await stack.until(lambda: stack.sent_with("альбом"), what="reply")
await asyncio.sleep(0.3)
prompts = [
p for c in ScriptedClient.instances for p in c.prompts if "attachment" in p
]
assert len(prompts) == 1
assert prompts[0].count("[attachment: photo") == 2
client = next(c for c in ScriptedClient.instances if c.contents)
assert [b["type"] for b in client.contents[-1]] == ["text", "image", "image"]
async def test_stop_button_interrupts_the_turn(stack: Stack) -> None:
stack.bot.message("hi")
await stack.until(lambda: stack.sent_with("hi"), what="reply")
ScriptedClient.hold = asyncio.Event()
stack.bot.message("думай долго")
await stack.until(lambda: stack.tg._drafts, what="draft") # noqa: SLF001
draft = next(iter(stack.tg._drafts.values())) # noqa: SLF001
await stack.until(
lambda: any(d.get("draft_id") == draft.draft_id for d in stack.bot.drafts),
what="draft pushed",
)
assert stack.bot.drafts[-1]["can_stop"] is True
stack.bot.push(
{
"stopped_message_generation": {
"chat": {"id": USER, "type": "private"},
"draft_id": draft.draft_id,
"message_thread_id": stack.bot.drafts[-1].get("message_thread_id"),
}
}
)
await stack.until(lambda: stack.sent_with("думай долго"), what="reply after stop")
master = await stack.world.conversations.find_bound(
frontend="telegram", external_id=GENERAL
)
items = await stack.world.conversations.queue.recent(master.id, limit=1)
assert items[0].status == "interrupted"
ScriptedClient.hold = None
async def test_question_survives_a_gateway_restart(stack: Stack) -> None:
stack.bot.message("hi")
await stack.until(lambda: stack.sent_with("hi"), what="reply")
master = await stack.world.conversations.find_bound(
frontend="telegram", external_id=GENERAL
)
payload = {
"questions": [
{
"header": "Цвет",
"question": "Какой цвет?",
"options": [{"label": "Синий"}, {"label": "Красный"}],
"multiSelect": False,
}
]
}
asking = asyncio.create_task(
stack.world.conversations.ask(master.external_id, payload)
)
question = await stack.until(
lambda: stack.sent_with("Какой цвет?"), what="question"
)
pending = stack.world.conversations.pending_question(master.external_id)
assert pending is not None
row = await stack.world.conversations.get(master.external_id)
assert row.flags["ask"]["id"] == pending[0]
asking.cancel()
with contextlib.suppress(asyncio.CancelledError):
await asking
stack.tg._asks.clear() # noqa: SLF001
stack.world.conversations._questions.clear() # noqa: SLF001
stack.bot.callback(f"q:{pending[0]}:0:1", question["message_id"])
await stack.until(lambda: stack.sent_with("ok:Красный"), what="answer as message")
row = await stack.world.conversations.get(master.external_id)
assert row.flags.get("ask") is None
def _sendable(name: str, body: bytes = b"x") -> tuple[Path, Path]:
root = Path(tempfile.mkdtemp(prefix="beaver-send-")).resolve()
file = root / name
file.write_bytes(body)
return root, file
def test_send_files_takes_only_paths_inside_its_roots() -> None:
root, note = _sendable("note.txt")
outside = Path(tempfile.mkdtemp(prefix="beaver-out-")).resolve()
(outside / "secret.txt").write_bytes(b"y")
(root / "escape.txt").symlink_to(outside / "secret.txt")
files = SendFiles(roots=(root,))
assert files.resolve(f"{root}/../{root.name}/./note.txt") == note
for bad in (
str(outside / "secret.txt"),
f"{root}/../{outside.name}/secret.txt",
str(root / "escape.txt"),
):
with pytest.raises(SendFileError, match="outside the allowed roots"):
files.resolve(bad)
with pytest.raises(SendFileError, match="is not a file"):
files.resolve(str(root / "gone.txt"))
with pytest.raises(SendFileError, match="outside the allowed roots"):
SendFiles().resolve(str(note))
def test_send_files_stops_a_file_over_the_limit() -> None:
root, big = _sendable("big.bin", b"0" * (2 * 1024 * 1024))
files = SendFiles(roots=(root,), max_bytes=1024 * 1024)
with pytest.raises(SendFileError, match="big.bin is 2 MB, over the 1 MB limit"):
files.resolve(str(big))
assert SendFiles(roots=(root,), max_bytes=4 * 1024 * 1024).resolve(str(big)) == big
assert files.note == f"paths under {root}, up to 1 MB"
def test_method_follows_the_extension_unless_it_is_given() -> None:
picked = {
name: method_for(Path(name))
for name in ("a.JPG", "b.gif", "c.mp3", "d.ogg", "e.mov", "f.zip", "g.svg")
}
assert picked == {
"a.JPG": "photo",
"b.gif": "animation",
"c.mp3": "audio",
"d.ogg": "voice",
"e.mov": "video",
"f.zip": "document",
"g.svg": "photo",
}
assert method_for(Path("a.jpg"), "document") == "document"
with pytest.raises(SendFileError, match="unknown method 'sticker'"):
method_for(Path("a.jpg"), "sticker")
async def _send(stack: Stack, conv, **args: Any) -> dict[str, Any]:
tool = next(
t
for t in _tools(stack.world.conversations, conv.external_id)
if t.name == "send_file"
)
return await tool.handler(args)
async def test_send_file_lands_in_the_topic_of_the_conversation(stack: Stack) -> None:
stack.bot.message("work", thread=11)
await stack.until(lambda: stack.sent_with("work"), what="branch reply")
branch = await stack.world.conversations.find_bound(
frontend="telegram", external_id=f"{USER}/11"
)
root, shot = _sendable("график.png", b"png")
stack.tg.send_files = SendFiles(roots=(root,))
result = await _send(stack, branch, path=str(shot), caption="**вот**")
assert result["content"][0]["text"].startswith("sent график.png as photo")
(sent,) = stack.bot.files
assert sent["method"] == "photo"
assert sent["message_thread_id"] == 11
assert sent["photo"].path == shot
assert sent["caption"] == "<b>вот</b>" and sent["parse_mode"] == "HTML"
async def test_long_caption_follows_the_file_as_a_message(stack: Stack) -> None:
stack.bot.message("hi")
await stack.until(lambda: stack.sent_with("hi"), what="reply")
master = await stack.world.conversations.find_bound(
frontend="telegram", external_id=GENERAL
)
root, doc = _sendable("отчёт.pdf")
stack.tg.send_files = SendFiles(roots=(root,))
await _send(stack, master, path=str(doc), caption="долго " * 300)
(sent,) = stack.bot.files
assert sent["method"] == "document" and "caption" not in sent
await stack.until(lambda: stack.sent_with("долго"), what="caption message")
async def test_send_file_without_a_window_falls_back_to_the_master(
stack: Stack,
) -> None:
stack.bot.message("hi")
await stack.until(lambda: stack.sent_with("hi"), what="reply")
job = await stack.world.conversations.spawn(
kind="job", agent="a", seed="brief", text="job", parent=None, origin="test"
)
root, song = _sendable("Пикник.mp3")
stack.tg.send_files = SendFiles(roots=(root,))
await _send(stack, job, path=str(song))
(sent,) = stack.bot.files
assert sent["method"] == "audio" and sent["title"] == "Пикник"
assert (sent["chat_id"], sent["message_thread_id"]) == (USER, 901)
async def test_a_refused_upload_comes_back_to_the_agent(stack: Stack) -> None:
stack.bot.message("hi")
await stack.until(lambda: stack.sent_with("hi"), what="reply")
master = await stack.world.conversations.find_bound(
frontend="telegram", external_id=GENERAL
)
root, shot = _sendable("bad.png")
stack.tg.send_files = SendFiles(roots=(root,))
stack.bot.reject_files = True
result = await _send(stack, master, path=str(shot))
assert result["is_error"]
assert "Telegram refused bad.png as photo" in result["content"][0]["text"]
outside = await _send(stack, master, path="/etc/passwd")
assert outside["is_error"]
assert "outside the allowed roots" in outside["content"][0]["text"]
-12
View File
@@ -5,7 +5,6 @@
"": {
"name": "ui",
"dependencies": {
"d3-force": "^3.0.0",
"dompurify": "^3.4.14",
"marked": "^18.0.11",
},
@@ -20,7 +19,6 @@
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.3.0",
"@types/d3-force": "^3.0.10",
"@types/node": "^26.4.0",
"bits-ui": "^2.19.0",
"clsx": "^2.1.1",
@@ -188,8 +186,6 @@
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
"@types/d3-force": ["@types/d3-force@3.0.10", "", {}, "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/node": ["@types/node@26.4.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ=="],
@@ -230,14 +226,6 @@
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="],
"d3-force": ["d3-force@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="],
"d3-quadtree": ["d3-quadtree@3.0.1", "", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="],
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
-2
View File
@@ -14,7 +14,6 @@
"preview": "vite preview"
},
"dependencies": {
"d3-force": "^3.0.0",
"dompurify": "^3.4.14",
"marked": "^18.0.11"
},
@@ -29,7 +28,6 @@
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.3.0",
"@types/d3-force": "^3.0.10",
"@types/node": "^26.4.0",
"bits-ui": "^2.19.0",
"clsx": "^2.1.1",
+12 -15
View File
@@ -10,21 +10,18 @@
</head>
<body data-sveltekit-preload-data="hover">
<!--
impeccable direction contract (seed 071a8c77, reroll 1 bolder, mode operate, user-steered fusion)
THESIS: the dispatcher's strip board under a live island. What is alive is a strip in a bay
(waiting for you, in motion, quiet); the island at the top of every screen carries the live
state and opens into the board. Refuses the sidebar-and-cards admin dashboard.
OWN-WORLD: plum signal on a warm rack ground; strips as white/aubergine objects with a state
edge (plum pulse = running, amber = waiting), fixed columns, hairline rules, tabular numerals,
system sans; amber only for what waits for the operator; no cards inside cards, no badges as
pills, no decorative motion.
STORY: the operator lands on the board, answers a question on its strip, watches a running
strip, opens a thread where only the thread lives, reads context as a counter, finds anything
with ⌘K, and reaches the text-heavy rooms (memory, usage, system) as wide documents.
FIRST VIEWPORT: top bar with four section words left, the island centered (pulse, questions,
context, quota), System right. Below: the instrument row (context / 5 h / 7 d / spend), then
the bays with strips, the living graph of the master's neighbourhood on the right.
FORM: strip board × live activity; user-steered over the dealt hand; seed key 071a8c77.
impeccable direction contract (seed 20df1617, mode operate, brief-pinned world)
THESIS: activity first. The console opens on what the agents are doing right now, as a live
ledger of turns and tool calls, and refuses the hero-metric dashboard grid of cards.
OWN-WORLD: msos mauve/pink tokens on Inter; two neutral layers (cooler sidebar, content surface);
one accent for selection and the live state; kind hues from the msos status palette; hairline
borders, tabular numerals, rows and rails instead of cards; no cards inside cards.
STORY: the operator lands, sees what runs and how much quota is left, opens a thread, watches
tools and subagents stream, answers a question, checks spend, manages tokens and memory.
FIRST VIEWPORT: sidebar left with the gateway connection dot; main opens with the "now" ledger
(running turns as live rows), then quota bars and the 5 h / 7 d spend row, then live sessions.
Primary action: open a running conversation.
FORM: dense operator console, brief-pinned (roll assigned index 7, superseded by the pin).
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review,
the verdict, and DESIGN.md
-->
+2 -32
View File
@@ -3,7 +3,6 @@ import type {
AgentsResponse,
AuditPage,
BusEvent,
ContextResponse,
ConversationInfo,
ConversationSummary,
EntriesPage,
@@ -12,13 +11,10 @@ import type {
LimitsResponse,
MemoryFile,
MemoryTree,
ScopeRow,
SearchResponse,
SessionsResponse,
TokenRow,
UsageGroup,
UsageResponse,
VaultGraph,
} from "./types";
const TRAILING_SLASHES = /\/+$/;
@@ -149,8 +145,7 @@ export class ApiClient {
async stream(
path: string,
onEvent: (event: BusEvent) => void,
signal: AbortSignal,
onActivity?: () => void
signal: AbortSignal
): Promise<void> {
const response = await fetch(this.url(path), {
headers: this.headers({ Accept: "text/event-stream" }),
@@ -164,7 +159,7 @@ export class ApiClient {
payload
);
}
await readSse(response, onEvent, signal, onActivity);
await readSse(response, onEvent, signal);
}
agents(): Promise<AgentsResponse> {
@@ -187,23 +182,6 @@ export class ApiClient {
return this.get(`/api/conversations/${id}/history`);
}
context(id: string, prompt = false): Promise<ContextResponse> {
return this.get(`/api/conversations/${id}/context`, {
prompt: prompt ? 1 : undefined,
});
}
vaultGraph(paths: string[], limit?: number): Promise<VaultGraph> {
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 }
@@ -305,10 +283,6 @@ export class ApiClient {
return this.post("/api/conversations", body);
}
search(q: string, limit?: number): Promise<SearchResponse> {
return this.get("/api/search", { limit, q });
}
sessions(): Promise<SessionsResponse> {
return this.get("/api/sessions");
}
@@ -352,10 +326,6 @@ export class ApiClient {
});
}
scopes(): Promise<{ scopes: ScopeRow[] }> {
return this.get("/api/scopes");
}
createToken(
name: string,
scope: string
+7 -101
View File
@@ -6,11 +6,6 @@ export type LiveState = "off" | "connecting" | "open" | "retrying" | "failed";
const BACKOFF_MIN_MS = 1000;
const BACKOFF_MAX_MS = 30_000;
const JITTER_MS = 250;
// The server sends a keepalive comment every 15s, so twice that of silence
// means the socket is dead even though nothing errored - which is exactly
// what a phone brings back from sleep: a stream that reads as open forever.
const STALE_AFTER_MS = 40_000;
const WATCH_EVERY_MS = 5000;
const FATAL = new Set([401, 403, 404]);
function sleep(ms: number, signal: AbortSignal): Promise<void> {
@@ -46,13 +41,9 @@ export class LiveStream {
state = $state<LiveState>("off");
detail = $state<string | null>(null);
private controller: AbortController | null = null;
private inner: AbortController | null = null;
private readonly client: () => ApiClient | null;
private readonly path: string;
private readonly options: LiveOptions;
private seen = 0;
private watcher: ReturnType<typeof setInterval> | null = null;
private wake: (() => void) | null = null;
constructor(
client: () => ApiClient | null,
@@ -74,7 +65,6 @@ export class LiveStream {
}
const controller = new AbortController();
this.controller = controller;
this.watch();
this.loop(controller.signal).catch((error: unknown) => {
this.state = "failed";
this.detail = describe(error);
@@ -82,108 +72,31 @@ export class LiveStream {
}
stop(): void {
this.unwatch();
this.controller?.abort();
this.controller = null;
this.inner = null;
this.state = "off";
}
/**
* Reconnect now: the operator tapped the dot, or the app came back to the
* foreground. A failed stream has no loop left to nudge, so it restarts.
*/
refresh(): void {
if (!this.controller || this.state === "failed") {
this.stop();
this.start();
return;
}
this.state = "connecting";
this.detail = null;
this.inner?.abort();
this.wake?.();
}
/**
* A stream that stopped delivering without erroring - the phone slept, the
* network changed under it - reads as open forever. Silence past the
* server's keepalive is the only tell, so the socket is dropped and the
* loop reconnects.
*/
private watch(): void {
if (this.watcher || typeof setInterval === "undefined") {
return;
}
this.watcher = setInterval(() => {
if (this.state !== "open" || Date.now() - this.seen < STALE_AFTER_MS) {
return;
}
this.state = "retrying";
this.detail = "stream went quiet";
this.inner?.abort();
}, WATCH_EVERY_MS);
}
private unwatch(): void {
if (this.watcher) {
clearInterval(this.watcher);
this.watcher = null;
}
}
private async loop(signal: AbortSignal): Promise<void> {
let delay = BACKOFF_MIN_MS;
while (!signal.aborted) {
this.state = delay === BACKOFF_MIN_MS ? "connecting" : "retrying";
// Each attempt gets its own controller, so the watchdog (and a manual
// refresh) can drop one socket without ending the loop.
const inner = new AbortController();
this.inner = inner;
const relay = () => inner.abort();
signal.addEventListener("abort", relay, { once: true });
// biome-ignore lint/performance/noAwaitInLoops: one connection at a time, by design
const outcome = await this.connectOnce(inner.signal, () => {
const outcome = await this.connectOnce(signal, () => {
delay = BACKOFF_MIN_MS;
});
signal.removeEventListener("abort", relay);
this.inner = null;
if (signal.aborted || outcome === "fatal") {
return;
}
// A socket dropped on purpose reconnects at once; a broken one waits.
const wait = outcome === "now" ? 0 : delay + Math.random() * JITTER_MS;
await this.pause(wait, signal);
delay =
outcome === "now"
? BACKOFF_MIN_MS
: Math.min(delay * 2, BACKOFF_MAX_MS);
await sleep(delay + Math.random() * JITTER_MS, signal);
delay = Math.min(delay * 2, BACKOFF_MAX_MS);
}
}
/** Backoff that a manual refresh can cut short. */
private pause(ms: number, signal: AbortSignal): Promise<void> {
if (ms <= 0) {
return Promise.resolve();
}
return new Promise((resolve) => {
const done = () => {
this.wake = null;
resolve();
};
this.wake = done;
sleep(ms, signal).then(() => {
if (this.wake === done) {
done();
}
}, done);
});
}
private async connectOnce(
signal: AbortSignal,
onOpen: () => void
): Promise<"retry" | "fatal" | "now"> {
): Promise<"retry" | "fatal"> {
try {
const client = this.client();
if (!client) {
@@ -191,9 +104,8 @@ export class LiveStream {
}
await this.options.prepare?.();
if (signal.aborted) {
return this.controller ? "now" : "fatal";
return "fatal";
}
this.seen = Date.now();
await client.stream(
this.path,
(event) => {
@@ -205,25 +117,19 @@ export class LiveStream {
}
this.options.onEvent(event);
},
signal,
() => {
this.seen = Date.now();
}
signal
);
this.state = "retrying";
this.detail = "stream closed";
return "retry";
} catch (error) {
if (signal.aborted) {
// Aborted while the session lives on: this was the watchdog or a
// manual refresh, so reconnect without waiting out the backoff.
return this.controller ? "now" : "fatal";
return "fatal";
}
if (error instanceof ApiError && FATAL.has(error.status)) {
this.state = "failed";
this.detail = `${error.status}: ${error.message}`;
this.controller = null;
this.unwatch();
return "fatal";
}
this.state = "retrying";
+1 -5
View File
@@ -38,10 +38,7 @@ function drain(buffer: string, onEvent: (event: BusEvent) => void): string {
export async function readSse(
response: Response,
onEvent: (event: BusEvent) => void,
signal?: AbortSignal,
// Every chunk the socket delivers, keepalive comments included: proof the
// connection is still alive on a phone that just came back from sleep.
onActivity?: () => void
signal?: AbortSignal
): Promise<void> {
const { body } = response;
if (!body) {
@@ -61,7 +58,6 @@ export async function readSse(
if (done) {
break;
}
onActivity?.();
buffer = drain(buffer + decoder.decode(value, { stream: true }), onEvent);
}
} finally {
-87
View File
@@ -54,7 +54,6 @@ export interface QueueItem {
export interface ConversationSummary {
agent: string;
context_tokens?: number;
created_at: string | null;
flags: Record<string, unknown>;
id: string;
@@ -63,7 +62,6 @@ 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;
@@ -106,8 +104,6 @@ export interface ContentBlock {
export interface HistoryMessage {
content: string | ContentBlock[];
role: "user" | "assistant";
// When the first block of this message was written; absent for old rows.
ts?: string | null;
}
export interface EntriesPage {
@@ -132,7 +128,6 @@ export interface FrontendInfo {
kinds: Kind[];
name: string;
path: string | null;
scope: string | null;
type: string;
url: string | null;
}
@@ -214,10 +209,6 @@ export interface LimitRecord {
export interface LimitWindow extends LimitRecord {
gateway: UsageTotals & { since: string };
// The last figure reported inside this window, if any: a lower bound.
last_known: LimitRecord | null;
// When the figure came from another event's unified report.
reported_at?: string | null;
}
export interface LimitsResponse {
@@ -246,11 +237,6 @@ export interface MemoryFile {
size: number;
}
export interface ScopeRow {
frontend: string | null;
scope: string;
}
export interface TokenRow {
created_at: string;
id: number;
@@ -324,76 +310,3 @@ 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<string, number>;
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;
}
+121
View File
@@ -0,0 +1,121 @@
<script lang="ts">
import LogOutIcon from "@lucide/svelte/icons/log-out";
import PanelLeftCloseIcon from "@lucide/svelte/icons/panel-left-close";
import PanelLeftOpenIcon from "@lucide/svelte/icons/panel-left-open";
import { goto } from "$app/navigation";
import { base } from "$app/paths";
import { page } from "$app/state";
import LiveDot from "$lib/components/live-dot.svelte";
import ThemeToggle from "$lib/components/theme-toggle.svelte";
import { Button } from "$lib/components/ui/button";
import { gateway } from "$lib/gateway.svelte";
import { isActive, NAV } from "$lib/nav";
import { session } from "$lib/session.svelte";
import { ui } from "$lib/ui.svelte";
import { cn } from "$lib/utils";
const running = $derived(gateway.running.length);
const open = $derived(ui.nav);
async function signOut() {
await session.logout();
await goto(`${base}/login`);
}
</script>
<aside
class={cn(
"hidden shrink-0 flex-col border-r bg-sidebar text-sidebar-foreground transition-[width] duration-150 sm:flex",
open ? "w-52" : "w-12"
)}
>
<a
class={cn(
"flex h-12 items-center gap-2 border-b font-semibold tracking-tight",
open ? "px-4" : "justify-center"
)}
href="{base}/"
title="Beaver"
>
<span class="size-2.5 shrink-0 rounded-sm bg-primary"></span>
{#if open}
Beaver
{/if}
</a>
<nav aria-label="Sections" class="flex flex-1 flex-col gap-0.5 p-2">
{#each NAV as item (item.href)}
{@const active = isActive(page.url.pathname, base, item.href)}
<a
aria-current={active ? "page" : undefined}
aria-label={item.label}
class={cn(
"relative flex h-8 items-center gap-2.5 rounded-md text-sm transition-colors",
open ? "px-2.5" : "justify-center",
active
? "bg-sidebar-accent font-medium text-sidebar-accent-foreground"
: "text-sidebar-foreground/80 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground"
)}
href="{base}{item.href}"
title={open ? undefined : item.label}
>
<item.icon class="size-4 shrink-0 text-icon" />
{#if open}
<span class="flex-1">{item.label}</span>
{/if}
{#if item.href === "/" && running > 0}
{#if open}
<span
class="tabular rounded-full bg-signal/12 px-1.5 font-medium text-signal text-xs"
>
{running}
</span>
{:else}
<span
class="absolute top-1 right-1 size-1.5 rounded-full bg-signal"
></span>
{/if}
{/if}
</a>
{/each}
</nav>
<div
class={cn(
"flex items-center gap-1 border-t",
open ? "px-3 py-2" : "flex-col px-1 py-2"
)}
>
<span
class={cn(open && "mr-auto")}
title="event stream from the gateway (SSE)"
>
<LiveDot
detail={gateway.live.detail}
label={open}
state={gateway.live.state}
/>
</span>
<ThemeToggle />
<Button
aria-label="Sign out"
onclick={signOut}
size="icon-sm"
title="Sign out"
variant="ghost"
>
<LogOutIcon class="size-4" />
</Button>
<Button
aria-label={open ? "Collapse sidebar" : "Expand sidebar"}
onclick={() => ui.toggleNav()}
size="icon-sm"
title="{open ? 'Collapse' : 'Expand'} sidebar (⌘B)"
variant="ghost"
>
{#if open}
<PanelLeftCloseIcon class="size-4" />
{:else}
<PanelLeftOpenIcon class="size-4" />
{/if}
</Button>
</div>
</aside>
+60 -23
View File
@@ -1,42 +1,79 @@
<script lang="ts">
import ActivityIcon from "@lucide/svelte/icons/activity";
import BrainIcon from "@lucide/svelte/icons/brain";
import GaugeIcon from "@lucide/svelte/icons/gauge";
import MessagesSquareIcon from "@lucide/svelte/icons/messages-square";
import SlidersHorizontalIcon from "@lucide/svelte/icons/sliders-horizontal";
import type { Component } from "svelte";
import MoreHorizontalIcon from "@lucide/svelte/icons/more-horizontal";
import { goto } from "$app/navigation";
import { base } from "$app/paths";
import { page } from "$app/state";
import { isSectionActive, SECTIONS, SYSTEM } from "$lib/nav";
import LiveDot from "$lib/components/live-dot.svelte";
import ThemeToggle from "$lib/components/theme-toggle.svelte";
import { Button } from "$lib/components/ui/button";
import * as Sheet from "$lib/components/ui/sheet";
import { gateway } from "$lib/gateway.svelte";
import { isActive, NAV } from "$lib/nav";
import { session } from "$lib/session.svelte";
import { cn } from "$lib/utils";
const ICONS: Record<string, Component<{ class?: string }>> = {
"/": ActivityIcon,
"/conversations": MessagesSquareIcon,
"/memory": BrainIcon,
"/system": SlidersHorizontalIcon,
"/usage": GaugeIcon,
};
const items = [...SECTIONS, SYSTEM];
let moreOpen = $state(false);
const primary = NAV.filter((item) => item.mobile);
const secondary = NAV.filter((item) => !item.mobile);
async function signOut() {
moreOpen = false;
await session.logout();
await goto(`${base}/login`);
}
</script>
<nav
aria-label="Sections"
class="flex shrink-0 items-stretch border-t bg-rack/90 pb-[env(safe-area-inset-bottom)] backdrop-blur-md sm:hidden"
class="flex shrink-0 items-stretch border-t bg-sidebar pb-[env(safe-area-inset-bottom)] sm:hidden"
>
{#each items as item (item.href)}
{@const active = isSectionActive(page.url.pathname, base, item.href)}
{@const Icon = ICONS[item.href]}
{#each primary as item (item.href)}
{@const active = isActive(page.url.pathname, base, item.href)}
<a
aria-current={active ? "page" : undefined}
class={cn(
"flex h-13 flex-1 flex-col items-center justify-center gap-0.5 text-[10px] transition-colors",
active ? "text-primary" : "text-muted-foreground"
"flex h-14 flex-1 flex-col items-center justify-center gap-1 text-[11px]",
active ? "text-link" : "text-muted-foreground"
)}
href="{base}{item.href}"
>
<Icon class="size-5" />
{item.label.split(" ")[0]}
<item.icon class="size-5" />
{item.label}
</a>
{/each}
<button
class="flex h-14 flex-1 flex-col items-center justify-center gap-1 text-[11px] text-muted-foreground"
onclick={() => {
moreOpen = true;
}}
type="button"
>
<MoreHorizontalIcon class="size-5" />
More
</button>
</nav>
<Sheet.Root bind:open={moreOpen}>
<Sheet.Content class="flex flex-col gap-1 pt-10" side="bottom">
<Sheet.Title class="px-2 pb-2">More</Sheet.Title>
{#each secondary as item (item.href)}
<a
class="flex h-11 items-center gap-3 rounded-md px-3 text-sm hover:bg-muted"
href="{base}{item.href}"
onclick={() => {
moreOpen = false;
}}
>
<item.icon class="size-4 text-icon" />
{item.label}
</a>
{/each}
<div class="mt-2 flex items-center justify-between border-t px-3 pt-3">
<LiveDot detail={gateway.live.detail} state={gateway.live.state} />
<div class="flex items-center gap-1">
<ThemeToggle />
<Button onclick={signOut} size="sm" variant="ghost">Sign out</Button>
</div>
</div>
</Sheet.Content>
</Sheet.Root>
@@ -0,0 +1,323 @@
<script lang="ts">
import PanelLeftCloseIcon from "@lucide/svelte/icons/panel-left-close";
import PlusIcon from "@lucide/svelte/icons/plus";
import { toast } from "svelte-sonner";
import { goto } from "$app/navigation";
import { base } from "$app/paths";
import type { AgentInfo, ConversationSummary } from "$lib/api/types";
import EmptyState from "$lib/components/empty-state.svelte";
import ErrorNote from "$lib/components/error-note.svelte";
import KindBadge from "$lib/components/kind-badge.svelte";
import StatusPill from "$lib/components/status-pill.svelte";
import { Button } from "$lib/components/ui/button";
import * as Dialog from "$lib/components/ui/dialog";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import * as Select from "$lib/components/ui/select";
import { Skeleton } from "$lib/components/ui/skeleton";
import { clip, fmtRelative, shortId } from "$lib/format";
import { gateway } from "$lib/gateway.svelte";
import { session } from "$lib/session.svelte";
import { ui } from "$lib/ui.svelte";
import { cn } from "$lib/utils";
let { selected = null }: { selected?: string | null } = $props();
const KINDS = ["all", "master", "branch", "deep", "job", "fork"];
const STATUSES = ["open", "all", "merged", "closed", "archived"];
const TITLE_MAX = 60;
const PREVIEW_MAX = 90;
let kind = $state("all");
let statusFilter = $state("open");
let search = $state("");
let createOpen = $state(false);
let agents = $state<AgentInfo[]>([]);
let form = $state({
agent: "",
kind: "deep",
seed: "clean",
text: "",
title: "",
});
let busy = $state(false);
const rows = $derived.by(() => {
const needle = search.trim().toLowerCase();
return gateway.conversations
.filter((row) => kind === "all" || row.kind === kind)
.filter((row) => statusFilter === "all" || row.status === statusFilter)
.filter(
(row) =>
!needle ||
(row.title ?? "").toLowerCase().includes(needle) ||
row.id.startsWith(needle) ||
row.agent.toLowerCase().includes(needle) ||
(row.last_item?.text ?? "").toLowerCase().includes(needle)
)
.sort(byActivity);
});
function activityOf(row: ConversationSummary): string {
return row.last_activity_at ?? row.created_at ?? "";
}
function byActivity(a: ConversationSummary, b: ConversationSummary) {
if (Boolean(a.running_turn) !== Boolean(b.running_turn)) {
return a.running_turn ? -1 : 1;
}
return activityOf(b).localeCompare(activityOf(a));
}
function titleOf(row: ConversationSummary): string {
if (row.title) {
return clip(row.title, TITLE_MAX);
}
if (row.kind === "master") {
return `Master · ${shortId(row.id)}`;
}
return `${row.kind} · ${shortId(row.id)}`;
}
const agentsForKind = $derived(
agents.filter((a) =>
a.kinds.includes(form.kind as AgentInfo["kinds"][number])
)
);
async function openCreate() {
createOpen = true;
if (agents.length === 0 && session.client) {
({ agents } = await session.client.agents());
}
}
async function create() {
if (!session.client) {
return;
}
busy = true;
try {
const created = await session.client.spawn({
agent: form.agent || undefined,
kind: form.kind,
seed: form.seed,
text: form.text || undefined,
title: form.title || undefined,
});
createOpen = false;
await goto(`${base}/conversations/${created.id}`);
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
busy = false;
}
}
</script>
<div class="flex h-full min-h-0 flex-col">
<div class="flex flex-wrap items-center gap-2 border-b px-3 py-2">
<Select.Root
onValueChange={(value) => {
kind = value;
}}
type="single"
value={kind}
>
<Select.Trigger class="h-8 text-xs" size="sm">
{kind === "all" ? "any kind" : kind}
</Select.Trigger>
<Select.Content>
{#each KINDS as option (option)}
<Select.Item
label={option === "all" ? "any kind" : option}
value={option}
/>
{/each}
</Select.Content>
</Select.Root>
<Select.Root
onValueChange={(value) => {
statusFilter = value;
}}
type="single"
value={statusFilter}
>
<Select.Trigger class="h-8 text-xs" size="sm">
{statusFilter === "all" ? "any status" : statusFilter}
</Select.Trigger>
<Select.Content>
{#each STATUSES as option (option)}
<Select.Item
label={option === "all" ? "any status" : option}
value={option}
/>
{/each}
</Select.Content>
</Select.Root>
<Input
aria-label="Search conversations"
class="h-8 min-w-24 flex-1 text-xs"
placeholder="search"
bind:value={search}
/>
<Button aria-label="New conversation" onclick={openCreate} size="icon-sm">
<PlusIcon class="size-4" />
</Button>
<Button
aria-label="Hide conversations"
class="hidden lg:inline-flex"
onclick={() => ui.toggleRail()}
size="icon-sm"
title="Hide conversations"
variant="ghost"
>
<PanelLeftCloseIcon class="size-4" />
</Button>
</div>
<div class="min-h-0 flex-1 overflow-y-auto">
{#if gateway.live.state === "failed"}
<div class="p-3">
<ErrorNote
message={gateway.live.detail ?? "event stream failed"}
retry={() => gateway.start()}
/>
</div>
{:else if !gateway.loaded}
<div class="flex flex-col gap-2 p-3">
<Skeleton class="h-12 w-full" />
<Skeleton class="h-12 w-full" />
<Skeleton class="h-12 w-full" />
</div>
{:else if rows.length === 0}
<div class="p-3">
<EmptyState
hint="Change the filters, or start one - a deep chat, a branch off the master, a headless job."
title="No conversations match"
>
<Button onclick={openCreate} size="sm" variant="outline">
New conversation
</Button>
</EmptyState>
</div>
{:else}
<ul class="flex flex-col">
{#each rows as row (row.id)}
<li>
<a
aria-current={selected === row.id ? "page" : undefined}
class={cn(
"row-hover flex flex-col gap-1 border-b px-3 py-2 text-sm",
selected === row.id && "bg-sidebar-accent"
)}
href="{base}/conversations/{row.id}"
>
<span class="flex items-center gap-2">
<KindBadge kind={row.kind} />
<span class="min-w-0 flex-1 truncate font-medium">
{titleOf(row)}
</span>
<span class="tabular shrink-0 text-muted-foreground text-xs">
{fmtRelative(activityOf(row))}
</span>
</span>
<span class="flex items-center gap-2 text-xs">
<StatusPill
status={row.running_turn ? "running" : row.status}
/>
<span class="text-muted-foreground">{row.agent}</span>
{#if row.pending_question}
<span class="font-medium text-link">question</span>
{/if}
</span>
{#if row.last_item}
<span class="truncate text-muted-foreground text-xs">
<span class="text-foreground/70"
>{row.last_item.origin}:</span
>
{clip(row.last_item.text, PREVIEW_MAX)}
</span>
{/if}
</a>
</li>
{/each}
</ul>
{/if}
</div>
</div>
<Dialog.Root bind:open={createOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>New conversation</Dialog.Title>
<Dialog.Description>
It opens in the home window of its kind (a vault file, a Telegram topic)
and stays silent until someone speaks.
</Dialog.Description>
</Dialog.Header>
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1.5">
<Label>Kind</Label>
<Select.Root
onValueChange={(value) => {
form.kind = value;
form.agent = "";
}}
type="single"
value={form.kind}
>
<Select.Trigger class="w-full">{form.kind}</Select.Trigger>
<Select.Content>
{#each ["deep", "branch", "master", "job"] as option (option)}
<Select.Item label={option} value={option} />
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="flex flex-col gap-1.5">
<Label>Agent</Label>
<Select.Root
onValueChange={(value) => {
form.agent = value === "default" ? "" : value;
}}
type="single"
value={form.agent || "default"}
>
<Select.Trigger class="w-full">
{form.agent || "frontend default"}
</Select.Trigger>
<Select.Content>
<Select.Item label="frontend default" value="default" />
{#each agentsForKind as agent (agent.name)}
<Select.Item label={agent.name} value={agent.name} />
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="col-span-2 flex flex-col gap-1.5">
<Label for="new-title">Title</Label>
<Input id="new-title" placeholder="optional" bind:value={form.title} />
</div>
<div class="col-span-2 flex flex-col gap-1.5">
<Label for="new-text">First message</Label>
<textarea
class="min-h-20 rounded-md border bg-input/30 px-3 py-2 text-sm focus-visible:border-ring focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/30"
id="new-text"
placeholder="optional - without it the window waits"
bind:value={form.text}
></textarea>
</div>
</div>
<Dialog.Footer>
<Button
onclick={() => {
createOpen = false;
}}
variant="ghost"
>
Cancel
</Button>
<Button disabled={busy} onclick={create}>Create</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
+1 -11
View File
@@ -117,17 +117,7 @@
};
default:
return {
endpoints: base
? [
{
hint: fe.scope
? `POST the frontend's own payload; bearer with scope ${fe.scope}`
: "",
label: "base",
url: base,
},
]
: [],
endpoints: base ? [{ hint: "", label: "base", url: base }] : [],
frontend: fe,
note: "",
};
+13 -5
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import type { Snippet } from "svelte";
import LiveDot from "$lib/components/live-dot.svelte";
import { gateway } from "$lib/gateway.svelte";
let {
title,
@@ -15,7 +17,7 @@
</script>
<header
class="flex min-h-12 flex-wrap items-center gap-x-4 gap-y-2 px-4 pt-4 pb-1 sm:px-6"
class="flex min-h-12 flex-wrap items-center gap-x-4 gap-y-2 border-b px-4 py-2 sm:px-6"
>
<div class="flex min-w-0 items-baseline gap-2">
<h1 class="truncate font-semibold text-lg tracking-tight">{title}</h1>
@@ -28,9 +30,15 @@
{@render children()}
</div>
{/if}
{#if actions}
<div class="ml-auto flex items-center gap-2">
<div class="ml-auto flex items-center gap-2">
{#if actions}
{@render actions()}
</div>
{/if}
{/if}
<LiveDot
class="sm:hidden"
detail={gateway.live.detail}
label={false}
state={gateway.live.state}
/>
</div>
</header>
+10 -8
View File
@@ -123,17 +123,19 @@ export function fmtRelative(
if (!date) {
return "–";
}
const diff = Math.max(0, now - date.getTime());
if (diff < MINUTE_MS) {
return "just now";
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";
}
if (diff < HOUR_MS) {
return `${Math.round(diff / MINUTE_MS)}m ago`;
if (abs < HOUR_MS) {
return `${Math.round(abs / MINUTE_MS)}m ${suffix}`;
}
if (diff < DAY_MS) {
return `${Math.round(diff / HOUR_MS)}h ago`;
if (abs < DAY_MS) {
return `${Math.round(abs / HOUR_MS)}h ${suffix}`;
}
return `${Math.round(diff / DAY_MS)}d ago`;
return `${Math.round(abs / DAY_MS)}d ${suffix}`;
}
export function fmtCountdown(iso: string | null | undefined, now = Date.now()) {
+2 -26
View File
@@ -1,18 +1,9 @@
import type {
BusEvent,
JobsResponse,
LimitsResponse,
LimitWindow,
} from "./api/types";
import { panelCache } from "./panel/cache";
import { browserHost } from "./panel/host";
import type { BusEvent, LimitsResponse, LimitWindow } from "./api/types";
import { ConversationIndex } from "./panel/index.svelte";
import { session } from "./session.svelte";
const TAPE_SIZE = 120;
const QUIET = new Set(["stream", "hello"]);
const JOBS_KEY = "jobs";
const cache = panelCache("beaver.admin");
// Gateway-wide live state for the admin: the conversation index, the last
// non-chatty events as a tape, and the quota windows updated the moment
@@ -20,20 +11,9 @@ const cache = panelCache("beaver.admin");
class Gateway extends ConversationIndex {
tape = $state<BusEvent[]>([]);
limits = $state<LimitsResponse | null>(null);
// The scheduler snapshot: what was last seen, shown at once, then refreshed.
jobs = $state<JobsResponse | null>(cache.get<JobsResponse>(JOBS_KEY) ?? null);
constructor() {
super(() => session.client, browserHost().cache ?? null);
}
async refreshJobs(): Promise<void> {
const client = this.client();
if (!client) {
return;
}
this.jobs = await client.jobs();
cache.set(JOBS_KEY, this.jobs);
super(() => session.client);
}
override async load(): Promise<void> {
@@ -48,7 +28,6 @@ class Gateway extends ConversationIndex {
if (limits) {
this.limits = limits;
}
this.refreshJobs().catch(() => undefined);
}
async refreshLimits(): Promise<void> {
@@ -69,9 +48,6 @@ class Gateway extends ConversationIndex {
this.applyLimit(event);
return;
}
if (event.type.startsWith("job.") || event.type.startsWith("schedule.")) {
this.refreshJobs().catch(() => undefined);
}
super.apply(event);
}
+1 -22
View File
@@ -6,27 +6,6 @@ export const LIMIT_LABELS: Record<string, string> = {
seven_day_sonnet: "7-day · Sonnet",
};
const SEVEN_DAY = "seven_day_";
export function limitLabel(window: string): string {
const known = LIMIT_LABELS[window];
if (known) {
return known;
}
if (window.startsWith(SEVEN_DAY)) {
const model = window.slice(SEVEN_DAY.length);
return `7-day · ${model.charAt(0).toUpperCase()}${model.slice(1)}`;
}
return window.replaceAll("_", " ");
}
// The status as a word, for the windows the API never puts a figure on.
export const LIMIT_WORDS: Record<string, string> = {
allowed: "clear",
allowed_warning: "near the limit",
rejected: "rejected",
};
export function limitWord(status: string): string {
return LIMIT_WORDS[status] ?? status.replace("_", " ");
return LIMIT_LABELS[window] ?? window;
}
+25 -35
View File
@@ -1,45 +1,35 @@
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 {
// Lit only on its own path, not on its children (an index page).
exact?: boolean;
href: string;
key: string;
icon: Component<{ class?: string }>;
label: string;
mobile: boolean;
}
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 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 SYSTEM: NavItem = { href: "/system", key: "5", label: "System" };
export const SYSTEM_PAGES: NavItem[] = [
{ exact: true, 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,
exact = false
) {
const path = pathname.startsWith(base)
? pathname.slice(base.length)
: pathname;
const current = path || "/";
if (href === "/" || exact) {
return current === href;
}
return current === href || current.startsWith(`${href}/`);
}
export function isSectionActive(pathname: string, base: string, href: string) {
export function isActive(pathname: string, base: string, href: string) {
const path = pathname.startsWith(base)
? pathname.slice(base.length)
: pathname;
+1 -1
View File
@@ -23,7 +23,7 @@
onMount(() => {
const timer = setInterval(() => {
if (model.live.length > 0) {
if (model.running) {
now = Date.now();
}
}, TICK_MS);
+5 -82
View File
@@ -27,9 +27,6 @@ export interface Turn {
itemOrigin: string | null;
nodes: Record<string, ToolNode>;
origin: string;
// A tool call came between two pieces of text; the next piece starts a
// paragraph, as the final message will show it.
paused: boolean;
resultSubtype: string | null;
roots: string[];
says: string[];
@@ -54,20 +51,6 @@ function str(value: unknown): string | null {
return typeof value === "string" ? value : null;
}
export function joinText(head: string, tail: string, paused: boolean): string {
if (paused && head.trim() && !head.endsWith("\n")) {
return `${head.trimEnd()}\n\n${tail.trimStart()}`;
}
return head + tail;
}
export function isLive(turn: Turn): boolean {
return (
turn.status === "running" ||
Object.values(turn.nodes).some((node) => node.status === "running")
);
}
function stopStatus(turn: Turn, stop: string | null): TurnStatus {
if (stop === "end_turn") {
return turn.status === "error" ? "error" : "done";
@@ -82,9 +65,6 @@ export class ActivityModel {
turns = $state<Turn[]>([]);
question = $state<PendingQuestion | null>(null);
// A subagent still working after its turn ended: nothing on this page
// knows the turn, so its tool calls hang under the launching Agent node
// of a turn named after that node.
private readonly handlers: Record<string, (c: Cursor) => void> = {
"conversation.created": (c) => this.onConversation(c),
"conversation.updated": (c) => this.onConversation(c),
@@ -109,12 +89,6 @@ export class ActivityModel {
return this.turns.find((turn) => turn.status === "running") ?? null;
}
// A turn is live while it runs or while a subagent it launched still
// works: the CLI reports subagent tool calls after the turn's own end.
get live(): Turn[] {
return this.turns.filter((turn) => isLive(turn));
}
setConversation(info: ConversationInfo): void {
this.conversation = info;
this.question = info.question;
@@ -177,42 +151,6 @@ export class ActivityModel {
turn.startedAt = ts;
turn.itemOrigin = str(event.item_origin);
turn.userText = str(event.text) ?? turn.userText;
if (turn.origin === "task") {
this.settleAgents(ts);
}
}
// The report is in: whatever Agent node still runs has finished.
private settleAgents(ts: string): void {
for (const turn of this.turns) {
if (turn.origin === "agent" && turn.status === "running") {
turn.status = "done";
turn.endedAt = ts;
}
for (const node of Object.values(turn.nodes)) {
const agent = SUBAGENT_TOOLS.has(node.name) || node.name === "?";
if (node.status === "running" && node.parent === null && agent) {
node.status = "done";
node.endedAt = ts;
}
}
}
}
// Subagent traffic belongs to the turn that launched the subagent, whatever
// turn the gateway was in when it arrived.
private turnFor(turnId: string, ts: string, parent: string | null): Turn {
if (parent) {
const owner = this.turns.find((turn) => parent in turn.nodes);
if (owner) {
return owner;
}
const current = this.turns.find((turn) => turn.id === turnId);
if (current?.status !== "running") {
return this.ensureTurn(`agent:${parent}`, ts, "agent");
}
}
return this.ensureTurn(turnId, ts, null);
}
private onStream({ event, ts, turnId, parent }: Cursor): void {
@@ -220,7 +158,7 @@ export class ActivityModel {
if (!(turnId && raw && typeof raw === "object")) {
return;
}
const turn = this.turnFor(turnId, ts, parent);
const turn = this.ensureTurn(turnId, ts, null);
const sdk = raw as Record<string, unknown>;
if (sdk.type === "content_block_start") {
const block = sdk.content_block as Record<string, unknown> | undefined;
@@ -234,8 +172,7 @@ export class ActivityModel {
if (sdk.type === "content_block_delta" && parent === null) {
const delta = sdk.delta as Record<string, unknown> | undefined;
if (delta?.type === "text_delta" && typeof delta.text === "string") {
turn.text = joinText(turn.text, delta.text, turn.paused);
turn.paused = false;
turn.text += delta.text;
}
}
}
@@ -244,10 +181,7 @@ export class ActivityModel {
if (!(turnId && typeof event.tool_use_id === "string")) {
return;
}
const turn = this.turnFor(turnId, ts, parent);
if (parent === null) {
turn.paused = true;
}
const turn = this.ensureTurn(turnId, ts, null);
const node = this.ensureNode(
turn,
event.tool_use_id,
@@ -262,7 +196,7 @@ export class ActivityModel {
if (!(turnId && typeof event.tool_use_id === "string")) {
return;
}
const turn = this.turnFor(turnId, ts, parent);
const turn = this.ensureTurn(turnId, ts, null);
const node = this.ensureNode(turn, event.tool_use_id, "?", parent, ts);
node.status = event.is_error ? "error" : "done";
node.endedAt = ts;
@@ -332,13 +266,11 @@ export class ActivityModel {
};
}
// The turn's own tool calls die with it; a subagent's keep going until
// their own results arrive.
private closeTurn(turn: Turn, status: TurnStatus, ts: string): void {
turn.status = status;
turn.endedAt = ts;
for (const node of Object.values(turn.nodes)) {
if (node.status === "running" && node.parent === null) {
if (node.status === "running") {
node.status = "aborted";
node.endedAt = ts;
}
@@ -359,7 +291,6 @@ export class ActivityModel {
itemOrigin: null,
nodes: {},
origin: origin ?? "?",
paused: false,
resultSubtype: null,
roots: [],
says: [],
@@ -463,11 +394,3 @@ export function summarizeInput(name: string, input: unknown): string {
export function toolLabel(name: string): string {
return name.startsWith("mcp__") ? name.slice(5).replace("__", " · ") : name;
}
// How a turn was started, as a word for the header.
export const ORIGIN_LABELS: Record<string, string> = {
agent: "subagent at work",
inject: "inject",
task: "subagent report",
user: "user",
};
-49
View File
@@ -1,49 +0,0 @@
// 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: <T>(key: string) => T | null;
set: (key: string, value: unknown) => void;
}
const memory = new Map<string, string>();
function storage(): Pick<Storage, "getItem" | "setItem" | "removeItem"> {
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<T>(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}`);
}
},
};
}
+38 -168
View File
@@ -1,15 +1,15 @@
<script lang="ts">
import { tick, untrack } from "svelte";
import { tick } from "svelte";
import type { ApiClient } from "$lib/api/client";
import type { ContentBlock, HistoryMessage } from "$lib/api/types";
import EmptyState from "$lib/components/empty-state.svelte";
import ErrorNote from "$lib/components/error-note.svelte";
import { clip, fmtDateTime, fmtTime } from "$lib/format";
import { Skeleton } from "$lib/components/ui/skeleton";
import { Switch } from "$lib/components/ui/switch";
import { clip } from "$lib/format";
import { cn } from "$lib/utils";
import type { ActivityModel } from "./activity.svelte";
import { isLive, summarizeInput, toolLabel } from "./activity.svelte";
import { cacheableHistory, historyKey } from "./history-cache";
import { usePanelHost } from "./host";
import { summarizeInput, toolLabel } from "./activity.svelte";
import Markdown from "./markdown.svelte";
import QuestionCard from "./question-card.svelte";
import TurnCard from "./turn-card.svelte";
@@ -27,10 +27,6 @@
} = $props();
const RESULT_CLIP = 600;
// Markdown for the last few screens renders at once; the rest waits
// until scrolled near, so a long thread paints as fast as a short one.
const EAGER_MARKDOWN = 40;
const host = usePanelHost();
const NEAR_BOTTOM_PX = 120;
const TICK_MS = 1000;
@@ -40,15 +36,12 @@
let loadedAt = $state(new Date(0).toISOString());
let scroller = $state<HTMLDivElement | null>(null);
let body = $state<HTMLDivElement | null>(null);
const SETTLE_MS = 2500;
const SETTLE_STEP_MS = 120;
let settleUntil = 0;
let now = $state(Date.now());
let pinned = true;
const tail = $derived(
model.turns
.filter((turn) => isLive(turn) || turn.startedAt > loadedAt)
.filter((turn) => turn.status === "running" || turn.startedAt > loadedAt)
.reverse()
);
const tailSize = $derived(
@@ -60,69 +53,34 @@
async function load() {
failure = null;
if (messages === null) {
const cached = host.cache?.get<HistoryMessage[]>(
historyKey(conversationId)
);
if (cached) {
messages = cached;
}
}
try {
({ messages } = await client.history(conversationId));
loadedAt = new Date().toISOString();
settle();
host.cache?.set(historyKey(conversationId), cacheableHistory(messages));
} catch (cause) {
failure = cause instanceof Error ? cause.message : String(cause);
}
}
// The scroller is column-reverse: its origin is the bottom, scrollTop is
// zero there and goes negative upward, and the browser keeps the view at
// the bottom while content grows - no matter how late markdown lands.
function nearBottom(): boolean {
return !scroller || Math.abs(scroller.scrollTop) < NEAR_BOTTOM_PX;
}
function toBottom() {
if (scroller) {
scroller.scrollTop = 0;
if (!scroller) {
return true;
}
const left =
scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight;
return left < NEAR_BOTTOM_PX;
}
async function scrollToBottom() {
await tick();
toBottom();
}
// A fresh thread keeps growing after it is on screen: markdown renders
// late, embeds load, fonts land. Hold the bottom for a while after a load.
function settle() {
settleUntil = Date.now() + SETTLE_MS;
const step = () => {
if (!pinned || Date.now() > settleUntil) {
return;
}
toBottom();
setTimeout(step, SETTLE_STEP_MS);
};
step();
}
// The date changes rarely; say it once, then only the time.
function dayOf(iso: string | null | undefined): string {
return iso ? iso.slice(0, 10) : "";
}
function minuteOf(iso: string | null | undefined): string {
return iso ? fmtTime(iso).replace(SECONDS, "") : "";
if (scroller) {
scroller.scrollTop = scroller.scrollHeight;
}
}
$effect(() => {
if (refreshKey >= 0) {
pinned = true;
untrack(() => load());
load();
}
});
@@ -150,45 +108,13 @@
$effect(() => {
const timer = setInterval(() => {
if (model.live.length > 0) {
if (model.running) {
now = Date.now();
}
}, TICK_MS);
return () => clearInterval(timer);
});
// Text the gateway or the CLI put in the user's seat: an inject header,
// an envelope, a subagent's report.
const SYSTEM_HEAD = /^(\[[^\]\n]+\]|<task-notification>)/;
const SECONDS = /:\d{2}$/;
const DAY_TIME = /,?\s*\d{2}:\d{2}$/;
let openSystem = $state<Set<number>>(new Set());
function systemText(message: HistoryMessage): string | null {
if (message.role !== "user" || typeof message.content !== "string") {
return null;
}
return SYSTEM_HEAD.test(message.content.trimStart())
? message.content
: null;
}
function toggleSystem(index: number) {
const next = new Set(openSystem);
if (next.has(index)) {
next.delete(index);
} else {
next.add(index);
}
openSystem = next;
}
// Keys count from the end: when the network copy grows the head past
// what the cache held, the tail keeps its nodes and its rendered markdown.
function keyOf(message: HistoryMessage, index: number, total: number) {
return `${index - total}:${message.role}:${message.ts ?? ""}`;
}
function blocks(message: HistoryMessage): ContentBlock[] {
return typeof message.content === "string"
? [{ text: message.content, type: "text" }]
@@ -214,114 +140,58 @@
</script>
<div
class="flex min-h-0 flex-1 flex-col-reverse overflow-y-auto overflow-x-hidden px-3 py-3 [overflow-anchor:none] @md:px-6"
class="min-h-0 flex-1 overflow-y-auto px-3 py-3 @md:px-6"
onscroll={() => {
if (Date.now() > settleUntil) {
pinned = nearBottom();
}
pinned = nearBottom();
}}
bind:this={scroller}
>
<div class="flex min-w-0 max-w-full flex-col gap-3" bind:this={body}>
<button
class="rule-word self-end text-xs"
data-active={showResults}
onclick={() => {
showResults = !showResults;
}}
type="button"
<div class="flex flex-col gap-3" bind:this={body}>
<span
class="flex items-center gap-2 self-end text-muted-foreground text-xs"
>
{showResults ? "hide tool results" : "show tool results"}
</button>
<Switch aria-label="Show tool results" bind:checked={showResults} />
show tool results
</span>
{#if failure}
<ErrorNote message={failure} retry={load} />
{:else if messages === null}
<p class="py-6 text-center text-muted-foreground text-xs">
Loading the thread…
</p>
<div class="flex flex-col gap-2">
<Skeleton class="h-10 w-2/3" />
<Skeleton class="h-16 w-full" />
<Skeleton class="h-10 w-1/2" />
</div>
{:else if messages.length === 0 && tail.length === 0}
<EmptyState
hint="Nothing has been said here yet. Write below, or wait for the first inject."
title="Empty conversation"
/>
{:else}
{#each messages as message, index (keyOf(message, index, messages.length))}
{#each messages as message, index (index)}
{@const parts = blocks(message)}
{@const isResultOnly = parts.every((b) => b.type === "tool_result")}
{@const system = systemText(message)}
{@const newDay =
message.ts && dayOf(message.ts) !== dayOf(messages[index - 1]?.ts)}
{@const newMinute =
message.ts &&
minuteOf(message.ts) !== minuteOf(messages[index - 1]?.ts)}
{@const spoken =
message.role === "user" ||
parts.some((b) => b.type === "text" && Boolean(b.text))}
{#if newDay}
<p class="label-quiet self-center pt-2">
{fmtDateTime(message.ts).replace(DAY_TIME, "")}
</p>
{/if}
{#if system}
<div class="flex min-w-0 items-start justify-center gap-2">
{#if message.ts}
<span
class="tabular shrink-0 py-1 text-[11px] text-muted-foreground opacity-60"
title={fmtDateTime(message.ts)}
>
{minuteOf(message.ts)}
</span>
{/if}
<button
aria-expanded={openSystem.has(index)}
class="@md:max-w-[75ch] min-w-0 max-w-full rounded-md px-2 py-1 text-left font-mono text-muted-foreground text-xs hover:bg-accent"
onclick={() => toggleSystem(index)}
type="button"
>
{#if openSystem.has(index)}
<span class="whitespace-pre-wrap break-words">{system}</span>
{:else}
<span class="block truncate"
>{clip(system.split("\n")[0], 120)}</span
>
{/if}
</button>
</div>
{:else if !(isResultOnly && !showResults)}
{#if !(isResultOnly && !showResults)}
<div
class={cn(
"group flex min-w-0 max-w-full flex-col gap-1.5",
"flex flex-col gap-1.5",
message.role === "user" && !isResultOnly && "items-end"
)}
>
{#if message.ts && (newMinute || spoken) && !isResultOnly}
<span
class="tabular px-1 text-[11px] text-muted-foreground opacity-60 transition-opacity group-hover:opacity-100"
title={fmtDateTime(message.ts)}
>
{minuteOf(message.ts)}
</span>
{/if}
{#each parts as block, blockIndex (blockIndex)}
{#if block.type === "text" && block.text}
<Markdown
class={cn(
"min-w-0 @md:max-w-[75ch] max-w-full",
message.role === "user"
? "rounded-2xl rounded-br-md bg-primary/10 px-3.5 py-2"
: "px-1 py-1"
)}
eager={index >= messages.length - EAGER_MARKDOWN}
"max-w-[75ch] rounded-lg px-3 py-2",
message.role === "user" ? "bg-primary/10" : "bg-muted/50"
)}
text={block.text}
/>
{:else if block.type === "tool_use"}
<p
class="flex min-w-0 max-w-full items-baseline gap-2 px-1 text-xs"
>
<span class="shrink-0 font-medium"
<p class="flex items-baseline gap-2 px-1 text-xs">
<span class="font-medium"
>{toolLabel(block.name ?? "?")}</span
>
<span class="min-w-0 truncate text-muted-foreground">
<span class="truncate text-muted-foreground">
{clip(summarizeInput(block.name ?? "", block.input), 160)}
</span>
</p>
+58 -64
View File
@@ -1,8 +1,9 @@
<script lang="ts">
import ArrowUpIcon from "@lucide/svelte/icons/arrow-up";
import SendHorizontalIcon from "@lucide/svelte/icons/send-horizontal";
import { toast } from "svelte-sonner";
import type { ApiClient } from "$lib/api/client";
import { cn } from "$lib/utils";
import { Button } from "$lib/components/ui/button";
import * as Select from "$lib/components/ui/select";
let {
client,
@@ -16,31 +17,28 @@
type Mode = "message" | "inject" | "urgent";
const MODES: { value: Mode; label: string; hint: string }[] = [
{ hint: "as you, mirrored to the window", label: "Say", value: "message" },
{
hint: "a system note for the next turn",
hint: "as the user, mirrored to Telegram",
label: "Message",
value: "message",
},
{
hint: "system note, rides with the next turn",
label: "Inject",
value: "inject",
},
{ hint: "interrupts the running turn", label: "Urgent", value: "urgent" },
{
hint: "interrupts the running turn",
label: "Urgent inject",
value: "urgent",
},
];
const MAX_ROWS = 10;
const LINE_PX = 22;
let mode = $state<Mode>("message");
let text = $state("");
let busy = $state(false);
let area = $state<HTMLTextAreaElement | null>(null);
const current = $derived(MODES.find((m) => m.value === mode) ?? MODES[0]);
function grow() {
if (!area) {
return;
}
area.style.height = "auto";
area.style.height = `${Math.min(area.scrollHeight, LINE_PX * MAX_ROWS)}px`;
}
async function send() {
const body = text.trim();
if (!body || busy) {
@@ -58,7 +56,6 @@
);
}
text = "";
queueMicrotask(grow);
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
@@ -75,60 +72,57 @@
</script>
<form
class="flex flex-col gap-1.5 px-3 pt-2 pb-2 @md:px-6"
class="flex flex-col gap-2 border-t bg-background px-3 py-2"
onsubmit={(event) => {
event.preventDefault();
send();
}}
>
<div
class={cn(
"flex items-end gap-2 rounded-xl bg-strip px-3 py-2 shadow-lift ring-1 ring-border focus-within:ring-ring",
disabled && "opacity-60"
)}
>
<textarea
aria-label="Message"
class="max-h-56 min-h-6 w-full resize-none bg-transparent py-0.5 text-sm leading-[22px] outline-none placeholder:text-muted-foreground"
{disabled}
oninput={grow}
onkeydown={onKeydown}
placeholder={disabled
? "This conversation is closed."
: `${current.label} something… (⌘↩)`}
rows="1"
bind:this={area}
bind:value={text}
></textarea>
<button
aria-label="Send"
class="inline-flex size-7 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground transition-opacity disabled:opacity-30"
<textarea
aria-label="Message"
class="min-h-16 w-full resize-y rounded-md border bg-input/30 px-3 py-2 text-sm focus-visible:border-ring focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/30"
{disabled}
onkeydown={onKeydown}
placeholder={mode === "message"
? "Say something to the agent…"
: "Text of the inject…"}
rows="2"
bind:value={text}
></textarea>
<div class="flex items-center gap-2">
<Select.Root
onValueChange={(value) => {
mode = value as Mode;
}}
type="single"
value={mode}
>
<Select.Trigger class="h-8 text-xs" size="sm"
>{current.label}</Select.Trigger
>
<Select.Content>
{#each MODES as option (option.value)}
<Select.Item label={option.label} value={option.value}>
<span class="flex flex-col">
<span>{option.label}</span>
<span class="text-muted-foreground text-xs">{option.hint}</span>
</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<span class="hidden text-muted-foreground text-xs @md:inline"
>{current.hint}</span
>
<Button
class="ml-auto"
disabled={disabled || busy || !text.trim()}
size="sm"
type="submit"
>
<ArrowUpIcon class="size-4" />
</button>
</div>
<div class="flex items-center gap-3 px-1">
{#each MODES as option (option.value)}
<button
class={cn(
"rule-word text-xs",
mode === option.value && "text-foreground",
option.value === "urgent" && mode === option.value && "text-destructive"
)}
data-active={mode === option.value}
onclick={() => {
mode = option.value;
}}
title={option.hint}
type="button"
>
{option.label}
</button>
{/each}
<span class="hidden truncate text-muted-foreground text-xs @md:inline">
{current.hint}
</span>
<SendHorizontalIcon class="size-4" />
Send
<kbd class="hidden text-[10px] opacity-70 @md:inline">⌘↩</kbd>
</Button>
</div>
</form>
-395
View File
@@ -1,395 +0,0 @@
<script lang="ts">
import { onMount } from "svelte";
import type { ApiClient } from "$lib/api/client";
import type { ContextResponse, VaultGraph } from "$lib/api/types";
import ErrorNote from "$lib/components/error-note.svelte";
import { fmtRelative, fmtTokens } from "$lib/format";
import Graph, {
type GraphEdge,
type GraphNode,
} from "$lib/shell/graph.svelte";
import { cn } from "$lib/utils";
import { usePanelHost } from "./host";
let {
client,
conversationId,
window = 1_000_000,
}: {
client: ApiClient;
conversationId: string;
window?: number;
} = $props();
const HARNESS_EST = 15_000;
const FILES_SHOWN = 12;
const MD_SUFFIX = /\.md$/;
const host = usePanelHost();
let data = $state<ContextResponse | null>(null);
let graph = $state<VaultGraph | null>(null);
let graphError = $state<string | null>(null);
let failure = $state<string | null>(null);
let promptOpen = $state(false);
let promptText = $state<string | null>(null);
let filesOpen = $state(false);
async function load() {
failure = null;
try {
data = await client.context(conversationId);
} catch (cause) {
failure = cause instanceof Error ? cause.message : String(cause);
return;
}
const paths = data.files.map((f) => f.path);
if (paths.length === 0) {
graph = null;
return;
}
try {
graph = await client.vaultGraph(paths.slice(0, 40));
graphError = null;
} catch (cause) {
graph = null;
graphError = cause instanceof Error ? cause.message : String(cause);
}
}
async function showPrompt() {
promptOpen = !promptOpen;
if (promptOpen && promptText === null) {
try {
const full = await client.context(conversationId, true);
promptText = full.prompt_text ?? "";
} catch (cause) {
promptText = cause instanceof Error ? cause.message : String(cause);
}
}
}
onMount(load);
interface Layer {
label: string;
tokens: number;
tone: string;
}
const layers = $derived.by((): Layer[] => {
if (!data) {
return [];
}
const prompt = data.prompt.tokens_est;
const skills = data.skills_tokens_est;
const harness = Math.min(
HARNESS_EST,
Math.max(0, data.context_tokens - prompt - skills)
);
const history = Math.max(
0,
data.context_tokens - prompt - skills - harness
);
return [
{ label: "harness", tokens: harness, tone: "bg-muted-foreground/40" },
{ label: "prompt", tokens: prompt, tone: "bg-primary" },
{ label: "skills", tokens: skills, tone: "bg-kind-deep" },
{ label: "history & tools", tokens: history, tone: "bg-kind-branch" },
];
});
const total = $derived(data?.context_tokens ?? 0);
const share = (tokens: number) =>
total > 0 ? `${Math.max(0.5, (tokens / total) * 100)}%` : "0%";
const graphNodes = $derived.by((): GraphNode[] => {
if (!graph) {
return [];
}
const single = graph.nodes.filter((n) => n.touched).length === 1;
return graph.nodes.map((n) => ({
href: n.exists ? n.path : undefined,
id: n.path,
kind: "file",
label: n.title,
ring: ringOf(n.touched, single),
state: stateOf(n.touched, n.exists),
}));
});
const graphEdges = $derived.by((): GraphEdge[] =>
graph ? graph.edges.map((e) => ({ from: e.from, to: e.to })) : []
);
const files = $derived(
filesOpen ? (data?.files ?? []) : (data?.files ?? []).slice(0, FILES_SHOWN)
);
function ringOf(touched: boolean, single: boolean): number {
if (!touched) {
return 2;
}
return single ? 0 : 1;
}
function stateOf(touched: boolean, exists: boolean): GraphNode["state"] {
if (!touched) {
return "ghost";
}
return exists ? "quiet" : "closed";
}
function openNote(path: string) {
host.openLink(path.replace(MD_SUFFIX, ""), "internal");
}
function touch(file: {
reads: number;
writes: number;
other: number;
}): string {
const parts: string[] = [];
if (file.reads) {
parts.push(`read ×${file.reads}`);
}
if (file.writes) {
parts.push(`wrote ×${file.writes}`);
}
if (file.other) {
parts.push(`touched ×${file.other}`);
}
return parts.join(" · ");
}
</script>
<div class="@container flex max-w-5xl flex-col gap-8">
{#if failure}
<ErrorNote message={failure} retry={load} />
{:else if !data}
<p class="text-muted-foreground text-sm">Reading the context…</p>
{:else}
<section class="flex flex-col gap-3">
<div class="flex items-baseline gap-3">
<span
class="tabular font-semibold text-3xl leading-none tracking-tight"
>
{fmtTokens(total)}
</span>
<span class="text-muted-foreground text-sm">
of {fmtTokens(window)} · {data.turns}
{data.turns === 1 ? "turn" : "turns"}
· {data.agent.model}
{data.agent.effort
? ` · ${data.agent.effort}`
: ""}
</span>
</div>
<div class="flex h-2 w-full overflow-hidden rounded-full bg-muted">
{#each layers as layer (layer.label)}
{#if layer.tokens > 0}
<span
class={cn("h-full", layer.tone)}
style="width: {share(layer.tokens)}"
title="{layer.label}: {fmtTokens(layer.tokens)}"
></span>
{/if}
{/each}
</div>
<div class="flex flex-wrap gap-x-5 gap-y-1 text-xs">
{#each layers as layer (layer.label)}
<span class="flex items-center gap-1.5">
<span class={cn("size-2 rounded-full", layer.tone)}></span>
<span class="text-muted-foreground">{layer.label}</span>
<span class="tabular">{fmtTokens(layer.tokens)}</span>
</span>
{/each}
<span class="text-muted-foreground/70"
>estimates, except the total</span
>
</div>
</section>
<div class="grid gap-8 @3xl:grid-cols-[minmax(0,1fr)_22rem]">
<div class="flex min-w-0 flex-col gap-8">
<section class="flex flex-col gap-2">
<div class="flex items-baseline gap-3">
<h2 class="font-medium text-sm">System prompt</h2>
<span class="tabular text-muted-foreground text-xs">
{fmtTokens(data.prompt.tokens_est)}
· {data.prompt.granules.length} granules
</span>
<button
class="rule-word ml-auto text-xs"
data-active={promptOpen}
onclick={showPrompt}
type="button"
>
{promptOpen ? "hide text" : "read it"}
</button>
</div>
<div class="bay-rack">
{#each data.prompt.granules as g, index (index)}
<div class="strip text-sm">
<span
class="size-2 justify-self-center rounded-full bg-primary"
></span>
<span class="flex min-w-0 flex-col leading-tight">
<button
class="truncate text-left font-medium hover:underline"
onclick={() => g.path && openNote(g.path)}
type="button"
>
{g.path ?? "verbatim prompt"}
</button>
{#if g.tag}
<span
class="truncate font-mono text-[10px] text-muted-foreground"
>
&lt;{g.tag}&gt;
</span>
{/if}
</span>
<span class="tabular text-muted-foreground text-xs">
{fmtTokens(g.tokens_est)}
</span>
</div>
{/each}
</div>
{#if promptOpen}
<pre
class="max-h-[32rem] overflow-auto rounded-lg border bg-strip p-3 text-xs whitespace-pre-wrap break-words"
>{promptText ?? "…"}</pre>
{/if}
</section>
<section class="flex flex-col gap-2">
<div class="flex items-baseline gap-3">
<h2 class="font-medium text-sm">Skills</h2>
<span class="tabular text-muted-foreground text-xs">
{data.skills.reduce((n, s) => n + s.skills.length, 0)}
loaded
</span>
</div>
{#if data.skills.length === 0}
<p class="text-muted-foreground text-sm">None for this kind.</p>
{:else}
<div class="bay-rack">
{#each data.skills as set (set.path)}
{#each set.skills as skill (skill.path)}
<div class="strip text-sm">
<span
class="size-2 justify-self-center rounded-full bg-kind-deep"
></span>
<span class="flex min-w-0 flex-col leading-tight">
<span class="truncate font-medium">{skill.name}</span>
{#if skill.description}
<span class="truncate text-muted-foreground text-xs">
{skill.description}
</span>
{/if}
</span>
<span class="text-muted-foreground text-xs">{set.set}</span>
</div>
{/each}
{/each}
</div>
{/if}
</section>
<section class="flex flex-col gap-2">
<h2 class="font-medium text-sm">Tools</h2>
<dl
class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1 text-xs"
>
{#if Object.keys(data.tool_counts).length > 0}
<dt class="text-muted-foreground">used</dt>
<dd class="tabular">
{Object.entries(data.tool_counts)
.sort((a, b) => b[1] - a[1])
.map(([name, n]) => `${name.replace("mcp__", "")} ×${n}`)
.join(" ")}
</dd>
{/if}
{#if data.tools.gateway.length}
<dt class="text-muted-foreground">gateway</dt>
<dd>{data.tools.gateway.join(", ")}</dd>
{/if}
{#if data.tools.mcps.length}
<dt class="text-muted-foreground">mcp</dt>
<dd>{data.tools.mcps.join(", ")}</dd>
{/if}
{#if data.tools.allowed}
<dt class="text-muted-foreground">allowed</dt>
<dd>{data.tools.allowed.join(", ")}</dd>
{/if}
{#if data.tools.disallowed.length}
<dt class="text-muted-foreground">off</dt>
<dd class="text-muted-foreground">
{data.tools.disallowed.join(", ")}
</dd>
{/if}
</dl>
</section>
</div>
<div class="flex min-w-0 flex-col gap-8">
<section class="flex flex-col gap-2">
<div class="flex items-baseline gap-3">
<h2 class="font-medium text-sm">Notes it reached</h2>
<span class="tabular text-muted-foreground text-xs"
>{data.files.length}</span
>
</div>
{#if graph && graph.nodes.length > 0}
<div class="h-72 overflow-hidden rounded-lg border bg-strip/60">
<Graph edges={graphEdges} nodes={graphNodes} onOpen={openNote} />
</div>
<p class="text-muted-foreground text-xs">
Solid: read or written by this thread. Hollow: one link away, not
reached.
</p>
{:else if graphError}
<p class="text-muted-foreground text-xs">{graphError}</p>
{/if}
{#if data.files.length === 0}
<p class="text-muted-foreground text-sm">No files touched yet.</p>
{:else}
<div class="bay-rack">
{#each files as file (file.path)}
<button
class="strip w-full text-left text-sm"
onclick={() => openNote(file.path)}
type="button"
>
<span
class={cn(
"size-2 justify-self-center rounded-full",
file.writes ? "bg-primary" : "bg-kind-branch"
)}
></span>
<span class="flex min-w-0 flex-col leading-tight">
<span class="truncate font-medium">{file.path}</span>
<span class="truncate text-muted-foreground text-xs"
>{touch(file)}</span
>
</span>
<span class="tabular text-muted-foreground text-xs">
{fmtRelative(file.last_at)}
</span>
</button>
{/each}
</div>
{#if data.files.length > FILES_SHOWN}
<button
class="rule-word self-start text-xs"
onclick={() => {
filesOpen = !filesOpen;
}}
type="button"
>
{filesOpen ? "fewer" : `all ${data.files.length}`}
</button>
{/if}
{/if}
</section>
</div>
</div>
{/if}
</div>
+78 -175
View File
@@ -1,16 +1,15 @@
<script lang="ts">
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import GitBranchIcon from "@lucide/svelte/icons/git-branch";
import type { ApiClient } from "$lib/api/client";
import type { LiveState } from "$lib/api/live.svelte";
import type { ConversationInfo } from "$lib/api/types";
import { fmtRelative, fmtTokens, shortId } from "$lib/format";
import KindMark from "$lib/shell/kind-mark.svelte";
import { cn } from "$lib/utils";
import BindingsList from "./bindings-list.svelte";
import KindBadge from "$lib/components/kind-badge.svelte";
import LiveDot from "$lib/components/live-dot.svelte";
import StatusPill from "$lib/components/status-pill.svelte";
import { Button } from "$lib/components/ui/button";
import { clip, fmtRelative, shortId } from "$lib/format";
import BranchDialog from "./branch-dialog.svelte";
import ConversationMenu from "./conversation-menu.svelte";
import QueueList from "./queue-list.svelte";
let {
client,
@@ -21,119 +20,63 @@
onChanged,
onOpen,
showTitle = true,
view = $bindable("chat"),
onContext,
}: {
client: ApiClient;
info: ConversationInfo;
liveState: LiveState;
liveDetail?: string | null;
// Where another conversation lives as a link; without it, parents open in place.
href?: ((id: string) => string) | null;
onChanged: () => void;
onOpen: (id: string) => void;
showTitle?: boolean;
view?: string;
onContext?: () => void;
} = $props();
const VIEWS = [
{ label: "Thread", value: "chat" },
{ label: "Activity", value: "activity" },
{ label: "Raw", value: "raw" },
{ label: "Context", value: "context" },
];
let branchOpen = $state(false);
let detailsOpen = $state(false);
const mood = $derived.by(() => {
if (info.running_turn) {
return {
dot: "bg-signal animate-pulse-dot",
text: "in motion",
tone: "text-signal",
};
}
if (info.pending_question) {
return {
dot: "bg-attention",
text: "waiting for you",
tone: "text-attention-foreground",
};
}
if (info.status !== "open") {
return {
dot: "bg-muted-foreground/50",
text: info.status,
tone: "text-muted-foreground",
};
}
return {
dot: "bg-muted-foreground/50",
text: `quiet · ${fmtRelative(info.last_activity_at)}`,
tone: "text-muted-foreground",
};
});
const WINDOW_MAX = 56;
const windows = $derived(info.bindings.filter((b) => b.visible));
const queued = $derived(
info.queue.filter((item) => item.status === "queued").length
);
const connection = $derived(
liveState === "open" ? null : (liveDetail ?? liveState)
);
</script>
<header class="flex flex-col border-b bg-background/80 backdrop-blur-md">
<div class="flex items-center gap-2 px-3 py-2 @md:px-6">
<header class="flex flex-col gap-1.5 border-b px-3 py-2.5 @md:px-6">
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
{#if showTitle}
<KindMark kind={info.kind} />
<KindBadge kind={info.kind} />
<h1 class="min-w-0 truncate font-semibold text-base tracking-tight">
{info.title || `${info.kind} ${shortId(info.id)}`}
</h1>
{/if}
<span class={cn("flex shrink-0 items-center gap-1.5 text-xs", mood.tone)}>
<span class={cn("size-1.5 rounded-full", mood.dot)}></span>
<span class="hidden @sm:inline">{mood.text}</span>
</span>
{#if queued > 0}
<span
class="tabular text-note text-xs"
title="messages waiting for the next turn"
>
+{queued}
</span>
<StatusPill status={info.running_turn ? "running" : info.status} />
{#if info.pending_question}
<span class="font-medium text-link text-xs">question pending</span>
{/if}
{#if connection}
<span class="truncate text-warn text-xs" title={liveDetail ?? undefined}>
{connection}
</span>
{/if}
<div class="ml-auto flex shrink-0 items-center gap-1">
{#if info.context_tokens}
<button
class="rule-word tabular inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs hover:bg-accent"
onclick={() => {
view = "context";
onContext?.();
}}
title="context of the last turn - what the model is holding"
type="button"
>
{fmtTokens(info.context_tokens)}
<span class="text-muted-foreground/70">/ 1M</span>
</button>
{/if}
<button
<div class="ml-auto flex items-center gap-1">
<LiveDot
class="hidden @md:inline-flex"
detail={liveDetail}
state={liveState}
/>
<LiveDot
class="@md:hidden"
detail={liveDetail}
label={false}
state={liveState}
/>
<Button
aria-label="Branch off this conversation"
class="rule-word inline-flex size-7 items-center justify-center rounded-md hover:bg-accent disabled:opacity-40"
disabled={info.status !== "open"}
onclick={() => {
branchOpen = true;
}}
title="Branch off"
type="button"
size="sm"
variant="outline"
>
<GitBranchIcon class="size-4" />
</button>
<span class="hidden @md:inline">Branch</span>
</Button>
<ConversationMenu
{client}
current
@@ -143,99 +86,59 @@
{onChanged}
{onOpen}
/>
<button
aria-expanded={detailsOpen}
aria-label="Details"
class={cn(
"rule-word inline-flex size-7 items-center justify-center rounded-md hover:bg-accent",
detailsOpen && "bg-accent text-foreground"
)}
onclick={() => {
detailsOpen = !detailsOpen;
}}
title="Details"
type="button"
>
<ChevronDownIcon
class={cn("size-4 transition-transform", detailsOpen && "rotate-180")}
/>
</button>
</div>
</div>
<div class="flex items-center gap-4 px-3 pb-1.5 @md:px-6">
{#each VIEWS as item (item.value)}
<button
class="rule-word text-xs"
data-active={view === item.value}
onclick={() => {
view = item.value;
}}
type="button"
<!-- Below @md the column is the live thread's; the rest of this lives in Meta. -->
<div
class="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-muted-foreground text-xs"
>
<span class="hidden @md:inline">{info.agent}</span>
<span class="hidden @md:inline">via {info.origin}</span>
{#if info.parent}
{#if href}
<a class="text-link hover:underline" href={href(info.parent)}>
parent {shortId(info.parent)}
</a>
{:else}
<button
class="text-link hover:underline"
onclick={() => info.parent && onOpen(info.parent)}
type="button"
>
parent {shortId(info.parent)}
</button>
{/if}
{/if}
<span class="tabular hidden @md:inline" title={info.id}>
{shortId(info.id)}
</span>
<span class="tabular hidden @md:inline">
{info.live ? "session live" : "no live session"}
{info.busy ? " · busy" : ""}
</span>
<span class="tabular">
last activity {fmtRelative(info.last_activity_at)}
</span>
{#if queued > 0}
<span
class="tabular font-medium text-note"
title="messages waiting for the next turn"
>
{item.label}
</button>
{/each}
{queued}
queued
</span>
{/if}
</div>
{#if detailsOpen}
{#if windows.length > 0}
<div
class="animate-fade-in grid gap-x-6 gap-y-3 border-t px-3 py-3 text-xs @md:grid-cols-2 @md:px-6"
class="hidden flex-wrap items-center gap-x-3 gap-y-0.5 text-muted-foreground text-xs @md:flex"
>
<dl class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1">
<dt class="text-muted-foreground">agent</dt>
<dd>{info.agent}</dd>
<dt class="text-muted-foreground">opened via</dt>
<dd>{info.origin}</dd>
{#if info.parent}
<dt class="text-muted-foreground">parent</dt>
<dd>
{#if href}
<a class="text-link hover:underline" href={href(info.parent)}>
{shortId(info.parent)}
</a>
{:else}
<button
class="text-link hover:underline"
onclick={() => info.parent && onOpen(info.parent)}
type="button"
>
{shortId(info.parent)}
</button>
{/if}
</dd>
{/if}
<dt class="text-muted-foreground">id</dt>
<dd class="tabular break-all">{info.id}</dd>
<dt class="text-muted-foreground">session</dt>
<dd class="tabular break-all">
{info.session_id ?? "none yet"}
{info.live ? " · process alive" : ""}
</dd>
{#if Object.keys(info.flags).length > 0}
<dt class="text-muted-foreground">flags</dt>
<dd class="tabular">
{Object.entries(info.flags)
.map(([k, v]) => `${k}=${JSON.stringify(v)}`)
.join(" ")}
</dd>
{/if}
</dl>
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-1">
<span class="text-muted-foreground">windows</span>
<BindingsList
bindings={info.bindings}
{client}
conversationId={info.id}
{onChanged}
/>
</div>
{#if info.queue.length > 0}
<div class="flex flex-col gap-1">
<span class="text-muted-foreground">queue</span>
<QueueList items={info.queue} />
</div>
{/if}
</div>
{#each windows as window (window.frontend + window.external_id)}
<span class="truncate" title="{window.frontend}: {window.external_id}">
<span class="text-foreground/70">{window.frontend}</span>
{clip(window.external_id, WINDOW_MAX)}
</span>
{/each}
</div>
{/if}
</header>
+3 -15
View File
@@ -24,7 +24,6 @@
onChanged,
class: className = "",
children,
trigger,
}: {
client: ApiClient;
id: string;
@@ -36,9 +35,6 @@
onChanged: () => void;
class?: string;
children?: Snippet;
// Render the trigger yourself with these props spread on it - the strip
// stays a strip instead of sitting inside a wrapper div.
trigger?: Snippet<[Record<string, unknown>]>;
} = $props();
const host = usePanelHost();
@@ -97,17 +93,9 @@
{#if mode === "context"}
<ContextMenu.Root bind:open>
{#if trigger}
<ContextMenu.Trigger>
{#snippet child({ props })}
{@render trigger(props)}
{/snippet}
</ContextMenu.Trigger>
{:else}
<ContextMenu.Trigger class={className}>
{@render children?.()}
</ContextMenu.Trigger>
{/if}
<ContextMenu.Trigger class={className}>
{@render children?.()}
</ContextMenu.Trigger>
<ContextMenu.Content class="min-w-52">
{@render body(ContextMenu)}
</ContextMenu.Content>
+219
View File
@@ -0,0 +1,219 @@
<script lang="ts">
import CheckIcon from "@lucide/svelte/icons/check";
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 type { ConversationSummary, Kind } from "$lib/api/types";
import EmptyState from "$lib/components/empty-state.svelte";
import ErrorNote from "$lib/components/error-note.svelte";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Skeleton } from "$lib/components/ui/skeleton";
import ConversationRow from "./conversation-row.svelte";
import type { ConversationIndex } from "./index.svelte";
let {
client,
index,
selected = null,
onOpen,
follow = $bindable(false),
showFollow = false,
autofocus = false,
}: {
client: ApiClient;
index: ConversationIndex;
selected?: string | null;
onOpen: (id: string) => void;
follow?: boolean;
showFollow?: boolean;
autofocus?: boolean;
} = $props();
const GROUPS: { kind: Kind; label: string }[] = [
{ kind: "master", label: "Master" },
{ kind: "branch", label: "Branches" },
{ kind: "deep", label: "Deep chats" },
{ kind: "job", label: "Jobs" },
{ kind: "fork", label: "Forks" },
];
let search = $state("");
let withClosed = $state(false);
let list = $state<HTMLDivElement | null>(null);
let searchInput = $state<HTMLInputElement | null>(null);
function activityOf(row: ConversationSummary): string {
return row.last_activity_at ?? row.created_at ?? "";
}
function byActivity(a: ConversationSummary, b: ConversationSummary) {
if (Boolean(a.running_turn) !== Boolean(b.running_turn)) {
return a.running_turn ? -1 : 1;
}
return activityOf(b).localeCompare(activityOf(a));
}
const rows = $derived.by(() => {
const needle = search.trim().toLowerCase();
return index.conversations
.filter(
(row) => withClosed || row.status === "open" || row.id === selected
)
.filter(
(row) =>
!needle ||
(row.title ?? "").toLowerCase().includes(needle) ||
row.id.startsWith(needle) ||
row.agent.toLowerCase().includes(needle) ||
(row.last_item?.text ?? "").toLowerCase().includes(needle)
)
.sort(byActivity);
});
const groups = $derived(
GROUPS.map((group) => ({
...group,
rows: rows.filter((row) => row.kind === group.kind),
})).filter((group) => group.rows.length > 0)
);
function refresh() {
index.load().catch(() => undefined);
}
// ↑/↓ walk the rows, / jumps to the search box, Enter is the button's own.
function onKeydown(event: KeyboardEvent) {
if (event.key === "/" && event.target !== searchInput) {
event.preventDefault();
searchInput?.focus();
return;
}
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") {
return;
}
const buttons = [
...(list?.querySelectorAll<HTMLButtonElement>("[data-row]") ?? []),
];
if (buttons.length === 0) {
return;
}
event.preventDefault();
const at = buttons.indexOf(document.activeElement as HTMLButtonElement);
const step = event.key === "ArrowDown" ? 1 : -1;
const next = at < 0 ? 0 : (at + step + buttons.length) % buttons.length;
buttons[next].focus();
}
$effect(() => {
if (autofocus) {
searchInput?.focus();
}
});
</script>
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
aria-label="Conversations"
class="flex h-full min-h-0 flex-col"
onkeydown={onKeydown}
role="listbox"
tabindex="-1"
>
<div class="flex items-center gap-1.5 border-b px-2 py-1.5">
<Input
aria-label="Search conversations"
class="h-8 min-w-0 flex-1 text-xs"
placeholder="search"
bind:ref={searchInput}
bind:value={search}
/>
<Button
aria-pressed={withClosed}
class="text-xs"
onclick={() => {
withClosed = !withClosed;
}}
size="xs"
title={withClosed
? "Hide closed conversations"
: "Show closed conversations"}
variant={withClosed ? "secondary" : "ghost"}
>
{#if withClosed}
<CheckIcon class="size-3" />
{/if}
closed
</Button>
{#if showFollow}
<Button
aria-label="Follow the active note"
aria-pressed={follow}
onclick={() => {
follow = !follow;
}}
size="icon-sm"
title={follow
? "Following the active note - click to pin this conversation"
: "Pinned - click to follow the active note"}
variant={follow ? "secondary" : "ghost"}
>
{#if follow}
<Link2Icon class="size-4" />
{:else}
<Link2OffIcon class="size-4" />
{/if}
</Button>
{/if}
</div>
<div class="min-h-0 flex-1 overflow-y-auto" bind:this={list}>
{#if index.live.state === "failed"}
<div class="p-3">
<ErrorNote
message={index.live.detail ?? "event stream failed"}
retry={() => index.start()}
/>
</div>
{:else if !index.loaded}
<div class="flex flex-col gap-2 p-3">
<Skeleton class="h-12 w-full" />
<Skeleton class="h-12 w-full" />
<Skeleton class="h-12 w-full" />
</div>
{:else if rows.length === 0}
<div class="p-3">
<EmptyState
hint={search
? "Nothing matches. Try fewer letters, or include closed ones."
: "Nothing is open. Say something to the master in Telegram, or send a note from Obsidian."}
title="No conversations"
/>
</div>
{:else}
{#each groups as group (group.kind)}
<section class="flex flex-col">
<h2
class="sticky top-0 z-10 bg-background/95 px-3 pt-3 pb-1 font-medium text-muted-foreground text-xs uppercase tracking-wide backdrop-blur-sm"
>
{group.label}
<span class="tabular ml-1 normal-case tracking-normal">
{group.rows.length}
</span>
</h2>
<ul class="flex flex-col">
{#each group.rows as row (row.id)}
<li class="border-b last:border-b-0">
<ConversationRow
{client}
onChanged={refresh}
{onOpen}
{row}
selected={selected === row.id}
/>
</li>
{/each}
</ul>
</section>
{/each}
{/if}
</div>
</div>
+91
View File
@@ -0,0 +1,91 @@
<script lang="ts">
import type { ApiClient } from "$lib/api/client";
import type { ConversationSummary } from "$lib/api/types";
import KindBadge from "$lib/components/kind-badge.svelte";
import StatusPill from "$lib/components/status-pill.svelte";
import { clip, fmtRelative, shortId } from "$lib/format";
import { cn } from "$lib/utils";
import ConversationMenu from "./conversation-menu.svelte";
import { usePanelHost } from "./host";
let {
client,
row,
selected = false,
onOpen,
onChanged,
}: {
client: ApiClient;
row: ConversationSummary;
selected?: boolean;
onOpen: (id: string) => void;
onChanged: () => void;
} = $props();
const host = usePanelHost();
const TITLE_MAX = 60;
const PREVIEW_MAX = 90;
const title = $derived(
row.title
? clip(row.title, TITLE_MAX)
: `${row.kind === "master" ? "Master" : row.kind} · ${shortId(row.id)}`
);
const when = $derived(row.last_activity_at ?? row.created_at ?? null);
</script>
<ConversationMenu
class={cn("group/row block", selected && "bg-sidebar-accent")}
{client}
current={selected}
id={row.id}
mode="context"
{onChanged}
{onOpen}
>
<div class="relative">
<button
aria-current={selected ? "true" : undefined}
class="row-hover flex w-full flex-col items-stretch justify-start gap-1 px-3 py-2 text-left text-sm"
data-row={row.id}
onclick={() => onOpen(row.id)}
type="button"
>
<span class="flex w-full items-center gap-2">
<KindBadge kind={row.kind} />
<span class="min-w-0 flex-1 truncate font-medium">{title}</span>
<span class="tabular shrink-0 text-muted-foreground text-xs">
{fmtRelative(when)}
</span>
</span>
<span class="flex w-full items-center gap-2 pr-7 text-xs">
<StatusPill status={row.running_turn ? "running" : row.status} />
<span class="truncate text-muted-foreground">{row.agent}</span>
{#if row.pending_question}
<span class="shrink-0 font-medium text-link">question</span>
{/if}
</span>
{#if row.last_item}
<span
class="block w-full min-w-0 truncate text-muted-foreground text-xs"
>
<span class="text-foreground/70">{row.last_item.origin}:</span>
{clip(row.last_item.text, PREVIEW_MAX)}
</span>
{/if}
</button>
<ConversationMenu
class={cn(
"absolute right-1.5 bottom-1 size-7 opacity-0 transition-opacity focus-visible:opacity-100 group-hover/row:opacity-100 aria-expanded:opacity-100",
host.touch && "opacity-100",
row.last_item && "bottom-6"
)}
{client}
current={selected}
id={row.id}
mode="button"
{onChanged}
{onOpen}
/>
</div>
</ConversationMenu>
+100 -41
View File
@@ -2,13 +2,15 @@
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 { usePanelHost } from "./host";
import QueueList from "./queue-list.svelte";
import RawEntries from "./raw-entries.svelte";
let {
@@ -17,8 +19,6 @@
href = null,
onOpen,
showTitle = true,
initialView = "chat",
onViewChange,
}: {
client: ApiClient;
id: string;
@@ -26,19 +26,13 @@
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;
// The page keeps the open tab in the URL so it survives a reload.
onViewChange?: (view: string) => void;
} = $props();
const host = usePanelHost();
const feed = new ConversationFeed(
() => client,
untrack(() => id),
host.cache ?? null
untrack(() => id)
);
let view = $state(untrack(() => initialView));
let tab = $state("chat");
let historyKey = $state(0);
onMount(() => {
@@ -53,10 +47,6 @@
historyKey += 1;
}
$effect(() => {
onViewChange?.(view);
});
$effect(() => {
const { running } = feed.model;
if (!running) {
@@ -71,10 +61,9 @@
{#if feed.error && !info}
<div class="p-4"><ErrorNote message={feed.error} retry={refresh} /></div>
{:else if !info}
<div class="flex flex-1 items-center justify-center p-4">
<span
class="size-2 animate-pulse-dot rounded-full bg-muted-foreground/40"
></span>
<div class="flex flex-col gap-3 p-4">
<Skeleton class="h-8 w-1/2" />
<Skeleton class="h-24 w-full" />
</div>
{:else}
<ConversationHeader
@@ -86,36 +75,49 @@
onChanged={refresh}
{onOpen}
{showTitle}
bind:view
/>
<div class="flex min-h-0 flex-1 flex-col">
{#if view === "chat"}
<ChatView
{client}
conversationId={id}
model={feed.model}
refreshKey={historyKey}
/>
{:else if view === "activity"}
<div class="min-h-0 flex-1 overflow-y-auto px-3 py-3 @md:px-6">
<Tabs.Root class="flex min-h-0 flex-1 flex-col gap-0" bind:value={tab}>
<div class="border-b px-3 py-2 @md:px-6">
<Tabs.List class="w-fit max-w-full">
<Tabs.Trigger value="chat">Chat</Tabs.Trigger>
<Tabs.Trigger value="activity">Activity</Tabs.Trigger>
<Tabs.Trigger value="raw">Raw</Tabs.Trigger>
<Tabs.Trigger value="meta">Meta</Tabs.Trigger>
</Tabs.List>
</div>
<Tabs.Content class="flex min-h-0 flex-1 flex-col" value="chat">
<ChatView
{client}
conversationId={id}
model={feed.model}
refreshKey={historyKey}
/>
</Tabs.Content>
<Tabs.Content
class="min-h-0 flex-1 overflow-y-auto px-3 py-3 @md:px-6"
value="activity"
>
<ActivityFeed
{client}
connected={feed.live.state === "open"}
conversationId={id}
model={feed.model}
/>
</div>
{:else if view === "context"}
<div class="min-h-0 flex-1 overflow-y-auto px-3 py-4 @md:px-6">
{#key historyKey}
<ContextView {client} conversationId={id} />
{/key}
</div>
{:else}
<div class="min-h-0 flex-1 overflow-y-auto px-3 py-3 @md:px-6">
</Tabs.Content>
<Tabs.Content
class="min-h-0 flex-1 overflow-y-auto px-3 py-3 @md:px-6"
value="raw"
>
<RawEntries {client} conversationId={id} />
</div>
{/if}
</Tabs.Content>
<Tabs.Content
class="min-h-0 flex-1 overflow-y-auto px-3 py-3 @md:px-6"
value="meta"
>
{@render meta()}
</Tabs.Content>
</Tabs.Root>
<Composer
{client}
conversationId={id}
@@ -124,3 +126,60 @@
</div>
{/if}
</div>
{#snippet meta()}
{#if info}
<div class="flex max-w-3xl flex-col gap-5">
<section class="flex flex-col gap-1.5">
<h2
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
>
Queue
</h2>
<QueueList items={info.queue} />
</section>
<section class="flex flex-col gap-1.5">
<h2
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
>
Windows
</h2>
<BindingsList
bindings={info.bindings}
{client}
conversationId={id}
onChanged={refresh}
/>
</section>
<section class="flex flex-col gap-1.5">
<h2
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
>
Flags
</h2>
{#if Object.keys(info.flags).length === 0}
<p class="text-muted-foreground text-xs">No flags set.</p>
{:else}
<dl
class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1 text-xs"
>
{#each Object.entries(info.flags) as [key, value] (key)}
<dt class="text-muted-foreground">{key}</dt>
<dd class="truncate">{JSON.stringify(value)}</dd>
{/each}
</dl>
{/if}
</section>
<section class="flex flex-col gap-1.5">
<h2
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
>
Session
</h2>
<p class="tabular break-all text-muted-foreground text-xs">
{info.session_id ?? "no session yet"}
</p>
</section>
</div>
{/if}
{/snippet}
+2 -17
View File
@@ -1,9 +1,6 @@
import type { ApiClient } from "$lib/api/client";
import { LiveStream } from "$lib/api/live.svelte";
import type { ConversationInfo } from "$lib/api/types";
import { ActivityModel } from "./activity.svelte";
import type { PanelCache } from "./cache";
import { infoKey } from "./history-cache";
// One conversation, live: the snapshot from ``GET /api/conversations/{id}``
// (bindings, queue, the turn in flight) plus its SSE stream folded into an
@@ -15,20 +12,10 @@ export class ConversationFeed {
readonly id: string;
readonly live: LiveStream;
private readonly client: () => ApiClient | null;
private readonly cache: PanelCache | null;
constructor(
client: () => ApiClient | null,
id: string,
cache: PanelCache | null = null
) {
constructor(client: () => ApiClient | null, id: string) {
this.client = client;
this.id = id;
this.cache = cache;
const known = cache?.get<ConversationInfo>(infoKey(id));
if (known) {
this.model.setConversation(known);
}
this.live = new LiveStream(client, `/api/conversations/${id}/events`, {
onEvent: (event) => this.model.apply(event),
prepare: () => this.refresh(),
@@ -41,9 +28,7 @@ export class ConversationFeed {
return;
}
try {
const info = await client.conversation(this.id);
this.model.setConversation(info);
this.cache?.set(infoKey(this.id), info);
this.model.setConversation(await client.conversation(this.id));
this.error = null;
} catch (error) {
this.error = error instanceof Error ? error.message : String(error);
-28
View File
@@ -1,28 +0,0 @@
import type { ContentBlock, HistoryMessage } from "$lib/api/types";
const MAX_BYTES = 250_000;
const TAIL = 200;
function slim(message: HistoryMessage): HistoryMessage {
if (typeof message.content === "string") {
return message;
}
const content: ContentBlock[] = message.content.map((block) =>
block.type === "tool_result" ? { ...block, content: "" } : block
);
return { ...message, content };
}
// The tail of a thread as it is worth keeping between opens: tool results
// dropped (hidden by default, and the bulk of the bytes), then trimmed from
// the head until it fits a slice of localStorage.
export function cacheableHistory(messages: HistoryMessage[]): HistoryMessage[] {
let tail = messages.slice(-TAIL).map(slim);
while (tail.length > 1 && JSON.stringify(tail).length > MAX_BYTES) {
tail = tail.slice(Math.ceil(tail.length / 4));
}
return tail;
}
export const historyKey = (id: string) => `history:${id}`;
export const infoKey = (id: string) => `info:${id}`;
-4
View File
@@ -1,5 +1,4 @@
import { getContext, setContext } from "svelte";
import { type PanelCache, panelCache } from "./cache";
export type LinkKind = "internal" | "external";
@@ -8,8 +7,6 @@ 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";
@@ -38,7 +35,6 @@ export function browserHost(): PanelHost {
return browser;
}
browser = {
cache: panelCache("beaver.panel"),
name: "browser",
openLink(target, kind) {
if (kind === "internal") {
+1 -81
View File
@@ -1,17 +1,9 @@
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";
import { cacheableHistory, historyKey, infoKey } from "./history-cache";
const RELOAD_DEBOUNCE_MS = 1500;
const PAGE = 500;
const CACHED_ROWS = 200;
const INDEX_KEY = "index";
const WARM_TTL_MS = 60_000;
const WARM_GAP_MS = 120;
const WARM_AHEAD = 8;
const QUIET_KINDS = new Set(["job", "fork"]);
// The conversation index kept fresh by ``/api/events``: rows land as
// ``conversation.*`` events arrive, turn markers flip ``running_turn``
@@ -22,19 +14,10 @@ export class ConversationIndex {
loaded = $state(false);
readonly live: LiveStream;
protected readonly client: () => ApiClient | null;
private readonly cache: PanelCache | null;
private reloadTimer: ReturnType<typeof setTimeout> | null = null;
private readonly warmed = new Map<string, number>();
private warming: Promise<void> | null = null;
constructor(client: () => ApiClient | null, cache: PanelCache | null = null) {
constructor(client: () => ApiClient | null) {
this.client = client;
this.cache = cache;
const cached = cache?.get<ConversationSummary[]>(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(),
@@ -61,64 +44,6 @@ 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));
this.prefetch(this.likely());
}
// What the operator opens next: anything alive, then the freshest strips.
private likely(): string[] {
const rows = this.conversations.filter(
(row) => !QUIET_KINDS.has(row.kind) && row.status === "open"
);
const alive = rows.filter(
(row) => row.running_turn || row.pending_question
);
const rest = rows
.filter((row) => !(row.running_turn || row.pending_question))
.sort((a, b) =>
(b.last_activity_at ?? b.created_at ?? "").localeCompare(
a.last_activity_at ?? a.created_at ?? ""
)
);
return [...alive, ...rest].slice(0, WARM_AHEAD).map((row) => row.id);
}
// Warm the cache for these threads, one at a time, in the background.
prefetch(ids: string[]): void {
if (!this.cache) {
return;
}
const now = Date.now();
const fresh = ids.filter(
(id) => now - (this.warmed.get(id) ?? 0) > WARM_TTL_MS
);
if (fresh.length === 0) {
return;
}
for (const id of fresh) {
this.warmed.set(id, now);
}
const run = async () => {
for (const id of fresh) {
const client = this.client();
if (!client) {
return;
}
try {
// biome-ignore lint/performance/noAwaitInLoops: one thread at a time, on purpose - background warmth, not a race
const [history, info] = await Promise.all([
client.history(id),
client.conversation(id),
]);
this.cache?.set(historyKey(id), cacheableHistory(history.messages));
this.cache?.set(infoKey(id), info);
} catch {
this.warmed.delete(id);
}
await new Promise((resolve) => setTimeout(resolve, WARM_GAP_MS));
}
};
this.warming = (this.warming ?? Promise.resolve()).then(run, run);
}
apply(event: BusEvent): void {
@@ -160,11 +85,6 @@ export class ConversationIndex {
this.live.stop();
}
/** The dot was tapped: drop the socket, reload the rows, reconnect. */
reconnect(): void {
this.live.refresh();
}
private upsert(row: ConversationSummary): void {
const index = this.conversations.findIndex((c) => c.id === row.id);
if (index >= 0) {
+5 -47
View File
@@ -3,13 +3,8 @@
import { usePanelHost } from "./host";
import { linkOf, renderMarkdown } from "./markdown";
let {
text,
class: className = "",
eager = true,
}: { text: string; class?: string; eager?: boolean } = $props();
const NEAR = "1200px";
let { text, class: className = "" }: { text: string; class?: string } =
$props();
const host = usePanelHost();
const html = $derived(host.markdown ? "" : renderMarkdown(text));
@@ -41,52 +36,15 @@
};
}
// The host renders when asked; far up the thread that is when the
// node comes near the viewport, with the plain text holding its place.
function render(node: HTMLElement, value: string) {
let dispose: (() => void) | undefined;
let watcher: IntersectionObserver | null = null;
let pending = value;
const now = (next: string) => {
dispose?.();
node.classList.remove("whitespace-pre-wrap");
dispose = host.markdown?.(node, next);
};
const later = (next: string) => {
pending = next;
node.textContent = next;
node.classList.add("whitespace-pre-wrap");
if (watcher) {
return;
}
watcher = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
watcher?.disconnect();
watcher = null;
now(pending);
}
},
{ rootMargin: NEAR }
);
watcher.observe(node);
};
if (eager || typeof IntersectionObserver === "undefined") {
now(value);
} else {
later(value);
}
let dispose = host.markdown?.(node, value);
return {
destroy() {
watcher?.disconnect();
dispose?.();
},
update(next: string) {
if (watcher) {
later(next);
} else {
now(next);
}
dispose?.();
dispose = host.markdown?.(node, next);
},
};
}
+28 -20
View File
@@ -3,11 +3,12 @@
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 Rail from "$lib/rail/rail.svelte";
import KindMark from "$lib/shell/kind-mark.svelte";
import { cn } from "$lib/utils";
import ConversationPicker from "./conversation-picker.svelte";
import type { ConversationIndex } from "./index.svelte";
let {
@@ -15,7 +16,6 @@
index,
selected = null,
onOpen,
onOpenFile,
follow = $bindable(false),
showFollow = false,
}: {
@@ -23,7 +23,6 @@
index: ConversationIndex;
selected?: string | null;
onOpen: (id: string) => void;
onOpenFile?: (path: string) => void;
follow?: boolean;
showFollow?: boolean;
} = $props();
@@ -31,9 +30,10 @@
const TITLE_MAX = 48;
let switcherOpen = $state(false);
const current = $derived(selected ? index.byId(selected) : undefined);
const running = $derived(index.running.length);
</script>
<div class="flex items-center gap-1.5 border-b px-2 py-1.5">
<div class="flex items-center gap-1 border-b px-2 py-1.5">
<Popover.Root bind:open={switcherOpen}>
<Popover.Trigger>
{#snippet child({ props })}
@@ -44,7 +44,7 @@
type="button"
>
{#if current}
<KindMark kind={current.kind} />
<KindBadge kind={current.kind} />
<span class="min-w-0 flex-1 truncate font-medium">
{current.title
? clip(current.title, TITLE_MAX)
@@ -59,6 +59,14 @@
Pick a conversation
</span>
{/if}
{#if running > 0 && !current?.running_turn}
<span
class="tabular shrink-0 rounded-full bg-signal/12 px-1.5 font-medium text-signal text-xs"
title="{running} running elsewhere"
>
{running}
</span>
{/if}
<ChevronDownIcon class="size-3.5 shrink-0 text-icon" />
</button>
{/snippet}
@@ -68,44 +76,44 @@
class="w-[min(24rem,calc(100vw-1rem))] gap-0 overflow-hidden p-0"
sideOffset={6}
>
<div class="flex h-[min(70vh,32rem)] flex-col">
<Rail
<div class="flex max-h-[min(70vh,32rem)] flex-col">
<ConversationPicker
autofocus
{client}
{index}
onOpen={(id) => {
switcherOpen = false;
onOpen(id);
}}
onOpenFile={(path) => {
switcherOpen = false;
onOpenFile?.(path);
}}
{selected}
/>
</div>
</Popover.Content>
</Popover.Root>
<LiveDot
class="px-1"
detail={index.live.detail}
label={false}
state={index.live.state}
/>
{#if showFollow}
<button
<Button
aria-label="Follow the active note"
aria-pressed={follow}
class={cn(
"rule-word inline-flex size-7 shrink-0 items-center justify-center rounded-md",
follow && "bg-accent text-foreground"
)}
onclick={() => {
follow = !follow;
}}
size="icon-sm"
title={follow
? "Following the active note - click to pin this conversation"
: "Pinned - click to follow the active note"}
type="button"
variant={follow ? "secondary" : "ghost"}
>
{#if follow}
<Link2Icon class="size-4" />
{:else}
<Link2OffIcon class="size-4" />
{/if}
</button>
</Button>
{/if}
</div>
+32 -59
View File
@@ -1,7 +1,8 @@
<script lang="ts">
import { onMount, untrack } from "svelte";
import { onMount } from "svelte";
import type { ApiClient } from "$lib/api/client";
import Rail from "$lib/rail/rail.svelte";
import EmptyState from "$lib/components/empty-state.svelte";
import ConversationPicker from "./conversation-picker.svelte";
import ConversationView from "./conversation-view.svelte";
import type { ConversationIndex } from "./index.svelte";
import PanelBar from "./panel-bar.svelte";
@@ -12,21 +13,12 @@
selected = $bindable(null),
follow = $bindable(false),
showFollow = false,
companion = false,
onOpenFile,
fallbackToMaster = false,
}: {
client: ApiClient;
index: ConversationIndex;
selected?: string | null;
follow?: boolean;
showFollow?: boolean;
// The thread lives in the note beside this panel: open on activity.
companion?: boolean;
onOpenFile?: (path: string) => void;
// Nothing picked yet: land on the freshest master rather than on a prompt.
// What the phone wants - the panel opens where the day is happening.
fallbackToMaster?: boolean;
} = $props();
// 48rem of panel: below it the switcher names the thread, above it the rail does.
@@ -39,51 +31,25 @@
return () => index.stop();
});
// The latest master that is still open, by its own activity.
const latestMaster = $derived.by(() => {
const masters = index.conversations.filter(
(row) => row.kind === "master" && row.status === "open"
);
let best: (typeof masters)[number] | undefined;
for (const row of masters) {
const when = row.last_activity_at ?? row.created_at ?? "";
const top = best ? (best.last_activity_at ?? best.created_at ?? "") : "";
if (!best || when > top) {
best = row;
}
}
return best ?? null;
});
$effect(() => {
if (!fallbackToMaster || selected || !index.loaded) {
return;
}
const master = latestMaster;
if (master) {
untrack(() => {
selected = master.id;
});
}
});
function open(id: string) {
selected = id;
}
</script>
<!--
impeccable direction contract (seed 071a8c77, mode operate, strip board × live activity, in Obsidian's skin)
THESIS: the agent's front inside the vault, not a website in a pane. The rail is the day's
strips, the thread owns its column; what is alive shows on the strips themselves.
OWN-WORLD: Obsidian's own variables through .beaver-root; strips with a state edge, hairline
rules, tabular numerals; amber only for a question waiting; nothing decorative.
STORY: the operator opens the tab, picks today's strip, answers a question,
watches tools stream; beside a deep chat the sidedock shows the agent's activity, never a
second copy of the note.
FIRST VIEWPORT: narrow: the switcher over the thread and the composer pinned low. Wide:
the rail on the left, the thread to the right.
FORM: strip board × live activity; user-steered; seed key 071a8c77.
impeccable direction contract (mode operate, brief-pinned world: beaver-calendar's Obsidian skin)
THESIS: the agent's terminal, not a chat app. The thread is always on screen; picking a
conversation is a header switch, never a screen of its own. Refuses the messenger's list-first
layout and the admin's chrome.
OWN-WORLD: Obsidian's own variables through .beaver-root - its font, radii, accent, and status
colors; hairline rows, tabular numerals, uppercase 12px section labels; nothing pink.
STORY: the operator opens a note, the panel follows to its conversation, they watch tools and
subagents stream, answer a question, branch off with a chosen seed, send the branch back to
Telegram, and close a deep chat with memory on or off - all from the sidedock.
FIRST VIEWPORT: narrow column: the switcher (kind badge, title, live dot, follow toggle) over
the thread header, tabs, the live thread and the composer pinned at the bottom. Wide tab:
the same with the grouped conversation rail on the left. Primary action: write to the agent.
FORM: two-pane operator console; pinned by the brief, no roll.
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review,
the verdict, and DESIGN.md
-->
@@ -93,8 +59,15 @@ the verdict, and DESIGN.md
>
<div class="flex min-h-0 flex-1">
{#if wide}
<aside class="flex w-80 shrink-0 flex-col border-r">
<Rail {client} {index} onOpen={open} {onOpenFile} {selected} />
<aside class="flex w-72 shrink-0 flex-col border-r bg-sidebar/40">
<ConversationPicker
{client}
{index}
onOpen={open}
{selected}
{showFollow}
bind:follow
/>
</aside>
{/if}
<section class="flex min-w-0 flex-1 flex-col">
@@ -103,7 +76,6 @@ the verdict, and DESIGN.md
{client}
{index}
onOpen={open}
{onOpenFile}
{selected}
{showFollow}
bind:follow
@@ -114,18 +86,19 @@ the verdict, and DESIGN.md
<ConversationView
{client}
id={selected}
initialView={companion ? "activity" : "chat"}
onOpen={open}
showTitle={wide}
/>
{/key}
{:else}
<div class="flex flex-1 items-start justify-center p-4 @md:p-6">
<p class="max-w-md text-muted-foreground text-sm">
{showFollow
? "Open a note with a conversation in its frontmatter, or pick a strip."
: "Pick a strip: the master, a branch, a deep chat."}
</p>
<EmptyState
class="w-full max-w-md"
hint={showFollow
? "Open a note with conversation_id in its frontmatter, or pick one from the list."
: "Pick one from the list: the master, a branch, a deep chat."}
title="No conversation on screen"
/>
</div>
{/if}
</section>
-194
View File
@@ -68,197 +68,3 @@
column-gap: 0.75rem;
align-items: center;
}
/* Strips: the one row vocabulary of the console. A strip is a physical
object in a bay: fixed columns, a state edge, lifts on hover, cocked
(pushed out) while it waits for the operator. */
@layer components {
.strip {
position: relative;
display: grid;
grid-template-columns: 1.5rem minmax(0, 1fr) auto;
column-gap: 0.75rem;
align-items: center;
min-height: 2.75rem;
padding: 0.375rem 0.875rem 0.375rem 0.625rem;
color: var(--foreground);
text-decoration: none;
background: var(--strip);
border-bottom: 1px solid var(--border);
transition:
transform 200ms cubic-bezier(0.2, 0.8, 0.2, 1),
box-shadow 200ms cubic-bezier(0.2, 0.8, 0.2, 1),
background-color 150ms ease-out;
}
.strip::before {
position: absolute;
top: 0.375rem;
bottom: 0.375rem;
left: 0;
width: 3px;
content: "";
background: transparent;
border-radius: 0 2px 2px 0;
transition: background-color 200ms ease-out;
}
.strip:first-child {
border-top-left-radius: var(--radius-md);
border-top-right-radius: var(--radius-md);
}
.strip:last-child {
border-bottom: 0;
border-bottom-right-radius: var(--radius-md);
border-bottom-left-radius: var(--radius-md);
}
.strip:hover,
.strip:focus-visible {
z-index: 1;
box-shadow: var(--shadow-lift);
transform: translateY(-1px);
}
.strip[data-state="running"]::before {
background: var(--signal);
animation: pulse-edge 1.6s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.strip[data-state="waiting"] {
z-index: 1;
box-shadow: var(--shadow-lift);
transform: translateX(0.5rem);
}
.strip[data-state="waiting"]::before {
background: var(--attention);
}
.strip[data-state="waiting"]:hover {
transform: translateX(0.5rem) translateY(-1px);
}
.strip[aria-current="true"] {
background: color-mix(in oklab, var(--strip) 85%, var(--primary) 15%);
}
.strip[data-state="closed"] {
color: var(--muted-foreground);
}
.strip-open {
display: block;
padding: 0.25rem 0.875rem 0.875rem 3rem;
background: var(--strip);
border-bottom: 1px solid var(--border);
}
.strip-open:last-child {
border-bottom: 0;
border-bottom-right-radius: var(--radius-md);
border-bottom-left-radius: var(--radius-md);
}
.bay {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.bay-rack {
background: color-mix(in oklab, var(--rack) 70%, var(--strip) 30%);
border: 1px solid var(--border);
border-radius: calc(var(--radius-md) + 1px);
box-shadow: inset 0 1px 0
color-mix(in oklab, var(--foreground) 4%, transparent);
}
.island {
display: inline-flex;
gap: 0.625rem;
align-items: center;
height: 2.125rem;
padding: 0 0.875rem 0 0.75rem;
font-size: var(--text-sm);
color: var(--foreground);
white-space: nowrap;
background: var(--strip);
border: 1px solid var(--border);
border-radius: 999px;
box-shadow: 0 1px 2px oklch(0.2 0.06 340 / 0.06);
transition:
transform 220ms cubic-bezier(0.2, 0.9, 0.25, 1.1),
box-shadow 220ms ease-out,
background-color 150ms ease-out;
}
.island:hover,
.island[aria-expanded="true"] {
box-shadow: var(--shadow-lift);
transform: scale(1.02);
}
.island-sep {
width: 1px;
height: 1rem;
background: var(--border);
}
.doc {
max-width: 78ch;
}
}
@keyframes pulse-edge {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
@layer components {
.strip-child {
padding-left: 1.75rem;
}
.strip-child::before {
left: 1.125rem;
}
}
/* Menus and selects: one quiet sheet, rows that stay inside it. Unlayered
on purpose - the generated components carry pill utilities of their own. */
[data-slot="dropdown-menu-content"],
[data-slot="context-menu-content"],
[data-slot="select-content"] {
--tw-ring-shadow: 0 0 #0000;
--tw-ring-offset-shadow: 0 0 #0000;
padding: 0.25rem;
background: var(--popover);
border: 1px solid var(--border);
border-radius: 0.625rem;
box-shadow: var(--shadow-float);
}
[data-slot="dropdown-menu-content"]::before,
[data-slot="context-menu-content"]::before,
[data-slot="select-content"]::before {
display: none;
}
[data-slot="dropdown-menu-item"],
[data-slot="dropdown-menu-checkbox-item"],
[data-slot="context-menu-item"],
[data-slot="context-menu-checkbox-item"],
[data-slot="select-item"] {
min-height: 1.75rem;
padding: 0.375rem 0.5rem;
font-weight: 400;
border-radius: 0.375rem;
}
[data-slot="select-item"] {
padding-right: 2rem;
}
[data-slot="dropdown-menu-checkbox-item"],
[data-slot="context-menu-checkbox-item"] {
padding-right: 2rem;
}
[data-slot$="-item"]:focus,
[data-slot$="-item"][data-highlighted] {
color: var(--accent-foreground);
background: var(--accent);
}
[data-slot$="-item"][data-variant="destructive"]:focus,
[data-slot$="-item"][data-variant="destructive"][data-highlighted] {
color: var(--destructive);
background: color-mix(in oklab, var(--destructive) 12%, transparent);
}
[data-slot="dropdown-menu-separator"],
[data-slot="context-menu-separator"] {
margin: 0.25rem -0.25rem;
background: var(--border);
}
+1 -1
View File
@@ -53,7 +53,7 @@
<span class={cn("size-2 rounded-full", DOT[node.status])}></span>
<span class="flex min-w-0 items-baseline gap-2">
<span class={cn("shrink-0 font-medium", subagent && "text-kind-deep")}>
{subagent || node.name === "?" ? "Agent" : toolLabel(node.name)}
{toolLabel(node.name)}
</span>
{#if summary}
<span class="truncate text-muted-foreground">{summary}</span>
+9 -20
View File
@@ -9,7 +9,7 @@
fmtTokens,
} from "$lib/format";
import { cn } from "$lib/utils";
import { isLive, ORIGIN_LABELS, type Turn } from "./activity.svelte";
import type { Turn } from "./activity.svelte";
import Markdown from "./markdown.svelte";
import ToolNodeView from "./tool-node.svelte";
@@ -18,35 +18,26 @@
const duration = $derived(
turn.usage?.duration_ms ?? elapsedMs(turn.startedAt, turn.endedAt, now)
);
const origin = $derived(ORIGIN_LABELS[turn.origin] ?? turn.origin);
const originLabel = $derived(
turn.itemOrigin && turn.itemOrigin !== turn.origin
? `${origin} · ${turn.itemOrigin}`
: origin
);
const pill = $derived(
turn.status !== "running" && isLive(turn) ? "running" : turn.status
? `${turn.origin} · ${turn.itemOrigin}`
: turn.origin
);
const toolCount = $derived(Object.keys(turn.nodes).length);
</script>
<article
class={cn(
"flex flex-col gap-2 border-b py-3",
pill === "running" && "bg-signal/[0.03]"
"flex animate-rise flex-col gap-2 border-b py-3",
turn.status === "running" && "bg-signal/[0.03]"
)}
>
<header class="flex flex-wrap items-center gap-x-3 gap-y-1 px-1 text-xs">
{#if turn.origin !== "agent"}
<StatusPill status={pill} />
{/if}
<StatusPill status={turn.status} />
<span
class={cn(
"font-medium",
turn.origin === "inject" && "text-note",
turn.origin === "agent" || turn.origin === "task"
? "text-kind-deep"
: "text-foreground"
turn.origin === "inject" ? "text-note" : "text-foreground"
)}
>
{originLabel}
@@ -74,9 +65,7 @@
</span>
{/if}
</header>
{#if turn.userText && turn.origin === "task"}
<p class="mx-1 px-1 text-muted-foreground text-xs">{turn.userText}</p>
{:else if turn.userText}
{#if turn.userText}
<Markdown
class="mx-1 rounded-md bg-muted/50 px-2.5 py-1.5"
text={turn.userText}
@@ -99,7 +88,7 @@
{/each}
{#if turn.text}
<Markdown class="mx-1 px-1" text={turn.text} />
{:else if turn.status === "running" && turn.origin !== "agent" && turn.roots.length === 0 && turn.thinking === 0}
{:else if turn.status === "running" && turn.roots.length === 0 && turn.thinking === 0}
<p class="mx-1 px-1 text-muted-foreground text-sm">
Waiting for the model…
</p>
-148
View File
@@ -1,148 +0,0 @@
import type { ConversationSummary } from "$lib/api/types";
import { parseDate } from "$lib/format";
import { byActivity } from "$lib/shell/state";
export interface DayGroup {
branches: ConversationSummary[];
day: string;
deep: ConversationSummary[];
forks: ConversationSummary[];
jobs: ConversationSummary[];
label: string;
live: boolean;
master: ConversationSummary | null;
others: ConversationSummary[];
}
const DAY_MS = 86_400_000;
function localDay(iso: string | null | undefined): string {
const date = parseDate(iso) ?? new Date();
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
export function dayLabel(day: string, now = new Date()): string {
const today = localDay(now.toISOString());
const yesterday = localDay(new Date(now.getTime() - DAY_MS).toISOString());
if (day === today) {
return "Today";
}
if (day === yesterday) {
return "Yesterday";
}
const date = new Date(`${day}T12:00:00`);
return date.toLocaleDateString(undefined, {
day: "numeric",
month: "short",
weekday: "short",
});
}
function activityOf(row: ConversationSummary): string {
return row.last_activity_at ?? row.created_at ?? "";
}
// Days as the operator remembers them: the master that began that day,
// and under it everything that last moved that day - branches, deep
// chats, forks, job runs. Open branches follow the master through the
// rotation, so their parent says nothing about when they were alive;
// the day of their last word does.
export function groupByDay(
rows: ConversationSummary[],
now = new Date()
): DayGroup[] {
const groups = new Map<string, DayGroup>();
const group = (day: string): DayGroup => {
let found = groups.get(day);
if (!found) {
found = {
branches: [],
day,
deep: [],
forks: [],
jobs: [],
label: dayLabel(day, now),
live: false,
master: null,
others: [],
};
groups.set(day, found);
}
return found;
};
const masters = rows
.filter((row) => row.kind === "master")
.sort((a, b) => (b.created_at ?? "").localeCompare(a.created_at ?? ""));
for (const master of masters) {
const g = group(localDay(master.created_at));
if (!g.master || master.status === "open") {
g.master = master;
} else {
g.others.push(master);
}
}
for (const row of rows) {
if (row.kind === "master") {
continue;
}
const g = group(localDay(activityOf(row)));
if (row.kind === "branch") {
g.branches.push(row);
} else if (row.kind === "deep") {
g.deep.push(row);
} else if (row.kind === "fork") {
g.forks.push(row);
} else if (row.kind === "job") {
g.jobs.push(row);
} else {
g.others.push(row);
}
}
const out = [...groups.values()];
for (const g of out) {
g.branches.sort(byActivity);
g.deep.sort(byActivity);
g.forks.sort(byActivity);
g.jobs.sort(byActivity);
g.others.sort(byActivity);
g.live = [
g.master,
...g.branches,
...g.deep,
...g.forks,
...g.jobs,
...g.others,
].some((row) => row && (row.running_turn || row.pending_question));
}
return out.sort((a, b) => b.day.localeCompare(a.day));
}
// One phrase for the day header: what the day held, and whether its
// master is still the live one.
export function daySummary(group: DayGroup, today: boolean): string {
const parts: string[] = [];
if (group.master?.status === "open" && !today) {
parts.push("master still open");
}
const count = (n: number, one: string, many = `${one}s`) =>
`${n} ${n === 1 ? one : many}`;
if (group.branches.length) {
parts.push(count(group.branches.length, "branch", "branches"));
}
if (group.deep.length) {
parts.push(count(group.deep.length, "deep", "deep"));
}
if (group.forks.length) {
parts.push(count(group.forks.length, "digest"));
}
if (group.jobs.length) {
parts.push(count(group.jobs.length, "job run"));
}
if (group.others.length) {
parts.push(count(group.others.length, "more", "more"));
}
return parts.join(" · ");
}
-139
View File
@@ -1,139 +0,0 @@
<script lang="ts">
import { toast } from "svelte-sonner";
import type { ApiClient } from "$lib/api/client";
import type { AgentInfo } from "$lib/api/types";
import { Button } from "$lib/components/ui/button";
import * as Dialog from "$lib/components/ui/dialog";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import * as Select from "$lib/components/ui/select";
let {
client,
open = $bindable(false),
onCreated,
}: {
client: ApiClient;
open?: boolean;
onCreated: (id: string) => void;
} = $props();
const KINDS = ["deep", "branch", "master", "job"];
let agents = $state<AgentInfo[]>([]);
let form = $state({ agent: "", kind: "deep", text: "", title: "" });
let busy = $state(false);
$effect(() => {
if (open && agents.length === 0) {
client
.agents()
.then((result) => {
({ agents } = result);
})
.catch(() => undefined);
}
});
const agentsForKind = $derived(
agents.filter((a) =>
a.kinds.includes(form.kind as AgentInfo["kinds"][number])
)
);
async function create() {
busy = true;
try {
const created = await client.spawn({
agent: form.agent || undefined,
kind: form.kind,
seed: "clean",
text: form.text || undefined,
title: form.title || undefined,
});
open = false;
form = { agent: "", kind: "deep", text: "", title: "" };
onCreated(created.id);
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
busy = false;
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>New conversation</Dialog.Title>
<Dialog.Description>
It opens in the home window of its kind (a vault file, a Telegram topic)
and stays silent until someone speaks.
</Dialog.Description>
</Dialog.Header>
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1.5">
<Label>Kind</Label>
<Select.Root
onValueChange={(value) => {
form.kind = value;
form.agent = "";
}}
type="single"
value={form.kind}
>
<Select.Trigger class="w-full">{form.kind}</Select.Trigger>
<Select.Content>
{#each KINDS as option (option)}
<Select.Item label={option} value={option} />
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="flex flex-col gap-1.5">
<Label>Agent</Label>
<Select.Root
onValueChange={(value) => {
form.agent = value === "default" ? "" : value;
}}
type="single"
value={form.agent || "default"}
>
<Select.Trigger class="w-full">
{form.agent || "frontend default"}
</Select.Trigger>
<Select.Content>
<Select.Item label="frontend default" value="default" />
{#each agentsForKind as agent (agent.name)}
<Select.Item label={agent.name} value={agent.name} />
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="col-span-2 flex flex-col gap-1.5">
<Label for="new-title">Title</Label>
<Input id="new-title" placeholder="optional" bind:value={form.title} />
</div>
<div class="col-span-2 flex flex-col gap-1.5">
<Label for="new-text">First message</Label>
<textarea
class="min-h-20 rounded-md border bg-input/30 px-3 py-2 text-sm focus-visible:border-ring focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/30"
id="new-text"
placeholder="optional - without it the window waits"
bind:value={form.text}
></textarea>
</div>
</div>
<Dialog.Footer>
<Button
onclick={() => {
// biome-ignore lint/suspicious/noGlobalAssign: bindable prop, not window.open
open = false;
}}
variant="ghost"
>
Cancel
</Button>
<Button disabled={busy} onclick={create}>Create</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
-408
View File
@@ -1,408 +0,0 @@
<script lang="ts">
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
import PlusIcon from "@lucide/svelte/icons/plus";
import SearchIcon from "@lucide/svelte/icons/search";
import { onMount } from "svelte";
import type { ApiClient } from "$lib/api/client";
import type { LiveState } from "$lib/api/live.svelte";
import type {
ConversationSummary,
SearchFile,
SearchResponse,
} from "$lib/api/types";
import ErrorNote from "$lib/components/error-note.svelte";
import { clip } from "$lib/format";
import type { ConversationIndex } from "$lib/panel/index.svelte";
import Strip from "$lib/shell/strip.svelte";
import { cn } from "$lib/utils";
import { type DayGroup, daySummary, groupByDay } from "./days";
import NewConversation from "./new-conversation.svelte";
let {
client,
index,
selected = null,
href,
onOpen,
onOpenFile,
class: className = "",
}: {
client: ApiClient;
index: ConversationIndex;
selected?: string | null;
href?: (id: string) => string;
onOpen?: (id: string) => void;
onOpenFile?: (path: string) => void;
class?: string;
} = $props();
const SEARCH_MIN = 2;
const SEARCH_DEBOUNCE_MS = 250;
const DAYS_SHOWN = 14;
const TICK_MS = 30_000;
// The connection, said in one dot beside the search box: the panel can sit
// untouched for hours on a phone, and this is where it admits it went quiet.
const LIVE_DOT: Record<LiveState, string> = {
connecting: "bg-warn",
failed: "bg-destructive",
off: "bg-muted-foreground/40",
open: "bg-ok",
retrying: "bg-warn animate-pulse-dot",
};
const LIVE_LABEL: Record<LiveState, string> = {
connecting: "Connecting",
failed: "Offline",
off: "Not connected",
open: "Live",
retrying: "Reconnecting",
};
let query = $state("");
let expanded = $state<Set<string>>(new Set());
let remote = $state<SearchResponse | null>(null);
let searching = $state(false);
let createOpen = $state(false);
let now = $state(Date.now());
let timer: ReturnType<typeof setTimeout> | undefined;
onMount(() => {
const tick = setInterval(() => {
now = Date.now();
}, TICK_MS);
return () => clearInterval(tick);
});
const needle = $derived(query.trim().toLowerCase());
const groups = $derived(groupByDay(index.conversations, new Date(now)));
const local = $derived.by((): ConversationSummary[] => {
if (!needle) {
return [];
}
return index.conversations.filter(
(row) =>
(row.title ?? "").toLowerCase().includes(needle) ||
row.id.startsWith(needle) ||
(row.last_item?.text ?? "").toLowerCase().includes(needle)
);
});
const found = $derived.by((): ConversationSummary[] => {
const seen = new Set(local.map((row) => row.id));
const extra = (remote?.query === needle ? remote.conversations : []).filter(
(row) => !seen.has(row.id)
);
return [...local, ...extra];
});
const files = $derived<SearchFile[]>(
remote?.query === needle ? remote.files : []
);
$effect(() => {
const q = needle;
clearTimeout(timer);
if (q.length < SEARCH_MIN) {
remote = null;
searching = false;
return;
}
searching = true;
timer = setTimeout(async () => {
try {
const result = await client.search(q);
if (result.query === query.trim().toLowerCase()) {
remote = result;
}
} catch {
remote = null;
} finally {
searching = false;
}
}, SEARCH_DEBOUNCE_MS);
return () => clearTimeout(timer);
});
function isOpen(group: DayGroup, position: number): boolean {
return position === 0 || group.live || expanded.has(group.day);
}
function toggle(day: string) {
const next = new Set(expanded);
if (next.has(day)) {
next.delete(day);
} else {
next.add(day);
}
expanded = next;
}
let jobsOpen = $state<Set<string>>(new Set());
function toggleJobs(day: string) {
const next = new Set(jobsOpen);
if (next.has(day)) {
next.delete(day);
} else {
next.add(day);
}
jobsOpen = next;
}
const refresh = () => index.load().catch(() => undefined);
const warm = (id: string) => index.prefetch([id]);
// What the agent did on its own that day: digests and scheduled runs.
function quietSummary(group: DayGroup): string {
const parts: string[] = [];
if (group.forks.length) {
parts.push(
`${group.forks.length} ${group.forks.length === 1 ? "digest" : "digests"}`
);
}
if (group.jobs.length) {
parts.push(
`${group.jobs.length} ${group.jobs.length === 1 ? "job run" : "job runs"}`
);
}
return parts.join(" · ");
}
const linkOf = (id: string) => href?.(id);
</script>
<div class={cn("flex h-full min-h-0 flex-col", className)}>
<div class="flex items-center gap-2 px-3 py-2">
<label
class="flex h-8 min-w-0 flex-1 items-center gap-2 rounded-md bg-strip px-2 ring-1 ring-border focus-within:ring-ring"
>
<SearchIcon class="size-3.5 shrink-0 text-icon" />
<input
aria-label="Search conversations and memory"
class="h-full w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
placeholder="Search"
spellcheck="false"
type="search"
bind:value={query}
>
{#if searching}
<span
class="size-1.5 shrink-0 animate-pulse-dot rounded-full bg-signal"
></span>
{/if}
</label>
<button
aria-label={LIVE_LABEL[index.live.state]}
class="grid size-8 shrink-0 place-items-center rounded-md hover:bg-accent"
onclick={() => index.reconnect()}
title="{LIVE_LABEL[index.live.state]} — tap to reconnect"
type="button"
>
<span
class={cn("size-2 rounded-full", LIVE_DOT[index.live.state])}
></span>
</button>
<button
aria-label="New conversation"
class="rule-word inline-flex size-8 items-center justify-center rounded-md hover:bg-accent"
onclick={() => {
createOpen = true;
}}
type="button"
>
<PlusIcon class="size-4" />
</button>
</div>
<div class="min-h-0 flex-1 overflow-y-auto px-3 pb-6">
{#if index.live.state === "failed"}
<ErrorNote
message={index.live.detail ?? "event stream failed"}
retry={() => index.start()}
/>
{:else if !index.loaded}
<p class="px-1 py-3 text-muted-foreground text-sm">Loading…</p>
{:else if needle}
<section class="flex flex-col gap-2 pt-1">
<h2 class="label-quiet px-1">
{found.length === 0 && !searching ? "Nothing matches" : "Conversations"}
</h2>
{#if found.length > 0}
<div class="bay-rack">
{#each found as row (row.id)}
<Strip
{client}
current={row.id === selected}
detail={row.last_item?.text ?? ""}
href={linkOf(row.id)}
{now}
onChanged={refresh}
onclick={onOpen}
onWarm={warm}
{row}
/>
{/each}
</div>
{/if}
{#if files.length > 0}
<h2 class="label-quiet px-1 pt-2">Memory</h2>
<div class="bay-rack">
{#each files as file (file.path)}
<button
class="strip w-full text-left text-sm"
onclick={() => onOpenFile?.(file.path)}
type="button"
>
<span class="size-5"></span>
<span class="flex min-w-0 flex-col leading-tight">
<span class="truncate font-medium">{file.path}</span>
{#if file.snippet}
<span class="truncate text-muted-foreground text-xs">
{clip(file.snippet, 96)}
</span>
{/if}
</span>
<span class="tabular text-muted-foreground text-xs">
{file.line ? `:${file.line}` : ""}
</span>
</button>
{/each}
</div>
{/if}
</section>
{:else}
{#each groups.slice(0, DAYS_SHOWN) as group, position (group.day)}
{@const shown = isOpen(group, position)}
<section class="flex flex-col gap-1.5 pt-2">
<button
aria-expanded={shown}
class="flex items-center gap-1.5 px-1 text-left"
onclick={() => toggle(group.day)}
type="button"
>
<ChevronRightIcon
class={cn(
"size-3.5 text-icon transition-transform duration-150",
shown && "rotate-90"
)}
/>
<span
class={cn(
"font-medium text-sm",
position === 0 ? "text-foreground" : "text-muted-foreground"
)}
>
{group.label}
</span>
{#if group.live}
<span
class="size-1.5 rounded-full bg-signal animate-pulse-dot"
></span>
{/if}
<span class="truncate text-muted-foreground text-xs">
{daySummary(group, position === 0)}
</span>
</button>
{#if shown}
<div class="bay-rack">
{#if group.master}
<Strip
{client}
current={group.master.id === selected}
detail={group.master.last_item?.text ?? ""}
href={linkOf(group.master.id)}
{now}
onChanged={refresh}
onclick={onOpen}
onWarm={warm}
row={group.master}
/>
{/if}
{#each group.branches as row (row.id)}
<Strip
{client}
current={row.id === selected}
detail={row.last_item?.text ?? ""}
href={linkOf(row.id)}
{now}
onChanged={refresh}
onclick={onOpen}
onWarm={warm}
{row}
/>
{/each}
{#each group.deep as row (row.id)}
<Strip
{client}
current={row.id === selected}
detail={row.last_item?.text ?? ""}
href={linkOf(row.id)}
{now}
onChanged={refresh}
onclick={onOpen}
onWarm={warm}
{row}
/>
{/each}
{#each group.others as row (row.id)}
<Strip
{client}
current={row.id === selected}
detail="{row.kind} · {row.status}"
href={linkOf(row.id)}
{now}
onChanged={refresh}
onclick={onOpen}
onWarm={warm}
{row}
/>
{/each}
{#if group.forks.length + group.jobs.length > 0}
{@const shownQuiet = jobsOpen.has(group.day)}
<button
aria-expanded={shownQuiet}
class="strip w-full text-left text-muted-foreground text-xs"
onclick={() => toggleJobs(group.day)}
type="button"
>
<span class="size-5"></span>
<span>
{quietSummary(group)}
· {shownQuiet ? "hide" : "show"}
</span>
<span></span>
</button>
{#if shownQuiet}
{#each [...group.forks, ...group.jobs] as row (row.id)}
<Strip
{client}
current={row.id === selected}
detail={row.last_item?.text ?? `${row.kind} · ${row.status}`}
href={linkOf(row.id)}
{now}
onChanged={refresh}
onclick={onOpen}
onWarm={warm}
{row}
/>
{/each}
{/if}
{/if}
</div>
{/if}
</section>
{/each}
{#if groups.length === 0}
<p class="px-1 py-3 text-muted-foreground text-sm">
No conversations yet. Start one, or wait for the morning master.
</p>
{/if}
{/if}
</div>
</div>
<NewConversation
{client}
onCreated={(id) => onOpen?.(id)}
bind:open={createOpen}
/>
-46
View File
@@ -1,46 +0,0 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { cn } from "$lib/utils";
let {
label,
count = null,
tone = "default",
hint = "",
class: className = "",
children,
aside,
}: {
label: string;
count?: number | null;
tone?: "default" | "attention" | "signal";
hint?: string;
class?: string;
children?: Snippet;
aside?: Snippet;
} = $props();
const TONE: Record<string, string> = {
attention: "text-attention-foreground",
default: "text-muted-foreground",
signal: "text-signal",
};
</script>
<section class={cn("bay", className)}>
<header class="flex items-baseline gap-2 px-1">
<h2 class={cn("font-medium text-sm", TONE[tone])}>{label}</h2>
{#if count !== null}
<span class={cn("tabular text-xs", TONE[tone])}>{count}</span>
{/if}
{#if hint}
<span class="truncate text-muted-foreground text-xs">{hint}</span>
{/if}
{#if aside}
<span class="ml-auto">{@render aside()}</span>
{/if}
</header>
<div class="bay-rack">
{@render children?.()}
</div>
</section>
-597
View File
@@ -1,597 +0,0 @@
<script lang="ts">
import { onMount, untrack } from "svelte";
import { goto } from "$app/navigation";
import { base } from "$app/paths";
import type {
ConversationSummary,
JobInfo,
QueuedJob,
VaultGraph,
} from "$lib/api/types";
import {
clip,
fmtCountdown,
fmtRelative,
fmtTime,
shortId,
} from "$lib/format";
import { gateway } from "$lib/gateway.svelte";
import { summarizeInput, toolLabel } from "$lib/panel/activity.svelte";
import QuestionCard from "$lib/panel/question-card.svelte";
import { session } from "$lib/session.svelte";
import { ui } from "$lib/ui.svelte";
import { cn } from "$lib/utils";
import Bay from "./bay.svelte";
import FloatMenu, { type FloatItem } from "./float-menu.svelte";
import Graph, { type GraphEdge, type GraphNode } from "./graph.svelte";
import Instruments from "./instruments.svelte";
import KindMark from "./kind-mark.svelte";
import { now as board } from "./now.svelte";
import { stateOf } from "./state";
import Strip from "./strip.svelte";
let {
compact = false,
}: {
compact?: boolean;
} = $props();
const TICK_MS = 1000;
const QUIET_SHOWN = 6;
const TAPE_SHOWN = 14;
const HOURS_DAY = 24;
const DEEP_RECENT_MS = 36 * 3_600_000;
const SCHEDULED_SHOWN = 8;
const PAYLOAD_MAX = 90;
let now = $state(Date.now());
let spend = $state<number | null>(null);
let vault = $state<VaultGraph | null>(null);
let vaultFor = $state<string | null>(null);
let quietOpen = $state(false);
let menuOpen = $state(false);
let menuAt = $state({ x: 0, y: 0 });
let menuItems = $state<FloatItem[]>([]);
onMount(() => {
const tick = setInterval(() => {
now = Date.now();
}, TICK_MS);
const { client } = session;
if (client) {
client
.usage({ group_by: "agent", hours: HOURS_DAY })
.then((usage) => {
spend = usage.total.cost_usd;
})
.catch(() => undefined);
}
return () => clearInterval(tick);
});
$effect(() => {
const key = board.liveIds.join("|");
if (key.length >= 0) {
untrack(() => board.refreshSnapshots().catch(() => undefined));
}
});
const href = (id: string) => `${base}/conversations/${id}`;
const GRAPH_FILES = 24;
const refresh = () => gateway.load().catch(() => undefined);
const warm = (id: string) => gateway.prefetch([id]);
async function loadVault(masterId: string) {
const { client } = session;
if (!client) {
return;
}
try {
const context = await client.context(masterId);
const paths = context.files.slice(0, GRAPH_FILES).map((f) => f.path);
vault = paths.length > 0 ? await client.vaultGraph(paths, 60) : null;
} catch {
vault = null;
}
vaultFor = masterId;
}
$effect(() => {
const { master } = board;
if (!master || master.running_turn) {
return;
}
const key = `${master.id}:${master.last_activity_at ?? ""}`;
if (vaultFor !== key) {
vaultFor = key;
untrack(() => loadVault(master.id));
}
});
function open(id: string) {
ui.closeAll();
goto(href(id));
}
function lastTool(row: ConversationSummary): string {
const info = board.snapshots[row.id];
const tools = info?.turn?.tools ?? [];
const live = tools.filter((t) => !t.ended_at);
const tool = live.at(-1) ?? tools.at(-1);
if (!tool) {
return info?.turn?.text ? clip(info.turn.text, 80) : "thinking";
}
const what = summarizeInput(tool.name, tool.input);
const short = what.startsWith("/")
? what.split("/").slice(-2).join("/")
: what;
return `${toolLabel(tool.name)} · ${clip(short, 72)}`;
}
const quiet = $derived(
quietOpen ? board.quiet : board.quiet.slice(0, QUIET_SHOWN)
);
const windows = $derived(gateway.limits?.windows ?? []);
const tape = $derived(gateway.tape.slice(0, TAPE_SHOWN));
// What is coming: queued injects and the next cron runs, soonest first.
interface Coming {
at: string;
detail: string;
id: string;
job: JobInfo | null;
kind: "queue" | "job";
label: string;
queued: QueuedJob | null;
}
const coming = $derived.by((): Coming[] => {
const snapshot = gateway.jobs;
if (!snapshot) {
return [];
}
const out: Coming[] = [];
for (const q of snapshot.queue) {
if (q.status === "queued" && q.execute_after) {
const { text } = q.payload;
out.push({
at: q.execute_after,
detail: typeof text === "string" ? text : q.entrypoint,
id: `q:${q.id}`,
job: null,
kind: "queue",
label: q.entrypoint.replace(INJECT_PREFIX, "inject "),
queued: q,
});
}
}
for (const job of snapshot.jobs) {
if (job.next_run) {
out.push({
at: job.next_run,
detail: `${job.cron ?? "manual"} · ${lastRun(job)}`,
id: `j:${job.name}`,
job,
kind: "job",
label: job.name,
queued: null,
});
}
}
return out
.sort((a, b) => a.at.localeCompare(b.at))
.slice(0, SCHEDULED_SHOWN);
});
function lastRun(job: JobInfo): string {
if (job.run) {
return `${job.run.status} ${fmtRelative(job.run.started_at, now)}`;
}
return job.last_run ? `ran ${fmtRelative(job.last_run, now)}` : "never ran";
}
const MD_SUFFIX = /\.md$/;
const INJECT_PREFIX = /^inject[._-]?/;
const SECONDS = /:\d{2}$/;
function recent(row: ConversationSummary): boolean {
const at = Date.parse(row.last_activity_at ?? row.created_at ?? "");
return Number.isFinite(at) && now - at < DEEP_RECENT_MS;
}
// The master, what hangs off it, what moves, and what spoke lately:
// not every open thread from the past week.
function conversationNodes(master: ConversationSummary | null): {
nodes: GraphNode[];
edges: GraphEdge[];
} {
const nodes: GraphNode[] = [];
const edges: GraphEdge[] = [];
if (master) {
nodes.push({
href: href(master.id),
id: master.id,
kind: "master",
label: master.title ?? "master",
ring: 0,
state: stateOf(master),
});
}
for (const row of gateway.conversations) {
if (row.id === master?.id || row.kind === "master") {
continue;
}
const child = Boolean(master) && row.parent === master?.id;
const live = Boolean(row.running_turn) || row.pending_question;
const shown = live || (row.status === "open" && (child || recent(row)));
if (!shown) {
continue;
}
nodes.push({
href: href(row.id),
id: row.id,
kind: row.kind,
label: row.title ?? `${row.kind} ${shortId(row.id)}`,
ring: child || live ? 1 : 2,
state: stateOf(row),
});
if (master && child) {
edges.push({ from: master.id, to: row.id });
}
}
return { edges, nodes };
}
function noteState(touched: boolean, exists: boolean): GraphNode["state"] {
if (!touched) {
return "ghost";
}
return exists ? "quiet" : "closed";
}
function vaultNodes(
master: ConversationSummary,
graphData: VaultGraph
): { nodes: GraphNode[]; edges: GraphEdge[] } {
const nodes: GraphNode[] = graphData.nodes.map((note) => ({
href: note.exists ? note.path : undefined,
id: note.path,
kind: "file",
label: note.title,
ring: note.touched ? 1 : 2,
state: noteState(note.touched, note.exists),
}));
const edges: GraphEdge[] = graphData.nodes
.filter((note) => note.touched)
.map((note) => ({ from: master.id, to: note.path }));
for (const edge of graphData.edges) {
edges.push({ from: edge.from, to: edge.to });
}
return { edges, nodes };
}
const graph = $derived.by((): { nodes: GraphNode[]; edges: GraphEdge[] } => {
const { master } = board;
const own = conversationNodes(master);
if (!(vault && master)) {
return own;
}
const extra = vaultNodes(master, vault);
return {
edges: [...own.edges, ...extra.edges],
nodes: [...own.nodes, ...extra.nodes],
};
});
function obsidianUrl(path: string): string {
return `obsidian://open?file=${encodeURIComponent(path.replace(MD_SUFFIX, ""))}`;
}
function openNode(id: string) {
if (id.endsWith(".md")) {
window.open(obsidianUrl(id));
return;
}
open(id);
}
function nodeMenu(node: GraphNode, x: number, y: number) {
menuAt = { x, y };
if (node.kind === "file") {
menuItems = [
{
label: "Open in Obsidian",
run: () => window.open(obsidianUrl(node.id)),
},
{
label: "Open in Memory",
run: () => {
ui.closeAll();
goto(`${base}/memory?path=${encodeURIComponent(node.id)}`);
},
},
{
gap: true,
label: "Copy path",
run: () =>
navigator.clipboard.writeText(node.id).catch(() => undefined),
},
];
} else {
menuItems = [
{ label: "Open", run: () => open(node.id) },
{
label: "Open in a new tab",
run: () => window.open(href(node.id), "_blank", "noopener"),
},
{
gap: true,
label: "Copy id",
run: () =>
navigator.clipboard.writeText(node.id).catch(() => undefined),
},
];
}
menuOpen = true;
}
function comingMenu(event: MouseEvent, item: Coming) {
event.preventDefault();
menuAt = { x: event.clientX, y: event.clientY };
const items: FloatItem[] = [
{
label: "Open the scheduler",
run: () => {
ui.closeAll();
goto(`${base}/system/jobs`);
},
},
];
if (item.job && session.client) {
const { client } = session;
const { name } = item.job;
items.push({
label: "Run now",
run: () =>
client
.runJob(name)
.then(refreshJobs)
.catch(() => undefined),
});
}
if (item.queued && session.client) {
const { client } = session;
const { id } = item.queued;
items.push({
danger: true,
gap: true,
label: "Cancel",
run: () =>
client
.cancelJob(id)
.then(refreshJobs)
.catch(() => undefined),
});
}
menuItems = items;
menuOpen = true;
}
const refreshJobs = () => gateway.refreshJobs().catch(() => undefined);
</script>
<FloatMenu items={menuItems} x={menuAt.x} y={menuAt.y} bind:open={menuOpen} />
<div
class={cn(
"@container grid gap-8",
compact
? "grid-cols-1"
: "grid-cols-1 lg:grid-cols-[minmax(0,1fr)_22rem] xl:grid-cols-[minmax(0,1fr)_26rem]"
)}
>
<div class="flex min-w-0 flex-col gap-8">
<Instruments
{compact}
contextTokens={board.contextTokens}
contextWindow={board.contextWindow}
{now}
{spend}
{windows}
/>
{#if board.waiting.length > 0}
<Bay
count={board.waiting.length}
hint="the agent asked and stopped"
label="Waiting for you"
tone="attention"
>
{#each board.waiting as row (row.id)}
{@const info = board.snapshots[row.id]}
<Strip
client={session.client}
detail={info?.question?.questions[0]?.question ?? "question pending"}
href={href(row.id)}
{now}
onChanged={refresh}
onclick={open}
onWarm={warm}
open={Boolean(info?.question)}
{row}
state="waiting"
>
{#if info?.question && session.client}
<QuestionCard
client={session.client}
conversationId={row.id}
question={info.question}
/>
{/if}
</Strip>
{/each}
</Bay>
{/if}
<Bay
count={board.running.length}
hint={board.running.length === 0 ? "nothing in a turn" : ""}
label="In motion"
tone="signal"
>
{#if board.running.length === 0}
<p class="px-4 py-3 text-muted-foreground text-sm">
Idle. The next message or inject lights a strip here.
</p>
{:else}
{#each board.running as row (row.id)}
<Strip
client={session.client}
detail={lastTool(row)}
href={href(row.id)}
{now}
onChanged={refresh}
onclick={open}
onWarm={warm}
{row}
state="running"
/>
{/each}
{/if}
</Bay>
{#if coming.length > 0}
<Bay
count={coming.length}
hint="queued injects and the next cron runs"
label="Coming up"
>
{#snippet aside()}
<a class="rule-word text-xs" href="{base}/system/jobs">scheduler</a>
{/snippet}
{#each coming as item (item.id)}
<a
class="strip text-left text-sm"
href="{base}/system/jobs"
oncontextmenu={(event) => comingMenu(event, item)}
title="right-click for actions"
>
{#if item.kind === "job"}
<KindMark kind="job" />
{:else}
<span
class="inline-flex size-5 shrink-0 items-center justify-center rounded-full bg-muted text-[11px] text-muted-foreground"
title="queued inject"
>
↳
</span>
{/if}
<span class="flex min-w-0 flex-col leading-tight">
<span class="truncate font-medium">{item.label}</span>
<span class="truncate text-muted-foreground text-xs">
{clip(item.detail, PAYLOAD_MAX)}
</span>
</span>
<span
class="tabular shrink-0 text-muted-foreground text-xs"
title={fmtTime(item.at)}
>
{fmtCountdown(item.at, now).replace("resets ", "")}
</span>
</a>
{/each}
</Bay>
{/if}
<Bay
count={board.quiet.length}
hint="open, waiting for the next word"
label="Quiet"
>
{#snippet aside()}
{#if board.quiet.length > QUIET_SHOWN}
<button
class="rule-word text-xs"
onclick={() => {
quietOpen = !quietOpen;
}}
type="button"
>
{quietOpen ? "fewer" : `all ${board.quiet.length}`}
</button>
{/if}
{/snippet}
{#if !gateway.loaded}
<p class="px-4 py-3 text-muted-foreground text-sm">Loading…</p>
{:else if quiet.length === 0}
<p class="px-4 py-3 text-muted-foreground text-sm">
No open conversations.
</p>
{:else}
{#each quiet as row (row.id)}
<Strip
client={session.client}
detail={row.last_item?.text ?? ""}
href={href(row.id)}
{now}
onChanged={refresh}
onclick={open}
onWarm={warm}
{row}
state="quiet"
/>
{/each}
{/if}
</Bay>
</div>
{#if !compact}
<aside class="hidden min-w-0 flex-col gap-6 lg:flex">
<section class="flex flex-col gap-2">
<h2 class="label-quiet px-1">Around the master</h2>
<div class="h-[26rem] overflow-hidden rounded-lg border bg-strip/60">
<Graph
edges={graph.edges}
nodes={graph.nodes}
onMenu={nodeMenu}
onOpen={openNode}
/>
</div>
</section>
<section class="flex flex-col gap-2">
<h2 class="label-quiet px-1">
Recent events
<span class="tabular font-normal">{gateway.tape.length}</span>
</h2>
<ul class="flex flex-col px-1 text-xs">
{#each tape as item (item.seq)}
<li
class="ledger-grid grid-cols-[4.5rem_7rem_minmax(0,1fr)] py-0.5"
>
<span class="tabular text-muted-foreground">
{fmtTime(item.ts).replace(SECONDS, "")}
</span>
<span class="truncate">{item.type}</span>
<span class="truncate text-muted-foreground">
{#if item.conversation_id}
<a class="hover:underline" href={href(item.conversation_id)}>
{shortId(item.conversation_id)}
</a>
{/if}
{#if typeof item.name === "string"}
{item.name}
{/if}
{#if typeof item.text === "string"}
{item.text.slice(0, 80)}
{/if}
</span>
</li>
{:else}
<li class="py-1 text-muted-foreground">
Nothing yet this session.
</li>
{/each}
</ul>
</section>
</aside>
{/if}
</div>
-130
View File
@@ -1,130 +0,0 @@
<script lang="ts" module>
export interface FloatItem {
danger?: boolean;
// A rule sits above this item.
gap?: boolean;
label: string;
run: () => void;
}
</script>
<script lang="ts">
import { cn } from "$lib/utils";
// A menu that opens where the pointer is, for things that are not
// conversations: a note in the graph, a file in the memory tree.
let {
items,
x,
y,
open = $bindable(false),
class: className = "",
}: {
items: FloatItem[];
x: number;
y: number;
open?: boolean;
class?: string;
} = $props();
const MARGIN = 8;
const WIDTH = 208;
let panel = $state<HTMLDivElement | null>(null);
let cursor = $state(0);
const left = $derived(
typeof window === "undefined"
? x
: Math.min(x, window.innerWidth - WIDTH - MARGIN)
);
const top = $derived.by(() => {
if (typeof window === "undefined" || !panel) {
return y;
}
return Math.min(y, window.innerHeight - panel.offsetHeight - MARGIN);
});
function close() {
open = false;
}
function pick(item: FloatItem) {
close();
item.run();
}
$effect(() => {
if (!open) {
return;
}
cursor = 0;
const away = (event: PointerEvent) => {
if (panel && !panel.contains(event.target as Node)) {
close();
}
};
const keys = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
close();
} else if (event.key === "ArrowDown") {
event.preventDefault();
cursor = (cursor + 1) % items.length;
} else if (event.key === "ArrowUp") {
event.preventDefault();
cursor = (cursor - 1 + items.length) % items.length;
} else if (event.key === "Enter") {
event.preventDefault();
const item = items[cursor];
if (item) {
pick(item);
}
}
};
window.addEventListener("pointerdown", away, true);
window.addEventListener("keydown", keys, true);
window.addEventListener("resize", close);
window.addEventListener("scroll", close, true);
queueMicrotask(() => panel?.focus());
return () => {
window.removeEventListener("pointerdown", away, true);
window.removeEventListener("keydown", keys, true);
window.removeEventListener("resize", close);
window.removeEventListener("scroll", close, true);
};
});
</script>
{#if open}
<div
class={cn(
"fixed z-50 flex animate-island-in flex-col rounded-lg border bg-popover p-1 text-popover-foreground text-sm shadow-float outline-none",
className
)}
role="menu"
style="left: {left}px; top: {top}px; width: {WIDTH}px"
tabindex="-1"
bind:this={panel}
>
{#each items as item, index (item.label)}
{#if item.gap && index > 0}
<div class="my-1 h-px bg-border"></div>
{/if}
<button
class={cn(
"flex w-full items-center rounded-md px-2 py-1.5 text-left hover:bg-accent",
index === cursor && "bg-accent",
item.danger && "text-destructive"
)}
onclick={() => pick(item)}
onpointerenter={() => {
cursor = index;
}}
role="menuitem"
type="button"
>
{item.label}
</button>
{/each}
</div>
{/if}
-206
View File
@@ -1,206 +0,0 @@
import {
forceCenter,
forceCollide,
forceLink,
forceManyBody,
forceRadial,
forceSimulation,
type Simulation,
type SimulationLinkDatum,
type SimulationNodeDatum,
} from "d3-force";
// d3-force under a small API: rings keep the picture legible, collision
// keeps dots apart, a faint alpha target keeps the graph breathing.
export interface Body extends SimulationNodeDatum {
id: string;
ring: number;
x: number;
y: number;
}
interface Link extends SimulationLinkDatum<Body> {
source: Body;
target: Body;
}
const RING_RADIUS = [0, 96, 176, 236];
const CHARGE = -150;
const CHARGE_REACH = 340;
const LINK_REST = 62;
const LINK_RING_GAP = 14;
const LINK_STRENGTH = 0.45;
const COLLIDE_HUB = 24;
const COLLIDE = 13;
const RADIAL_HUB = 1;
const RADIAL = 0.07;
const CENTER = 0.02;
const ALPHA_DECAY = 0.03;
const VELOCITY_DECAY = 0.45;
const BREATH_TARGET = 0.012;
const SETTLED = 0.004;
const JITTER = 0.06;
const TAU = Math.PI * 2;
const HASH_MOD = 1_000_003;
const HASH_BASE = 31;
function hash(id: string): number {
let h = 0;
for (const ch of id) {
h = (h * HASH_BASE + ch.charCodeAt(0)) % HASH_MOD;
}
return h;
}
export function ringRadius(ring: number): number {
return RING_RADIUS[Math.min(ring, RING_RADIUS.length - 1)] ?? 0;
}
export class Force {
bodies: Body[] = [];
private readonly sim: Simulation<Body, Link>;
private readonly linkForce = forceLink<Body, Link>([])
.id((body) => body.id)
.distance(
(link) =>
LINK_REST +
Math.abs(link.source.ring - link.target.ring) * LINK_RING_GAP
)
.strength(LINK_STRENGTH);
private readonly index = new Map<string, Body>();
private clock = 0;
constructor() {
this.sim = forceSimulation<Body>([])
.stop()
.alphaDecay(ALPHA_DECAY)
.velocityDecay(VELOCITY_DECAY)
.force(
"charge",
forceManyBody<Body>().strength(CHARGE).distanceMax(CHARGE_REACH)
)
.force("link", this.linkForce)
.force(
"collide",
forceCollide<Body>((body) => (body.ring === 0 ? COLLIDE_HUB : COLLIDE))
)
.force(
"radial",
forceRadial<Body>((body) => ringRadius(body.ring), 0, 0).strength(
(body) => (body.ring === 0 ? RADIAL_HUB : RADIAL)
)
)
.force("center", forceCenter(0, 0).strength(CENTER));
}
// Keep what is placed, seat newcomers on their ring beside a neighbour
// when they have one, forget what is gone.
sync(
nodes: { id: string; ring: number }[],
edges: { from: string; to: string }[]
): void {
const keep = new Set<string>();
const fresh: Body[] = [];
for (const node of nodes) {
keep.add(node.id);
const known = this.index.get(node.id);
if (known) {
known.ring = node.ring;
continue;
}
const angle = (hash(node.id) % 360) * (TAU / 360);
const r = ringRadius(node.ring);
const body: Body = {
id: node.id,
ring: node.ring,
x: Math.cos(angle) * r,
y: Math.sin(angle) * r,
};
this.index.set(node.id, body);
fresh.push(body);
}
for (const id of [...this.index.keys()]) {
if (!keep.has(id)) {
this.index.delete(id);
}
}
this.bodies = nodes
.map((node) => this.index.get(node.id))
.filter((body): body is Body => body !== undefined);
const links: Link[] = [];
for (const edge of edges) {
const source = this.index.get(edge.from);
const target = this.index.get(edge.to);
if (source && target && source !== target) {
links.push({ source, target });
}
}
for (const body of fresh) {
const link = links.find((l) => l.source === body || l.target === body);
if (link) {
const other = link.source === body ? link.target : link.source;
const r = ringRadius(body.ring);
const angle = Math.atan2(other.y, other.x) + (hash(body.id) % 7) * 0.09;
body.x = Math.cos(angle) * r;
body.y = Math.sin(angle) * r;
}
}
this.sim.nodes(this.bodies);
this.linkForce.links(links);
if (fresh.length > 0) {
this.reheat(0.7);
}
}
body(id: string): Body | undefined {
return this.index.get(id);
}
get alpha(): number {
return this.sim.alpha();
}
reheat(to = 0.4): void {
this.sim.alpha(Math.max(this.sim.alpha(), to));
}
get settled(): boolean {
return this.sim.alpha() < SETTLED;
}
// One tick; ``breathe`` keeps a faint target so the graph never freezes.
step(breathe: boolean): void {
this.clock += 1 / 60;
this.sim.alphaTarget(breathe ? BREATH_TARGET : 0);
if (breathe) {
for (const body of this.bodies) {
if (body.fx !== null && body.fx !== undefined) {
continue;
}
const seed = hash(body.id) % 11;
body.vx = (body.vx ?? 0) + Math.sin(this.clock * 0.6 + seed) * JITTER;
body.vy =
(body.vy ?? 0) + Math.cos(this.clock * 0.5 + seed * 1.3) * JITTER;
}
}
this.sim.tick();
}
bounds(): { x: number; y: number; w: number; h: number } {
if (this.bodies.length === 0) {
return { h: 1, w: 1, x: 0, y: 0 };
}
let minX = Number.POSITIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
for (const body of this.bodies) {
minX = Math.min(minX, body.x);
minY = Math.min(minY, body.y);
maxX = Math.max(maxX, body.x);
maxY = Math.max(maxY, body.y);
}
return { h: maxY - minY || 1, w: maxX - minX || 1, x: minX, y: minY };
}
}
-503
View File
@@ -1,503 +0,0 @@
<script lang="ts" module>
export interface GraphNode {
href?: string;
id: string;
kind: string;
label: string;
// 0 = hub, 1 = first ring, 2 = second ring, 3 = ghost
ring: number;
state: "running" | "waiting" | "quiet" | "closed" | "ghost";
}
export interface GraphEdge {
from: string;
to: string;
}
</script>
<script lang="ts">
import Maximize2Icon from "@lucide/svelte/icons/maximize-2";
import Minimize2Icon from "@lucide/svelte/icons/minimize-2";
import ScanIcon from "@lucide/svelte/icons/scan";
import { onMount, untrack } from "svelte";
import { clip } from "$lib/format";
import { cn } from "$lib/utils";
import { type Body, Force } from "./force";
let {
nodes,
edges,
onOpen,
onMenu,
class: className = "",
}: {
nodes: GraphNode[];
edges: GraphEdge[];
onOpen?: (id: string) => void;
// The right button on a node: what is open lands where the pointer is.
onMenu?: (node: GraphNode, x: number, y: number) => void;
class?: string;
} = $props();
const LABEL_MAX = 24;
const LABEL_MAX_NEAR = 48;
const ZOOM_MIN = 0.25;
const ZOOM_MAX = 4;
const ZOOM_STEP = 0.0016;
const FIT_PAD = 0.86;
const FIT_MAX = 1.5;
const CLICK_SLOP = 4;
const LABEL_RING1_MAX = 14;
const LABEL_RING1_ZOOM = 1.2;
const LABEL_RING2_ZOOM = 2;
const SETTLE_FRAMES = 90;
const FONT = 11;
// Small box: the hub, its ring, and a few of what lies one link away.
const SMALL_RING1_FILES = 16;
const SMALL_GHOSTS = 12;
const force = new Force();
let frame = $state(0);
let width = $state(0);
let height = $state(0);
let k = $state(1);
let tx = $state(0);
let ty = $state(0);
let hovered = $state<string | null>(null);
let expanded = $state(false);
let touched = false;
let reduced = false;
let settling = 0;
let svg = $state<SVGSVGElement | null>(null);
interface Drag {
body: Body | null;
moved: boolean;
pointer: number;
startTx: number;
startTy: number;
startX: number;
startY: number;
}
let drag: Drag | null = null;
const shown = $derived.by((): GraphNode[] => {
if (expanded) {
return nodes;
}
let files = 0;
let ghosts = 0;
return nodes.filter((node) => {
if (node.kind !== "file") {
return true;
}
if (node.state === "ghost") {
ghosts += 1;
return ghosts <= SMALL_GHOSTS;
}
files += 1;
return files <= SMALL_RING1_FILES;
});
});
const byId = $derived(new Map(shown.map((node) => [node.id, node])));
const shownEdges = $derived(
edges.filter((edge) => byId.has(edge.from) && byId.has(edge.to))
);
const neighbours = $derived.by(() => {
const map = new Map<string, Set<string>>();
for (const edge of shownEdges) {
map.set(edge.from, (map.get(edge.from) ?? new Set()).add(edge.to));
map.set(edge.to, (map.get(edge.to) ?? new Set()).add(edge.from));
}
return map;
});
const ringOne = $derived(shown.filter((node) => node.ring === 1).length);
$effect(() => {
force.sync(
shown.map((n) => ({ id: n.id, ring: n.ring })),
shownEdges
);
settling = SETTLE_FRAMES;
untrack(() => {
frame += 1;
});
});
onMount(() => {
reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let handle = 0;
const tick = () => {
const breathe = !(reduced || drag);
if (breathe || !force.settled || settling > 0) {
force.step(breathe && force.settled);
frame += 1;
}
if (settling > 0) {
settling -= 1;
if (!touched) {
fit();
}
}
handle = requestAnimationFrame(tick);
};
handle = requestAnimationFrame(tick);
return () => cancelAnimationFrame(handle);
});
// Positions as plain numbers, refreshed every frame: the bodies mutate in
// place, and nothing in the template would notice otherwise.
const pos = $derived.by(() => {
if (frame < 0) {
return new Map<string, { x: number; y: number }>();
}
return new Map(
force.bodies.map((body) => [body.id, { x: body.x, y: body.y }])
);
});
function fit() {
if (width === 0 || height === 0) {
return;
}
const b = force.bounds();
const pad = 48;
const scale = Math.min(
(width * FIT_PAD) / (b.w + pad),
(height * FIT_PAD) / (b.h + pad),
FIT_MAX
);
k = Math.max(scale, ZOOM_MIN);
tx = -(b.x + b.w / 2) * k;
ty = -(b.y + b.h / 2) * k;
}
function refit() {
touched = false;
fit();
}
// Pointer position in graph units.
function toGraph(event: PointerEvent | WheelEvent): { x: number; y: number } {
const rect = svg?.getBoundingClientRect();
const px = event.clientX - (rect?.left ?? 0) - width / 2 - tx;
const py = event.clientY - (rect?.top ?? 0) - height / 2 - ty;
return { x: px / k, y: py / k };
}
function onWheel(event: WheelEvent) {
event.preventDefault();
touched = true;
const before = toGraph(event);
const next = Math.min(
ZOOM_MAX,
Math.max(ZOOM_MIN, k * Math.exp(-event.deltaY * ZOOM_STEP))
);
tx -= before.x * (next - k);
ty -= before.y * (next - k);
k = next;
}
function onDown(event: PointerEvent, body: Body | null) {
if (event.button !== 0) {
return;
}
event.stopPropagation();
svg?.setPointerCapture(event.pointerId);
drag = {
body,
moved: false,
pointer: event.pointerId,
startTx: tx,
startTy: ty,
startX: event.clientX,
startY: event.clientY,
};
if (body) {
body.fx = body.x;
body.fy = body.y;
force.reheat(0.3);
settling = 0;
}
}
function onMove(event: PointerEvent) {
if (!drag || drag.pointer !== event.pointerId) {
return;
}
const dx = event.clientX - drag.startX;
const dy = event.clientY - drag.startY;
if (Math.abs(dx) > CLICK_SLOP || Math.abs(dy) > CLICK_SLOP) {
drag.moved = true;
touched = true;
}
if (drag.body) {
const at = toGraph(event);
drag.body.fx = at.x;
drag.body.fy = at.y;
force.reheat(0.25);
} else if (drag.moved) {
tx = drag.startTx + dx;
ty = drag.startTy + dy;
}
}
function onUp(event: PointerEvent) {
if (!drag || drag.pointer !== event.pointerId) {
return;
}
const { body, moved } = drag;
drag = null;
if (body) {
body.fx = null;
body.fy = null;
if (!moved) {
onOpen?.(body.id);
}
}
}
function onContext(event: MouseEvent, node: GraphNode) {
if (!onMenu) {
return;
}
event.preventDefault();
event.stopPropagation();
onMenu(node, event.clientX, event.clientY);
}
function onKey(event: KeyboardEvent) {
if (event.key === "Escape" && expanded) {
event.stopPropagation();
expanded = false;
}
}
function toggleExpanded() {
expanded = !expanded;
touched = false;
settling = SETTLE_FRAMES / 3;
}
const RADIUS: Record<string, number> = {
closed: 4,
ghost: 3,
quiet: 5,
running: 7,
waiting: 6.5,
};
const FILL: Record<string, string> = {
branch: "var(--color-kind-branch)",
deep: "var(--color-kind-deep)",
file: "var(--foreground)",
fork: "var(--color-kind-fork)",
job: "var(--color-kind-job)",
master: "var(--color-kind-master)",
};
function radius(node: GraphNode): number {
const base = RADIUS[node.state] ?? 4;
return node.ring === 0 ? base + 5 : base;
}
function strokeOf(node: GraphNode): string {
if (node.state === "ghost") {
return "var(--muted-foreground)";
}
return node.state === "waiting" ? "var(--attention)" : "none";
}
function fillOf(node: GraphNode): string {
return node.state === "ghost"
? "var(--background)"
: (FILL[node.kind] ?? "var(--foreground)");
}
function near(id: string): boolean {
return (
hovered === id ||
(hovered !== null && Boolean(neighbours.get(hovered)?.has(id)))
);
}
// Labels stay one screen size whatever the zoom; the ring decides
// whether a node earns one when nothing is hovered.
function labelled(node: GraphNode): boolean {
if (hovered) {
return near(node.id);
}
if (node.ring === 0) {
return true;
}
if (node.ring === 1) {
return ringOne <= LABEL_RING1_MAX || k >= LABEL_RING1_ZOOM;
}
return k >= LABEL_RING2_ZOOM;
}
function dimmed(id: string): boolean {
return hovered !== null && !near(id);
}
function edgeDimmed(edge: GraphEdge): boolean {
return hovered !== null && edge.from !== hovered && edge.to !== hovered;
}
function ghostEdge(edge: GraphEdge): boolean {
return (
byId.get(edge.from)?.state === "ghost" ||
byId.get(edge.to)?.state === "ghost"
);
}
</script>
<svelte:window onkeydown={onKey} />
<div
class={cn(
"graph-frame relative overflow-hidden",
expanded
? "fixed inset-3 z-50 animate-island-in rounded-2xl border bg-background shadow-float sm:inset-6"
: cn("h-full w-full", className)
)}
bind:clientHeight={height}
bind:clientWidth={width}
>
<!-- biome-ignore lint/a11y/noNoninteractiveElementInteractions: the canvas pans and zooms; the nodes inside are links -->
<svg
aria-label="what the agent is touching"
class="block h-full w-full cursor-grab touch-none select-none active:cursor-grabbing"
ondblclick={refit}
onpointercancel={onUp}
onpointerdown={(event) => onDown(event, null)}
onpointermove={onMove}
onpointerup={onUp}
onwheel={onWheel}
role="img"
bind:this={svg}
>
<defs>
<radialGradient id="graph-glow">
<stop offset="0%" stop-color="var(--signal)" stop-opacity="0.4" />
<stop offset="100%" stop-color="var(--signal)" stop-opacity="0" />
</radialGradient>
</defs>
<g transform="translate({width / 2 + tx} {height / 2 + ty}) scale({k})">
{#each shownEdges as edge (edge.from + edge.to)}
{@const a = pos.get(edge.from)}
{@const b = pos.get(edge.to)}
{#if a && b}
<line
stroke="var(--foreground)"
stroke-dasharray={ghostEdge(edge) ? "2 4" : undefined}
stroke-opacity={edgeDimmed(edge) ? 0.05 : 0.18}
stroke-width={1 / Math.sqrt(k)}
x1={a.x}
x2={b.x}
y1={a.y}
y2={b.y}
/>
{/if}
{/each}
{#each shown as node (node.id)}
{@const at = pos.get(node.id)}
{#if at}
{@const r = radius(node)}
{@const faded = dimmed(node.id)}
<a
class="cursor-pointer outline-none"
href={node.href ?? `#${node.id}`}
onclick={(event) => {
event.preventDefault();
if (event.detail === 0) {
onOpen?.(node.id);
}
}}
oncontextmenu={(event) => onContext(event, node)}
onpointerdown={(event) => onDown(event, force.body(node.id) ?? null)}
onpointerenter={() => {
hovered = node.id;
}}
onpointerleave={() => {
hovered = null;
}}
style="opacity: {faded ? 0.25 : 1}; transition: opacity 150ms"
>
{#if node.state === "running"}
<circle cx={at.x} cy={at.y} fill="url(#graph-glow)" r={r * 4} />
{/if}
<circle cx={at.x} cy={at.y} fill="transparent" r={r + 8} />
<circle
cx={at.x}
cy={at.y}
fill={fillOf(node)}
fill-opacity={node.state === "closed" ? 0.4 : 1}
{r}
stroke={hovered === node.id ? "var(--ring)" : strokeOf(node)}
stroke-opacity={node.state === "ghost" ? 0.6 : 1}
stroke-width={node.state === "waiting" ? 2.5 : 1.25}
/>
{#if labelled(node)}
<text
dominant-baseline="hanging"
fill={node.state === "ghost" || node.state === "closed"
? "var(--muted-foreground)"
: "var(--foreground)"}
font-size={FONT / k}
font-weight={node.ring === 0 || hovered === node.id ? 600 : 400}
paint-order="stroke"
stroke="var(--background)"
stroke-linejoin="round"
stroke-width={3 / k}
text-anchor="middle"
x={at.x}
y={at.y + r + 3 / k}
>
{clip(node.label, near(node.id) ? LABEL_MAX_NEAR : LABEL_MAX)}
</text>
{/if}
</a>
{/if}
{/each}
</g>
</svg>
<div class="absolute top-2 right-2 flex items-center gap-1">
<button
aria-label="Fit the graph"
class="rule-word inline-flex size-7 items-center justify-center rounded-md bg-background/70 backdrop-blur"
onclick={refit}
title="Fit (double-click the canvas)"
type="button"
>
<ScanIcon class="size-3.5" />
</button>
<button
aria-label={expanded ? "Shrink the graph" : "Expand the graph"}
class="rule-word inline-flex size-7 items-center justify-center rounded-md bg-background/70 backdrop-blur"
onclick={toggleExpanded}
title={expanded ? "Shrink (Esc)" : "Expand"}
type="button"
>
{#if expanded}
<Minimize2Icon class="size-3.5" />
{:else}
<Maximize2Icon class="size-3.5" />
{/if}
</button>
</div>
{#if !expanded && shown.length < nodes.length}
<span
class="pointer-events-none absolute bottom-2 left-3 text-[11px] text-muted-foreground"
>
{nodes.length - shown.length}
more when expanded
</span>
{/if}
{#if nodes.length === 0}
<p
class="pointer-events-none absolute inset-0 flex items-center justify-center text-muted-foreground text-xs"
>
Nothing reached yet.
</p>
{/if}
</div>
-135
View File
@@ -1,135 +0,0 @@
<script lang="ts">
import type { LimitWindow } from "$lib/api/types";
import { fmtCountdown, fmtMoney, fmtRelative, fmtTokens } from "$lib/format";
import { limitLabel, limitWord } from "$lib/limits";
import { cn } from "$lib/utils";
let {
contextTokens,
contextWindow,
windows,
spend = null,
now,
compact = false,
}: {
contextTokens: number;
contextWindow: number;
windows: LimitWindow[];
spend?: number | null;
now: number;
compact?: boolean;
} = $props();
const TONE: Record<string, string> = {
allowed: "bg-primary",
allowed_warning: "bg-primary",
rejected: "bg-destructive",
};
const contextPct = $derived(
Math.min(100, Math.round((contextTokens / contextWindow) * 100))
);
// The figure shown: the report's own, else the last one inside the window.
const figure = (w: LimitWindow) => w.utilization ?? w.last_known?.utilization;
const pct = (w: LimitWindow) =>
Math.min(100, Math.round((figure(w) ?? 0) * 100));
const bound = (w: LimitWindow) =>
w.utilization === null || w.utilization === undefined ? "≥ " : "";
</script>
<div
class={cn(
"grid gap-x-6 gap-y-4",
compact
? "@lg:grid-cols-5 grid-cols-2"
: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-5"
)}
>
<div class="flex flex-col gap-1.5">
<span class="label-quiet">Context</span>
<span class="tabular font-semibold text-2xl leading-none tracking-tight">
{fmtTokens(contextTokens)}
<span class="font-normal text-muted-foreground text-sm">
/ {fmtTokens(contextWindow)}
</span>
</span>
<span
aria-label="context share"
aria-valuemax="100"
aria-valuemin="0"
aria-valuenow={contextPct}
class="h-0.5 w-full overflow-hidden rounded-full bg-muted"
role="progressbar"
>
<span
class="block h-full rounded-full bg-primary transition-[width] duration-500"
style="width: {contextPct}%"
></span>
</span>
</div>
{#each windows as w (w.window)}
{@const known = figure(w) !== null && figure(w) !== undefined}
<div class="flex flex-col gap-1.5">
<span class="label-quiet">{limitLabel(w.window)}</span>
{#if known}
<span
class={cn(
"tabular font-semibold text-2xl leading-none tracking-tight",
w.status === "rejected" && "text-destructive"
)}
>
{bound(w)}{pct(w)}
<span class="font-normal text-muted-foreground text-sm">%</span>
<span class="font-normal text-muted-foreground text-xs">
{fmtCountdown(w.resets_at, now).replace("resets ", "")}
</span>
</span>
<span
aria-label="{limitLabel(w.window)} utilization"
aria-valuemax="100"
aria-valuemin="0"
aria-valuenow={pct(w)}
class="h-0.5 w-full overflow-hidden rounded-full bg-muted"
role="progressbar"
>
<span
class={cn(
"block h-full rounded-full transition-[width] duration-500",
TONE[w.status]
)}
style="width: {pct(w)}%"
></span>
</span>
{:else}
<span
class={cn(
"font-semibold text-2xl leading-none tracking-tight",
w.status === "rejected" && "text-destructive"
)}
>
{limitWord(w.status)}
<span class="font-normal text-muted-foreground text-xs">
{fmtCountdown(w.resets_at, now).replace("resets ", "")}
</span>
</span>
<span
class="tabular text-muted-foreground text-xs"
title="through the gateway this window · last report {fmtRelative(w.ts, now)}"
>
{fmtTokens(
w.gateway.input + w.gateway.output + w.gateway.cache_creation
)}
· {fmtMoney(w.gateway.cost_usd)}
</span>
{/if}
</div>
{/each}
{#if spend !== null}
<div class="flex flex-col gap-1.5">
<span class="label-quiet">Spend · 24 h</span>
<span class="tabular font-semibold text-2xl leading-none tracking-tight">
{fmtMoney(spend)}
</span>
<span class="text-muted-foreground text-xs">in API price</span>
</div>
{/if}
</div>
-86
View File
@@ -1,86 +0,0 @@
<script lang="ts">
import { fmtTokens } from "$lib/format";
import { gateway } from "$lib/gateway.svelte";
import { ui } from "$lib/ui.svelte";
import { cn } from "$lib/utils";
import { now } from "./now.svelte";
let { class: className = "" }: { class?: string } = $props();
const running = $derived(now.running.length);
const waiting = $derived(now.waiting.length);
const worst = $derived.by(() => {
const windows = gateway.limits?.windows ?? [];
let top: number | null = null;
for (const w of windows) {
if (w.utilization !== null && w.utilization !== undefined) {
top = Math.max(top ?? 0, w.utilization);
}
}
return top;
});
const live = $derived(gateway.live.state);
const dot = $derived.by(() => {
if (live === "failed") {
return "bg-destructive";
}
if (live !== "open") {
return "bg-warn";
}
return running > 0 ? "bg-signal animate-pulse-dot" : "bg-ok";
});
const headline = $derived.by(() => {
if (live === "failed") {
return "offline";
}
if (live !== "open") {
return "connecting";
}
if (running === 0) {
return "idle";
}
return running === 1 ? "1 in motion" : `${running} in motion`;
});
</script>
<button
aria-expanded={ui.island}
aria-haspopup="dialog"
aria-label="What is happening now"
class={cn("island", className)}
onclick={() => ui.toggleIsland()}
type="button"
>
<span class={cn("size-2 shrink-0 rounded-full", dot)}></span>
<span class="font-medium">{headline}</span>
{#if waiting > 0}
<span class="island-sep"></span>
<span class="font-medium text-attention-foreground">
{waiting === 1 ? "1 question" : `${waiting} questions`}
</span>
{/if}
{#if now.contextTokens > 0}
<span class="island-sep hidden sm:block"></span>
<span
class="tabular hidden text-muted-foreground sm:inline"
title="master context"
>
{fmtTokens(now.contextTokens)}
</span>
{/if}
{#if worst !== null}
<span class="island-sep hidden sm:block"></span>
<span
class="tabular hidden text-muted-foreground sm:inline"
title="highest quota window"
>
{Math.round(worst * 100)}%
</span>
{/if}
<kbd
class="ml-1 hidden rounded border px-1 font-sans text-[10px] text-muted-foreground md:inline"
title="open the board"
>
⌘K
</kbd>
</button>
-46
View File
@@ -1,46 +0,0 @@
<script lang="ts" module>
export const KIND_LETTER: Record<string, string> = {
branch: "B",
deep: "D",
fork: "F",
job: "J",
master: "M",
};
// What the letter stands for, for the tooltip and the legend.
export const KIND_MEANING: Record<string, string> = {
branch: "branch — a side thread off the master, merged back when done",
deep: "deep chat — a long thread of its own, closed with a digest",
fork: "fork — the agent's own copy of a thread, used to write a digest",
job: "job run — a scheduled or triggered task, no human in it",
master: "master — the day's main thread with the dispatcher",
};
</script>
<script lang="ts">
import type { Kind } from "$lib/api/types";
import { cn } from "$lib/utils";
let { kind, class: className = "" }: { kind: Kind | string; class?: string } =
$props();
const TONE: Record<string, string> = {
branch: "text-kind-branch bg-kind-branch/12",
deep: "text-kind-deep bg-kind-deep/12",
fork: "text-kind-fork bg-kind-fork/12",
job: "text-kind-job bg-kind-job/12",
master: "text-kind-master bg-kind-master/12",
};
</script>
<span
aria-label={kind}
class={cn(
"inline-flex size-5 shrink-0 items-center justify-center rounded-full font-semibold text-[10px] leading-none",
TONE[kind] ?? "bg-muted text-muted-foreground",
className
)}
role="img"
title={KIND_MEANING[kind] ?? kind}
>
{KIND_LETTER[kind] ?? "?"}
</span>
-97
View File
@@ -1,97 +0,0 @@
import type { ConversationInfo, ConversationSummary } from "$lib/api/types";
import { gateway } from "$lib/gateway.svelte";
import { session } from "$lib/session.svelte";
import { byActivity } from "./state";
const CONTEXT_WINDOW = 1_000_000;
const SNAPSHOT_MIN_GAP_MS = 400;
// What the board shows: the open conversations sorted into bays, the
// open master's context, and a snapshot (tools in flight, the pending
// question) for every strip that is waiting or running.
class Now {
snapshots = $state<Record<string, ConversationInfo>>({});
private snapshotAt = 0;
private inFlight: Promise<void> | null = null;
get master(): ConversationSummary | null {
return (
gateway.conversations.find(
(row) => row.kind === "master" && row.status === "open"
) ?? null
);
}
get waiting(): ConversationSummary[] {
return gateway.open.filter((row) => row.pending_question).sort(byActivity);
}
get running(): ConversationSummary[] {
return gateway.open
.filter((row) => row.running_turn && !row.pending_question)
.sort(byActivity);
}
get quiet(): ConversationSummary[] {
return gateway.open
.filter(
(row) =>
!(row.running_turn || row.pending_question) && row.kind !== "job"
)
.sort(byActivity);
}
get contextTokens(): number {
return this.master?.context_tokens ?? 0;
}
get contextShare(): number {
return Math.min(1, this.contextTokens / CONTEXT_WINDOW);
}
get contextWindow(): number {
return CONTEXT_WINDOW;
}
get liveIds(): string[] {
return [...this.waiting, ...this.running].map((row) => row.id);
}
// One round of ``GET /api/conversations/{id}`` for every live strip,
// coalesced so a burst of turn events costs one fetch.
refreshSnapshots(): Promise<void> {
if (this.inFlight) {
return this.inFlight;
}
const gap = Date.now() - this.snapshotAt;
this.inFlight = (async () => {
if (gap < SNAPSHOT_MIN_GAP_MS) {
await new Promise((resolve) =>
setTimeout(resolve, SNAPSHOT_MIN_GAP_MS - gap)
);
}
const { client } = session;
const ids = this.liveIds;
if (!client || ids.length === 0) {
this.snapshots = {};
return;
}
const results = await Promise.all(
ids.map((id) => client.conversation(id).catch(() => null))
);
const next: Record<string, ConversationInfo> = {};
for (const info of results) {
if (info) {
next[info.id] = info;
}
}
this.snapshots = next;
this.snapshotAt = Date.now();
})().finally(() => {
this.inFlight = null;
});
return this.inFlight;
}
}
export const now = new Now();
-164
View File
@@ -1,164 +0,0 @@
<script lang="ts">
import SearchIcon from "@lucide/svelte/icons/search";
import { goto } from "$app/navigation";
import { base } from "$app/paths";
import type { ConversationSummary } from "$lib/api/types";
import { clip, fmtRelative, shortId } from "$lib/format";
import { gateway } from "$lib/gateway.svelte";
import { SECTIONS, SYSTEM_PAGES } from "$lib/nav";
import { ui } from "$lib/ui.svelte";
import { cn } from "$lib/utils";
import KindMark from "./kind-mark.svelte";
import { byActivity } from "./state";
interface Item {
hint: string;
href: string;
id: string;
kind: "section" | "conversation";
label: string;
row?: ConversationSummary;
}
const LIMIT = 10;
let query = $state("");
let cursor = $state(0);
let input = $state<HTMLInputElement | null>(null);
const items = $derived.by((): Item[] => {
const needle = query.trim().toLowerCase();
const sections: Item[] = [...SECTIONS, ...SYSTEM_PAGES]
.filter((s) => !needle || s.label.toLowerCase().includes(needle))
.map((s) => ({
hint: "section",
href: `${base}${s.href}`,
id: `s:${s.href}`,
kind: "section",
label: s.label,
}));
const conversations: Item[] = gateway.conversations
.filter(
(row) =>
!needle ||
(row.title ?? "").toLowerCase().includes(needle) ||
row.id.startsWith(needle) ||
row.agent.toLowerCase().includes(needle) ||
(row.last_item?.text ?? "").toLowerCase().includes(needle)
)
.sort(byActivity)
.slice(0, needle ? LIMIT : 5)
.map((row) => ({
hint: `${row.status === "open" ? "" : `${row.status} · `}${fmtRelative(row.last_activity_at ?? row.created_at)}`,
href: `${base}/conversations/${row.id}`,
id: row.id,
kind: "conversation",
label: row.title ?? `${row.kind} · ${shortId(row.id)}`,
row,
}));
return needle
? [...conversations, ...sections]
: [...sections.slice(0, 4), ...conversations];
});
$effect(() => {
if (ui.palette) {
query = "";
cursor = 0;
queueMicrotask(() => input?.focus());
}
});
$effect(() => {
if (cursor >= items.length) {
cursor = Math.max(0, items.length - 1);
}
});
function go(item: Item) {
ui.closeAll();
goto(item.href);
}
function onKeydown(event: KeyboardEvent) {
if (event.key === "ArrowDown") {
event.preventDefault();
cursor = Math.min(items.length - 1, cursor + 1);
} else if (event.key === "ArrowUp") {
event.preventDefault();
cursor = Math.max(0, cursor - 1);
} else if (event.key === "Enter" && items[cursor]) {
event.preventDefault();
go(items[cursor]);
}
}
</script>
{#if ui.palette}
<div
aria-label="Go to"
aria-modal="true"
class="fixed inset-0 z-40 flex items-start justify-center px-4 pt-[12vh]"
role="dialog"
>
<button
aria-label="Close"
class="absolute inset-0 animate-fade-in bg-foreground/15 backdrop-blur-[2px]"
onclick={() => ui.closePalette()}
type="button"
></button>
<div
class="relative flex w-full max-w-lg animate-island-in flex-col overflow-hidden rounded-xl border bg-popover shadow-float"
>
<label class="flex items-center gap-2 border-b px-3">
<SearchIcon class="size-4 shrink-0 text-icon" />
<input
autocomplete="off"
class="h-11 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
onkeydown={onKeydown}
placeholder="Conversation, section…"
spellcheck="false"
type="text"
bind:this={input}
bind:value={query}
>
<kbd
class="rounded border px-1 font-sans text-[10px] text-muted-foreground"
>
esc
</kbd>
</label>
<div class="max-h-[50vh] overflow-y-auto py-1" role="listbox">
{#each items as item, index (item.id)}
<div
aria-selected={index === cursor}
class={cn(
"flex cursor-pointer items-center gap-2.5 px-3 py-2 text-sm",
index === cursor && "bg-accent"
)}
onclick={() => go(item)}
onkeydown={(event) => event.key === "Enter" && go(item)}
onmousemove={() => {
cursor = index;
}}
role="option"
tabindex="-1"
>
{#if item.row}
<KindMark kind={item.row.kind} />
{:else}
<span class="size-5 shrink-0"></span>
{/if}
<span class="min-w-0 flex-1 truncate">{clip(item.label, 80)}</span>
<span class="tabular shrink-0 text-muted-foreground text-xs">
{item.hint}
</span>
</div>
{:else}
<p class="px-3 py-3 text-muted-foreground text-sm">
Nothing matches.
</p>
{/each}
</div>
</div>
</div>
{/if}
-21
View File
@@ -1,21 +0,0 @@
import type { ConversationSummary } from "$lib/api/types";
export type StripState = "waiting" | "running" | "quiet" | "closed";
function activityOf(row: ConversationSummary): string {
return row.last_activity_at ?? row.created_at ?? "";
}
export function byActivity(a: ConversationSummary, b: ConversationSummary) {
return activityOf(b).localeCompare(activityOf(a));
}
export function stateOf(row: ConversationSummary): StripState {
if (row.status !== "open") {
return "closed";
}
if (row.pending_question) {
return "waiting";
}
return row.running_turn ? "running" : "quiet";
}
-134
View File
@@ -1,134 +0,0 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { ApiClient } from "$lib/api/client";
import type { ConversationSummary } from "$lib/api/types";
import { clip, fmtRelative, fmtTokens, shortId } from "$lib/format";
import ConversationMenu from "$lib/panel/conversation-menu.svelte";
import { cn } from "$lib/utils";
import KindMark from "./kind-mark.svelte";
import { type StripState, stateOf } from "./state";
let {
row,
href,
state = stateOf(row),
current = false,
open = false,
detail = "",
now = Date.now(),
class: className = "",
children,
onclick,
client = null,
onChanged,
onWarm,
}: {
row: ConversationSummary;
href?: string;
state?: StripState;
current?: boolean;
open?: boolean;
detail?: string;
now?: number;
class?: string;
children?: Snippet;
onclick?: (id: string) => void;
// With a client the strip answers to the right button (and a long
// press) with the conversation's actions.
client?: ApiClient | null;
onChanged?: () => void;
// The pointer is over the strip: a good moment to fetch what it opens.
onWarm?: (id: string) => void;
} = $props();
const TITLE_MAX = 72;
const DETAIL_MAX = 96;
const title = $derived(
row.title
? clip(row.title, TITLE_MAX)
: `${row.kind === "master" ? "Master" : row.kind} · ${shortId(row.id)}`
);
const when = $derived(row.last_activity_at ?? row.created_at ?? null);
const tag = $derived(href ? "a" : "button");
function activate(event: MouseEvent) {
if (!onclick) {
return;
}
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button) {
return;
}
event.preventDefault();
onclick(row.id);
}
function follow(id: string) {
if (onclick) {
onclick(id);
} else if (href) {
// Shared code cannot reach the router; a real anchor click can.
const link = document.createElement("a");
link.href = href.replace(row.id, id);
document.body.append(link);
link.click();
link.remove();
}
}
</script>
{#snippet body(extra: Record<string, unknown>)}
<svelte:element
{...extra}
aria-current={current ? "true" : undefined}
class={cn("strip text-left text-sm", className, extra.class as string)}
data-row={row.id}
data-state={state}
href={href ?? undefined}
onclick={activate}
onpointerenter={onWarm ? () => onWarm(row.id) : undefined}
role={href ? undefined : "button"}
tabindex="0"
this={tag}
type={href ? undefined : "button"}
>
<KindMark kind={row.kind} />
<span class="flex min-w-0 flex-col leading-tight">
<span class="truncate font-medium">{title}</span>
{#if detail}
<span class="truncate text-muted-foreground text-xs">
{clip(detail, DETAIL_MAX)}
</span>
{/if}
</span>
<span
class="tabular flex shrink-0 items-center gap-3 text-muted-foreground text-xs"
>
{#if row.context_tokens}
<span title="context of the last turn"
>{fmtTokens(row.context_tokens)}</span
>
{/if}
<span class="w-14 text-right">{fmtRelative(when, now)}</span>
</span>
</svelte:element>
{/snippet}
{#if client}
<ConversationMenu
{client}
{current}
id={row.id}
mode="context"
onChanged={() => onChanged?.()}
onOpen={follow}
>
{#snippet trigger(props)}
{@render body(props)}
{/snippet}
</ConversationMenu>
{:else}
{@render body({})}
{/if}
{#if children && open}
<div class="strip-open">{@render children()}</div>
{/if}
-80
View File
@@ -1,80 +0,0 @@
<script lang="ts">
import LogOutIcon from "@lucide/svelte/icons/log-out";
import MoonIcon from "@lucide/svelte/icons/moon";
import SunIcon from "@lucide/svelte/icons/sun";
import { mode, toggleMode } from "mode-watcher";
import { goto } from "$app/navigation";
import { base } from "$app/paths";
import { page } from "$app/state";
import { isSectionActive, SECTIONS, SYSTEM } from "$lib/nav";
import { session } from "$lib/session.svelte";
import { cn } from "$lib/utils";
import Island from "./island.svelte";
async function signOut() {
await session.logout();
await goto(`${base}/login`);
}
</script>
<header
class="relative z-30 grid h-12 shrink-0 grid-cols-[1fr_auto_1fr] items-center gap-3 border-b bg-rack/85 px-3 backdrop-blur-md sm:px-5"
>
<nav aria-label="Sections" class="flex min-w-0 items-center gap-4">
<a
aria-label="Beaver"
class="flex size-6 shrink-0 items-center justify-center"
href="{base}/"
>
<span class="size-2.5 rounded-[3px] bg-primary"></span>
</a>
<div class="hidden items-center gap-4 sm:flex">
{#each SECTIONS as item (item.href)}
{@const active = isSectionActive(page.url.pathname, base, item.href)}
<a
aria-current={active ? "page" : undefined}
class={cn("rule-word", active && "text-foreground")}
href="{base}{item.href}"
title="{item.label} ({item.key})"
>
{item.label}
</a>
{/each}
</div>
</nav>
<Island />
<div class="flex min-w-0 items-center justify-end gap-3">
<a
aria-current={isSectionActive(page.url.pathname, base, SYSTEM.href)
? "page"
: undefined}
class="rule-word hidden sm:inline"
href="{base}{SYSTEM.href}"
title="{SYSTEM.label} ({SYSTEM.key})"
>
{SYSTEM.label}
</a>
<button
aria-label="Toggle theme"
class="rule-word inline-flex size-7 items-center justify-center rounded-md"
onclick={toggleMode}
title="Toggle theme"
type="button"
>
{#if mode.current === "dark"}
<SunIcon class="size-4" />
{:else}
<MoonIcon class="size-4" />
{/if}
</button>
<button
aria-label="Sign out"
class="rule-word hidden size-7 items-center justify-center rounded-md sm:inline-flex"
onclick={signOut}
title="Sign out"
type="button"
>
<LogOutIcon class="size-4" />
</button>
</div>
</header>
+16 -35
View File
@@ -1,5 +1,6 @@
import { browser } from "$app/environment";
const NAV_KEY = "beaver.ui.nav";
const RAIL_KEY = "beaver.ui.rail";
function stored(key: string, fallback: boolean): boolean {
@@ -16,48 +17,28 @@ function store(key: string, value: boolean): void {
}
}
// Chrome state: the island's board, the command palette, the conversation
// rail. The rail toggle persists; the overlays never do.
// Chrome state: the section sidebar and the conversation rail. Explicit
// toggles persist; the automatic collapse on the conversations pages does
// not, so leaving them restores what the user had.
class Ui {
nav = $state(stored(NAV_KEY, true));
rail = $state(stored(RAIL_KEY, true));
island = $state(false);
palette = $state(false);
setNav(open: boolean, persist = false): void {
this.nav = open;
if (persist) {
store(NAV_KEY, open);
}
}
toggleNav(): void {
this.setNav(!this.nav, true);
}
toggleRail(): void {
this.rail = !this.rail;
store(RAIL_KEY, this.rail);
}
openIsland(): void {
this.palette = false;
this.island = true;
}
closeIsland(): void {
this.island = false;
}
toggleIsland(): void {
if (this.island) {
this.closeIsland();
} else {
this.openIsland();
}
}
openPalette(): void {
this.island = false;
this.palette = true;
}
closePalette(): void {
this.palette = false;
}
closeAll(): void {
this.island = false;
this.palette = false;
}
}
export const ui = new Ui();
+22 -69
View File
@@ -1,29 +1,27 @@
<script lang="ts">
import "./layout.css";
import { ModeWatcher } from "mode-watcher";
import { onMount } from "svelte";
import { onMount, untrack } from "svelte";
import { goto } from "$app/navigation";
import { base } from "$app/paths";
import { page } from "$app/state";
import favicon from "$lib/assets/favicon.svg";
import AppSidebar from "$lib/components/app-sidebar.svelte";
import BottomNav from "$lib/components/bottom-nav.svelte";
import { Toaster } from "$lib/components/ui/sonner";
import * as Tooltip from "$lib/components/ui/tooltip";
import { gateway } from "$lib/gateway.svelte";
import { SECTIONS, SYSTEM } from "$lib/nav";
import { session } from "$lib/session.svelte";
import Board from "$lib/shell/board.svelte";
import Palette from "$lib/shell/palette.svelte";
import TopBar from "$lib/shell/top-bar.svelte";
import { ui } from "$lib/ui.svelte";
let { children } = $props();
const isLogin = $derived(page.url.pathname === `${base}/login`);
const isPanel = $derived(page.url.pathname.startsWith(`${base}/panel`));
const isHome = $derived(
page.url.pathname === `${base}/` || page.url.pathname === base
const inConversations = $derived(
page.url.pathname.startsWith(`${base}/conversations`)
);
let navBefore: boolean | null = null;
onMount(() => {
if (!isPanel) {
@@ -50,47 +48,23 @@
});
$effect(() => {
if (page.url.pathname) {
ui.closeAll();
if (inConversations) {
untrack(() => {
if (navBefore === null) {
navBefore = ui.nav;
ui.setNav(false);
}
});
} else if (navBefore !== null) {
ui.setNav(navBefore);
navBefore = null;
}
});
function typing(target: EventTarget | null): boolean {
const el = target as HTMLElement | null;
return Boolean(
el &&
(el.tagName === "INPUT" ||
el.tagName === "TEXTAREA" ||
el.isContentEditable)
);
}
function onKeydown(event: KeyboardEvent) {
if ((event.metaKey || event.ctrlKey) && event.key === "k") {
if ((event.metaKey || event.ctrlKey) && event.key === "b") {
event.preventDefault();
if (ui.palette) {
ui.closePalette();
} else {
ui.openPalette();
}
return;
}
if (event.key === "Escape" && (ui.island || ui.palette)) {
ui.closeAll();
return;
}
if (
event.metaKey ||
event.ctrlKey ||
event.altKey ||
typing(event.target)
) {
return;
}
const section = [...SECTIONS, SYSTEM].find((s) => s.key === event.key);
if (section) {
event.preventDefault();
goto(`${base}${section.href}`);
ui.toggleNav();
}
}
</script>
@@ -106,34 +80,13 @@
{#if isLogin || isPanel}
{@render children()}
{:else if session.ready && session.user}
<div class="flex h-dvh flex-col overflow-hidden">
<TopBar />
<div class="relative flex min-h-0 flex-1 flex-col">
<main class="flex min-h-0 flex-1 flex-col overflow-hidden">
{@render children()}
</main>
{#if ui.island && !isHome}
<div
class="absolute inset-0 z-20 flex items-start justify-center px-3 pt-3 sm:px-6"
>
<button
aria-label="Close the board"
class="absolute inset-0 animate-fade-in bg-foreground/10 backdrop-blur-[1.5px]"
onclick={() => ui.closeIsland()}
type="button"
></button>
<section
aria-label="Now"
class="relative max-h-full w-full max-w-4xl animate-island-in overflow-y-auto rounded-2xl border bg-background p-5 shadow-float sm:p-6"
>
<Board compact />
</section>
</div>
{/if}
</div>
<div class="flex h-dvh flex-col overflow-hidden sm:flex-row">
<AppSidebar />
<main class="flex min-w-0 flex-1 flex-col overflow-hidden">
{@render children()}
</main>
<BottomNav />
</div>
<Palette />
{:else if session.ready && session.error}
<div class="flex h-dvh items-center justify-center p-6 text-sm">
<p class="text-destructive">{session.error}</p>
+388 -3
View File
@@ -1,11 +1,396 @@
<script lang="ts">
import Board from "$lib/shell/board.svelte";
import { onMount } from "svelte";
import { base } from "$app/paths";
import type {
AgentsResponse,
SessionsResponse,
UsageResponse,
} from "$lib/api/types";
import EmptyState from "$lib/components/empty-state.svelte";
import Endpoints from "$lib/components/endpoints.svelte";
import ErrorNote from "$lib/components/error-note.svelte";
import KindBadge from "$lib/components/kind-badge.svelte";
import LimitBar from "$lib/components/limit-bar.svelte";
import PageHeader from "$lib/components/page-header.svelte";
import { Skeleton } from "$lib/components/ui/skeleton";
import {
cacheShare,
elapsedMs,
fmtBytes,
fmtDuration,
fmtMoney,
fmtPct,
fmtSeconds,
fmtTime,
fmtTokens,
shortId,
} from "$lib/format";
import { gateway } from "$lib/gateway.svelte";
import { session } from "$lib/session.svelte";
import { cn } from "$lib/utils";
const HOURS_5H = 5;
const HOURS_WEEK = 168;
const SESSIONS_POLL_MS = 10_000;
const TICK_MS = 1000;
let sessions = $state<SessionsResponse | null>(null);
let usage5h = $state<UsageResponse | null>(null);
let usageWeek = $state<UsageResponse | null>(null);
let agents = $state<AgentsResponse | null>(null);
let failure = $state<string | null>(null);
let now = $state(Date.now());
async function load() {
const { client } = session;
if (!client) {
return;
}
failure = null;
try {
[sessions, usage5h, usageWeek, agents] = await Promise.all([
client.sessions(),
client.usage({ group_by: "agent", hours: HOURS_5H }),
client.usage({ group_by: "agent", hours: HOURS_WEEK }),
client.agents(),
]);
await gateway.refreshLimits();
} catch (cause) {
failure = cause instanceof Error ? cause.message : String(cause);
}
}
async function pollSessions() {
const { client } = session;
if (!client) {
return;
}
sessions = await client.sessions().catch(() => sessions);
}
onMount(() => {
load();
const poll = setInterval(pollSessions, SESSIONS_POLL_MS);
const tick = setInterval(() => {
now = Date.now();
}, TICK_MS);
return () => {
clearInterval(poll);
clearInterval(tick);
};
});
const running = $derived(gateway.running);
const subtitle = $derived.by(() => {
if (running.length > 0) {
return `${running.length} running`;
}
return gateway.loaded ? "idle" : "";
});
const windows = $derived(gateway.limits?.windows ?? []);
const tape = $derived(gateway.tape.slice(0, 40));
</script>
<svelte:head><title>Now · Beaver</title></svelte:head>
<PageHeader {subtitle} title="Now" />
<div class="min-h-0 flex-1 overflow-y-auto">
<div class="mx-auto flex w-full max-w-6xl flex-col gap-6 px-4 py-6 sm:px-6">
<Board />
<div class="flex flex-col gap-8 px-4 py-4 sm:px-6">
{#if failure}
<ErrorNote message={failure} retry={load} />
{/if}
<section class="flex flex-col gap-2">
<h2
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
>
Running
</h2>
{#if !gateway.loaded}
<Skeleton class="h-10 w-full" />
{:else if running.length === 0}
<EmptyState
hint="Nothing is in a turn. The ledger fills in the moment a message or an inject lands."
title="The agents are idle"
/>
{:else}
<ul class="flex flex-col">
{#each running as row (row.id)}
<li>
<a
class="row-hover ledger-grid grid-cols-[auto_auto_minmax(0,1fr)_auto] border-b py-2 text-sm"
href="{base}/conversations/{row.id}"
>
<span
class="size-2 animate-pulse-dot rounded-full bg-signal"
></span>
<KindBadge kind={row.kind} />
<span class="flex min-w-0 flex-col">
<span class="truncate font-medium">
{row.title || `${row.kind} ${shortId(row.id)}`}
</span>
<span class="truncate text-muted-foreground text-xs">
{row.agent}
{row.pending_question ? " · waiting for an answer" : ""}
</span>
</span>
<span class="tabular text-muted-foreground text-xs">
{fmtDuration(elapsedMs(row.last_activity_at ?? "", null, now))}
</span>
</a>
</li>
{/each}
</ul>
{/if}
</section>
<section class="grid gap-6 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
<div class="flex flex-col gap-2">
<h2
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
>
Subscription quota
</h2>
{#if windows.length === 0}
<EmptyState
hint="The SDK reports a window the first time its state changes; until then there is nothing to show."
title="No rate-limit reports yet"
/>
{:else}
<div class="flex flex-col divide-y">
{#each windows as w (w.window)}
<LimitBar {now} window={w} />
{/each}
</div>
{/if}
</div>
<div class="flex flex-col gap-2">
<h2
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
>
Spend via gateway
</h2>
{#if usage5h && usageWeek}
<table class="w-full text-sm">
<thead class="text-muted-foreground text-xs">
<tr class="border-b text-left">
<th class="py-1.5 pr-3 font-medium">range</th>
<th class="py-1.5 pr-3 text-right font-medium">cost</th>
<th class="py-1.5 pr-3 text-right font-medium">turns</th>
<th class="py-1.5 pr-3 text-right font-medium">in / out</th>
<th class="py-1.5 pr-3 text-right font-medium">cache share</th>
<th class="py-1.5 text-right font-medium">written</th>
</tr>
</thead>
<tbody>
{#each [["last 5 h", usage5h.total], ["last 7 d", usageWeek.total]] as [label, total] (label)}
{@const t = total as UsageResponse["total"]}
{@const share = cacheShare(t.input, t.cache_read)}
<tr class="border-b">
<td class="py-1.5 pr-3 font-medium">{label}</td>
<td class="tabular py-1.5 pr-3 text-right font-semibold">
{fmtMoney(t.cost_usd)}
</td>
<td class="tabular py-1.5 pr-3 text-right">{t.turns}</td>
<td class="tabular py-1.5 pr-3 text-right">
{fmtTokens(t.input)}
/ {fmtTokens(t.output)}
</td>
<td
class={cn("tabular py-1.5 pr-3 text-right", share !== null && share < 0.5 && t.turns > 0 && "text-warn")}
>
{fmtPct(share)}
</td>
<td class="tabular py-1.5 text-right">
{fmtTokens(t.cache_creation)}
</td>
</tr>
{/each}
</tbody>
</table>
<a class="text-link text-xs hover:underline" href="{base}/usage">
Full breakdown →
</a>
{:else}
<Skeleton class="h-32 w-full" />
{/if}
</div>
</section>
<section class="flex flex-col gap-2">
<div class="flex items-baseline gap-3">
<h2
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
>
Live sessions
</h2>
{#if sessions}
<span class="tabular text-muted-foreground text-xs">
{sessions.sessions.length}
processes · {fmtBytes(sessions.rss)}
{#if sessions.rss_limit}
of {fmtBytes(sessions.rss_limit)}
{/if}
</span>
{/if}
</div>
{#if !sessions}
<Skeleton class="h-10 w-full" />
{:else if sessions.sessions.length === 0}
<EmptyState
hint="Sessions spawn on the first turn and are reaped by idle time or memory pressure."
title="No claude processes alive"
/>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="text-muted-foreground text-xs">
<tr class="border-b text-left">
<th class="py-1.5 pr-3 font-medium">agent</th>
<th class="py-1.5 pr-3 font-medium">kind</th>
<th class="py-1.5 pr-3 font-medium">state</th>
<th class="py-1.5 pr-3 text-right font-medium">rss</th>
<th class="py-1.5 pr-3 text-right font-medium">idle</th>
<th class="py-1.5 pr-3 text-right font-medium">age</th>
<th class="py-1.5 pr-3 text-right font-medium">turns</th>
<th class="py-1.5 font-medium">conversation</th>
</tr>
</thead>
<tbody>
{#each sessions.sessions as s (s.key)}
<tr class="row-hover border-b">
<td class="py-1.5 pr-3 font-medium">{s.agent}</td>
<td class="py-1.5 pr-3"><KindBadge kind={s.kind} /></td>
<td class="py-1.5 pr-3 text-xs">
<span
class={cn(s.busy ? "text-signal" : "text-muted-foreground")}
>
{s.busy ? "busy" : "idle"}
</span>
{#if s.pinned}
<span class="ml-1 text-muted-foreground">pinned</span>
{/if}
{#if s.dirty}
<span class="ml-1 text-warn">dirty</span>
{/if}
{#if s.pending_question}
<span class="ml-1 text-link">question</span>
{/if}
</td>
<td class="tabular py-1.5 pr-3 text-right">
{fmtBytes(s.rss)}
</td>
<td class="tabular py-1.5 pr-3 text-right">
{fmtSeconds(s.idle_seconds)}
</td>
<td class="tabular py-1.5 pr-3 text-right">
{fmtSeconds(s.age_seconds)}
</td>
<td class="tabular py-1.5 pr-3 text-right">{s.turns}</td>
<td class="tabular py-1.5 text-xs">
<a
class="text-link hover:underline"
href="{base}/conversations/{s.key}"
>
{shortId(s.key)}
</a>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
<section class="grid gap-6 lg:grid-cols-2">
<div class="flex flex-col gap-2">
<h2
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
>
Agents
</h2>
{#if agents}
<ul class="flex flex-col">
{#each agents.agents as a (a.name)}
<li
class="ledger-grid grid-cols-[minmax(0,1fr)_auto] border-b py-1.5 text-sm"
>
<span class="flex min-w-0 flex-col">
<span class="truncate font-medium">{a.name}</span>
<span class="truncate text-muted-foreground text-xs">
{a.model}{a.effort ? ` · ${a.effort}` : ""}
</span>
</span>
<span class="flex gap-1">
{#each a.kinds as k (k)}
<KindBadge kind={k} />
{/each}
</span>
</li>
{/each}
</ul>
{:else}
<Skeleton class="h-20 w-full" />
{/if}
</div>
<div class="flex flex-col gap-2">
<h2
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
>
Endpoints
</h2>
{#if agents}
<Endpoints catalog={agents} />
{:else}
<Skeleton class="h-20 w-full" />
{/if}
</div>
</section>
<section class="flex flex-col gap-2">
<h2
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
>
Event tape
</h2>
{#if tape.length === 0}
<p class="text-muted-foreground text-xs">
Quiet. Bus events (turns, tools, injects, questions) scroll here as
they happen.
</p>
{:else}
<ul class="flex flex-col font-mono text-xs">
{#each tape as item (item.seq)}
<li class="ledger-grid grid-cols-[5rem_8rem_minmax(0,1fr)] py-0.5">
<span class="tabular text-muted-foreground"
>{fmtTime(item.ts)}</span
>
<span class="truncate">{item.type}</span>
<span class="truncate text-muted-foreground">
{#if item.conversation_id}
<a
class="hover:underline"
href="{base}/conversations/{item.conversation_id}"
>
{shortId(item.conversation_id)}
</a>
{/if}
{#if typeof item.name === "string"}
{item.name}
{/if}
{#if typeof item.text === "string"}
{item.text.slice(0, 80)}
{/if}
{#if typeof item.stop === "string"}
{item.stop}
{/if}
</span>
</li>
{/each}
</ul>
{/if}
</section>
</div>
</div>
+105
View File
@@ -0,0 +1,105 @@
<script lang="ts">
import { onMount } from "svelte";
import type { AuditRecord } from "$lib/api/types";
import EmptyState from "$lib/components/empty-state.svelte";
import ErrorNote from "$lib/components/error-note.svelte";
import PageHeader from "$lib/components/page-header.svelte";
import { Button } from "$lib/components/ui/button";
import { Skeleton } from "$lib/components/ui/skeleton";
import { fmtDateTime } from "$lib/format";
import { session } from "$lib/session.svelte";
const PAGE = 100;
let records = $state<AuditRecord[] | null>(null);
let nextBefore = $state<number | null>(null);
let failure = $state<string | null>(null);
let busy = $state(false);
async function load(more = false) {
const { client } = session;
if (!client) {
return;
}
failure = null;
busy = true;
try {
const page = await client.audit({
before: more && nextBefore ? nextBefore : undefined,
limit: PAGE,
});
records = more ? [...(records ?? []), ...page.records] : page.records;
nextBefore = page.next_before;
} catch (cause) {
failure = cause instanceof Error ? cause.message : String(cause);
} finally {
busy = false;
}
}
onMount(() => {
load();
});
function detail(record: AuditRecord): string {
if (typeof record.detail === "string") {
return record.detail;
}
return Object.entries(record.detail)
.map(
([key, value]) =>
`${key}=${typeof value === "string" ? value : JSON.stringify(value)}`
)
.join(" ");
}
</script>
<svelte:head><title>Audit · Beaver</title></svelte:head>
<PageHeader title="Audit" />
<div class="min-h-0 flex-1 overflow-y-auto">
<div class="flex flex-col gap-3 px-4 py-4 sm:px-6">
{#if failure}
<ErrorNote message={failure} retry={() => load()} />
{:else if records === null}
<Skeleton class="h-40 w-full" />
{:else if records.length === 0}
<EmptyState
hint="Logins, token changes and API writes land here."
title="Nothing audited yet"
/>
{:else}
<ul class="flex flex-col text-xs">
{#each records as record (record.id)}
<li
class="ledger-grid grid-cols-[9rem_7rem_9rem_minmax(0,1fr)] border-b py-1.5"
>
<span class="tabular text-muted-foreground"
>{fmtDateTime(record.ts)}</span
>
<span class="truncate font-medium">{record.kind}</span>
<span class="truncate text-muted-foreground">{record.actor}</span>
<span class="truncate font-mono text-muted-foreground">
{#if record.agent}
{record.agent}
·
{/if}
{detail(record)}
</span>
</li>
{/each}
</ul>
{#if nextBefore !== null}
<Button
class="self-start"
disabled={busy}
onclick={() => load(true)}
size="sm"
variant="outline"
>
Older
</Button>
{/if}
{/if}
</div>
</div>
+20 -25
View File
@@ -1,18 +1,16 @@
<script lang="ts">
import PanelLeftOpenIcon from "@lucide/svelte/icons/panel-left-open";
import { goto } from "$app/navigation";
import { base } from "$app/paths";
import { page } from "$app/state";
import ConversationList from "$lib/components/conversation-list.svelte";
import { Button } from "$lib/components/ui/button";
import { gateway } from "$lib/gateway.svelte";
import Rail from "$lib/rail/rail.svelte";
import { session } from "$lib/session.svelte";
import { ui } from "$lib/ui.svelte";
import { cn } from "$lib/utils";
let { children } = $props();
const selected = $derived(page.params.id ?? null);
const href = (id: string) => `${base}/conversations/${id}`;
const running = $derived(gateway.running.length);
</script>
<svelte:head><title>Conversations · Beaver</title></svelte:head>
@@ -22,42 +20,39 @@
"grid min-h-0 flex-1 grid-cols-1",
ui.rail
? "lg:grid-cols-[22rem_minmax(0,1fr)]"
: "lg:grid-cols-[2.5rem_minmax(0,1fr)]"
: "lg:grid-cols-[2.75rem_minmax(0,1fr)]"
)}
>
<div
class={cn(
"min-h-0 border-r",
"min-h-0 border-r bg-sidebar/40",
selected ? "hidden lg:block" : "block"
)}
>
{#if ui.rail}
{#if session.client}
<Rail
client={session.client}
{href}
index={gateway}
onOpenFile={(path) =>
goto(`${base}/memory?path=${encodeURIComponent(path)}`)}
{selected}
/>
{/if}
<ConversationList {selected} />
{:else}
<div class="hidden h-full flex-col items-center py-2 lg:flex">
<button
<div class="hidden h-full flex-col items-center gap-2 py-2 lg:flex">
<Button
aria-label="Show conversations"
class="rule-word inline-flex size-7 items-center justify-center rounded-md"
onclick={() => ui.toggleRail()}
size="icon-sm"
title="Show conversations"
type="button"
variant="ghost"
>
<PanelLeftOpenIcon class="size-4" />
</button>
</Button>
{#if running > 0}
<span
class="tabular rounded-full bg-signal/12 px-1.5 font-medium text-signal text-xs"
title="{running} running"
>
{running}
</span>
{/if}
</div>
<div class="h-full lg:hidden">
{#if session.client}
<Rail client={session.client} {href} index={gateway} {selected} />
{/if}
<ConversationList {selected} />
</div>
{/if}
</div>
+9 -28
View File
@@ -1,35 +1,16 @@
<script lang="ts">
import { gateway } from "$lib/gateway.svelte";
import KindMark, { KIND_MEANING } from "$lib/shell/kind-mark.svelte";
const KINDS = ["master", "branch", "deep", "fork", "job"];
const running = $derived(gateway.running.length);
function meaning(kind: string): string {
return KIND_MEANING[kind]?.split(" — ")[1] ?? "";
}
</script>
<div class="flex flex-1 flex-col items-center justify-center gap-6 p-6">
<p class="text-center text-muted-foreground text-sm">
Pick a strip on the left.
{#if running > 0}
<span class="text-signal">{running} in motion right now.</span>
{/if}
</p>
<dl
class="grid max-w-md grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-2 text-sm"
>
{#each KINDS as kind (kind)}
<dt class="flex items-center gap-2">
<KindMark {kind} />
<span class="font-medium">{kind}</span>
</dt>
<dd class="text-muted-foreground">{meaning(kind)}</dd>
{/each}
</dl>
<p class="max-w-md text-center text-muted-foreground text-xs">
Days go by the last word, not by the parent: an open branch follows the
master through the morning rotation. Right-click a strip for its actions.
</p>
<div
class="flex flex-1 items-center justify-center p-6 text-muted-foreground text-sm"
>
{#if running > 0}
{running}
running - pick one on the left.
{:else}
Pick a conversation on the left.
{/if}
</div>
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff. Show more