Compare commits
76
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c120953b02 | ||
|
|
5eb9f435ec | ||
|
|
e79e9fc149 | ||
|
|
60782d6276 | ||
|
|
421248b0ad | ||
|
|
8019c76e53 | ||
|
|
2afd3bc3c1 | ||
|
|
5b851c11d4 | ||
|
|
2d903c7c5f | ||
|
|
ef7474f636 | ||
|
|
fe1c894b93 | ||
|
|
2815956f5b | ||
|
|
c46ba026ba | ||
|
|
81d6560bf7 | ||
|
|
f35980ba85 | ||
|
|
c6401f1114 | ||
|
|
025f2dbc3f | ||
|
|
85b14e2c2f | ||
|
|
7a8ee0f200 | ||
|
|
2d34d488d7 | ||
|
|
0e07e8409d | ||
|
|
3915ab48f9 | ||
|
|
9aaddbed75 | ||
|
|
253bde1b11 | ||
|
|
61b5bda31a | ||
|
|
e2d611dd81 | ||
|
|
bac40ff268 | ||
|
|
39f2bc05d9 | ||
|
|
76d6f81789 | ||
|
|
8f9ca2800c | ||
|
|
f9bf51badf | ||
|
|
90063a7cfe | ||
|
|
a8491330fa | ||
|
|
064379ca90 | ||
|
|
e90963a6c3 | ||
|
|
064d855382 | ||
|
|
5d67cfcc9e | ||
|
|
b4853b9fd0 | ||
|
|
2d12391176 | ||
|
|
60dc7db925 | ||
|
|
be114e6eaf | ||
|
|
4a59c59bda | ||
|
|
72639c4b90 | ||
|
|
1786406b31 | ||
|
|
12728b2eb4 | ||
|
|
bc4233977d | ||
|
|
fd69eae05d | ||
|
|
d3a3e7b1f1 | ||
|
|
772db11c7f | ||
|
|
4fb020c788 | ||
|
|
932a7a80af | ||
|
|
3ca5843868 | ||
|
|
8ea4cea3ef | ||
|
|
b6c5a80326 | ||
|
|
98796a82c6 | ||
|
|
61562e947d | ||
|
|
609af9fe67 | ||
|
|
e469e7e174 | ||
|
|
c88726db9e | ||
|
|
38986cb82e | ||
|
|
43407016d0 | ||
|
|
7240e0cdac | ||
|
|
7b0c61c18e | ||
|
|
0110cbd1cc | ||
|
|
9663fbf956 | ||
|
|
5177392f46 | ||
|
|
5fc58e3bc7 | ||
|
|
5d2ce68f6b | ||
|
|
70349e5ff4 | ||
|
|
827fa0977b | ||
|
|
e3074c266a | ||
|
|
ab52fdc2b8 | ||
|
|
7424d52f88 | ||
|
|
b3a584a362 | ||
|
|
a2a80ab150 | ||
|
|
d33039f497 |
No files matched your search
@@ -28,3 +28,7 @@ RAYCAST_BEARER=
|
||||
# токен от @BotFather, свой user id - @userinfobot
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
TELEGRAM_USER_ID=
|
||||
|
||||
# Откуда тулза send_file имеет право брать файлы (через ':').
|
||||
# Папка входящих вложений разрешена всегда, её дописывает сам фронтенд.
|
||||
SEND_FILE_ROOTS=/mnt/hole/shared
|
||||
@@ -7,6 +7,8 @@ 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.
|
||||
|
||||

|
||||
|
||||
## What it gives a setup
|
||||
|
||||
- **agents/** - what an agent is: a Claude agent with a prompt per
|
||||
@@ -20,9 +22,13 @@ 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),
|
||||
- **frontends/** - the windows: Telegram (master = General, topic = branch,
|
||||
files out of the agent through `send_file`),
|
||||
markdown files in a vault, `/api` + the admin SPA, an Anthropic-compatible
|
||||
`/anthropic/v1/messages`, MCP re-exposure at `/mcp/<name>`.
|
||||
`/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`).
|
||||
- **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.
|
||||
@@ -32,21 +38,29 @@ 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. Every string the gateway puts in front of
|
||||
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.
|
||||
|
||||

|
||||
|
||||
 Every string the gateway puts in front of
|
||||
a model or a user has an English default and is overridable
|
||||
(`ConversationTexts`, `TelegramTexts`).
|
||||
|
||||
## Run
|
||||
|
||||
```sh
|
||||
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
|
||||
cd examples
|
||||
CLAUDE_CODE_OAUTH_TOKEN=$(claude setup-token) docker compose up --build
|
||||
```
|
||||
|
||||
`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).
|
||||
`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).
|
||||
|
||||
## Develop
|
||||
|
||||
|
||||
+27
-22
@@ -1,31 +1,36 @@
|
||||
# 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:
|
||||
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
|
||||
|
||||
gateway:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: Dockerfile
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
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.
|
||||
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}
|
||||
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.
|
After Width: | Height: | Size: 138 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
@@ -6,6 +6,7 @@ import asyncio
|
||||
import functools
|
||||
import logging
|
||||
from contextlib import AsyncExitStack
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import psycopg
|
||||
@@ -33,7 +34,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
|
||||
from beaver_gateway.security.auth import TokenStore, scopes_with
|
||||
from beaver_gateway.storage import (
|
||||
Database,
|
||||
PostgresSessionStore,
|
||||
@@ -194,6 +195,8 @@ 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)
|
||||
@@ -296,6 +299,10 @@ 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
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ from claude_agent_sdk import (
|
||||
PermissionResultDeny,
|
||||
ResultMessage,
|
||||
StreamEvent,
|
||||
TaskNotificationMessage,
|
||||
TextBlock,
|
||||
ThinkingBlock,
|
||||
ToolResultBlock,
|
||||
@@ -72,7 +73,14 @@ from beaver_gateway.events.stream import (
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Sequence
|
||||
from collections.abc import (
|
||||
AsyncIterator,
|
||||
Awaitable,
|
||||
Callable,
|
||||
Coroutine,
|
||||
Iterable,
|
||||
Sequence,
|
||||
)
|
||||
|
||||
from anthropic.types import MessageParam
|
||||
from claude_agent_sdk import (
|
||||
@@ -201,8 +209,16 @@ 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()
|
||||
@@ -256,6 +272,51 @@ 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:
|
||||
@@ -313,7 +374,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_text(history[-1].get("content"))
|
||||
prompt = _prompt(history[-1].get("content"))
|
||||
prior = history[:-1]
|
||||
key = conversation_id or fingerprint(prior)
|
||||
spec = _SessionSpec(kind=kind, pinned=pinned, tools=tools)
|
||||
@@ -404,70 +465,71 @@ class ClaudeSdkBackend:
|
||||
async def _run_turn(
|
||||
self,
|
||||
live: Session,
|
||||
prompt: str,
|
||||
prompt: str | list[dict[str, Any]] | None,
|
||||
turn: _Turn,
|
||||
observer: Callable[[Any], None] | None = None,
|
||||
capture: TurnCapture | None = None,
|
||||
) -> AsyncIterator[MessageStreamEvent]:
|
||||
"""Run one prompt, yielding wire events as they arrive.
|
||||
"""Run one prompt, or attach to a self-started one, yielding events.
|
||||
|
||||
``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] = []
|
||||
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,
|
||||
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)
|
||||
)
|
||||
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"))
|
||||
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
|
||||
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"
|
||||
)
|
||||
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
|
||||
turn.synthesized = synthesize_turn_messages(raw)
|
||||
_log.info(
|
||||
"turn: agent=%s session=%s events=%d synthesized=%d stop=%s",
|
||||
@@ -478,6 +540,88 @@ 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,
|
||||
@@ -541,7 +685,7 @@ class ClaudeSdkBackend:
|
||||
spec.tools,
|
||||
self._runner.user,
|
||||
)
|
||||
return Session(
|
||||
live = Session(
|
||||
key=key,
|
||||
agent=self._agent.name,
|
||||
kind=spec.kind,
|
||||
@@ -550,6 +694,8 @@ 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)
|
||||
@@ -739,6 +885,15 @@ class _SessionSpec:
|
||||
tools: bool
|
||||
|
||||
|
||||
_END = object()
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Cursor:
|
||||
next_index: int = 0
|
||||
offset: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Turn:
|
||||
events: int = 0
|
||||
@@ -845,14 +1000,54 @@ def _mcp_disallowed(
|
||||
return out
|
||||
|
||||
|
||||
def _prompt_text(content: Any) -> str:
|
||||
def _prompt(content: Any) -> str | list[dict[str, Any]]:
|
||||
"""Plain text when the turn is text only; the block list when images ride along."""
|
||||
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:
|
||||
|
||||
@@ -20,7 +20,13 @@ from typing import TYPE_CHECKING, Any, Protocol
|
||||
import psutil
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator, Iterator, Mapping
|
||||
from collections.abc import (
|
||||
AsyncIterable,
|
||||
AsyncIterator,
|
||||
Callable,
|
||||
Iterator,
|
||||
Mapping,
|
||||
)
|
||||
|
||||
__all__ = ["DEFAULT_TTL", "Session", "SessionClient", "SessionPool", "cgroup_limit"]
|
||||
|
||||
@@ -41,8 +47,8 @@ _RSS_HEADROOM = 0.8
|
||||
|
||||
class SessionClient(Protocol):
|
||||
async def connect(self) -> None: ...
|
||||
async def query(self, prompt: str) -> None: ...
|
||||
def receive_response(self) -> AsyncIterator[Any]: ...
|
||||
async def query(self, prompt: str | AsyncIterable[dict[str, Any]]) -> None: ...
|
||||
def receive_messages(self) -> AsyncIterator[Any]: ...
|
||||
async def interrupt(self) -> None: ...
|
||||
async def disconnect(self) -> None: ...
|
||||
|
||||
@@ -67,6 +73,20 @@ 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
|
||||
@@ -192,6 +212,8 @@ 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
|
||||
@@ -238,6 +260,7 @@ 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,
|
||||
|
||||
@@ -161,7 +161,10 @@ def build_entries(
|
||||
return entries
|
||||
|
||||
|
||||
def messages_from_entries(entries: Iterable[Mapping[str, Any]]) -> list[dict[str, Any]]:
|
||||
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."""
|
||||
out: list[dict[str, Any]] = []
|
||||
last_message_id: str | None = None
|
||||
for entry in entries:
|
||||
@@ -180,7 +183,13 @@ def messages_from_entries(entries: Iterable[Mapping[str, Any]]) -> list[dict[str
|
||||
):
|
||||
out[-1]["content"].extend(results)
|
||||
else:
|
||||
out.append({"role": "user", "content": _user_content(content)})
|
||||
message_out: dict[str, Any] = {
|
||||
"role": "user",
|
||||
"content": _user_content(content),
|
||||
}
|
||||
if with_ts:
|
||||
message_out["ts"] = entry.get("timestamp")
|
||||
out.append(message_out)
|
||||
last_message_id = None
|
||||
continue
|
||||
blocks = _assistant_blocks(content)
|
||||
@@ -193,7 +202,10 @@ def messages_from_entries(entries: Iterable[Mapping[str, Any]]) -> list[dict[str
|
||||
):
|
||||
out[-1]["content"].extend(blocks)
|
||||
else:
|
||||
out.append({"role": "assistant", "content": blocks})
|
||||
message_out: dict[str, Any] = {"role": "assistant", "content": blocks}
|
||||
if with_ts:
|
||||
message_out["ts"] = entry.get("timestamp")
|
||||
out.append(message_out)
|
||||
last_message_id = message_id
|
||||
return out
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any
|
||||
@@ -30,6 +31,8 @@ if TYPE_CHECKING:
|
||||
|
||||
__all__ = ["ConfigError", "Gateway", "load"]
|
||||
|
||||
_SCOPE = re.compile(r"[a-z][a-z0-9_-]*")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Gateway:
|
||||
@@ -135,6 +138,12 @@ 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):
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
"""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)
|
||||
),
|
||||
}
|
||||
@@ -25,6 +25,8 @@ 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)
|
||||
@@ -40,7 +42,12 @@ class Envelope:
|
||||
texts: EnvelopeTexts = field(default_factory=EnvelopeTexts)
|
||||
|
||||
def build(
|
||||
self, *, now: datetime | None = None, text: str = "", kind: str = "master"
|
||||
self,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
text: str = "",
|
||||
kind: str = "master",
|
||||
agent: str = "",
|
||||
) -> str:
|
||||
now = now or datetime.now(UTC)
|
||||
changes = self.watch.take() if self.watch is not None else []
|
||||
@@ -59,20 +66,26 @@ class Envelope:
|
||||
texts=self.texts,
|
||||
)
|
||||
self.last_at = now
|
||||
block = self.recall_block(text=text, kind=kind, now=now)
|
||||
block = self.recall_block(text=text, kind=kind, now=now, agent=agent)
|
||||
return f"{out}\n{block}" if block else out
|
||||
|
||||
def recall_only(
|
||||
self, *, text: str, kind: str, now: datetime | None = None
|
||||
self, *, text: str, kind: str, now: datetime | None = None, agent: str = ""
|
||||
) -> str | None:
|
||||
block = self.recall_block(text=text, kind=kind, now=now or datetime.now(UTC))
|
||||
block = self.recall_block(
|
||||
text=text, kind=kind, now=now or datetime.now(UTC), agent=agent
|
||||
)
|
||||
return f"{self.texts.header}\n{block}" if block else None
|
||||
|
||||
def recall_block(self, *, text: str, kind: str, now: datetime) -> str | None:
|
||||
def recall_block(
|
||||
self, *, text: str, kind: str, now: datetime, agent: str = ""
|
||||
) -> str | None:
|
||||
if self.recall is None or not text.strip():
|
||||
return None
|
||||
try:
|
||||
block = self.recall(RecallContext(text=text, kind=kind, now=now))
|
||||
block = self.recall(
|
||||
RecallContext(text=text, kind=kind, now=now, agent=agent)
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
_log.exception("recall hook failed")
|
||||
return None
|
||||
|
||||
@@ -9,7 +9,7 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Literal, cast
|
||||
from typing import TYPE_CHECKING, Any, Literal, cast
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlmodel import col, select
|
||||
@@ -70,13 +70,23 @@ class InjectQueue:
|
||||
self._db = db
|
||||
|
||||
async def push(
|
||||
self, *, conversation_id: int, priority: Priority, origin: str, text: str
|
||||
self,
|
||||
*,
|
||||
conversation_id: int,
|
||||
priority: Priority,
|
||||
origin: str,
|
||||
text: str,
|
||||
attachments: list[dict[str, Any]] | None = None,
|
||||
) -> 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
|
||||
conversation_id=conversation_id,
|
||||
priority=priority,
|
||||
origin=origin,
|
||||
text=text,
|
||||
attachments=attachments or None,
|
||||
)
|
||||
async with self._db.session() as session:
|
||||
session.add(row)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Putting words into a conversation: a message, an inject, ``say``, ``schedule``."""
|
||||
"""Putting words into a conversation: a message, an inject, ``say``, a file."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -6,6 +6,7 @@ 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
|
||||
@@ -20,13 +21,19 @@ _log = logging.getLogger(__name__)
|
||||
|
||||
class Messaging(Turns):
|
||||
async def post(
|
||||
self, conv: Conversation, text: str, *, origin: str = "user"
|
||||
self,
|
||||
conv: Conversation,
|
||||
text: str,
|
||||
*,
|
||||
origin: str = "user",
|
||||
attachments: list[dict[str, Any]] | None = None,
|
||||
) -> 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(
|
||||
@@ -86,6 +93,14 @@ 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])
|
||||
@@ -96,6 +111,31 @@ 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,
|
||||
|
||||
@@ -8,6 +8,8 @@ from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import String, func
|
||||
from sqlalchemy import cast as sa_cast
|
||||
from sqlmodel import col, select
|
||||
|
||||
from beaver_gateway.backends.transcript import (
|
||||
@@ -23,6 +25,7 @@ from beaver_gateway.storage.models import (
|
||||
ConversationBinding,
|
||||
ConversationMessage,
|
||||
RateLimit,
|
||||
TranscriptEntry,
|
||||
Usage,
|
||||
)
|
||||
|
||||
@@ -132,6 +135,30 @@ class Rows(State):
|
||||
async with self._db.session() as session:
|
||||
return list((await session.exec(stmt)).all())
|
||||
|
||||
async def search(self, query: str, *, limit: int = 20) -> list[Conversation]:
|
||||
needle = f"%{query}%"
|
||||
by_title = select(Conversation.id).where(col(Conversation.title).ilike(needle))
|
||||
by_first = select(ConversationMessage.conversation_id).where(
|
||||
ConversationMessage.seq == 0,
|
||||
col(ConversationMessage.content_json).ilike(needle),
|
||||
)
|
||||
by_entry = (
|
||||
select(Conversation.id)
|
||||
.join(
|
||||
TranscriptEntry,
|
||||
col(TranscriptEntry.session_id) == col(Conversation.session_id),
|
||||
)
|
||||
.where(sa_cast(col(TranscriptEntry.entry), String).ilike(needle))
|
||||
)
|
||||
stmt = (
|
||||
select(Conversation)
|
||||
.where(col(Conversation.id).in_(by_title.union(by_first, by_entry)))
|
||||
.order_by(col(Conversation.id).desc())
|
||||
.limit(limit)
|
||||
)
|
||||
async with self._db.session() as session:
|
||||
return list((await session.exec(stmt)).all())
|
||||
|
||||
async def bindings(self, conv: Conversation) -> list[ConversationBinding]:
|
||||
async with self._db.session() as session:
|
||||
result = await session.exec(
|
||||
@@ -317,6 +344,7 @@ class Rows(State):
|
||||
async def describe(self, conv: Conversation) -> dict[str, Any]:
|
||||
out = self.public(conv)
|
||||
out["title"] = await self.implied_title(conv)
|
||||
out["context_tokens"] = await self.context_tokens(conv)
|
||||
parent = await self.get_row(conv.parent_id) if conv.parent_id else None
|
||||
out["parent"] = parent.external_id if parent is not None else None
|
||||
out["bindings"] = [
|
||||
@@ -359,6 +387,25 @@ class Rows(State):
|
||||
).first()
|
||||
return context_of(row)
|
||||
|
||||
async def context_tokens_by_id(self, ids: Iterable[str]) -> dict[str, int]:
|
||||
wanted = list(ids)
|
||||
if not wanted:
|
||||
return {}
|
||||
latest = (
|
||||
select(func.max(Usage.id))
|
||||
.where(col(Usage.conversation_id).in_(wanted))
|
||||
.group_by(Usage.conversation_id)
|
||||
)
|
||||
async with self._db.session() as session:
|
||||
rows = (
|
||||
await session.exec(select(Usage).where(col(Usage.id).in_(latest)))
|
||||
).all()
|
||||
return {
|
||||
cast("str", row.conversation_id): context_of(row)
|
||||
for row in rows
|
||||
if row.conversation_id
|
||||
}
|
||||
|
||||
async def usage_tokens(self, since: datetime) -> int:
|
||||
async with self._db.session() as session:
|
||||
rows = (
|
||||
@@ -427,7 +474,9 @@ class Rows(State):
|
||||
return await load_messages(
|
||||
session, conversation_id=cast("int", conv.id)
|
||||
)
|
||||
return messages_from_entries(cast("Any", await self.entries(conv)))
|
||||
return messages_from_entries(
|
||||
cast("Any", await self.entries(conv)), with_ts=True
|
||||
)
|
||||
|
||||
async def entries(self, conv: Conversation, *, subpath: str = "") -> list[Any]:
|
||||
if conv.session_id is None:
|
||||
|
||||
@@ -44,6 +44,7 @@ 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.
|
||||
|
||||
@@ -95,6 +96,7 @@ 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,6 +127,12 @@ 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,6 +51,8 @@ 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)
|
||||
@@ -77,6 +79,8 @@ 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 = (
|
||||
|
||||
@@ -14,6 +14,7 @@ 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 = (
|
||||
@@ -42,7 +43,23 @@ 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")
|
||||
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."
|
||||
)
|
||||
|
||||
|
||||
def build_tool_server(
|
||||
@@ -232,6 +249,33 @@ 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 "
|
||||
@@ -248,7 +292,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, close_chat]
|
||||
return [read_conversation, spawn, say, schedule, inject, send_file, close_chat]
|
||||
|
||||
|
||||
def _text(text: str) -> dict[str, Any]:
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
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
|
||||
|
||||
@@ -34,7 +36,7 @@ if TYPE_CHECKING:
|
||||
|
||||
from beaver_gateway.events.stream import MessageStreamEvent
|
||||
|
||||
__all__ = ["Turns"]
|
||||
__all__ = ["Turns", "content_of", "image_block"]
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
@@ -52,8 +54,15 @@ 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."""
|
||||
"""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.
|
||||
"""
|
||||
row_id = cast("int", conv.id)
|
||||
runner = self._runner(row_id)
|
||||
backend = self._backend(conv.agent_name)
|
||||
@@ -63,7 +72,7 @@ class Turns(Seeds):
|
||||
async with runner.lock:
|
||||
runner.turn_id = turn_id
|
||||
runner.origin = origin
|
||||
runner.text = _prompt_preview(messages)
|
||||
runner.text = shown if shown is not None else _prompt_preview(messages)
|
||||
runner.started_at = datetime.now(UTC)
|
||||
runner.tools = {}
|
||||
await self._mark_running(conv, turn_id)
|
||||
@@ -79,18 +88,28 @@ class Turns(Seeds):
|
||||
stop = "error"
|
||||
cut = False
|
||||
try:
|
||||
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,
|
||||
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,
|
||||
)
|
||||
)
|
||||
async for event in events:
|
||||
yield event
|
||||
@@ -125,18 +144,23 @@ 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": text}],
|
||||
messages=[{"role": "user", "content": content_of(text, attachments)}],
|
||||
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)
|
||||
@@ -157,6 +181,47 @@ 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()
|
||||
@@ -167,6 +232,10 @@ 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:
|
||||
@@ -224,7 +293,13 @@ 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
|
||||
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,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
_log.exception("turn %s on %s failed", turn_id, conv.external_id)
|
||||
@@ -246,6 +321,12 @@ 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)
|
||||
@@ -255,9 +336,11 @@ class Turns(Seeds):
|
||||
if self._envelope is None:
|
||||
return None
|
||||
if conv.kind == "master":
|
||||
return self._envelope.build(text=text, kind="master")
|
||||
return self._envelope.build(text=text, kind="master", agent=conv.agent_name)
|
||||
if conv.kind == "branch":
|
||||
return self._envelope.recall_only(text=text, kind="branch")
|
||||
return self._envelope.recall_only(
|
||||
text=text, kind="branch", agent=conv.agent_name
|
||||
)
|
||||
return None
|
||||
|
||||
async def _note_user(self, conv: Conversation, text: str) -> None:
|
||||
@@ -269,6 +352,7 @@ class Turns(Seeds):
|
||||
title=conv.title,
|
||||
text=text,
|
||||
at=datetime.now(UTC),
|
||||
agent=conv.agent_name,
|
||||
)
|
||||
try:
|
||||
result = self._user_sink(message)
|
||||
@@ -402,6 +486,33 @@ 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
|
||||
|
||||
@@ -18,6 +18,10 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from sqlmodel import col, select
|
||||
|
||||
from beaver_gateway.agents.claude import ClaudeAgent
|
||||
from beaver_gateway.agents.prompts import assemble
|
||||
from beaver_gateway.backends.transcript import prompt_count
|
||||
from beaver_gateway.conversations.context import compose
|
||||
from beaver_gateway.conversations.injects import URGENCY
|
||||
from beaver_gateway.conversations.kinds import Kind, as_kind
|
||||
from beaver_gateway.conversations.service import SEEDS, implied_title
|
||||
@@ -31,7 +35,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 VALID_SCOPES, hash_token
|
||||
from beaver_gateway.security.auth import hash_token
|
||||
from beaver_gateway.storage import (
|
||||
create_token,
|
||||
list_audit_records,
|
||||
@@ -45,6 +49,7 @@ from beaver_gateway.storage.models import (
|
||||
Token,
|
||||
Usage,
|
||||
)
|
||||
from beaver_gateway.vault.links import LinkIndex
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator, Iterable, Sequence
|
||||
@@ -70,6 +75,10 @@ WINDOWS: dict[str, timedelta] = {
|
||||
MEMORY_MAX_DEPTH = 12
|
||||
MEMORY_MAX_FILE = 2_000_000
|
||||
MEMORY_MAX_ENTRIES = 5000
|
||||
SEARCH_MIN = 2
|
||||
GRAPH_LIMIT = 120
|
||||
SEARCH_LIMIT = 20
|
||||
SNIPPET_MAX = 160
|
||||
|
||||
|
||||
class ApiFrontend(Frontend):
|
||||
@@ -85,12 +94,14 @@ class ApiFrontend(Frontend):
|
||||
deep_agent: str | None = None,
|
||||
job_agent: str | None = None,
|
||||
memory_root: Path | None = None,
|
||||
vault_root: Path | None = None,
|
||||
) -> None:
|
||||
self.master_agent = master_agent
|
||||
self.branch_agent = branch_agent
|
||||
self.deep_agent = deep_agent
|
||||
self.job_agent = job_agent
|
||||
self.memory_root = memory_root.resolve() if memory_root is not None else None
|
||||
self.vault_root = vault_root.resolve() if vault_root is not None else None
|
||||
self._app: FastAPI | None = None
|
||||
|
||||
def agent_for(self, kind: Kind) -> str | None:
|
||||
@@ -105,14 +116,22 @@ class ApiFrontend(Frontend):
|
||||
if runtime.conversations is None or runtime.bus is None:
|
||||
msg = "ApiFrontend needs runtime.conversations and runtime.bus"
|
||||
raise RuntimeError(msg)
|
||||
self._app = build_app(runtime, memory_root=self.memory_root)
|
||||
self._app = build_app(
|
||||
runtime, memory_root=self.memory_root, vault_root=self.vault_root
|
||||
)
|
||||
|
||||
def app(self) -> FastAPI | None:
|
||||
return self._app
|
||||
|
||||
|
||||
def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> FastAPI: # noqa: PLR0915
|
||||
def build_app( # noqa: PLR0915
|
||||
runtime: GatewayRuntime,
|
||||
*,
|
||||
memory_root: Path | None = None,
|
||||
vault_root: Path | None = None,
|
||||
) -> FastAPI:
|
||||
app = FastAPI(title="beaver-gateway / API")
|
||||
links = LinkIndex(vault_root) if vault_root is not None else None
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
@@ -198,6 +217,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
||||
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
|
||||
@@ -218,17 +238,49 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
||||
firsts = await conversations.first_user_texts(
|
||||
cast("int", r.id) for r in rows if not r.title
|
||||
)
|
||||
contexts = await conversations.context_tokens_by_id(r.external_id for r in rows)
|
||||
public_ids = {r.id: r.external_id for r in rows}
|
||||
return {
|
||||
"conversations": [
|
||||
{
|
||||
**conversations.public(r),
|
||||
"title": r.title or implied_title(firsts.get(cast("int", r.id))),
|
||||
"last_item": _queue_item(latest.get(cast("int", r.id))),
|
||||
"context_tokens": contexts.get(r.external_id, 0),
|
||||
"parent": public_ids.get(r.parent_id) if r.parent_id else None,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
}
|
||||
|
||||
@app.get("/search")
|
||||
async def search(request: Request) -> dict[str, Any]:
|
||||
await require_token(request, runtime, scope=SCOPE)
|
||||
query = (request.query_params.get("q") or "").strip()
|
||||
if len(query) < SEARCH_MIN:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST, f"q must be at least {SEARCH_MIN} chars"
|
||||
)
|
||||
limit = min(query_int(request, "limit", SEARCH_LIMIT), SEARCH_LIMIT)
|
||||
rows = await conversations.search(query, limit=limit)
|
||||
firsts = await conversations.first_user_texts(
|
||||
cast("int", r.id) for r in rows if not r.title
|
||||
)
|
||||
contexts = await conversations.context_tokens_by_id(r.external_id for r in rows)
|
||||
files = _grep_memory(memory_root, query, limit=limit) if memory_root else []
|
||||
return {
|
||||
"query": query,
|
||||
"conversations": [
|
||||
{
|
||||
**conversations.public(r),
|
||||
"title": r.title or implied_title(firsts.get(cast("int", r.id))),
|
||||
"context_tokens": contexts.get(r.external_id, 0),
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
"files": files,
|
||||
}
|
||||
|
||||
@app.post("/conversations", status_code=status.HTTP_201_CREATED)
|
||||
async def create_conversation(request: Request) -> dict[str, Any]:
|
||||
token = await require_token(request, runtime, scope=SCOPE)
|
||||
@@ -301,6 +353,45 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
||||
conv = await conv_of(public_id)
|
||||
return {"id": conv.external_id, "messages": await conversations.history(conv)}
|
||||
|
||||
@app.get("/conversations/{public_id}/context")
|
||||
async def get_context(public_id: str, request: Request) -> dict[str, Any]:
|
||||
await require_token(request, runtime, scope=SCOPE)
|
||||
conv = await conv_of(public_id)
|
||||
agent = runtime.agents.get(conv.agent_name)
|
||||
if not isinstance(agent, ClaudeAgent):
|
||||
raise HTTPException(
|
||||
status.HTTP_404_NOT_FOUND, f"no claude agent behind {public_id}"
|
||||
)
|
||||
entries = list(await conversations.entries(conv))
|
||||
for sub in await conversations.subpaths(conv):
|
||||
entries.extend(await conversations.entries(conv, subpath=sub))
|
||||
out = compose(
|
||||
agent,
|
||||
as_kind(conv.kind),
|
||||
entries,
|
||||
context_tokens=await conversations.context_tokens(conv),
|
||||
)
|
||||
out["id"] = conv.external_id
|
||||
out["turns"] = prompt_count(entries)
|
||||
if request.query_params.get("prompt"):
|
||||
sources = agent.prompt_for(as_kind(conv.kind))
|
||||
out["prompt_text"] = (
|
||||
assemble(sources) if sources is not None else agent.system_prompt
|
||||
)
|
||||
return out
|
||||
|
||||
@app.get("/vault/graph")
|
||||
async def vault_graph(request: Request) -> dict[str, Any]:
|
||||
await require_token(request, runtime, scope=SCOPE)
|
||||
if links is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_404_NOT_FOUND,
|
||||
"no vault root: set ApiFrontend(vault_root=...)",
|
||||
)
|
||||
paths = [p for p in request.query_params.getlist("path") if p]
|
||||
limit = min(query_int(request, "limit", GRAPH_LIMIT), GRAPH_LIMIT)
|
||||
return {"root": str(links.root), **links.neighbours(paths, limit=limit)}
|
||||
|
||||
@app.get("/conversations/{public_id}/entries")
|
||||
async def get_entries(public_id: str, request: Request) -> dict[str, Any]:
|
||||
await require_token(request, runtime, scope=SCOPE)
|
||||
@@ -647,22 +738,51 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
||||
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, 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)
|
||||
)
|
||||
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
|
||||
gateway = _sum_usage(await _usage_rows(runtime, since, now))
|
||||
gateway["since"] = since.isoformat(timespec="seconds")
|
||||
windows.append({**_limit_public(row), "gateway": gateway})
|
||||
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.sort(
|
||||
key=lambda w: (WINDOWS.get(w["window"], timedelta.max), w["window"])
|
||||
key=lambda w: (_window_length(w["window"]) or timedelta.max, w["window"])
|
||||
)
|
||||
return {"windows": windows, "history": [_limit_public(r) for r in rows[:50]]}
|
||||
|
||||
@@ -695,6 +815,19 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
||||
"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)
|
||||
@@ -709,7 +842,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
||||
data = await body_of(request)
|
||||
name = text_of(data, "name").strip()
|
||||
scope = str(data.get("scope") or "*")
|
||||
if scope not in VALID_SCOPES:
|
||||
if scope not in runtime.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:
|
||||
@@ -944,6 +1077,59 @@ 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,
|
||||
@@ -993,6 +1179,35 @@ def _memory_path(root: Path, raw: str) -> Path:
|
||||
return target
|
||||
|
||||
|
||||
def _grep_memory(root: Path, query: str, *, limit: int) -> list[dict[str, Any]]:
|
||||
needle = query.casefold()
|
||||
hits: list[dict[str, Any]] = []
|
||||
for path in sorted(root.rglob("*")):
|
||||
if len(hits) >= limit:
|
||||
break
|
||||
if not path.is_file() or any(part.startswith(".") for part in path.parts):
|
||||
continue
|
||||
if path.suffix not in (".md", ".txt", ".json", ".yaml", ".yml"):
|
||||
continue
|
||||
try:
|
||||
if path.stat().st_size > MEMORY_MAX_FILE:
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
rel = str(path.relative_to(root))
|
||||
if needle in rel.casefold():
|
||||
hits.append({"path": rel, "line": 0, "snippet": ""})
|
||||
continue
|
||||
for number, line in enumerate(text.splitlines(), 1):
|
||||
if needle in line.casefold():
|
||||
hits.append(
|
||||
{"path": rel, "line": number, "snippet": line.strip()[:SNIPPET_MAX]}
|
||||
)
|
||||
break
|
||||
return hits
|
||||
|
||||
|
||||
def _tree(root: Path, directory: Path, *, depth: int) -> list[dict[str, Any]]:
|
||||
if depth > MEMORY_MAX_DEPTH:
|
||||
return []
|
||||
|
||||
@@ -10,8 +10,12 @@ 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
|
||||
|
||||
@@ -54,6 +58,10 @@ 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):
|
||||
@@ -64,6 +72,10 @@ 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
|
||||
@@ -76,6 +88,9 @@ 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: ...
|
||||
@@ -95,3 +110,19 @@ 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 ""
|
||||
@@ -0,0 +1,95 @@
|
||||
"""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,14 +48,19 @@ 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; only text and thinking content reach
|
||||
the file (see :mod:`.parser` for how the reverse strip works).
|
||||
Tool-use blocks render to nothing; of the thinking blocks between two
|
||||
texts only the first one reaches the file.
|
||||
"""
|
||||
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,12 +47,17 @@ 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())
|
||||
@@ -61,11 +66,22 @@ class Draft:
|
||||
if status != self.status:
|
||||
self.status = status
|
||||
self._dirty = True
|
||||
self._paused = bool(self.text.strip())
|
||||
|
||||
def append(self, text: str) -> None:
|
||||
if text:
|
||||
"""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:
|
||||
self.text += text
|
||||
self._dirty = True
|
||||
self._paused = False
|
||||
self._dirty = True
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop pushing and wait for the push in flight.
|
||||
@@ -126,6 +142,7 @@ class Draft:
|
||||
message_thread_id=self._thread_id,
|
||||
text=text,
|
||||
parse_mode=parse_mode,
|
||||
can_stop=True,
|
||||
)
|
||||
|
||||
def _render(self) -> tuple[str, str]:
|
||||
|
||||
@@ -21,10 +21,13 @@ from zoneinfo import ZoneInfo
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.exceptions import TelegramAPIError
|
||||
from aiogram.client.session.aiohttp import AiohttpSession
|
||||
from aiogram.client.telegram import TelegramAPIServer
|
||||
from aiogram.exceptions import TelegramAPIError, TelegramNetworkError
|
||||
from aiogram.types import (
|
||||
BotCommand,
|
||||
CallbackQuery,
|
||||
FSInputFile,
|
||||
InlineKeyboardButton,
|
||||
InlineKeyboardMarkup,
|
||||
Message,
|
||||
@@ -33,10 +36,17 @@ 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
|
||||
from beaver_gateway.frontends.telegram.render import chunks, status_label, to_html
|
||||
from beaver_gateway.frontends.telegram.texts import TelegramTexts
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -53,6 +63,8 @@ _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)
|
||||
@@ -95,10 +107,46 @@ 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,
|
||||
@@ -109,12 +157,15 @@ 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()
|
||||
@@ -123,6 +174,8 @@ 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
|
||||
@@ -136,7 +189,9 @@ 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()
|
||||
|
||||
@@ -149,7 +204,14 @@ class TelegramFrontend(Frontend):
|
||||
raise RuntimeError(msg)
|
||||
self._runtime = runtime
|
||||
if self._bot is None:
|
||||
self._bot = Bot(self._token, default=DefaultBotProperties(parse_mode=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._inbox = Inbox(
|
||||
runtime.db, self._bot, handler=self._handle, poll_timeout=self.poll_timeout
|
||||
)
|
||||
@@ -157,6 +219,16 @@ 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(
|
||||
@@ -213,6 +285,44 @@ 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:
|
||||
@@ -302,7 +412,9 @@ class TelegramFrontend(Frontend):
|
||||
self._master_window = self._ext(topic.message_thread_id)
|
||||
return self._master_window
|
||||
|
||||
async def _master(self, text: str | None = None) -> tuple[Conversation, bool]:
|
||||
async def _master(
|
||||
self, text: str | None = None, attachments: list[dict[str, Any]] | 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
|
||||
@@ -324,11 +436,17 @@ 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
|
||||
self,
|
||||
thread_id: int,
|
||||
*,
|
||||
title: str | None,
|
||||
text: str | None,
|
||||
attachments: list[dict[str, Any]] | None = None,
|
||||
) -> Conversation:
|
||||
master, _ = await self._master()
|
||||
return await self.conversations.spawn(
|
||||
@@ -341,6 +459,7 @@ class TelegramFrontend(Frontend):
|
||||
text=text,
|
||||
origin=FRONTEND,
|
||||
binding=(FRONTEND, self._ext(thread_id)),
|
||||
attachments=attachments,
|
||||
)
|
||||
|
||||
async def _handle(self, update: Update) -> None:
|
||||
@@ -348,6 +467,8 @@ 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
|
||||
@@ -382,9 +503,79 @@ class TelegramFrontend(Frontend):
|
||||
)
|
||||
return
|
||||
text = (message.text or message.caption or "").strip()
|
||||
attachment = await self._save_attachment(message)
|
||||
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)
|
||||
if attachment:
|
||||
text = f"{text}\n\n{attachment}".strip()
|
||||
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()
|
||||
if not text:
|
||||
return
|
||||
if message.text and message.text.startswith("/"):
|
||||
@@ -394,14 +585,17 @@ class TelegramFrontend(Frontend):
|
||||
await self._command(command, args.strip(), message, thread_id)
|
||||
return
|
||||
if is_master:
|
||||
conv, consumed = await self._master(text)
|
||||
conv, consumed = await self._master(text, attachments or None)
|
||||
if consumed:
|
||||
return
|
||||
else:
|
||||
conv = await self._live(thread_id)
|
||||
topic = cast("int", thread_id)
|
||||
conv = await self._live(topic)
|
||||
if conv is None:
|
||||
title = self._topic_names.get(thread_id) or self._title_from(text)
|
||||
await self._branch(thread_id, title=title, text=text)
|
||||
title = self._topic_names.get(topic) or self._title_from(text)
|
||||
await self._branch(
|
||||
topic, title=title, text=text, attachments=attachments or None
|
||||
)
|
||||
return
|
||||
pending = self.conversations.pending_question(conv.external_id)
|
||||
if pending is not None and self.conversations.answer(pending[0], text):
|
||||
@@ -409,7 +603,9 @@ class TelegramFrontend(Frontend):
|
||||
pending[0], self.texts.question_typed.format(text=text)
|
||||
)
|
||||
return
|
||||
item = await self.conversations.post(conv, text, origin=FRONTEND)
|
||||
item = await self.conversations.post(
|
||||
conv, text, origin=FRONTEND, attachments=attachments or None
|
||||
)
|
||||
if conv.running_turn or conv.pending_question:
|
||||
await self._react(message, item.id)
|
||||
|
||||
@@ -453,55 +649,25 @@ 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) -> str | None:
|
||||
file_id: str | None = None
|
||||
name: str | None = None
|
||||
kind = ""
|
||||
size: int | None = None
|
||||
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."""
|
||||
texts = self.texts
|
||||
if message.sticker:
|
||||
return texts.attachment_sticker
|
||||
return texts.attachment_sticker, None
|
||||
if message.animation:
|
||||
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"
|
||||
return texts.attachment_animation, None
|
||||
file_id, kind, size, name, media_type = _media(message)
|
||||
if file_id is None or name is None:
|
||||
return None
|
||||
return None, 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
|
||||
return (
|
||||
texts.attachment_too_big.format(
|
||||
kind=kind, name=name, mb=size // 1024 // 1024
|
||||
),
|
||||
None,
|
||||
)
|
||||
now = time.time()
|
||||
folder = self.attachments.root / self.attachments.day(now)
|
||||
@@ -515,12 +681,20 @@ 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)
|
||||
return texts.attachment_failed.format(kind=kind, name=name, error=exc), None
|
||||
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)
|
||||
return texts.attachment_kept.format(kind=kind, path=path, size=shown, days=keep)
|
||||
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,
|
||||
)
|
||||
|
||||
async def _sweep_loop(self, interval: float = 6 * 3600) -> None:
|
||||
while True:
|
||||
@@ -675,7 +849,7 @@ class TelegramFrontend(Frontend):
|
||||
return
|
||||
match kind:
|
||||
case "turn.start":
|
||||
if event.get("origin") == "user":
|
||||
if event.get("origin") in ("user", "task"):
|
||||
await self._open_draft(key, event, target)
|
||||
case "stream":
|
||||
self._feed_draft(key, event)
|
||||
@@ -721,6 +895,14 @@ 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(
|
||||
@@ -848,6 +1030,27 @@ 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:
|
||||
@@ -856,7 +1059,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)
|
||||
ask = self._asks.get(question_id) or await self._restore_ask(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)
|
||||
@@ -896,11 +1099,20 @@ 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):
|
||||
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)
|
||||
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)
|
||||
|
||||
async def _strip_keyboard(self, query: CallbackQuery) -> None:
|
||||
message = query.message if isinstance(query.message, Message) else None
|
||||
@@ -920,6 +1132,7 @@ 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:
|
||||
@@ -935,6 +1148,50 @@ 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")
|
||||
ALLOWED_UPDATES = ("message", "callback_query", "stopped_message_generation")
|
||||
|
||||
|
||||
def _unset(_value: object) -> None:
|
||||
|
||||
@@ -26,6 +26,7 @@ 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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 Mapping
|
||||
from collections.abc import Iterable, Mapping
|
||||
|
||||
from beaver_gateway.storage import Database
|
||||
|
||||
@@ -35,8 +35,8 @@ class TokenStoreError(ValueError):
|
||||
"""Malformed ``BOOTSTRAP_TOKENS`` value or duplicate token."""
|
||||
|
||||
|
||||
VALID_SCOPES: frozenset[str] = frozenset({"*", "messages", "mcp", "admin", "api"})
|
||||
"""The scopes a ``Token.scope`` may hold.
|
||||
BUILTIN_SCOPES: frozenset[str] = frozenset({"*", "messages", "mcp", "admin", "api"})
|
||||
"""The scopes the gateway's own frontends define.
|
||||
|
||||
* ``*`` — wildcard, may use any frontend
|
||||
* ``messages`` — Anthropic Messages frontend only
|
||||
@@ -45,16 +45,24 @@ VALID_SCOPES: frozenset[str] = frozenset({"*", "messages", "mcp", "admin", "api"
|
||||
* ``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:`VALID_SCOPES`);
|
||||
gates which frontend the token may hit (see :data:`BUILTIN_SCOPES`);
|
||||
bootstrap tokens implicitly get ``"*"``.
|
||||
"""
|
||||
|
||||
@@ -67,8 +75,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 one of
|
||||
:data:`VALID_SCOPES`.
|
||||
strict equality check — callers should pass a scope the runtime
|
||||
knows (:data:`BUILTIN_SCOPES` plus every frontend's own).
|
||||
"""
|
||||
return self.scope in ("*", required)
|
||||
|
||||
@@ -325,11 +333,12 @@ class TokenStore:
|
||||
|
||||
|
||||
__all__ = [
|
||||
"VALID_SCOPES",
|
||||
"BUILTIN_SCOPES",
|
||||
"TokenIdentity",
|
||||
"TokenStore",
|
||||
"TokenStoreError",
|
||||
"hash_token",
|
||||
"scopes_with",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -57,6 +57,10 @@ 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."""
|
||||
@@ -144,6 +144,11 @@ 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)
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Wikilink index over a vault: which note links to which, refreshed by mtime."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
__all__ = ["LinkIndex"]
|
||||
|
||||
_WIKILINK = re.compile(r"\[\[([^\]|#\n]+)(?:#[^\]|\n]*)?(?:\|[^\]\n]*)?\]\]")
|
||||
_MD = ".md"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _Note:
|
||||
mtime: float
|
||||
links: set[str] = field(default_factory=set)
|
||||
|
||||
|
||||
class LinkIndex:
|
||||
"""Forward and backward links between the vault's markdown notes."""
|
||||
|
||||
def __init__(self, root: Path, *, max_age: float = 20.0) -> None:
|
||||
self.root = root.resolve()
|
||||
self.max_age = max_age
|
||||
self._notes: dict[str, _Note] = {}
|
||||
self._raw: dict[str, set[str]] = {}
|
||||
self._scanned_at = 0.0
|
||||
|
||||
def refresh(self, *, force: bool = False) -> None:
|
||||
if not force and time.monotonic() - self._scanned_at < self.max_age:
|
||||
return
|
||||
seen: set[str] = set()
|
||||
for dirpath, dirnames, filenames in os.walk(self.root):
|
||||
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
|
||||
for name in filenames:
|
||||
if not name.endswith(_MD):
|
||||
continue
|
||||
path = Path(dirpath) / name
|
||||
rel = str(path.relative_to(self.root))
|
||||
seen.add(rel)
|
||||
try:
|
||||
mtime = path.stat().st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
note = self._notes.get(rel)
|
||||
if note is None or note.mtime != mtime:
|
||||
self._notes[rel] = _Note(mtime=mtime)
|
||||
self._raw[rel] = self._targets(path)
|
||||
for rel in list(self._notes):
|
||||
if rel not in seen:
|
||||
self._notes.pop(rel, None)
|
||||
self._raw.pop(rel, None)
|
||||
self._resolve()
|
||||
self._scanned_at = time.monotonic()
|
||||
|
||||
@staticmethod
|
||||
def _targets(path: Path) -> set[str]:
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return set()
|
||||
return {m.group(1).strip() for m in _WIKILINK.finditer(text)}
|
||||
|
||||
def _resolve(self) -> None:
|
||||
by_stem: dict[str, str] = {}
|
||||
for rel in sorted(self._notes):
|
||||
by_stem.setdefault(Path(rel).stem.casefold(), rel)
|
||||
by_path = {rel.casefold(): rel for rel in self._notes}
|
||||
for rel, targets in self._raw.items():
|
||||
links: set[str] = set()
|
||||
for target in targets:
|
||||
candidate = target if target.endswith(_MD) else f"{target}{_MD}"
|
||||
found = by_path.get(candidate.casefold()) or by_stem.get(
|
||||
Path(target).stem.casefold()
|
||||
)
|
||||
if found and found != rel:
|
||||
links.add(found)
|
||||
self._notes[rel].links = links
|
||||
|
||||
def resolve(self, raw: str) -> str:
|
||||
"""A path as a tool named it (absolute or relative) as an index key."""
|
||||
path = Path(raw)
|
||||
if path.is_absolute():
|
||||
try:
|
||||
return str(path.resolve().relative_to(self.root))
|
||||
except (ValueError, OSError):
|
||||
return raw
|
||||
return raw
|
||||
|
||||
def neighbours(self, paths: list[str], *, limit: int = 80) -> dict[str, Any]:
|
||||
"""Nodes for ``paths`` plus every note one link away, and their edges."""
|
||||
self.refresh()
|
||||
touched = [self.resolve(p) for p in paths]
|
||||
backlinks: dict[str, set[str]] = {}
|
||||
for rel, note in self._notes.items():
|
||||
for target in note.links:
|
||||
backlinks.setdefault(target, set()).add(rel)
|
||||
nodes: dict[str, dict[str, Any]] = {}
|
||||
for rel in touched:
|
||||
nodes[rel] = {
|
||||
"path": rel,
|
||||
"title": Path(rel).stem,
|
||||
"touched": True,
|
||||
"exists": rel in self._notes,
|
||||
}
|
||||
for rel in touched:
|
||||
note = self._notes.get(rel)
|
||||
around = (note.links if note else set()) | backlinks.get(rel, set())
|
||||
for other in sorted(around):
|
||||
if len(nodes) >= limit:
|
||||
break
|
||||
nodes.setdefault(
|
||||
other,
|
||||
{
|
||||
"path": other,
|
||||
"title": Path(other).stem,
|
||||
"touched": False,
|
||||
"exists": True,
|
||||
},
|
||||
)
|
||||
edges: list[dict[str, str]] = []
|
||||
for rel in nodes:
|
||||
note = self._notes.get(rel)
|
||||
if note is None:
|
||||
continue
|
||||
edges.extend(
|
||||
{"from": rel, "to": target}
|
||||
for target in sorted(note.links)
|
||||
if target in nodes
|
||||
)
|
||||
return {"nodes": list(nodes.values()), "edges": edges}
|
||||
+113
-4
@@ -29,7 +29,7 @@ from beaver_gateway.frontends.api import ApiFrontend
|
||||
from beaver_gateway.frontends.api.frontend import build_app as build_api
|
||||
from beaver_gateway.frontends.base import Frontend, GatewayRuntime
|
||||
from beaver_gateway.frontends.root import build_root_app
|
||||
from beaver_gateway.storage.models import RateLimit, Usage
|
||||
from beaver_gateway.storage.models import RateLimit, TranscriptEntry, Usage
|
||||
|
||||
TOKEN = "tok"
|
||||
HEADERS = {"Authorization": f"Bearer {TOKEN}"}
|
||||
@@ -247,6 +247,7 @@ 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(
|
||||
@@ -264,7 +265,20 @@ 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))
|
||||
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},
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
await seed_usage(
|
||||
world,
|
||||
@@ -280,12 +294,19 @@ 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.9
|
||||
assert windows["five_hour"]["utilization"] == 0.72
|
||||
assert windows["five_hour"]["reported_at"] is not None
|
||||
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 [w["window"] for w in out["windows"]] == ["five_hour", "seven_day"]
|
||||
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 len(out["history"]) == 3
|
||||
|
||||
|
||||
@@ -525,3 +546,91 @@ async def test_bind_without_a_window_materializes_one(world: World) -> None:
|
||||
headers=HEADERS,
|
||||
)
|
||||
assert unknown.status_code == 400
|
||||
|
||||
|
||||
async def test_search_finds_conversations_and_memory_files(world: World) -> None:
|
||||
root = Path(tempfile.mkdtemp(prefix="beaver-mem-"))
|
||||
(root / "notes").mkdir()
|
||||
(root / "notes" / "studio.md").write_text("# studio\nthe kiln is warm\n")
|
||||
api = Api(world, memory_root=root)
|
||||
conv = await world.conversations.create(kind="master", agent="a", origin="test")
|
||||
await world.conversations.set_title(conv, "kiln schedule")
|
||||
other = await world.conversations.create(kind="deep", agent="d", origin="test")
|
||||
await world.conversations.post(other, "go")
|
||||
await world.settle(other, 1)
|
||||
other = await world.conversations.get(other.external_id)
|
||||
assert other is not None and other.session_id is not None
|
||||
async with world.db.session() as db:
|
||||
db.add(
|
||||
TranscriptEntry(
|
||||
project_key="p",
|
||||
session_id=other.session_id,
|
||||
seq=0,
|
||||
entry={"type": "assistant", "message": {"content": "warm kiln"}},
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
found = await api.get("/search", {"q": "kiln"})
|
||||
assert {c["id"] for c in found["conversations"]} == {
|
||||
conv.external_id,
|
||||
other.external_id,
|
||||
}
|
||||
assert found["files"] == [
|
||||
{"path": "notes/studio.md", "line": 2, "snippet": "the kiln is warm"}
|
||||
]
|
||||
short = await api.http.get("/search", params={"q": "k"}, headers=HEADERS)
|
||||
assert short.status_code == 400
|
||||
|
||||
|
||||
async def test_context_endpoint_reports_prompt_and_files(world: World) -> None:
|
||||
api = Api(world)
|
||||
conv = await world.conversations.create(kind="master", agent="a", origin="test")
|
||||
await world.conversations.post(conv, "go")
|
||||
await world.settle(conv, 1)
|
||||
conv = await world.conversations.get(conv.external_id)
|
||||
assert conv is not None and conv.session_id is not None
|
||||
await world.store.append(
|
||||
world.key(conv.session_id),
|
||||
build_entries(
|
||||
[
|
||||
{"role": "user", "content": "go"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "t1",
|
||||
"name": "Read",
|
||||
"input": {"file_path": str(world.root / "note.md")},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "tool_result", "tool_use_id": "t1", "content": "x"}
|
||||
],
|
||||
},
|
||||
],
|
||||
session_id=conv.session_id,
|
||||
cwd=str(world.root),
|
||||
model="m",
|
||||
),
|
||||
)
|
||||
ctx = await api.get(f"/conversations/{conv.external_id}/context", {"prompt": 1})
|
||||
assert ctx["agent"]["name"] == "a"
|
||||
assert ctx["prompt"]["granules"][0]["bytes"] == len("hi")
|
||||
assert ctx["prompt_text"] == "hi"
|
||||
assert ctx["files"] == [
|
||||
{
|
||||
"path": "note.md",
|
||||
"reads": 1,
|
||||
"writes": 0,
|
||||
"other": 0,
|
||||
"last_at": ctx["files"][0]["last_at"],
|
||||
}
|
||||
]
|
||||
assert ctx["tool_counts"] == {"Read": 1}
|
||||
assert ctx["turns"] == 1
|
||||
graph = await api.http.get("/vault/graph", headers=HEADERS)
|
||||
assert graph.status_code == 404
|
||||
@@ -86,6 +86,7 @@ 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:
|
||||
@@ -93,6 +94,17 @@ 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
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
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}
|
||||
]
|
||||
+109
-1
@@ -8,6 +8,7 @@ from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
from claude_agent_sdk import (
|
||||
TaskNotificationMessage,
|
||||
AssistantMessage,
|
||||
InMemorySessionStore,
|
||||
ResultMessage,
|
||||
@@ -88,16 +89,46 @@ 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: str) -> None:
|
||||
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"
|
||||
)
|
||||
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]
|
||||
@@ -850,3 +881,80 @@ 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"]
|
||||
@@ -219,3 +219,25 @@ 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)
|
||||
@@ -39,6 +39,23 @@ 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")
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
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"
|
||||
+276
-1
@@ -1,8 +1,11 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import contextlib
|
||||
import functools
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramNetworkError
|
||||
@@ -12,8 +15,11 @@ 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,
|
||||
@@ -33,6 +39,8 @@ 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] = []
|
||||
@@ -129,6 +137,21 @@ 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)
|
||||
@@ -717,6 +740,22 @@ 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"
|
||||
@@ -919,3 +958,239 @@ 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
@@ -5,6 +5,7 @@
|
||||
"": {
|
||||
"name": "ui",
|
||||
"dependencies": {
|
||||
"d3-force": "^3.0.0",
|
||||
"dompurify": "^3.4.14",
|
||||
"marked": "^18.0.11",
|
||||
},
|
||||
@@ -19,6 +20,7 @@
|
||||
"@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",
|
||||
@@ -186,6 +188,8 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -226,6 +230,14 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"d3-force": "^3.0.0",
|
||||
"dompurify": "^3.4.14",
|
||||
"marked": "^18.0.11"
|
||||
},
|
||||
@@ -28,6 +29,7 @@
|
||||
"@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",
|
||||
|
||||
+15
-12
@@ -10,18 +10,21 @@
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<!--
|
||||
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).
|
||||
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.
|
||||
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review,
|
||||
the verdict, and DESIGN.md
|
||||
-->
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
AgentsResponse,
|
||||
AuditPage,
|
||||
BusEvent,
|
||||
ContextResponse,
|
||||
ConversationInfo,
|
||||
ConversationSummary,
|
||||
EntriesPage,
|
||||
@@ -11,10 +12,13 @@ import type {
|
||||
LimitsResponse,
|
||||
MemoryFile,
|
||||
MemoryTree,
|
||||
ScopeRow,
|
||||
SearchResponse,
|
||||
SessionsResponse,
|
||||
TokenRow,
|
||||
UsageGroup,
|
||||
UsageResponse,
|
||||
VaultGraph,
|
||||
} from "./types";
|
||||
|
||||
const TRAILING_SLASHES = /\/+$/;
|
||||
@@ -145,7 +149,8 @@ export class ApiClient {
|
||||
async stream(
|
||||
path: string,
|
||||
onEvent: (event: BusEvent) => void,
|
||||
signal: AbortSignal
|
||||
signal: AbortSignal,
|
||||
onActivity?: () => void
|
||||
): Promise<void> {
|
||||
const response = await fetch(this.url(path), {
|
||||
headers: this.headers({ Accept: "text/event-stream" }),
|
||||
@@ -159,7 +164,7 @@ export class ApiClient {
|
||||
payload
|
||||
);
|
||||
}
|
||||
await readSse(response, onEvent, signal);
|
||||
await readSse(response, onEvent, signal, onActivity);
|
||||
}
|
||||
|
||||
agents(): Promise<AgentsResponse> {
|
||||
@@ -182,6 +187,23 @@ 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 }
|
||||
@@ -283,6 +305,10 @@ 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");
|
||||
}
|
||||
@@ -326,6 +352,10 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
scopes(): Promise<{ scopes: ScopeRow[] }> {
|
||||
return this.get("/api/scopes");
|
||||
}
|
||||
|
||||
createToken(
|
||||
name: string,
|
||||
scope: string
|
||||
|
||||
@@ -6,6 +6,11 @@ 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> {
|
||||
@@ -41,9 +46,13 @@ 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,
|
||||
@@ -65,6 +74,7 @@ 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);
|
||||
@@ -72,31 +82,108 @@ 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(signal, () => {
|
||||
const outcome = await this.connectOnce(inner.signal, () => {
|
||||
delay = BACKOFF_MIN_MS;
|
||||
});
|
||||
signal.removeEventListener("abort", relay);
|
||||
this.inner = null;
|
||||
if (signal.aborted || outcome === "fatal") {
|
||||
return;
|
||||
}
|
||||
await sleep(delay + Math.random() * JITTER_MS, signal);
|
||||
delay = Math.min(delay * 2, BACKOFF_MAX_MS);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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"> {
|
||||
): Promise<"retry" | "fatal" | "now"> {
|
||||
try {
|
||||
const client = this.client();
|
||||
if (!client) {
|
||||
@@ -104,8 +191,9 @@ export class LiveStream {
|
||||
}
|
||||
await this.options.prepare?.();
|
||||
if (signal.aborted) {
|
||||
return "fatal";
|
||||
return this.controller ? "now" : "fatal";
|
||||
}
|
||||
this.seen = Date.now();
|
||||
await client.stream(
|
||||
this.path,
|
||||
(event) => {
|
||||
@@ -117,19 +205,25 @@ export class LiveStream {
|
||||
}
|
||||
this.options.onEvent(event);
|
||||
},
|
||||
signal
|
||||
signal,
|
||||
() => {
|
||||
this.seen = Date.now();
|
||||
}
|
||||
);
|
||||
this.state = "retrying";
|
||||
this.detail = "stream closed";
|
||||
return "retry";
|
||||
} catch (error) {
|
||||
if (signal.aborted) {
|
||||
return "fatal";
|
||||
// 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";
|
||||
}
|
||||
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";
|
||||
|
||||
@@ -38,7 +38,10 @@ function drain(buffer: string, onEvent: (event: BusEvent) => void): string {
|
||||
export async function readSse(
|
||||
response: Response,
|
||||
onEvent: (event: BusEvent) => void,
|
||||
signal?: AbortSignal
|
||||
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
|
||||
): Promise<void> {
|
||||
const { body } = response;
|
||||
if (!body) {
|
||||
@@ -58,6 +61,7 @@ export async function readSse(
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
onActivity?.();
|
||||
buffer = drain(buffer + decoder.decode(value, { stream: true }), onEvent);
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -54,6 +54,7 @@ export interface QueueItem {
|
||||
|
||||
export interface ConversationSummary {
|
||||
agent: string;
|
||||
context_tokens?: number;
|
||||
created_at: string | null;
|
||||
flags: Record<string, unknown>;
|
||||
id: string;
|
||||
@@ -62,6 +63,7 @@ export interface ConversationSummary {
|
||||
last_item?: QueueItem | null;
|
||||
last_user_activity_at: string | null;
|
||||
origin: string;
|
||||
parent?: string | null;
|
||||
parent_row: number | null;
|
||||
pending_question: boolean;
|
||||
running_turn: string | null;
|
||||
@@ -104,6 +106,8 @@ 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 {
|
||||
@@ -128,6 +132,7 @@ export interface FrontendInfo {
|
||||
kinds: Kind[];
|
||||
name: string;
|
||||
path: string | null;
|
||||
scope: string | null;
|
||||
type: string;
|
||||
url: string | null;
|
||||
}
|
||||
@@ -209,6 +214,10 @@ 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 {
|
||||
@@ -237,6 +246,11 @@ export interface MemoryFile {
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface ScopeRow {
|
||||
frontend: string | null;
|
||||
scope: string;
|
||||
}
|
||||
|
||||
export interface TokenRow {
|
||||
created_at: string;
|
||||
id: number;
|
||||
@@ -310,3 +324,76 @@ export interface TurnUsage {
|
||||
input?: number;
|
||||
output?: number;
|
||||
}
|
||||
|
||||
export interface SearchFile {
|
||||
line: number;
|
||||
path: string;
|
||||
snippet: string;
|
||||
}
|
||||
|
||||
export interface SearchResponse {
|
||||
conversations: ConversationSummary[];
|
||||
files: SearchFile[];
|
||||
query: string;
|
||||
}
|
||||
|
||||
export interface Granule {
|
||||
bytes: number;
|
||||
path: string | null;
|
||||
tag: string | null;
|
||||
tokens_est: number;
|
||||
}
|
||||
|
||||
export interface SkillInfo {
|
||||
description: string;
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface SkillSetInfo {
|
||||
path: string;
|
||||
set: string;
|
||||
skills: SkillInfo[];
|
||||
}
|
||||
|
||||
export interface TouchedFile {
|
||||
last_at: string;
|
||||
other: number;
|
||||
path: string;
|
||||
reads: number;
|
||||
writes: number;
|
||||
}
|
||||
|
||||
export interface ContextResponse {
|
||||
agent: { effort: string | null; model: string; name: string };
|
||||
context_tokens: number;
|
||||
files: TouchedFile[];
|
||||
history_tokens_est: number;
|
||||
id: string;
|
||||
kind: Kind;
|
||||
prompt: { bytes: number; granules: Granule[]; tokens_est: number };
|
||||
prompt_text?: string;
|
||||
skills: SkillSetInfo[];
|
||||
skills_tokens_est: number;
|
||||
tool_counts: Record<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;
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
<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>
|
||||
@@ -1,79 +1,42 @@
|
||||
<script lang="ts">
|
||||
import MoreHorizontalIcon from "@lucide/svelte/icons/more-horizontal";
|
||||
import { goto } from "$app/navigation";
|
||||
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 { 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 * 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 { isSectionActive, SECTIONS, SYSTEM } from "$lib/nav";
|
||||
import { cn } from "$lib/utils";
|
||||
|
||||
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`);
|
||||
}
|
||||
const ICONS: Record<string, Component<{ class?: string }>> = {
|
||||
"/": ActivityIcon,
|
||||
"/conversations": MessagesSquareIcon,
|
||||
"/memory": BrainIcon,
|
||||
"/system": SlidersHorizontalIcon,
|
||||
"/usage": GaugeIcon,
|
||||
};
|
||||
const items = [...SECTIONS, SYSTEM];
|
||||
</script>
|
||||
|
||||
<nav
|
||||
aria-label="Sections"
|
||||
class="flex shrink-0 items-stretch border-t bg-sidebar pb-[env(safe-area-inset-bottom)] sm:hidden"
|
||||
class="flex shrink-0 items-stretch border-t bg-rack/90 pb-[env(safe-area-inset-bottom)] backdrop-blur-md sm:hidden"
|
||||
>
|
||||
{#each primary as item (item.href)}
|
||||
{@const active = isActive(page.url.pathname, base, item.href)}
|
||||
{#each items as item (item.href)}
|
||||
{@const active = isSectionActive(page.url.pathname, base, item.href)}
|
||||
{@const Icon = ICONS[item.href]}
|
||||
<a
|
||||
aria-current={active ? "page" : undefined}
|
||||
class={cn(
|
||||
"flex h-14 flex-1 flex-col items-center justify-center gap-1 text-[11px]",
|
||||
active ? "text-link" : "text-muted-foreground"
|
||||
"flex h-13 flex-1 flex-col items-center justify-center gap-0.5 text-[10px] transition-colors",
|
||||
active ? "text-primary" : "text-muted-foreground"
|
||||
)}
|
||||
href="{base}{item.href}"
|
||||
>
|
||||
<item.icon class="size-5" />
|
||||
{item.label}
|
||||
<Icon class="size-5" />
|
||||
{item.label.split(" ")[0]}
|
||||
</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>
|
||||
@@ -1,323 +0,0 @@
|
||||
<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>
|
||||
@@ -117,7 +117,17 @@
|
||||
};
|
||||
default:
|
||||
return {
|
||||
endpoints: base ? [{ hint: "", label: "base", url: base }] : [],
|
||||
endpoints: base
|
||||
? [
|
||||
{
|
||||
hint: fe.scope
|
||||
? `POST the frontend's own payload; bearer with scope ${fe.scope}`
|
||||
: "",
|
||||
label: "base",
|
||||
url: base,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
frontend: fe,
|
||||
note: "",
|
||||
};
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import LiveDot from "$lib/components/live-dot.svelte";
|
||||
import { gateway } from "$lib/gateway.svelte";
|
||||
|
||||
let {
|
||||
title,
|
||||
@@ -17,7 +15,7 @@
|
||||
</script>
|
||||
|
||||
<header
|
||||
class="flex min-h-12 flex-wrap items-center gap-x-4 gap-y-2 border-b px-4 py-2 sm:px-6"
|
||||
class="flex min-h-12 flex-wrap items-center gap-x-4 gap-y-2 px-4 pt-4 pb-1 sm:px-6"
|
||||
>
|
||||
<div class="flex min-w-0 items-baseline gap-2">
|
||||
<h1 class="truncate font-semibold text-lg tracking-tight">{title}</h1>
|
||||
@@ -30,15 +28,9 @@
|
||||
{@render children()}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
{#if actions}
|
||||
{#if actions}
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
{@render actions()}
|
||||
{/if}
|
||||
<LiveDot
|
||||
class="sm:hidden"
|
||||
detail={gateway.live.detail}
|
||||
label={false}
|
||||
state={gateway.live.state}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
+8
-10
@@ -123,19 +123,17 @@ export function fmtRelative(
|
||||
if (!date) {
|
||||
return "–";
|
||||
}
|
||||
const diff = now - date.getTime();
|
||||
const abs = Math.abs(diff);
|
||||
const suffix = diff >= 0 ? "ago" : "from now";
|
||||
if (abs < MINUTE_MS) {
|
||||
return diff >= 0 ? "just now" : "in <1m";
|
||||
const diff = Math.max(0, now - date.getTime());
|
||||
if (diff < MINUTE_MS) {
|
||||
return "just now";
|
||||
}
|
||||
if (abs < HOUR_MS) {
|
||||
return `${Math.round(abs / MINUTE_MS)}m ${suffix}`;
|
||||
if (diff < HOUR_MS) {
|
||||
return `${Math.round(diff / MINUTE_MS)}m ago`;
|
||||
}
|
||||
if (abs < DAY_MS) {
|
||||
return `${Math.round(abs / HOUR_MS)}h ${suffix}`;
|
||||
if (diff < DAY_MS) {
|
||||
return `${Math.round(diff / HOUR_MS)}h ago`;
|
||||
}
|
||||
return `${Math.round(abs / DAY_MS)}d ${suffix}`;
|
||||
return `${Math.round(diff / DAY_MS)}d ago`;
|
||||
}
|
||||
|
||||
export function fmtCountdown(iso: string | null | undefined, now = Date.now()) {
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import type { BusEvent, LimitsResponse, LimitWindow } from "./api/types";
|
||||
import type {
|
||||
BusEvent,
|
||||
JobsResponse,
|
||||
LimitsResponse,
|
||||
LimitWindow,
|
||||
} from "./api/types";
|
||||
import { panelCache } from "./panel/cache";
|
||||
import { browserHost } from "./panel/host";
|
||||
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
|
||||
@@ -11,9 +20,20 @@ const QUIET = new Set(["stream", "hello"]);
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
override async load(): Promise<void> {
|
||||
@@ -28,6 +48,7 @@ class Gateway extends ConversationIndex {
|
||||
if (limits) {
|
||||
this.limits = limits;
|
||||
}
|
||||
this.refreshJobs().catch(() => undefined);
|
||||
}
|
||||
|
||||
async refreshLimits(): Promise<void> {
|
||||
@@ -48,6 +69,9 @@ class Gateway extends ConversationIndex {
|
||||
this.applyLimit(event);
|
||||
return;
|
||||
}
|
||||
if (event.type.startsWith("job.") || event.type.startsWith("schedule.")) {
|
||||
this.refreshJobs().catch(() => undefined);
|
||||
}
|
||||
super.apply(event);
|
||||
}
|
||||
|
||||
|
||||
+22
-1
@@ -6,6 +6,27 @@ export const LIMIT_LABELS: Record<string, string> = {
|
||||
seven_day_sonnet: "7-day · Sonnet",
|
||||
};
|
||||
|
||||
const SEVEN_DAY = "seven_day_";
|
||||
|
||||
export function limitLabel(window: string): string {
|
||||
return LIMIT_LABELS[window] ?? window;
|
||||
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("_", " ");
|
||||
}
|
||||
+35
-25
@@ -1,35 +1,45 @@
|
||||
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;
|
||||
icon: Component<{ class?: string }>;
|
||||
key: string;
|
||||
label: string;
|
||||
mobile: boolean;
|
||||
}
|
||||
|
||||
export const NAV: NavItem[] = [
|
||||
{ href: "/", icon: ActivityIcon, label: "Now", mobile: true },
|
||||
{
|
||||
href: "/conversations",
|
||||
icon: MessagesSquareIcon,
|
||||
label: "Conversations",
|
||||
mobile: true,
|
||||
},
|
||||
{ href: "/usage", icon: GaugeIcon, label: "Usage", mobile: true },
|
||||
{ href: "/memory", icon: BrainIcon, label: "Memory", mobile: true },
|
||||
{ href: "/tokens", icon: KeyRoundIcon, label: "Tokens", mobile: false },
|
||||
{ href: "/jobs", icon: ClockIcon, label: "Jobs", mobile: false },
|
||||
{ href: "/audit", icon: ScrollTextIcon, label: "Audit", mobile: false },
|
||||
export const SECTIONS: NavItem[] = [
|
||||
{ href: "/", key: "1", label: "Now" },
|
||||
{ href: "/conversations", key: "2", label: "Conversations" },
|
||||
{ href: "/memory", key: "3", label: "Memory" },
|
||||
{ href: "/usage", key: "4", label: "Usage" },
|
||||
];
|
||||
|
||||
export function isActive(pathname: string, base: string, href: string) {
|
||||
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) {
|
||||
const path = pathname.startsWith(base)
|
||||
? pathname.slice(base.length)
|
||||
: pathname;
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
onMount(() => {
|
||||
const timer = setInterval(() => {
|
||||
if (model.running) {
|
||||
if (model.live.length > 0) {
|
||||
now = Date.now();
|
||||
}
|
||||
}, TICK_MS);
|
||||
|
||||
@@ -27,6 +27,9 @@ 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[];
|
||||
@@ -51,6 +54,20 @@ 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";
|
||||
@@ -65,6 +82,9 @@ 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),
|
||||
@@ -89,6 +109,12 @@ 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;
|
||||
@@ -151,6 +177,42 @@ 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 {
|
||||
@@ -158,7 +220,7 @@ export class ActivityModel {
|
||||
if (!(turnId && raw && typeof raw === "object")) {
|
||||
return;
|
||||
}
|
||||
const turn = this.ensureTurn(turnId, ts, null);
|
||||
const turn = this.turnFor(turnId, ts, parent);
|
||||
const sdk = raw as Record<string, unknown>;
|
||||
if (sdk.type === "content_block_start") {
|
||||
const block = sdk.content_block as Record<string, unknown> | undefined;
|
||||
@@ -172,7 +234,8 @@ 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 += delta.text;
|
||||
turn.text = joinText(turn.text, delta.text, turn.paused);
|
||||
turn.paused = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,7 +244,10 @@ export class ActivityModel {
|
||||
if (!(turnId && typeof event.tool_use_id === "string")) {
|
||||
return;
|
||||
}
|
||||
const turn = this.ensureTurn(turnId, ts, null);
|
||||
const turn = this.turnFor(turnId, ts, parent);
|
||||
if (parent === null) {
|
||||
turn.paused = true;
|
||||
}
|
||||
const node = this.ensureNode(
|
||||
turn,
|
||||
event.tool_use_id,
|
||||
@@ -196,7 +262,7 @@ export class ActivityModel {
|
||||
if (!(turnId && typeof event.tool_use_id === "string")) {
|
||||
return;
|
||||
}
|
||||
const turn = this.ensureTurn(turnId, ts, null);
|
||||
const turn = this.turnFor(turnId, ts, parent);
|
||||
const node = this.ensureNode(turn, event.tool_use_id, "?", parent, ts);
|
||||
node.status = event.is_error ? "error" : "done";
|
||||
node.endedAt = ts;
|
||||
@@ -266,11 +332,13 @@ 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") {
|
||||
if (node.status === "running" && node.parent === null) {
|
||||
node.status = "aborted";
|
||||
node.endedAt = ts;
|
||||
}
|
||||
@@ -291,6 +359,7 @@ export class ActivityModel {
|
||||
itemOrigin: null,
|
||||
nodes: {},
|
||||
origin: origin ?? "?",
|
||||
paused: false,
|
||||
resultSubtype: null,
|
||||
roots: [],
|
||||
says: [],
|
||||
@@ -394,3 +463,11 @@ 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",
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
// A small keyed cache behind the panel: the conversation index and the
|
||||
// last threads land here so the next open paints before the network
|
||||
// answers. localStorage when it exists, memory otherwise.
|
||||
export interface PanelCache {
|
||||
get: <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}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { tick } from "svelte";
|
||||
import { tick, untrack } 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 { Skeleton } from "$lib/components/ui/skeleton";
|
||||
import { Switch } from "$lib/components/ui/switch";
|
||||
import { clip } from "$lib/format";
|
||||
import { clip, fmtDateTime, fmtTime } from "$lib/format";
|
||||
import { cn } from "$lib/utils";
|
||||
import type { ActivityModel } from "./activity.svelte";
|
||||
import { summarizeInput, toolLabel } from "./activity.svelte";
|
||||
import { isLive, summarizeInput, toolLabel } from "./activity.svelte";
|
||||
import { cacheableHistory, historyKey } from "./history-cache";
|
||||
import { usePanelHost } from "./host";
|
||||
import Markdown from "./markdown.svelte";
|
||||
import QuestionCard from "./question-card.svelte";
|
||||
import TurnCard from "./turn-card.svelte";
|
||||
@@ -27,6 +27,10 @@
|
||||
} = $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;
|
||||
|
||||
@@ -36,12 +40,15 @@
|
||||
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) => turn.status === "running" || turn.startedAt > loadedAt)
|
||||
.filter((turn) => isLive(turn) || turn.startedAt > loadedAt)
|
||||
.reverse()
|
||||
);
|
||||
const tailSize = $derived(
|
||||
@@ -53,34 +60,69 @@
|
||||
|
||||
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 {
|
||||
if (!scroller) {
|
||||
return true;
|
||||
return !scroller || Math.abs(scroller.scrollTop) < NEAR_BOTTOM_PX;
|
||||
}
|
||||
|
||||
function toBottom() {
|
||||
if (scroller) {
|
||||
scroller.scrollTop = 0;
|
||||
}
|
||||
const left =
|
||||
scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight;
|
||||
return left < NEAR_BOTTOM_PX;
|
||||
}
|
||||
|
||||
async function scrollToBottom() {
|
||||
await tick();
|
||||
if (scroller) {
|
||||
scroller.scrollTop = scroller.scrollHeight;
|
||||
}
|
||||
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, "") : "";
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (refreshKey >= 0) {
|
||||
pinned = true;
|
||||
load();
|
||||
untrack(() => load());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -108,13 +150,45 @@
|
||||
|
||||
$effect(() => {
|
||||
const timer = setInterval(() => {
|
||||
if (model.running) {
|
||||
if (model.live.length > 0) {
|
||||
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" }]
|
||||
@@ -140,58 +214,114 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="min-h-0 flex-1 overflow-y-auto px-3 py-3 @md:px-6"
|
||||
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"
|
||||
onscroll={() => {
|
||||
pinned = nearBottom();
|
||||
if (Date.now() > settleUntil) {
|
||||
pinned = nearBottom();
|
||||
}
|
||||
}}
|
||||
bind:this={scroller}
|
||||
>
|
||||
<div class="flex flex-col gap-3" bind:this={body}>
|
||||
<span
|
||||
class="flex items-center gap-2 self-end text-muted-foreground text-xs"
|
||||
<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"
|
||||
>
|
||||
<Switch aria-label="Show tool results" bind:checked={showResults} />
|
||||
show tool results
|
||||
</span>
|
||||
{showResults ? "hide tool results" : "show tool results"}
|
||||
</button>
|
||||
{#if failure}
|
||||
<ErrorNote message={failure} retry={load} />
|
||||
{:else if messages === null}
|
||||
<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>
|
||||
<p class="py-6 text-center text-muted-foreground text-xs">
|
||||
Loading the thread…
|
||||
</p>
|
||||
{: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 (index)}
|
||||
{#each messages as message, index (keyOf(message, index, messages.length))}
|
||||
{@const parts = blocks(message)}
|
||||
{@const isResultOnly = parts.every((b) => b.type === "tool_result")}
|
||||
{#if !(isResultOnly && !showResults)}
|
||||
{@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)}
|
||||
<div
|
||||
class={cn(
|
||||
"flex flex-col gap-1.5",
|
||||
"group flex min-w-0 max-w-full 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(
|
||||
"max-w-[75ch] rounded-lg px-3 py-2",
|
||||
message.role === "user" ? "bg-primary/10" : "bg-muted/50"
|
||||
)}
|
||||
"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}
|
||||
text={block.text}
|
||||
/>
|
||||
{:else if block.type === "tool_use"}
|
||||
<p class="flex items-baseline gap-2 px-1 text-xs">
|
||||
<span class="font-medium"
|
||||
<p
|
||||
class="flex min-w-0 max-w-full items-baseline gap-2 px-1 text-xs"
|
||||
>
|
||||
<span class="shrink-0 font-medium"
|
||||
>{toolLabel(block.name ?? "?")}</span
|
||||
>
|
||||
<span class="truncate text-muted-foreground">
|
||||
<span class="min-w-0 truncate text-muted-foreground">
|
||||
{clip(summarizeInput(block.name ?? "", block.input), 160)}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<script lang="ts">
|
||||
import SendHorizontalIcon from "@lucide/svelte/icons/send-horizontal";
|
||||
import ArrowUpIcon from "@lucide/svelte/icons/arrow-up";
|
||||
import { toast } from "svelte-sonner";
|
||||
import type { ApiClient } from "$lib/api/client";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Select from "$lib/components/ui/select";
|
||||
import { cn } from "$lib/utils";
|
||||
|
||||
let {
|
||||
client,
|
||||
@@ -17,28 +16,31 @@
|
||||
|
||||
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: "as the user, mirrored to Telegram",
|
||||
label: "Message",
|
||||
value: "message",
|
||||
},
|
||||
{
|
||||
hint: "system note, rides with the next turn",
|
||||
hint: "a system note for the next turn",
|
||||
label: "Inject",
|
||||
value: "inject",
|
||||
},
|
||||
{
|
||||
hint: "interrupts the running turn",
|
||||
label: "Urgent inject",
|
||||
value: "urgent",
|
||||
},
|
||||
{ hint: "interrupts the running turn", label: "Urgent", 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) {
|
||||
@@ -56,6 +58,7 @@
|
||||
);
|
||||
}
|
||||
text = "";
|
||||
queueMicrotask(grow);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
@@ -72,57 +75,60 @@
|
||||
</script>
|
||||
|
||||
<form
|
||||
class="flex flex-col gap-2 border-t bg-background px-3 py-2"
|
||||
class="flex flex-col gap-1.5 px-3 pt-2 pb-2 @md:px-6"
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
send();
|
||||
}}
|
||||
>
|
||||
<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"
|
||||
<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"
|
||||
disabled={disabled || busy || !text.trim()}
|
||||
size="sm"
|
||||
type="submit"
|
||||
>
|
||||
<SendHorizontalIcon class="size-4" />
|
||||
Send
|
||||
<kbd class="hidden text-[10px] opacity-70 @md:inline">⌘↩</kbd>
|
||||
</Button>
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
@@ -0,0 +1,395 @@
|
||||
<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"
|
||||
>
|
||||
<{g.tag}>
|
||||
</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>
|
||||
@@ -1,15 +1,16 @@
|
||||
<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 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 { 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 BranchDialog from "./branch-dialog.svelte";
|
||||
import ConversationMenu from "./conversation-menu.svelte";
|
||||
import QueueList from "./queue-list.svelte";
|
||||
|
||||
let {
|
||||
client,
|
||||
@@ -20,63 +21,119 @@
|
||||
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();
|
||||
|
||||
let branchOpen = $state(false);
|
||||
const VIEWS = [
|
||||
{ label: "Thread", value: "chat" },
|
||||
{ label: "Activity", value: "activity" },
|
||||
{ label: "Raw", value: "raw" },
|
||||
{ label: "Context", value: "context" },
|
||||
];
|
||||
|
||||
const WINDOW_MAX = 56;
|
||||
const windows = $derived(info.bindings.filter((b) => b.visible));
|
||||
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 queued = $derived(
|
||||
info.queue.filter((item) => item.status === "queued").length
|
||||
);
|
||||
const connection = $derived(
|
||||
liveState === "open" ? null : (liveDetail ?? liveState)
|
||||
);
|
||||
</script>
|
||||
|
||||
<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">
|
||||
<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">
|
||||
{#if showTitle}
|
||||
<KindBadge kind={info.kind} />
|
||||
<KindMark kind={info.kind} />
|
||||
<h1 class="min-w-0 truncate font-semibold text-base tracking-tight">
|
||||
{info.title || `${info.kind} ${shortId(info.id)}`}
|
||||
</h1>
|
||||
{/if}
|
||||
<StatusPill status={info.running_turn ? "running" : info.status} />
|
||||
{#if info.pending_question}
|
||||
<span class="font-medium text-link text-xs">question pending</span>
|
||||
<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>
|
||||
{/if}
|
||||
<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
|
||||
{#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
|
||||
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;
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
title="Branch off"
|
||||
type="button"
|
||||
>
|
||||
<GitBranchIcon class="size-4" />
|
||||
<span class="hidden @md:inline">Branch</span>
|
||||
</Button>
|
||||
</button>
|
||||
<ConversationMenu
|
||||
{client}
|
||||
current
|
||||
@@ -86,59 +143,99 @@
|
||||
{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>
|
||||
<!-- 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"
|
||||
<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"
|
||||
>
|
||||
{queued}
|
||||
queued
|
||||
</span>
|
||||
{/if}
|
||||
{item.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if windows.length > 0}
|
||||
{#if detailsOpen}
|
||||
<div
|
||||
class="hidden flex-wrap items-center gap-x-3 gap-y-0.5 text-muted-foreground text-xs @md:flex"
|
||||
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"
|
||||
>
|
||||
{#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}
|
||||
<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>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
onChanged,
|
||||
class: className = "",
|
||||
children,
|
||||
trigger,
|
||||
}: {
|
||||
client: ApiClient;
|
||||
id: string;
|
||||
@@ -35,6 +36,9 @@
|
||||
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();
|
||||
@@ -93,9 +97,17 @@
|
||||
|
||||
{#if mode === "context"}
|
||||
<ContextMenu.Root bind:open>
|
||||
<ContextMenu.Trigger class={className}>
|
||||
{@render children?.()}
|
||||
</ContextMenu.Trigger>
|
||||
{#if trigger}
|
||||
<ContextMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
{@render trigger(props)}
|
||||
{/snippet}
|
||||
</ContextMenu.Trigger>
|
||||
{:else}
|
||||
<ContextMenu.Trigger class={className}>
|
||||
{@render children?.()}
|
||||
</ContextMenu.Trigger>
|
||||
{/if}
|
||||
<ContextMenu.Content class="min-w-52">
|
||||
{@render body(ContextMenu)}
|
||||
</ContextMenu.Content>
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
<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>
|
||||
@@ -1,91 +0,0 @@
|
||||
<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>
|
||||
@@ -2,15 +2,13 @@
|
||||
import { onMount, untrack } from "svelte";
|
||||
import type { ApiClient } from "$lib/api/client";
|
||||
import ErrorNote from "$lib/components/error-note.svelte";
|
||||
import { Skeleton } from "$lib/components/ui/skeleton";
|
||||
import * as Tabs from "$lib/components/ui/tabs";
|
||||
import ActivityFeed from "./activity-feed.svelte";
|
||||
import BindingsList from "./bindings-list.svelte";
|
||||
import ChatView from "./chat-view.svelte";
|
||||
import Composer from "./composer.svelte";
|
||||
import ContextView from "./context-view.svelte";
|
||||
import { ConversationFeed } from "./conversation.svelte";
|
||||
import ConversationHeader from "./conversation-header.svelte";
|
||||
import QueueList from "./queue-list.svelte";
|
||||
import { usePanelHost } from "./host";
|
||||
import RawEntries from "./raw-entries.svelte";
|
||||
|
||||
let {
|
||||
@@ -19,6 +17,8 @@
|
||||
href = null,
|
||||
onOpen,
|
||||
showTitle = true,
|
||||
initialView = "chat",
|
||||
onViewChange,
|
||||
}: {
|
||||
client: ApiClient;
|
||||
id: string;
|
||||
@@ -26,13 +26,19 @@
|
||||
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)
|
||||
untrack(() => id),
|
||||
host.cache ?? null
|
||||
);
|
||||
let tab = $state("chat");
|
||||
let view = $state(untrack(() => initialView));
|
||||
let historyKey = $state(0);
|
||||
|
||||
onMount(() => {
|
||||
@@ -47,6 +53,10 @@
|
||||
historyKey += 1;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
onViewChange?.(view);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const { running } = feed.model;
|
||||
if (!running) {
|
||||
@@ -61,9 +71,10 @@
|
||||
{#if feed.error && !info}
|
||||
<div class="p-4"><ErrorNote message={feed.error} retry={refresh} /></div>
|
||||
{:else if !info}
|
||||
<div class="flex flex-col gap-3 p-4">
|
||||
<Skeleton class="h-8 w-1/2" />
|
||||
<Skeleton class="h-24 w-full" />
|
||||
<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>
|
||||
{:else}
|
||||
<ConversationHeader
|
||||
@@ -75,49 +86,36 @@
|
||||
onChanged={refresh}
|
||||
{onOpen}
|
||||
{showTitle}
|
||||
bind:view
|
||||
/>
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<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"
|
||||
>
|
||||
{#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">
|
||||
<ActivityFeed
|
||||
{client}
|
||||
connected={feed.live.state === "open"}
|
||||
conversationId={id}
|
||||
model={feed.model}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content
|
||||
class="min-h-0 flex-1 overflow-y-auto px-3 py-3 @md:px-6"
|
||||
value="raw"
|
||||
>
|
||||
</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">
|
||||
<RawEntries {client} conversationId={id} />
|
||||
</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>
|
||||
</div>
|
||||
{/if}
|
||||
<Composer
|
||||
{client}
|
||||
conversationId={id}
|
||||
@@ -126,60 +124,3 @@
|
||||
</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}
|
||||
@@ -1,6 +1,9 @@
|
||||
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
|
||||
@@ -12,10 +15,20 @@ 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) {
|
||||
constructor(
|
||||
client: () => ApiClient | null,
|
||||
id: string,
|
||||
cache: PanelCache | null = null
|
||||
) {
|
||||
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(),
|
||||
@@ -28,7 +41,9 @@ export class ConversationFeed {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.model.setConversation(await client.conversation(this.id));
|
||||
const info = await client.conversation(this.id);
|
||||
this.model.setConversation(info);
|
||||
this.cache?.set(infoKey(this.id), info);
|
||||
this.error = null;
|
||||
} catch (error) {
|
||||
this.error = error instanceof Error ? error.message : String(error);
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
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}`;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getContext, setContext } from "svelte";
|
||||
import { type PanelCache, panelCache } from "./cache";
|
||||
|
||||
export type LinkKind = "internal" | "external";
|
||||
|
||||
@@ -7,6 +8,8 @@ export type LinkKind = "internal" | "external";
|
||||
// Obsidian plugin renders through MarkdownRenderer and opens notes in
|
||||
// place. Anything the host leaves undefined falls back to the browser way.
|
||||
export interface PanelHost {
|
||||
// Where the index and the last threads persist between opens.
|
||||
cache?: PanelCache;
|
||||
// Renders ``text`` into ``node``; returns the cleanup. Absent: marked + DOMPurify.
|
||||
markdown?: (node: HTMLElement, text: string) => (() => void) | undefined;
|
||||
name: "browser" | "obsidian";
|
||||
@@ -35,6 +38,7 @@ export function browserHost(): PanelHost {
|
||||
return browser;
|
||||
}
|
||||
browser = {
|
||||
cache: panelCache("beaver.panel"),
|
||||
name: "browser",
|
||||
openLink(target, kind) {
|
||||
if (kind === "internal") {
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
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``
|
||||
@@ -14,10 +22,19 @@ 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) {
|
||||
constructor(client: () => ApiClient | null, cache: PanelCache | null = 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(),
|
||||
@@ -44,6 +61,64 @@ 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 {
|
||||
@@ -85,6 +160,11 @@ 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) {
|
||||
|
||||
@@ -3,8 +3,13 @@
|
||||
import { usePanelHost } from "./host";
|
||||
import { linkOf, renderMarkdown } from "./markdown";
|
||||
|
||||
let { text, class: className = "" }: { text: string; class?: string } =
|
||||
$props();
|
||||
let {
|
||||
text,
|
||||
class: className = "",
|
||||
eager = true,
|
||||
}: { text: string; class?: string; eager?: boolean } = $props();
|
||||
|
||||
const NEAR = "1200px";
|
||||
|
||||
const host = usePanelHost();
|
||||
const html = $derived(host.markdown ? "" : renderMarkdown(text));
|
||||
@@ -36,15 +41,52 @@
|
||||
};
|
||||
}
|
||||
|
||||
// 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 = host.markdown?.(node, value);
|
||||
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);
|
||||
}
|
||||
return {
|
||||
destroy() {
|
||||
watcher?.disconnect();
|
||||
dispose?.();
|
||||
},
|
||||
update(next: string) {
|
||||
dispose?.();
|
||||
dispose = host.markdown?.(node, next);
|
||||
if (watcher) {
|
||||
later(next);
|
||||
} else {
|
||||
now(next);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,12 +3,11 @@
|
||||
import Link2Icon from "@lucide/svelte/icons/link-2";
|
||||
import Link2OffIcon from "@lucide/svelte/icons/link-2-off";
|
||||
import type { ApiClient } from "$lib/api/client";
|
||||
import KindBadge from "$lib/components/kind-badge.svelte";
|
||||
import LiveDot from "$lib/components/live-dot.svelte";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Popover from "$lib/components/ui/popover";
|
||||
import { clip, shortId } from "$lib/format";
|
||||
import ConversationPicker from "./conversation-picker.svelte";
|
||||
import Rail from "$lib/rail/rail.svelte";
|
||||
import KindMark from "$lib/shell/kind-mark.svelte";
|
||||
import { cn } from "$lib/utils";
|
||||
import type { ConversationIndex } from "./index.svelte";
|
||||
|
||||
let {
|
||||
@@ -16,6 +15,7 @@
|
||||
index,
|
||||
selected = null,
|
||||
onOpen,
|
||||
onOpenFile,
|
||||
follow = $bindable(false),
|
||||
showFollow = false,
|
||||
}: {
|
||||
@@ -23,6 +23,7 @@
|
||||
index: ConversationIndex;
|
||||
selected?: string | null;
|
||||
onOpen: (id: string) => void;
|
||||
onOpenFile?: (path: string) => void;
|
||||
follow?: boolean;
|
||||
showFollow?: boolean;
|
||||
} = $props();
|
||||
@@ -30,10 +31,9 @@
|
||||
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 border-b px-2 py-1.5">
|
||||
<div class="flex items-center gap-1.5 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}
|
||||
<KindBadge kind={current.kind} />
|
||||
<KindMark kind={current.kind} />
|
||||
<span class="min-w-0 flex-1 truncate font-medium">
|
||||
{current.title
|
||||
? clip(current.title, TITLE_MAX)
|
||||
@@ -59,14 +59,6 @@
|
||||
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}
|
||||
@@ -76,44 +68,44 @@
|
||||
class="w-[min(24rem,calc(100vw-1rem))] gap-0 overflow-hidden p-0"
|
||||
sideOffset={6}
|
||||
>
|
||||
<div class="flex max-h-[min(70vh,32rem)] flex-col">
|
||||
<ConversationPicker
|
||||
autofocus
|
||||
<div class="flex h-[min(70vh,32rem)] flex-col">
|
||||
<Rail
|
||||
{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"}
|
||||
variant={follow ? "secondary" : "ghost"}
|
||||
type="button"
|
||||
>
|
||||
{#if follow}
|
||||
<Link2Icon class="size-4" />
|
||||
{:else}
|
||||
<Link2OffIcon class="size-4" />
|
||||
{/if}
|
||||
</Button>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,8 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { onMount, untrack } from "svelte";
|
||||
import type { ApiClient } from "$lib/api/client";
|
||||
import EmptyState from "$lib/components/empty-state.svelte";
|
||||
import ConversationPicker from "./conversation-picker.svelte";
|
||||
import Rail from "$lib/rail/rail.svelte";
|
||||
import ConversationView from "./conversation-view.svelte";
|
||||
import type { ConversationIndex } from "./index.svelte";
|
||||
import PanelBar from "./panel-bar.svelte";
|
||||
@@ -13,12 +12,21 @@
|
||||
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.
|
||||
@@ -31,25 +39,51 @@
|
||||
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 (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.
|
||||
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.
|
||||
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review,
|
||||
the verdict, and DESIGN.md
|
||||
-->
|
||||
@@ -59,15 +93,8 @@ the verdict, and DESIGN.md
|
||||
>
|
||||
<div class="flex min-h-0 flex-1">
|
||||
{#if wide}
|
||||
<aside class="flex w-72 shrink-0 flex-col border-r bg-sidebar/40">
|
||||
<ConversationPicker
|
||||
{client}
|
||||
{index}
|
||||
onOpen={open}
|
||||
{selected}
|
||||
{showFollow}
|
||||
bind:follow
|
||||
/>
|
||||
<aside class="flex w-80 shrink-0 flex-col border-r">
|
||||
<Rail {client} {index} onOpen={open} {onOpenFile} {selected} />
|
||||
</aside>
|
||||
{/if}
|
||||
<section class="flex min-w-0 flex-1 flex-col">
|
||||
@@ -76,6 +103,7 @@ the verdict, and DESIGN.md
|
||||
{client}
|
||||
{index}
|
||||
onOpen={open}
|
||||
{onOpenFile}
|
||||
{selected}
|
||||
{showFollow}
|
||||
bind:follow
|
||||
@@ -86,19 +114,18 @@ 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">
|
||||
<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"
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -68,3 +68,197 @@
|
||||
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);
|
||||
}
|
||||
@@ -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")}>
|
||||
{toolLabel(node.name)}
|
||||
{subagent || node.name === "?" ? "Agent" : toolLabel(node.name)}
|
||||
</span>
|
||||
{#if summary}
|
||||
<span class="truncate text-muted-foreground">{summary}</span>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
fmtTokens,
|
||||
} from "$lib/format";
|
||||
import { cn } from "$lib/utils";
|
||||
import type { Turn } from "./activity.svelte";
|
||||
import { isLive, ORIGIN_LABELS, type Turn } from "./activity.svelte";
|
||||
import Markdown from "./markdown.svelte";
|
||||
import ToolNodeView from "./tool-node.svelte";
|
||||
|
||||
@@ -18,26 +18,35 @@
|
||||
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
|
||||
? `${turn.origin} · ${turn.itemOrigin}`
|
||||
: turn.origin
|
||||
? `${origin} · ${turn.itemOrigin}`
|
||||
: origin
|
||||
);
|
||||
const pill = $derived(
|
||||
turn.status !== "running" && isLive(turn) ? "running" : turn.status
|
||||
);
|
||||
const toolCount = $derived(Object.keys(turn.nodes).length);
|
||||
</script>
|
||||
|
||||
<article
|
||||
class={cn(
|
||||
"flex animate-rise flex-col gap-2 border-b py-3",
|
||||
turn.status === "running" && "bg-signal/[0.03]"
|
||||
"flex flex-col gap-2 border-b py-3",
|
||||
pill === "running" && "bg-signal/[0.03]"
|
||||
)}
|
||||
>
|
||||
<header class="flex flex-wrap items-center gap-x-3 gap-y-1 px-1 text-xs">
|
||||
<StatusPill status={turn.status} />
|
||||
{#if turn.origin !== "agent"}
|
||||
<StatusPill status={pill} />
|
||||
{/if}
|
||||
<span
|
||||
class={cn(
|
||||
"font-medium",
|
||||
turn.origin === "inject" ? "text-note" : "text-foreground"
|
||||
turn.origin === "inject" && "text-note",
|
||||
turn.origin === "agent" || turn.origin === "task"
|
||||
? "text-kind-deep"
|
||||
: "text-foreground"
|
||||
)}
|
||||
>
|
||||
{originLabel}
|
||||
@@ -65,7 +74,9 @@
|
||||
</span>
|
||||
{/if}
|
||||
</header>
|
||||
{#if turn.userText}
|
||||
{#if turn.userText && turn.origin === "task"}
|
||||
<p class="mx-1 px-1 text-muted-foreground text-xs">{turn.userText}</p>
|
||||
{:else if turn.userText}
|
||||
<Markdown
|
||||
class="mx-1 rounded-md bg-muted/50 px-2.5 py-1.5"
|
||||
text={turn.userText}
|
||||
@@ -88,7 +99,7 @@
|
||||
{/each}
|
||||
{#if turn.text}
|
||||
<Markdown class="mx-1 px-1" text={turn.text} />
|
||||
{:else if turn.status === "running" && turn.roots.length === 0 && turn.thinking === 0}
|
||||
{:else if turn.status === "running" && turn.origin !== "agent" && turn.roots.length === 0 && turn.thinking === 0}
|
||||
<p class="mx-1 px-1 text-muted-foreground text-sm">
|
||||
Waiting for the model…
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
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(" · ");
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<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>
|
||||
@@ -0,0 +1,408 @@
|
||||
<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}
|
||||
/>
|
||||
@@ -0,0 +1,46 @@
|
||||
<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>
|
||||
@@ -0,0 +1,597 @@
|
||||
<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>
|
||||
@@ -0,0 +1,130 @@
|
||||
<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}
|
||||
@@ -0,0 +1,206 @@
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
<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>
|
||||
@@ -0,0 +1,135 @@
|
||||
<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>
|
||||
@@ -0,0 +1,86 @@
|
||||
<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>
|
||||
@@ -0,0 +1,46 @@
|
||||
<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>
|
||||
@@ -0,0 +1,97 @@
|
||||
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();
|
||||
@@ -0,0 +1,164 @@
|
||||
<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}
|
||||
@@ -0,0 +1,21 @@
|
||||
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";
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<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}
|
||||
@@ -0,0 +1,80 @@
|
||||
<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>
|
||||
+35
-16
@@ -1,6 +1,5 @@
|
||||
import { browser } from "$app/environment";
|
||||
|
||||
const NAV_KEY = "beaver.ui.nav";
|
||||
const RAIL_KEY = "beaver.ui.rail";
|
||||
|
||||
function stored(key: string, fallback: boolean): boolean {
|
||||
@@ -17,28 +16,48 @@ function store(key: string, value: boolean): void {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Chrome state: the island's board, the command palette, the conversation
|
||||
// rail. The rail toggle persists; the overlays never do.
|
||||
class Ui {
|
||||
nav = $state(stored(NAV_KEY, true));
|
||||
rail = $state(stored(RAIL_KEY, true));
|
||||
|
||||
setNav(open: boolean, persist = false): void {
|
||||
this.nav = open;
|
||||
if (persist) {
|
||||
store(NAV_KEY, open);
|
||||
}
|
||||
}
|
||||
|
||||
toggleNav(): void {
|
||||
this.setNav(!this.nav, true);
|
||||
}
|
||||
island = $state(false);
|
||||
palette = $state(false);
|
||||
|
||||
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();
|
||||
@@ -1,27 +1,29 @@
|
||||
<script lang="ts">
|
||||
import "./layout.css";
|
||||
import { ModeWatcher } from "mode-watcher";
|
||||
import { onMount, untrack } from "svelte";
|
||||
import { onMount } 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 inConversations = $derived(
|
||||
page.url.pathname.startsWith(`${base}/conversations`)
|
||||
const isHome = $derived(
|
||||
page.url.pathname === `${base}/` || page.url.pathname === base
|
||||
);
|
||||
let navBefore: boolean | null = null;
|
||||
|
||||
onMount(() => {
|
||||
if (!isPanel) {
|
||||
@@ -48,23 +50,47 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (inConversations) {
|
||||
untrack(() => {
|
||||
if (navBefore === null) {
|
||||
navBefore = ui.nav;
|
||||
ui.setNav(false);
|
||||
}
|
||||
});
|
||||
} else if (navBefore !== null) {
|
||||
ui.setNav(navBefore);
|
||||
navBefore = null;
|
||||
if (page.url.pathname) {
|
||||
ui.closeAll();
|
||||
}
|
||||
});
|
||||
|
||||
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 === "b") {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "k") {
|
||||
event.preventDefault();
|
||||
ui.toggleNav();
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -80,13 +106,34 @@
|
||||
{#if isLogin || isPanel}
|
||||
{@render children()}
|
||||
{:else if session.ready && session.user}
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
+3
-388
@@ -1,396 +1,11 @@
|
||||
<script lang="ts">
|
||||
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));
|
||||
import Board from "$lib/shell/board.svelte";
|
||||
</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="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 class="mx-auto flex w-full max-w-6xl flex-col gap-6 px-4 py-6 sm:px-6">
|
||||
<Board />
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,105 +0,0 @@
|
||||
<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>
|
||||
@@ -1,16 +1,18 @@
|
||||
<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 running = $derived(gateway.running.length);
|
||||
const href = (id: string) => `${base}/conversations/${id}`;
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Conversations · Beaver</title></svelte:head>
|
||||
@@ -20,39 +22,42 @@
|
||||
"grid min-h-0 flex-1 grid-cols-1",
|
||||
ui.rail
|
||||
? "lg:grid-cols-[22rem_minmax(0,1fr)]"
|
||||
: "lg:grid-cols-[2.75rem_minmax(0,1fr)]"
|
||||
: "lg:grid-cols-[2.5rem_minmax(0,1fr)]"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
class={cn(
|
||||
"min-h-0 border-r bg-sidebar/40",
|
||||
"min-h-0 border-r",
|
||||
selected ? "hidden lg:block" : "block"
|
||||
)}
|
||||
>
|
||||
{#if ui.rail}
|
||||
<ConversationList {selected} />
|
||||
{#if session.client}
|
||||
<Rail
|
||||
client={session.client}
|
||||
{href}
|
||||
index={gateway}
|
||||
onOpenFile={(path) =>
|
||||
goto(`${base}/memory?path=${encodeURIComponent(path)}`)}
|
||||
{selected}
|
||||
/>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="hidden h-full flex-col items-center gap-2 py-2 lg:flex">
|
||||
<Button
|
||||
<div class="hidden h-full flex-col items-center 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"
|
||||
variant="ghost"
|
||||
type="button"
|
||||
>
|
||||
<PanelLeftOpenIcon class="size-4" />
|
||||
</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}
|
||||
</button>
|
||||
</div>
|
||||
<div class="h-full lg:hidden">
|
||||
<ConversationList {selected} />
|
||||
{#if session.client}
|
||||
<Rail client={session.client} {href} index={gateway} {selected} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,35 @@
|
||||
<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 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 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>
|
||||
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user