Compare commits

...
24 Commits
Author SHA1 Message Date
hh 645df6f2b4 feat(ui): endpoint catalog with copyable urls on the now page 2026-08-28 22:30:11 +02:00
hh b577034c83 chore(ui): beaver favicon from beaver-land and impeccable design sidecar 2026-08-28 22:20:36 +02:00
hh 00618ab7ad feat(ui,api,admin): sveltekit admin spa, usage by day/agent/conversation/model, rate limits, memory tree, turn snapshot 2026-08-28 22:14:15 +02:00
hh aaaf459e6d fix(backends): remember the session id from the first frame so a cut turn stays resumable 2026-08-28 19:38:47 +02:00
hh 4ad014726b fix(core): a turn cut by shutdown keeps running_turn for recover 2026-08-28 19:33:26 +02:00
hh 8c9c6eee69 fix(telegram): brief-seeded branches reply and stream in their topic 2026-08-28 19:21:03 +02:00
hh 21766fe2d7 feat(core): seeds without text wait for the first turn 2026-08-28 19:03:08 +02:00
hh 1f8dae57ac fix(telegram,core): no draft for seed turns 2026-08-28 18:59:00 +02:00
hh 66325c2879 fix(telegram): last draft push is the final text so the client folds it into the message 2026-08-28 18:51:16 +02:00
hh 5a44a16ba3 fix(telegram,core): bare seeds stay silent in telegram 2026-08-28 18:42:35 +02:00
hh c711dcab13 fix(telegram): final message waits for the draft push in flight 2026-08-28 18:32:45 +02:00
hh 3995741cd5 fix(telegram,core): first message seeds the branch, registered commands, no ellipses, html drafts 2026-08-28 18:25:46 +02:00
hh b0f280b3ca feat(telegram,core): master topic instead of general in private chats 2026-08-28 18:12:58 +02:00
hh 45a5eb4a94 feat(telegram,core,backends,storage): telegram frontend with inbox, outbox, drafts and question buttons 2026-08-28 17:57:09 +02:00
hh 7ae87aeeb8 fix(backends,markdown): stream turn events live, no blank lines for tool blocks 2026-08-28 17:27:35 +02:00
hh fd8cbacd7b feat(core): publish tool.result on the bus for the activity panel 2026-08-28 17:01:00 +02:00
hh bd2d2368e1 refactor(agents,core,frontends): typed kinds, prompts per kind, frontend agents as parameters 2026-08-28 16:36:44 +02:00
hh 1d8d65b69a feat(core,frontends,agents): agent kinds, frontend routing, anthropic on conversations, vault mirror 2026-08-28 03:50:33 +02:00
hh 33ccc78fec feat(core,backends,frontends,storage): conversations, inject queue, session pool, gateway mcp tools, api frontend 2026-08-28 03:08:30 +02:00
hh 50b7057fa4 fix(backends,storage,core): setuid in exec wrapper, missing-column migration, runner config dir for mirror, tagged prompt sources 2026-08-28 02:10:19 +02:00
hh e1f242a87a feat(backends,storage,core,markdown,infra): claude agent sdk backend, session store, transcript seeding, runner isolation 2026-08-28 01:56:39 +02:00
hhandClaude Fable 5 ee2dc918ee docs(env): .env.example for local development
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 23:45:53 +02:00
hhandClaude Fable 5 33cc769ca2 chore: make ruff format and ty clean
Format two mcp modules with the current ruff, and narrow the text-block
check in conversation_store through a typed helper so ty stops flagging
dict.get on an unnarrowed block.

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

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

No files matched your search

+22
View File
@@ -0,0 +1,22 @@
## Code Principles
- **Simplicity**: Write simple, straightforward code
- **Readability**: Make code easy to understand
- **Maintainability**: Write code that's easy to update
- **Less Code = Less Debt**: Minimize code footprint
- **NEVER write comments** - code should be self-documenting
## Layout
- `src/beaver_gateway` - python, unopinionated primitives (no vault paths, no agent names)
- `ui` - SvelteKit SPA (admin + Obsidian panel), served by the gateway from `ui/build`
- `docs/MISSION.md`, `docs/PRD.md`, `docs/PLAN.md`, `docs/PROGRESS.md` - keep PROGRESS.md current
## Checking commands
After writing code, always run (or `make check`):
```shell
ruff format # python formatter
ruff check --fix # python linter
ty check # python type-checker
cd ui && bun fix # frontend linter+formatter (ultracite)
cd ui && bun check # svelte-check
```
Dependencies: `uv add` / `cd ui && bun add`, never edit lock files by hand.
+23
View File
@@ -0,0 +1,23 @@
You are able to use the Svelte MCP server, where you have access to comprehensive Svelte 5 and SvelteKit documentation. Here's how to use the available tools effectively:
## Available Svelte MCP Tools:
### 1. list-sections
Use this FIRST to discover all available documentation sections. Returns a structured list with titles, use_cases, and paths.
When asked about Svelte or SvelteKit topics, ALWAYS use this tool at the start of the chat to find relevant sections.
### 2. get-documentation
Retrieves full documentation content for specific sections. Accepts single or multiple sections.
After calling the list-sections tool, you MUST analyze the returned documentation sections (especially the use_cases field) and then use the get-documentation tool to fetch ALL documentation sections that are relevant for the user's task.
### 3. svelte-autofixer
Analyzes Svelte code and returns issues and suggestions.
You MUST use this tool whenever writing Svelte code before sending it to the user. Keep calling it until no issues or suggestions are returned.
### 4. playground-link
Generates a Svelte Playground link with the provided code.
After completing the code, ask the user if they want a playground link. Only call this tool after user confirmation and NEVER if code was written to files in their project.
+123
View File
@@ -0,0 +1,123 @@
# Ultracite Code Standards
This project uses **Ultracite**, a zero-config preset that enforces strict code quality standards through automated formatting and linting.
## Quick Reference
- **Format code**: `bun x ultracite fix`
- **Check for issues**: `bun x ultracite check`
- **Diagnose setup**: `bun x ultracite doctor`
Biome (the underlying engine) provides robust linting and formatting. Most issues are automatically fixable.
---
## Core Principles
Write code that is **accessible, performant, type-safe, and maintainable**. Focus on clarity and explicit intent over brevity.
### Type Safety & Explicitness
- Use explicit types for function parameters and return values when they enhance clarity
- Prefer `unknown` over `any` when the type is genuinely unknown
- Use const assertions (`as const`) for immutable values and literal types
- Leverage TypeScript's type narrowing instead of type assertions
- Use meaningful variable names instead of magic numbers - extract constants with descriptive names
### Modern JavaScript/TypeScript
- Use arrow functions for callbacks and short functions
- Prefer `for...of` loops over `.forEach()` and indexed `for` loops
- Use optional chaining (`?.`) and nullish coalescing (`??`) for safer property access
- Prefer template literals over string concatenation
- Use destructuring for object and array assignments
- Use `const` by default, `let` only when reassignment is needed, never `var`
### Async & Promises
- Always `await` promises in async functions - don't forget to use the return value
- Use `async/await` syntax instead of promise chains for better readability
- Handle errors appropriately in async code with try-catch blocks
- Don't use async functions as Promise executors
### React & JSX
- Use function components over class components
- Call hooks at the top level only, never conditionally
- Specify all dependencies in hook dependency arrays correctly
- Use the `key` prop for elements in iterables (prefer unique IDs over array indices)
- Nest children between opening and closing tags instead of passing as props
- Don't define components inside other components
- Use semantic HTML and ARIA attributes for accessibility:
- Provide meaningful alt text for images
- Use proper heading hierarchy
- Add labels for form inputs
- Include keyboard event handlers alongside mouse events
- Use semantic elements (`<button>`, `<nav>`, etc.) instead of divs with roles
### Error Handling & Debugging
- Remove `console.log`, `debugger`, and `alert` statements from production code
- Throw `Error` objects with descriptive messages, not strings or other values
- Use `try-catch` blocks meaningfully - don't catch errors just to rethrow them
- Prefer early returns over nested conditionals for error cases
### Code Organization
- Keep functions focused and under reasonable cognitive complexity limits
- Extract complex conditions into well-named boolean variables
- Use early returns to reduce nesting
- Prefer simple conditionals over nested ternary operators
- Group related code together and separate concerns
### Security
- Add `rel="noopener"` when using `target="_blank"` on links
- Avoid `dangerouslySetInnerHTML` unless absolutely necessary
- Don't use `eval()` or assign directly to `document.cookie`
- Validate and sanitize user input
### Performance
- Avoid spread syntax in accumulators within loops
- Use top-level regex literals instead of creating them in loops
- Prefer specific imports over namespace imports
- Avoid barrel files (index files that re-export everything)
- Use proper image components (e.g., Next.js `<Image>`) over `<img>` tags
### Framework-Specific Guidance
**Next.js:**
- Use Next.js `<Image>` component for images
- Use `next/head` or App Router metadata API for head elements
- Use Server Components for async data fetching instead of async Client Components
**React 19+:**
- Use ref as a prop instead of `React.forwardRef`
**Solid/Svelte/Vue/Qwik:**
- Use `class` and `for` attributes (not `className` or `htmlFor`)
---
## Testing
- Write assertions inside `it()` or `test()` blocks
- Avoid done callbacks in async tests - use async/await instead
- Don't use `.only` or `.skip` in committed code
- Keep test suites reasonably flat - avoid excessive `describe` nesting
## When Biome Can't Help
Biome's linter will catch most issues automatically. Focus your attention on:
1. **Business logic correctness** - Biome can't validate your algorithms
2. **Meaningful naming** - Use descriptive names for functions, variables, and types
3. **Architecture decisions** - Component structure, data flow, and API design
4. **Edge cases** - Handle boundary conditions and error states
5. **User experience** - Accessibility, performance, and usability considerations
6. **Documentation** - Add comments for complex logic, but prefer self-documenting code
---
Most formatting and common issues are automatically fixed by Biome. Run `bun x ultracite fix` before committing to ensure compliance.
+15
View File
@@ -0,0 +1,15 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "f=$(python3 -c 'import json,sys;print(json.load(sys.stdin).get(\"tool_input\",{}).get(\"file_path\",\"\"))'); case \"$f\" in */ui/*) cd ui && bun fix --skip=correctness/noUnusedImports ;; *.py) uv run ruff format \"$f\" && uv run ruff check --fix \"$f\" ;; esac"
}
]
}
]
}
}
+7
View File
@@ -14,3 +14,10 @@ examples
**/.ruff_cache
**/.ty_cache
README.md
ui/node_modules
ui/.svelte-kit
ui/build
ui/.vscode
.claude
.mcp.json
Makefile
+30
View File
@@ -0,0 +1,30 @@
# beaver-gateway - локальный dev. В проде переменные приходят из .env
# стека beaver-agent (env_file), этот файл там не нужен.
# Postgres из `make db` (профиль db в ../beaver-agent) или sqlite для быстрого старта
DATABASE_URL=postgresql+psycopg://beaver:beaver@localhost:5432/beaver
#DATABASE_URL=sqlite+aiosqlite:///./db.db
# Admin UI
ADMIN_USER=admin
ADMIN_PASS=admin
SESSION_SECRET=dev-only-change-me
# Пользовательский config.py (агенты, MCP, фронтенды)
CONFIG_PATH=../beaver-agent/config.py
# Токены для Anthropic/MCP фронтендов без admin UI: name:value,name:value
BOOTSTRAP_TOKENS=dev:dev-token-change-me
# Claude: `claude setup-token` на маке
CLAUDE_CODE_OAUTH_TOKEN=
# Raycast (нужно только если в config.py есть RaycastAgent)
RAYCAST_CONFIG_PATH=../raycast-api/config.json
RAYCAST_DEVICE_ID=
RAYCAST_BEARER=
# Telegram (нужно только если в config.py есть TelegramFrontend):
# токен от @BotFather, свой user id - @userinfobot
TELEGRAM_BOT_TOKEN=
TELEGRAM_USER_ID=
+4
View File
@@ -11,6 +11,7 @@ wheels/
# Sloppy
docs/
t/
# IDE
.idea
@@ -18,3 +19,6 @@ docs/
# Local env
.env
db.db
# Impeccable review screenshots
.impeccable/review/
+185
View File
@@ -0,0 +1,185 @@
{
"schemaVersion": 2,
"generatedAt": "2026-08-28T00:00:00Z",
"title": "Design System: Beaver admin",
"extensions": {
"colorMeta": {
"primary": { "role": "primary", "displayName": "Mulberry", "canonical": "oklch(0.47 0.13 340)", "dark": "#7c3871", "tonalRamp": ["oklch(0.15 0.13 340)", "oklch(0.26 0.13 340)", "oklch(0.37 0.13 340)", "oklch(0.47 0.13 340)", "oklch(0.58 0.13 340)", "oklch(0.70 0.13 340)", "oklch(0.83 0.13 340)", "oklch(0.95 0.13 340)"] },
"signal": { "role": "primary", "displayName": "Orchid Signal", "canonical": "oklch(0.48 0.21 342)", "dark": "#ff82f3", "tonalRamp": ["oklch(0.15 0.21 342)", "oklch(0.26 0.21 342)", "oklch(0.37 0.21 342)", "oklch(0.48 0.21 342)", "oklch(0.60 0.21 342)", "oklch(0.72 0.21 342)", "oklch(0.84 0.21 342)", "oklch(0.95 0.21 342)"] },
"ring": { "role": "primary", "displayName": "Focus Ring", "canonical": "oklch(0.55 0.2 342)", "dark": "#ff82f3", "tonalRamp": ["oklch(0.15 0.2 342)", "oklch(0.26 0.2 342)", "oklch(0.38 0.2 342)", "oklch(0.55 0.2 342)", "oklch(0.64 0.2 342)", "oklch(0.74 0.2 342)", "oklch(0.85 0.2 342)", "oklch(0.95 0.2 342)"] },
"status-reply": { "role": "secondary", "displayName": "Branch Blue", "canonical": "oklch(0.55 0.14 235)", "dark": "oklch(0.72 0.13 235)", "tonalRamp": ["oklch(0.15 0.14 235)", "oklch(0.26 0.14 235)", "oklch(0.40 0.14 235)", "oklch(0.55 0.14 235)", "oklch(0.65 0.14 235)", "oklch(0.75 0.14 235)", "oklch(0.85 0.14 235)", "oklch(0.95 0.14 235)"] },
"status-meeting": { "role": "secondary", "displayName": "Deep Violet", "canonical": "oklch(0.5 0.2 300)", "dark": "oklch(0.72 0.16 300)", "tonalRamp": ["oklch(0.15 0.2 300)", "oklch(0.26 0.2 300)", "oklch(0.38 0.2 300)", "oklch(0.50 0.2 300)", "oklch(0.62 0.2 300)", "oklch(0.74 0.2 300)", "oklch(0.85 0.2 300)", "oklch(0.95 0.2 300)"] },
"status-work": { "role": "secondary", "displayName": "Job Teal", "canonical": "oklch(0.55 0.13 195)", "dark": "oklch(0.75 0.12 195)", "tonalRamp": ["oklch(0.15 0.13 195)", "oklch(0.26 0.13 195)", "oklch(0.40 0.13 195)", "oklch(0.55 0.13 195)", "oklch(0.65 0.13 195)", "oklch(0.75 0.13 195)", "oklch(0.85 0.13 195)", "oklch(0.95 0.13 195)"] },
"status-skip": { "role": "secondary", "displayName": "Fork Grey", "canonical": "oklch(0.5 0.04 340)", "dark": "#a989a3", "tonalRamp": ["oklch(0.15 0.04 340)", "oklch(0.26 0.04 340)", "oklch(0.38 0.04 340)", "oklch(0.50 0.04 340)", "oklch(0.62 0.04 340)", "oklch(0.74 0.04 340)", "oklch(0.85 0.04 340)", "oklch(0.95 0.04 340)"] },
"status-done": { "role": "tertiary", "displayName": "OK Green", "canonical": "oklch(0.55 0.14 155)", "dark": "oklch(0.74 0.14 155)", "tonalRamp": ["oklch(0.15 0.14 155)", "oklch(0.26 0.14 155)", "oklch(0.40 0.14 155)", "oklch(0.55 0.14 155)", "oklch(0.65 0.14 155)", "oklch(0.75 0.14 155)", "oklch(0.85 0.14 155)", "oklch(0.95 0.14 155)"] },
"warn": { "role": "tertiary", "displayName": "Warn Amber", "canonical": "oklch(0.55 0.13 75)", "dark": "oklch(0.8 0.13 75)", "tonalRamp": ["oklch(0.15 0.13 75)", "oklch(0.26 0.13 75)", "oklch(0.40 0.13 75)", "oklch(0.55 0.13 75)", "oklch(0.65 0.13 75)", "oklch(0.75 0.13 75)", "oklch(0.85 0.13 75)", "oklch(0.95 0.13 75)"] },
"note": { "role": "tertiary", "displayName": "Inject Amber", "canonical": "oklch(0.52 0.13 78)", "dark": "oklch(0.84 0.14 88)", "tonalRamp": ["oklch(0.15 0.13 78)", "oklch(0.26 0.13 78)", "oklch(0.40 0.13 78)", "oklch(0.52 0.13 78)", "oklch(0.64 0.13 78)", "oklch(0.75 0.13 78)", "oklch(0.85 0.13 78)", "oklch(0.95 0.13 78)"] },
"destructive": { "role": "tertiary", "displayName": "Error Red", "canonical": "oklch(0.58 0.22 25)", "dark": "oklch(0.62 0.2 25)", "tonalRamp": ["oklch(0.15 0.22 25)", "oklch(0.26 0.22 25)", "oklch(0.40 0.22 25)", "oklch(0.58 0.22 25)", "oklch(0.66 0.22 25)", "oklch(0.75 0.22 25)", "oklch(0.85 0.22 25)", "oklch(0.95 0.22 25)"] },
"background": { "role": "neutral", "displayName": "Blush Paper", "canonical": "oklch(0.99 0.004 340)", "dark": "#22111e", "tonalRamp": ["oklch(0.15 0.004 340)", "oklch(0.26 0.004 340)", "oklch(0.40 0.004 340)", "oklch(0.55 0.004 340)", "oklch(0.70 0.004 340)", "oklch(0.85 0.004 340)", "oklch(0.95 0.004 340)", "oklch(0.99 0.004 340)"] },
"foreground": { "role": "neutral", "displayName": "Plum Ink", "canonical": "oklch(0.24 0.03 340)", "dark": "#f5f5f5", "tonalRamp": ["oklch(0.15 0.03 340)", "oklch(0.24 0.03 340)", "oklch(0.38 0.03 340)", "oklch(0.50 0.03 340)", "oklch(0.62 0.03 340)", "oklch(0.75 0.03 340)", "oklch(0.87 0.03 340)", "oklch(0.95 0.03 340)"] },
"sidebar": { "role": "neutral", "displayName": "Sidebar Layer", "canonical": "oklch(0.975 0.01 340)", "dark": "#2c1b29", "tonalRamp": ["oklch(0.15 0.01 340)", "oklch(0.26 0.01 340)", "oklch(0.40 0.01 340)", "oklch(0.55 0.01 340)", "oklch(0.70 0.01 340)", "oklch(0.85 0.01 340)", "oklch(0.94 0.01 340)", "oklch(0.975 0.01 340)"] },
"sidebar-accent": { "role": "neutral", "displayName": "Sidebar Accent", "canonical": "oklch(0.94 0.016 340)", "dark": "#341d2f", "tonalRamp": ["oklch(0.15 0.016 340)", "oklch(0.26 0.016 340)", "oklch(0.40 0.016 340)", "oklch(0.55 0.016 340)", "oklch(0.70 0.016 340)", "oklch(0.85 0.016 340)", "oklch(0.94 0.016 340)", "oklch(0.98 0.016 340)"] },
"muted": { "role": "neutral", "displayName": "Muted", "canonical": "oklch(0.96 0.012 340)", "dark": "#40283a", "tonalRamp": ["oklch(0.15 0.012 340)", "oklch(0.26 0.012 340)", "oklch(0.40 0.012 340)", "oklch(0.55 0.012 340)", "oklch(0.70 0.012 340)", "oklch(0.85 0.012 340)", "oklch(0.96 0.012 340)", "oklch(0.99 0.012 340)"] },
"muted-foreground": { "role": "neutral", "displayName": "Muted Ink", "canonical": "oklch(0.5 0.04 340)", "dark": "#a989a3", "tonalRamp": ["oklch(0.15 0.04 340)", "oklch(0.26 0.04 340)", "oklch(0.38 0.04 340)", "oklch(0.50 0.04 340)", "oklch(0.62 0.04 340)", "oklch(0.74 0.04 340)", "oklch(0.85 0.04 340)", "oklch(0.95 0.04 340)"] },
"icon": { "role": "neutral", "displayName": "Icon", "canonical": "oklch(0.55 0.03 340)", "dark": "#877384", "tonalRamp": ["oklch(0.15 0.03 340)", "oklch(0.26 0.03 340)", "oklch(0.40 0.03 340)", "oklch(0.55 0.03 340)", "oklch(0.65 0.03 340)", "oklch(0.75 0.03 340)", "oklch(0.85 0.03 340)", "oklch(0.95 0.03 340)"] },
"border": { "role": "neutral", "displayName": "Hairline", "canonical": "oklch(0.9 0.02 340)", "dark": "oklch(0.92 0.04 340 / 10%)", "tonalRamp": ["oklch(0.15 0.02 340)", "oklch(0.26 0.02 340)", "oklch(0.40 0.02 340)", "oklch(0.55 0.02 340)", "oklch(0.70 0.02 340)", "oklch(0.80 0.02 340)", "oklch(0.90 0.02 340)", "oklch(0.97 0.02 340)"] }
},
"typographyMeta": {
"headline": { "displayName": "Headline", "purpose": "Page titles in page-header.svelte; the sidebar wordmark shares weight and tracking." },
"title": { "displayName": "Title", "purpose": "Thread title, stat values (tabular), memory file path." },
"body": { "displayName": "Body", "purpose": "Rows, cells, bubbles, dialogs; primary row text at 500." },
"label": { "displayName": "Label", "purpose": "Uppercase section headings; same size without uppercase for table headers, badges, pills, meta lines." },
"mono": { "displayName": "Mono", "purpose": "Raw JSON, tool input/result blocks, event tape, ids, the plaintext token." }
},
"shadows": [
{ "name": "login-lift", "value": "0 8px 24px -12px rgb(0 0 0 / 0.25)", "purpose": "The sign-in card only (routes/login/+page.svelte). The single application-layer shadow." },
{ "name": "popover", "value": "shadow-xl plus ring-1 ring-foreground/5 (10% dark)", "purpose": "Luma dialogs, sheets, menus, selects. Overlay surfaces only." }
],
"motion": [
{ "name": "pulse-dot", "value": "pulse-dot 1.6s cubic-bezier(0.4, 0, 0.6, 1) infinite", "purpose": "Signal-colored halo (0 to 5px, 55% to 0% alpha) on live and running dots." },
{ "name": "rise", "value": "rise 220ms cubic-bezier(0.16, 1, 0.3, 1) both", "purpose": "Opacity 0 to 1 and translateY 3px to 0 when a turn card lands in the activity feed." },
{ "name": "row-hover", "value": "background-color 150ms ease-out", "purpose": "Ink wash at 4% on interactive rows." },
{ "name": "chevron", "value": "transform 150ms", "purpose": "90 degree rotation of expand chevrons in tool nodes, raw entries, memory tree." },
{ "name": "bar-fill", "value": "width 300ms", "purpose": "Utilization bar fill in limit-bar.svelte." },
{ "name": "dialog", "value": "fade-in and zoom-in-95 over 100ms", "purpose": "Luma dialog open and close." },
{ "name": "reduced-motion", "value": "transition-duration and animation-duration 0.01ms under prefers-reduced-motion", "purpose": "Global kill switch in layout.css." }
],
"breakpoints": [
{ "name": "sm", "value": "640px", "purpose": "Sidebar replaces bottom nav; header padding 24px; hidden row columns return." },
{ "name": "md", "value": "768px", "purpose": "Memory tree and file split side by side." },
{ "name": "lg", "value": "1024px", "purpose": "Thread meta rail (18rem) and two-column dashboard sections." }
]
},
"components": [
{
"name": "Sidebar nav item",
"kind": "nav",
"refersTo": "sidebar-nav-item",
"description": "Section link in the 13rem sidebar; the active item takes the sidebar accent fill, the Now item carries the running count in signal.",
"html": "<nav class=\"ds-nav\"><a class=\"ds-nav-item ds-nav-item--active\" href=\"#\" aria-current=\"page\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2\"/></svg><span class=\"ds-nav-label\">Now</span><span class=\"ds-nav-count\">1</span></a><a class=\"ds-nav-item\" href=\"#\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M14 9a2 2 0 0 1-2 2H6l-4 4V4c0-1.1.9-2 2-2h8a2 2 0 0 1 2 2z\"/><path d=\"M18 9h2a2 2 0 0 1 2 2v11l-4-4h-6a2 2 0 0 1-2-2v-1\"/></svg><span class=\"ds-nav-label\">Conversations</span></a></nav>",
"css": ".ds-nav { display: flex; flex-direction: column; gap: 2px; padding: 8px; width: 208px; background: var(--sidebar); color: var(--sidebar-foreground); font-family: 'Inter Variable', sans-serif; } .ds-nav-item { display: flex; align-items: center; gap: 10px; height: 32px; padding: 0 10px; border-radius: 0.36rem; font-size: 0.8125rem; color: color-mix(in oklab, var(--sidebar-foreground) 80%, transparent); text-decoration: none; transition: background-color 150ms, color 150ms; } .ds-nav-item svg { color: var(--icon); flex-shrink: 0; } .ds-nav-item:hover { background: color-mix(in oklab, var(--sidebar-accent) 60%, transparent); color: var(--sidebar-foreground); } .ds-nav-item--active { background: var(--sidebar-accent); color: var(--sidebar-foreground); font-weight: 500; } .ds-nav-item:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } .ds-nav-label { flex: 1; } .ds-nav-count { font-variant-numeric: tabular-nums; font-size: 0.75rem; font-weight: 500; padding: 0 6px; border-radius: 999px; color: var(--signal); background: color-mix(in oklab, var(--signal) 12%, transparent); }"
},
{
"name": "Section heading",
"kind": "custom",
"refersTo": "section-heading",
"description": "The 12px uppercase muted h2 that opens every ledger section, optionally followed by a tabular summary.",
"html": "<div class=\"ds-section-head\"><h2 class=\"ds-section-heading\">Live sessions</h2><span class=\"ds-section-summary\">1 processes · 263 MB</span></div>",
"css": ".ds-section-head { display: flex; align-items: baseline; gap: 12px; font-family: 'Inter Variable', sans-serif; } .ds-section-heading { margin: 0; font-size: 0.75rem; font-weight: 500; letter-spacing: 0.025em; text-transform: uppercase; color: var(--muted-foreground); } .ds-section-summary { font-size: 0.75rem; color: var(--muted-foreground); font-variant-numeric: tabular-nums; }"
},
{
"name": "Ledger row (running)",
"kind": "card",
"refersTo": "ledger-row",
"description": "A running conversation on the Now page: pulsing signal dot, kind badge, two-line title, tabular elapsed time, hairline below, ink-wash hover.",
"html": "<a class=\"ds-row\" href=\"#\"><span class=\"ds-row-dot\"></span><span class=\"ds-badge ds-badge--master\">master</span><span class=\"ds-row-title\"><span class=\"ds-row-name\">master e56eabe0</span><span class=\"ds-row-sub\">beaver-dispatcher</span></span><span class=\"ds-row-time\">20.1s</span></a>",
"css": ".ds-row { display: grid; grid-template-columns: auto auto minmax(0, 1fr) auto; column-gap: 12px; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--border); font-family: 'Inter Variable', sans-serif; font-size: 0.8125rem; color: var(--foreground); text-decoration: none; transition: background-color 150ms ease-out; } .ds-row:hover { background: color-mix(in oklab, var(--foreground) 4%, transparent); } .ds-row:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } .ds-row-dot { width: 8px; height: 8px; border-radius: 999px; background: var(--signal); animation: ds-pulse 1.6s cubic-bezier(0.4, 0, 0.6, 1) infinite; } @keyframes ds-pulse { 0%, 100% { box-shadow: 0 0 0 0 color-mix(in oklab, var(--signal) 55%, transparent); } 50% { box-shadow: 0 0 0 5px color-mix(in oklab, var(--signal) 0%, transparent); } } .ds-row-title { display: flex; flex-direction: column; min-width: 0; } .ds-row-name { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ds-row-sub { font-size: 0.75rem; color: var(--muted-foreground); } .ds-row-time { font-size: 0.75rem; color: var(--muted-foreground); font-variant-numeric: tabular-nums; } .ds-badge { display: inline-flex; align-items: center; height: 20px; padding: 0 6px; border-radius: 0.36rem; font-size: 0.75rem; font-weight: 500; line-height: 1; } .ds-badge--master { color: var(--signal); background: color-mix(in oklab, var(--signal) 12%, transparent); }"
},
{
"name": "Kind badge",
"kind": "chip",
"refersTo": "kind-badge",
"description": "Conversation kind as its hue at 12% behind hue text; master, branch, deep, job, fork.",
"html": "<span class=\"ds-kinds\"><span class=\"ds-badge ds-badge--master\">master</span><span class=\"ds-badge ds-badge--branch\">branch</span><span class=\"ds-badge ds-badge--deep\">deep</span><span class=\"ds-badge ds-badge--job\">job</span><span class=\"ds-badge ds-badge--fork\">fork</span></span>",
"css": ".ds-kinds { display: inline-flex; gap: 4px; font-family: 'Inter Variable', sans-serif; } .ds-badge { display: inline-flex; align-items: center; height: 20px; padding: 0 6px; border-radius: 0.36rem; font-size: 0.75rem; font-weight: 500; line-height: 1; } .ds-badge--master { color: var(--signal); background: color-mix(in oklab, var(--signal) 12%, transparent); } .ds-badge--branch { color: var(--status-reply); background: color-mix(in oklab, var(--status-reply) 12%, transparent); } .ds-badge--deep { color: var(--status-meeting); background: color-mix(in oklab, var(--status-meeting) 12%, transparent); } .ds-badge--job { color: var(--status-work); background: color-mix(in oklab, var(--status-work) 12%, transparent); } .ds-badge--fork { color: var(--status-skip); background: color-mix(in oklab, var(--status-skip) 12%, transparent); }"
},
{
"name": "Status pill",
"kind": "chip",
"refersTo": "status-pill",
"description": "Outcome as a 6px current-color dot plus 12px text; running pulses in signal.",
"html": "<span class=\"ds-pills\"><span class=\"ds-pill ds-pill--running\"><span class=\"ds-pill-dot ds-pill-dot--pulse\"></span>running</span><span class=\"ds-pill ds-pill--done\"><span class=\"ds-pill-dot\"></span>done</span><span class=\"ds-pill ds-pill--warn\"><span class=\"ds-pill-dot\"></span>interrupted</span><span class=\"ds-pill ds-pill--error\"><span class=\"ds-pill-dot\"></span>failed</span><span class=\"ds-pill ds-pill--muted\"><span class=\"ds-pill-dot\"></span>closed</span></span>",
"css": ".ds-pills { display: inline-flex; gap: 12px; font-family: 'Inter Variable', sans-serif; } .ds-pill { display: inline-flex; align-items: center; gap: 6px; font-size: 0.75rem; font-weight: 500; } .ds-pill-dot { width: 6px; height: 6px; border-radius: 999px; background: currentColor; } .ds-pill-dot--pulse { animation: ds-pulse 1.6s cubic-bezier(0.4, 0, 0.6, 1) infinite; } @keyframes ds-pulse { 0%, 100% { box-shadow: 0 0 0 0 color-mix(in oklab, var(--signal) 55%, transparent); } 50% { box-shadow: 0 0 0 5px color-mix(in oklab, var(--signal) 0%, transparent); } } .ds-pill--running { color: var(--signal); } .ds-pill--done { color: var(--status-done); } .ds-pill--warn { color: var(--warn); } .ds-pill--error { color: var(--destructive); } .ds-pill--muted { color: var(--muted-foreground); }"
},
{
"name": "Tool node",
"kind": "custom",
"refersTo": "ledger-row",
"description": "One row of the tool tree: chevron, per-node status dot, tool label (deep-tinted for subagents) with input summary, tabular duration; children nest under a left rail.",
"html": "<div class=\"ds-tool\"><button class=\"ds-tool-row\" type=\"button\" aria-expanded=\"false\"><svg class=\"ds-tool-chev\" width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"m9 18 6-6-6-6\"/></svg><span class=\"ds-tool-dot ds-tool-dot--running\"></span><span class=\"ds-tool-main\"><span class=\"ds-tool-name\">Bash</span><span class=\"ds-tool-sum\">python3 -c \"import time; time.sleep(30)\"</span></span><span class=\"ds-tool-time\">13.5s</span></button><div class=\"ds-tool ds-tool--child\"><button class=\"ds-tool-row\" type=\"button\" aria-expanded=\"false\"><svg class=\"ds-tool-chev\" width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"m9 18 6-6-6-6\"/></svg><span class=\"ds-tool-dot ds-tool-dot--done\"></span><span class=\"ds-tool-main\"><span class=\"ds-tool-name ds-tool-name--subagent\">Agent</span><span class=\"ds-tool-sum\">Read role.md and summarize</span></span><span class=\"ds-tool-time\">12.1s</span></button></div></div>",
"css": ".ds-tool { display: flex; flex-direction: column; font-family: 'Inter Variable', sans-serif; } .ds-tool--child { margin-left: 12px; padding-left: 8px; border-left: 1px solid var(--border); } .ds-tool-row { display: grid; grid-template-columns: auto auto minmax(0, 1fr) auto; column-gap: 12px; align-items: center; padding: 4px 6px; border: 0; border-radius: 0.36rem; background: transparent; color: var(--foreground); font: inherit; font-size: 0.8125rem; text-align: left; cursor: pointer; transition: background-color 150ms ease-out; } .ds-tool-row:hover { background: color-mix(in oklab, var(--foreground) 4%, transparent); } .ds-tool-row:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } .ds-tool-chev { color: var(--icon); transition: transform 150ms; } .ds-tool-row[aria-expanded=\"true\"] .ds-tool-chev { transform: rotate(90deg); } .ds-tool-dot { width: 8px; height: 8px; border-radius: 999px; } .ds-tool-dot--running { background: var(--signal); animation: ds-pulse 1.6s cubic-bezier(0.4, 0, 0.6, 1) infinite; } .ds-tool-dot--done { background: var(--status-done); } @keyframes ds-pulse { 0%, 100% { box-shadow: 0 0 0 0 color-mix(in oklab, var(--signal) 55%, transparent); } 50% { box-shadow: 0 0 0 5px color-mix(in oklab, var(--signal) 0%, transparent); } } .ds-tool-main { display: flex; align-items: baseline; gap: 8px; min-width: 0; } .ds-tool-name { font-weight: 500; flex-shrink: 0; } .ds-tool-name--subagent { color: var(--status-meeting); } .ds-tool-sum { color: var(--muted-foreground); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ds-tool-time { font-size: 0.75rem; color: var(--muted-foreground); font-variant-numeric: tabular-nums; }"
},
{
"name": "Primary button",
"kind": "button",
"refersTo": "button-primary",
"description": "Luma pill button in mulberry; the one filled action per surface (Send, New, Create, Sign in).",
"html": "<button class=\"ds-btn ds-btn--primary\" type=\"button\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M5 12h14\"/><path d=\"M12 5v14\"/></svg>New token</button>",
"css": ".ds-btn { display: inline-flex; align-items: center; justify-content: center; gap: 4px; height: 32px; padding: 0 12px; border: 1px solid transparent; border-radius: 2rem; font-family: 'Inter Variable', sans-serif; font-size: 0.8125rem; font-weight: 500; white-space: nowrap; cursor: pointer; user-select: none; transition: all 150ms; } .ds-btn:active { transform: translateY(1px); } .ds-btn:disabled { opacity: 0.5; pointer-events: none; } .ds-btn:focus-visible { border-color: var(--ring); box-shadow: 0 0 0 3px color-mix(in oklab, var(--ring) 30%, transparent); outline: none; } .ds-btn--primary { background: var(--primary); color: var(--primary-foreground); } .ds-btn--primary:hover { background: color-mix(in oklab, var(--primary) 80%, transparent); }"
},
{
"name": "Outline button",
"kind": "button",
"refersTo": "button-outline",
"description": "Secondary action everywhere (Branch, Retry, Copy, answer options): hairline pill on the background surface.",
"html": "<button class=\"ds-btn ds-btn--outline\" type=\"button\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><line x1=\"6\" x2=\"6\" y1=\"3\" y2=\"15\"/><circle cx=\"18\" cy=\"6\" r=\"3\"/><circle cx=\"6\" cy=\"18\" r=\"3\"/><path d=\"M18 9a9 9 0 0 1-9 9\"/></svg>Branch</button>",
"css": ".ds-btn { display: inline-flex; align-items: center; justify-content: center; gap: 4px; height: 32px; padding: 0 12px; border: 1px solid transparent; border-radius: 2rem; font-family: 'Inter Variable', sans-serif; font-size: 0.8125rem; font-weight: 500; white-space: nowrap; cursor: pointer; user-select: none; transition: all 150ms; } .ds-btn:active { transform: translateY(1px); } .ds-btn:focus-visible { border-color: var(--ring); box-shadow: 0 0 0 3px color-mix(in oklab, var(--ring) 30%, transparent); outline: none; } .ds-btn--outline { border-color: var(--border); background: var(--background); color: var(--foreground); } .ds-btn--outline:hover { background: var(--muted); }"
},
{
"name": "Ghost button",
"kind": "button",
"refersTo": "button-ghost",
"description": "Quiet action (Cancel, Revoke, Done, icon buttons): transparent until hover.",
"html": "<button class=\"ds-btn ds-btn--ghost\" type=\"button\">Cancel</button>",
"css": ".ds-btn { display: inline-flex; align-items: center; justify-content: center; gap: 4px; height: 32px; padding: 0 12px; border: 1px solid transparent; border-radius: 2rem; font-family: 'Inter Variable', sans-serif; font-size: 0.8125rem; font-weight: 500; white-space: nowrap; cursor: pointer; user-select: none; transition: all 150ms; } .ds-btn:active { transform: translateY(1px); } .ds-btn:focus-visible { border-color: var(--ring); box-shadow: 0 0 0 3px color-mix(in oklab, var(--ring) 30%, transparent); outline: none; } .ds-btn--ghost { background: transparent; color: var(--foreground); } .ds-btn--ghost:hover { background: var(--muted); }"
},
{
"name": "Text input",
"kind": "input",
"refersTo": "input",
"description": "Luma pill input on a 50% input tint; focus is a ring-colored border plus a 30% halo.",
"html": "<label class=\"ds-field\"><span class=\"ds-label\">Name</span><input class=\"ds-input\" type=\"text\" placeholder=\"cursor-laptop\"></label>",
"css": ".ds-field { display: flex; flex-direction: column; gap: 6px; font-family: 'Inter Variable', sans-serif; max-width: 320px; } .ds-label { font-size: 0.8125rem; font-weight: 500; color: var(--foreground); } .ds-input { height: 36px; width: 100%; padding: 4px 12px; border: 1px solid transparent; border-radius: 1.5rem; background: color-mix(in oklab, var(--input) 50%, transparent); color: var(--foreground); font: inherit; font-size: 0.8125rem; caret-color: var(--primary); outline: none; transition: color 150ms, box-shadow 150ms, background-color 150ms; } .ds-input::placeholder { color: var(--muted-foreground); } .ds-input:focus-visible { border-color: var(--ring); box-shadow: 0 0 0 3px color-mix(in oklab, var(--ring) 30%, transparent); } .ds-input:disabled { opacity: 0.5; cursor: not-allowed; }"
},
{
"name": "Empty state",
"kind": "card",
"refersTo": "empty-state",
"description": "The only dashed border in the system: a bordered note with a 500 title, a muted hint and an optional outline action.",
"html": "<div class=\"ds-empty\"><p class=\"ds-empty-title\">The agents are idle</p><p class=\"ds-empty-hint\">Nothing is in a turn. The ledger fills in the moment a message or an inject lands.</p></div>",
"css": ".ds-empty { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; padding: 20px 16px; border: 1px dashed var(--border); border-radius: 0.45rem; font-family: 'Inter Variable', sans-serif; font-size: 0.8125rem; color: var(--foreground); } .ds-empty p { margin: 0; } .ds-empty-title { font-weight: 500; } .ds-empty-hint { color: var(--muted-foreground); }"
}
],
"narrative": {
"northStar": "The Live Ledger",
"overview": "The console is a ledger, not a dashboard. It opens on what the agents are doing right now, as rows of running turns and tool calls, and refuses the grid of metric cards (direction contract, ui/src/app.html). Everything is a row on a rail: hairline borders separate rows, tabular numerals line up columns, and small uppercase section headings name each ledger. Cards exist only as bordered notes for exceptions (an empty state, an error, a pending question, the login form), never as containers for other cards.\n\nThe palette is the msos mauve/pink theme on Inter, inherited verbatim from Kit's msos project (ui/src/routes/layout.css, :root and .dark). Two neutral layers do the spatial work: a slightly cooler, tinted sidebar layer for navigation and rails, and the content surface. One saturated accent (signal) is reserved for two meanings only: something is live right now, or something is selected. Every other hue is a status or kind hue from the msos status palette, used at low alpha as a tint and at full strength as text.\n\nDensity is that of an operator console: 13px body, 12px labels, 32px nav items, 8px row padding, 48px page header. Motion is limited to a soft pulse on live dots, a 220ms rise when a turn lands, and 150ms hover fades; everything collapses under prefers-reduced-motion.",
"keyCharacteristics": [
"Rows and rails with hairline borders; no cards inside cards",
"Tabular numerals on every number; right-aligned numeric columns",
"Uppercase 12px section headings in muted ink",
"Pink (signal) means live or selected, nothing else",
"Kind hues (master, branch, deep, job, fork) carried by low-alpha badges and text tints",
"Pill-shaped luma primitives (buttons, inputs, tabs) on top of small-radius application rows",
"Light and dark modes, both tinted toward hue 340; dark borders are 10% alpha overlays"
],
"rules": [
{ "name": "The Pink Ration Rule", "body": "signal marks live state (pulsing dots, running rows, busy sessions, the running count) and selection (active mobile nav, master kind). It never decorates a heading, a button, or a static surface. Hover is a 4% ink wash, not pink.", "section": "colors" },
{ "name": "The Twelve Percent Rule", "body": "Every colored chip is its hue at 12% alpha behind full-strength text of the same hue (kind-badge.svelte, sidebar running count). Solid colored chips do not exist.", "section": "colors" },
{ "name": "The Two Layers Rule", "body": "Navigation and rails sit on sidebar (or sidebar/50); content sits on background. There is no third surface color; depth beyond that is a hairline.", "section": "colors" },
{ "name": "The Tabular Rule", "body": "Every number - durations, token counts, money, percentages, timestamps, ids, byte sizes - carries the tabular utility (font-variant-numeric: tabular-nums, layout.css). Numeric table columns are right-aligned.", "section": "typography" },
{ "name": "The One Heading Rule", "body": "A page has one headline (the header) and any number of uppercase 12px section labels; there is no intermediate heading size. Section labels are h2 elements, not decorative kickers.", "section": "typography" },
{ "name": "The Flat Ledger Rule", "body": "Nothing inside the main column casts a shadow. Overlays (dialog, sheet, menu) and the login card are the only lifted surfaces.", "section": "elevation" }
],
"dos": [
"Do build lists as ledger-grid rows with border-b and a per-row column template; use a native table when there are more than four numeric columns.",
"Do put tabular on every number and right-align numeric columns.",
"Do start each section with the 12px uppercase muted h2 and stack sections with gap-8.",
"Do use signal only for live state (pulsing dots, running rows, running counts) and selection.",
"Do color kinds and statuses through kind-badge.svelte and status-pill.svelte; extend their maps rather than inventing new hue pairs.",
"Do give hover to interactive rows only, via row-hover; keep non-interactive rows still.",
"Do put secondary surfaces (rails, trees) on bg-sidebar/50 with a hairline, not on a card.",
"Do show every error as error-note.svelte with a retry and every empty list as empty-state.svelte with a one-line hint.",
"Do respect prefers-reduced-motion; new motion must be a CSS transition or one of animate-pulse-dot and animate-rise.",
"Do keep the luma primitives as shipped; compose them with size=\"sm\" and variant=\"outline\" or \"ghost\" for in-page actions."
],
"donts": [
"Don't put a card inside a card, or wrap ledger rows in a bordered container.",
"Don't add drop shadows to in-page surfaces; only overlays and the login card are lifted.",
"Don't use signal pink for headings, buttons, or static decoration; primary mulberry is the button color.",
"Don't introduce a heading size between the page headline (1rem) and the section label (0.75rem uppercase).",
"Don't render solid colored chips; chips are hue/12 behind hue text.",
"Don't use hue-chip, hairline, touch-shown or scrollbar-none from layout.css as if they were system rules - they are defined and currently unused by the admin.",
"Don't apply this palette inside Obsidian; the panel takes Obsidian's variables (PRODUCT.md brand commitment)."
]
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"mcpServers": {
"svelte": {
"type": "http",
"url": "https://mcp.svelte.dev/mcp"
}
}
}
+21 -7
View File
@@ -1,18 +1,32 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.13
rev: v0.15.20
hooks:
- id: ruff-check
types_or: [python, pyi]
args: [--fix]
types_or: [ python, pyi ]
args: [ --fix ]
- id: ruff-format
types_or: [python, pyi]
types_or: [ python, pyi ]
- repo: local
hooks:
- id: ty
name: ty check
entry: uvx ty check
language: python
types_or: [python, pyi]
entry: uv run ty check
language: system
types_or: [ python, pyi ]
pass_filenames: false
- id: ultracite
name: ultracite
entry: bash -c 'cd ui && bun x ultracite fix'
language: system
files: ^ui/
pass_filenames: false
- id: svelte-check
name: svelte check
entry: bash -c 'cd ui && bun check'
language: system
files: ^ui/
pass_filenames: false
+22 -17
View File
@@ -30,35 +30,40 @@ COPY . /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev --extra prod
# ---- UI: SvelteKit SPA built with bun ----
FROM oven/bun:1-slim AS ui
WORKDIR /ui
COPY ui/package.json ui/bun.lock ./
RUN bun install --frozen-lockfile
COPY ui ./
RUN bun --bun svelte-kit sync && bun --bun run build
# ---- Runtime ----
FROM python:3.13-slim AS runtime
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates git \
&& rm -rf /var/lib/apt/lists/*
# Bun native binary (glibc) — fast `npm install` replacement, only used
# to drop the claude CLI into the image. Not invoked at runtime.
# Bun native binary (glibc): `bunx` runs the stdio MCP servers declared in
# the user's config. The claude CLI itself ships inside the
# claude-agent-sdk wheel (`_bundled/claude`), nothing to install here.
COPY --from=oven/bun:1-slim /usr/local/bin/bun /usr/local/bin/bun
RUN ln -s /usr/local/bin/bun /usr/local/bin/bunx
# `--trust` is required: without it bun skips the postinstall step that
# fetches claude's native binary (anthropics/claude-code#50203). The
# postinstall itself is bun's smoke check — if it fails the layer
# fails. We deliberately DO NOT run `claude --version` here: claude
# touches `$HOME` on every invocation (creates `/root/.claude/`,
# `/root/.claude.json`, sometimes `/root/.config/claude/`), and those
# build-time artifacts seed the runtime named-volume `claude-home`
# with stale "haven't onboarded" state, so the user gets re-prompted
# for trust/bypass dialogs on every rebuild and the subscription auth
# can land on a tainted credential file.
ENV BUN_INSTALL=/usr/local/bun-global \
PATH=/usr/local/bun-global/bin:/app/.venv/bin:$PATH
RUN bun install -g --trust @anthropic-ai/claude-code \
&& test -x "$(command -v claude)" \
&& rm -rf /root/.claude /root/.claude.json /root/.config/claude
# The model process runs as `beaver-runner`, not as the gateway: the
# adapter spawns claude under this uid with a whitelisted environment.
# `acl` lets the entrypoint grant it write access to the vault sub-mounts
# without chowning files that Obsidian Sync keeps rewriting as root.
RUN apt-get update \
&& apt-get install -y --no-install-recommends acl \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --system --uid 1001 --create-home --shell /usr/sbin/nologin beaver-runner
ENV PATH=/app/.venv/bin:$PATH
COPY --from=builder /app/.venv /app/.venv
COPY --from=builder /app /app
COPY --from=ui /ui/build /app/ui/build
WORKDIR /app
ENTRYPOINT ["python", "-m"]
+31
View File
@@ -0,0 +1,31 @@
.PHONY: sync check fix run ui ui-build db db-down
sync:
uv sync --extra local
cd ui && bun install
check:
uv run ruff format --check
uv run ruff check
uv run ty check
cd ui && bun lint && bun check
fix:
uv run ruff format
uv run ruff check --fix
cd ui && bun fix
run:
uv run python -m beaver_gateway
ui:
cd ui && bun run dev
ui-build:
cd ui && bun run build
db:
docker compose --project-directory ../beaver-agent --profile db up -d postgres
db-down:
docker compose --project-directory ../beaver-agent --profile db down
+16 -21
View File
@@ -12,7 +12,7 @@ from beaver_gateway.agents.base import ExposedMcp
from beaver_gateway.agents.claude import ClaudeAgent
from beaver_gateway.agents.raycast import RaycastAgent, RemoteTool, UserPreferences
from beaver_gateway.core.registry import Gateway
from beaver_gateway.core.turn_record import TurnRecord, slugify
from beaver_gateway.core.turn_record import slugify
from beaver_gateway.frontends.admin import AdminFrontend
from beaver_gateway.frontends.anthropic import AnthropicMessagesFrontend
from beaver_gateway.frontends.markdown import MarkdownFrontend
@@ -20,20 +20,19 @@ from beaver_gateway.frontends.mcp_server import McpServerFrontend
from beaver_gateway.mcp.types import McpServer
def chat_log_path(record: TurnRecord, vault: Path) -> Path:
"""Decide where a logged chat from another frontend lands in the vault.
def chat_path(title: str, agent: str, vault: Path) -> Path: # noqa: ARG001
"""Where a new chat file lands in the vault.
Called by ``MarkdownFrontend`` (with ``log_all_chats=True``) the first
time a conversation needs a file — continuation turns are matched by
fingerprint and stick to the file picked here. Return value can be
Called by ``MarkdownFrontend`` for every conversation that needs a
file: a ``deep`` chat spawned by the dispatcher, a ``/v1/messages``
chat (``title`` = first user message), or with ``log_all_chats=True``
the archive of a stateless agent's turns. Return value can be
absolute or relative; relative paths are anchored under ``vault``.
Layout below: ``<vault>/<YYYY-MM>/<YYYY-MM-DD>_<topic>.md`` where
``topic`` is a slug of the very first user message in the chat.
Layout below: ``<vault>/<YYYY-MM>/<YYYY-MM-DD>_<topic>.md``.
"""
today = date.today()
topic = slugify(record.first_user_text, maxlen=40)
return vault / f"{today:%Y-%m}" / f"{today:%Y-%m-%d}_{topic}.md"
return vault / f"{today:%Y-%m}" / f"{today:%Y-%m-%d}_{slugify(title, maxlen=40)}.md"
def current_time() -> str:
@@ -208,17 +207,13 @@ gateway = Gateway(
vault_path=Path(tempfile.mkdtemp(prefix="beaver-vault-")).resolve(),
default_agent="research",
log_all_chats=True,
# ``log_path`` (optional) overrides the default
# ``{vault}/_logs/<agent>/<date>_<hex>.md`` layout for chats
# logged from OTHER frontends (Anthropic Messages, admin
# in-browser chat). Defined as a top-of-file function so the
# types are explicit and the IDE can hover them; a lambda
# works too, but a real ``def`` keeps the signature visible
# and lets you docstring it. Heads up: any custom path
# forces ``warm_index`` to scan the entire vault on startup
# so the fingerprint→file map survives a restart no matter
# where you put files.
log_path=chat_log_path,
# ``chat_path`` (optional) overrides the default
# ``{vault}/_logs/<agent>/<date>_<slug>.md`` layout. Heads up:
# any custom path forces ``warm_index`` to scan the entire
# vault on startup so the fingerprint→file map of archived
# stateless chats survives a restart no matter where you put
# files.
chat_path=chat_path,
),
],
)
+7 -7
View File
@@ -8,16 +8,18 @@ authors = [
requires-python = ">=3.13"
dependencies = [
"aiofile>=3.11.1",
"aiogram>=3.31.0",
"aiohttp>=3.13.5",
"aiosqlite>=0.22.1",
"anthropic>=0.103.0",
"anyio>=4.13.0",
"argon2-cffi>=25.1.0",
"claude-agent-sdk>=0.2.146",
"fastapi>=0.136.1",
"fastmcp>=3.3.1",
"greenlet>=3.5.0",
"itsdangerous>=2.2.0",
"jinja2>=3.1.6",
"psutil>=7.2.2",
"psycopg[binary]>=3.3.4",
"pydantic>=2.13.4",
"pydantic-settings>=2.14.1",
@@ -30,11 +32,9 @@ dependencies = [
[project.optional-dependencies]
local = [
"raycast-api",
"claude-code-api",
]
prod = [
"raycast-api",
"claude-code-api",
]
[tool.uv]
@@ -47,10 +47,6 @@ raycast-api = [
{ path = "../raycast-api", editable = true, extra = "local" },
{ git = "https://git.kotikot.com/beaver/raycast-api.git", extra = "prod" },
]
claude-code-api = [
{ path = "../claude-code-api", editable = true, extra = "local" },
{ git = "https://git.kotikot.com/beaver/claude-code-api.git", extra = "prod" },
]
[project.scripts]
beaver-gateway = "beaver_gateway:main"
@@ -67,3 +63,7 @@ dev = [
"ruff>=0.15.13",
"ty>=0.0.37",
]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
+8 -2
View File
@@ -2,7 +2,7 @@
``BaseAgent`` is the structural contract every backend-specific agent
honours. Subclasses add **capabilities as fields** (``ClaudeAgent.cwd``,
``RaycastAgent.streaming``) — backends dispatch on type, not on a runtime
``RaycastAgent.streaming``) - backends dispatch on type, not on a runtime
matrix.
"""
@@ -15,10 +15,16 @@ from pydantic import BaseModel, ConfigDict
@dataclass(frozen=True, slots=True)
class ExposedMcp:
"""Reference to an ``McpServer`` (by name) exposed to a single agent."""
"""Reference to an ``McpServer`` (by name) exposed to a single agent.
``tools`` is an allowlist of tool names (``None`` = every tool);
``deny`` is a tuple of ``fnmatch`` patterns removed on top of that,
e.g. ``("delete_*",)``.
"""
name: str
tools: tuple[str, ...] | None = None
deny: tuple[str, ...] = ()
class BaseAgent(BaseModel):
+77 -229
View File
@@ -1,258 +1,106 @@
"""Claude Code agent definition.
"""Claude agent definition, backed by the Claude Agent SDK.
There is no ``streaming`` field, but there is
:attr:`ClaudeCodeOptions.include_partial_messages`, and the difference
matters. Token-level deltas are not a property of the agent, they are a
property of the transport: the PTY transport reads a JSONL that only
ever contains finished blocks, so it cannot stream no matter what the
agent asks for, while ``transport="stream_json"`` can. The flag lives
next to the transport that grants it rather than pretending to be a
free-standing capability.
``BaseAgent.system_prompt`` maps onto the claude CLI's
``--system-prompt`` — i.e. it really *is* the agent's system prompt,
not "added on top of claude-code's giant built-in". The additive slot
``--append-system-prompt`` is exposed via
:attr:`ClaudeCodeOptions.append_system_prompt` for the rare case
when the user wants claude-code's planning conventions / dynamic
sections *and* a delta on top. Difference, measured empirically:
* tools (names, JSON schemas, embedded guidance like Bash's "prefer
Read over cat/head/tail") survive both flags — they ride the
Anthropic API ``tools=[]`` field, not the system prompt text;
* ``--system-prompt`` drops ~8.6k tokens of claude-code's *textual*
baseline — agent persona, multi-step-work conventions ("use
TaskCreate proactively", etc.), and the dynamic per-machine sections
(cwd, env info, git status, memory paths — see the
``--exclude-dynamic-system-prompt-sections`` flag note in
``claude --help``: "Only applies with the default system prompt
(ignored with --system-prompt)").
We pick override as the default because it preserves the principle of
least surprise: the ``system_prompt=...`` you wrote on the agent is
what claude actually receives. If you need claude-code's full
planning/dynamic-context behaviour on top of your prompt, opt in via
``options=ClaudeCodeOptions(append_system_prompt=...)`` and leave
``system_prompt`` for your agent's identity (or vice versa — set
``system_prompt=""``-ish and put everything in ``append_*``).
Per-agent passthrough of ``claude_code_api.BackendOptions`` lives in
:class:`ClaudeCodeOptions`, attached to :class:`ClaudeAgent` as
``options``. Every tunable knob ``BackendOptions`` supports — except
the ones derived from the agent's own primary surface
(``cwd`` / ``model`` / ``system_prompt`` / ``available_native_tools`` →
``allowed_tools`` / ``expose_mcps`` → ``mcp_servers``) — is exposed
there. Defaults match upstream except where correctness demands
otherwise (``wait_for_turn_duration=True``,
``dangerously_skip_permissions=True``).
The system prompt is ``system_prompt`` verbatim or, per conversation kind,
the granules named in ``prompts`` assembled at every session spawn (see
``core/prompt.py``). ``skill_sets`` are directories of ``<skill>/SKILL.md``
folders; each becomes a local SDK plugin. Nothing from disk is loaded
otherwise: the adapter runs with ``setting_sources=[]``.
"""
from __future__ import annotations
from collections.abc import Mapping # noqa: TC003 — pydantic runtime
from pathlib import Path # noqa: TC003 — pydantic runtime
from typing import Literal
from collections.abc import Mapping # noqa: TC003 - pydantic runtime
from pathlib import Path # noqa: TC003 - pydantic runtime
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, model_validator
from beaver_gateway.agents.base import BaseAgent
from beaver_gateway.core.kinds import KINDS, Kind
from beaver_gateway.core.prompt import PromptSource # noqa: TC001 - pydantic runtime
HistoryInjectionMode = Literal["native_jsonl", "concat_message"]
"""Mirrors ``claude_code_api.HistoryInjectionMode`` to avoid an import
cycle on the user-facing path (configs may be loaded without
``claude-code-api`` installed, e.g. ``--extra prod`` minus claude)."""
Transport = Literal["pty", "stream_json"]
"""Mirrors ``claude_code_api.Transport``, for the same reason."""
__all__ = ["ClaudeAgent", "ClaudeOptions", "Prompts"]
class ClaudeCodeOptions(BaseModel):
"""Per-agent passthrough for ``claude_code_api.BackendOptions``.
Mirrors every field of ``BackendOptions`` except those that come
from the agent's primary surface
(``cwd`` / ``model`` / ``system_prompt`` /
``available_native_tools`` / ``expose_mcps``). Defaults match
upstream, with two exceptions for correctness:
* ``wait_for_turn_duration=True`` — without it, extended-thinking
turns drop the text response because ``TurnManager`` returns on
the first terminal assistant record (the thinking snapshot) and
never reads the second one (the actual text). Phase 2.2 PROGRESS
describes the incident in detail.
* ``dangerously_skip_permissions=True`` — Beaver Gateway is a
single-user trusted-config product; the user already wrote the
``config.py`` that spawns claude. Permission prompts on a
headless backend mean stuck turns.
Any field on :class:`claude_code_api.BackendOptions` we forward
here lives on this model with the same name and type, so a future
``BackendOptions`` field addition is a one-liner here plus a
one-liner in :mod:`backends.claude_code`.
"""
class ClaudeOptions(BaseModel):
model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
transport: Transport = "pty"
"""Which claude process backs this agent.
``stream_json`` runs ``claude -p`` with stream-json on stdin and
stdout: prompts go into a pipe, events come back as they happen, and
a native ``result`` record closes each turn. ``pty`` (the default,
kept for backwards compatibility) drives the interactive TUI and
tails the session JSONL, which costs a multi-second readiness wait
per spawn — the thing ``startup_delay`` bounds — and brings the
swallowed-paste failure mode with it.
Switching also makes the PTY-only knobs below inert
(``startup_delay`` / ``file_wait_timeout`` /
``turn_duration_timeout`` / ``wait_for_turn_duration``) and drops
interactive-only ``extra_args`` such as ``--remote-control``, which
would take claude out of print mode. Everything else — model,
prompts, tools, MCPs, history seeding — behaves identically."""
warmup_turn: bool | None = None
"""Spend a throwaway turn when a session spawns, so MCP tools are
live on the agent's first reply.
``None`` (the default) decides per agent: on when it exposes any
MCP, off when it doesn't. Set it explicitly to override.
It exists because the CLI begins connecting MCP servers when a turn
starts and doesn't wait for them — measured on the pi, turn one saw
29 tools with both servers ``pending``, turn two saw 90 with both
``connected``. For an agent whose whole job is those tools, a first
reply without them is worse than a couple of extra seconds. Nothing
cheaper works: sleeping longer changes nothing, and neither a
``/status`` command nor the SDK's ``initialize`` handshake touches
the MCP client.
``stream_json`` only — the PTY transport's readiness wait covers
this incidentally."""
include_partial_messages: bool = False
"""Stream token-level deltas to the client instead of one delta per
finished block. Requires ``transport="stream_json"``; inert on the
PTY transport, whose JSONL contains only finished blocks. Off by
default because it multiplies the SSE event count — worth it for
interactive chat, pointless for a client that waits for the whole
message anyway."""
append_system_prompt: str | None = None
"""Maps to claude CLI's ``--append-system-prompt``. Opting in
re-attaches claude-code's full built-in prompt (persona, planning
conventions like "use TaskCreate proactively", and the dynamic
per-machine sections — cwd, env info, git status, memory paths)
plus this string on top. By default we ship only
:attr:`BaseAgent.system_prompt` via ``--system-prompt`` (~8.6k
tokens lighter); use this when the agent should behave like a
real claude-code coding session and your text is a delta on top
of those built-ins. Tool schemas survive either way — they ride
the Anthropic ``tools=[]`` channel, not the prompt text."""
disallowed_tools: tuple[str, ...] = ()
"""Tool names that claude must refuse to call. Combines with the
agent's ``available_native_tools`` allowlist — disallow wins."""
effort: str | None = None
include_partial_messages: bool = True
"""Token-level deltas on the wire; off means one delta per finished block."""
permission_mode: str = "bypassPermissions"
"""``claude --permission-mode`` value. Only meaningful when
``dangerously_skip_permissions=False``; otherwise the CLI bypasses
the prompt path entirely."""
tools: tuple[str, ...] | None = None
"""Base set of built-in tools; ``None`` keeps the CLI default set."""
dangerously_skip_permissions: bool = True
"""Pass ``--dangerously-skip-permissions`` to claude. ``True`` by
default — see the class docstring for why."""
disallowed_tools: tuple[str, ...] = ()
add_dirs: tuple[str, ...] = ()
env: Mapping[str, str] = Field(default_factory=dict)
"""Extra environment for the claude subprocess (always passed through)."""
effort: str | None = None
"""``claude --effort`` value (typically ``low`` / ``medium`` /
``high``). Tunes reasoning effort budget for newer models."""
env_keep: tuple[str, ...] = ()
"""Extra inherited variable names to let through the env whitelist."""
add_dir: tuple[str, ...] = ()
"""Extra directories claude is allowed to read/edit beyond
``cwd``. Maps to repeated ``--add-dir`` flags."""
max_turns: int | None = None
session_store_flush: str = "eager"
"""``eager`` mirrors every transcript frame as it lands, so a gateway
killed mid-turn loses at most the frame in flight."""
settings: str | None = None
"""Path to a ``--settings`` JSON file claude should load (hooks,
MCP servers, etc. that don't belong in our internal aggregator)."""
extra_args: tuple[str, ...] = ()
"""Raw extra argv to append to the claude command. Escape hatch
for flags we haven't surfaced as first-class options."""
class Prompts(BaseModel):
"""Prompt assembly per conversation kind (§3.12): the granules, in order.
extra_env: Mapping[str, str] = Field(default_factory=dict)
"""Additional environment variables for the spawned claude
process. Layered on top of the gateway's own env after
``preserve_provider_env`` is applied."""
A kind left ``None`` is not served by the agent; ``ClaudeAgent.kinds``
follows from the kinds set here.
"""
preserve_provider_env: bool = False
"""When ``False`` (default), the spawn env strips
``ANTHROPIC_API_KEY`` / ``ANTHROPIC_AUTH_TOKEN`` /
``ANTHROPIC_BASE_URL`` so claude uses subscription auth instead of
leaking through whatever the gateway process inherited."""
model_config = ConfigDict(frozen=True)
history_injection_mode: HistoryInjectionMode = "native_jsonl"
"""How prior turns are seeded into a fresh session when no live
session matches: ``native_jsonl`` writes a hand-crafted transcript
and ``--resume``s; ``concat_message`` instead folds history into
the first user prompt. ``native_jsonl`` is more faithful."""
master: tuple[PromptSource, ...] | None = None
branch: tuple[PromptSource, ...] | None = None
deep: tuple[PromptSource, ...] | None = None
job: tuple[PromptSource, ...] | None = None
fork: tuple[PromptSource, ...] | None = None
wait_for_turn_duration: bool = True
"""Keep reading JSONL until the ``turn_duration`` heartbeat
arrives, instead of returning on the first terminal assistant
record. ``True`` by default — see class docstring."""
def for_kind(self, kind: Kind) -> tuple[PromptSource, ...] | None:
return getattr(self, kind)
include_meta_user: bool = False
"""Surface claude's ``isMeta=True`` user records (local-command
caveats) as ``UserMessage`` events. Off by default — they're not
part of the real conversation."""
startup_delay: float = 60.0
"""Upper bound (seconds) on waiting for claude's TUI to enable
bracketed-paste mode after spawn. claude-code-api polls PTY output
for the DECSET 2004 marker and returns as soon as it arrives, so a
generous cap costs nothing on a warm host — it only bounds the case
where the marker never shows.
It has to be generous, because timing out here means writing the
prompt into a terminal that hasn't enabled bracketed paste, where our
``ESC[200~`` framing is parsed as junk and the message is silently
dropped. Observed on a cold Raspberry Pi: 10s elapsed with *zero*
bytes of PTY output, the prompt went into the void, and the turn hung
until a human typed into the session by hand."""
file_wait_timeout: float = 30.0
"""How long to wait for the session JSONL to appear after spawn.
Failure here usually means a CLI auth / config problem."""
turn_duration_timeout: float = 5.0
"""How long to wait for the ``turn_duration`` heartbeat once a
terminal assistant has been seen. Bound on extra latency when
``wait_for_turn_duration=True``."""
idle_session_ttl: float = 1800.0
"""Seconds a pooled claude session may sit unused before it is
terminated. The backend pools live PTYs by conversation-history
fingerprint; when a conversation forks, the session behind the old
fingerprint becomes unreachable but stays resident — one live
``claude`` (hundreds of MB) each. 30 minutes keeps an active chat
warm across a coffee break while bounding what a fork storm can
strand. There is no count cap: concurrent conversations should all
stay warm. ``0`` disables reaping."""
@property
def kinds(self) -> tuple[Kind, ...]:
return tuple(k for k in KINDS if self.for_kind(k) is not None)
class ClaudeAgent(BaseAgent):
"""Agent backed by ``claude-code-api``.
``cwd`` is the working directory the claude CLI is spawned in;
typed as ``Path`` (Pydantic coerces strings) since claude-code-api
accepts ``str | os.PathLike[str]``. ``available_native_tools`` stays
``tuple[str, ...]`` because Claude Code's tool set is a moving target
(Bash/Read/Edit/Write/WebSearch/etc. — versions add and rename), so
pinning it as a ``Literal`` would rot the type each release.
"""
cwd: Path
available_native_tools: tuple[str, ...] = ()
options: ClaudeCodeOptions = Field(default_factory=ClaudeCodeOptions)
"""Per-agent passthrough for the underlying claude-code-api
``BackendOptions``. See :class:`ClaudeCodeOptions`."""
system_prompt: str = ""
"""Verbatim prompt for agents without ``prompts`` (tests, one-offs)."""
prompts: Prompts = Field(default_factory=Prompts)
kinds: tuple[Kind, ...] = ()
"""Conversation kinds this agent serves; ``create``/``spawn`` reject the
rest. Defaults to the kinds ``prompts`` covers, or ``("deep",)`` for a
verbatim ``system_prompt``."""
skill_sets: tuple[Path, ...] = ()
gateway_tools: tuple[str, ...] = ()
"""Gateway tools exposed in-process (``read_conversation``, ``spawn``,
``say``, ``schedule``, ``inject``); empty = no gateway MCP server."""
options: ClaudeOptions = Field(default_factory=ClaudeOptions)
@model_validator(mode="after")
def _kinds_follow_prompts(self) -> ClaudeAgent:
covered = self.prompts.kinds
if not self.kinds:
object.__setattr__(self, "kinds", covered or ("deep",))
elif covered:
missing = [k for k in self.kinds if k not in covered]
if missing:
msg = f"agent {self.name!r} serves {missing} without a prompt"
raise ValueError(msg)
return self
def prompt_for(self, kind: Kind) -> tuple[PromptSource, ...] | None:
return self.prompts.for_kind(kind)
def serves(self, kind: str) -> bool:
return kind in self.kinds
+1 -1
View File
@@ -1,6 +1,6 @@
"""Backend adapters.
Each backend wraps a provider-specific SDK (``raycast-api``, ``claude-code-api``)
Each backend wraps a provider SDK (``raycast-api``, ``claude-agent-sdk``)
and yields the unified :class:`~beaver_gateway.core.events.MessageStreamEvent`
family. The Anthropic-style frontend serialises events straight to SSE.
"""
+9 -2
View File
@@ -5,8 +5,15 @@ into a stream of :class:`~beaver_gateway.core.events.MessageStreamEvent`
records. The frontend serializes whatever comes out straight to SSE, so
backends are the only place where provider quirks are translated.
Implementations are plain :class:`typing.Protocol` conformers — no ABC
subclassing — to keep them swappable in tests with bare async generators.
Implementations are plain :class:`typing.Protocol` conformers - no ABC
subclassing - to keep them swappable in tests with bare async generators.
``**options`` is the one extension point. Known keys, all optional and
ignored by backends that don't keep state: ``conversation_id`` (stable id
the backend may pin a live session to), ``session_id`` (backend session to
resume when nothing is live), ``capture`` (a
:class:`~beaver_gateway.core.turn_capture.TurnCapture` the backend fills
after the stream closes).
"""
from __future__ import annotations
-502
View File
@@ -1,502 +0,0 @@
"""Claude Code backend adapter.
One :class:`ClaudeCodeBackendAdapter` per :class:`ClaudeAgent`. The
underlying :class:`claude_code_api.ClaudeCodeBackend` bakes ``cwd`` /
``model`` / ``system_prompt`` / MCP wiring into a single
:class:`~claude_code_api.BackendOptions` at construction time, so a
single backend instance is conceptually bound to one agent (different
agents would mean different cwds / system prompts / exposed MCPs and
thus different live-session pools).
Per :meth:`complete` we:
* hand the full Anthropic-style ``messages`` list to
``ClaudeCodeBackend.complete`` — it does its own fingerprint-based
session lookup, so we never need to track sessions ourselves;
* turn its events into one ``message_start`` … ``message_stop``
envelope per ``complete`` call, with content-block indices increasing
monotonically across the whole turn;
* close the envelope on the ``ResultMessage``.
How the blocks inside that envelope are produced depends on the agent's
transport. By default each ``AssistantMessage`` is re-emitted as
``content_block_start`` + one delta + ``content_block_stop`` per block —
a "stream" of finished blocks, because the PTY transport has nothing
finer to offer. With ``transport="stream_json"`` *and*
``include_partial_messages``, the backend instead feeds us the real
token-level ``StreamEvent``s and we emit from those; the whole-block
records still arrive and are still kept for :class:`TurnCapture`, but
emitting from both would duplicate every block on the wire.
The per-request ``system`` parameter is intentionally **ignored** —
``BackendOptions.system_prompt`` is fixed at session-spawn time, and the
agent's ``system_prompt`` is the canonical identity of the agent.
"""
from __future__ import annotations
import json
import logging
import uuid
from collections.abc import Mapping # runtime import: isinstance in _emit_stream_event
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Self
from claude_code_api import (
AssistantMessage,
BackendOptions,
ClaudeCodeBackend,
ResultMessage,
StreamEvent,
TextBlock,
ThinkingBlock,
ToolUseBlock,
synthesize_turn_messages,
)
from beaver_gateway.agents.claude import ClaudeAgent
from beaver_gateway.core.events import (
StopReason,
build_content_block_stop,
build_input_json_delta,
build_message_delta,
build_message_start,
build_message_stop,
build_signature_delta,
build_text_block_start,
build_text_delta,
build_thinking_block_start,
build_thinking_delta,
build_tool_use_block_start,
)
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Iterable
from anthropic.types import MessageParam
from beaver_gateway.agents.base import BaseAgent
from beaver_gateway.core.events import MessageStreamEvent
_log = logging.getLogger("beaver_gateway.backends.claude_code")
__all__ = ["ClaudeCodeBackendAdapter", "TurnCapture"]
@dataclass
class TurnCapture:
"""Side-channel sink for per-turn metadata.
Pass an instance via ``ClaudeCodeBackendAdapter.complete(capture=...)``.
After the stream finishes, :attr:`synthesized_messages` holds the
full assistant↔tool-result cycle (from
:func:`claude_code_api.synthesize_turn_messages`) — i.e. the exact
list of canonical Anthropic-shape messages claude-code-api stashed
the live session under. The markdown frontend uses this to write the
conversation history to its DB so a subsequent turn's prefix
fingerprint hits the same session.
Other backends (anthropic, raycast) ignore the kwarg — it lands in
their ``**options`` and is silently dropped.
"""
synthesized_messages: list[dict[str, Any]] = field(default_factory=list)
_CLAUDE_TO_ANTHROPIC_STOP: dict[str, StopReason] = {
"end_turn": "end_turn",
"tool_use": "tool_use",
"max_tokens": "max_tokens",
"stop_sequence": "stop_sequence",
"refusal": "refusal",
}
def _map_stop_reason(raw: str | None) -> StopReason:
"""Map claude-code's stop reason into Anthropic's vocabulary.
Unknown / missing values collapse to ``end_turn`` so the client sees
a clean finish rather than a wire-format error.
"""
if raw is None:
return "end_turn"
return _CLAUDE_TO_ANTHROPIC_STOP.get(raw, "end_turn")
def _build_mcp_servers(
agent: ClaudeAgent, mcp_internal_urls: Mapping[str, str]
) -> dict[str, dict[str, Any]] | None:
"""Render ``agent.expose_mcps`` into ``BackendOptions.mcp_servers``.
Each exposed MCP is a streamable-HTTP pointer at the gateway's
internal aggregator (built by :mod:`beaver_gateway.mcp.internal_app`).
``None`` keeps claude-code from materializing an ``--mcp-config``
file when the agent exposes nothing.
"""
if not agent.expose_mcps:
return None
servers: dict[str, dict[str, Any]] = {}
for em in agent.expose_mcps:
url = mcp_internal_urls.get(em.name)
if url is None:
msg = (
f"agent {agent.name!r} exposes MCP {em.name!r} "
"but no internal URL is registered for it"
)
raise ValueError(msg)
servers[em.name] = {"type": "http", "url": url}
return servers
def _build_backend_options(
agent: ClaudeAgent, mcp_internal_urls: Mapping[str, str]
) -> BackendOptions:
"""Compose the per-agent :class:`BackendOptions`.
Agent-primary fields:
* ``cwd`` / ``model`` come from the agent directly;
* ``system_prompt`` carries :attr:`BaseAgent.system_prompt`
verbatim — i.e. wire-level ``--system-prompt`` (~8.6k tokens
lighter than ``--append-system-prompt`` because claude-code's
persona/planning conventions and dynamic sections drop out;
tool schemas survive via the API ``tools=[]`` channel);
* ``append_system_prompt`` carries
:attr:`ClaudeCodeOptions.append_system_prompt`, normally
``None``. Setting it re-attaches claude-code's built-in prompt
*and* this delta — opt-in for "claude as a real coding session";
* ``allowed_tools`` follows the PLAN: when the user lists native
tools we restrict to those *plus* a per-MCP wildcard so MCP tools
stay reachable; when no native list is declared we leave
``allowed_tools`` empty (= all tools allowed by claude-code's
default);
* ``mcp_servers`` comes from :func:`_build_mcp_servers`.
Every other tunable knob is passed through from
:attr:`ClaudeAgent.options`. Our default overrides
(``wait_for_turn_duration=True``,
``dangerously_skip_permissions=True``) live on
:class:`ClaudeCodeOptions`, not here, so a user who builds
``ClaudeCodeOptions(...)`` explicitly inherits the same defaults
instead of getting whatever claude-code-api ships.
"""
allowed_tools: tuple[str, ...] = ()
if agent.available_native_tools:
mcp_wildcards = tuple(f"mcp__{em.name}" for em in agent.expose_mcps)
allowed_tools = tuple(agent.available_native_tools) + mcp_wildcards
opt = agent.options
return BackendOptions(
cwd=agent.cwd,
model=agent.model or None,
system_prompt=agent.system_prompt,
append_system_prompt=opt.append_system_prompt,
allowed_tools=allowed_tools,
mcp_servers=_build_mcp_servers(agent, mcp_internal_urls),
transport=opt.transport,
include_partial_messages=opt.include_partial_messages,
# ``None`` means "decide from the agent": an agent that exposes
# MCPs needs them on its first reply, one that doesn't shouldn't
# pay for a warm-up it gains nothing from.
warmup_turn=(
bool(agent.expose_mcps) if opt.warmup_turn is None else opt.warmup_turn
),
disallowed_tools=opt.disallowed_tools,
permission_mode=opt.permission_mode,
dangerously_skip_permissions=opt.dangerously_skip_permissions,
effort=opt.effort,
add_dir=opt.add_dir,
settings=opt.settings,
extra_args=opt.extra_args,
extra_env=opt.extra_env,
preserve_provider_env=opt.preserve_provider_env,
history_injection_mode=opt.history_injection_mode,
wait_for_turn_duration=opt.wait_for_turn_duration,
include_meta_user=opt.include_meta_user,
startup_delay=opt.startup_delay,
file_wait_timeout=opt.file_wait_timeout,
turn_duration_timeout=opt.turn_duration_timeout,
idle_session_ttl=opt.idle_session_ttl,
)
class ClaudeCodeBackendAdapter:
"""One ``claude-code-api`` backend bound to a single :class:`ClaudeAgent`.
Owns the underlying :class:`ClaudeCodeBackend`'s lifecycle through
the async-context-manager protocol so :mod:`beaver_gateway.cli` can
park it in its ``AsyncExitStack``.
"""
def __init__(
self, *, agent: ClaudeAgent, mcp_internal_urls: Mapping[str, str]
) -> None:
self._agent = agent
options = _build_backend_options(agent, mcp_internal_urls)
self._backend = ClaudeCodeBackend(options)
self._streaming = (
options.transport == "stream_json" and options.include_partial_messages
)
"""Whether the backend feeds us token-level ``StreamEvent``s.
When it does, blocks are emitted from those and the whole-block
``AssistantMessage`` records are used only for bookkeeping —
emitting from both would duplicate every block on the wire."""
@property
def agent(self) -> ClaudeAgent:
return self._agent
@property
def live_session_count(self) -> int:
return self._backend.live_session_count
@property
def live_sessions(self) -> dict[str, Any]:
"""Live claude processes keyed by claude session_id.
Pass-through to the underlying ``ClaudeCodeBackend``. The value
is a ``PtyClaudeProcess`` or a ``StreamClaudeProcess`` depending
on the agent's transport; both implement the
``captured_output()`` / ``add_output_listener`` / ``write``
surface the admin terminal consumes, so it works either way —
with the caveat that the stream transport's "terminal" is a
read-only view of the JSON event stream rather than a TUI you
can type into. Typed as ``Any`` to avoid leaking the lower
layer's type into the gateway's public surface.
"""
return self._backend.live_sessions
async def __aenter__(self) -> Self:
await self._backend.__aenter__()
return self
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
await self._backend.__aexit__(exc_type, exc, tb)
async def aclose(self) -> None:
await self._backend.aclose()
async def complete(
self,
*,
agent: BaseAgent,
messages: Iterable[MessageParam],
system: str | None = None, # noqa: ARG002 — see module docstring
capture: TurnCapture | None = None,
**options: Any, # noqa: ARG002 — no per-request knobs for claude-code yet
) -> AsyncIterator[MessageStreamEvent]:
if not isinstance(agent, ClaudeAgent):
msg = (
"ClaudeCodeBackendAdapter requires ClaudeAgent, "
f"got {type(agent).__name__}"
)
raise TypeError(msg)
if agent.name != self._agent.name:
# Adapter is per-agent; routing a different agent through it
# would mean a different cwd / system_prompt / MCP set than
# the live-session pool was spawned with.
msg = (
f"ClaudeCodeBackendAdapter bound to {self._agent.name!r} "
f"got request for {agent.name!r}"
)
raise ValueError(msg)
msgs_list: list[Mapping[str, Any]] = list(messages)
_log.info(
"complete: agent=%s n_messages=%d capture=%s live_sessions=%d",
agent.name,
len(msgs_list),
capture is not None,
self._backend.live_session_count,
)
message_id = f"msg_{uuid.uuid4().hex}"
yield build_message_start(message_id=message_id, model=agent.model)
next_index = 0
stop_reason: str | None = None
usage: Mapping[str, Any] | None = None
n_text = 0
n_thinking = 0
n_tool_use = 0
# We keep raw events so we can hand them to
# ``synthesize_turn_messages`` after the stream closes — the
# markdown frontend stores the result in its conversation
# history so the next turn's prefix matches the backend's
# session-pool fingerprint. UserMessage (tool_result) events
# are silently discarded from the wire but kept here.
raw_events: list[Any] = []
async for event in self._backend.complete(msgs_list):
raw_events.append(event)
if isinstance(event, StreamEvent):
# Only ever arrives when ``self._streaming`` — the
# backend doesn't emit these otherwise. Indices are
# already rebased onto the whole turn by claude-code-api.
for ev in _emit_stream_event(event.event):
yield ev
elif isinstance(event, AssistantMessage):
for block in event.content:
if isinstance(block, TextBlock):
n_text += 1
elif isinstance(block, ThinkingBlock):
n_thinking += 1
elif isinstance(block, ToolUseBlock):
n_tool_use += 1
if not self._streaming:
for ev in _emit_block(block, next_index):
yield ev
next_index += 1
elif isinstance(event, ResultMessage):
# ResultMessage is the terminal event from TurnManager
# — we capture its stop_reason / usage for the envelope
# below. We DO NOT break here: an early break would
# raise GeneratorExit inside claude-code-api's
# ``complete`` coroutine before it gets a chance to
# stash the live session under the post-turn
# fingerprint, so every continuation would miss the
# cache and reseed. Let the inner generator exit
# naturally instead.
stop_reason = event.stop_reason
usage = event.usage
# UserMessage (tool_result records) and SystemMessage
# (turn_duration heartbeats) carry no content for the
# /v1/messages caller — skip silently on the wire, but they
# ARE retained in ``raw_events`` for synthesis below.
if capture is not None:
capture.synthesized_messages = synthesize_turn_messages(raw_events)
_log.info(
"complete: agent=%s DONE text=%d thinking=%d tool_use=%d stop=%s synth=%d",
agent.name,
n_text,
n_thinking,
n_tool_use,
stop_reason,
len(capture.synthesized_messages) if capture is not None else 0,
)
yield build_message_delta(
stop_reason=_map_stop_reason(stop_reason), usage=_normalize_usage(usage)
)
yield build_message_stop()
def _emit_stream_event(event: Mapping[str, Any]) -> Iterable[MessageStreamEvent]:
"""Render one raw Anthropic streaming event through our own builders.
The payload is already Anthropic-shaped, so forwarding it verbatim
is tempting — but it arrives as a plain dict and the frontend
serializes pydantic models, so it would have to be validated against
the SDK's ``RawMessageStreamEvent`` union anyway. Rebuilding it from
the fields we recognize is the same amount of work and fails the
same way :func:`_emit_block` already does: a block or delta type
this gateway doesn't know is dropped rather than raising mid-stream
on a claude release that added one.
Only ``content_block_*`` events reach here — claude-code-api strips
the inner per-request message envelopes, because the caller owns the
single envelope spanning the whole turn.
"""
etype = event.get("type")
index = event.get("index")
if not isinstance(index, int):
return ()
if etype == "content_block_start":
block = event.get("content_block")
if not isinstance(block, Mapping):
return ()
btype = block.get("type")
if btype == "text":
return (build_text_block_start(index),)
if btype == "thinking":
return (build_thinking_block_start(index),)
if btype == "tool_use":
return (
build_tool_use_block_start(
index,
tool_use_id=str(block.get("id", "")),
name=str(block.get("name", "")),
),
)
return ()
if etype == "content_block_delta":
delta = event.get("delta")
if not isinstance(delta, Mapping):
return ()
dtype = delta.get("type")
if dtype == "text_delta":
return (build_text_delta(index, str(delta.get("text", ""))),)
if dtype == "thinking_delta":
return (build_thinking_delta(index, str(delta.get("thinking", ""))),)
if dtype == "signature_delta":
return (build_signature_delta(index, str(delta.get("signature", ""))),)
if dtype == "input_json_delta":
return (build_input_json_delta(index, str(delta.get("partial_json", ""))),)
return ()
if etype == "content_block_stop":
return (build_content_block_stop(index),)
return ()
def _emit_block(
block: TextBlock | ThinkingBlock | ToolUseBlock | Any, index: int
) -> Iterable[MessageStreamEvent]:
"""Render one ``claude-code`` content block as Anthropic stream events.
``ToolResultBlock`` would arrive only on user-role records — we
don't emit it here because :meth:`complete` skips ``UserMessage``.
"""
if isinstance(block, TextBlock):
return (
build_text_block_start(index),
build_text_delta(index, block.text),
build_content_block_stop(index),
)
if isinstance(block, ThinkingBlock):
return (
build_thinking_block_start(index),
build_thinking_delta(index, block.thinking),
build_signature_delta(index, block.signature),
build_content_block_stop(index),
)
if isinstance(block, ToolUseBlock):
partial = json.dumps(block.input, separators=(",", ":"), ensure_ascii=False)
return (
build_tool_use_block_start(index, tool_use_id=block.id, name=block.name),
build_input_json_delta(index, partial),
build_content_block_stop(index),
)
return ()
def _normalize_usage(usage: Mapping[str, Any] | None) -> dict[str, int] | None:
"""Coerce claude-code's ``usage`` dict to Anthropic ``MessageDeltaUsage`` shape.
claude-code copies whatever the JSONL ``usage`` record carried —
fields can be missing, strings, or ints. We pass through only the
fields ``MessageDeltaUsage`` knows about and discard the rest so an
odd ``cache_creation`` object structure doesn't fail pydantic
validation downstream.
"""
if not usage:
return None
out: dict[str, int] = {}
for key in (
"input_tokens",
"output_tokens",
"cache_creation_input_tokens",
"cache_read_input_tokens",
):
value = usage.get(key)
if isinstance(value, int):
out[key] = value
return out or None
+930
View File
@@ -0,0 +1,930 @@
"""Claude Agent SDK backend adapter.
One :class:`ClaudeSdkBackend` per :class:`ClaudeAgent`. A live session is
one ``ClaudeSDKClient`` (one claude subprocess) and runs one turn at a
time; the sessions of every agent live in one shared
:class:`~beaver_gateway.core.sessions.SessionPool` that owns TTL and
memory-pressure eviction. Sessions are keyed by ``conversation_id`` when
the caller passes one or by a text-only fingerprint of ``messages[:-1]``
for stateless callers (``/v1/messages``). Without a live session the
adapter resumes ``session_id`` from the session store (after closing any
``tool_use`` left open by a crash), or seeds the incoming history into the
store via ``core/transcript`` and resumes that.
Per-turn ``**options`` beyond the protocol's: ``kind`` (conversation kind,
picks the prompt assembly and the pool TTL), ``pinned`` (never evicted),
``tools=False`` (no MCP at all - forks and jobs), ``observer`` (callback
receiving every raw SDK message, subagent ones included), ``turn_id``.
Events on the wire are the Anthropic ``MessageStreamEvent`` family: one
``message_start``/``message_stop`` envelope per turn, block indices
rebased across the API calls claude makes inside the turn.
Process isolation: claude is spawned through a small exec wrapper that
drops every inherited environment variable outside a whitelist and, when
``RunnerConfig.user`` is set, switches to that uid before exec (done in the
wrapper rather than via ``subprocess(user=...)``, which uvloop rejects).
"""
from __future__ import annotations
import asyncio
import contextlib
import fnmatch
import hashlib
import json
import logging
import os
import pwd
import shutil
import sys
import tempfile
import time
import uuid
import warnings
from collections.abc import Mapping
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any, Self, cast
import claude_agent_sdk
from claude_agent_sdk import (
AssistantMessage,
CanUseToolShadowedWarning,
ClaudeAgentOptions,
ClaudeSDKClient,
MirrorErrorMessage,
PermissionResultAllow,
PermissionResultDeny,
ResultMessage,
StreamEvent,
TextBlock,
ThinkingBlock,
ToolResultBlock,
ToolUseBlock,
UserMessage,
project_key_for_directory,
)
from beaver_gateway.core import prompt as prompt_assembly
from beaver_gateway.core.events import (
StopReason,
build_content_block_stop,
build_input_json_delta,
build_message_delta,
build_message_start,
build_message_stop,
build_signature_delta,
build_text_block_start,
build_text_delta,
build_thinking_block_start,
build_thinking_delta,
build_tool_use_block_start,
)
from beaver_gateway.core.kinds import as_kind
from beaver_gateway.core.sessions import Session, SessionClient, SessionPool
from beaver_gateway.core.transcript import (
build_entries,
close_open_tool_uses,
fingerprint,
text_of,
)
from beaver_gateway.core.turn_capture import TurnCapture, TurnUsage
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Sequence
from anthropic.types import MessageParam
from claude_agent_sdk import (
McpSdkServerConfig,
PermissionResult,
SessionStore,
ToolPermissionContext,
)
from beaver_gateway.agents.base import BaseAgent
from beaver_gateway.agents.claude import ClaudeAgent
from beaver_gateway.core.events import MessageStreamEvent
_log = logging.getLogger("beaver_gateway.backends.claude_sdk")
# §3.7: in bypass the callback only ever sees AskUserQuestion, and that is
# exactly the one we want - the SDK's warning about the rest is noise here.
warnings.filterwarnings("ignore", category=CanUseToolShadowedWarning)
ASK_TOOL = "AskUserQuestion"
__all__ = [
"ClaudeSdkBackend",
"RunnerConfig",
"SessionClient",
"ToolServerFactory",
"UsageSink",
"fingerprint",
]
ENV_KEEP: tuple[str, ...] = (
"PATH",
"HOME",
"LANG",
"LANGUAGE",
"LC_ALL",
"LC_CTYPE",
"TZ",
"TERM",
"USER",
"LOGNAME",
"SHELL",
"TMPDIR",
"PWD",
"SSL_CERT_FILE",
"SSL_CERT_DIR",
"NODE_EXTRA_CA_CERTS",
"NODE_OPTIONS",
"IS_SANDBOX",
"HTTP_PROXY",
"HTTPS_PROXY",
"NO_PROXY",
"http_proxy",
"https_proxy",
"no_proxy",
)
ENV_KEEP_PREFIXES: tuple[str, ...] = ("CLAUDE_", "ANTHROPIC_", "DISABLE_")
_STOP_REASONS: dict[str, StopReason] = {
"end_turn": "end_turn",
"tool_use": "tool_use",
"max_tokens": "max_tokens",
"stop_sequence": "stop_sequence",
"refusal": "refusal",
"pause_turn": "pause_turn",
}
ClientFactory = "Callable[[ClaudeAgentOptions], SessionClient]"
UsageSink = "Callable[[UsageEvent], Awaitable[None]]"
ToolServerFactory = "Callable[[str, str], McpSdkServerConfig | None]"
"""``(conversation_key, kind) -> in-process MCP server config`` or ``None``."""
Asker = "Callable[[str, dict[str, Any]], Awaitable[str]]"
"""``(conversation_key, AskUserQuestion input) -> text the model reads as the
tool result``. The only channel an answer has in bypass mode is
``PermissionResultDeny.message`` (spike S1, s05): ``updated_input`` never
reaches the model."""
@dataclass(frozen=True, slots=True)
class RunnerConfig:
user: str | None = None
home: Path | None = None
@dataclass(frozen=True, slots=True)
class UsageEvent:
agent_name: str
model: str
effort: str | None
conversation_id: str | None
session_id: str | None
usage: TurnUsage
class _RunnerClient(ClaudeSDKClient):
"""``ClaudeSDKClient`` that hands the materialized resume dir to the runner uid."""
def __init__(self, options: ClaudeAgentOptions, *, uid: int | None) -> None:
super().__init__(options=options)
self._runner_uid = uid
async def _connect_inner(self, prompt: Any, actual_prompt: Any) -> None:
materialized = self._materialized
if materialized is not None and self._runner_uid is not None:
_chown_tree(materialized.config_dir, self._runner_uid)
await super()._connect_inner(prompt, actual_prompt)
class ClaudeSdkBackend:
def __init__(
self,
*,
agent: ClaudeAgent,
mcp_internal_urls: Mapping[str, str],
session_store: SessionStore,
mcp_tool_names: Mapping[str, Sequence[str]] | None = None,
runner: RunnerConfig | None = None,
usage_sink: Callable[[UsageEvent], Awaitable[None]] | None = None,
client_factory: Callable[[ClaudeAgentOptions], SessionClient] | None = None,
work_dir: Path | None = None,
pool: SessionPool | None = None,
tool_server: Callable[[str, str], McpSdkServerConfig | None] | None = None,
asker: Callable[[str, dict[str, Any]], Awaitable[str]] | None = None,
) -> None:
self._agent = agent
self._store = session_store
self._runner = runner or RunnerConfig()
self._usage_sink = usage_sink
self._factory = client_factory or self._default_factory
self._work_dir = work_dir or Path(tempfile.gettempdir()) / "beaver-claude"
self._servers = _mcp_servers(agent, mcp_internal_urls)
self._mcp_disallowed = _mcp_disallowed(agent, mcp_tool_names or {})
self._pool = pool if pool is not None else SessionPool()
self._tool_server = tool_server
self._asker = asker if ASK_TOOL not in agent.options.disallowed_tools else None
self._uid, self._gid = _resolve_ids(self._runner.user)
self._wrapper: Path | None = None
@property
def agent(self) -> ClaudeAgent:
return self._agent
@property
def pool(self) -> SessionPool:
return self._pool
@property
def sessions(self) -> dict[str, dict[str, Any]]:
return {
row["key"]: row
for row in self._pool.snapshot()
if row["agent"] == self._agent.name
}
async def __aenter__(self) -> Self:
return self
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
await self.aclose()
async def aclose(self) -> None:
await self._pool.close_all(agent=self._agent.name)
async def close(self, key: str) -> None:
await self._pool.close(key)
async def interrupt(self, key: str) -> bool:
live = self._pool.get(key)
if live is None or not live.busy:
return False
live.interrupt_requested = True
await live.client.interrupt()
return True
def live(self, key: str) -> Session | None:
return self._pool.get(key)
async def repair_session(self, session_id: str) -> int:
"""Close ``tool_use`` blocks a crash left without a result; count added."""
key = self._store_key(session_id)
entries = await self._store.load(cast("Any", key))
if not entries:
return 0
fixes = close_open_tool_uses(cast("list[Mapping[str, Any]]", entries))
if fixes:
await self._store.append(cast("Any", key), cast("Any", fixes))
_log.warning(
"session %s: closed %d open tool_use with synthetic results",
session_id,
len(fixes),
)
return len(fixes)
async def complete(
self,
*,
agent: BaseAgent,
messages: Iterable[MessageParam],
system: str | None = None, # noqa: ARG002 - the agent owns its prompt
conversation_id: str | None = None,
session_id: str | None = None,
capture: TurnCapture | None = None,
kind: str = "deep",
pinned: bool = False,
tools: bool = True,
observer: Callable[[Any], None] | None = None,
turn_id: str | None = None,
**options: Any, # noqa: ARG002 - per-request knobs are not supported
) -> AsyncIterator[MessageStreamEvent]:
if agent.name != self._agent.name:
msg = f"backend bound to {self._agent.name!r}, got {agent.name!r}"
raise ValueError(msg)
history = [dict(m) for m in messages]
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"))
prior = history[:-1]
key = conversation_id or fingerprint(prior)
spec = _SessionSpec(kind=kind, pinned=pinned, tools=tools)
live = await self._acquire(key, session_id=session_id, history=prior, spec=spec)
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()
try:
# Events go out as the CLI produces them: the frontends
# stream text and thinking live, the turn is not buffered.
async for event in self._run_turn(
live, prompt, turn, observer, capture
):
yield event
except Exception:
live.running_turn = None
await self._pool.close(key)
# A dead resume can be reseeded from history, but only
# while nothing of this turn has reached the caller yet.
if not (live.resumed and live.turns == 0) or turn.events:
raise
_log.exception(
"resume of %s failed, reseeding from history", live.session_id
)
live = await self._acquire(
key, session_id=None, history=prior, spec=spec
)
turn = _Turn()
async with live.lock:
live.running_turn = turn_id or message_id
async for event in self._run_turn(
live, prompt, turn, observer, capture
):
yield event
live.turns += 1
live.last_used = time.monotonic()
live.running_turn = None
usage, interrupted = await self._after_turn(
live,
turn,
conversation_id=conversation_id,
history=history,
capture=capture,
)
if turn.result is not None and turn.result.is_error and not interrupted:
msg = f"claude: {turn.result.result or turn.result.subtype}"
raise RuntimeError(msg)
yield build_message_delta(
stop_reason=turn.stop_reason, usage=_wire_usage(usage)
)
yield build_message_stop()
async def _after_turn(
self,
live: Session,
turn: _Turn,
*,
conversation_id: str | None,
history: list[dict[str, Any]],
capture: TurnCapture | None,
) -> tuple[TurnUsage, bool]:
if turn.result is not None and turn.result.session_id:
live.session_id = turn.result.session_id
if conversation_id is None:
self._rekey(live.key, fingerprint([*history, *turn.synthesized]))
usage = _usage_of(turn.result)
interrupted = live.interrupt_requested
live.interrupt_requested = False
if capture is not None:
capture.synthesized_messages = turn.synthesized
capture.session_id = live.session_id
capture.usage = usage
capture.interrupted = interrupted
if self._usage_sink is not None:
await self._usage_sink(
UsageEvent(
agent_name=self._agent.name,
model=self._agent.model,
effort=self._agent.options.effort,
conversation_id=conversation_id,
session_id=live.session_id,
usage=usage,
)
)
return usage, interrupted
async def _run_turn(
self,
live: Session,
prompt: str,
turn: _Turn,
observer: Callable[[Any], None] | None = None,
capture: TurnCapture | None = None,
) -> AsyncIterator[MessageStreamEvent]:
"""Run one prompt, yielding wire events as they arrive.
``turn`` is filled in place (result, synthesized history, count of
events already yielded) so the caller can finish bookkeeping - and
decide whether a retry is still possible - after a failure. The
session id lands in ``capture`` with the first frame, so a turn cut
by a restart still leaves a resumable session behind.
"""
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,
)
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
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"
)
turn.synthesized = synthesize_turn_messages(raw)
_log.info(
"turn: agent=%s session=%s events=%d synthesized=%d stop=%s",
self._agent.name,
live.session_id,
turn.events,
len(turn.synthesized),
turn.stop_reason,
)
async def _acquire(
self,
key: str,
*,
session_id: str | None,
history: list[dict[str, Any]],
spec: _SessionSpec,
) -> Session:
live = self._pool.get(key)
if live is not None:
return live
resume = session_id
if resume is not None:
await self.repair_session(resume)
elif history:
resume = await self._seed(history)
await self._pool.make_room()
live = await self._spawn(resume, key=key, spec=spec)
return self._pool.add(live)
def _store_key(self, session_id: str) -> dict[str, str]:
return {
"project_key": project_key_for_directory(str(self._agent.cwd)),
"session_id": session_id,
}
async def _seed(self, history: list[dict[str, Any]]) -> str:
session_id = str(uuid.uuid4())
entries = build_entries(
history,
session_id=session_id,
cwd=str(self._agent.cwd),
model=self._agent.model,
permission_mode=self._agent.options.permission_mode,
)
await self._store.append(
cast("Any", self._store_key(session_id)), cast("Any", entries)
)
_log.info(
"seeded session %s with %d entries from %d messages",
session_id,
len(entries),
len(history),
)
return session_id
async def _spawn(
self, resume: str | None, *, key: str, spec: _SessionSpec
) -> Session:
options = self._build_options(resume, key=key, spec=spec)
client = self._factory(options)
await client.connect()
_log.info(
"spawned claude: agent=%s kind=%s resume=%s tools=%s user=%s",
self._agent.name,
spec.kind,
resume,
spec.tools,
self._runner.user,
)
return Session(
key=key,
agent=self._agent.name,
kind=spec.kind,
client=client,
session_id=resume,
resumed=resume is not None,
pinned=spec.pinned,
)
def _default_factory(self, options: ClaudeAgentOptions) -> SessionClient:
return _RunnerClient(options, uid=self._uid)
def _build_options(
self, resume: str | None, *, key: str, spec: _SessionSpec
) -> ClaudeAgentOptions:
agent = self._agent
opt = agent.options
env = dict(opt.env)
if self._runner.home is not None:
env["HOME"] = str(self._runner.home)
env.setdefault("CLAUDE_CONFIG_DIR", str(self._runner.home / ".claude"))
plugins = self._plugins()
sources = agent.prompt_for(as_kind(spec.kind))
system_prompt = (
prompt_assembly.assemble(sources) if sources else agent.system_prompt
)
servers: dict[str, Any] = dict(self._servers) if spec.tools else {}
gateway = (
self._tool_server(key, spec.kind)
if spec.tools and self._tool_server is not None and agent.gateway_tools
else None
)
if gateway is not None:
servers[str(gateway["name"])] = gateway
return ClaudeAgentOptions(
model=agent.model or None,
effort=cast("Any", opt.effort),
system_prompt=system_prompt,
setting_sources=[],
strict_mcp_config=True,
mcp_servers=cast("Any", servers),
permission_mode=cast("Any", opt.permission_mode),
tools=list(opt.tools) if opt.tools is not None else None,
disallowed_tools=[*opt.disallowed_tools, *self._mcp_disallowed],
cwd=str(agent.cwd),
add_dirs=list(opt.add_dirs),
env=env,
cli_path=str(self._exec_wrapper(extra_keep=tuple(env))),
include_partial_messages=opt.include_partial_messages,
can_use_tool=self._can_use_tool(key) if self._asker else None,
session_store=self._store,
session_store_flush=cast("Any", opt.session_store_flush),
resume=resume,
plugins=cast("Any", plugins),
skills="all" if plugins else None,
max_turns=opt.max_turns,
stderr=lambda line: _log.warning(
"claude[%s]: %s", agent.name, line.rstrip()
),
)
def _can_use_tool(
self, key: str
) -> Callable[
[str, dict[str, Any], ToolPermissionContext], Awaitable[PermissionResult]
]:
asker = self._asker
async def can_use_tool(
name: str, tool_input: dict[str, Any], _ctx: ToolPermissionContext
) -> PermissionResult:
if name != ASK_TOOL or asker is None:
return PermissionResultAllow()
live = self._pool.get(key)
if live is not None:
live.pending_question = True
try:
message = await asker(key, tool_input)
finally:
if live is not None:
live.pending_question = False
return PermissionResultDeny(message=message)
return can_use_tool
def _plugins(self) -> list[dict[str, str]]:
plugins: list[dict[str, str]] = []
root = self._work_dir / "plugins" / self._agent.name
for raw in sorted(self._agent.skill_sets, key=str):
source = Path(str(raw))
name = source.name
target = root / name
if target.exists():
shutil.rmtree(target)
shutil.copytree(source, target / "skills")
(target / ".claude-plugin").mkdir(parents=True, exist_ok=True)
(target / ".claude-plugin" / "plugin.json").write_text(
json.dumps({"name": name, "version": "0.0.0"}), encoding="utf-8"
)
_chmod_tree(target)
plugins.append({"type": "local", "path": str(target)})
return plugins
def _exec_wrapper(self, *, extra_keep: tuple[str, ...]) -> Path:
if self._wrapper is not None:
return self._wrapper
keep = sorted({*ENV_KEEP, *self._agent.options.env_keep, *extra_keep})
target = _claude_binary()
script = _WRAPPER.format(
python=sys.executable,
target=json.dumps(target),
keep=json.dumps(keep),
prefixes=json.dumps(list(ENV_KEEP_PREFIXES)),
uid=repr(self._uid),
gid=repr(self._gid),
)
digest = hashlib.sha256(script.encode("utf-8")).hexdigest()[:12]
path = self._work_dir / f"claude-exec-{digest}.py"
path.parent.mkdir(parents=True, exist_ok=True)
if not path.exists() or path.read_text(encoding="utf-8") != script:
path.write_text(script, encoding="utf-8")
path.chmod(0o755)
path.parent.chmod(0o755)
self._wrapper = path
return path
def _rekey(self, old: str, new: str) -> None:
stale = self._pool.rekey(old, new)
if stale is not None:
asyncio.get_running_loop().create_task(_disconnect(stale))
@dataclass(frozen=True, slots=True)
class _SessionSpec:
kind: str
pinned: bool
tools: bool
@dataclass
class _Turn:
events: int = 0
"""Wire events already yielded to the caller."""
synthesized: list[dict[str, Any]] = field(default_factory=list)
result: ResultMessage | None = None
stop_reason: StopReason = "end_turn"
_WRAPPER = """#!{python}
import os
import sys
TARGET = {target}
KEEP = set({keep})
PREFIXES = tuple({prefixes})
UID = {uid}
GID = {gid}
env = {{
k: v for k, v in os.environ.items() if k in KEEP or k.startswith(PREFIXES)
}}
if UID is not None and os.getuid() != UID:
os.setgroups([])
os.setgid(GID)
os.setuid(UID)
os.execve(TARGET, [TARGET, *sys.argv[1:]], env)
"""
async def _disconnect(live: Session) -> None:
try:
await live.client.disconnect()
except Exception: # noqa: BLE001
_log.exception("disconnect failed for session %s", live.session_id)
def _claude_binary() -> str:
bundled = Path(claude_agent_sdk.__file__).parent / "_bundled" / "claude"
if bundled.is_file():
return str(bundled)
found = shutil.which("claude")
if found is None:
msg = "claude CLI not found: neither bundled in claude_agent_sdk nor on PATH"
raise FileNotFoundError(msg)
return found
def _resolve_ids(user: str | None) -> tuple[int | None, int | None]:
if user is None:
return None, None
record = pwd.getpwuid(int(user)) if user.isdigit() else pwd.getpwnam(user)
return record.pw_uid, record.pw_gid
def _chown_tree(root: Path, uid: int) -> None:
for path in [root, *root.rglob("*")]:
with contextlib.suppress(OSError):
os.chown(path, uid, -1)
def _chmod_tree(root: Path) -> None:
for path in [root, *root.rglob("*")]:
with contextlib.suppress(OSError):
path.chmod(0o755 if path.is_dir() else 0o644)
def _mcp_servers(
agent: ClaudeAgent, urls: Mapping[str, str]
) -> dict[str, dict[str, Any]]:
servers: dict[str, dict[str, Any]] = {}
for exposed in agent.expose_mcps:
url = urls.get(exposed.name)
if url is None:
msg = f"agent {agent.name!r} exposes MCP {exposed.name!r} without a URL"
raise ValueError(msg)
servers[exposed.name] = {"type": "http", "url": url}
return servers
def _mcp_disallowed(
agent: ClaudeAgent, catalog: Mapping[str, Sequence[str]]
) -> list[str]:
out: list[str] = []
for exposed in agent.expose_mcps:
if exposed.tools is None and not exposed.deny:
continue
names = list(catalog.get(exposed.name, ()))
if not names:
_log.warning(
"MCP %r has no tool catalog; disallowing the whole server for %s",
exposed.name,
agent.name,
)
out.append(f"mcp__{exposed.name}")
continue
for name in names:
allowed = exposed.tools is None or name in exposed.tools
denied = any(fnmatch.fnmatchcase(name, pat) for pat in exposed.deny)
if not allowed or denied:
out.append(f"mcp__{exposed.name}__{name}")
return out
def _prompt_text(content: Any) -> str:
text = text_of(content)
if not text:
msg = "user message has no text content"
raise ValueError(msg)
return text
def synthesize_turn_messages(raw: Iterable[Any]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for message in raw:
if isinstance(message, AssistantMessage):
out.append(
{
"role": "assistant",
"content": [_block_to_dict(b) for b in message.content],
}
)
elif isinstance(message, UserMessage):
content = message.content
if isinstance(content, list) and content:
out.append(
{"role": "user", "content": [_block_to_dict(b) for b in content]}
)
return out
def _block_to_dict(block: Any) -> dict[str, Any]:
if isinstance(block, TextBlock):
return {"type": "text", "text": block.text}
if isinstance(block, ToolUseBlock):
return {
"type": "tool_use",
"id": block.id,
"name": block.name,
"input": block.input,
}
if isinstance(block, ToolResultBlock):
result: dict[str, Any] = {
"type": "tool_result",
"tool_use_id": block.tool_use_id,
"content": block.content,
}
if block.is_error is not None:
result["is_error"] = block.is_error
return result
if isinstance(block, ThinkingBlock):
return {
"type": "thinking",
"thinking": block.thinking,
"signature": block.signature,
}
msg = f"unknown content block type: {type(block).__name__}"
raise TypeError(msg)
def _usage_of(result: ResultMessage | None) -> TurnUsage:
if result is None:
return TurnUsage()
usage = result.usage or {}
return TurnUsage(
input_tokens=_int(usage.get("input_tokens")),
output_tokens=_int(usage.get("output_tokens")),
cache_read_tokens=_int(usage.get("cache_read_input_tokens")),
cache_creation_tokens=_int(usage.get("cache_creation_input_tokens")),
cost_usd=result.total_cost_usd,
duration_ms=result.duration_ms,
num_turns=result.num_turns,
model_usage=cast("dict[str, Any] | None", result.model_usage),
)
def _wire_usage(usage: TurnUsage) -> dict[str, int]:
return {
"input_tokens": usage.input_tokens,
"output_tokens": usage.output_tokens,
"cache_read_input_tokens": usage.cache_read_tokens,
"cache_creation_input_tokens": usage.cache_creation_tokens,
}
def _int(value: Any) -> int:
return value if isinstance(value, int) else 0
def _emit_stream_event(
event: Mapping[str, Any], index: int
) -> Iterable[MessageStreamEvent]:
etype = event.get("type")
if etype == "content_block_start":
block = event.get("content_block")
if not isinstance(block, Mapping):
return ()
btype = block.get("type")
if btype == "text":
return (build_text_block_start(index),)
if btype == "thinking":
return (build_thinking_block_start(index),)
if btype == "tool_use":
return (
build_tool_use_block_start(
index,
tool_use_id=str(block.get("id", "")),
name=str(block.get("name", "")),
),
)
return ()
if etype == "content_block_delta":
delta = event.get("delta")
if not isinstance(delta, Mapping):
return ()
dtype = delta.get("type")
if dtype == "text_delta":
return (build_text_delta(index, str(delta.get("text", ""))),)
if dtype == "thinking_delta":
return (build_thinking_delta(index, str(delta.get("thinking", ""))),)
if dtype == "signature_delta":
return (build_signature_delta(index, str(delta.get("signature", ""))),)
if dtype == "input_json_delta":
return (build_input_json_delta(index, str(delta.get("partial_json", ""))),)
return ()
if etype == "content_block_stop":
return (build_content_block_stop(index),)
return ()
def _emit_block(block: Any, index: int) -> Iterable[MessageStreamEvent]:
if isinstance(block, TextBlock):
return (
build_text_block_start(index),
build_text_delta(index, block.text),
build_content_block_stop(index),
)
if isinstance(block, ThinkingBlock):
return (
build_thinking_block_start(index),
build_thinking_delta(index, block.thinking),
build_signature_delta(index, block.signature),
build_content_block_stop(index),
)
if isinstance(block, ToolUseBlock):
partial = json.dumps(block.input, separators=(",", ":"), ensure_ascii=False)
return (
build_tool_use_block_start(index, tool_use_id=block.id, name=block.name),
build_input_json_delta(index, partial),
build_content_block_stop(index),
)
return ()
+4 -1
View File
@@ -26,11 +26,12 @@ tools we route it back to the underlying MCP in-process, append the
result as a ``tool`` message, and re-issue the stream — all inside one
Anthropic envelope (one ``message_start`` … one ``message_stop``). The
tool_use blocks DO surface to the caller (mirrors what
``ClaudeCodeBackendAdapter`` does), but tool_results stay internal.
``ClaudeSdkBackend`` does), but tool_results stay internal.
"""
from __future__ import annotations
import fnmatch
import json
import logging
import uuid
@@ -297,6 +298,8 @@ def _build_agent_catalog(
for mt in mcp_tools.get(em.name, []):
if em.tools is not None and mt.name not in em.tools:
continue
if any(fnmatch.fnmatchcase(mt.name, pat) for pat in em.deny):
continue
wire_name = f"{em.name}__{mt.name}"
routing[wire_name] = (em.name, mt.name)
local_tools.append(
+106 -14
View File
@@ -22,10 +22,11 @@ from __future__ import annotations
import asyncio
import contextlib
import functools
import logging
import signal
from contextlib import AsyncExitStack
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
import uvicorn
import uvloop
@@ -36,16 +37,25 @@ from raycast_api.config import Config as RaycastConfig
from beaver_gateway import config_loader
from beaver_gateway.agents.claude import ClaudeAgent
from beaver_gateway.agents.raycast import RaycastAgent
from beaver_gateway.backends.claude_code import ClaudeCodeBackendAdapter
from beaver_gateway.backends.claude_sdk import (
ClaudeSdkBackend,
RunnerConfig,
UsageEvent,
)
from beaver_gateway.backends.raycast import RaycastBackend
from beaver_gateway.core.auth import TokenStore
from beaver_gateway.core.bus import EventBus
from beaver_gateway.core.conversations import Conversations
from beaver_gateway.core.gateway_tools import build_tool_server
from beaver_gateway.core.registry import AgentRegistry, McpRegistry
from beaver_gateway.core.sessions import SessionPool
from beaver_gateway.frontends.base import GatewayRuntime
from beaver_gateway.mcp.internal_app import build_internal_app
from beaver_gateway.settings import Settings
from beaver_gateway.storage import Database
from beaver_gateway.storage import Database, PostgresSessionStore, Usage, append_usage
if TYPE_CHECKING:
from claude_agent_sdk import McpSdkServerConfig
from fastmcp import FastMCP
from fastmcp.tools.base import Tool as FastMCPTool
from starlette.applications import Starlette
@@ -122,8 +132,8 @@ async def _async_main() -> None:
await token_store.start()
stack.push_async_callback(token_store.stop)
# Internal MCP URLs must exist before we construct any
# ClaudeCodeBackendAdapter — adapters bake the URLs into their
# ``BackendOptions.mcp_servers`` at construction time. The
# ClaudeSdkBackend - adapters bake the URLs into their
# ``mcp_servers`` at construction time. The
# ``mcp_servers`` map is used by the Raycast backend, which
# needs in-process ``list_tools`` / ``call_tool`` access (the
# Raycast wire has no native MCP concept).
@@ -136,14 +146,33 @@ async def _async_main() -> None:
# surfaces at startup instead of mid-conversation.
mcp_tools = await _prefetch_mcp_tools(mcp_servers)
pool = SessionPool()
bus = EventBus()
late = _LateConversations()
session_store = PostgresSessionStore(db)
backends: dict[str, Backend] = await _build_backends(
settings=settings,
agents=agents,
stack=stack,
db=db,
session_store=session_store,
mcp_internal_urls=internal_urls,
mcp_servers=mcp_servers,
mcp_tools=mcp_tools,
pool=pool,
late=late,
)
conversations = Conversations(
db=db,
agents=agents,
backends=backends,
bus=bus,
pool=pool,
store=session_store,
texts=gateway.texts,
frontends=gateway.frontends,
)
late.conversations = conversations
runtime = GatewayRuntime(
agents=agents,
@@ -156,6 +185,9 @@ async def _async_main() -> None:
admin_pass=settings.admin_pass,
session_secret=settings.session_secret,
frontends=tuple(gateway.frontends),
conversations=conversations,
bus=bus,
pool=pool,
)
for fe in gateway.frontends:
@@ -181,7 +213,10 @@ async def _async_main() -> None:
# it in this path and exit cleanly (Phase 0 DoD).
return
await conversations.start()
stack.push_async_callback(conversations.stop)
async with asyncio.TaskGroup() as tg:
tg.create_task(pool.reap_loop())
if internal_app is not None:
tg.create_task(_serve_internal_mcp(internal_app, settings=settings))
for fe in gateway.frontends:
@@ -247,27 +282,49 @@ async def _serve_internal_mcp(app: Starlette, *, settings: Settings) -> None:
await server.serve()
class _LateConversations:
"""Backends need a tool-server factory before the service that backs it exists."""
conversations: Conversations | None = None
def server(
self, key: str, _kind: str, names: tuple[str, ...]
) -> McpSdkServerConfig | None:
if self.conversations is None or not names:
return None
return build_tool_server(self.conversations, conversation_key=key, names=names)
async def ask(self, key: str, payload: dict[str, Any]) -> str:
if self.conversations is None:
msg = "conversations service is not up yet"
raise RuntimeError(msg)
answer = await self.conversations.ask(key, payload)
return self.conversations.answer_text(answer)
async def _build_backends(
*,
settings: Settings,
agents: AgentRegistry,
stack: AsyncExitStack,
db: Database,
session_store: PostgresSessionStore,
mcp_internal_urls: dict[str, str],
mcp_servers: dict[str, FastMCP],
mcp_tools: dict[str, list[FastMCPTool]],
pool: SessionPool,
late: _LateConversations,
) -> dict[str, Backend]:
"""Construct one backend per agent name.
The Raycast ``Client`` is shared across every ``RaycastAgent``
(bearer + device-id are process-wide), so we open it lazily — only
when at least one ``RaycastAgent`` is present — and close it via
(bearer + device-id are process-wide), so we open it lazily - only
when at least one ``RaycastAgent`` is present - and close it via
the caller's exit stack.
Each :class:`ClaudeAgent` gets its own
:class:`ClaudeCodeBackendAdapter`: ``BackendOptions`` pins
``cwd`` / ``model`` / ``system_prompt`` / ``mcp_servers`` for the
lifetime of the underlying ``ClaudeCodeBackend``, so different
agents can't share one.
Each :class:`ClaudeAgent` gets its own :class:`ClaudeSdkBackend`
(own live-session pool, own prompt and MCP set); all of them share
the session store and the usage sink.
"""
backends: dict[str, Backend] = {}
@@ -281,10 +338,45 @@ async def _build_backends(
for a in raycast_agents:
backends[a.name] = raycast_backend
runner = RunnerConfig(user=settings.claude_runner_user, home=settings.claude_home)
mcp_tool_names = {
name: [t.name for t in tools] for name, tools in mcp_tools.items()
}
async def record_usage(event: UsageEvent) -> None:
row = Usage(
agent_name=event.agent_name,
conversation_id=event.conversation_id,
session_id=event.session_id,
model=event.model,
effort=event.effort,
input_tokens=event.usage.input_tokens,
output_tokens=event.usage.output_tokens,
cache_read_tokens=event.usage.cache_read_tokens,
cache_creation_tokens=event.usage.cache_creation_tokens,
cost_usd=event.usage.cost_usd,
duration_ms=event.usage.duration_ms,
num_turns=event.usage.num_turns,
model_usage=event.usage.model_usage,
)
try:
async with db.session() as session:
await append_usage(session, row)
except Exception: # noqa: BLE001
_log.exception("usage write failed for %s", event.agent_name)
for a in agents:
if isinstance(a, ClaudeAgent):
adapter = ClaudeCodeBackendAdapter(
agent=a, mcp_internal_urls=mcp_internal_urls
adapter = ClaudeSdkBackend(
agent=a,
mcp_internal_urls=mcp_internal_urls,
session_store=session_store,
mcp_tool_names=mcp_tool_names,
runner=runner,
usage_sink=record_usage,
pool=pool,
tool_server=functools.partial(late.server, names=a.gateway_tools),
asker=late.ask,
)
await stack.enter_async_context(adapter)
backends[a.name] = adapter
+2
View File
@@ -19,6 +19,7 @@ from typing import TYPE_CHECKING, Any
from beaver_gateway.agents.base import BaseAgent, ExposedMcp
from beaver_gateway.agents.claude import ClaudeAgent
from beaver_gateway.agents.raycast import RaycastAgent
from beaver_gateway.core.conversations import ConversationTexts
from beaver_gateway.core.registry import Gateway
from beaver_gateway.frontends.base import Frontend
from beaver_gateway.mcp.types import HttpMcp, McpServer, PythonToolMcp, StdioMcp
@@ -37,6 +38,7 @@ _PUBLIC_NAMES: dict[str, Any] = {
"McpServer": McpServer,
"ExposedMcp": ExposedMcp,
"Gateway": Gateway,
"ConversationTexts": ConversationTexts,
}
_McpInstance = StdioMcp | HttpMcp | PythonToolMcp
+7 -1
View File
@@ -60,12 +60,13 @@ class TokenStoreError(ValueError):
"""Malformed ``BOOTSTRAP_TOKENS`` value or duplicate token."""
VALID_SCOPES: frozenset[str] = frozenset({"*", "messages", "mcp", "admin"})
VALID_SCOPES: frozenset[str] = frozenset({"*", "messages", "mcp", "admin", "api"})
"""The scopes a ``Token.scope`` may hold (Phase 4.3 admin UI enforces).
* ``*`` — wildcard, may use any frontend
* ``messages`` — Anthropic Messages frontend only
* ``mcp`` — MCP server frontend only
* ``api`` — conversations API (``/api``) and its SSE
* ``admin`` — reserved for programmatic admin access; the AdminFrontend
itself authenticates via session cookies, not bearer tokens, so this
scope is unused today and kept for forward compatibility.
@@ -235,6 +236,11 @@ class TokenStore:
self._flusher_task = None
await self._flush_now()
def grant(self, name: str, value: str, *, scope: str = _BOOTSTRAP_SCOPE) -> None:
"""Add an in-memory token for the process lifetime (the admin UI's bearer)."""
self._bootstrap_by_value[value] = name
self._bootstrap_scopes[name] = scope
async def invalidate(self) -> None:
"""Force the next verify to re-read from DB (Phase 4.3 admin hook)."""
self._loaded_at = 0.0
+69
View File
@@ -0,0 +1,69 @@
"""In-process event bus: what the gateway does, as a stream frontends can tap.
Events are plain dicts ``{"type", "seq", "ts", ...}``. Publishers never
block; a subscriber that falls behind loses its oldest events rather than
stalling the turn that produced them. ``/api/events`` serialises the
stream as SSE, the panel builds its subagent tree from ``stream`` events
carrying ``parent_tool_use_id``.
"""
from __future__ import annotations
import asyncio
import contextlib
import itertools
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import AsyncIterator
__all__ = ["Event", "EventBus"]
Event = dict[str, Any]
class EventBus:
def __init__(self, *, maxsize: int = 2000) -> None:
self._maxsize = maxsize
self._subscribers: set[asyncio.Queue[Event]] = set()
self._seq = itertools.count(1)
def publish(self, type_: str, **data: Any) -> Event:
event: Event = {
"type": type_,
"seq": next(self._seq),
"ts": datetime.now(UTC).isoformat(timespec="milliseconds"),
**data,
}
for queue in list(self._subscribers):
if queue.full():
with contextlib.suppress(asyncio.QueueEmpty):
queue.get_nowait()
queue.put_nowait(event)
return event
@contextlib.asynccontextmanager
async def subscribe(self) -> AsyncIterator[asyncio.Queue[Event]]:
queue: asyncio.Queue[Event] = asyncio.Queue(maxsize=self._maxsize)
self._subscribers.add(queue)
try:
yield queue
finally:
self._subscribers.discard(queue)
async def stream(
self, *, conversation_id: str | None = None
) -> AsyncIterator[Event]:
async with self.subscribe() as queue:
while True:
event = await queue.get()
if (
conversation_id is None
or event.get("conversation_id") == conversation_id
):
yield event
@property
def subscribers(self) -> int:
return len(self._subscribers)
+24 -7
View File
@@ -63,6 +63,7 @@ __all__ = [
"load_messages",
"mint_conversation",
"rewrite_messages",
"set_session_id",
]
@@ -121,6 +122,17 @@ async def mint_conversation(
return row
async def set_session_id(
session: AsyncSession, *, conversation_id: int, session_id: str | None
) -> None:
conv = await session.get(Conversation, conversation_id)
if conv is None or conv.session_id == session_id:
return
conv.session_id = session_id
session.add(conv)
await session.commit()
async def load_messages(
session: AsyncSession, *, conversation_id: int
) -> list[dict[str, Any]]:
@@ -484,12 +496,14 @@ def _walk_prefix(
s.name for s in inc.structure if isinstance(s, ToolSegment)
)
inc_text_count = sum(1 for s in inc.structure if isinstance(s, TextSegment))
if inc_skeleton != st.skeleton:
# Files rendered without tool callouts (§3.10) carry no skeleton:
# prose alone decides whether the turn matched.
if inc_skeleton and inc_skeleton != st.skeleton:
return spliced_groups, i
if inc.text == st.spoken_text:
spliced_groups.append(list(st.messages))
continue
if inc_text_count != st.text_segment_count:
if inc_skeleton and inc_text_count != st.text_segment_count:
return spliced_groups, i
spliced = _splice_assistant_group(stored_group=st, incoming=inc)
if spliced is None:
@@ -539,6 +553,13 @@ def _splice_assistant_group(
return _splice_by_rebuild(stored_group=stored_group, incoming=incoming)
def _is_text_block(blk: object) -> bool:
if not isinstance(blk, dict):
return False
block = cast("dict[str, Any]", blk)
return block.get("type") == "text" and bool(str(block.get("text", "")).strip())
def _splice_in_place(
*, stored_group: _StoredDisplayTurn, incoming: ParsedTurn
) -> list[dict[str, Any]] | None:
@@ -570,11 +591,7 @@ def _splice_in_place(
if msg["role"] != "assistant" or not isinstance(content, list):
continue
positions.extend(
(mi, bi)
for bi, blk in enumerate(content)
if isinstance(blk, dict)
and blk.get("type") == "text"
and str(blk.get("text", "")).strip()
(mi, bi) for bi, blk in enumerate(content) if _is_text_block(blk)
)
if len(positions) != len(new_texts):
return None
File diff suppressed because it is too large. Load diff
+177
View File
@@ -0,0 +1,177 @@
"""In-process MCP server with the gateway's own tools (§3.1, §3.2).
One server per live session so every tool knows which conversation is
calling; ``alwaysLoad`` keeps the tools out of tool search. Which names a
session gets comes from ``ClaudeAgent.gateway_tools``.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any, cast
from claude_agent_sdk import create_sdk_mcp_server, tool
from beaver_gateway.core.kinds import as_kind
if TYPE_CHECKING:
from collections.abc import Iterable
from claude_agent_sdk import McpSdkServerConfig, SdkMcpTool
from beaver_gateway.core.conversations import Conversations
__all__ = ["SERVER_NAME", "TOOL_NAMES", "build_tool_server"]
_log = logging.getLogger("beaver_gateway.core.gateway_tools")
SERVER_NAME = "gateway"
TOOL_NAMES = ("read_conversation", "spawn", "say", "schedule", "inject")
def build_tool_server(
conversations: Conversations, *, conversation_key: str, names: Iterable[str]
) -> McpSdkServerConfig | None:
wanted = set(names)
unknown = wanted - set(TOOL_NAMES)
if unknown:
msg = f"unknown gateway tools: {sorted(unknown)}"
raise ValueError(msg)
tools = [t for t in _tools(conversations, conversation_key) if t.name in wanted]
if not tools:
return None
server = create_sdk_mcp_server(SERVER_NAME, tools=tools)
return cast("McpSdkServerConfig", {**server, "alwaysLoad": True})
def _tools(conversations: Conversations, key: str) -> list[SdkMcpTool[Any]]:
async def current() -> Any:
conv = await conversations.get(key)
if conv is None:
msg = f"conversation {key} not found"
raise LookupError(msg)
return conv
@tool(
"read_conversation",
"Read another conversation (a branch, the master, a deep chat) as plain "
"text. `window` limits it to the last N user turns.",
{
"type": "object",
"properties": {
"id": {"type": "string", "description": "conversation id"},
"window": {"type": "integer", "minimum": 1},
},
"required": ["id"],
},
)
async def read_conversation(args: dict[str, Any]) -> dict[str, Any]:
conv = await conversations.get(str(args["id"]))
if conv is None:
return _error(f"conversation {args['id']} not found")
text = await conversations.read(conv, window=args.get("window"))
return _text(text or "(empty)")
@tool(
"spawn",
"Open a new conversation of the given kind (branch = your own thread, "
"deep = a long research chat, job = a headless task). `seed` is how it "
"starts: clean (nothing), morning (handout), copy (copy of this "
"conversation, last `window` turns), brief (your `text`). A branch "
"keeps your agent, other kinds get their frontend's default unless "
"`agent` says otherwise. Returns the id.",
{
"type": "object",
"properties": {
"kind": {"type": "string", "enum": ["branch", "deep", "job"]},
"agent": {"type": "string", "description": "agent name"},
"seed": {
"type": "string",
"enum": ["clean", "morning", "copy", "brief"],
"default": "clean",
},
"text": {"type": "string", "description": "brief for seed=brief"},
"title": {"type": "string"},
"window": {"type": "integer", "minimum": 1},
},
"required": ["kind"],
},
)
async def spawn(args: dict[str, Any]) -> dict[str, Any]:
parent = await current()
try:
child = await conversations.spawn(
kind=as_kind(str(args["kind"])),
agent=args.get("agent"),
seed=str(args.get("seed") or "clean"),
parent=parent,
text=args.get("text"),
title=args.get("title"),
window=args.get("window"),
origin="mcp",
)
except (ValueError, LookupError) as exc:
return _error(str(exc))
return _text(f"spawned {child.kind} {child.external_id}")
@tool(
"say",
"Say something to the human in the frontend this conversation is bound "
"to. The only way an inject-started turn can speak; silence is simply "
"not calling it.",
{"text": str},
)
async def say(args: dict[str, Any]) -> dict[str, Any]:
conv = await current()
await conversations.say(conv, str(args["text"]))
return _text("ok")
@tool(
"schedule",
"Promise yourself an inject later: `at` is `+15m`, `+2h`, `+1d` or an "
"ISO datetime; `text` arrives in this conversation at that time.",
{"at": str, "text": str},
)
async def schedule(args: dict[str, Any]) -> dict[str, Any]:
conv = await current()
row = await conversations.schedule(conv, str(args["at"]), str(args["text"]))
return _text(f"scheduled #{row.id} at {row.execute_at.isoformat()}")
@tool(
"inject",
"Put a system-origin message into another conversation's queue.",
{
"type": "object",
"properties": {
"conversation": {"type": "string"},
"text": {"type": "string"},
"urgency": {
"type": "string",
"enum": ["normal", "urgent"],
"default": "normal",
},
},
"required": ["conversation", "text"],
},
)
async def inject(args: dict[str, Any]) -> dict[str, Any]:
target = await conversations.get(str(args["conversation"]))
if target is None:
return _error(f"conversation {args['conversation']} not found")
item = await conversations.inject(
target,
str(args["text"]),
urgency=cast("Any", args.get("urgency") or "normal"),
origin="агент",
)
return _text(f"queued #{item.id}")
return [read_conversation, spawn, say, schedule, inject]
def _text(text: str) -> dict[str, Any]:
return {"content": [{"type": "text", "text": text}]}
def _error(text: str) -> dict[str, Any]:
return {"content": [{"type": "text", "text": text}], "is_error": True}
+130
View File
@@ -0,0 +1,130 @@
"""Persisted per-conversation queue with priorities ``urgent > user > normal`` (§3.4).
One ``ClaudeSDKClient`` runs one turn at a time, so ordering has to happen
before the client: the rows here are the queue, ``core/conversations``
runs one worker per conversation over them. A row that is still
``running`` when the gateway starts was cut by a restart; it is flagged
``interrupted`` and never re-run.
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Literal
from sqlmodel import col, select
from beaver_gateway.storage.models import InjectQueueItem
if TYPE_CHECKING:
from collections.abc import Iterable, Sequence
from beaver_gateway.storage.db import Database
__all__ = ["PRIORITY_RANK", "InjectQueue", "Priority", "inject_header"]
Priority = Literal["urgent", "user", "normal"]
PRIORITY_RANK: dict[str, int] = {"urgent": 0, "user": 1, "normal": 2}
def inject_header(origin: str) -> str:
return f"[инжект: {origin} - это не Бобёр, отвечать не нужно, голос не обязателен]"
class InjectQueue:
def __init__(self, db: Database) -> None:
self._db = db
async def push(
self, *, conversation_id: int, priority: Priority, origin: str, text: str
) -> InjectQueueItem:
if priority not in PRIORITY_RANK:
msg = f"unknown priority {priority!r}"
raise ValueError(msg)
row = InjectQueueItem(
conversation_id=conversation_id, priority=priority, origin=origin, text=text
)
async with self._db.session() as session:
session.add(row)
await session.commit()
await session.refresh(row)
return row
async def pending(self, conversation_id: int) -> list[InjectQueueItem]:
async with self._db.session() as session:
result = await session.exec(
select(InjectQueueItem)
.where(
InjectQueueItem.conversation_id == conversation_id,
InjectQueueItem.status == "queued",
)
.order_by(col(InjectQueueItem.created_at), col(InjectQueueItem.id))
)
rows = list(result.all())
rows.sort(key=lambda r: (PRIORITY_RANK.get(r.priority, 9), r.created_at))
return rows
async def conversations_with_pending(self) -> list[int]:
async with self._db.session() as session:
result = await session.exec(
select(InjectQueueItem.conversation_id)
.where(InjectQueueItem.status == "queued")
.distinct()
)
return list(result.all())
async def start(self, items: Iterable[InjectQueueItem], turn_id: str) -> None:
await self._mark(items, status="running", turn_id=turn_id, delivered=True)
async def finish(
self, items: Iterable[InjectQueueItem], status: str = "done"
) -> None:
await self._mark(items, status=status)
async def interrupted(self) -> Sequence[InjectQueueItem]:
async with self._db.session() as session:
result = await session.exec(
select(InjectQueueItem).where(InjectQueueItem.status == "running")
)
rows = list(result.all())
for row in rows:
row.status = "interrupted"
session.add(row)
await session.commit()
return rows
async def recent(
self, conversation_id: int, *, limit: int = 50
) -> list[InjectQueueItem]:
async with self._db.session() as session:
result = await session.exec(
select(InjectQueueItem)
.where(InjectQueueItem.conversation_id == conversation_id)
.order_by(col(InjectQueueItem.id).desc())
.limit(limit)
)
return list(result.all())
async def _mark(
self,
items: Iterable[InjectQueueItem],
*,
status: str,
turn_id: str | None = None,
delivered: bool = False,
) -> None:
ids = [i.id for i in items if i.id is not None]
if not ids:
return
async with self._db.session() as session:
result = await session.exec(
select(InjectQueueItem).where(col(InjectQueueItem.id).in_(ids))
)
for row in result.all():
row.status = status
if turn_id is not None:
row.turn_id = turn_id
if delivered:
row.delivered_at = datetime.now(UTC)
session.add(row)
await session.commit()
+18
View File
@@ -0,0 +1,18 @@
"""Conversation kinds (§3.1) as one closed type for agents, frontends, service."""
from __future__ import annotations
from typing import Literal, get_args
__all__ = ["KINDS", "Kind", "as_kind"]
Kind = Literal["master", "branch", "deep", "job", "fork"]
KINDS: tuple[Kind, ...] = get_args(Kind)
def as_kind(value: str) -> Kind:
for kind in KINDS:
if value == kind:
return kind
msg = f"unknown conversation kind {value!r}"
raise ValueError(msg)
+42
View File
@@ -0,0 +1,42 @@
"""System prompt assembly from a list of source files.
The gateway holds no prompt text: an agent names its granules (paths from
``config.py``) and :func:`assemble` concatenates them in that order, so the
result is byte-for-byte identical for every session of the same agent as
long as the files are. A source is a path, or a ``(tag, path)`` pair whose
content is wrapped in ``<tag>...</tag>`` - the markup lives here, the vault
keeps plain markdown. Each granule's hash is logged at assembly so a
drifted prompt can be traced to the file that changed.
"""
from __future__ import annotations
import hashlib
import logging
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Iterable
__all__ = ["PromptSource", "assemble"]
_log = logging.getLogger("beaver_gateway.core.prompt")
PromptSource = str | Path | tuple[str, str | Path]
def assemble(sources: Iterable[PromptSource]) -> str:
parts: list[str] = []
for source in sources:
tag, raw = source if isinstance(source, tuple) else (None, source)
path = Path(raw)
text = path.read_text(encoding="utf-8").strip()
digest = hashlib.sha256(text.encode("utf-8")).hexdigest()[:12]
_log.info(
"prompt granule %s tag=%s sha=%s bytes=%d", path, tag, digest, len(text)
)
if not text:
continue
parts.append(f"<{tag}>\n{text}\n</{tag}>" if tag else text)
return "\n\n".join(parts) + "\n"
+3
View File
@@ -16,6 +16,7 @@ if TYPE_CHECKING:
from collections.abc import Iterable, Iterator
from beaver_gateway.agents.base import BaseAgent
from beaver_gateway.core.conversations import ConversationTexts
from beaver_gateway.frontends.base import Frontend
from beaver_gateway.mcp.types import McpServerT
@@ -81,3 +82,5 @@ class Gateway:
agents: list[BaseAgent] = field(default_factory=list)
mcps: list[McpServerT] = field(default_factory=list)
frontends: list[Frontend] = field(default_factory=list)
texts: ConversationTexts | None = None
"""Merge prompt and seed bodies for ``core/conversations`` (§8.2-8.3)."""
+257
View File
@@ -0,0 +1,257 @@
"""Pool of live Agent SDK sessions across every Claude agent (§3.2).
One :class:`Session` is one ``ClaudeSDKClient`` (one claude subprocess).
The pool owns the two decisions the adapters used to make on their own:
when a session is closed for idleness (TTL by conversation kind) and
which one goes when memory runs out (measured RSS of the subprocess tree
against the cgroup limit, ``max_live`` where there is no limit). Eviction
only ever picks ``idle && !running_turn && !pending_question`` sessions
that are neither pinned (the master) nor ``dirty`` (mirror gap not yet
repaired); forks and jobs go first.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import os
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any, Protocol
import psutil
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Iterator, Mapping
__all__ = ["DEFAULT_TTL", "Session", "SessionClient", "SessionPool", "cgroup_limit"]
_log = logging.getLogger("beaver_gateway.core.sessions")
DEFAULT_TTL: Mapping[str, float | None] = {
"master": None,
"branch": 7200.0,
"deep": 1800.0,
"job": 0.0,
"fork": 0.0,
}
"""Idle seconds before a session is closed; ``None`` = never (pinned kinds)."""
_EVICT_ORDER = {"fork": 0, "job": 0, "deep": 1, "branch": 2, "master": 3}
_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 interrupt(self) -> None: ...
async def disconnect(self) -> None: ...
@dataclass
class Session:
key: str
agent: str
kind: str
client: SessionClient
session_id: str | None
resumed: bool
pinned: bool = False
dirty: bool = False
running_turn: str | None = None
pending_question: bool = False
interrupt_requested: bool = False
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
last_used: float = field(default_factory=time.monotonic)
created_at: float = field(default_factory=time.monotonic)
turns: int = 0
@property
def busy(self) -> bool:
return self.lock.locked() or self.running_turn is not None
@property
def evictable(self) -> bool:
return not (self.pinned or self.dirty or self.busy or self.pending_question)
@property
def pid(self) -> int | None:
transport = getattr(self.client, "_transport", None)
process = getattr(transport, "_process", None)
pid = getattr(process, "pid", None)
return pid if isinstance(pid, int) else None
class SessionPool:
def __init__(
self,
*,
rss_limit: int | None = None,
max_live: int = 8,
ttl: Mapping[str, float | None] = DEFAULT_TTL,
reap_interval: float = 60.0,
) -> None:
self._sessions: dict[str, Session] = {}
self._rss_limit = rss_limit if rss_limit is not None else cgroup_limit()
self._max_live = max_live
self._ttl = dict(ttl)
self._reap_interval = reap_interval
def __len__(self) -> int:
return len(self._sessions)
def __iter__(self) -> Iterator[Session]:
return iter(list(self._sessions.values()))
def __contains__(self, key: object) -> bool:
return key in self._sessions
@property
def rss_limit(self) -> int | None:
return self._rss_limit
def get(self, key: str) -> Session | None:
return self._sessions.get(key)
def add(self, session: Session) -> Session:
self._sessions[session.key] = session
return session
def pop(self, key: str) -> Session | None:
return self._sessions.pop(key, None)
def rekey(self, old: str, new: str) -> Session | None:
session = self._sessions.pop(old, None)
if session is None:
return None
stale = self._sessions.pop(new, None)
session.key = new
self._sessions[new] = session
return stale if stale is not session else None
def ttl_for(self, kind: str) -> float | None:
return self._ttl.get(kind, self._ttl.get("deep"))
def rss(self) -> int:
try:
children = psutil.Process(os.getpid()).children(recursive=True)
except psutil.Error:
return 0
total = 0
for child in children:
with contextlib.suppress(psutil.Error):
total += child.memory_info().rss
return total
@staticmethod
def rss_of(session: Session) -> int | None:
pid = session.pid
if pid is None:
return None
try:
process = psutil.Process(pid)
return process.memory_info().rss + sum(
c.memory_info().rss for c in process.children(recursive=True)
)
except psutil.Error:
return None
def over_limit(self) -> bool:
if self._rss_limit is not None:
return self.rss() > self._rss_limit * _RSS_HEADROOM
return len(self._sessions) >= self._max_live
def victims(self) -> list[Session]:
candidates = [s for s in self._sessions.values() if s.evictable]
candidates.sort(key=lambda s: (_EVICT_ORDER.get(s.kind, 1), s.last_used))
return candidates
async def make_room(self) -> int:
closed = 0
while self.over_limit():
victims = self.victims()
if not victims:
_log.warning(
"session pool over limit (%d live, rss=%d) but nothing evictable",
len(self._sessions),
self.rss(),
)
break
await self.close(victims[0].key)
closed += 1
return closed
async def close(self, key: str) -> None:
session = self._sessions.pop(key, None)
if session is None:
return
_log.info("closing session %s (%s, %s)", session.session_id, session.kind, key)
try:
await session.client.disconnect()
except Exception: # noqa: BLE001
_log.exception("disconnect failed for session %s", session.session_id)
async def close_all(self, *, agent: str | None = None) -> None:
for session in list(self._sessions.values()):
if agent is None or session.agent == agent:
await self.close(session.key)
async def reap_once(self) -> int:
now = time.monotonic()
closed = 0
for session in list(self._sessions.values()):
ttl = self.ttl_for(session.kind)
if ttl is None or not session.evictable:
continue
if now - session.last_used > ttl:
await self.close(session.key)
closed += 1
return closed
async def reap_loop(self) -> None:
while True:
await asyncio.sleep(self._reap_interval)
try:
await self.reap_once()
await self.make_room()
except Exception: # noqa: BLE001
_log.exception("session reaper failed")
def snapshot(self) -> list[dict[str, Any]]:
now = time.monotonic()
return [
{
"key": s.key,
"agent": s.agent,
"kind": s.kind,
"session_id": s.session_id,
"pid": s.pid,
"rss": self.rss_of(s),
"idle_seconds": round(now - s.last_used, 1),
"age_seconds": round(now - s.created_at, 1),
"turns": s.turns,
"busy": s.busy,
"running_turn": s.running_turn,
"pending_question": s.pending_question,
"pinned": s.pinned,
"dirty": s.dirty,
}
for s in self._sessions.values()
]
def cgroup_limit() -> int | None:
for path in (
"/sys/fs/cgroup/memory.max",
"/sys/fs/cgroup/memory/memory.limit_in_bytes",
):
try:
raw = Path(path).read_text(encoding="ascii").strip()
except OSError:
continue
if raw.isdigit() and int(raw) < 1 << 60:
return int(raw)
return None
+543
View File
@@ -0,0 +1,543 @@
"""Anthropic messages <-> Agent SDK transcript entries.
:func:`build_entries` renders a message list as the entries the Claude CLI
itself writes (reference: ``t/spike_sdk/entries_reference.json``, CLI
2.1.248 via ``import_session_to_store``): one ``user`` entry per prompt,
one ``assistant`` entry per content block sharing a message id, one
``user`` entry per ``tool_result`` parented on the matching ``tool_use``
entry. Only ``user``/``assistant`` entries are produced - no attachments,
titles or queue markers. Appending the result to a session store and
resuming that session id seeds an external history into the SDK.
:func:`messages_from_entries` is the projection back, used by tests and by
anything that needs Anthropic-shape history out of a mirrored transcript.
"""
from __future__ import annotations
import hashlib
import uuid as _uuid
from collections.abc import Mapping
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
try:
from claude_agent_sdk._cli_version import __cli_version__ as _cli_version
except ImportError: # pragma: no cover
_cli_version = "2.1.248"
if TYPE_CHECKING:
from collections.abc import Iterable
__all__ = [
"CLI_VERSION",
"build_entries",
"close_open_tool_uses",
"fingerprint",
"messages_from_entries",
"open_tool_uses",
"prompt_count",
"render_messages",
"strip_tool_entries",
"text_of",
"window_entries",
]
CLI_VERSION = _cli_version
_ENTRYPOINT = "sdk-py"
_USER_TYPE = "external"
_PROMPT_SOURCE = "sdk"
_GIT_BRANCH = "HEAD"
def build_entries(
messages: Iterable[Mapping[str, Any]],
*,
session_id: str,
cwd: str,
model: str,
permission_mode: str = "bypassPermissions",
now: datetime | None = None,
) -> list[dict[str, Any]]:
stamp = (now or datetime.now(UTC)).strftime("%Y-%m-%dT%H:%M:%S.") + (
f"{(now or datetime.now(UTC)).microsecond // 1000:03d}Z"
)
common = {
"isSidechain": False,
"userType": _USER_TYPE,
"entrypoint": _ENTRYPOINT,
"cwd": cwd,
"sessionId": session_id,
"version": CLI_VERSION,
"gitBranch": _GIT_BRANCH,
}
entries: list[dict[str, Any]] = []
parent: str | None = None
prompt_id: str | None = None
tool_use_owner: dict[str, str] = {}
for message in messages:
role = message.get("role")
content = message.get("content")
if role == "user":
results = _tool_results(content)
if results:
for block in results:
owner = tool_use_owner.get(
str(block.get("tool_use_id", "")), parent
)
uid = _new_uuid()
entries.append(
{
"parentUuid": owner,
"promptId": prompt_id,
"type": "user",
"message": {"role": "user", "content": [block]},
"uuid": uid,
"timestamp": stamp,
"toolUseResult": _result_text(block),
"sourceToolAssistantUUID": owner,
**common,
}
)
parent = uid
continue
prompt_id = _new_uuid()
uid = _new_uuid()
entries.append(
{
"parentUuid": parent,
"promptId": prompt_id,
"type": "user",
"message": {"role": "user", "content": _user_content(content)},
"uuid": uid,
"timestamp": stamp,
"permissionMode": permission_mode,
"promptSource": _PROMPT_SOURCE,
**common,
}
)
parent = uid
elif role == "assistant":
blocks = _assistant_blocks(content)
message_id = f"msg_{_uuid.uuid4().hex[:24]}"
request_id = f"req_{_uuid.uuid4().hex[:24]}"
stop_reason = (
"tool_use"
if any(b.get("type") == "tool_use" for b in blocks)
else "end_turn"
)
for block in blocks:
uid = _new_uuid()
entries.append(
{
"parentUuid": parent,
"message": {
"model": model,
"id": message_id,
"type": "message",
"role": "assistant",
"content": [block],
"stop_reason": stop_reason,
"stop_sequence": None,
"stop_details": None,
"usage": _zero_usage(),
"diagnostics": None,
},
"requestId": request_id,
"type": "assistant",
"uuid": uid,
"timestamp": stamp,
**common,
}
)
if block.get("type") == "tool_use" and block.get("id"):
tool_use_owner[str(block["id"])] = uid
parent = uid
else:
msg = f"message role must be user or assistant, got {role!r}"
raise ValueError(msg)
return entries
def messages_from_entries(entries: Iterable[Mapping[str, Any]]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
last_message_id: str | None = None
for entry in entries:
kind = entry.get("type")
message = entry.get("message")
if kind not in ("user", "assistant") or not isinstance(message, dict):
continue
content = message.get("content")
if kind == "user":
results = _tool_results(content)
if (
results
and out
and out[-1]["role"] == "user"
and _tool_results(out[-1]["content"])
):
out[-1]["content"].extend(results)
else:
out.append({"role": "user", "content": _user_content(content)})
last_message_id = None
continue
blocks = _assistant_blocks(content)
message_id = message.get("id")
if (
out
and out[-1]["role"] == "assistant"
and message_id is not None
and message_id == last_message_id
):
out[-1]["content"].extend(blocks)
else:
out.append({"role": "assistant", "content": blocks})
last_message_id = message_id
return out
def _new_uuid() -> str:
return str(_uuid.uuid4())
def _tool_results(content: Any) -> list[dict[str, Any]]:
if not isinstance(content, list):
return []
return [
_clean_block(b)
for b in content
if isinstance(b, dict) and b.get("type") == "tool_result"
]
def _user_content(content: Any) -> Any:
if isinstance(content, str):
return content
if isinstance(content, list):
return [_clean_block(b) for b in content if isinstance(b, dict)]
msg = f"user content must be str or list, got {type(content).__name__}"
raise TypeError(msg)
def _assistant_blocks(content: Any) -> list[dict[str, Any]]:
if isinstance(content, str):
return [{"type": "text", "text": content}]
if isinstance(content, list):
blocks = [_clean_block(b) for b in content if isinstance(b, dict)]
if blocks:
return blocks
msg = "assistant content must be a non-empty list of blocks"
raise ValueError(msg)
def _clean_block(block: dict[str, Any]) -> dict[str, Any]:
kind = block.get("type")
if kind == "tool_use":
return {
"type": "tool_use",
"id": block.get("id", ""),
"name": block.get("name", ""),
"input": block.get("input", {}),
}
if kind == "tool_result":
out: dict[str, Any] = {
"type": "tool_result",
"tool_use_id": block.get("tool_use_id", ""),
"content": block.get("content", ""),
}
if block.get("is_error") is not None:
out["is_error"] = bool(block["is_error"])
return out
if kind == "thinking":
return {
"type": "thinking",
"thinking": block.get("thinking", ""),
"signature": block.get("signature", ""),
}
if kind == "text":
return {"type": "text", "text": str(block.get("text", ""))}
return dict(block)
def _result_text(block: dict[str, Any]) -> str:
content = block.get("content", "")
if isinstance(content, str):
return content
if isinstance(content, list):
return "\n".join(
str(b.get("text", ""))
for b in content
if isinstance(b, dict) and b.get("type") == "text"
)
return str(content)
def _zero_usage() -> dict[str, Any]:
return {
"input_tokens": 0,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0,
"output_tokens": 0,
"service_tier": "standard",
"server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0},
"cache_creation": {
"ephemeral_1h_input_tokens": 0,
"ephemeral_5m_input_tokens": 0,
},
"inference_geo": "not_available",
"iterations": [],
"speed": "standard",
}
# ---- repair, windows, projections ---------------------------------------
_PROMPT_TYPES = ("user", "assistant")
_INTERRUPTED = "прервано"
def open_tool_uses(
entries: Iterable[Mapping[str, Any]],
) -> list[tuple[Mapping[str, Any], dict[str, Any]]]:
"""``(assistant entry, tool_use block)`` pairs that never got a ``tool_result``."""
closed: set[str] = set()
uses: list[tuple[Mapping[str, Any], dict[str, Any]]] = []
for entry in entries:
content = _entry_content(entry)
if entry.get("type") == "user":
closed.update(
str(b.get("tool_use_id", ""))
for b in content
if b.get("type") == "tool_result"
)
elif entry.get("type") == "assistant":
uses.extend((entry, b) for b in content if b.get("type") == "tool_use")
return [
(owner, block)
for owner, block in uses
if str(block.get("id", "")) not in closed
]
def close_open_tool_uses(
entries: list[Mapping[str, Any]], *, text: str = _INTERRUPTED
) -> list[dict[str, Any]]:
"""Synthetic ``tool_result`` entries on the leaf, one per open ``tool_use``.
Appending the result to the session store gives the next ``resume`` a
transcript the CLI accepts: an assistant message ending in ``tool_use``
without its result is rejected by the API on the next call.
"""
pending = open_tool_uses(entries)
if not pending:
return []
leaf = next(
(
e
for e in reversed(entries)
if e.get("type") in _PROMPT_TYPES and e.get("uuid")
),
None,
)
parent = str(leaf["uuid"]) if leaf is not None else None
stamp = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S.000Z")
out: list[dict[str, Any]] = []
for owner, block in pending:
uid = _new_uuid()
result = {
"type": "tool_result",
"tool_use_id": block.get("id", ""),
"content": text,
"is_error": True,
}
out.append(
{
"parentUuid": parent,
"promptId": owner.get("promptId"),
"type": "user",
"message": {"role": "user", "content": [result]},
"uuid": uid,
"timestamp": stamp,
"toolUseResult": text,
"sourceToolAssistantUUID": owner.get("uuid"),
**{k: owner[k] for k in _COMMON_KEYS if k in owner},
}
)
parent = uid
return out
_COMMON_KEYS = (
"isSidechain",
"userType",
"entrypoint",
"cwd",
"sessionId",
"version",
"gitBranch",
)
def window_entries(
entries: Iterable[Mapping[str, Any]], *, window: int | None = None
) -> list[dict[str, Any]]:
"""``user``/``assistant`` entries only, last ``window`` prompts, relinked."""
kept = [
dict(e)
for e in entries
if e.get("type") in _PROMPT_TYPES and isinstance(e.get("uuid"), str)
]
if window is not None and window > 0:
starts = [i for i, e in enumerate(kept) if _is_prompt_entry(e)]
if len(starts) > window:
kept = kept[starts[-window] :]
return _relink(kept)
def strip_tool_entries(entries: Iterable[Mapping[str, Any]]) -> list[dict[str, Any]]:
"""Drop ``tool_result`` entries and ``tool_use``/``thinking`` blocks; relink."""
out: list[dict[str, Any]] = []
for raw in entries:
entry = dict(raw)
message = entry.get("message")
if not isinstance(message, dict):
out.append(entry)
continue
content = message.get("content")
if isinstance(content, list):
blocks = [
b
for b in content
if isinstance(b, dict)
and b.get("type") not in ("tool_use", "tool_result", "thinking")
]
if not blocks:
continue
entry["message"] = {**message, "content": blocks}
out.append(entry)
return _relink(out)
def prompt_count(entries: Iterable[Mapping[str, Any]]) -> int:
return sum(1 for e in entries if _is_prompt_entry(e))
def render_messages(
messages: Iterable[Mapping[str, Any]], *, window: int | None = None
) -> str:
"""Plain-text projection for ``read_conversation``: ``user:``/``assistant:`` turns.
Tool calls collapse to a one-line summary per assistant turn; tool
results and thinking are dropped.
"""
turns: list[str] = []
tools: list[str] = []
current: list[str] = []
def flush() -> None:
if not current and not tools:
return
body = "\n\n".join(current).strip()
if tools:
body = (body + "\n" if body else "") + "(tools: " + ", ".join(tools) + ")"
turns.append("assistant:\n" + body)
current.clear()
tools.clear()
for message in messages:
role = message.get("role")
content = message.get("content")
if role == "user":
if _tool_results(content):
continue
flush()
turns.append("user:\n" + _text_of_content(content))
continue
for block in _assistant_blocks(content):
if block.get("type") == "text" and block.get("text"):
current.append(str(block["text"]))
elif block.get("type") == "tool_use":
tools.append(str(block.get("name", "")))
flush()
if window is not None and window > 0:
starts = [i for i, t in enumerate(turns) if t.startswith("user:")]
if len(starts) > window:
turns = turns[starts[-window] :]
return "\n\n".join(turns)
def _entry_content(entry: Mapping[str, Any]) -> list[dict[str, Any]]:
message = entry.get("message")
if not isinstance(message, dict):
return []
content = message.get("content")
if not isinstance(content, list):
return []
return [b for b in content if isinstance(b, dict)]
def _is_prompt_entry(entry: Mapping[str, Any]) -> bool:
if entry.get("type") != "user":
return False
message = entry.get("message")
if not isinstance(message, dict):
return False
content = message.get("content")
if isinstance(content, str):
return True
return not _tool_results(content)
def _relink(entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
parent: str | None = None
for entry in entries:
entry["parentUuid"] = parent
parent = entry.get("uuid")
return entries
def _text_of_content(content: Any) -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
return "\n\n".join(
str(b.get("text", ""))
for b in content
if isinstance(b, dict) and b.get("type") == "text" and b.get("text")
)
return ""
def fingerprint(messages: Iterable[Mapping[str, Any]]) -> str:
"""Text-only hash of a history; stateless callers are keyed by it."""
turns: list[tuple[str, str]] = []
for message in messages:
text = text_of(message.get("content"))
if not text:
continue
role = str(message.get("role", ""))
if turns and turns[-1][0] == role:
turns[-1] = (role, turns[-1][1] + "\n" + text)
else:
turns.append((role, text))
digest = hashlib.sha1(usedforsecurity=False)
for role, text in turns:
digest.update(role.encode("utf-8"))
digest.update(b"\x00")
digest.update(text.strip().encode("utf-8"))
digest.update(b"\x01")
return digest.hexdigest()
def text_of(content: Any) -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
parts = [
str(b.get("text", ""))
for b in content
if isinstance(b, Mapping) and b.get("type") == "text"
]
return "\n".join(p for p in parts if p)
return ""
+41
View File
@@ -0,0 +1,41 @@
"""Side channel for what a backend learned during one turn.
Frontends pass ``capture=TurnCapture()`` through ``Backend.complete``'s
``**options``. Backends that keep state per conversation (the Claude SDK
adapter) fill it in after the stream closes; backends that don't
(anthropic, raycast) drop the kwarg and the frontend falls back to a
text-only history.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
__all__ = ["TurnCapture", "TurnUsage"]
@dataclass(frozen=True, slots=True)
class TurnUsage:
input_tokens: int = 0
output_tokens: int = 0
cache_read_tokens: int = 0
cache_creation_tokens: int = 0
cost_usd: float | None = None
duration_ms: int | None = None
num_turns: int | None = None
model_usage: dict[str, Any] | None = None
"""``ResultMessage.model_usage`` verbatim: per-model tokens, cost, web searches."""
@dataclass
class TurnCapture:
synthesized_messages: list[dict[str, Any]] = field(default_factory=list)
"""The assistant/tool_result cycle of the turn as Anthropic messages."""
session_id: str | None = None
"""Backend session that ran the turn; persist and pass back as ``session_id``."""
usage: TurnUsage | None = None
interrupted: bool = False
"""The turn was cut by ``interrupt()`` (urgent inject), not by an error."""
+63
View File
@@ -0,0 +1,63 @@
"""Server-sent events helpers shared by the markdown and API frontends."""
from __future__ import annotations
import asyncio
import contextlib
import json
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import AsyncIterator
__all__ = ["HEARTBEAT_INTERVAL", "SSE_HEADERS", "events_with_heartbeat", "sse_pack"]
HEARTBEAT_INTERVAL = 15.0
"""Seconds of backend silence before a comment frame keeps the socket warm."""
SSE_HEADERS = {
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
}
KEEPALIVE = b": keepalive\n\n"
async def events_with_heartbeat(
events: AsyncIterator[Any], interval: float = HEARTBEAT_INTERVAL
) -> AsyncIterator[Any]:
"""Pass ``events`` through, yielding ``None`` after ``interval`` seconds of silence.
One in-flight ``__anext__`` task is reused across timeouts: a second
consumer on the same async generator raises ``RuntimeError``.
Cancellation of the outer scope cancels that task instead of leaving
it dangling.
"""
src = events.__aiter__()
next_task: asyncio.Task[Any] | None = None
try:
while True:
if next_task is None:
next_task = asyncio.ensure_future(src.__anext__())
done, _pending = await asyncio.wait({next_task}, timeout=interval)
if not done:
yield None
continue
task = next_task
next_task = None
try:
result = task.result()
except StopAsyncIteration:
return
yield result
finally:
if next_task is not None and not next_task.done():
next_task.cancel()
with contextlib.suppress(BaseException):
await next_task
def sse_pack(event: str, data: dict[str, Any]) -> bytes:
body = json.dumps(data, ensure_ascii=False)
return f"event: {event}\ndata: {body}\n\n".encode()
@@ -1,10 +1,4 @@
"""Admin UI (Phase 4.3).
Browser-facing console: login, dashboard, token CRUD, audit viewer.
Templates live in ``./templates``; the package loader picks them up via
``importlib.resources`` so the layout works editable and inside the
Docker image without copy hacks.
"""
"""Admin console: the ``ui/`` SPA served from the admin port plus its login."""
from beaver_gateway.frontends.admin.frontend import AdminFrontend
File diff suppressed because it is too large. Load diff
@@ -1,206 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}beaver-gateway · admin{% endblock %}</title>
<script src="https://unpkg.com/htmx.org@2.0.4" defer></script>
<style>
:root {
--bg: #fbfbfd;
--fg: #1d1d1f;
--muted: #6e6e73;
--line: #e5e5ea;
--accent: #0071e3;
--danger: #d70015;
--surface: #ffffff;
--code-bg: #f5f5f7;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--fg); }
body {
font-family: -apple-system, "SF Pro Display", "SF Pro Text",
BlinkMacSystemFont, system-ui, sans-serif;
font-size: 15px;
line-height: 1.55;
letter-spacing: -0.005em;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
code, pre, kbd, samp {
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 0.92em;
}
pre {
background: var(--code-bg);
padding: 1rem 1.25rem;
border-radius: 12px;
overflow-x: auto;
}
header.top {
border-bottom: 1px solid var(--line);
background: var(--surface);
}
header.top .inner {
max-width: 1080px;
margin: 0 auto;
padding: 1rem 1.5rem;
display: flex;
align-items: center;
gap: 1.25rem;
}
header.top h1 {
font-size: 1.05rem;
font-weight: 600;
letter-spacing: -0.01em;
margin: 0;
}
nav.tabs { display: flex; gap: 1.1rem; flex: 1; }
nav.tabs a {
color: var(--fg);
padding: 0.25rem 0.1rem;
border-bottom: 2px solid transparent;
}
nav.tabs a.active {
border-bottom-color: var(--fg);
}
header.top .actor {
color: var(--muted);
font-size: 0.9em;
}
main {
max-width: 1080px;
margin: 0 auto;
padding: 2.25rem 1.5rem 4rem;
}
h2 {
font-weight: 600;
letter-spacing: -0.015em;
margin: 2rem 0 0.75rem;
}
h2:first-of-type { margin-top: 0; }
.muted { color: var(--muted); }
.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 14px;
padding: 1.25rem 1.5rem;
margin-bottom: 1.25rem;
}
table { width: 100%; border-collapse: collapse; }
th, td {
text-align: left;
padding: 0.6rem 0.85rem;
border-bottom: 1px solid var(--line);
vertical-align: top;
}
th {
font-weight: 500;
color: var(--muted);
font-size: 0.85em;
text-transform: uppercase;
letter-spacing: 0.04em;
}
tr:last-child td { border-bottom: none; }
.revoked td { color: var(--muted); }
button, .btn {
font-family: inherit;
font-size: 0.92em;
padding: 0.45rem 0.95rem;
border-radius: 8px;
border: 1px solid var(--line);
background: var(--surface);
color: var(--fg);
cursor: pointer;
transition: background 120ms ease;
}
button:hover, .btn:hover { background: var(--code-bg); }
button.primary, .btn.primary {
background: var(--accent);
border-color: var(--accent);
color: white;
}
button.primary:hover, .btn.primary:hover {
background: #005bb5;
}
button.danger { color: var(--danger); border-color: #f0c5c9; }
button.danger:hover { background: #fdeff1; }
form.inline { display: inline; margin: 0; }
.form-grid {
display: grid;
grid-template-columns: minmax(180px, 1fr) 160px auto;
gap: 0.75rem;
align-items: end;
}
.form-grid label {
display: block;
font-size: 0.8em;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.3rem;
}
input[type="text"], input[type="password"], select {
width: 100%;
padding: 0.55rem 0.7rem;
border: 1px solid var(--line);
border-radius: 8px;
font-family: inherit;
font-size: 0.95em;
background: var(--surface);
color: var(--fg);
}
input:focus, select:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
.banner {
padding: 1rem 1.25rem;
border-radius: 12px;
background: #f0f9ff;
border: 1px solid #cfe8ff;
margin: 0 0 1.25rem;
}
.banner.error {
background: #fff0f0;
border-color: #f5c2c5;
color: var(--danger);
}
.banner.warn {
background: #fff7e6;
border-color: #fadf9c;
}
.pill {
display: inline-block;
padding: 0.1rem 0.55rem;
border-radius: 999px;
background: var(--code-bg);
font-size: 0.78em;
color: var(--muted);
}
.pill.scope-wild { background: #ecfdf3; color: #027a48; }
.pill.scope-admin { background: #fff4e6; color: #b54708; }
</style>
</head>
<body>
{% block header %}
<header class="top">
<div class="inner">
<h1>beaver-gateway</h1>
<nav class="tabs">
<a href="{{ p }}/" class="{% if active == 'dashboard' %}active{% endif %}">Dashboard</a>
<a href="{{ p }}/chat" class="{% if active == 'chat' %}active{% endif %}">Chat</a>
<a href="{{ p }}/pty" class="{% if active == 'pty' %}active{% endif %}">PTY</a>
<a href="{{ p }}/tokens" class="{% if active == 'tokens' %}active{% endif %}">Tokens</a>
<a href="{{ p }}/audit" class="{% if active == 'audit' %}active{% endif %}">Audit</a>
</nav>
<span class="actor">Signed in as <strong>{{ user }}</strong></span>
<form class="inline" method="post" action="{{ p }}/logout">
<input type="hidden" name="csrf_token" value="{{ csrf }}">
<button type="submit">Log out</button>
</form>
</div>
</header>
{% endblock %}
<main>
{% block content %}{% endblock %}
</main>
</body>
</html>
@@ -1,12 +0,0 @@
{# Renders into #token-create-result (hx-target on the form). The
two OOB swaps reach the rest of the page: prepend the new row,
and erase the empty-state placeholder if it's still in the DOM. #}
<div class="banner warn">
<strong>Token created: {{ token.name }}</strong>
<p class="muted" style="margin:0.25rem 0 0.5rem;">Copy it now — this is the only time it will be shown.</p>
<pre style="margin:0;">{{ plaintext }}</pre>
</div>
<tbody id="tokens-rows" hx-swap-oob="afterbegin">
{% include "_token_row.html" %}
</tbody>
<tr id="tokens-empty" hx-swap-oob="delete"></tr>
@@ -1 +0,0 @@
<div class="banner error">{{ message }}</div>
@@ -1,25 +0,0 @@
<tr id="token-row-{{ token.id }}" {% if token.revoked_at %}class="revoked"{% endif %}>
<td><strong>{{ token.name }}</strong></td>
<td>
<span class="pill {% if token.scope == '*' %}scope-wild{% elif token.scope == 'admin' %}scope-admin{% endif %}">{{ token.scope }}</span>
</td>
<td><code>{{ token.created_at | fmt_dt }}</code></td>
<td><code>{{ token.last_used_at | fmt_dt }}</code></td>
<td><code>{{ token.revoked_at | fmt_dt }}</code></td>
<td style="text-align:right;">
{% if not token.revoked_at %}
<form
class="inline"
hx-post="{{ p }}/tokens/{{ token.id }}/revoke"
hx-target="#token-row-{{ token.id }}"
hx-swap="outerHTML"
hx-confirm="Revoke token {{ token.name }}? This cannot be undone."
>
<input type="hidden" name="csrf_token" value="{{ csrf }}">
<button class="danger" type="submit">Revoke</button>
</form>
{% else %}
<span class="muted">revoked</span>
{% endif %}
</td>
</tr>
@@ -1,37 +0,0 @@
{% extends "_layout.html" %}
{% set active = "audit" %}
{% block title %}beaver-gateway · audit{% endblock %}
{% block content %}
<h2>Audit log</h2>
<div class="card">
{% if audit %}
<table>
<thead>
<tr>
<th>Time</th><th>Actor</th><th>Kind</th><th>Agent</th><th>Detail</th>
</tr>
</thead>
<tbody>
{% for row in audit %}
<tr>
<td><code>{{ row.ts | fmt_dt }}</code></td>
<td>{{ row.actor }}</td>
<td><span class="pill">{{ row.kind }}</span></td>
<td>{{ row.agent_name or "—" }}</td>
<td><code>{{ row.detail_json | fmt_detail }}</code></td>
</tr>
{% endfor %}
</tbody>
</table>
<p class="muted" style="margin-top:1rem;">
{% if next_before %}
<a href="{{ p }}/audit?before={{ next_before }}">Older entries →</a>
{% else %}
End of log.
{% endif %}
</p>
{% else %}
<p class="muted">Nothing logged yet.</p>
{% endif %}
</div>
{% endblock %}
@@ -1,355 +0,0 @@
{% extends "_layout.html" %}
{% set active = "chat" %}
{% block title %}beaver-gateway · chat{% endblock %}
{% block content %}
<div class="chat-wrap">
<div class="chat-toolbar">
<label>
<span>Agent</span>
<select id="agent-select">
{% for a in agents %}
<option value="{{ a.name }}">{{ a.name }} · {{ a.model }}</option>
{% else %}
<option disabled>no agents with a backend</option>
{% endfor %}
</select>
</label>
<div class="spacer"></div>
<button id="new-chat-btn" type="button">New chat</button>
</div>
<div id="messages" class="chat-messages" aria-live="polite"></div>
<form id="chat-form" class="chat-input">
<textarea id="chat-text" rows="3" placeholder="Message… (⌘/Ctrl+Enter to send)" required></textarea>
<button type="submit" class="primary" id="send-btn">Send</button>
</form>
</div>
<style>
.chat-wrap {
display: flex; flex-direction: column;
height: calc(100vh - 200px); min-height: 480px;
}
.chat-toolbar {
display: flex; gap: 0.85rem; align-items: end;
margin-bottom: 1rem;
}
.chat-toolbar .spacer { flex: 1; }
.chat-toolbar label {
display: flex; flex-direction: column; gap: 0.3rem;
min-width: 260px;
}
.chat-toolbar label > span {
font-size: 0.8em; color: var(--muted);
text-transform: uppercase; letter-spacing: 0.05em;
}
.chat-messages {
flex: 1; overflow-y: auto;
background: var(--surface); border: 1px solid var(--line);
border-radius: 14px; padding: 1.25rem;
display: flex; flex-direction: column; gap: 1rem;
}
.chat-empty { color: var(--muted); margin: auto; }
.msg { display: flex; }
.msg.user { justify-content: flex-end; }
.msg.user .bubble {
max-width: 78%; padding: 0.7rem 0.95rem; border-radius: 14px;
background: var(--accent); color: white;
white-space: pre-wrap; word-wrap: break-word;
}
.msg.assistant .blocks {
display: flex; flex-direction: column; gap: 0.5rem;
max-width: 88%;
}
.block-text {
background: var(--code-bg); padding: 0.7rem 0.95rem;
border-radius: 14px;
white-space: pre-wrap; word-wrap: break-word;
}
details.tool-call, details.thinking-block {
border: 1px solid var(--line); border-radius: 10px;
background: var(--surface); padding: 0 0.85rem;
font-size: 0.9em;
}
details.tool-call > summary,
details.thinking-block > summary {
cursor: pointer; padding: 0.55rem 0;
list-style: none;
display: flex; align-items: center; gap: 0.55rem;
}
details > summary::-webkit-details-marker { display: none; }
details.tool-call > summary::before,
details.thinking-block > summary::before {
content: "▸"; color: var(--muted); font-size: 0.78em;
}
details.tool-call[open] > summary::before,
details.thinking-block[open] > summary::before { content: "▾"; }
.tool-name {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-weight: 500;
}
.tool-id, .tool-label {
color: var(--muted); font-size: 0.78em;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
}
details.tool-call pre, details.thinking-block pre {
margin: 0 0 0.6rem; padding: 0.7rem 0.85rem;
background: var(--code-bg); border-radius: 8px;
font-size: 0.85em; max-height: 360px; overflow: auto;
}
.tool-label { display: block; margin: 0.15rem 0 0.25rem; }
.chat-input { display: flex; gap: 0.75rem; margin-top: 1rem; }
.chat-input textarea {
flex: 1; padding: 0.7rem 0.9rem;
border: 1px solid var(--line); border-radius: 12px;
font-family: inherit; font-size: 0.95em;
background: var(--surface); color: var(--fg); resize: vertical;
}
.chat-input textarea:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
.chat-input button { align-self: stretch; padding-left: 1.4rem; padding-right: 1.4rem; }
.chat-error {
color: var(--danger); font-size: 0.85em;
padding: 0.55rem 0.75rem;
background: #fff0f0; border-radius: 10px;
border: 1px solid #f5c2c5;
}
.chat-input button:disabled { opacity: 0.55; cursor: progress; }
</style>
<script>
(() => {
const CSRF = {{ csrf | tojson }};
const URL_PREFIX = {{ p | tojson }};
const agentSelect = document.getElementById("agent-select");
const messagesEl = document.getElementById("messages");
const form = document.getElementById("chat-form");
const textEl = document.getElementById("chat-text");
const sendBtn = document.getElementById("send-btn");
const newBtn = document.getElementById("new-chat-btn");
// Anthropic-style history sent to the backend. We keep assistant
// content text-only — tool_use blocks can't round-trip without
// matching tool_result, and backends (claude-code) run tools
// internally anyway.
let apiMessages = [];
function renderEmpty() {
messagesEl.innerHTML = '<div class="chat-empty">No messages yet — say something.</div>';
}
renderEmpty();
newBtn.addEventListener("click", () => {
apiMessages = [];
renderEmpty();
});
function clearEmpty() {
const e = messagesEl.querySelector(".chat-empty");
if (e) e.remove();
}
function scrollDown() { messagesEl.scrollTop = messagesEl.scrollHeight; }
function appendUser(text) {
clearEmpty();
const row = document.createElement("div");
row.className = "msg user";
const bub = document.createElement("div");
bub.className = "bubble";
bub.textContent = text;
row.appendChild(bub);
messagesEl.appendChild(row);
scrollDown();
}
function appendAssistant() {
clearEmpty();
const row = document.createElement("div");
row.className = "msg assistant";
const blocks = document.createElement("div");
blocks.className = "blocks";
row.appendChild(blocks);
messagesEl.appendChild(row);
scrollDown();
return blocks;
}
function appendError(text) {
clearEmpty();
const e = document.createElement("div");
e.className = "chat-error";
e.textContent = text;
messagesEl.appendChild(e);
scrollDown();
}
function ensureBlock(state, index, type, extra) {
if (state.blocks[index]) return state.blocks[index];
const block = { type };
if (type === "text") {
const el = document.createElement("div");
el.className = "block-text";
state.container.appendChild(el);
block.el = el;
} else if (type === "thinking") {
const det = document.createElement("details");
det.className = "thinking-block";
const sum = document.createElement("summary");
sum.textContent = "Thinking";
det.appendChild(sum);
const pre = document.createElement("pre");
det.appendChild(pre);
state.container.appendChild(det);
block.el = pre;
} else if (type === "tool_use") {
const det = document.createElement("details");
det.className = "tool-call";
const sum = document.createElement("summary");
const name = document.createElement("span");
name.className = "tool-name";
name.textContent = "🔧 " + (extra.name || "tool");
sum.appendChild(name);
if (extra.id) {
const idEl = document.createElement("span");
idEl.className = "tool-id";
idEl.textContent = extra.id;
sum.appendChild(idEl);
}
det.appendChild(sum);
const label = document.createElement("span");
label.className = "tool-label";
label.textContent = "input";
det.appendChild(label);
const pre = document.createElement("pre");
det.appendChild(pre);
state.container.appendChild(det);
block.el = pre;
block.jsonBuf = "";
block.seedInput = extra.input;
}
state.blocks[index] = block;
return block;
}
function applyEvent(state, ev) {
const t = ev.type;
if (t === "content_block_start") {
const cb = ev.content_block || {};
if (cb.type === "text") {
ensureBlock(state, ev.index, "text", {});
} else if (cb.type === "thinking") {
ensureBlock(state, ev.index, "thinking", {});
} else if (cb.type === "tool_use") {
ensureBlock(state, ev.index, "tool_use",
{ name: cb.name, id: cb.id, input: cb.input });
}
} else if (t === "content_block_delta") {
const d = ev.delta || {};
const b = state.blocks[ev.index];
if (!b) return;
if (d.type === "text_delta") {
b.el.textContent += d.text || "";
state.assistantText += d.text || "";
scrollDown();
} else if (d.type === "thinking_delta") {
b.el.textContent += d.thinking || "";
scrollDown();
} else if (d.type === "input_json_delta") {
b.jsonBuf += d.partial_json || "";
}
} else if (t === "content_block_stop") {
const b = state.blocks[ev.index];
if (!b) return;
if (b.type === "tool_use") {
let input = null;
if (b.jsonBuf && b.jsonBuf.trim()) {
try { input = JSON.parse(b.jsonBuf); }
catch { input = b.jsonBuf; }
} else if (b.seedInput !== undefined && b.seedInput !== null) {
input = b.seedInput;
}
b.el.textContent = input === null
? "(no input)"
: (typeof input === "string"
? input
: JSON.stringify(input, null, 2));
}
} else if (t === "error") {
const msg = (ev.error && ev.error.message) || "stream error";
appendError(msg);
}
}
async function send() {
const text = textEl.value.trim();
if (!text) return;
const model = agentSelect.value;
if (!model) { appendError("no agent selected"); return; }
apiMessages.push({ role: "user", content: text });
appendUser(text);
textEl.value = "";
sendBtn.disabled = true;
const container = appendAssistant();
const state = { container, blocks: {}, assistantText: "" };
let resp;
try {
resp = await fetch(URL_PREFIX + "/chat/send", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": CSRF,
},
body: JSON.stringify({ model, messages: apiMessages }),
});
} catch (e) {
appendError("network error: " + e.message);
sendBtn.disabled = false;
return;
}
if (!resp.ok || !resp.body) {
let msg = resp.status + " " + resp.statusText;
try { const body = await resp.text(); if (body) msg = body; } catch {}
appendError(msg);
sendBtn.disabled = false;
return;
}
const reader = resp.body.getReader();
const dec = new TextDecoder();
let buf = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf("\n\n")) >= 0) {
const raw = buf.slice(0, i);
buf = buf.slice(i + 2);
if (!raw) continue;
let dataLine = "";
for (const line of raw.split("\n")) {
if (line.startsWith("data:")) dataLine += line.slice(5).trimStart();
}
if (!dataLine) continue;
let payload;
try { payload = JSON.parse(dataLine); } catch { continue; }
applyEvent(state, payload);
}
}
if (state.assistantText) {
apiMessages.push({ role: "assistant", content: state.assistantText });
}
sendBtn.disabled = false;
textEl.focus();
}
form.addEventListener("submit", (e) => { e.preventDefault(); send(); });
textEl.addEventListener("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
send();
}
});
})();
</script>
{% endblock %}
@@ -1,490 +0,0 @@
{% extends "_layout.html" %}
{% set active = "dashboard" %}
{% block title %}beaver-gateway · dashboard{% endblock %}
{% block content %}
<h2>Agents</h2>
<div class="card">
{% if agents %}
<table>
<thead><tr><th>Name</th><th>Type</th><th>Model</th><th>Exposed MCPs</th></tr></thead>
<tbody>
{% for a in agents %}
<tr>
<td><code>{{ a.name }}</code></td>
<td><span class="pill">{{ a.__class__.__name__ }}</span></td>
<td><code>{{ a.model }}</code></td>
<td>
{% for em in a.expose_mcps %}<code>{{ em.name }}</code>{% if not loop.last %}, {% endif %}{% else %}<span class="muted">—</span>{% endfor %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="muted">No agents configured.</p>
{% endif %}
</div>
<h2>MCP namespaces</h2>
<div class="card">
{% if mcps %}
<table>
<thead><tr><th>Name</th><th>Kind</th></tr></thead>
<tbody>
{% for m in mcps %}
<tr>
<td><code>{{ m.name }}</code></td>
<td><span class="pill">{{ m.kind }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="muted">No MCP servers configured.</p>
{% endif %}
</div>
<h2>Endpoints</h2>
<div class="card endpoints">
{% if endpoints.agents or endpoints.mcps %}
<div class="ep-controls">
<label class="ep-token">
<span>Token (hint)</span>
<select id="ep-token-select">
<option value="" data-scope="">— select a known token —</option>
{% for t in tokens %}
<option value="{{ t.name }}" data-scope="{{ t.scope }}">{{ t.name }} · scope {{ t.scope }}</option>
{% endfor %}
</select>
</label>
<label class="ep-secret">
<span>Bearer secret</span>
<input type="password" id="ep-token-secret" autocomplete="off" spellcheck="false"
placeholder="paste plaintext (we only store the Argon2 hash)">
</label>
<label class="ep-show">
<input type="checkbox" id="ep-show-secret"> <span>show</span>
</label>
</div>
<p class="muted ep-note">
Beaver stores only an Argon2 hash of each token, so the plaintext can't be reconstructed.
Paste the value you saved at creation; if you've lost it, <a href="{{ p }}/tokens">mint a new one</a>.
Below: pick a token to see which endpoints its scope covers, paste the secret to fill it
into URL / curl, then click Copy.
</p>
{% if endpoints.agents %}
<h3 class="ep-h3">Agents — <code>POST /v1/messages</code></h3>
<table class="ep-table" data-required-scope="messages">
<thead><tr><th>Agent</th><th>Model</th><th>URL</th><th class="ep-actions-th"></th></tr></thead>
<tbody>
{% for ep in endpoints.agents %}
<tr class="ep-row" data-kind="messages" data-agent="{{ ep.agent }}" data-url="{{ ep.url }}">
<td>
<code>{{ ep.agent }}</code>
<span class="pill">{{ ep.agent_type }}</span>
<span class="ep-scope-warn" hidden>scope mismatch</span>
</td>
<td><code>{{ ep.model }}</code></td>
<td><code class="ep-url">{{ ep.url }}</code></td>
<td class="ep-actions">
<button type="button" data-action="copy-url">Copy URL</button>
<button type="button" data-action="copy-curl">Copy curl</button>
<button type="button" data-action="toggle-curl" aria-expanded="false">▸ curl</button>
</td>
</tr>
<tr class="ep-curl-row" hidden><td colspan="4"><pre class="ep-curl"></pre></td></tr>
{% endfor %}
</tbody>
</table>
{% elif endpoints.anthropic_base is none and agents %}
<p class="muted">
Agents are declared but no <code>AnthropicMessagesFrontend</code> is configured —
add one to <code>Gateway(frontends=[...])</code> to expose them over HTTP.
</p>
{% endif %}
{% if endpoints.mcps %}
<h3 class="ep-h3">MCP — streamable HTTP</h3>
<table class="ep-table" data-required-scope="mcp">
<thead><tr><th>Namespace</th><th>Kind</th><th>URL</th><th class="ep-actions-th"></th></tr></thead>
<tbody>
{% for ep in endpoints.mcps %}
<tr class="ep-row" data-kind="mcp" data-namespace="{{ ep.namespace }}" data-url="{{ ep.url }}">
<td>
<code>{{ ep.namespace }}</code>
<span class="ep-scope-warn" hidden>scope mismatch</span>
</td>
<td><span class="pill">{{ ep.kind }}</span></td>
<td><code class="ep-url">{{ ep.url }}</code></td>
<td class="ep-actions">
<button type="button" data-action="copy-url">Copy URL</button>
<button type="button" data-action="copy-url-token">Copy URL+token</button>
<button type="button" data-action="copy-curl">Copy curl</button>
<button type="button" data-action="toggle-curl" aria-expanded="false">▸ curl</button>
</td>
</tr>
<tr class="ep-curl-row" hidden><td colspan="4"><pre class="ep-curl"></pre></td></tr>
{% endfor %}
</tbody>
</table>
{% elif endpoints.mcp_base is none and mcps %}
<p class="muted">
MCP servers are declared but no <code>McpServerFrontend</code> is configured —
add one to <code>Gateway(frontends=[...])</code> to expose them over HTTP.
</p>
{% endif %}
{% if endpoints.markdown %}
<h3 class="ep-h3">Markdown — <code>POST /chat</code></h3>
<table class="ep-table" data-required-scope="messages">
<thead><tr><th>Vault</th><th>Default agent</th><th>URL</th><th class="ep-actions-th"></th></tr></thead>
<tbody>
<tr class="ep-row"
data-kind="markdown"
data-url="{{ endpoints.markdown.url }}"
data-base="{{ endpoints.markdown.base }}"
data-sample-agent="{{ endpoints.markdown.sample_agent }}">
<td>
<code>{{ endpoints.markdown.vault_path }}</code>
<span class="ep-scope-warn" hidden>scope mismatch</span>
{% if endpoints.markdown.log_all_chats %}
<span class="pill">log_all_chats · {{ endpoints.markdown.logged_subdir }}/</span>
{% endif %}
</td>
<td>
{% if endpoints.markdown.default_agent %}
<code>{{ endpoints.markdown.default_agent }}</code>
{% else %}
<span class="muted">— (request must set <code>agent</code> or frontmatter)</span>
{% endif %}
</td>
<td>
<code class="ep-url">{{ endpoints.markdown.url }}</code>
<div class="muted" style="font-size:0.85em; margin-top:0.25rem;">
plugin base: <code>{{ endpoints.markdown.base }}</code>
</div>
</td>
<td class="ep-actions">
<button type="button" data-action="copy-url">Copy URL</button>
<button type="button" data-action="copy-base">Copy plugin base</button>
<button type="button" data-action="copy-curl">Copy curl</button>
<button type="button" data-action="toggle-curl" aria-expanded="false">▸ curl</button>
</td>
</tr>
<tr class="ep-curl-row" hidden><td colspan="4"><pre class="ep-curl"></pre></td></tr>
</tbody>
</table>
{% endif %}
{% else %}
<p class="muted">
Nothing exposed yet — declare agents / MCPs and the matching frontends
(<code>AnthropicMessagesFrontend</code>, <code>McpServerFrontend</code>) in your config.
</p>
{% endif %}
</div>
<h2>Recent activity</h2>
<div class="card">
{% if audit %}
<table>
<thead><tr><th>Time</th><th>Actor</th><th>Kind</th><th>Agent</th><th>Detail</th></tr></thead>
<tbody>
{% for row in audit %}
<tr>
<td><code>{{ row.ts | fmt_dt }}</code></td>
<td>{{ row.actor }}</td>
<td><span class="pill">{{ row.kind }}</span></td>
<td>{{ row.agent_name or "—" }}</td>
<td><code>{{ row.detail_json | fmt_detail }}</code></td>
</tr>
{% endfor %}
</tbody>
</table>
<p class="muted" style="margin-top:0.85rem;"><a href="{{ p }}/audit">Full log →</a></p>
{% else %}
<p class="muted">Nothing logged yet.</p>
{% endif %}
</div>
<style>
.endpoints .ep-controls {
display: grid;
grid-template-columns: minmax(220px, 1fr) 2fr auto;
gap: 0.85rem;
align-items: end;
margin-bottom: 0.5rem;
}
.endpoints .ep-controls label > span {
display: block;
font-size: 0.8em; color: var(--muted);
text-transform: uppercase; letter-spacing: 0.05em;
margin-bottom: 0.3rem;
}
.endpoints .ep-show {
display: flex; align-items: center; gap: 0.4rem;
padding-bottom: 0.6rem; color: var(--muted); font-size: 0.9em;
}
.endpoints .ep-note {
font-size: 0.85em; margin: 0.25rem 0 1.2rem;
}
.endpoints .ep-h3 {
font-size: 0.95rem; font-weight: 600;
margin: 1.5rem 0 0.55rem;
}
.endpoints .ep-table { table-layout: auto; }
.endpoints .ep-table th.ep-actions-th { width: 1%; }
.endpoints .ep-url {
word-break: break-all;
}
.endpoints .ep-actions {
white-space: nowrap;
text-align: right;
}
.endpoints .ep-actions button {
padding: 0.32rem 0.7rem;
font-size: 0.85em;
margin-left: 0.3rem;
}
.endpoints .ep-scope-warn {
display: inline-block;
margin-left: 0.5rem;
padding: 0.05rem 0.45rem;
background: #fff7e6;
border: 1px solid #fadf9c;
color: #8a5a00;
border-radius: 999px;
font-size: 0.72em;
}
.endpoints .ep-row.scope-mismatch td:not(.ep-actions) { opacity: 0.55; }
.endpoints .ep-row.scope-mismatch .ep-actions button { opacity: 0.7; }
.endpoints .ep-curl-row td { padding-top: 0; padding-bottom: 0; }
.endpoints .ep-curl {
margin: 0.4rem 0 1rem;
font-size: 0.82em;
max-height: 280px;
}
.endpoints button[data-copied="1"] {
background: #ecfdf3 !important;
border-color: #b6e6cb !important;
color: #027a48;
}
@media (max-width: 760px) {
.endpoints .ep-controls { grid-template-columns: 1fr; }
.endpoints .ep-actions { text-align: left; padding-top: 0.4rem; }
}
</style>
<script>
(() => {
const card = document.querySelector(".endpoints");
if (!card) return;
const sel = card.querySelector("#ep-token-select");
const secret = card.querySelector("#ep-token-secret");
const showCb = card.querySelector("#ep-show-secret");
if (!sel || !secret || !showCb) return;
const PLACEHOLDER = "<YOUR_TOKEN>";
function currentToken() {
const v = secret.value;
return v && v.length > 0 ? v : null;
}
function selectedScope() {
const o = sel.options[sel.selectedIndex];
return o ? (o.getAttribute("data-scope") || "") : "";
}
function scopeCovers(have, need) {
if (!have) return true; // no token selected — don't grey anything out
if (have === "*") return true;
return have === need;
}
function escSh(s) {
// single-quote shell escape: close, escape, reopen.
return "'" + String(s).replace(/'/g, "'\\''") + "'";
}
function buildCurl(row) {
const kind = row.getAttribute("data-kind");
const url = row.getAttribute("data-url");
const tok = currentToken() || PLACEHOLDER;
if (kind === "messages") {
const agent = row.getAttribute("data-agent");
const body = JSON.stringify({
model: agent,
messages: [{ role: "user", content: "hello" }],
stream: true,
});
return [
"curl -N \\",
" -H " + escSh("x-api-key: " + tok) + " \\",
" -H 'content-type: application/json' \\",
" -d " + escSh(body) + " \\",
" " + url,
].join("\n");
}
if (kind === "markdown") {
const agent = row.getAttribute("data-sample-agent") || "";
const body = JSON.stringify({
filename: "example.md",
content: "### User:\n\nhello\n",
agent: agent,
});
return [
"curl \\",
" -H " + escSh("Authorization: Bearer " + tok) + " \\",
" -H 'content-type: application/json' \\",
" -d " + escSh(body) + " \\",
" " + url,
].join("\n");
}
if (kind === "mcp") {
// Streamable-HTTP MCP wants `initialize` first — the response
// carries the `Mcp-Session-Id` header you must echo back on
// every subsequent call (tools/list, tools/call, ...). We use
// `-i` so the session-id is visible in the response, and ship
// the proper handshake body so a fresh paste actually works.
const body = JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "beaver-admin-curl", version: "0" },
},
});
return [
"# 1) initialize — grab Mcp-Session-Id from the response headers",
"curl -N -i \\",
" -H " + escSh("Authorization: Bearer " + tok) + " \\",
" -H 'content-type: application/json' \\",
" -H 'accept: application/json, text/event-stream' \\",
" -d " + escSh(body) + " \\",
" " + url,
"",
"# 2) reuse that id on follow-up calls, e.g. tools/list:",
"# curl -N \\",
"# -H " + escSh("Authorization: Bearer " + tok) + " \\",
"# -H 'Mcp-Session-Id: <paste-from-step-1>' \\",
"# -H 'content-type: application/json' \\",
"# -H 'accept: application/json, text/event-stream' \\",
"# -d '{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\"}' \\",
"# " + url,
].join("\n");
}
return "";
}
function refreshScopeMarks() {
const have = selectedScope();
card.querySelectorAll(".ep-table").forEach((tbl) => {
const need = tbl.getAttribute("data-required-scope") || "";
const ok = scopeCovers(have, need);
tbl.querySelectorAll("tr.ep-row").forEach((row) => {
const warn = row.querySelector(".ep-scope-warn");
if (!ok) {
row.classList.add("scope-mismatch");
if (warn) warn.hidden = false;
} else {
row.classList.remove("scope-mismatch");
if (warn) warn.hidden = true;
}
});
});
}
function refreshOpenCurls() {
card.querySelectorAll("tr.ep-curl-row").forEach((cr) => {
if (cr.hidden) return;
const row = cr.previousElementSibling;
const pre = cr.querySelector(".ep-curl");
if (row && pre) pre.textContent = buildCurl(row);
});
}
function copyText(btn, text) {
const done = () => {
btn.setAttribute("data-copied", "1");
const prev = btn.textContent;
btn.textContent = "✓ copied";
setTimeout(() => {
btn.removeAttribute("data-copied");
btn.textContent = prev;
}, 1200);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(done, () => {
// Fallback for non-secure contexts.
fallbackCopy(text);
done();
});
} else {
fallbackCopy(text);
done();
}
}
function fallbackCopy(text) {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed"; ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
try { document.execCommand("copy"); } catch {}
document.body.removeChild(ta);
}
sel.addEventListener("change", () => {
const name = sel.value;
secret.placeholder = name
? "paste plaintext for '" + name + "' (we only store the hash)"
: "paste plaintext (we only store the Argon2 hash)";
refreshScopeMarks();
});
secret.addEventListener("input", refreshOpenCurls);
showCb.addEventListener("change", () => {
secret.type = showCb.checked ? "text" : "password";
});
card.addEventListener("click", (ev) => {
const btn = ev.target.closest("button[data-action]");
if (!btn) return;
const row = btn.closest("tr.ep-row");
if (!row) return;
const action = btn.getAttribute("data-action");
const url = row.getAttribute("data-url");
const tok = currentToken() || PLACEHOLDER;
if (action === "copy-url") {
copyText(btn, url);
} else if (action === "copy-base") {
const base = row.getAttribute("data-base");
if (base) copyText(btn, base);
} else if (action === "copy-url-token") {
const sep = url.indexOf("?") >= 0 ? "&" : "?";
copyText(btn, url + sep + "token=" + encodeURIComponent(tok));
} else if (action === "copy-curl") {
copyText(btn, buildCurl(row));
} else if (action === "toggle-curl") {
const curlRow = row.nextElementSibling;
if (!curlRow || !curlRow.classList.contains("ep-curl-row")) return;
const pre = curlRow.querySelector(".ep-curl");
if (curlRow.hidden) {
if (pre) pre.textContent = buildCurl(row);
curlRow.hidden = false;
btn.textContent = "▾ curl";
btn.setAttribute("aria-expanded", "true");
} else {
curlRow.hidden = true;
btn.textContent = "▸ curl";
btn.setAttribute("aria-expanded", "false");
}
}
});
refreshScopeMarks();
})();
</script>
{% endblock %}
@@ -1,83 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>beaver-gateway · log in</title>
<style>
html, body { margin: 0; padding: 0; height: 100%; background: #fbfbfd; color: #1d1d1f; }
body {
font-family: -apple-system, "SF Pro Display", system-ui, sans-serif;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.card {
background: #fff;
border: 1px solid #e5e5ea;
border-radius: 16px;
padding: 2rem 2.25rem;
width: min(360px, 92vw);
box-shadow: 0 10px 24px rgba(0,0,0,0.04);
}
h1 {
margin: 0 0 0.25rem;
font-size: 1.25rem;
font-weight: 600;
letter-spacing: -0.02em;
}
p.muted { color: #6e6e73; margin: 0 0 1.4rem; font-size: 0.92em; }
label { display: block; margin: 0.85rem 0 0.3rem; font-size: 0.82em; color: #6e6e73; }
input {
width: 100%;
padding: 0.6rem 0.75rem;
font-family: inherit;
font-size: 0.95em;
border: 1px solid #e5e5ea;
border-radius: 8px;
background: #fff;
box-sizing: border-box;
}
input:focus { outline: 2px solid #0071e3; outline-offset: 1px; }
button {
width: 100%;
margin-top: 1.25rem;
padding: 0.65rem;
font-family: inherit;
font-size: 0.95em;
background: #0071e3;
border: 1px solid #0071e3;
color: white;
border-radius: 8px;
cursor: pointer;
}
button:hover { background: #005bb5; }
.error {
background: #fff0f0;
border: 1px solid #f5c2c5;
color: #d70015;
padding: 0.65rem 0.85rem;
border-radius: 8px;
font-size: 0.9em;
margin-bottom: 1rem;
}
</style>
</head>
<body>
<div class="card">
<h1>beaver-gateway</h1>
<p class="muted">Sign in to manage tokens and view audit logs.</p>
{% if error %}
<div class="error">{{ error }}</div>
{% endif %}
<form method="post" action="{{ p }}/login">
<label for="username">Username</label>
<input id="username" name="username" type="text" autocomplete="username" required autofocus>
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password" required>
<button type="submit">Sign in</button>
</form>
</div>
</body>
</html>
@@ -1,146 +0,0 @@
{% extends "_layout.html" %}
{% set active = "pty" %}
{% block title %}PTY {{ session_id }} · beaver-gateway{% endblock %}
{% block content %}
<style>
/* xterm.js styles inlined from the CDN bundle — keep them tight and
constrained to the terminal block so they don't leak into the rest
of the admin UI. */
.term-wrap {
background: #000;
border-radius: 12px;
padding: 12px;
border: 1px solid var(--line);
}
.term-host {
height: 70vh;
}
.term-host .xterm {
height: 100%;
}
.term-toolbar {
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.term-toolbar .status {
margin-left: auto;
font-size: 0.85em;
color: var(--muted);
}
.term-toolbar .status.connected { color: #027a48; }
.term-toolbar .status.dropped { color: var(--danger); }
</style>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
<h2>PTY <code>{{ session_id }}</code></h2>
<p class="muted">
Live view of the claude subprocess TUI. Keystrokes you type here go
straight into its stdin — Enter, arrows, Ctrl-C, paste, all work.
<a href="{{ p }}/pty">← back to list</a>
</p>
<div class="term-toolbar">
<button id="reconnect-btn" type="button">Reconnect</button>
<button id="enter-btn" type="button" title="Send a single \r — handy if a paste is stuck in the input box">Send Enter</button>
<button id="ctrl-c-btn" type="button">Send Ctrl-C</button>
<span id="status" class="status">connecting…</span>
</div>
<div class="term-wrap">
<div id="term" class="term-host"></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
<script>
(function () {
const sessionId = {{ session_id|tojson }};
const wsBase =
(location.protocol === "https:" ? "wss://" : "ws://") +
location.host +
{{ p|tojson }} +
"/pty/" + encodeURIComponent(sessionId) + "/ws";
const term = new Terminal({
fontFamily: 'ui-monospace, "SF Mono", Menlo, Consolas, monospace',
fontSize: 13,
theme: {
background: "#000000",
foreground: "#d0d0d0",
},
convertEol: false,
cursorBlink: true,
scrollback: 5000,
});
const fit = new FitAddon.FitAddon();
term.loadAddon(fit);
term.open(document.getElementById("term"));
// Defer fit until after layout settles; xterm's measurement reads
// computed CSS that isn't stable until the page paints once.
requestAnimationFrame(() => fit.fit());
window.addEventListener("resize", () => fit.fit());
const statusEl = document.getElementById("status");
function setStatus(text, cls) {
statusEl.textContent = text;
statusEl.className = "status" + (cls ? " " + cls : "");
}
let ws = null;
let manuallyClosed = false;
function connect() {
manuallyClosed = false;
setStatus("connecting…", "");
ws = new WebSocket(wsBase);
ws.binaryType = "arraybuffer";
ws.onopen = () => setStatus("connected", "connected");
ws.onclose = () => {
if (!manuallyClosed) setStatus("disconnected", "dropped");
};
ws.onerror = () => setStatus("error", "dropped");
ws.onmessage = (ev) => {
if (typeof ev.data === "string") {
term.write(ev.data);
} else {
// ArrayBuffer — feed raw bytes to xterm. It expects either
// string or Uint8Array; the latter preserves byte boundaries
// exactly, which matters for ANSI sequences split across frames.
term.write(new Uint8Array(ev.data));
}
};
}
// Keystrokes -> server. xterm.js gives us the exact byte sequence
// the terminal would emit (e.g. Enter -> "\r", arrows -> "\x1b[A"
// etc.), so we just pass it through.
term.onData((data) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
});
document.getElementById("reconnect-btn").addEventListener("click", () => {
if (ws) {
manuallyClosed = true;
ws.close();
}
connect();
});
document.getElementById("enter-btn").addEventListener("click", () => {
if (ws && ws.readyState === WebSocket.OPEN) ws.send("\r");
term.focus();
});
document.getElementById("ctrl-c-btn").addEventListener("click", () => {
if (ws && ws.readyState === WebSocket.OPEN) ws.send("\x03");
term.focus();
});
connect();
})();
</script>
{% endblock %}
@@ -1,46 +0,0 @@
{% extends "_layout.html" %}
{% set active = "pty" %}
{% block title %}PTY · beaver-gateway · admin{% endblock %}
{% block content %}
<h2>Live PTY sessions</h2>
<p class="muted">
Each row is a running <code>claude</code> subprocess, most recently used first.
Open one to see what's currently rendered on its TUI and (if needed) type into
it directly. Sessions idle past the agent's TTL are terminated automatically.
</p>
{% if sessions %}
<div class="card" style="padding: 0;">
<table>
<thead>
<tr>
<th>Session ID</th>
<th>Agent</th>
<th>Idle</th>
<th>Age</th>
<th>PID</th>
<th>Buffer</th>
<th></th>
</tr>
</thead>
<tbody>
{% for s in sessions %}
<tr>
<td><code>{{ s.session_id }}</code></td>
<td>{{ s.agent }}</td>
<td>{{ s.idle }}</td>
<td class="muted">{{ s.age }}</td>
<td><span class="pill">{{ s.pid or "?" }}</span></td>
<td>{{ s.buffer_size }} bytes</td>
<td>
<a class="btn primary" href="{{ p }}/pty/{{ s.session_id }}">Open</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="banner">No live PTY sessions right now. Start a turn and refresh.</div>
{% endif %}
{% endblock %}
@@ -1,64 +0,0 @@
{% extends "_layout.html" %}
{% set active = "tokens" %}
{% block title %}beaver-gateway · tokens{% endblock %}
{% block content %}
<h2>Create token</h2>
<div class="card">
<p class="muted">Plaintext is shown <strong>once</strong>, immediately after creation. Copy it before you navigate away — the database only ever holds the Argon2 hash.</p>
<div id="token-create-result"></div>
<form
hx-post="{{ p }}/tokens"
hx-target="#token-create-result"
hx-swap="innerHTML"
hx-on::after-request="if(event.detail.successful){this.reset();}"
>
<input type="hidden" name="csrf_token" value="{{ csrf }}">
<div class="form-grid">
<div>
<label for="name">Name</label>
<input id="name" name="name" type="text" placeholder="cursor / claude-desktop / mobile …" required>
</div>
<div>
<label for="scope">Scope</label>
<select id="scope" name="scope">
{% for s in scopes %}
<option value="{{ s }}" {% if s == "*" %}selected{% endif %}>{{ s }}</option>
{% endfor %}
</select>
</div>
<div>
<button class="primary" type="submit">Create</button>
</div>
</div>
</form>
</div>
<h2>
Tokens
<span class="muted" style="font-size:0.8em; font-weight:400; margin-left:0.5rem;">
{% if include_revoked %}
<a href="{{ p }}/tokens">Hide revoked</a>
{% else %}
<a href="{{ p }}/tokens?include_revoked=1">Show revoked</a>
{% endif %}
</span>
</h2>
<div class="card">
<table>
<thead>
<tr>
<th>Name</th><th>Scope</th><th>Created</th><th>Last used</th><th>Revoked</th><th></th>
</tr>
</thead>
{# Render the tbody unconditionally so the HTMX OOB swap on
create has a target even when the table starts empty. #}
<tbody id="tokens-rows">
{% for token in tokens %}
{% include "_token_row.html" %}
{% else %}
<tr id="tokens-empty"><td colspan="6" class="muted">No tokens yet. Create one above.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
+145 -91
View File
@@ -4,39 +4,44 @@ Exposes the gateway as an Anthropic-compatible Messages endpoint, so any
client that already speaks Anthropic (Cursor, Cline, the official SDK,
``curl``) can hit a configured agent by passing its name as ``model``.
The frontend is intentionally thin: it authenticates the bearer token,
resolves ``body.model`` to an agent + its backend, and then either
streams the backend's events straight to SSE or accumulates them into a
single ``Message`` for ``stream=false`` callers. All provider quirks
already live in the backend adapters; we don't translate here.
Phase 1.4 wires only ``RaycastAgent`` through ``RaycastBackend``;
``ClaudeAgent`` lands in Phase 2 and will plug into the same dispatch
table without changes to this module.
A Claude agent behind this endpoint is a ``deep`` conversation: the client
knows nothing about our ids, so the text fingerprint of the history it
sends is the ``(anthropic, fingerprint)`` binding of the conversation,
rebound after every turn to the fingerprint the next request will carry.
A history nobody has seen becomes a new conversation, materialized by the
home frontend of ``deep`` (the vault file), and every reply is published
on the bus so that file follows the chat. Other agents (Raycast) stay
stateless and are only archived through ``turn_log_handlers``.
"""
from __future__ import annotations
import json
import logging
import uuid
from typing import TYPE_CHECKING, Any
from fastapi import FastAPI, HTTPException, Request, status
from fastapi.responses import JSONResponse, StreamingResponse
from beaver_gateway.agents.claude import ClaudeAgent
from beaver_gateway.core import audit
from beaver_gateway.core.transcript import fingerprint, text_of
from beaver_gateway.core.turn_capture import TurnCapture
from beaver_gateway.core.turn_record import TurnRecord
from beaver_gateway.frontends._accumulate import StreamAccumulator, accumulate
from beaver_gateway.frontends._accumulate import StreamAccumulator
from beaver_gateway.frontends._auth import require_token
from beaver_gateway.frontends.base import Frontend
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from collections.abc import AsyncIterator, Awaitable, Callable
from anthropic.types import Message, MessageParam
from beaver_gateway.core.conversations import Conversations
from beaver_gateway.core.events import MessageStreamEvent
from beaver_gateway.frontends.base import GatewayRuntime
from beaver_gateway.storage.models import Conversation
_log = logging.getLogger("beaver_gateway.frontends.anthropic")
@@ -44,10 +49,16 @@ _log = logging.getLogger("beaver_gateway.frontends.anthropic")
__all__ = ["AnthropicMessagesFrontend"]
FRONTEND = "anthropic"
_TITLE_LEN = 80
class AnthropicMessagesFrontend(Frontend):
"""FastAPI app behind ``POST /v1/messages`` + ``GET /v1/models``."""
name = FRONTEND
kinds = ("deep",)
def __init__(
self,
*,
@@ -57,14 +68,6 @@ class AnthropicMessagesFrontend(Frontend):
) -> None:
self.host = host
self.port = port
# External URL prefix the reverse proxy (Caddy/nginx/Cloudflare/…)
# uses to reach this frontend, e.g. ``https://api.example.com/ai``.
# The frontend's internal paths (``/v1/messages``, ``/v1/models``)
# are appended to it when the admin dashboard renders copy-pastable
# URLs. Trailing slash is stripped so the concatenation is
# idempotent. ``None`` means "advertise raw ``host:port``" (dev /
# no proxy) — the dashboard then derives the base from the
# browser's own request hostname.
self.public_base_url = public_base_url.rstrip("/") if public_base_url else None
self._runtime: GatewayRuntime | None = None
self._app: FastAPI | None = None
@@ -74,9 +77,6 @@ class AnthropicMessagesFrontend(Frontend):
self._app = self._build_app(runtime)
async def serve(self) -> None:
# Local import: uvicorn pulls in a lot, no reason to load it when
# something else (a test, a script) imports this module just for
# the FastAPI factory.
import uvicorn
if self._app is None:
@@ -134,9 +134,6 @@ class AnthropicMessagesFrontend(Frontend):
backend = runtime.backends.get(agent.name)
if backend is None:
# Agent exists in config but its backend isn't wired in
# this phase (e.g. ClaudeAgent before Phase 2, or a
# RaycastAgent without RAYCAST_BEARER set at startup).
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
f"no backend configured for agent {agent.name!r}",
@@ -144,7 +141,12 @@ class AnthropicMessagesFrontend(Frontend):
messages = body.get("messages") or []
system = body.get("system")
system_str = system if isinstance(system, str) else None
stream_flag = bool(body.get("stream", False))
if not messages or messages[-1].get("role") != "user":
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "the last message must be a user turn"
)
_log.info(
"messages: actor=%s agent=%s stream=%s msgs=%d",
@@ -153,10 +155,6 @@ class AnthropicMessagesFrontend(Frontend):
stream_flag,
len(messages),
)
# Record at request acceptance, not at stream completion:
# a long streaming response can be aborted mid-flight by
# the client, and we still want the row in the audit trail.
# Detail stays small — no message bodies, no system prompt.
await audit.log(
runtime,
actor=f"token:{token_name}",
@@ -166,74 +164,139 @@ class AnthropicMessagesFrontend(Frontend):
msgs=len(messages),
)
# Forward per-request knobs the Anthropic body may carry —
# backend adapters layer these over per-agent defaults. Only
# values explicitly present (not Anthropic-defaulted ones we
# never received) are forwarded, so the agent's default still
# wins when the caller omits the field.
options: dict[str, Any] = {}
if isinstance(body.get("temperature"), int | float):
options["temperature"] = body["temperature"]
if isinstance(agent, ClaudeAgent) and runtime.conversations is not None:
conv, history = await _resolve(runtime.conversations, agent, messages)
turn_id = f"turn_{uuid.uuid4().hex[:12]}"
capture = TurnCapture()
events = runtime.conversations.turn(
conv,
messages=messages,
origin="user",
capture=capture,
turn_id=turn_id,
)
events = backend.complete(
agent=agent,
messages=messages,
system=system if isinstance(system, str) else None,
**options,
)
async def after(message: Message) -> None:
await _finish(
runtime,
conv,
messages=messages,
message=message,
capture=capture,
turn_id=turn_id,
history=history,
)
else:
options: dict[str, Any] = {}
if isinstance(body.get("temperature"), int | float):
options["temperature"] = body["temperature"]
events = backend.complete(
agent=agent, messages=messages, system=system_str, **options
)
system_str = system if isinstance(system, str) else None
if stream_flag:
# Side-accumulate while streaming so we can still emit a
# ``TurnRecord`` after the response closes. The buffered
# ``Message`` lives only in this coroutine's frame; SSE
# bytes still flow to the client unchanged.
return StreamingResponse(
_sse_and_broadcast(
events,
runtime=runtime,
async def after(message: Message) -> None:
await _broadcast_turn(
runtime,
agent_name=agent.name,
input_messages=messages,
output_message=message,
system=system_str,
model=model,
),
)
if stream_flag:
return StreamingResponse(
_sse(events, model=model, after=after),
media_type="text/event-stream",
)
message = await accumulate(events, model=model)
await _broadcast_turn(
runtime,
agent_name=agent.name,
input_messages=messages,
output_message=message,
system=system_str,
)
acc = StreamAccumulator()
async for ev in events:
acc.feed(ev)
message = acc.finalize(model=model)
await after(message)
return JSONResponse(content=message.model_dump(mode="json"))
return app
async def _sse_and_broadcast(
async def _resolve(
conversations: Conversations, agent: ClaudeAgent, messages: list[MessageParam]
) -> tuple[Conversation, list[dict[str, str]] | None]:
prior = [dict(m) for m in messages[:-1]]
if prior:
conv = await conversations.find_bound(
frontend=FRONTEND, external_id=fingerprint(prior)
)
if conv is not None:
if conv.agent_name != agent.name:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
f"this chat runs on {conv.agent_name!r}, not {agent.name!r}",
)
return conv, None
first = next(
(text_of(m.get("content")) for m in messages if m.get("role") == "user"), ""
)
try:
conv = await conversations.create(
kind="deep",
agent=agent.name,
origin=FRONTEND,
title=" ".join(first.split())[:_TITLE_LEN] or None,
)
except ValueError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
await conversations.materialize(conv)
history = [
{"role": str(m.get("role", "")), "text": text_of(m.get("content"))}
for m in prior
]
return conv, history
async def _finish(
runtime: GatewayRuntime,
conv: Conversation,
*,
messages: list[MessageParam],
message: Message,
capture: TurnCapture,
turn_id: str,
history: list[dict[str, str]] | None,
) -> None:
reply = "\n\n".join(
getattr(b, "text", "")
for b in message.content
if getattr(b, "type", "") == "text"
).strip()
tail = capture.synthesized_messages or [
{"role": "assistant", "content": [{"type": "text", "text": reply}]}
]
await runtime.conversations.bind(
conv, frontend=FRONTEND, external_id=fingerprint([*messages, *tail])
)
runtime.bus.publish(
"reply",
conversation_id=conv.external_id,
turn_id=turn_id,
source=FRONTEND,
prompt=text_of(messages[-1].get("content")),
text=reply,
history=history,
)
async def _sse(
events: AsyncIterator[MessageStreamEvent],
*,
runtime: GatewayRuntime,
agent_name: str,
input_messages: list[MessageParam],
system: str | None,
model: str,
after: Callable[[Message], Awaitable[None]],
) -> AsyncIterator[bytes]:
r"""Serialize an event stream to SSE; broadcast a :class:`TurnRecord` after.
r"""Serialize an event stream to SSE, then hand the assembled ``Message`` on.
Each event becomes ``event: <type>\ndata: <json>\n\n`` — the shape
Each event becomes ``event: <type>\ndata: <json>\n\n`` - the shape
the Anthropic SDK's SSE decoder expects. Errors mid-stream are
swallowed into a synthetic ``error`` event so the client sees the
failure rather than a hung connection.
The same events feed a :class:`StreamAccumulator` on the side so that
once the SSE response closes we can hand a fully-assembled
``Message`` to every ``runtime.turn_log_handlers`` entry (the
markdown frontend's archive logger lives in there). Broadcast
failures are caught — they must never bubble up to the client.
"""
acc = StreamAccumulator()
try:
@@ -248,14 +311,10 @@ async def _sse_and_broadcast(
)
yield f"event: error\ndata: {err}\n\n".encode()
return
message = acc.finalize(model=model)
await _broadcast_turn(
runtime,
agent_name=agent_name,
input_messages=input_messages,
output_message=message,
system=system,
)
try:
await after(acc.finalize(model=model))
except Exception: # noqa: BLE001
_log.exception("post-turn hook failed")
async def _broadcast_turn(
@@ -266,11 +325,6 @@ async def _broadcast_turn(
output_message: Message,
system: str | None,
) -> None:
"""Fire each ``turn_log_handlers`` entry with a fresh :class:`TurnRecord`.
Handler exceptions are caught and logged — they're observability
plumbing, not part of the user-visible request path.
"""
if not runtime.turn_log_handlers:
return
record = TurnRecord(
@@ -278,7 +332,7 @@ async def _broadcast_turn(
input_messages=list(input_messages),
output_message=output_message,
system=system,
source="anthropic",
source=FRONTEND,
)
for handler in runtime.turn_log_handlers:
try:
@@ -0,0 +1,5 @@
"""``ApiFrontend`` - the conversations API and event stream (§3.9)."""
from beaver_gateway.frontends.api.frontend import ApiFrontend
__all__ = ["ApiFrontend"]
@@ -0,0 +1,939 @@
"""``ApiFrontend`` - ``/api/*``: conversations, SSE, sessions, usage, limits (§3.9).
Bearer scope ``api``; token and audit management need ``admin``. Every
write goes through ``core/conversations``; the frontend only shapes JSON.
``/api/events`` and ``/api/conversations/{id}/events`` replay the gateway
bus as SSE with the same keepalive the markdown frontend uses, so a proxy
never sees a silent socket.
Usage figures come from the ``usage`` table (one row per turn, API-price
``cost_usd`` and per-model ``model_usage`` from the SDK's
``ResultMessage``); subscription quotas come from ``rate_limits``
(``RateLimitEvent``). The quota covers the whole subscription, so
``/api/limits`` puts the gateway's own spend for the window next to it
for calibration by eye.
"""
from __future__ import annotations
import json
import logging
import secrets
from collections import Counter
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any, cast
from fastapi import FastAPI, HTTPException, Request, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse
from sqlmodel import col, select
from beaver_gateway.core import audit
from beaver_gateway.core.auth import VALID_SCOPES, hash_token
from beaver_gateway.core.conversations import SEEDS
from beaver_gateway.core.kinds import Kind, as_kind
from beaver_gateway.frontends._auth import require_token
from beaver_gateway.frontends._sse import (
KEEPALIVE,
SSE_HEADERS,
events_with_heartbeat,
sse_pack,
)
from beaver_gateway.frontends.base import Frontend
from beaver_gateway.storage import (
create_token,
list_audit_records,
list_tokens,
revoke_token,
)
from beaver_gateway.storage.models import Conversation, RateLimit, Token, Usage
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Iterable, Sequence
from pathlib import Path
from beaver_gateway.core.conversations import Conversations
from beaver_gateway.frontends.base import GatewayRuntime
_log = logging.getLogger("beaver_gateway.frontends.api")
__all__ = ["ApiFrontend"]
SCOPE = "api"
ADMIN_SCOPE = "admin"
GROUPS = ("day", "agent", "conversation", "model")
WINDOWS: dict[str, timedelta] = {
"five_hour": timedelta(hours=5),
"seven_day": timedelta(days=7),
"seven_day_opus": timedelta(days=7),
"seven_day_sonnet": timedelta(days=7),
}
MEMORY_MAX_DEPTH = 12
MEMORY_MAX_FILE = 2_000_000
MEMORY_MAX_ENTRIES = 5000
class ApiFrontend(Frontend):
name = "api"
kinds = ("master", "branch", "deep", "job")
def __init__(
self,
*,
host: str = "0.0.0.0", # noqa: S104
port: int = 8004,
public_base_url: str | None = None,
master_agent: str | None = None,
branch_agent: str | None = None,
deep_agent: str | None = None,
job_agent: str | None = None,
memory_root: Path | None = None,
) -> None:
self.host = host
self.port = port
self.public_base_url = public_base_url.rstrip("/") if public_base_url else 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._app: FastAPI | None = None
def agent_for(self, kind: Kind) -> str | None:
return {
"master": self.master_agent,
"branch": self.branch_agent,
"deep": self.deep_agent,
"job": self.job_agent,
}.get(kind)
def configure(self, runtime: GatewayRuntime) -> None:
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)
async def serve(self) -> None:
import uvicorn
if self._app is None:
msg = "configure() must be called before serve()"
raise RuntimeError(msg)
server = uvicorn.Server(
uvicorn.Config(self._app, host=self.host, port=self.port, log_level="info")
)
await server.serve()
def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> FastAPI: # noqa: PLR0915
app = FastAPI(title="beaver-gateway / API")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
conversations = cast("Conversations", runtime.conversations)
async def body_of(request: Request) -> dict[str, Any]:
if not await request.body():
return {}
try:
data = await request.json()
except json.JSONDecodeError as exc:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, f"invalid JSON: {exc}"
) from exc
if not isinstance(data, dict):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "body must be an object")
return data
async def conv_of(public_id: str) -> Conversation:
conv = await conversations.get(public_id)
if conv is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND, f"unknown conversation {public_id}"
)
return conv
def text_of(data: dict[str, Any], key: str = "text") -> str:
text = data.get(key)
if not isinstance(text, str) or not text.strip():
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"missing `{key}`")
return text
def int_or_none(data: dict[str, Any], key: str) -> int | None:
value = data.get(key)
if value is None:
return None
if not isinstance(value, int) or value < 1:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, f"`{key}` must be a positive int"
)
return value
def query_int(request: Request, key: str, default: int) -> int:
raw = request.query_params.get(key)
return int(raw) if raw and raw.isdigit() else default
@app.get("/healthz")
async def healthz() -> dict[str, str]:
return {"status": "ok"}
@app.get("/api/agents")
async def list_agents(request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
return {
"agents": [
{
"name": a.name,
"model": a.model,
"type": a.__class__.__name__,
"kinds": list(getattr(a, "kinds", ())),
"effort": getattr(getattr(a, "options", None), "effort", None),
}
for a in runtime.agents
],
"frontends": [
{
"name": fe.name or fe.__class__.__name__,
"type": fe.__class__.__name__,
"kinds": list(fe.kinds),
"default_agents": {
k: fe.agent_for(k) for k in fe.kinds if fe.agent_for(k)
},
"port": getattr(fe, "port", None),
"public_base_url": getattr(fe, "public_base_url", None),
}
for fe in runtime.frontends
],
"mcps": [{"name": m.name, "kind": m.kind} for m in runtime.mcps],
}
@app.get("/api/conversations")
async def list_conversations(request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
q = request.query_params
rows = await conversations.find(
status=q.get("status"),
kind=q.get("kind"),
limit=query_int(request, "limit", 200),
)
return {"conversations": [conversations.public(r) for r in rows]}
@app.post("/api/conversations", status_code=status.HTTP_201_CREATED)
async def create_conversation(request: Request) -> dict[str, Any]:
token = await require_token(request, runtime, scope=SCOPE)
data = await body_of(request)
agent = data.get("agent")
try:
kind = as_kind(str(data.get("kind") or "deep"))
except ValueError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
if kind == "fork":
raise HTTPException(status.HTTP_400_BAD_REQUEST, "fork is internal")
if agent is not None and (
not isinstance(agent, str) or agent not in runtime.agents
):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "unknown `agent`")
seed = str(data.get("seed") or "clean")
if seed not in SEEDS:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, f"seed must be one of {SEEDS}"
)
parent = await conv_of(str(data["parent"])) if data.get("parent") else None
try:
conv = await conversations.spawn(
kind=kind,
agent=agent,
seed=seed,
parent=parent,
text=data.get("text"),
title=data.get("title"),
window=int_or_none(data, "window"),
origin="api",
)
except (ValueError, LookupError) as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
await audit.log(
runtime,
actor=f"token:{token}",
kind="api_spawn",
agent_name=conv.agent_name,
conversation=conv.external_id,
seed=seed,
)
return await conversations.describe(conv)
@app.get("/api/conversations/{public_id}")
async def get_conversation(public_id: str, request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id)
out = await conversations.describe(conv)
out["queue"] = [
{
"id": i.id,
"priority": i.priority,
"origin": i.origin,
"status": i.status,
"created_at": _iso(i.created_at),
"text": i.text[:200],
}
for i in await conversations.queue.recent(cast("int", conv.id), limit=20)
]
return out
@app.get("/api/conversations/{public_id}/messages")
async def get_messages(public_id: str, request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id)
raw = request.query_params.get("window")
window = int(raw) if raw and raw.isdigit() else None
return {
"id": conv.external_id,
"text": await conversations.read(conv, window=window),
}
@app.get("/api/conversations/{public_id}/history")
async def get_history(public_id: str, request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id)
return {"id": conv.external_id, "messages": await conversations.history(conv)}
@app.get("/api/conversations/{public_id}/entries")
async def get_entries(public_id: str, request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id)
subpath = request.query_params.get("subpath") or ""
entries = await conversations.entries(conv, subpath=subpath)
limit = query_int(request, "limit", 100)
offset = query_int(request, "offset", max(len(entries) - limit, 0))
return {
"id": conv.external_id,
"subpath": subpath,
"subpaths": await conversations.subpaths(conv),
"total": len(entries),
"offset": offset,
"entries": entries[offset : offset + limit],
}
@app.post(
"/api/conversations/{public_id}/messages", status_code=status.HTTP_202_ACCEPTED
)
async def post_message(public_id: str, request: Request) -> dict[str, Any]:
token = await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id)
data = await body_of(request)
item = await conversations.post(
conv, text_of(data), origin=str(data.get("origin") or "user")
)
await audit.log(
runtime,
actor=f"token:{token}",
kind="api_message",
agent_name=conv.agent_name,
conversation=conv.external_id,
)
return {"id": conv.external_id, "item": item.id, "status": item.status}
@app.post(
"/api/conversations/{public_id}/inject", status_code=status.HTTP_202_ACCEPTED
)
async def post_inject(public_id: str, request: Request) -> dict[str, Any]:
token = await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id)
data = await body_of(request)
urgency = str(data.get("urgency") or "normal")
if urgency not in ("normal", "urgent"):
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "urgency must be normal|urgent"
)
item = await conversations.inject(
conv,
text_of(data),
urgency=cast("Any", urgency),
origin=str(data.get("origin") or "api"),
)
await audit.log(
runtime,
actor=f"token:{token}",
kind="api_inject",
agent_name=conv.agent_name,
conversation=conv.external_id,
urgency=urgency,
)
return {"id": conv.external_id, "item": item.id, "priority": item.priority}
@app.post("/api/conversations/{public_id}/say")
async def post_say(public_id: str, request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id)
return await conversations.say(conv, text_of(await body_of(request)))
@app.post("/api/conversations/{public_id}/answer")
async def post_answer(public_id: str, request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id)
data = await body_of(request)
question_id = text_of(data, "question_id")
if not conversations.answer(question_id, text_of(data, "answer")):
raise HTTPException(
status.HTTP_404_NOT_FOUND, f"no open question {question_id}"
)
return {"id": conv.external_id, "question_id": question_id}
@app.post(
"/api/conversations/{public_id}/branch", status_code=status.HTTP_201_CREATED
)
async def post_branch(public_id: str, request: Request) -> dict[str, Any]:
token = await require_token(request, runtime, scope=SCOPE)
parent = await conv_of(public_id)
data = await body_of(request)
seed = str(data.get("seed_mode") or data.get("seed") or "morning")
if seed not in SEEDS:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, f"seed must be one of {SEEDS}"
)
agent = data.get("agent")
if agent is not None and not isinstance(agent, str):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "`agent` must be a string")
try:
child = await conversations.spawn(
kind="branch",
agent=agent,
seed=seed,
parent=parent,
text=data.get("text"),
title=data.get("title"),
window=int_or_none(data, "window"),
origin="api",
)
except (ValueError, LookupError) as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
await audit.log(
runtime,
actor=f"token:{token}",
kind="api_branch",
agent_name=child.agent_name,
conversation=child.external_id,
parent=parent.external_id,
seed=seed,
)
return await conversations.describe(child)
@app.post("/api/conversations/{public_id}/merge")
async def post_merge(public_id: str, request: Request) -> dict[str, Any]:
token = await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id)
try:
result = await conversations.merge(conv)
except (ValueError, LookupError) as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
await audit.log(
runtime,
actor=f"token:{token}",
kind="api_merge",
agent_name=conv.agent_name,
conversation=conv.external_id,
)
return {
"id": conv.external_id,
"status": "merged",
"fork": result.conversation.external_id,
"text": result.text,
}
@app.post("/api/conversations/{public_id}/fork")
async def post_fork(public_id: str, request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id)
data = await body_of(request)
try:
result = await conversations.fork(
conv,
text_of(data, "prompt"),
window=int_or_none(data, "window"),
strip_tools=bool(data.get("strip_tools", False)),
)
except (ValueError, RuntimeError) as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
return {
"id": conv.external_id,
"fork": result.conversation.external_id,
"text": result.text,
}
@app.post("/api/conversations/{public_id}/bind")
async def post_bind(public_id: str, request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id)
data = await body_of(request)
frontend = text_of(data, "frontend")
external_id = text_of(data, "external_id")
try:
await conversations.bind(
conv,
frontend=frontend,
external_id=external_id,
visible=bool(data.get("visible", True)),
)
except ValueError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
return await conversations.describe(conv)
@app.patch("/api/conversations/{public_id}/flags")
async def patch_flags(public_id: str, request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id)
data = await body_of(request)
return conversations.public(await conversations.set_flags(conv, data))
@app.patch("/api/conversations/{public_id}")
async def patch_conversation(public_id: str, request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id)
data = await body_of(request)
try:
if isinstance(data.get("status"), str):
conv = await conversations.set_status(conv, data["status"])
if isinstance(data.get("title"), str):
conv = await conversations.set_title(conv, data["title"])
except ValueError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
return conversations.public(conv)
@app.get("/api/conversations/{public_id}/events")
async def conversation_events(public_id: str, request: Request) -> Any:
await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id)
return _sse(runtime, conversation_id=conv.external_id)
@app.get("/api/events")
async def all_events(request: Request) -> Any:
await require_token(request, runtime, scope=SCOPE)
return _sse(runtime, conversation_id=None)
@app.get("/api/sessions")
async def sessions(request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
pool = runtime.pool
return {
"rss": pool.rss() if pool is not None else None,
"rss_limit": pool.rss_limit if pool is not None else None,
"sessions": pool.snapshot() if pool is not None else [],
}
@app.get("/api/schedules")
async def schedules(request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
raw = request.query_params.get("conversation")
conv = await conv_of(raw) if raw else None
return {
"schedules": [
{
"id": s.id,
"conversation_row": s.conversation_id,
"execute_at": _iso(s.execute_at),
"text": s.text,
"created_at": _iso(s.created_at),
"delivered_at": _iso(s.delivered_at),
}
for s in await conversations.schedules(conv)
]
}
@app.get("/api/usage")
async def usage(request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
q = request.query_params
group_by = q.get("group_by", "agent")
if group_by not in GROUPS:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, f"group_by must be one of {GROUPS}"
)
until = _parse_time(q.get("until")) or datetime.now(UTC)
since = _parse_time(q.get("since")) or until - timedelta(
hours=float(q.get("hours", "24"))
)
rows = await _usage_rows(runtime, since, until)
groups = _group_usage(rows, group_by)
if group_by == "conversation":
titles = await _conversation_titles(runtime, [g["key"] for g in groups])
for g in groups:
g.update(titles.get(g["key"], {}))
return {
"since": since.isoformat(timespec="seconds"),
"until": until.isoformat(timespec="seconds"),
"group_by": group_by,
"total": _sum_usage(rows),
"rows": groups,
}
@app.get("/api/limits")
async def limits(request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
rows = await conversations.rate_limits(limit=200)
latest: dict[str, RateLimit] = {}
for row in rows:
latest.setdefault(row.window, row)
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)
)
gateway = _sum_usage(await _usage_rows(runtime, since, now))
gateway["since"] = since.isoformat(timespec="seconds")
windows.append({**_limit_public(row), "gateway": gateway})
windows.sort(
key=lambda w: (WINDOWS.get(w["window"], timedelta.max), w["window"])
)
return {"windows": windows, "history": [_limit_public(r) for r in rows[:50]]}
@app.get("/api/memory")
async def memory_tree(request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
root = _memory_root(memory_root)
return {"root": str(root), "tree": _tree(root, root, depth=0)}
@app.get("/api/memory/file")
async def memory_file(request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
root = _memory_root(memory_root)
target = _memory_path(root, request.query_params.get("path") or "")
if not target.is_file():
raise HTTPException(status.HTTP_404_NOT_FOUND, "no such file")
stat = target.stat()
if stat.st_size > MEMORY_MAX_FILE:
raise HTTPException(status.HTTP_413_CONTENT_TOO_LARGE, "file too large")
try:
content = target.read_text(encoding="utf-8")
except UnicodeDecodeError as exc:
raise HTTPException(
status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, "not a text file"
) from exc
return {
"path": str(target.relative_to(root)),
"size": stat.st_size,
"mtime": _mtime(stat.st_mtime),
"content": content,
}
@app.get("/api/tokens")
async def tokens(request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=ADMIN_SCOPE)
include_revoked = request.query_params.get("include_revoked") == "1"
async with runtime.db.session() as session:
rows = await list_tokens(session, include_revoked=include_revoked)
return {"tokens": [_token_public(t) for t in rows]}
@app.post("/api/tokens", status_code=status.HTTP_201_CREATED)
async def token_create(request: Request) -> dict[str, Any]:
actor = await require_token(request, runtime, scope=ADMIN_SCOPE)
data = await body_of(request)
name = text_of(data, "name").strip()
scope = str(data.get("scope") or "*")
if scope not in VALID_SCOPES:
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"invalid scope {scope!r}")
plaintext = secrets.token_urlsafe(32)
async with runtime.db.session() as session:
try:
row = await create_token(
session, name=name, scope=scope, hashed_value=hash_token(plaintext)
)
except Exception as exc:
raise HTTPException(
status.HTTP_409_CONFLICT, f"could not create token {name!r}: {exc}"
) from exc
await runtime.token_store.invalidate()
await audit.log(
runtime,
actor=f"token:{actor}",
kind="token_create",
name=name,
scope=scope,
token_id=row.id,
)
return {"token": _token_public(row), "plaintext": plaintext}
@app.post("/api/tokens/{token_id}/revoke")
async def token_revoke(token_id: int, request: Request) -> dict[str, Any]:
actor = await require_token(request, runtime, scope=ADMIN_SCOPE)
async with runtime.db.session() as session:
ok = await revoke_token(session, token_id=token_id)
if not ok:
raise HTTPException(
status.HTTP_404_NOT_FOUND, f"no active token with id {token_id}"
)
await runtime.token_store.invalidate()
await audit.log(
runtime, actor=f"token:{actor}", kind="token_revoke", token_id=token_id
)
return {"id": token_id, "revoked": True}
@app.get("/api/audit")
async def audit_list(request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=ADMIN_SCOPE)
before_raw = request.query_params.get("before")
before = int(before_raw) if before_raw and before_raw.isdigit() else None
limit = min(query_int(request, "limit", 50), 500)
async with runtime.db.session() as session:
rows = await list_audit_records(session, limit=limit, before_id=before)
return {
"records": [
{
"id": r.id,
"ts": _iso(r.ts),
"actor": r.actor,
"kind": r.kind,
"agent": r.agent_name,
"detail": _detail(r.detail_json),
}
for r in rows
],
"next_before": rows[-1].id if len(rows) == limit else None,
}
@app.exception_handler(HTTPException)
async def http_error(_request: Request, exc: HTTPException) -> JSONResponse:
return JSONResponse(
status_code=exc.status_code,
content={"error": exc.detail},
headers=exc.headers,
)
return app
def _sse(runtime: GatewayRuntime, *, conversation_id: str | None) -> StreamingResponse:
async def gen() -> AsyncIterator[bytes]:
stream = runtime.bus.stream(conversation_id=conversation_id)
yield sse_pack("hello", {"type": "hello", "conversation_id": conversation_id})
async for event in events_with_heartbeat(stream):
if event is None:
yield KEEPALIVE
continue
yield sse_pack(str(event["type"]), event)
return StreamingResponse(gen(), media_type="text/event-stream", headers=SSE_HEADERS)
def _parse_time(raw: str | None) -> datetime | None:
if not raw:
return None
try:
value = datetime.fromisoformat(raw)
except ValueError as exc:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, f"bad timestamp {raw!r}"
) from exc
return _aware(value)
def _aware(value: datetime) -> datetime:
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
def _iso(value: datetime | None) -> str | None:
return _aware(value).isoformat(timespec="seconds") if value is not None else None
def _mtime(value: float) -> str:
return datetime.fromtimestamp(value, tz=UTC).isoformat(timespec="seconds")
async def _usage_rows(
runtime: GatewayRuntime, since: datetime, until: datetime
) -> list[Usage]:
stmt = (
select(Usage)
.where(
col(Usage.ts) >= since.astimezone(UTC).replace(tzinfo=None),
col(Usage.ts) < until.astimezone(UTC).replace(tzinfo=None),
)
.order_by(col(Usage.ts))
)
async with runtime.db.session() as session:
return list((await session.exec(stmt)).all())
def _empty() -> dict[str, Any]:
return {
"turns": 0,
"input": 0,
"output": 0,
"cache_read": 0,
"cache_creation": 0,
"cost_usd": 0.0,
"web_searches": 0,
}
def _add(acc: dict[str, Any], row: Usage) -> None:
acc["turns"] += 1
acc["input"] += row.input_tokens
acc["output"] += row.output_tokens
acc["cache_read"] += row.cache_read_tokens
acc["cache_creation"] += row.cache_creation_tokens
acc["cost_usd"] += row.cost_usd or 0.0
for per_model in (row.model_usage or {}).values():
acc["web_searches"] += int(per_model.get("webSearchRequests") or 0)
def _add_model(acc: dict[str, Any], per_model: dict[str, Any]) -> None:
acc["turns"] += 1
acc["input"] += int(per_model.get("inputTokens") or 0)
acc["output"] += int(per_model.get("outputTokens") or 0)
acc["cache_read"] += int(per_model.get("cacheReadInputTokens") or 0)
acc["cache_creation"] += int(per_model.get("cacheCreationInputTokens") or 0)
acc["cost_usd"] += float(per_model.get("costUSD") or 0.0)
acc["web_searches"] += int(per_model.get("webSearchRequests") or 0)
def _finish(acc: dict[str, Any]) -> dict[str, Any]:
acc["cost_usd"] = round(acc["cost_usd"], 4)
return acc
def _sum_usage(rows: Iterable[Usage]) -> dict[str, Any]:
acc = _empty()
for row in rows:
_add(acc, row)
return _finish(acc)
def _group_usage(rows: Sequence[Usage], group_by: str) -> list[dict[str, Any]]:
groups: dict[str, dict[str, Any]] = {}
agents: dict[str, Counter[str]] = {}
for row in rows:
if group_by == "model":
per_model = row.model_usage or {}
if not per_model:
_add(groups.setdefault(row.model, _empty()), row)
for model, mu in per_model.items():
_add_model(groups.setdefault(model, _empty()), mu)
continue
key = _group_key(row, group_by)
_add(groups.setdefault(key, _empty()), row)
agents.setdefault(key, Counter())[row.agent_name] += 1
out = []
for key, acc in groups.items():
entry = {"key": key, group_by: key, **_finish(acc)}
if key in agents:
entry["agent"] = agents[key].most_common(1)[0][0]
out.append(entry)
if group_by == "day":
out.sort(key=lambda g: g["key"])
else:
out.sort(key=lambda g: (-g["cost_usd"], -g["output"], g["key"]))
return out
def _group_key(row: Usage, group_by: str) -> str:
if group_by == "day":
return _aware(row.ts).date().isoformat()
if group_by == "agent":
return row.agent_name
return row.conversation_id or "-"
async def _conversation_titles(
runtime: GatewayRuntime, ids: Sequence[str]
) -> dict[str, dict[str, Any]]:
wanted = [i for i in ids if i != "-"]
if not wanted:
return {}
async with runtime.db.session() as session:
rows = (
await session.exec(
select(Conversation).where(col(Conversation.external_id).in_(wanted))
)
).all()
return {
r.external_id: {"title": r.title, "kind": r.kind, "status": r.status}
for r in rows
}
def _limit_public(row: RateLimit) -> dict[str, Any]:
return {
"id": row.id,
"ts": _iso(row.ts),
"window": row.window,
"status": row.status,
"utilization": row.utilization,
"resets_at": _iso(row.resets_at),
"overage_status": row.overage_status,
"overage_resets_at": _iso(row.overage_resets_at),
"agent": row.agent_name,
"session_id": row.session_id,
}
def _token_public(row: Token) -> dict[str, Any]:
return {
"id": row.id,
"name": row.name,
"scope": row.scope,
"created_at": _iso(row.created_at),
"last_used_at": _iso(row.last_used_at),
"revoked_at": _iso(row.revoked_at),
}
def _detail(raw: str) -> Any:
try:
return json.loads(raw) if raw else {}
except json.JSONDecodeError:
return raw
def _memory_root(root: Path | None) -> Path:
if root is None or not root.is_dir():
raise HTTPException(
status.HTTP_404_NOT_FOUND,
"memory root is not configured (ApiFrontend(memory_root=...))",
)
return root.resolve()
def _memory_path(root: Path, raw: str) -> Path:
target = (root / raw).resolve()
if not target.is_relative_to(root):
raise HTTPException(status.HTTP_404_NOT_FOUND, "no such file")
return target
def _tree(root: Path, directory: Path, *, depth: int) -> list[dict[str, Any]]:
if depth > MEMORY_MAX_DEPTH:
return []
out: list[dict[str, Any]] = []
try:
children = sorted(
directory.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())
)
except OSError:
return out
for child in children:
if child.name.startswith(".") or len(out) >= MEMORY_MAX_ENTRIES:
continue
try:
stat = child.stat()
except OSError:
continue
node: dict[str, Any] = {
"name": child.name,
"path": str(child.relative_to(root)),
"type": "dir" if child.is_dir() else "file",
"mtime": _mtime(stat.st_mtime),
}
if child.is_dir():
node["children"] = _tree(root, child, depth=depth + 1)
else:
node["size"] = stat.st_size
out.append(node)
return out
+31 -3
View File
@@ -12,16 +12,18 @@ from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable, Mapping, Sequence
from beaver_gateway.backends.base import Backend
from beaver_gateway.core.auth import TokenStore
from beaver_gateway.core.kinds import Kind
from beaver_gateway.core.registry import AgentRegistry, McpRegistry
from beaver_gateway.core.turn_record import TurnRecord
from beaver_gateway.storage import Database
from beaver_gateway.storage.models import Conversation, ConversationBinding
TurnLogHandler = Callable[[TurnRecord], Awaitable[None]]
@@ -36,7 +38,7 @@ class GatewayRuntime:
the name in hand, so the indirection lives one step earlier.
``mcp_internal_urls`` is filled in Phase 2.1: one loopback URL per
declared ``McpServer`` so ``ClaudeCodeBackendAdapter`` (Phase 2.2)
declared ``McpServer`` so ``ClaudeSdkBackend``
can pass them to ``BackendOptions.mcp_servers`` without re-running
discovery.
@@ -77,13 +79,39 @@ class GatewayRuntime:
# to keep ``anthropic.types`` out of the runtime import graph for
# this base module.
turn_log_handlers: list[TurnLogHandler] = field(default_factory=list)
# M1b: conversations service, event bus and the shared session pool.
# ``Any`` for the same import-graph reason as above; ``None`` only in
# tests that build a runtime without them.
conversations: Any = None
bus: Any = None
pool: Any = None
class Frontend(ABC):
"""Listens on a port, dispatches into the gateway."""
"""Listens on a port, dispatches into the gateway.
A frontend that shows conversations declares ``name`` (the binding
key) and ``kinds`` (which conversation kinds it shows);
``core/conversations`` refuses to bind a conversation to a frontend
outside its declaration. The first frontend in declaration order
whose ``materialize`` returns a binding is the *home* of that kind:
``spawn`` calls it so a new conversation gets a window (a vault file,
a Telegram topic). ``agent_for`` names the default agent for a kind
so callers may omit ``agent``. Stateless frontends (MCP, admin) keep
the defaults and stay outside the routing.
"""
name: str = ""
kinds: tuple[Kind, ...] = ()
@abstractmethod
def configure(self, runtime: GatewayRuntime) -> None: ...
@abstractmethod
async def serve(self) -> None: ...
def agent_for(self, kind: Kind) -> str | None: # noqa: ARG002
return None
async def materialize(self, conv: Conversation) -> ConversationBinding | None: # noqa: ARG002
return None
@@ -38,14 +38,16 @@ if TYPE_CHECKING:
_log = logging.getLogger("beaver_gateway.frontends.markdown.crossfront")
# User hook: take a turn + vault root, return where the new file should
# live. Returning a relative ``Path`` is treated as relative to the
# vault. ``None`` (the default) keeps the built-in
# ``{vault}/{logged_subdir}/{agent}/{YYYY-MM-DD}_{hex8}.md`` layout.
LogPathFn = "Callable[[TurnRecord, Path], Path]"
__all__ = [
"ChatPathFn",
"CrossFrontendLogger",
"fingerprint_messages",
"strip_trailing_user_scaffold",
]
__all__ = ["CrossFrontendLogger", "LogPathFn", "fingerprint_messages"]
ChatPathFn = "Callable[[str, str, Path], Path]"
"""``(title, agent, vault) -> path`` of a new chat file; relative = under the
vault. ``None`` keeps ``{vault}/{logged_subdir}/{agent}/{YYYY-MM-DD}_{slug}.md``."""
def fingerprint_messages(messages: Iterable[MessageParam]) -> str:
@@ -96,18 +98,18 @@ class CrossFrontendLogger:
*,
vault_path: Path,
logged_subdir: str,
log_path: Callable[[TurnRecord, Path], Path] | None = None,
chat_path: Callable[[str, str, Path], Path] | None = None,
) -> None:
self._vault = vault_path
self._root = vault_path / logged_subdir
self._index: dict[str, Path] = {}
self._lock = asyncio.Lock()
self._log_path_fn = log_path
self._chat_path_fn = chat_path
# When the user supplies a custom path function, files can land
# anywhere in the vault — so we have to scan the whole vault on
# startup to rebuild the fingerprint→path map. With the default
# layout we can bound the scan to ``_logs/``.
self._scan_root = vault_path if log_path is not None else self._root
self._scan_root = vault_path if chat_path is not None else self._root
def warm_index(self) -> None:
"""Scan logged files synchronously, populating the fingerprint map.
@@ -171,7 +173,7 @@ class CrossFrontendLogger:
if target.exists():
existing = target.read_text(encoding="utf-8")
parsed = frontmatter.loads(existing)
body = _strip_trailing_user_scaffold(parsed.content)
body = strip_trailing_user_scaffold(parsed.content)
# We append only the *new* user turn (the last one in
# input_messages, since prior turns are already on disk)
# plus the assistant reply.
@@ -206,13 +208,15 @@ class CrossFrontendLogger:
def _new_file_path(self, record: TurnRecord) -> Path:
"""Pick a fresh filename for a brand-new conversation.
With a user-supplied ``log_path`` we delegate to it (joining a
With a user-supplied ``chat_path`` we delegate to it (joining a
relative result with the vault root). Without one, we fall back
to ``{logged_subdir}/{agent}/{date}_{hex8}.md`` and ensure the
``.md`` suffix in case the user picks a non-md extension by hand.
"""
if self._log_path_fn is not None:
result = self._log_path_fn(record, self._vault)
if self._chat_path_fn is not None:
result = self._chat_path_fn(
record.first_user_text, record.agent_name, self._vault
)
if not result.is_absolute():
result = self._vault / result
if result.suffix != ".md":
@@ -270,7 +274,7 @@ def _render_full_history(messages: list[MessageParam], assistant: Any) -> str:
return body
def _strip_trailing_user_scaffold(body: str) -> str:
def strip_trailing_user_scaffold(body: str) -> str:
"""Drop a trailing empty ``### User:`` block if present.
Cross-frontend turns aren't typed into the file by the human — they
@@ -0,0 +1,50 @@
"""Vault file IO shared by the markdown frontend and its mirror."""
from __future__ import annotations
import contextlib
import os
import tempfile
from typing import TYPE_CHECKING, Any
import aiofile
import frontmatter
if TYPE_CHECKING:
from pathlib import Path
__all__ = ["read_or_empty", "reattach_frontmatter", "write_atomic"]
async def read_or_empty(path: Path) -> str:
if not path.exists(): # noqa: ASYNC240
return ""
async with aiofile.async_open(path, "r", encoding="utf-8") as f:
return await f.read()
async def write_atomic(path: Path, content: str) -> None:
"""Write via tmp + ``os.replace`` in the same directory: no torn reads."""
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(
prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent)
)
try:
async with aiofile.async_open(tmp_path, "w", encoding="utf-8") as f:
await f.write(content)
os.close(fd)
os.replace(tmp_path, path) # noqa: PTH105
except BaseException:
with contextlib.suppress(OSError):
os.close(fd)
with contextlib.suppress(OSError):
os.unlink(tmp_path) # noqa: PTH108
raise
def reattach_frontmatter(metadata: dict[str, Any], body: str) -> str:
"""Re-emit a ``.md`` with YAML frontmatter; no block at all for empty metadata."""
if not metadata:
return body if body.endswith("\n") else body + "\n"
post = frontmatter.Post(content=body, **metadata)
return frontmatter.dumps(post) + "\n"
+178 -283
View File
@@ -13,7 +13,16 @@ Concurrency model: an in-memory ``set[Path]`` of files currently in
flight. Two concurrent requests for the same file → the second gets
409. The set is single-process (one gateway instance) — that's by
design; the markdown frontend is the only writer in its vault from
the gateway side.
the gateway side. The turn itself runs through ``core/conversations``
(one turn per conversation, ``running_turn`` in the DB), so a message
posted to the same conversation via ``/api`` waits its turn.
A chat file is a ``deep`` conversation bound as
``(markdown, <vault-relative path>)``; frontmatter carries only ``agent``
and ``conversation_id`` (§3.10), tool calls are never rendered. The
frontend is the home of ``deep``: ``materialize`` gives a conversation
spawned elsewhere its file, and :class:`.mirror.ChatMirror` keeps that
file in step with replies produced outside ``/chat``.
Cross-frontend logging: when ``log_all_chats=True``, ``configure()``
registers a handler on ``runtime.turn_log_handlers`` so every other
@@ -28,44 +37,48 @@ import asyncio
import contextlib
import json
import logging
import os
import tempfile
import time
from collections.abc import AsyncIterator
from pathlib import Path
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, cast
import aiofile
from anthropic.types import RawContentBlockStopEvent
from fastapi import FastAPI, HTTPException, Request, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse
from beaver_gateway.backends.claude_code import ClaudeCodeBackendAdapter, TurnCapture
from beaver_gateway.core import audit
from beaver_gateway.core.conversation_store import (
diff_and_fork,
load_conversation,
load_messages,
mint_conversation,
rewrite_messages,
)
from beaver_gateway.core.turn_capture import TurnCapture
from beaver_gateway.core.turn_record import TurnRecord
from beaver_gateway.frontends._accumulate import StreamAccumulator
from beaver_gateway.frontends._auth import require_token
from beaver_gateway.frontends._sse import (
KEEPALIVE,
SSE_HEADERS,
events_with_heartbeat,
sse_pack,
)
from beaver_gateway.frontends.base import Frontend
from beaver_gateway.frontends.markdown import parser, renderer
from beaver_gateway.frontends.markdown.crossfront import (
CrossFrontendLogger,
fingerprint_messages,
from beaver_gateway.frontends.markdown.crossfront import CrossFrontendLogger
from beaver_gateway.frontends.markdown.files import (
read_or_empty,
reattach_frontmatter,
write_atomic,
)
from beaver_gateway.frontends.markdown.mirror import FRONTEND, ChatMirror
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Callable
from anthropic.types import MessageParam
from beaver_gateway.core.kinds import Kind
from beaver_gateway.frontends.base import GatewayRuntime
from beaver_gateway.storage.models import Conversation, ConversationBinding
_log = logging.getLogger("beaver_gateway.frontends.markdown")
@@ -88,17 +101,13 @@ _STREAM_FLUSH_DEBOUNCE = 0.4
# disk round-trip).
_SSE_FLUSH_DEBOUNCE = 0.1
# Interval between SSE comment-frames sent when the backend is silent
# (e.g. claude is mid-thinking on a large context). The Obsidian plugin
# and any intermediate proxies will hold the connection open as long as
# bytes keep flowing; a comment-frame is the cheapest legal SSE keepalive.
# Set well under typical proxy/client idle timeouts (60s).
_SSE_HEARTBEAT_INTERVAL = 15.0
class MarkdownFrontend(Frontend):
"""FastAPI app behind ``POST /chat`` driven by Obsidian-vault files."""
name = FRONTEND
kinds = ("deep",)
def __init__(
self,
*,
@@ -108,7 +117,7 @@ class MarkdownFrontend(Frontend):
default_agent: str | None = None,
log_all_chats: bool = False,
logged_subdir: str = "_logs",
log_path: Callable[[TurnRecord, Path], Path] | None = None,
chat_path: Callable[[str, str, Path], Path] | None = None,
public_base_url: str | None = None,
) -> None:
self.vault_path = Path(vault_path).expanduser().resolve()
@@ -117,7 +126,7 @@ class MarkdownFrontend(Frontend):
self.default_agent = default_agent
self.log_all_chats = log_all_chats
self.logged_subdir = logged_subdir
self.log_path = log_path
self.chat_path = chat_path
# External URL prefix when behind a reverse proxy — same role as
# on the other bearer frontends. Trailing slash trimmed for
# idempotent concatenation; ``None`` means "no proxy / advertise
@@ -131,15 +140,25 @@ class MarkdownFrontend(Frontend):
# request reliably loses the race to 409.
self._busy: set[Path] = set()
self._crossfront: CrossFrontendLogger | None = None
self._mirror: ChatMirror | None = None
def configure(self, runtime: GatewayRuntime) -> None:
if runtime.conversations is None:
msg = "MarkdownFrontend needs runtime.conversations"
raise RuntimeError(msg)
self._runtime = runtime
self.vault_path.mkdir(parents=True, exist_ok=True)
self._mirror = ChatMirror(
vault_path=self.vault_path,
runtime=runtime,
logged_subdir=self.logged_subdir,
chat_path=self.chat_path,
)
if self.log_all_chats:
self._crossfront = CrossFrontendLogger(
vault_path=self.vault_path,
logged_subdir=self.logged_subdir,
log_path=self.log_path,
chat_path=self.chat_path,
)
# Scan the existing logged files synchronously here so the
# fingerprint→path map is populated before the first
@@ -148,6 +167,19 @@ class MarkdownFrontend(Frontend):
runtime.turn_log_handlers.append(self._crossfront.handle)
self._app = self._build_app(runtime)
@property
def mirror(self) -> ChatMirror:
if self._mirror is None:
msg = "configure() must be called first"
raise RuntimeError(msg)
return self._mirror
def agent_for(self, kind: Kind) -> str | None:
return self.default_agent if kind == "deep" else None
async def materialize(self, conv: Conversation) -> ConversationBinding | None:
return await self.mirror.materialize(conv)
async def serve(self) -> None:
import uvicorn
@@ -158,7 +190,13 @@ class MarkdownFrontend(Frontend):
self._app, host=self.host, port=self.port, log_level="info"
)
server = uvicorn.Server(config)
await server.serve()
mirror = asyncio.create_task(self.mirror.run())
try:
await server.serve()
finally:
mirror.cancel()
with contextlib.suppress(asyncio.CancelledError):
await mirror
# ---- app builder ---------------------------------------------------
@@ -285,17 +323,7 @@ class MarkdownFrontend(Frontend):
self._busy.discard(file_path)
return StreamingResponse(
gen(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
# nginx default-buffers SSE bodies; this header tells
# both nginx and uvicorn-behind-proxy to flush as we
# write. Harmless if the deployment has no reverse
# proxy in front.
"X-Accel-Buffering": "no",
},
gen(), media_type="text/event-stream", headers=SSE_HEADERS
)
return app
@@ -312,11 +340,11 @@ class MarkdownFrontend(Frontend):
content_override: Any,
agent_override: str | None,
) -> Any:
write_disk = content_override is None
if isinstance(content_override, str):
await _write_atomic(file_path, content_override)
file_text = content_override
elif content_override is None:
file_text = await _read_or_empty(file_path)
file_text = await read_or_empty(file_path)
else:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "`content` must be a string when present"
@@ -381,22 +409,21 @@ class MarkdownFrontend(Frontend):
# Resolve / mint the conversation row, align incoming against
# stored history, and feed the aligned messages to the backend
# — see ``core/conversation_store.py`` for the full rationale.
# If the backend isn't claude-code (no ``TurnCapture`` support)
# we fall through to the legacy parser-only path.
# - see ``core/conversation_store.py`` for the full rationale.
conv, conv_external_id, stored_msgs = await self._resolve_conversation(
runtime=runtime, metadata=parsed.metadata, agent_name=agent.name
runtime=runtime,
metadata=parsed.metadata,
agent_name=agent.name,
file_path=file_path,
)
outcome = diff_and_fork(stored=stored_msgs, incoming=parsed.turns)
capture: TurnCapture | None = (
TurnCapture() if isinstance(backend, ClaudeCodeBackendAdapter) else None
)
kwargs: dict[str, Any] = {}
if capture is not None:
kwargs["capture"] = capture
events = backend.complete(
agent=agent, messages=outcome.messages, system=None, **kwargs
capture = TurnCapture()
events = runtime.conversations.turn(
conv,
messages=outcome.messages,
origin="user",
capture=capture,
use_session=outcome.divergence_index is None,
)
try:
message = await self._stream_to_file(
@@ -405,6 +432,7 @@ class MarkdownFrontend(Frontend):
parsed=parsed,
model=agent.model or agent.name,
filename=filename,
write_disk=write_disk,
)
except HTTPException:
raise
@@ -419,11 +447,12 @@ class MarkdownFrontend(Frontend):
message=message,
agent_name=agent.name,
conv_external_id=conv_external_id,
write_disk=write_disk,
)
await self._persist_canonical_history(
runtime=runtime,
conversation_id=conv.id,
conversation_id=cast("int", conv.id),
persist_messages=outcome.persist_messages,
new_user_text=parsed.turns[-1].text,
capture=capture,
@@ -480,17 +509,15 @@ class MarkdownFrontend(Frontend):
writers in their respective halves of Obsidian Sync. Final
content is identical on both sides, so Sync no-ops.
"""
# File-text resolution + early bailouts. ``content_override`` is
# still written to disk on the gateway side because that's the
# state the rest of the request consumes; it just doesn't keep
# ticking after that single write.
# With ``content`` the plugin is the only writer of the file
# (§3.10): the gateway never touches disk in that case.
write_disk = content_override is None
if isinstance(content_override, str):
await _write_atomic(file_path, content_override)
file_text = content_override
elif content_override is None:
file_text = await _read_or_empty(file_path)
file_text = await read_or_empty(file_path)
else:
yield _sse_pack(
yield sse_pack(
"error",
{
"status_code": status.HTTP_400_BAD_REQUEST,
@@ -506,7 +533,7 @@ class MarkdownFrontend(Frontend):
default=self.default_agent,
)
if not agent_name:
yield _sse_pack(
yield sse_pack(
"error",
{
"status_code": status.HTTP_400_BAD_REQUEST,
@@ -519,7 +546,7 @@ class MarkdownFrontend(Frontend):
return
if not parsed.messages:
yield _sse_pack(
yield sse_pack(
"done",
{
"status": "nothing_to_do",
@@ -530,7 +557,7 @@ class MarkdownFrontend(Frontend):
return
if parser.last_role(parsed.messages) == "assistant":
yield _sse_pack(
yield sse_pack(
"done",
{
"status": "nothing_to_do",
@@ -542,7 +569,7 @@ class MarkdownFrontend(Frontend):
agent = runtime.agents.get(agent_name)
if agent is None:
yield _sse_pack(
yield sse_pack(
"error",
{
"status_code": status.HTTP_404_NOT_FOUND,
@@ -552,7 +579,7 @@ class MarkdownFrontend(Frontend):
return
backend = runtime.backends.get(agent.name)
if backend is None:
yield _sse_pack(
yield sse_pack(
"error",
{
"status_code": status.HTTP_503_SERVICE_UNAVAILABLE,
@@ -577,9 +604,18 @@ class MarkdownFrontend(Frontend):
msgs=len(parsed.messages),
)
conv, conv_external_id, stored_msgs = await self._resolve_conversation(
runtime=runtime, metadata=parsed.metadata, agent_name=agent.name
)
try:
conv, conv_external_id, stored_msgs = await self._resolve_conversation(
runtime=runtime,
metadata=parsed.metadata,
agent_name=agent.name,
file_path=file_path,
)
except HTTPException as exc:
yield sse_pack(
"error", {"status_code": exc.status_code, "detail": exc.detail}
)
return
_log.info(
"chat/stream: file=%s conv_external_id=%s conv_id=%d "
"stored_msgs=%d incoming_turns=%d",
@@ -598,21 +634,19 @@ class MarkdownFrontend(Frontend):
len(outcome.messages),
len(outcome.persist_messages),
)
capture: TurnCapture | None = (
TurnCapture() if isinstance(backend, ClaudeCodeBackendAdapter) else None
)
kwargs: dict[str, Any] = {}
if capture is not None:
kwargs["capture"] = capture
capture = TurnCapture()
_log.info(
"chat/stream: file=%s calling backend.complete agent=%s capture=%s",
"chat/stream: file=%s calling backend.complete agent=%s session=%s",
filename,
agent.name,
capture is not None,
conv.session_id,
)
events = backend.complete(
agent=agent, messages=outcome.messages, system=None, **kwargs
events = runtime.conversations.turn(
conv,
messages=outcome.messages,
origin="user",
capture=capture,
use_session=outcome.divergence_index is None,
)
acc = StreamAccumulator()
@@ -626,16 +660,12 @@ class MarkdownFrontend(Frontend):
return None
rendered = renderer.render_assistant_message(partial)
new_body = renderer.append_to_body(parsed.body, rendered)
return _reattach_frontmatter(parsed.metadata, new_body)
return reattach_frontmatter(parsed.metadata, new_body)
try:
async for ev in _events_with_heartbeat(events):
async for ev in events_with_heartbeat(events):
if ev is None:
# Backend is quiet (claude mid-thinking, MCP slow,
# whatever). SSE comment-frame keeps the TCP socket
# warm so the plugin / uvicorn / any reverse proxy
# doesn't time the request out before we finish.
yield b": keepalive\n\n"
yield KEEPALIVE
continue
acc.feed(ev)
now = time.monotonic()
@@ -648,7 +678,7 @@ class MarkdownFrontend(Frontend):
# render to the same prefix as before they closed
# (we don't surface the tool-call args in markdown).
if payload is not None and payload != last_payload:
yield _sse_pack("delta", {"new_content": payload})
yield sse_pack("delta", {"new_content": payload})
last_payload = payload
last_flush = now
except Exception as exc: # noqa: BLE001 — wire any backend failure as an SSE error frame
@@ -663,10 +693,11 @@ class MarkdownFrontend(Frontend):
new_body, renderer.render_assistant_message(partial)
)
new_body = renderer.append_to_body(new_body, _render_error_block(exc))
await _write_atomic(
file_path, _reattach_frontmatter(parsed.metadata, new_body)
)
yield _sse_pack(
if write_disk:
await write_atomic(
file_path, reattach_frontmatter(parsed.metadata, new_body)
)
yield sse_pack(
"error",
{
"status_code": status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -683,11 +714,12 @@ class MarkdownFrontend(Frontend):
message=message,
agent_name=agent.name,
conv_external_id=conv_external_id,
write_disk=write_disk,
)
await self._persist_canonical_history(
runtime=runtime,
conversation_id=conv.id,
conversation_id=cast("int", conv.id),
persist_messages=outcome.persist_messages,
new_user_text=parsed.turns[-1].text,
capture=capture,
@@ -707,7 +739,7 @@ class MarkdownFrontend(Frontend):
except Exception: # noqa: BLE001
_log.exception("turn_log_handler raised; continuing")
yield _sse_pack(
yield sse_pack(
"done",
{
"status": "ok",
@@ -727,6 +759,7 @@ class MarkdownFrontend(Frontend):
parsed: parser.ParsedFile,
model: str,
filename: str,
write_disk: bool = True,
) -> Any:
"""Drain ``events`` into a ``Message``, flushing partials to disk.
@@ -745,13 +778,15 @@ class MarkdownFrontend(Frontend):
acc = StreamAccumulator()
async def flush_partial() -> None:
if not write_disk:
return
partial = acc.finalize(model=model)
if not partial.content:
return
rendered = renderer.render_assistant_message(partial)
new_body = renderer.append_to_body(parsed.body, rendered)
await _write_atomic(
file_path, _reattach_frontmatter(parsed.metadata, new_body)
await write_atomic(
file_path, reattach_frontmatter(parsed.metadata, new_body)
)
try:
@@ -774,9 +809,10 @@ class MarkdownFrontend(Frontend):
new_body, renderer.render_assistant_message(partial)
)
new_body = renderer.append_to_body(new_body, _render_error_block(exc))
await _write_atomic(
file_path, _reattach_frontmatter(parsed.metadata, new_body)
)
if write_disk:
await write_atomic(
file_path, reattach_frontmatter(parsed.metadata, new_body)
)
raise
return acc.finalize(model=model)
@@ -788,75 +824,62 @@ class MarkdownFrontend(Frontend):
message: Any,
agent_name: str,
conv_external_id: str,
write_disk: bool = True,
) -> str:
"""Render the assistant turn, append to the file, refresh frontmatter."""
"""Render the assistant turn, refresh frontmatter, write if we own the file."""
rendered = renderer.render_assistant_message(message)
new_body = renderer.append_to_body(parsed.body, rendered)
new_body = renderer.append_to_body(new_body, renderer.USER_SCAFFOLD)
# Recompute fingerprint so a future cross-frontend hit on this
# same conversation can find it. Stored as hex string in
# frontmatter — only the markdown frontend reads it.
assistant_param: MessageParam = {
"role": "assistant",
"content": _flatten_assistant_text(message),
}
updated_messages: list[MessageParam] = [*parsed.messages, assistant_param]
updated_metadata = dict(parsed.metadata)
updated_metadata.pop("fingerprint", None)
updated_metadata["agent"] = agent_name
updated_metadata["conversation_id"] = conv_external_id
updated_metadata["fingerprint"] = fingerprint_messages(updated_messages)
new_content = _reattach_frontmatter(updated_metadata, new_body)
await _write_atomic(file_path, new_content)
new_content = reattach_frontmatter(updated_metadata, new_body)
if write_disk:
await write_atomic(file_path, new_content)
return new_content
async def _resolve_conversation(
self, *, runtime: GatewayRuntime, metadata: dict[str, Any], agent_name: str
) -> tuple[Any, str, list[dict[str, Any]]]:
"""Resolve the conversation row + stored messages for this request.
self,
*,
runtime: GatewayRuntime,
metadata: dict[str, Any],
agent_name: str,
file_path: Path,
) -> tuple[Conversation, str, list[dict[str, Any]]]:
"""Resolve the ``deep`` conversation for this file + its stored messages.
Looks up by frontmatter ``conversation_id``, mints a new row if
missing, and returns ``(conv, external_id, stored_messages)``.
``conv.id`` is guaranteed non-None because both
``load_conversation`` (after refresh on a committed row) and
``mint_conversation`` (post-commit refresh) populate it. We
coerce with a runtime check so the rest of the handler can
treat it as ``int``.
Frontmatter ``conversation_id`` wins; a file that lost it is found
by its visible ``(markdown, path)`` binding; otherwise a new
conversation is created. The binding follows the file: a moved
chat re-binds to its new path on the next turn.
"""
conversations = runtime.conversations
rel = file_path.relative_to(self.vault_path).as_posix()
raw = metadata.get("conversation_id")
lookup_id = raw if isinstance(raw, str) and raw else None
conv = await conversations.get(raw) if isinstance(raw, str) and raw else None
if conv is None:
conv = await conversations.find_bound(frontend=FRONTEND, external_id=rel)
if conv is None:
try:
conv = await conversations.create(
kind="deep", agent=agent_name, origin=FRONTEND, title=file_path.stem
)
except ValueError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
_log.info("minted conversation %s for %s", conv.external_id, rel)
bound = [
b
for b in await conversations.bindings(conv)
if b.frontend == FRONTEND and b.visible and b.external_id == rel
]
if not bound:
await conversations.bind(conv, frontend=FRONTEND, external_id=rel)
await conversations.touch_user(conv)
if conv.id is None:
msg = "conversation row missing primary key after commit"
raise RuntimeError(msg)
async with runtime.db.session() as session:
conv = None
if lookup_id is not None:
conv = await load_conversation(
session, frontend="markdown", external_id=lookup_id
)
if conv is None:
_log.info(
"_resolve_conversation: frontmatter conv_id=%s "
"not found in DB, will mint new",
lookup_id,
)
else:
_log.info(
"_resolve_conversation: LOADED existing conv "
"id=%d external_id=%s",
conv.id or -1,
conv.external_id,
)
if conv is None:
conv = await mint_conversation(
session, frontend="markdown", agent_name=agent_name
)
_log.info(
"_resolve_conversation: MINTED new conv "
"id=%d external_id=%s agent=%s",
conv.id or -1,
conv.external_id,
agent_name,
)
if conv.id is None:
msg = "conversation row missing primary key after commit"
raise RuntimeError(msg)
stored = await load_messages(session, conversation_id=conv.id)
return conv, conv.external_id, stored
@@ -867,21 +890,17 @@ class MarkdownFrontend(Frontend):
conversation_id: int,
persist_messages: list[dict[str, Any]],
new_user_text: str,
capture: TurnCapture | None,
capture: TurnCapture,
message: Any,
) -> None:
"""Stamp the DB with the post-turn canonical Anthropic-shape history.
Combines the matched/spliced prior state, the new user prompt,
and the synthesized assistant↔tool cycle from the backend (or
a text-only fallback for backends without ``TurnCapture``).
and the synthesized assistant/tool cycle from the backend (or a
text-only fallback for backends that left ``capture`` empty).
"""
new_user_msg = {"role": "user", "content": new_user_text}
synthesized = (
capture.synthesized_messages
if capture is not None
else _fallback_synthesized(message)
)
synthesized = capture.synthesized_messages or _fallback_synthesized(message)
canonical = [*persist_messages, new_user_msg, *synthesized]
_log.info(
"_persist_canonical_history: conv_id=%d writing %d msgs "
@@ -919,115 +938,6 @@ class MarkdownFrontend(Frontend):
return candidate
# ---- module-level utilities ----------------------------------------------
async def _events_with_heartbeat(
events: AsyncIterator[Any], interval: float = _SSE_HEARTBEAT_INTERVAL
) -> AsyncIterator[Any]:
"""Wrap an async event stream with idle-time heartbeat markers.
Yields ``None`` every ``interval`` seconds during silence; real
events pass through unchanged. When the wrapped iterator is
exhausted, this generator returns. Cancellation propagates: if the
outer scope is cancelled we cancel the pending ``__anext__`` task
instead of leaving it dangling.
"""
src = events.__aiter__()
next_task: asyncio.Task[Any] | None = None
try:
while True:
# Reuse the in-flight task across timeouts. Spawning a fresh
# ``__anext__()`` while the previous one is still pending
# puts two consumers on the same async generator — that
# raises ``RuntimeError: anext(): asynchronous generator is
# already running``.
if next_task is None:
next_task = asyncio.ensure_future(src.__anext__())
done, _pending = await asyncio.wait({next_task}, timeout=interval)
if not done:
yield None
continue
task = next_task
next_task = None
try:
result = task.result()
except StopAsyncIteration:
return
yield result
finally:
if next_task is not None and not next_task.done():
next_task.cancel()
with contextlib.suppress(BaseException):
await next_task
def _sse_pack(event: str, data: dict[str, Any]) -> bytes:
r"""Format one Server-Sent Event frame.
Uses named events (``event: <name>``) so the plugin can dispatch on
type without parsing JSON discriminators. ``ensure_ascii=False`` so
multibyte content rides through verbatim instead of becoming
``\uXXXX`` blobs that bloat the wire.
"""
body = json.dumps(data, ensure_ascii=False)
return f"event: {event}\ndata: {body}\n\n".encode()
async def _read_or_empty(path: Path) -> str:
"""Return file contents, or empty string if the file doesn't exist."""
# ``path.exists()`` here is a metadata stat — microseconds — and
# gating an async read on whether the file is there is exactly the
# check we want. Switching to anyio.Path / aiofiles.os just to
# silence the async-pathlib lint would cost a dep edge for no
# practical win.
if not path.exists(): # noqa: ASYNC240
return ""
async with aiofile.async_open(path, "r", encoding="utf-8") as f:
return await f.read()
async def _write_atomic(path: Path, content: str) -> None:
"""Write ``content`` to ``path`` via tmp + ``os.replace`` (atomic)."""
path.parent.mkdir(parents=True, exist_ok=True)
# ``NamedTemporaryFile`` keeps the file open which complicates
# ``os.replace`` on some platforms. Build the tmp name manually,
# write+fsync, then rename. Same-directory so the rename is atomic.
tmp_name = tempfile.mkstemp(
prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent)
)
fd, tmp_path = tmp_name
try:
async with aiofile.async_open(tmp_path, "w", encoding="utf-8") as f:
await f.write(content)
os.close(fd)
# ``os.replace`` is the atomic primitive — ``Path.replace`` is a
# thin wrapper around the same syscall; either works, ``os.`` is
# the one Linux/POSIX docs reach for.
os.replace(tmp_path, path) # noqa: PTH105
except BaseException:
# Cleanup on failure: close fd, remove tmp.
with contextlib.suppress(OSError):
os.close(fd)
with contextlib.suppress(OSError):
os.unlink(tmp_path) # noqa: PTH108
raise
def _reattach_frontmatter(metadata: dict[str, Any], body: str) -> str:
r"""Re-emit a ``.md`` file with YAML frontmatter at the top.
Empty metadata → no frontmatter block (avoid littering every file
with a hollow ``---\n---``).
"""
if not metadata:
return body if body.endswith("\n") else body + "\n"
import frontmatter as _fm
post = _fm.Post(content=body, **metadata)
return _fm.dumps(post) + "\n"
def _fallback_synthesized(message: Any) -> list[dict[str, Any]]:
"""Build a single-assistant ``synthesized_messages`` list from a raw ``Message``.
@@ -1065,21 +975,6 @@ def _fallback_synthesized(message: Any) -> list[dict[str, Any]]:
return [{"role": "assistant", "content": content}]
def _flatten_assistant_text(message: Any) -> str:
"""Pull all text blocks from an assistant ``Message`` and join them.
Used when we need the assistant content as a plain string for
fingerprinting / equality with a parser-shaped history (parser
already drops thinking + tool_use from assistant turns).
"""
chunks = [
getattr(block, "text", "") or ""
for block in getattr(message, "content", ())
if getattr(block, "type", None) == "text"
]
return "\n\n".join(c for c in chunks if c)
def _render_error_block(exc: BaseException) -> str:
"""Render a backend failure as an Assistant turn with a ``[!error]-`` callout."""
msg = str(exc) or exc.__class__.__name__
@@ -0,0 +1,169 @@
"""Vault files for ``deep`` conversations that were not typed into a file (§3.10).
``materialize`` is the markdown frontend's answer to ``spawn(kind=deep)``:
a new file in the vault with ``agent`` + ``conversation_id`` frontmatter
and the ``(markdown, path)`` binding. ``run`` tails the gateway bus and
appends every ``reply`` of a markdown-bound conversation to its file -
the seed turn of a spawn, a message posted through ``/api``, a turn
that came in over ``/v1/messages`` - and stamps the same exchange into
the canonical history, so a continuation typed in Obsidian aligns
against the store and resumes the same SDK session instead of reseeding.
"""
from __future__ import annotations
import asyncio
import logging
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
import frontmatter
from beaver_gateway.core.conversation_store import load_messages, rewrite_messages
from beaver_gateway.core.turn_record import slugify
from beaver_gateway.frontends.markdown import renderer
from beaver_gateway.frontends.markdown.crossfront import strip_trailing_user_scaffold
from beaver_gateway.frontends.markdown.files import (
read_or_empty,
reattach_frontmatter,
write_atomic,
)
if TYPE_CHECKING:
from collections.abc import Callable, Sequence
from pathlib import Path
from beaver_gateway.core.bus import Event
from beaver_gateway.frontends.base import GatewayRuntime
from beaver_gateway.storage.models import Conversation, ConversationBinding
__all__ = ["FRONTEND", "ChatMirror"]
_log = logging.getLogger("beaver_gateway.frontends.markdown.mirror")
FRONTEND = "markdown"
class ChatMirror:
def __init__(
self,
*,
vault_path: Path,
runtime: GatewayRuntime,
logged_subdir: str,
chat_path: Callable[[str, str, Path], Path] | None = None,
) -> None:
self._vault = vault_path
self._runtime = runtime
self._root = vault_path / logged_subdir
self._chat_path_fn = chat_path
self._lock = asyncio.Lock()
def path_for(self, title: str, agent: str) -> Path:
if self._chat_path_fn is not None:
path = self._chat_path_fn(title, agent, self._vault)
if not path.is_absolute():
path = self._vault / path
else:
day = datetime.now(UTC).strftime("%Y-%m-%d")
path = self._root / agent / f"{day}_{slugify(title, maxlen=60)}.md"
if path.suffix != ".md":
path = path.with_suffix(".md")
path = path.resolve()
path.relative_to(self._vault)
candidate, n = path, 1
while candidate.exists():
n += 1
candidate = path.with_name(f"{path.stem} ({n}){path.suffix}")
return candidate
async def materialize(self, conv: Conversation) -> ConversationBinding:
path = self.path_for(conv.title or conv.external_id, conv.agent_name)
rel = path.relative_to(self._vault).as_posix()
async with self._lock:
await write_atomic(path, reattach_frontmatter(_frontmatter(conv), ""))
binding = await self._runtime.conversations.bind(
conv, frontend=FRONTEND, external_id=rel
)
_log.info("materialized %s as %s", conv.external_id, rel)
return binding
async def bound_path(self, conv: Conversation) -> Path | None:
for b in await self._runtime.conversations.bindings(conv):
if b.frontend == FRONTEND and b.visible:
return self._vault / b.external_id
return None
async def run(self) -> None:
async for event in self._runtime.bus.stream():
if event.get("type") != "reply":
continue
try:
await self._on_reply(event)
except Exception: # noqa: BLE001
_log.exception("mirror of reply %s failed", event.get("turn_id"))
async def _on_reply(self, event: Event) -> None:
conv = await self._runtime.conversations.get(str(event["conversation_id"]))
if conv is None:
return
await self.append(
conv,
prompt=str(event.get("prompt") or ""),
text=str(event.get("text") or ""),
history=event.get("history"),
)
async def append(
self,
conv: Conversation,
*,
prompt: str,
text: str,
history: Sequence[dict[str, Any]] | None = None,
) -> None:
path = await self.bound_path(conv)
if path is None:
return
prior = [
{"role": str(h["role"]), "content": str(h["text"])}
for h in history or ()
if h.get("text")
]
async with self._lock:
parsed = frontmatter.loads(await read_or_empty(path))
body = strip_trailing_user_scaffold(parsed.content)
if prior and "### " not in body:
for m in prior:
body = renderer.append_to_body(body, _render(m))
body = renderer.append_to_body(body, renderer.render_user_text(prompt))
body = renderer.append_to_body(body, renderer.render_assistant_text(text))
body = renderer.append_to_body(body, renderer.USER_SCAFFOLD)
metadata = {**parsed.metadata, **_frontmatter(conv)}
metadata.pop("fingerprint", None)
await write_atomic(path, reattach_frontmatter(metadata, body))
await self._persist(conv, prior=prior, prompt=prompt, text=text)
async def _persist(
self, conv: Conversation, *, prior: list[dict[str, Any]], prompt: str, text: str
) -> None:
if conv.id is None:
return
async with self._runtime.db.session() as session:
stored = await load_messages(session, conversation_id=conv.id)
canonical = [
*(stored or prior),
{"role": "user", "content": prompt},
{"role": "assistant", "content": [{"type": "text", "text": text}]},
]
await rewrite_messages(session, conversation_id=conv.id, messages=canonical)
def _frontmatter(conv: Conversation) -> dict[str, Any]:
return {"agent": conv.agent_name, "conversation_id": conv.external_id}
def _render(message: dict[str, Any]) -> str:
if message["role"] == "user":
return renderer.render_user_text(message["content"])
return renderer.render_assistant_text(message["content"])
@@ -9,11 +9,10 @@ from other frontends).
from __future__ import annotations
import json
import re
from typing import TYPE_CHECKING, Any, cast
from anthropic.types import Message, TextBlock, ThinkingBlock, ToolUseBlock
from anthropic.types import Message, TextBlock, ThinkingBlock
if TYPE_CHECKING:
from collections.abc import Iterable
@@ -34,7 +33,9 @@ __all__ = [
# Empty ``### User:`` block appended after each assistant reply so the
# human has an obvious place to type the next turn. Parser drops empty
# user blocks, so this doesn't re-trigger dispatch on its own.
USER_SCAFFOLD = "### User:\n"
# Blank line after the header, like every rendered turn - the file stays
# symmetric whether the human or the gateway wrote the marker.
USER_SCAFFOLD = "### User:\n\n"
# Default 4-backtick fence so tool results that contain literal ```` ``` ````
@@ -54,6 +55,11 @@ def render_user_text(content: str) -> str:
return f"### User:\n\n{content.strip()}\n"
def render_assistant_text(text: str) -> str:
r"""Render a text-only assistant turn as ``### Assistant:\n\n<text>``."""
return f"### Assistant:\n\n{text.strip()}\n"
def render_assistant_message(message: Message) -> str:
"""Render an assistant ``Message`` (with content blocks) into a turn block.
@@ -61,16 +67,20 @@ def render_assistant_message(message: Message) -> str:
* ``ThinkingBlock`` → ``> [!thinking]-`` collapsed callout
* ``TextBlock`` → plain text (the spoken answer)
* ``ToolUseBlock`` → ``> [!tool]- <name>`` callout with the ``input``
JSON quoted inside. Tool *results* are not persisted — see
module docstring on ``parser.py`` for why.
* ``ToolUseBlock`` → nothing (§3.10: tool calls never reach the file;
"what the agent is doing" is the activity panel fed by SSE)
Blank lines separate adjacent blocks; trailing newline guarantees
the next ``---`` / ``### User:`` marker lands on its own line.
"""
parts: list[str] = ["### Assistant:", ""]
for block in message.content:
parts.extend(_render_block(block))
lines = list(_render_block(block))
if not lines:
# Tool calls render to nothing - no separator for them either,
# or every tool leaves a blank line behind.
continue
parts.extend(lines)
parts.append("")
return "\n".join(parts).rstrip() + "\n"
@@ -147,10 +157,7 @@ def _render_block(block: object) -> Iterable[str]:
if isinstance(block, ThinkingBlock):
yield from _render_thinking(block.thinking or "")
return
if isinstance(block, ToolUseBlock):
yield from _render_tool_use(block)
return
# Unknown block type — skip silently rather than corrupting the file.
# Tool-use blocks and unknown block types never reach the file.
def _render_thinking(text: str) -> Iterable[str]:
@@ -159,17 +166,6 @@ def _render_thinking(text: str) -> Iterable[str]:
yield f"> {line}" if line else ">"
def _render_tool_use(block: ToolUseBlock) -> Iterable[str]:
title = summarize_tool_input(block.name, block.input)
yield f"> [!tool]- {title}"
yield "> **input:**"
yield "> ```json"
pretty = json.dumps(block.input, indent=2, ensure_ascii=False, sort_keys=True)
for line in pretty.splitlines():
yield f"> {line}" if line else ">"
yield "> ```"
def adaptive_fence(content: str) -> str:
"""Return a backtick fence at least one longer than the longest run in ``content``.
@@ -0,0 +1,5 @@
"""Telegram frontend (§3.8): General = master, topic = branch, drafts, inbox/outbox."""
from beaver_gateway.frontends.telegram.frontend import Attachments, TelegramFrontend
__all__ = ["Attachments", "TelegramFrontend"]
@@ -0,0 +1,138 @@
"""One ``sendMessageDraft`` stream per running turn (§3.8).
The client folds a draft into the message that follows only when their
texts are identical - so the last push is the final text itself, rendered
exactly as the outbox will send it, with no status line.
A draft is ephemeral and lives 30 s, Telegram throttles edits to about one
per second per chat, and thinking or a tool call would otherwise look like a
hang - so the draft opens with a status line straight away, is refreshed on
a timer rather than on every delta, and is kept alive while nothing changes.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import time
import zlib
from typing import TYPE_CHECKING
from aiogram.exceptions import TelegramAPIError, TelegramBadRequest
from beaver_gateway.frontends.telegram.render import to_html
if TYPE_CHECKING:
from aiogram import Bot
__all__ = ["Draft"]
_log = logging.getLogger("beaver_gateway.frontends.telegram.drafts")
_TAIL = 3500
_KEEPALIVE = 20.0
class Draft:
def __init__(
self,
bot: Bot,
*,
chat_id: int,
thread_id: int | None,
turn_id: str,
interval: float = 0.7,
status: str = "⏳ думаю",
) -> None:
self._bot = bot
self._chat_id = chat_id
self._thread_id = thread_id
self._draft_id = (zlib.crc32(turn_id.encode()) & 0x7FFFFFFF) or 1
self._interval = interval
self.status = status
self.text = ""
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
def start(self) -> None:
if self._task is None:
self._task = asyncio.create_task(self._run())
def set_status(self, status: str) -> None:
if status != self.status:
self.status = status
self._dirty = True
def append(self, text: str) -> None:
if text:
self.text += text
self._dirty = True
async def stop(self) -> None:
"""Stop pushing and wait for the push in flight.
The final ``sendMessage`` must reach Telegram after the last draft,
or the draft lands on top of the message and lingers for its 30 s.
"""
if self._task is not None:
self._task.cancel()
await asyncio.gather(self._task, return_exceptions=True)
self._task = None
if self._inflight is not None:
with contextlib.suppress(Exception):
await self._inflight
self._inflight = None
async def finish(self, text: str) -> None:
"""Last push: the final text verbatim, so the message replaces the draft."""
await self.stop()
if self._broken or not text.strip():
return
try:
try:
await self._send(to_html(text), "HTML")
except TelegramBadRequest:
await self._send(text, None)
except TelegramAPIError as exc:
_log.warning(
"final draft to %s/%s failed: %s", self._chat_id, self._thread_id, exc
)
async def _run(self) -> None:
while not self._broken:
if self._dirty or time.monotonic() - self._last_sent > _KEEPALIVE:
self._inflight = asyncio.ensure_future(self._push())
await asyncio.shield(self._inflight)
await asyncio.sleep(self._interval)
async def _push(self) -> None:
self._dirty = False
self._last_sent = time.monotonic()
text = self._render()
try:
try:
await self._send(to_html(text), "HTML")
except TelegramBadRequest:
await self._send(text, None)
except TelegramAPIError as exc:
self._broken = True
_log.warning(
"draft to %s/%s stopped: %s", self._chat_id, self._thread_id, exc
)
async def _send(self, text: str, parse_mode: str | None) -> None:
await self._bot.send_message_draft(
chat_id=self._chat_id,
draft_id=self._draft_id,
message_thread_id=self._thread_id,
text=text,
parse_mode=parse_mode,
)
def _render(self) -> str:
tail = self.text[-_TAIL:]
return f"{self.status}\n\n{tail}" if tail.strip() else self.status
@@ -0,0 +1,899 @@
"""``TelegramFrontend`` - the private chat with the bot as the window (§3.8).
A private chat with topics has no General: the gateway makes one topic for
the master (``master_topic``) and rebinds it to every new master; any other
topic is a branch. The user makes a topic and the
first message in it spawns the branch (``seed=morning``); a message into a
topic whose branch is merged or closed spawns a new branch on the same
topic. Replies stream as drafts and land through the outbox; turns that
came from other windows are mirrored with a marker; ``origin=system`` is
never shown. ``AskUserQuestion`` becomes inline buttons (§3.7).
"""
from __future__ import annotations
import asyncio
import contextlib
import html
import logging
import tempfile
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, cast
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.exceptions import TelegramAPIError
from aiogram.types import (
BotCommand,
CallbackQuery,
InlineKeyboardButton,
InlineKeyboardMarkup,
Message,
ReactionTypeEmoji,
Update,
)
from beaver_gateway.frontends.base import Frontend
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
if TYPE_CHECKING:
from beaver_gateway.core.bus import Event, EventBus
from beaver_gateway.core.conversations import Conversations
from beaver_gateway.core.kinds import Kind
from beaver_gateway.frontends.base import GatewayRuntime
from beaver_gateway.storage.models import Conversation, ConversationBinding
__all__ = ["FRONTEND", "Attachments", "TelegramFrontend"]
_log = logging.getLogger("beaver_gateway.frontends.telegram")
FRONTEND = "telegram"
_DONE = "done"
_COMMAND_HELP = (
("merge", "слить ветку в мастер"),
("new", "новая ветка: в General - новый топик, в топике - заново на нём"),
("chat", "открыть глубокий чат в vault: /chat тема"),
("status", "что с этим разговором"), # noqa: RUF001
("help", "команды"),
)
_COMMANDS = (*(c for c, _ in _COMMAND_HELP), "start")
_HELP = "General - мастер, любой другой топик - ветка.\n" + "\n".join(
f"/{c} - {d}" for c, d in _COMMAND_HELP
)
@dataclass(frozen=True, slots=True)
class Attachments:
"""Where files from Telegram go.
``ephemeral`` - under the gateway's data dir, swept after ``keep_days``;
``vault`` - into a directory the agent owns. Pulling what is worth
keeping into the vault is the agent's job either way.
"""
mode: Literal["ephemeral", "vault"] = "ephemeral"
dir: Path | None = None
keep_days: int = 7
@property
def root(self) -> Path:
if self.dir is not None:
return self.dir
if self.mode == "vault":
msg = "Attachments(mode='vault') needs `dir`"
raise ValueError(msg)
return Path(tempfile.gettempdir()) / "beaver-attachments"
EPHEMERAL = Attachments()
@dataclass
class _Ask:
conversation_id: str
chat_id: int
thread_id: int | None
questions: list[dict[str, Any]]
messages: list[int] = field(default_factory=list)
picked: dict[int, list[str]] = field(default_factory=dict)
done: set[int] = field(default_factory=set)
class TelegramFrontend(Frontend):
name = FRONTEND
kinds = ("master", "branch")
def __init__(
self,
*,
token: str,
user_id: int,
master_agent: str | None = None,
branch_agent: str | None = None,
chat_id: int | None = None,
attachments: Attachments = EPHEMERAL,
draft_interval: float = 0.7,
master_topic: str = "🦫 General",
queued_reaction: str = "👀",
poll_timeout: int = 30,
outbox_backoff: float = 2.0,
) -> None:
self._token = token
self.user_id = user_id
self.chat_id = chat_id if chat_id is not None else user_id
self.master_agent = master_agent
self.branch_agent = branch_agent
self.attachments = attachments
self.master_topic = master_topic
self.draft_interval = draft_interval
self.queued_reaction = queued_reaction
self.poll_timeout = poll_timeout
self.outbox_backoff = outbox_backoff
self._runtime: GatewayRuntime | None = None
self._bot: Bot | None = None
self._inbox: Inbox | None = None
self._outbox: Outbox | None = None
self._targets: dict[str, tuple[int, int | None] | None] = {}
self._master_window: str | None = None
self._topic_names: dict[int, str] = {}
self._drafts: dict[str, Draft] = {}
self._asks: dict[str, _Ask] = {}
self._reactions: dict[int, tuple[int, int]] = {}
self._tasks: set[asyncio.Task[None]] = set()
# ---- Frontend --------------------------------------------------------
def agent_for(self, kind: Kind) -> str | None:
return {"master": self.master_agent, "branch": self.branch_agent}.get(kind)
def configure(self, runtime: GatewayRuntime) -> None:
if runtime.conversations is None or runtime.bus is None:
msg = "TelegramFrontend needs runtime.conversations and runtime.bus"
raise RuntimeError(msg)
self._runtime = runtime
if self._bot is None:
self._bot = Bot(self._token, default=DefaultBotProperties(parse_mode=None))
self._inbox = Inbox(
runtime.db, self._bot, handler=self._handle, poll_timeout=self.poll_timeout
)
self._outbox = Outbox(
runtime.db, self._bot, bus=runtime.bus, backoff=self.outbox_backoff
)
async def serve(self) -> None:
me = await self.bot.get_me()
_log.info(
"telegram: @%s, user %s, chat %s, topics=%s",
me.username,
self.user_id,
self.chat_id,
getattr(me, "has_topics_enabled", None),
)
self._sweep_attachments()
await self.bot.set_my_commands(
[BotCommand(command=c, description=d) for c, d in _COMMAND_HELP]
)
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(self.inbox.run())
tg.create_task(self.outbox.run())
tg.create_task(self._events())
finally:
for draft in list(self._drafts.values()):
await draft.stop()
await self.bot.session.close()
async def materialize(self, conv: Conversation) -> ConversationBinding | None:
if conv.kind == "master":
return await self.conversations.bind(
conv, frontend=FRONTEND, external_id=await self._master_ext()
)
if conv.kind != "branch":
return None
topic = await self.bot.create_forum_topic(
self.chat_id, name=(conv.title or "ветка")[:128]
)
self._topic_names[topic.message_thread_id] = topic.name
return await self.conversations.bind(
conv, frontend=FRONTEND, external_id=self._ext(topic.message_thread_id)
)
async def mark_topic(self, conv: Conversation, prefix: str = "✅ ") -> bool:
"""Rotation hook for M3.
``closeForumTopic`` does not exist in private chats; the state of a
merged or closed branch lives in its name.
"""
target = await self._target_of(conv)
if target is None or target[1] is None:
return False
name = self._topic_names.get(target[1]) or conv.title or "ветка"
if name.startswith(prefix):
return True
await self.bot.edit_forum_topic(
target[0], target[1], name=f"{prefix}{name}"[:128]
)
self._topic_names[target[1]] = f"{prefix}{name}"
return True
# ---- plumbing --------------------------------------------------------
@property
def bot(self) -> Bot:
if self._bot is None:
msg = "configure() must be called before use"
raise RuntimeError(msg)
return self._bot
@property
def inbox(self) -> Inbox:
return cast("Inbox", self._inbox)
@property
def outbox(self) -> Outbox:
return cast("Outbox", self._outbox)
@property
def conversations(self) -> Conversations:
return cast(
"Conversations", cast("GatewayRuntime", self._runtime).conversations
)
@property
def bus(self) -> EventBus:
return cast("EventBus", cast("GatewayRuntime", self._runtime).bus)
def _ext(self, thread_id: int | None) -> str:
return f"{self.chat_id}/{thread_id}" if thread_id else str(self.chat_id)
@staticmethod
def _parse_ext(ext: str) -> tuple[int, int | None]:
chat, _, thread = ext.partition("/")
return int(chat), int(thread) if thread else None
async def _target_of(self, conv: Conversation) -> tuple[int, int | None] | None:
key = conv.external_id
if key not in self._targets:
bound = next(
(
b
for b in await self.conversations.bindings(conv)
if b.frontend == FRONTEND and b.visible
),
None,
)
self._targets[key] = self._parse_ext(bound.external_id) if bound else None
return self._targets[key]
async def _deliver(
self,
conv: Conversation,
text: str,
*,
turn_id: str | None = None,
key: str | None = None,
) -> None:
target = await self._target_of(conv)
if target is None or not text.strip():
return
await self.outbox.enqueue(
chat_id=target[0],
thread_id=target[1],
text=text,
conversation_id=conv.id,
turn_id=turn_id,
dedupe_key=key,
)
async def _master_ext(self) -> str:
"""The master's window.
General in a forum group, our own topic in a private chat (Telegram
has no General there). Created once, then found through whatever
master used it last.
"""
if self._master_window is not None:
return self._master_window
last = await self.conversations.last_binding(frontend=FRONTEND, kind="master")
if last is not None:
self._master_window = last.external_id
elif self.chat_id < 0:
self._master_window = self._ext(None)
else:
topic = await self.bot.create_forum_topic(
self.chat_id, name=self.master_topic[:128]
)
self._topic_names[topic.message_thread_id] = topic.name
self._master_window = self._ext(topic.message_thread_id)
return self._master_window
async def _master(self, text: str | None = None) -> tuple[Conversation, bool]:
"""The open master behind its window, spawning one when there is none.
``text`` rides with the seed of a fresh master; the second value says
whether it was consumed that way.
"""
ext = await self._master_ext()
conv = await self.conversations.find_bound(frontend=FRONTEND, external_id=ext)
if conv is not None and conv.status == "open":
return conv, False
masters = await self.conversations.find(kind="master", status="open", limit=1)
if masters:
await self.conversations.bind(
masters[0], frontend=FRONTEND, external_id=ext
)
return masters[0], False
conv = await self.conversations.spawn(
kind="master",
seed="clean",
text=text,
origin=FRONTEND,
binding=(FRONTEND, ext),
)
return conv, text is not None
async def _branch(
self, thread_id: int, *, title: str | None, text: str | None
) -> Conversation:
master, _ = await self._master()
return await self.conversations.spawn(
kind="branch",
seed="morning",
parent=master,
title=(title or self._topic_names.get(thread_id) or "ветка")[:128],
text=text,
origin=FRONTEND,
binding=(FRONTEND, self._ext(thread_id)),
)
# ---- inbox -----------------------------------------------------------
async def _handle(self, update: Update) -> None:
if update.message is not None:
await self._on_message(update.message)
elif update.callback_query is not None:
await self._on_callback(update.callback_query)
async def _on_message(self, message: Message) -> None:
created = message.forum_topic_created
if (
created is not None
and message.message_thread_id
and not created.is_name_implicit
):
self._topic_names[message.message_thread_id] = created.name
if message.from_user is None or message.from_user.id != self.user_id:
_log.warning(
"ignoring message from %s", getattr(message.from_user, "id", None)
)
return
if message.chat.id != self.chat_id:
return
in_topic = message.is_topic_message or message.forum_topic_created is not None
thread_id = message.message_thread_id if in_topic else None
is_master = (
thread_id is None or self._ext(thread_id) == await self._master_ext()
)
if message.forum_topic_created is not None:
return
if message.forum_topic_edited is not None and thread_id is not None:
if message.forum_topic_edited.name:
self._topic_names[thread_id] = message.forum_topic_edited.name
return
text = (message.text or message.caption or "").strip()
attachment = await self._save_attachment(message, thread_id)
if attachment:
text = f"{text}\n\n{attachment}".strip()
if not text:
return
if message.text and message.text.startswith("/"):
command, _, args = message.text[1:].partition(" ")
command = command.partition("@")[0].lower()
if command in _COMMANDS:
await self._command(command, args.strip(), message, thread_id)
return
if is_master:
conv, consumed = await self._master(text)
if consumed:
return
else:
conv = await self._live(thread_id)
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)
return
pending = self.conversations.pending_question(conv.external_id)
if pending is not None and self.conversations.answer(pending[0], text):
await self._close_ask(pending[0], f"✍️ {text}")
return
item = await self.conversations.post(conv, text, origin=FRONTEND)
if conv.running_turn or conv.pending_question:
await self._react(message, item.id)
async def _live(self, thread_id: int) -> Conversation | None:
conv = await self.conversations.find_bound(
frontend=FRONTEND, external_id=self._ext(thread_id)
)
return conv if conv is not None and conv.status == "open" else None
@staticmethod
def _title_from(text: str) -> str:
line = text.strip().splitlines()[0]
return line if len(line) <= 60 else line[:57] + "…"
async def _react(self, message: Message, item_id: int | None) -> None:
if not self.queued_reaction or item_id is None:
return
try:
await self.bot.set_message_reaction(
message.chat.id,
message.message_id,
reaction=[ReactionTypeEmoji(emoji=self.queued_reaction)],
)
except TelegramAPIError as exc:
_log.debug("reaction failed: %s", exc)
return
self._reactions[item_id] = (message.chat.id, message.message_id)
async def _unreact(self, item_id: int | None) -> None:
target = self._reactions.pop(cast("int", item_id), None) if item_id else None
if target is None:
return
with contextlib.suppress(TelegramAPIError):
await self.bot.set_message_reaction(target[0], target[1], reaction=[])
async def _save_attachment(
self, message: Message, thread_id: int | None
) -> str | None:
file_id: str | None = None
name: str | None = None
kind = ""
size: int | None = None
if message.photo:
photo = message.photo[-1]
file_id, kind, size = photo.file_id, "фото", photo.file_size
name = f"{photo.file_unique_id}.jpg"
elif message.document:
doc = message.document
file_id, kind, size = doc.file_id, "файл", 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,
"голосовое",
message.voice.file_size,
)
name = f"{message.voice.file_unique_id}.ogg"
elif message.audio:
file_id, kind, size = (
message.audio.file_id,
"аудио",
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,
"видео",
message.video.file_size,
)
name = message.video.file_name or f"{message.video.file_unique_id}.mp4"
elif message.video_note:
file_id, kind, size = (
message.video_note.file_id,
"кружок",
message.video_note.file_size,
)
name = f"{message.video_note.file_unique_id}.mp4"
if file_id is None or name is None:
return None
if size and size > 20 * 1024 * 1024:
return (
f"[вложение: {kind} {name}, {size // 1024 // 1024} МБ - "
"больше 20 МБ, Telegram не отдаёт ботам]"
)
folder = self.attachments.root / (self._ext(thread_id).replace("/", "_"))
folder.mkdir(parents=True, exist_ok=True)
with contextlib.suppress(OSError):
self.attachments.root.chmod(0o755)
folder.chmod(0o755)
path = folder / f"{int(time.time())}-{Path(name).name}"
try:
await self.bot.download(file_id, destination=path)
path.chmod(0o644)
except (TelegramAPIError, OSError) as exc:
_log.warning("attachment download failed: %s", exc)
return f"[вложение: {kind} {name} - не скачалось: {exc}]"
shown = f", {size // 1024} КБ" if size else ""
return f"[вложение: {kind} {path}{shown}]"
def _sweep_attachments(self) -> None:
if self.attachments.mode != "ephemeral":
return
root = self.attachments.root
if not root.exists():
return
cutoff = time.time() - self.attachments.keep_days * 86400
for path in root.rglob("*"):
with contextlib.suppress(OSError):
if path.is_file() and path.stat().st_mtime < cutoff:
path.unlink()
# ---- commands --------------------------------------------------------
async def _command(
self, command: str, args: str, message: Message, thread_id: int | None
) -> None:
reply = await self._run_command(command, args, thread_id)
if reply:
await self.outbox.enqueue(
chat_id=message.chat.id, thread_id=thread_id, text=reply
)
async def _run_command(self, command: str, args: str, thread_id: int | None) -> str:
if command in ("start", "help"):
return _HELP
is_master = (
thread_id is None or self._ext(thread_id) == await self._master_ext()
)
conv = (await self._master())[0] if is_master else await self._live(thread_id)
if command == "status":
return await self._status(conv)
if command == "merge":
if conv is None or conv.kind != "branch":
return "сливать нечего: это не открытая ветка"
self._spawn_task(self._merge(conv))
return "🔀 сливаю в мастер"
if command == "new":
if is_master or thread_id is None:
child = await self.conversations.spawn(
kind="branch",
seed="morning",
parent=conv,
title=args or None,
origin=FRONTEND,
)
return f"🌿 ветка «{child.title or child.external_id}» - в новом топике"
if conv is not None:
await self.conversations.set_status(conv, "closed")
await self._branch(thread_id, title=args or None, text=None)
return "🌿 новая ветка на этом топике"
if command == "chat":
if not args:
return "/chat <тема>"
try:
deep = await self.conversations.spawn(
kind="deep", seed="clean", title=args, origin=FRONTEND
)
except (ValueError, LookupError) as exc:
return f"не вышло: {exc}"
where = next(
(
b.external_id
for b in await self.conversations.bindings(deep)
if b.visible
),
deep.external_id,
)
return f"💬 глубокий чат: {where}"
return _HELP
async def _status(self, conv: Conversation | None) -> str:
if conv is None:
return "этот топик ни к чему не привязан - напиши, и откроется ветка"
info = await self.conversations.describe(conv)
queued = sum(
1
for i in await self.conversations.queue.recent(
cast("int", conv.id), limit=20
)
if i.status == "queued"
)
lines = [
f"{conv.kind} · {conv.status} · {conv.agent_name}",
f"сессия: {'живая' if info['live'] else 'нет'}"
f" · тёрн: {'идёт' if conv.running_turn else 'нет'}"
f" · в очереди: {queued}",
]
if conv.pending_question:
lines.append("❓ ждёт ответа на вопрос")
if conv.kind == "master":
pool = self.conversations.pool
lines.append(f"пул: {len(pool)} сессий, rss {pool.rss() // (1 << 20)} МБ")
lines.append(f"outbox: {await self.outbox.pending()} в очереди")
lines.append(f"id: {conv.external_id}")
return "\n".join(lines)
async def _merge(self, conv: Conversation) -> None:
try:
await self.conversations.merge(conv)
except Exception as exc: # noqa: BLE001
_log.exception("merge of %s failed", conv.external_id)
await self._deliver(conv, f"⚠️ слив не удался: {exc}")
def _spawn_task(self, coro: Any) -> None:
task = asyncio.create_task(coro)
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
# ---- bus -------------------------------------------------------------
async def _events(self) -> None:
async for event in self.bus.stream():
try:
await self._on_event(event)
except Exception: # noqa: BLE001
_log.exception("event %s failed", event.get("type"))
async def _on_event(self, event: Event) -> None:
kind = event["type"]
if kind == "conversation.bound":
self._targets.pop(str(event.get("conversation_id")), None)
return
if kind in ("delivery.sent", "delivery.failed", "conversation.created"):
return
key = event.get("conversation_id")
if not isinstance(key, str):
return
conv = await self.conversations.get(key)
if conv is None:
return
target = await self._target_of(conv)
if target is None:
return
match kind:
case "turn.start":
if event.get("origin") == "user":
await self._open_draft(key, event, target)
case "stream":
self._feed_draft(key, event)
case "tool":
draft = self._drafts.get(key)
if draft is not None and event.get("parent_tool_use_id") is None:
draft.set_status(
f"⏳ {status_label(str(event['name']), event.get('input'))}"
)
case "turn.end":
draft = self._drafts.get(key)
if draft is not None:
await draft.stop()
if event.get("stop") == "error" and event.get("origin") == "user":
await self._deliver(
conv,
"⚠️ тёрн упал, смотри логи gateway",
turn_id=event.get("turn_id"),
key=f"{event.get('turn_id')}:error",
)
case "reply":
await self._on_reply(conv, event)
case "say":
await self._deliver(
conv, str(event.get("text") or ""), turn_id=event.get("turn_id")
)
case "question":
await self._ask(conv, event, target)
case "question.answered":
await self._close_ask(
str(event["question_id"]), f"✅ {event.get('answer') or ''}"
)
case "question.timeout":
await self._close_ask(
str(event["question_id"]), "⌛ время вышло - ответь текстом"
)
case "conversation.merged":
await self._deliver(conv, "✅ слито в мастер", key=f"{key}:merged")
async def _on_reply(self, conv: Conversation, event: Event) -> None:
turn_id = str(event.get("turn_id") or "")
origin = str(event.get("item_origin") or "")
text = str(event.get("text") or "")
await self._unreact(event.get("item"))
draft = self._drafts.pop(conv.external_id, None)
if draft is not None:
await draft.finish(chunks(text)[-1] if text.strip() else "")
if origin != FRONTEND and not origin.startswith("сид"):
user_text = str(event.get("user_text") or "")
if user_text:
await self._deliver(
conv,
f"📝 из панели:\n{user_text}",
turn_id=turn_id,
key=f"{turn_id}:mirror",
)
await self._deliver(conv, text, turn_id=turn_id, key=f"{turn_id}:reply")
# ---- drafts ------------------------------------------------------------
async def _open_draft(
self, key: str, event: Event, target: tuple[int, int | None]
) -> None:
if target[0] < 0:
return
await self._close_draft(key)
draft = Draft(
self.bot,
chat_id=target[0],
thread_id=target[1],
turn_id=str(event.get("turn_id") or key),
interval=self.draft_interval,
)
self._drafts[key] = draft
draft.start()
def _feed_draft(self, key: str, event: Event) -> None:
draft = self._drafts.get(key)
if draft is None or event.get("parent_tool_use_id") is not None:
return
raw = event.get("event") or {}
kind = raw.get("type")
if kind == "content_block_delta":
delta = raw.get("delta") or {}
if delta.get("type") == "text_delta":
draft.set_status("✍️ пишу")
draft.append(str(delta.get("text") or ""))
elif delta.get("type") == "thinking_delta":
draft.set_status("🤔 думаю")
elif kind == "content_block_start":
block = raw.get("content_block") or {}
if block.get("type") == "tool_use":
draft.set_status(
f"⏳ {status_label(str(block.get('name') or ''), None)}"
)
async def _close_draft(self, key: str) -> None:
draft = self._drafts.pop(key, None)
if draft is not None:
await draft.stop()
# ---- questions (§3.7) ---------------------------------------------------
async def _ask(
self, conv: Conversation, event: Event, target: tuple[int, int | None]
) -> None:
question_id = str(event["question_id"])
questions = [q for q in event.get("questions") or [] if isinstance(q, dict)]
if not questions:
return
ask = _Ask(
conversation_id=conv.external_id,
chat_id=target[0],
thread_id=target[1],
questions=questions,
)
self._asks[question_id] = ask
for qi, question in enumerate(questions):
try:
sent = await self.bot.send_message(
target[0],
_question_html(question),
message_thread_id=target[1],
parse_mode="HTML",
reply_markup=_keyboard(question_id, qi, question, []),
)
except TelegramAPIError:
_log.exception("question %s could not be sent", question_id)
continue
ask.messages.append(sent.message_id)
async def _on_callback(self, query: CallbackQuery) -> None:
if query.from_user.id != self.user_id or not query.data:
return
parts = query.data.split(":")
if len(parts) != 4 or parts[0] != "q":
return
_, question_id, qi_raw, choice = parts
ask = self._asks.get(question_id)
if ask is None or not qi_raw.isdigit():
await self._callback_reply(query, "вопрос уже закрыт")
return
qi = int(qi_raw)
question = ask.questions[qi]
options = [str(o.get("label", "")) for o in question.get("options") or []]
picked = ask.picked.setdefault(qi, [])
multi = bool(question.get("multiSelect"))
if choice == _DONE:
ask.done.add(qi)
elif choice.isdigit() and int(choice) < len(options):
label = options[int(choice)]
if multi:
if label in picked:
picked.remove(label)
else:
picked.append(label)
else:
picked[:] = [label]
ask.done.add(qi)
message = query.message if isinstance(query.message, Message) else None
if message is not None:
with contextlib.suppress(TelegramAPIError):
if qi in ask.done:
await self.bot.edit_message_text(
f"{_question_html(question)}\n\n"
f"✅ {html.escape(', '.join(picked) or '-')}",
chat_id=message.chat.id,
message_id=message.message_id,
parse_mode="HTML",
)
else:
await self.bot.edit_message_reply_markup(
chat_id=message.chat.id,
message_id=message.message_id,
reply_markup=_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, "⌛ время вышло - ответь текстом")
async def _callback_reply(self, query: CallbackQuery, text: str | None) -> None:
with contextlib.suppress(TelegramAPIError):
await self.bot.answer_callback_query(query.id, text=text)
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._edit_asks(ask, note)
async def _edit_asks(self, ask: _Ask, note: str) -> None:
for qi, message_id in enumerate(ask.messages):
if qi in ask.done and not note.startswith("⌛"):
continue
with contextlib.suppress(TelegramAPIError):
await self.bot.edit_message_text(
f"{_question_html(ask.questions[qi])}\n\n{html.escape(note)}",
chat_id=ask.chat_id,
message_id=message_id,
parse_mode="HTML",
)
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())
lines = [f"❓ <b>{header}</b>" if header else "❓", body]
for option in question.get("options") or []:
label = html.escape(str(option.get("label", "")))
description = html.escape(str(option.get("description") or "").strip())
lines.append(f"• <b>{label}</b>" + (f" - {description}" if description else ""))
return "\n".join(line for line in lines if line)
def _keyboard(
question_id: str, qi: int, question: dict[str, Any], picked: list[str]
) -> InlineKeyboardMarkup:
rows = [
[
InlineKeyboardButton(
text=("☑ " if str(o.get("label", "")) in picked else "")
+ str(o.get("label", ""))[:60],
callback_data=f"q:{question_id}:{qi}:{oi}",
)
]
for oi, o in enumerate(question.get("options") or [])
]
if question.get("multiSelect"):
rows.append(
[
InlineKeyboardButton(
text="✅ готово", callback_data=f"q:{question_id}:{qi}:{_DONE}"
)
]
)
return InlineKeyboardMarkup(inline_keyboard=rows)
def _answer_text(ask: _Ask) -> str:
if len(ask.questions) == 1:
return ", ".join(ask.picked.get(0, [])) or "-"
return "; ".join(
f"{q.get('header') or q.get('question')}: "
f"{', '.join(ask.picked.get(i, [])) or '-'}"
for i, q in enumerate(ask.questions)
)
@@ -0,0 +1,138 @@
"""Long-polling inbox (§3.8).
Every update lands in ``telegram_updates`` before the offset moves past it;
a worker handles rows from the table, oldest first, and finishes whatever a
previous process left unprocessed at startup.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from aiogram.exceptions import TelegramConflictError, TelegramNetworkError
from aiogram.types import Update
from sqlalchemy import func
from sqlalchemy.exc import IntegrityError
from sqlmodel import col, select
from beaver_gateway.storage.models import TelegramUpdate
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from aiogram import Bot
from beaver_gateway.storage.db import Database
__all__ = ["Inbox"]
_log = logging.getLogger("beaver_gateway.frontends.telegram.inbox")
ALLOWED_UPDATES = ("message", "callback_query")
class Inbox:
def __init__(
self,
db: Database,
bot: Bot,
*,
handler: Callable[[Update], Awaitable[None]],
poll_timeout: int = 30,
) -> None:
self._db = db
self._bot = bot
self._handler = handler
self._poll_timeout = poll_timeout
self._wake = asyncio.Event()
async def run(self) -> None:
async with asyncio.TaskGroup() as tg:
tg.create_task(self._poll())
tg.create_task(self._work())
async def _poll(self) -> None:
offset = await self._next_offset()
backoff = 1.0
while True:
try:
updates = await self._bot.get_updates(
offset=offset,
timeout=self._poll_timeout,
allowed_updates=list(ALLOWED_UPDATES),
request_timeout=self._poll_timeout + 10,
)
except TelegramConflictError:
_log.error("another poller holds this bot token; retrying in 10s")
await asyncio.sleep(10)
continue
except (TimeoutError, TelegramNetworkError, OSError) as exc:
_log.warning("getUpdates failed (%s); retrying in %.0fs", exc, backoff)
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 60.0)
continue
backoff = 1.0
for update in updates:
await self._store(update)
offset = update.update_id + 1
if updates:
self._wake.set()
async def _store(self, update: Update) -> None:
row = TelegramUpdate(
update_id=update.update_id,
payload=update.model_dump(mode="json", by_alias=True, exclude_none=True),
)
async with self._db.session() as session:
session.add(row)
try:
await session.commit()
except IntegrityError:
await session.rollback()
async def _next_offset(self) -> int | None:
async with self._db.session() as session:
latest = (
await session.exec(select(func.max(col(TelegramUpdate.update_id))))
).one()
return int(latest) + 1 if latest is not None else None
async def _work(self) -> None:
while True:
rows = await self._pending()
if not rows:
self._wake.clear()
with contextlib.suppress(TimeoutError):
await asyncio.wait_for(self._wake.wait(), timeout=5.0)
continue
for row in rows:
await self._handle(row)
async def _pending(self, limit: int = 50) -> list[TelegramUpdate]:
async with self._db.session() as session:
result = await session.exec(
select(TelegramUpdate)
.where(col(TelegramUpdate.processed_at).is_(None))
.order_by(col(TelegramUpdate.update_id))
.limit(limit)
)
return list(result.all())
async def _handle(self, row: TelegramUpdate) -> None:
error: str | None = None
try:
await self._handler(Update.model_validate(row.payload))
except Exception as exc: # noqa: BLE001
error = f"{type(exc).__name__}: {exc}"[:500]
_log.exception("update %s failed", row.update_id)
async with self._db.session() as session:
stored = await session.get(TelegramUpdate, row.update_id)
if stored is not None:
stored.processed_at = datetime.now(UTC)
stored.error = error
session.add(stored)
await session.commit()
@@ -0,0 +1,235 @@
"""Outbox (§3.8): a reply is a ``deliveries`` row first, a message second.
Rows are sent oldest first, retried with backoff on network errors and
flood limits, resent as plain text when Telegram rejects our HTML, and
given up only when Telegram says the window is gone.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING
from aiogram.exceptions import (
TelegramBadRequest,
TelegramForbiddenError,
TelegramNetworkError,
TelegramNotFound,
TelegramRetryAfter,
TelegramServerError,
)
from aiogram.types import LinkPreviewOptions
from sqlalchemy import func
from sqlalchemy.exc import IntegrityError
from sqlmodel import col, select
from beaver_gateway.frontends.telegram.render import chunks, to_html
from beaver_gateway.storage.models import Delivery
if TYPE_CHECKING:
from aiogram import Bot
from beaver_gateway.core.bus import EventBus
from beaver_gateway.storage.db import Database
__all__ = ["Outbox"]
_log = logging.getLogger("beaver_gateway.frontends.telegram.outbox")
_MAX_BACKOFF = 300.0
_GONE = ("thread not found", "chat not found", "topic_deleted", "topic_closed")
_NO_PREVIEW = LinkPreviewOptions(is_disabled=True)
class Outbox:
def __init__(
self, db: Database, bot: Bot, *, bus: EventBus, backoff: float = 2.0
) -> None:
self._db = db
self._bot = bot
self._bus = bus
self._backoff = backoff
self._wake = asyncio.Event()
async def enqueue(
self,
*,
chat_id: int,
thread_id: int | None,
text: str,
conversation_id: int | None = None,
turn_id: str | None = None,
dedupe_key: str | None = None,
) -> list[Delivery]:
rows: list[Delivery] = []
for n, part in enumerate(chunks(text)):
row = Delivery(
conversation_id=conversation_id,
chat_id=chat_id,
thread_id=thread_id,
text=part,
turn_id=turn_id,
dedupe_key=f"{dedupe_key}:{n}" if dedupe_key else None,
)
async with self._db.session() as session:
session.add(row)
try:
await session.commit()
except IntegrityError:
await session.rollback()
continue
await session.refresh(row)
rows.append(row)
if rows:
self._wake.set()
return rows
async def run(self) -> None:
while True:
rows = await self._due()
if not rows:
self._wake.clear()
with contextlib.suppress(TimeoutError):
await asyncio.wait_for(
self._wake.wait(), timeout=await self._wait_for_next()
)
continue
for row in rows:
await self._send(row)
async def _wait_for_next(self, cap: float = 5.0) -> float:
async with self._db.session() as session:
earliest = (
await session.exec(
select(func.min(col(Delivery.next_attempt_at))).where(
Delivery.status == "queued"
)
)
).one()
if earliest is None:
return cap
now = datetime.now(UTC).replace(tzinfo=None)
return max(0.05, min(cap, (earliest - now).total_seconds()))
async def _due(self, limit: int = 50) -> list[Delivery]:
now = datetime.now(UTC).replace(tzinfo=None)
async with self._db.session() as session:
result = await session.exec(
select(Delivery)
.where(
Delivery.status == "queued", col(Delivery.next_attempt_at) <= now
)
.order_by(col(Delivery.id))
.limit(limit)
)
return list(result.all())
async def _send(self, row: Delivery) -> None:
try:
message = await self._bot.send_message(
row.chat_id,
row.text if row.plain else to_html(row.text),
message_thread_id=row.thread_id,
parse_mode=None if row.plain else "HTML",
link_preview_options=_NO_PREVIEW,
)
except TelegramRetryAfter as exc:
await self._retry(row, str(exc), delay=float(exc.retry_after))
except TelegramBadRequest as exc:
text = str(exc).lower()
if "parse" in text and not row.plain:
await self._retry(row, str(exc), delay=0.0, plain=True)
elif any(marker in text for marker in _GONE):
await self._fail(row, str(exc))
else:
await self._fail(row, str(exc))
except (TelegramNotFound, TelegramForbiddenError) as exc:
await self._fail(row, str(exc))
except (TelegramNetworkError, TelegramServerError, OSError) as exc:
await self._retry(
row,
str(exc),
delay=min(self._backoff ** (row.attempts + 1), _MAX_BACKOFF),
)
else:
await self._mark(row, status="sent", message_id=message.message_id)
self._bus.publish(
"delivery.sent",
delivery=row.id,
conversation_row=row.conversation_id,
chat_id=row.chat_id,
thread_id=row.thread_id,
message_id=message.message_id,
turn_id=row.turn_id,
)
async def _retry(
self, row: Delivery, error: str, *, delay: float, plain: bool = False
) -> None:
_log.warning(
"delivery #%s attempt %d failed: %s (retry in %.0fs)",
row.id,
row.attempts + 1,
error,
delay,
)
await self._mark(row, status="queued", error=error, delay=delay, plain=plain)
if delay < 5.0:
self._wake.set()
async def _fail(self, row: Delivery, error: str) -> None:
_log.error(
"delivery #%s to %s/%s given up: %s",
row.id,
row.chat_id,
row.thread_id,
error,
)
await self._mark(row, status="failed", error=error)
self._bus.publish(
"delivery.failed",
delivery=row.id,
conversation_row=row.conversation_id,
chat_id=row.chat_id,
thread_id=row.thread_id,
error=error,
)
async def _mark(
self,
row: Delivery,
*,
status: str,
error: str | None = None,
delay: float = 0.0,
plain: bool = False,
message_id: int | None = None,
) -> None:
async with self._db.session() as session:
stored = await session.get(Delivery, row.id)
if stored is None:
return
stored.status = status
stored.attempts += 1
stored.last_error = error[:500] if error else None
stored.next_attempt_at = (
datetime.now(UTC) + timedelta(seconds=delay)
).replace(tzinfo=None)
if plain:
stored.plain = True
if message_id is not None:
stored.message_id = message_id
if status == "sent":
stored.sent_at = datetime.now(UTC)
session.add(stored)
await session.commit()
async def pending(self) -> int:
async with self._db.session() as session:
result = await session.exec(
select(Delivery).where(Delivery.status == "queued")
)
return len(list(result.all()))
@@ -0,0 +1,111 @@
"""Model markdown → Telegram HTML, chunking, and the status line for drafts."""
from __future__ import annotations
import html
import re
from typing import Any
__all__ = ["LIMIT", "chunks", "status_label", "to_html"]
LIMIT = 4000
_FENCE = re.compile(r"```[^\n]*\n(.*?)(?:```|$)", re.DOTALL)
_INLINE_CODE = re.compile(r"(`[^`\n]+`)")
_HEADING = re.compile(r"^#{1,6}\s+(.+)$", re.MULTILINE)
_BOLD = re.compile(r"\*\*(.+?)\*\*|__(.+?)__", re.DOTALL)
_ITALIC = re.compile(r"(?<![\w*])\*(?!\s)(.+?)(?<!\s)\*(?![\w*])")
_ITALIC_U = re.compile(r"(?<![\w_])_(?!\s)(.+?)(?<!\s)_(?![\w_])")
_LINK = re.compile(r"\[([^\]]+)\]\((https?://[^)\s]+)\)")
_BULLET = re.compile(r"^(\s*)[-*]\s+", re.MULTILINE)
_STRIKE = re.compile(r"~~(.+?)~~")
_LABELS: dict[str, str] = {
"Read": "читаю vault",
"Glob": "ищу файлы",
"Grep": "ищу в vault",
"Edit": "правлю файл",
"Write": "пишу файл",
"MultiEdit": "правлю файлы",
"Bash": "выполняю команду",
"WebSearch": "ищу в сети",
"WebFetch": "читаю страницу",
"Task": "запустил сабагента",
"Agent": "запустил сабагента",
"AskUserQuestion": "спрашиваю",
"TodoWrite": "планирую",
"Skill": "открываю скилл",
"mcp__gateway__spawn": "открываю разговор",
"mcp__gateway__read_conversation": "читаю разговор",
"mcp__gateway__say": "говорю",
"mcp__gateway__schedule": "ставлю напоминание",
"mcp__gateway__inject": "передаю в другой разговор",
}
def to_html(text: str) -> str:
out: list[str] = []
pos = 0
for match in _FENCE.finditer(text):
out.append(_inline(text[pos : match.start()]))
out.append(f"<pre>{html.escape(match.group(1).rstrip())}</pre>")
pos = match.end()
out.append(_inline(text[pos:]))
return "".join(out).strip()
def _inline(text: str) -> str:
parts = _INLINE_CODE.split(text)
for i, part in enumerate(parts):
if i % 2:
parts[i] = f"<code>{html.escape(part[1:-1])}</code>"
continue
s = html.escape(part, quote=False)
s = _HEADING.sub(r"<b>\1</b>", s)
s = _BOLD.sub(lambda m: f"<b>{m.group(1) or m.group(2)}</b>", s)
s = _ITALIC.sub(r"<i>\1</i>", s)
s = _ITALIC_U.sub(r"<i>\1</i>", s)
s = _STRIKE.sub(r"<s>\1</s>", s)
s = _LINK.sub(r'<a href="\2">\1</a>', s)
parts[i] = _BULLET.sub(r"\1• ", s)
return "".join(parts)
def chunks(text: str, limit: int = LIMIT) -> list[str]:
text = text.strip()
if len(text) <= limit:
return [text] if text else []
out: list[str] = []
while len(text) > limit:
cut = _cut_point(text, limit)
out.append(text[:cut].rstrip())
text = text[cut:].lstrip()
if text:
out.append(text)
return out
def _cut_point(text: str, limit: int) -> int:
for sep in ("\n\n", "\n", ". ", " "):
cut = text.rfind(sep, limit // 2, limit)
if cut > 0:
return cut + len(sep)
return limit
def status_label(name: str, tool_input: dict[str, Any] | None = None) -> str:
label = _LABELS.get(name)
if label is not None:
return label
if name.startswith("mcp__"):
parts = name.split("__", 2)
server = parts[1]
tool = parts[2] if len(parts) == 3 else ""
return f"{server}: {tool}" if tool else server
hint = ""
if tool_input:
for key in ("description", "command", "file_path", "pattern", "query"):
value = tool_input.get(key)
if isinstance(value, str) and value.strip():
hint = value.strip().splitlines()[0][:60]
break
return f"{name} {hint}" if hint else name
+1 -1
View File
@@ -17,7 +17,7 @@ heavy setups (PRD §6 cites the ~95%→~71% tool-selection drop on flat
namespaces) but real and reachable.
The aggregator returns both the app and a ``{name: url}`` map; Phase
2.2's ``ClaudeCodeBackendAdapter`` plugs the map directly into
``ClaudeSdkBackend`` plugs the map directly into
``BackendOptions.mcp_servers``. ``/mcp/all/`` is NOT included in that
map — claude-code-agents always get per-domain framing; only the
external MCP frontend (Phase 3.1) reverse-proxies the flat endpoint.
+13 -24
View File
@@ -84,23 +84,19 @@ class LenientStdioTransport(ClientTransport):
self, **session_kwargs: Unpack[SessionKwargs]
) -> AsyncIterator[ClientSession]:
errlog: TextIO = self.log_file if self.log_file is not None else sys.stderr
async with _lenient_stdio_client(
StdioServerParameters(
command=self.command,
args=self.args,
env=self.env,
cwd=self.cwd,
),
errlog=errlog,
) as (read_stream, write_stream), ClientSession(
read_stream, write_stream, **session_kwargs
) as session:
async with (
_lenient_stdio_client(
StdioServerParameters(
command=self.command, args=self.args, env=self.env, cwd=self.cwd
),
errlog=errlog,
) as (read_stream, write_stream),
ClientSession(read_stream, write_stream, **session_kwargs) as session,
):
yield session
def __repr__(self) -> str:
return (
f"<LenientStdioTransport(command={self.command!r}, args={self.args!r})>"
)
return f"<LenientStdioTransport(command={self.command!r}, args={self.args!r})>"
@contextlib.asynccontextmanager
@@ -133,9 +129,7 @@ async def _lenient_stdio_client( # noqa: PLR0915 — mirrors mcp.client.stdio.s
command=command,
args=server.args,
env=(
{**env_default, **server.env}
if server.env is not None
else env_default
{**env_default, **server.env} if server.env is not None else env_default
),
errlog=errlog,
cwd=server.cwd,
@@ -164,9 +158,7 @@ async def _lenient_stdio_client( # noqa: PLR0915 — mirrors mcp.client.stdio.s
if not stripped:
continue
try:
message = types.JSONRPCMessage.model_validate_json(
stripped
)
message = types.JSONRPCMessage.model_validate_json(stripped)
except Exception: # noqa: BLE001 — by design, see module doc
_log.debug(
"lenient stdio: dropped non-JSON line: %r",
@@ -194,10 +186,7 @@ async def _lenient_stdio_client( # noqa: PLR0915 — mirrors mcp.client.stdio.s
except anyio.ClosedResourceError:
await anyio.lowlevel.checkpoint()
async with (
anyio.create_task_group() as tg,
process,
):
async with anyio.create_task_group() as tg, process:
tg.start_soon(stdout_reader)
tg.start_soon(stdin_writer)
try:
+1 -3
View File
@@ -62,9 +62,7 @@ class PythonToolMcp(_BaseMcp):
tools: tuple[Callable[..., object], ...]
McpServerT = Annotated[
StdioMcp | HttpMcp | PythonToolMcp, Field(discriminator="kind")
]
McpServerT = Annotated[StdioMcp | HttpMcp | PythonToolMcp, Field(discriminator="kind")]
class McpServer:
+6
View File
@@ -27,6 +27,12 @@ class Settings(BaseSettings):
internal_mcp_port: int = 8765
config_path: Path = Path("/config/config.py")
claude_runner_user: str | None = None
"""Unix user the claude subprocess runs as. Needs the gateway to be root."""
claude_home: Path | None = None
"""``HOME`` for the claude subprocess (its ``~/.claude`` lives there)."""
raycast_bearer: str | None = None
raycast_config_path: Path = Path("/config/raycast.json")
raycast_device_id: str | None = None
+29 -5
View File
@@ -1,14 +1,14 @@
"""SQLModel-backed persistence.
Two tables — :class:`Token`, :class:`AuditLog` — plus a thin
:class:`Database` wrapper around an async SQLAlchemy engine. The
``GatewayRuntime`` carries the handle so auth (token verify / touch)
and admin (token CRUD + audit listing) can reach it.
Tokens, audit, conversations, Agent SDK transcript entries and per-turn
usage, plus a thin :class:`Database` wrapper around an async SQLAlchemy
engine. ``GatewayRuntime`` carries the handle.
"""
from beaver_gateway.storage.db import (
Database,
append_audit,
append_usage,
create_token,
list_active_tokens,
list_audit_records,
@@ -16,13 +16,37 @@ from beaver_gateway.storage.db import (
revoke_token,
touch_token,
)
from beaver_gateway.storage.models import AuditLog, Token
from beaver_gateway.storage.models import (
AuditLog,
Conversation,
ConversationBinding,
Delivery,
InjectQueueItem,
RateLimit,
Schedule,
TelegramUpdate,
Token,
TranscriptEntry,
Usage,
)
from beaver_gateway.storage.session_store import PostgresSessionStore
__all__ = [
"AuditLog",
"Conversation",
"ConversationBinding",
"Database",
"Delivery",
"InjectQueueItem",
"PostgresSessionStore",
"RateLimit",
"Schedule",
"TelegramUpdate",
"Token",
"TranscriptEntry",
"Usage",
"append_audit",
"append_usage",
"create_token",
"list_active_tokens",
"list_audit_records",
+51 -2
View File
@@ -18,15 +18,17 @@ import json
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
from sqlalchemy import inspect, text
from sqlalchemy.ext.asyncio import create_async_engine
from sqlmodel import SQLModel, select
from sqlmodel.ext.asyncio.session import AsyncSession
from beaver_gateway.storage.models import AuditLog, Token
from beaver_gateway.storage.models import AuditLog, Token, Usage
if TYPE_CHECKING:
from collections.abc import Sequence
from sqlalchemy import Connection
from sqlalchemy.ext.asyncio import AsyncEngine
@@ -72,9 +74,14 @@ class Database:
self._engine: AsyncEngine = create_async_engine(_to_async_url(url), echo=False)
async def create_all(self) -> None:
"""Idempotent ``CREATE TABLE IF NOT EXISTS`` for every model."""
"""Create missing tables, then add columns models gained since.
Nullable columns only - that is the whole migration story until
Alembic is worth it.
"""
async with self._engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
await conn.run_sync(_add_missing_columns)
def session(self) -> AsyncSession:
"""Open a fresh :class:`AsyncSession` (use as ``async with``).
@@ -90,6 +97,39 @@ class Database:
await self._engine.dispose()
def _add_missing_columns(conn: Connection) -> None:
inspector = inspect(conn)
for table in SQLModel.metadata.sorted_tables:
existing = {c["name"] for c in inspector.get_columns(table.name)}
for column in table.columns:
if column.name in existing:
continue
kind = column.type.compile(conn.dialect)
ddl = f"ALTER TABLE {table.name} ADD COLUMN {column.name} {kind}"
default = _column_default(column)
if default is not None:
ddl += f" DEFAULT {default}"
conn.execute(text(ddl))
def _column_default(column: Any) -> str | None:
"""Literal for ``ADD COLUMN ... DEFAULT`` so old rows get the model default."""
server_default = getattr(column, "server_default", None)
if server_default is not None:
return str(server_default.arg.text)
default = getattr(column, "default", None)
if default is None or default.is_callable:
return None
value = default.arg
if isinstance(value, bool):
return "TRUE" if value else "FALSE"
if isinstance(value, int | float):
return repr(value)
if isinstance(value, str):
return "'" + value.replace("'", "''") + "'"
return None
# ---- Token CRUD ---------------------------------------------------------
@@ -183,9 +223,18 @@ async def list_audit_records(
return result.all()
# ---- Usage --------------------------------------------------------------
async def append_usage(session: AsyncSession, row: Usage) -> None:
session.add(row)
await session.commit()
__all__ = [
"Database",
"append_audit",
"append_usage",
"create_token",
"list_active_tokens",
"list_audit_records",
+253 -11
View File
@@ -1,6 +1,6 @@
"""SQLModel tables.
Four tables, all flat, no FK relationships modelled (``actor`` and
Flat tables, no FK relationships modelled (``actor`` and
``agent_name`` are stored as strings — joining audit→token by name is
fine at this volume; we'll introduce FKs when the admin UI actually
demands them).
@@ -23,8 +23,10 @@ needs an id uses ``Optional[int]`` so SQLAlchemy can autoincrement.
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import UniqueConstraint
from sqlalchemy import JSON, Column, Index, UniqueConstraint, text
from sqlalchemy.dialects.postgresql import JSONB
from sqlmodel import Field, SQLModel
@@ -72,15 +74,16 @@ class AuditLog(SQLModel, table=True):
class Conversation(SQLModel, table=True):
"""One chat thread, scoped to a frontend.
"""One conversation (§3.1): a master thread, a branch, a deep chat or a job.
``external_id`` is the identifier the frontend uses to find this
thread again on the next request — for the markdown frontend it's a
uuid we mint and persist into the file's frontmatter, for the
anthropic frontend it'd be the same metadata.conversation_id the
client passes. Unique per ``(frontend, external_id)`` because two
frontends sharing a uuid is fine; the same frontend reusing one is
a bug.
``external_id`` is the public id (uuid) every frontend, the API and the
usage table refer to; ``frontend`` names the frontend that created the
row (``markdown``, ``api``, ``mcp``, ``system``). Where a conversation
is *visible* lives in :class:`ConversationBinding`.
``running_turn`` survives a restart: a non-null value at startup means
the gateway died mid-turn and the transcript needs its open ``tool_use``
closed before the session is resumed (``core/conversations``).
"""
__tablename__ = "conversations"
@@ -92,8 +95,139 @@ class Conversation(SQLModel, table=True):
frontend: str = Field(index=True)
external_id: str = Field(index=True)
agent_name: str = Field(index=True)
session_id: str | None = Field(default=None, index=True)
kind: str = Field(default="deep", index=True)
parent_id: int | None = Field(default=None, index=True)
title: str | None = Field(default=None)
status: str = Field(default="open", index=True)
running_turn: str | None = Field(default=None)
pending_question: bool = Field(default=False)
flags: dict[str, Any] = Field(
default_factory=dict,
sa_column=Column(
JSON().with_variant(JSONB(), "postgresql"),
nullable=False,
server_default=text("'{}'"),
),
)
created_at: datetime = Field(default_factory=_utcnow)
updated_at: datetime = Field(default_factory=_utcnow)
last_user_activity_at: datetime | None = Field(default=None)
last_activity_at: datetime | None = Field(default=None)
class ConversationBinding(SQLModel, table=True):
"""Where a conversation shows up: a Telegram topic id, a vault-relative path.
Invariant (§3.1): at most one *visible* binding per frontend per
conversation - enforced by the partial unique index. The same external
id may point at several conversations over time (a renamed topic gets a
new branch), only one of them visible.
"""
__tablename__ = "conversation_bindings"
__table_args__ = (
Index(
"uq_binding_visible",
"conversation_id",
"frontend",
unique=True,
postgresql_where=text("visible"),
sqlite_where=text("visible"),
),
Index("ix_binding_lookup", "frontend", "external_id"),
)
id: int | None = Field(default=None, primary_key=True)
conversation_id: int = Field(index=True)
frontend: str
external_id: str
visible: bool = Field(default=True)
created_at: datetime = Field(default_factory=_utcnow)
class InjectQueueItem(SQLModel, table=True):
"""Persisted per-conversation queue (§3.4), priorities ``urgent > user > normal``.
``status`` walks ``queued -> running -> done``; a row still ``running``
at startup was cut by a restart and becomes ``interrupted`` - it is
never re-run, the agent gets an inject saying so instead.
"""
__tablename__ = "inject_queue"
id: int | None = Field(default=None, primary_key=True)
conversation_id: int = Field(index=True)
priority: str = Field(index=True)
origin: str = Field(default="system")
text: str
status: str = Field(default="queued", index=True)
turn_id: str | None = Field(default=None)
created_at: datetime = Field(default_factory=_utcnow)
delivered_at: datetime | None = Field(default=None)
class Schedule(SQLModel, table=True):
"""Deferred inject written by the ``schedule`` tool (§3.6).
M1b only records the promise; the executor (pgqueuer, M3) will move
these into its own job table and this one goes away.
"""
__tablename__ = "schedules"
id: int | None = Field(default=None, primary_key=True)
conversation_id: int = Field(index=True)
execute_at: datetime = Field(index=True)
text: str
created_at: datetime = Field(default_factory=_utcnow)
delivered_at: datetime | None = Field(default=None)
class TelegramUpdate(SQLModel, table=True):
"""Inbox of the Telegram frontend (§3.8).
The update is stored before the poll offset moves past it and handled
from here, so a restart neither loses nor duplicates a message.
"""
__tablename__ = "telegram_updates"
update_id: int = Field(primary_key=True, sa_column_kwargs={"autoincrement": False})
payload: dict[str, Any] = Field(
sa_column=Column(JSON().with_variant(JSONB(), "postgresql"), nullable=False)
)
received_at: datetime = Field(default_factory=_utcnow)
processed_at: datetime | None = Field(default=None, index=True)
error: str | None = Field(default=None)
class Delivery(SQLModel, table=True):
"""Outbox (§3.8): a reply is a row first and a Telegram message second.
``status`` walks ``queued -> sent`` with retries on the way, ``failed``
only when Telegram rejects the row for good (unknown thread, blocked
bot). ``dedupe_key`` keeps one row per turn chunk across restarts.
"""
__tablename__ = "deliveries"
id: int | None = Field(default=None, primary_key=True)
conversation_id: int | None = Field(default=None, index=True)
frontend: str = Field(default="telegram", index=True)
chat_id: int
thread_id: int | None = Field(default=None)
text: str
plain: bool = Field(default=False)
turn_id: str | None = Field(default=None)
dedupe_key: str | None = Field(default=None, unique=True)
status: str = Field(default="queued", index=True)
attempts: int = Field(default=0)
next_attempt_at: datetime = Field(default_factory=_utcnow, index=True)
last_error: str | None = Field(default=None)
message_id: int | None = Field(default=None)
created_at: datetime = Field(default_factory=_utcnow)
sent_at: datetime | None = Field(default=None)
class ConversationMessage(SQLModel, table=True):
@@ -125,4 +259,112 @@ class ConversationMessage(SQLModel, table=True):
created_at: datetime = Field(default_factory=_utcnow)
__all__ = ["AuditLog", "Conversation", "ConversationMessage", "Token"]
class TranscriptEntry(SQLModel, table=True):
"""One Agent SDK transcript line, mirrored from the session store protocol.
``(project_key, session_id, subpath)`` is the ``SessionKey``; ``subpath``
is ``""`` for the main transcript so the unique constraints stay simple.
``uuid`` is the SDK's idempotency key - the partial unique index rejects
a replayed batch, entries without a uuid are appended as-is.
"""
__tablename__ = "transcript_entries"
__table_args__ = (
UniqueConstraint(
"project_key", "session_id", "subpath", "seq", name="uq_transcript_seq"
),
Index(
"uq_transcript_uuid",
"project_key",
"session_id",
"subpath",
"uuid",
unique=True,
postgresql_where=text("uuid IS NOT NULL"),
sqlite_where=text("uuid IS NOT NULL"),
),
)
id: int | None = Field(default=None, primary_key=True)
project_key: str = Field(index=True)
session_id: str = Field(index=True)
subpath: str = Field(default="")
seq: int
uuid: str | None = Field(default=None)
entry: dict[str, Any] = Field(
sa_column=Column(JSON().with_variant(JSONB(), "postgresql"), nullable=False)
)
origin: str | None = Field(default=None)
source: str | None = Field(default=None)
turn_id: str | None = Field(default=None)
created_at: datetime = Field(default_factory=_utcnow)
class Usage(SQLModel, table=True):
"""Per-turn token accounting from ``ResultMessage.usage``."""
__tablename__ = "usage"
id: int | None = Field(default=None, primary_key=True)
ts: datetime = Field(default_factory=_utcnow, index=True)
agent_name: str = Field(index=True)
conversation_id: str | None = Field(default=None, index=True)
session_id: str | None = Field(default=None, index=True)
model: str
effort: str | None = Field(default=None)
input_tokens: int = 0
output_tokens: int = 0
cache_read_tokens: int = 0
cache_creation_tokens: int = 0
cost_usd: float | None = Field(default=None)
duration_ms: int | None = Field(default=None)
num_turns: int | None = Field(default=None)
model_usage: dict[str, Any] | None = Field(
default=None,
sa_column=Column(JSON().with_variant(JSONB(), "postgresql"), nullable=True),
)
class RateLimit(SQLModel, table=True):
"""One ``RateLimitEvent`` from the SDK; the newest row per ``window`` is the state.
The quota is per subscription, not per agent or conversation - the row
only remembers which session reported it.
"""
__tablename__ = "rate_limits"
id: int | None = Field(default=None, primary_key=True)
ts: datetime = Field(default_factory=_utcnow, index=True)
window: str = Field(index=True)
status: str
utilization: float | None = Field(default=None)
resets_at: datetime | None = Field(default=None)
overage_status: str | None = Field(default=None)
overage_resets_at: datetime | None = Field(default=None)
agent_name: str | None = Field(default=None)
session_id: str | None = Field(default=None)
raw: dict[str, Any] = Field(
default_factory=dict,
sa_column=Column(
JSON().with_variant(JSONB(), "postgresql"),
nullable=False,
server_default=text("'{}'"),
),
)
__all__ = [
"AuditLog",
"Conversation",
"ConversationBinding",
"ConversationMessage",
"Delivery",
"InjectQueueItem",
"RateLimit",
"Schedule",
"TelegramUpdate",
"Token",
"TranscriptEntry",
"Usage",
]
+126
View File
@@ -0,0 +1,126 @@
"""``SessionStore`` adapter for the Claude Agent SDK on top of :class:`Database`.
Entries are stored verbatim as JSON (``jsonb`` on Postgres), ordered by a
per-key ``seq``. ``append`` is idempotent on ``entry["uuid"]`` because the
SDK re-delivers a batch on retry; entries without a uuid are appended as
they come. Runs on SQLite too - the conformance suite uses that in tests.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, cast
from claude_agent_sdk import SessionStore
from sqlalchemy import delete, func
from sqlmodel import col, select
from beaver_gateway.storage.models import TranscriptEntry
if TYPE_CHECKING:
from claude_agent_sdk.types import (
SessionKey,
SessionListSubkeysKey,
SessionStoreEntry,
)
from beaver_gateway.storage.db import Database
__all__ = ["PostgresSessionStore"]
class PostgresSessionStore(SessionStore):
def __init__(self, db: Database) -> None:
self._db = db
async def append(self, key: SessionKey, entries: list[SessionStoreEntry]) -> None:
if not entries:
return
project_key, session_id, subpath = _parts(key)
wanted = [
u for u in (e.get("uuid") for e in entries) if isinstance(u, str) and u
]
async with self._db.session() as session:
known: set[str] = set()
if wanted:
result = await session.exec(
select(TranscriptEntry.uuid).where(
TranscriptEntry.project_key == project_key,
TranscriptEntry.session_id == session_id,
TranscriptEntry.subpath == subpath,
col(TranscriptEntry.uuid).in_(wanted),
)
)
known = {u for u in result.all() if u is not None}
seq_result = await session.exec(
select(func.max(TranscriptEntry.seq)).where(
TranscriptEntry.project_key == project_key,
TranscriptEntry.session_id == session_id,
TranscriptEntry.subpath == subpath,
)
)
seq = seq_result.one() or 0
for entry in entries:
uuid = entry.get("uuid")
uuid_str = uuid if isinstance(uuid, str) and uuid else None
if uuid_str is not None:
if uuid_str in known:
continue
known.add(uuid_str)
seq += 1
session.add(
TranscriptEntry(
project_key=project_key,
session_id=session_id,
subpath=subpath,
seq=seq,
uuid=uuid_str,
entry=cast("dict[str, Any]", dict(entry)),
)
)
await session.commit()
async def load(self, key: SessionKey) -> list[SessionStoreEntry] | None:
project_key, session_id, subpath = _parts(key)
async with self._db.session() as session:
result = await session.exec(
select(TranscriptEntry.entry)
.where(
TranscriptEntry.project_key == project_key,
TranscriptEntry.session_id == session_id,
TranscriptEntry.subpath == subpath,
)
.order_by(col(TranscriptEntry.seq))
)
rows = result.all()
if not rows:
return None
return [cast("SessionStoreEntry", row) for row in rows]
async def list_subkeys(self, key: SessionListSubkeysKey) -> list[str]:
async with self._db.session() as session:
result = await session.exec(
select(TranscriptEntry.subpath)
.where(
TranscriptEntry.project_key == key["project_key"],
TranscriptEntry.session_id == key["session_id"],
TranscriptEntry.subpath != "",
)
.distinct()
)
return sorted(result.all())
async def delete(self, key: SessionKey) -> None:
project_key, session_id, subpath = _parts(key)
stmt = delete(TranscriptEntry).where(
col(TranscriptEntry.project_key) == project_key,
col(TranscriptEntry.session_id) == session_id,
)
if key.get("subpath"):
stmt = stmt.where(col(TranscriptEntry.subpath) == subpath)
async with self._db.session() as session:
await session.execute(stmt) # ty: ignore[deprecated]
await session.commit()
def _parts(key: SessionKey) -> tuple[str, str, str]:
return key["project_key"], key["session_id"], key.get("subpath") or ""
+387
View File
@@ -0,0 +1,387 @@
import asyncio
import tempfile
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any, cast
import pytest
from claude_agent_sdk import (
AssistantMessage,
RateLimitEvent,
RateLimitInfo,
ResultMessage,
TextBlock,
ToolResultBlock,
ToolUseBlock,
UserMessage,
)
from httpx import ASGITransport, AsyncClient
from test_conversations import ScriptedClient, World
from beaver_gateway.core.auth import TokenStore
from beaver_gateway.core.registry import McpRegistry
from beaver_gateway.core.transcript import build_entries
from beaver_gateway.frontends.admin.frontend import build_app as build_admin
from beaver_gateway.frontends.api.frontend import build_app as build_api
from beaver_gateway.frontends.base import GatewayRuntime
from beaver_gateway.storage.models import RateLimit, Usage
TOKEN = "tok"
HEADERS = {"Authorization": f"Bearer {TOKEN}"}
class ToolClient(ScriptedClient):
async def receive_response(self):
yield AssistantMessage(
content=[ToolUseBlock(id="tu_1", name="Bash", input={"command": "ls"})],
model="m",
)
yield AssistantMessage(
content=[ToolUseBlock(id="tu_2", name="Read", input={"file_path": "x"})],
model="m",
parent_tool_use_id="tu_1",
)
yield RateLimitEvent(
rate_limit_info=RateLimitInfo(
status="allowed_warning",
resets_at=int(datetime.now(UTC).timestamp()) + 3600,
rate_limit_type="five_hour",
utilization=0.8,
raw={"status": "allowed_warning"},
),
uuid="r",
session_id=self.session_id,
)
hold = ScriptedClient.hold
if hold is not None:
await hold.wait()
yield UserMessage(content=[ToolResultBlock(tool_use_id="tu_1", content="done")])
yield AssistantMessage(content=[TextBlock(text="ok")], model="m")
yield ResultMessage(
subtype="success",
duration_ms=1,
duration_api_ms=1,
is_error=False,
num_turns=1,
session_id=self.session_id,
stop_reason="end_turn",
total_cost_usd=0.5,
usage={"input_tokens": 10, "output_tokens": 5},
model_usage=cast(
"Any",
{
"claude-opus-5": {
"inputTokens": 8,
"outputTokens": 4,
"cacheReadInputTokens": 0,
"cacheCreationInputTokens": 0,
"webSearchRequests": 1,
"costUSD": 0.4,
},
"claude-haiku-4-5": {
"inputTokens": 2,
"outputTokens": 1,
"cacheReadInputTokens": 0,
"cacheCreationInputTokens": 0,
"webSearchRequests": 0,
"costUSD": 0.1,
},
},
),
)
class Api:
def __init__(self, world: World, memory_root: Path | None = None) -> None:
self.world = world
self.store = TokenStore(world.db, bootstrap={"t": TOKEN})
self.runtime = GatewayRuntime(
agents=world.conversations._agents, # noqa: SLF001
mcps=McpRegistry([]),
backends={"a": world.backend, "d": world.deep_backend},
token_store=self.store,
db=world.db,
admin_user="admin",
admin_pass="secret",
session_secret="s" * 32,
frontends=(world.api, world.markdown),
conversations=world.conversations,
bus=world.bus,
pool=world.pool,
)
self.app = build_api(self.runtime, memory_root=memory_root)
self.http = AsyncClient(
transport=ASGITransport(app=self.app), base_url="http://api"
)
async def get(self, path: str, params: dict[str, Any] | None = None) -> Any:
res = await self.http.get(path, params=params, headers=HEADERS)
assert res.status_code == 200, res.text
return res.json()
@pytest.fixture
async def world() -> World:
root = Path(tempfile.mkdtemp(prefix="beaver-api-"))
w = await World(root).setup()
yield w
await w.conversations.stop()
await w.pool.close_all()
await w.db.dispose()
async def seed_usage(world: World, rows: list[dict[str, Any]]) -> None:
async with world.db.session() as session:
for row in rows:
session.add(Usage(**row))
await session.commit()
async def test_usage_groups_by_agent_day_and_model(world: World) -> None:
api = Api(world)
now = datetime.now(UTC).replace(tzinfo=None)
await seed_usage(
world,
[
{
"ts": now - timedelta(hours=1),
"agent_name": "a",
"conversation_id": "c1",
"model": "claude-opus-5",
"input_tokens": 10,
"output_tokens": 20,
"cache_read_tokens": 100,
"cache_creation_tokens": 5,
"cost_usd": 0.5,
"model_usage": {
"claude-opus-5": {
"inputTokens": 10,
"outputTokens": 20,
"cacheReadInputTokens": 100,
"cacheCreationInputTokens": 5,
"webSearchRequests": 2,
"costUSD": 0.5,
}
},
},
{
"ts": now - timedelta(days=2),
"agent_name": "d",
"conversation_id": "c2",
"model": "claude-sonnet-5",
"input_tokens": 1,
"output_tokens": 2,
"cost_usd": 0.1,
},
],
)
by_agent = await api.get("/api/usage", {"group_by": "agent"})
assert [r["agent"] for r in by_agent["rows"]] == ["a"]
assert by_agent["total"] == {
"turns": 1,
"input": 10,
"output": 20,
"cache_read": 100,
"cache_creation": 5,
"cost_usd": 0.5,
"web_searches": 2,
}
since = (datetime.now(UTC) - timedelta(days=3)).isoformat()
by_day = await api.get("/api/usage", {"group_by": "day", "since": since})
assert len(by_day["rows"]) == 2
assert by_day["rows"][0]["day"] < by_day["rows"][1]["day"]
by_model = await api.get("/api/usage", {"group_by": "model", "since": since})
models = {r["model"]: r for r in by_model["rows"]}
assert models["claude-opus-5"]["web_searches"] == 2
assert models["claude-sonnet-5"]["output"] == 2
by_conv = await api.get("/api/usage", {"group_by": "conversation", "since": since})
assert {r["conversation"] for r in by_conv["rows"]} == {"c1", "c2"}
bad = await api.http.get("/api/usage", params={"group_by": "x"}, headers=HEADERS)
assert bad.status_code == 400
async def test_limits_report_latest_window_with_gateway_spend(world: World) -> None:
api = Api(world)
now = datetime.now(UTC)
async with world.db.session() as session:
session.add(
RateLimit(
window="five_hour",
status="allowed",
utilization=0.2,
resets_at=now + timedelta(hours=4),
)
)
session.add(
RateLimit(
window="five_hour",
status="allowed_warning",
utilization=0.9,
resets_at=now + timedelta(hours=4),
)
)
session.add(RateLimit(window="seven_day", status="allowed", utilization=0.3))
await session.commit()
await seed_usage(
world,
[
{
"ts": (now - timedelta(minutes=30)).replace(tzinfo=None),
"agent_name": "a",
"model": "m",
"output_tokens": 7,
"cost_usd": 0.25,
}
],
)
out = await api.get("/api/limits")
windows = {w["window"]: w for w in out["windows"]}
assert windows["five_hour"]["utilization"] == 0.9
assert windows["five_hour"]["status"] == "allowed_warning"
assert windows["five_hour"]["gateway"]["output"] == 7
assert windows["five_hour"]["gateway"]["cost_usd"] == 0.25
assert windows["seven_day"]["utilization"] == 0.3
assert [w["window"] for w in out["windows"]] == ["five_hour", "seven_day"]
assert len(out["history"]) == 3
async def test_memory_tree_and_file(world: World) -> None:
root = world.root / "zone"
(root / "дни").mkdir(parents=True)
(root / "дни" / "2026-08-27.md").write_text("# day", encoding="utf-8")
(root / "состояние.md").write_text("# state", encoding="utf-8")
(root / ".hidden").write_text("x", encoding="utf-8")
api = Api(world, memory_root=root)
tree = await api.get("/api/memory")
names = [n["name"] for n in tree["tree"]]
assert names == ["дни", "состояние.md"]
assert tree["tree"][0]["children"][0]["path"] == "дни/2026-08-27.md"
file = await api.get("/api/memory/file", {"path": "дни/2026-08-27.md"})
assert file["content"] == "# day"
escape = await api.http.get(
"/api/memory/file", params={"path": "../w.db"}, headers=HEADERS
)
assert escape.status_code == 404
unset = Api(world)
res = await unset.http.get("/api/memory", headers=HEADERS)
assert res.status_code == 404
async def test_describe_snapshots_open_tools_and_records_rate_limit(
world: World,
) -> None:
world.backend._factory = ToolClient # noqa: SLF001
api = Api(world)
async def record(event: Any) -> None:
async with world.db.session() as session:
session.add(
Usage(
agent_name=event.agent_name,
conversation_id=event.conversation_id,
model=event.model,
cost_usd=event.usage.cost_usd,
model_usage=event.usage.model_usage,
)
)
await session.commit()
world.backend._usage_sink = record # noqa: SLF001
conv = await world.conversations.create(kind="master", agent="a", origin="test")
ScriptedClient.hold = asyncio.Event()
await world.conversations.post(conv, "go")
await asyncio.sleep(0.3)
described = await api.get(f"/api/conversations/{conv.external_id}")
turn = described["turn"]
assert turn is not None and turn["id"] == described["running_turn"]
tools = {t["tool_use_id"]: t for t in turn["tools"]}
assert tools["tu_1"]["name"] == "Bash" and tools["tu_1"]["ended_at"] is None
assert tools["tu_2"]["parent_tool_use_id"] == "tu_1"
ScriptedClient.hold.set()
await world.settle(conv, 1)
described = await api.get(f"/api/conversations/{conv.external_id}")
assert described["turn"] is None
limits = await api.get("/api/limits")
assert limits["windows"][0]["utilization"] == 0.8
assert limits["windows"][0]["gateway"]["cost_usd"] == 0.5
usage = await api.get("/api/usage", {"group_by": "model"})
assert {r["model"] for r in usage["rows"]} == {"claude-opus-5", "claude-haiku-4-5"}
assert usage["total"]["web_searches"] == 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": "text", "text": "ok"}]},
],
session_id=conv.session_id,
cwd=str(world.root),
model="m",
),
)
history = await api.get(f"/api/conversations/{conv.external_id}/history")
assert [m["role"] for m in history["messages"]] == ["user", "assistant"]
entries = await api.get(f"/api/conversations/{conv.external_id}/entries")
assert entries["total"] == len(entries["entries"]) == 2
page = await api.get(
f"/api/conversations/{conv.external_id}/entries", {"limit": 1, "offset": 0}
)
assert page["offset"] == 0 and len(page["entries"]) == 1
async def test_tokens_and_audit_need_admin_scope(world: World) -> None:
api = Api(world)
api.store.grant("api-only", "weak", scope="api")
weak = {"Authorization": "Bearer weak"}
assert (await api.http.get("/api/tokens", headers=weak)).status_code == 403
created = await api.http.post(
"/api/tokens", json={"name": "cursor", "scope": "mcp"}, headers=HEADERS
)
assert created.status_code == 201
plaintext = created.json()["plaintext"]
await api.store.invalidate()
identity = await api.store.verify(plaintext)
assert identity is not None and identity.scope == "mcp"
listed = await api.get("/api/tokens")
assert [t["name"] for t in listed["tokens"]] == ["cursor"]
token_id = listed["tokens"][0]["id"]
revoked = await api.http.post(f"/api/tokens/{token_id}/revoke", headers=HEADERS)
assert revoked.status_code == 200
assert (await api.get("/api/tokens"))["tokens"] == []
audit = await api.get("/api/audit")
assert [r["kind"] for r in audit["records"]] == ["token_revoke", "token_create"]
async def test_admin_login_hands_out_the_ui_bearer(world: World) -> None:
api = Api(world)
ui_dir = world.root / "build"
ui_dir.mkdir()
(ui_dir / "index.html").write_text("<html>ui</html>", encoding="utf-8")
admin = build_admin(api.runtime, token="ui-bearer", ui_dir=ui_dir)
api.store.grant("admin-ui", "ui-bearer")
http = AsyncClient(transport=ASGITransport(app=admin), base_url="http://admin")
assert (await http.get("/admin/auth/session")).status_code == 401
bad = await http.post(
"/admin/auth/login", json={"username": "admin", "password": "nope"}
)
assert bad.status_code == 401
ok = await http.post(
"/admin/auth/login", json={"username": "admin", "password": "secret"}
)
assert ok.status_code == 200
session = (await http.get("/admin/auth/session")).json()
assert session["user"] == "admin" and session["token"] == "ui-bearer"
assert session["api_base"] is None
spa = await http.get("/admin/conversations/abc")
assert spa.status_code == 200 and spa.text == "<html>ui</html>"
assert (await http.get("/")).status_code == 307
escape = await http.get("/admin/%2e%2e/pyproject.toml")
assert escape.status_code == 200 and escape.text == "<html>ui</html>"
me = await api.http.get(
"/api/agents", headers={"Authorization": "Bearer ui-bearer"}
)
assert me.status_code == 200
assert (await http.post("/admin/auth/logout")).status_code == 204
assert (await http.get("/admin/auth/session")).status_code == 401
+460
View File
@@ -0,0 +1,460 @@
import asyncio
import tempfile
from pathlib import Path
from typing import Any
import pytest
from anthropic.types import (
RawContentBlockDeltaEvent,
RawContentBlockStartEvent,
RawContentBlockStopEvent,
RawMessageDeltaEvent,
RawMessageStartEvent,
RawMessageStopEvent,
)
from claude_agent_sdk import (
AssistantMessage,
InMemorySessionStore,
ResultMessage,
StreamEvent,
TextBlock,
ToolResultBlock,
ToolUseBlock,
UserMessage,
project_key_for_directory,
)
from beaver_gateway.agents.base import ExposedMcp
from beaver_gateway.agents.claude import ClaudeAgent, ClaudeOptions, Prompts
from beaver_gateway.backends.claude_sdk import (
ClaudeSdkBackend,
RunnerConfig,
UsageEvent,
fingerprint,
)
from beaver_gateway.core.transcript import messages_from_entries
from beaver_gateway.core.turn_capture import TurnCapture
def _stream(index: int, text: str) -> list[StreamEvent]:
def ev(event: dict[str, Any]) -> StreamEvent:
return StreamEvent(uuid="u", session_id="s", event=event)
return [
ev(
{
"type": "content_block_start",
"index": index,
"content_block": {"type": "text", "text": ""},
}
),
ev(
{
"type": "content_block_delta",
"index": index,
"delta": {"type": "text_delta", "text": text},
}
),
ev({"type": "content_block_stop", "index": index}),
]
def _result(session_id: str) -> ResultMessage:
return ResultMessage(
subtype="success",
duration_ms=10,
duration_api_ms=5,
is_error=False,
num_turns=1,
session_id=session_id,
stop_reason="end_turn",
total_cost_usd=0.01,
usage={
"input_tokens": 3,
"output_tokens": 7,
"cache_read_input_tokens": 100,
"cache_creation_input_tokens": 20,
},
)
class FakeClient:
instances: list["FakeClient"] = []
def __init__(self, options: Any) -> None:
self.options = options
self.prompts: list[str] = []
self.connected = False
self.session_id = options.resume or "fresh-session"
FakeClient.instances.append(self)
async def connect(self) -> None:
self.connected = True
async def query(self, prompt: str) -> None:
self.prompts.append(prompt)
async def interrupt(self) -> None:
self.interrupted = True
async def receive_response(self):
start = {"type": "message_start", "message": {}}
yield StreamEvent(uuid="u", session_id="s", event=start)
for e in _stream(0, "calling "):
yield e
tool = ToolUseBlock(id="toolu_1", name="Read", input={"path": "x"})
yield StreamEvent(
uuid="u",
session_id="s",
event={
"type": "content_block_start",
"index": 1,
"content_block": {"type": "tool_use", "id": "toolu_1", "name": "Read"},
},
)
yield StreamEvent(
uuid="u", session_id="s", event={"type": "content_block_stop", "index": 1}
)
yield AssistantMessage(content=[TextBlock(text="calling "), tool], model="m")
yield UserMessage(
content=[ToolResultBlock(tool_use_id="toolu_1", content="42")]
)
yield StreamEvent(uuid="u", session_id="s", event=start)
for e in _stream(0, "done"):
yield e
yield AssistantMessage(content=[TextBlock(text="done")], model="m")
yield StreamEvent(
uuid="sub",
session_id="s",
event={"type": "content_block_stop", "index": 5},
parent_tool_use_id="toolu_9",
)
yield _result(self.session_id)
async def disconnect(self) -> None:
self.connected = False
@pytest.fixture
def cwd() -> Path:
return Path(tempfile.mkdtemp(prefix="beaver-sdk-"))
def _backend(
cwd: Path, store: InMemorySessionStore, sink=None, **agent_kwargs
) -> ClaudeSdkBackend:
FakeClient.instances.clear()
agent = ClaudeAgent(
name="a",
model="claude-x",
system_prompt="hi",
cwd=cwd,
options=ClaudeOptions(effort="low"),
**agent_kwargs,
)
return ClaudeSdkBackend(
agent=agent,
mcp_internal_urls={"firefly": "http://127.0.0.1:1/mcp/firefly/"},
session_store=store,
mcp_tool_names={"firefly": ["list_account", "delete_account", "store_account"]},
usage_sink=sink,
client_factory=FakeClient,
work_dir=cwd / "work",
)
async def _drain(events) -> list[Any]:
return [e async for e in events]
async def test_stream_envelope_and_index_rebase(cwd: Path) -> None:
backend = _backend(
cwd, InMemorySessionStore(), expose_mcps=(ExposedMcp(name="firefly"),)
)
capture = TurnCapture()
events = await _drain(
backend.complete(
agent=backend.agent,
messages=[{"role": "user", "content": "hi"}],
conversation_id="conv-1",
capture=capture,
)
)
assert isinstance(events[0], RawMessageStartEvent)
assert isinstance(events[-1], RawMessageStopEvent)
assert isinstance(events[-2], RawMessageDeltaEvent)
assert events[-2].usage.output_tokens == 7
assert events[-2].usage.cache_read_input_tokens == 100
starts = [e.index for e in events if isinstance(e, RawContentBlockStartEvent)]
assert starts == [0, 1, 2]
stops = [e.index for e in events if isinstance(e, RawContentBlockStopEvent)]
assert stops == [0, 1, 2]
deltas = [e.delta.text for e in events if isinstance(e, RawContentBlockDeltaEvent)]
assert deltas == ["calling ", "done"]
assert capture.session_id == "fresh-session"
assert capture.usage is not None and capture.usage.cost_usd == 0.01
assert capture.synthesized_messages == [
{
"role": "assistant",
"content": [
{"type": "text", "text": "calling "},
{
"type": "tool_use",
"id": "toolu_1",
"name": "Read",
"input": {"path": "x"},
},
],
},
{
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": "toolu_1", "content": "42"}
],
},
{"role": "assistant", "content": [{"type": "text", "text": "done"}]},
]
client = FakeClient.instances[0]
assert client.prompts == ["hi"]
opts = client.options
assert opts.setting_sources == []
assert opts.strict_mcp_config is True
assert opts.permission_mode == "bypassPermissions"
assert opts.mcp_servers == {
"firefly": {"type": "http", "url": "http://127.0.0.1:1/mcp/firefly/"}
}
assert opts.session_store is not None
assert opts.resume is None
assert Path(opts.cli_path).exists()
wrapper = Path(opts.cli_path).read_text()
assert "CLAUDE_" in wrapper and "DATABASE_URL" not in wrapper
async def test_conversation_reuses_live_session(cwd: Path) -> None:
backend = _backend(cwd, InMemorySessionStore())
for i in range(2):
await _drain(
backend.complete(
agent=backend.agent,
messages=[{"role": "user", "content": f"turn {i}"}],
conversation_id="conv-1",
)
)
assert len(FakeClient.instances) == 1
assert FakeClient.instances[0].prompts == ["turn 0", "turn 1"]
assert backend.sessions["conv-1"]["turns"] == 2
async def test_history_is_seeded_into_store_and_resumed(cwd: Path) -> None:
store = InMemorySessionStore()
backend = _backend(cwd, store)
history = [
{"role": "user", "content": "first"},
{"role": "assistant", "content": [{"type": "text", "text": "reply"}]},
{"role": "user", "content": "second"},
]
await _drain(
backend.complete(
agent=backend.agent, messages=history, conversation_id="conv-2"
)
)
client = FakeClient.instances[0]
assert client.options.resume is not None
key = {
"project_key": project_key_for_directory(str(cwd)),
"session_id": client.options.resume,
}
entries = store.get_entries(key)
assert messages_from_entries(entries) == history[:2]
assert client.prompts == ["second"]
async def test_known_session_id_is_resumed_without_seeding(cwd: Path) -> None:
store = InMemorySessionStore()
backend = _backend(cwd, store)
history = [
{"role": "user", "content": "first"},
{"role": "assistant", "content": "reply"},
{"role": "user", "content": "second"},
]
await _drain(
backend.complete(
agent=backend.agent,
messages=history,
conversation_id="conv-3",
session_id="known-sid",
)
)
assert FakeClient.instances[0].options.resume == "known-sid"
assert store.size == 0
async def test_stateless_caller_hits_same_session_next_turn(cwd: Path) -> None:
backend = _backend(cwd, InMemorySessionStore())
first = [{"role": "user", "content": "hi"}]
await _drain(backend.complete(agent=backend.agent, messages=first))
follow_up = [
*first,
{
"role": "assistant",
"content": [
{"type": "text", "text": "calling "},
{"type": "text", "text": "done"},
],
},
{"role": "user", "content": "more"},
]
assert list(backend.sessions) == [fingerprint(follow_up[:-1])]
await _drain(backend.complete(agent=backend.agent, messages=follow_up))
assert len(FakeClient.instances) == 1
assert FakeClient.instances[0].prompts == ["hi", "more"]
async def test_mcp_deny_and_usage_sink(cwd: Path) -> None:
seen: list[UsageEvent] = []
async def sink(event: UsageEvent) -> None:
seen.append(event)
backend = _backend(
cwd,
InMemorySessionStore(),
sink,
expose_mcps=(ExposedMcp(name="firefly", deny=("delete_*",)),),
)
await _drain(
backend.complete(
agent=backend.agent,
messages=[{"role": "user", "content": "x"}],
conversation_id="c",
)
)
assert FakeClient.instances[0].options.disallowed_tools == [
"mcp__firefly__delete_account"
]
assert len(seen) == 1
assert seen[0].usage.input_tokens == 3
assert seen[0].conversation_id == "c"
assert seen[0].session_id == "fresh-session"
async def test_skill_sets_become_sorted_plugins(cwd: Path) -> None:
sets = cwd / "skills"
for name in ("zeta", "общие"):
(sets / name / "demo").mkdir(parents=True)
(sets / name / "demo" / "SKILL.md").write_text("---\nname: demo\n---\n")
backend = _backend(
cwd, InMemorySessionStore(), skill_sets=(sets / "zeta", sets / "общие")
)
await _drain(
backend.complete(
agent=backend.agent,
messages=[{"role": "user", "content": "x"}],
conversation_id="c",
)
)
plugins = FakeClient.instances[0].options.plugins
assert [p["type"] for p in plugins] == ["local", "local"]
paths = [Path(p["path"]) for p in plugins]
assert [p.name for p in paths] == ["zeta", "общие"]
for path in paths:
assert (path / ".claude-plugin" / "plugin.json").exists()
assert (path / "skills" / "demo" / "SKILL.md").exists()
assert FakeClient.instances[0].options.skills == "all"
async def test_prompt_sources_are_assembled(cwd: Path) -> None:
(cwd / "a.md").write_text("alpha\n")
(cwd / "b.md").write_text("\nbeta\n\n")
backend = _backend(
cwd,
InMemorySessionStore(),
prompts=Prompts(deep=(("role", cwd / "a.md"), cwd / "b.md")),
)
await _drain(
backend.complete(
agent=backend.agent,
messages=[{"role": "user", "content": "x"}],
conversation_id="c",
)
)
assert (
FakeClient.instances[0].options.system_prompt
== "<role>\nalpha\n</role>\n\nbeta\n"
)
async def test_runner_user_lands_in_wrapper(cwd: Path) -> None:
import os
import pwd
me = pwd.getpwuid(os.getuid())
backend = _backend(cwd, InMemorySessionStore())
backend._runner = RunnerConfig(user=me.pw_name, home=cwd)
backend._uid, backend._gid = me.pw_uid, me.pw_gid
await _drain(
backend.complete(
agent=backend.agent,
messages=[{"role": "user", "content": "x"}],
conversation_id="c",
)
)
opts = FakeClient.instances[0].options
assert opts.env["HOME"] == str(cwd)
assert opts.env["CLAUDE_CONFIG_DIR"] == str(cwd / ".claude")
wrapper = Path(opts.cli_path).read_text()
assert f"UID = {me.pw_uid}" in wrapper
assert "os.setuid(UID)" in wrapper
async def test_close_disconnects(cwd: Path) -> None:
backend = _backend(cwd, InMemorySessionStore())
async with backend:
await _drain(
backend.complete(
agent=backend.agent,
messages=[{"role": "user", "content": "x"}],
conversation_id="c",
)
)
assert FakeClient.instances[0].connected is False
assert backend.sessions == {}
class GatedClient(FakeClient):
"""Streams one delta, then waits for ``gate`` before finishing the turn."""
gate: asyncio.Event
async def receive_response(self):
yield StreamEvent(
uuid="u", session_id="s", event={"type": "message_start", "message": {}}
)
for e in _stream(0, "first"):
yield e
await GatedClient.gate.wait()
yield AssistantMessage(content=[TextBlock(text="first")], model="m")
yield _result(self.session_id)
async def test_deltas_reach_the_caller_before_the_turn_ends(cwd: Path) -> None:
"""The turn is not buffered: a delta is observable while the CLI still runs."""
GatedClient.gate = asyncio.Event()
backend = _backend(cwd, InMemorySessionStore())
backend._factory = GatedClient
events = backend.complete(
agent=backend.agent,
messages=[{"role": "user", "content": "hi"}],
conversation_id="conv-live",
)
seen: list[Any] = []
async for ev in events:
seen.append(ev)
if isinstance(ev, RawContentBlockDeltaEvent):
break
assert seen[-1].delta.text == "first"
assert not GatedClient.gate.is_set()
GatedClient.gate.set()
rest = [e async for e in events]
assert isinstance(rest[-1], RawMessageStopEvent)
+729
View File
@@ -0,0 +1,729 @@
import asyncio
import tempfile
import uuid
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
import pytest
from claude_agent_sdk import (
AssistantMessage,
InMemorySessionStore,
ResultMessage,
StreamEvent,
TextBlock,
ToolResultBlock,
ToolUseBlock,
UserMessage,
project_key_for_directory,
)
from beaver_gateway.agents.claude import ClaudeAgent, ClaudeOptions
from beaver_gateway.backends.claude_sdk import ClaudeSdkBackend
from beaver_gateway.core.bus import EventBus
from beaver_gateway.core.conversations import Conversations, ConversationTexts, parse_at
from beaver_gateway.core.registry import AgentRegistry
from beaver_gateway.core.sessions import SessionPool
from beaver_gateway.core.transcript import (
build_entries,
close_open_tool_uses,
open_tool_uses,
render_messages,
strip_tool_entries,
window_entries,
)
from beaver_gateway.frontends.base import Frontend
from beaver_gateway.storage import Database
from beaver_gateway.storage.models import (
Conversation,
ConversationBinding,
InjectQueueItem,
)
class StubFrontend(Frontend):
def __init__(
self,
name: str,
kinds: tuple[str, ...],
agents: dict[str, str] | None = None,
*,
home: bool = False,
) -> None:
self.name = name
self.kinds = kinds
self.agents = agents or {}
self.home = home
self.materialized: list[str] = []
self.conversations = None
def configure(self, runtime) -> None:
pass
async def serve(self) -> None:
pass
def agent_for(self, kind: str) -> str | None:
return self.agents.get(kind)
async def materialize(self, conv: Conversation) -> ConversationBinding | None:
if not self.home:
return None
self.materialized.append(conv.external_id)
return await self.conversations.bind(
conv, frontend=self.name, external_id=f"{self.name}:{conv.external_id}"
)
class ScriptedClient:
instances: list["ScriptedClient"] = []
hold: asyncio.Event | None = None
def __init__(self, options: Any) -> None:
self.options = options
self.prompts: list[str] = []
self.session_id = options.resume or str(uuid.uuid4())
self.interrupted = False
self.connected = False
ScriptedClient.instances.append(self)
async def connect(self) -> None:
self.connected = True
async def query(self, prompt: str) -> None:
self.prompts.append(prompt)
async def receive_response(self):
prompt = self.prompts[-1]
yield StreamEvent(
uuid="u",
session_id=self.session_id,
event={"type": "message_start", "message": {}},
)
yield StreamEvent(
uuid="u",
session_id=self.session_id,
event={
"type": "content_block_start",
"index": 0,
"content_block": {"type": "text", "text": ""},
},
)
yield StreamEvent(
uuid="u",
session_id=self.session_id,
event={
"type": "content_block_delta",
"index": 0,
"delta": {"type": "text_delta", "text": f"ok:{prompt}"},
},
)
yield StreamEvent(
uuid="u",
session_id=self.session_id,
event={"type": "content_block_stop", "index": 0},
)
yield AssistantMessage(content=[TextBlock(text=f"ok:{prompt}")], model="m")
hold = ScriptedClient.hold
if hold is not None and not self.interrupted:
await hold.wait()
cut = self.interrupted
self.interrupted = False
yield ResultMessage(
subtype="error_during_execution" if cut else "success",
duration_ms=1,
duration_api_ms=1,
is_error=cut,
num_turns=1,
session_id=self.session_id,
stop_reason="end_turn",
total_cost_usd=0.0,
usage={"input_tokens": 1, "output_tokens": 1},
)
async def interrupt(self) -> None:
self.interrupted = True
if ScriptedClient.hold is not None:
ScriptedClient.hold.set()
async def disconnect(self) -> None:
self.connected = False
class World:
def __init__(self, root: Path) -> None:
ScriptedClient.instances.clear()
ScriptedClient.hold = None
self.root = root
self.db = Database(f"sqlite:///{root / 'w.db'}")
self.store = InMemorySessionStore()
self.agent = ClaudeAgent(
name="a",
model="m",
system_prompt="hi",
cwd=root,
kinds=("master", "branch", "job", "fork"),
gateway_tools=("say",),
options=ClaudeOptions(effort="low"),
)
self.deep_agent = ClaudeAgent(
name="d", model="m", system_prompt="deep", cwd=root
)
self.markdown = StubFrontend("markdown", ("deep",), {"deep": "d"}, home=True)
self.api = StubFrontend("api", ("master", "branch", "deep", "job"))
self.pool = SessionPool(rss_limit=1 << 40, max_live=100)
self.backend, self.deep_backend = (
ClaudeSdkBackend(
agent=agent,
mcp_internal_urls={},
session_store=self.store,
client_factory=ScriptedClient,
work_dir=root / "work",
pool=self.pool,
)
for agent in (self.agent, self.deep_agent)
)
self.bus = EventBus()
self.conversations = Conversations(
db=self.db,
agents=AgentRegistry([self.agent, self.deep_agent]),
backends={"a": self.backend, "d": self.deep_backend},
bus=self.bus,
pool=self.pool,
store=self.store,
texts=ConversationTexts(),
frontends=[self.api, self.markdown],
idle_interval=3600,
)
self.markdown.conversations = self.conversations
self.api.conversations = self.conversations
async def setup(self) -> "World":
await self.db.create_all()
return self
def key(self, session_id: str) -> dict[str, str]:
return {
"project_key": project_key_for_directory(str(self.root)),
"session_id": session_id,
}
async def statuses(self, conv) -> list[tuple[str, str]]:
rows = await self.conversations.queue.recent(conv.id)
return [(r.priority, r.status) for r in reversed(rows)]
async def settle(self, conv, expected: int, timeout: float = 5.0) -> None:
deadline = asyncio.get_running_loop().time() + timeout
while asyncio.get_running_loop().time() < deadline:
rows = await self.conversations.queue.recent(conv.id)
if sum(1 for r in rows if r.status == "done") >= expected:
return
await asyncio.sleep(0.02)
msg = f"queue did not settle: {await self.statuses(conv)}"
raise AssertionError(msg)
@pytest.fixture
async def world() -> World:
root = Path(tempfile.mkdtemp(prefix="beaver-conv-"))
w = await World(root).setup()
yield w
await w.conversations.stop()
await w.pool.close_all()
await w.db.dispose()
async def test_two_messages_run_one_at_a_time_in_order(world: World) -> None:
conv = await world.conversations.create(kind="master", agent="a", origin="test")
ScriptedClient.hold = asyncio.Event()
await world.conversations.post(conv, "first")
await asyncio.sleep(0.2)
await world.conversations.post(conv, "second")
await asyncio.sleep(0.2)
assert await world.statuses(conv) == [("user", "running"), ("user", "queued")]
live = world.pool.get(conv.external_id)
assert live is not None and live.busy
assert (await world.conversations.get(conv.external_id)).running_turn is not None
ScriptedClient.hold.set()
await world.settle(conv, 2)
assert len(ScriptedClient.instances) == 1
assert ScriptedClient.instances[0].prompts == ["first", "second"]
row = await world.conversations.get(conv.external_id)
assert row.running_turn is None
assert row.session_id == ScriptedClient.instances[0].session_id
async def test_urgent_interrupts_and_goes_first(world: World) -> None:
conv = await world.conversations.create(kind="master", agent="a", origin="test")
ScriptedClient.hold = asyncio.Event()
await world.conversations.post(conv, "first")
await asyncio.sleep(0.2)
await world.conversations.post(conv, "second")
await world.conversations.inject(conv, "ALERT", urgency="urgent", origin="крон")
await world.settle(conv, 2)
client = ScriptedClient.instances[0]
assert await world.statuses(conv) == [
("user", "interrupted"),
("user", "done"),
("urgent", "done"),
]
assert await world.statuses(conv) == [
("user", "interrupted"),
("user", "done"),
("urgent", "done"),
]
assert client.prompts[0] == "first"
assert client.prompts[1].startswith("[инжект: крон")
assert client.prompts[1].endswith("ALERT")
assert client.prompts[2] == "second"
async def test_normal_injects_ride_with_the_next_user_message(world: World) -> None:
conv = await world.conversations.create(kind="master", agent="a", origin="test")
await world.conversations.inject(
conv, "vault changed", urgency="normal", origin="watch"
)
await asyncio.sleep(0.2)
assert await world.statuses(conv) == [("normal", "queued")]
await world.conversations.post(conv, "hello")
await world.settle(conv, 2)
prompts = ScriptedClient.instances[0].prompts
assert len(prompts) == 1
assert prompts[0].startswith("hello\n\n[инжекты")
assert "- [watch] vault changed" in prompts[0]
async def test_inject_turn_reply_is_not_routed(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())
world.conversations._normal_window = 0.05
await world.conversations.inject(conv, "tick", urgency="normal", origin="крон")
await world.settle(conv, 1)
await asyncio.sleep(0.05)
task.cancel()
types = [e["type"] for e in seen]
assert "turn.start" in types and "turn.end" in types
assert "reply" not in types
assert all(e.get("origin") == "inject" for e in seen if e["type"] == "turn.start")
async def test_spawn_seeds_first_user_message(world: World) -> None:
conv = await world.conversations.spawn(
kind="branch", agent="a", seed="brief", text="do X", title="t"
)
await world.settle(conv, 1)
prompt = ScriptedClient.instances[0].prompts[0]
assert prompt.startswith("[сид: brief] branch «t», ")
assert prompt.endswith("\n\ndo X")
assert ScriptedClient.instances[0].options.system_prompt == "hi"
async def test_fork_leaves_original_untouched(world: World) -> None:
sid = str(uuid.uuid4())
history = [
{"role": "user", "content": "one"},
{
"role": "assistant",
"content": [{"type": "tool_use", "id": "t1", "name": "Read", "input": {}}],
},
{
"role": "user",
"content": [{"type": "tool_result", "tool_use_id": "t1", "content": "x"}],
},
{"role": "assistant", "content": "done one"},
{"role": "user", "content": "two"},
{"role": "assistant", "content": "done two"},
{"role": "user", "content": "three"},
{"role": "assistant", "content": "done three"},
]
entries = build_entries(history, session_id=sid, cwd=str(world.root), model="m")
await world.store.append(world.key(sid), entries)
conv = await world.conversations.create(
kind="branch", agent="a", origin="test", session_id=sid
)
before = [dict(e) for e in await world.store.load(world.key(sid))]
result = await world.conversations.fork(
conv, "summarize", window=2, strip_tools=True
)
after = await world.store.load(world.key(sid))
assert after == before
assert result.text == "ok:summarize"
assert result.conversation.status == "closed"
assert result.conversation.parent_id == conv.id
fork_entries = await world.store.load(world.key(result.conversation.session_id))
assert not {e["uuid"] for e in fork_entries} & {e["uuid"] for e in before}
texts = [e["message"]["content"] for e in fork_entries]
assert texts[0] == "two"
assert len(fork_entries) == 4
assert fork_entries[0]["parentUuid"] is None
assert all(
b["type"] == "text"
for e in fork_entries[1:]
for b in e["message"]["content"]
if isinstance(e["message"]["content"], list)
)
client = ScriptedClient.instances[0]
assert client.options.resume == result.conversation.session_id
assert client.options.mcp_servers == {}
assert world.pool.get(result.conversation.external_id) is None
async def test_copy_seed_forks_parent_with_window(world: World) -> None:
sid = str(uuid.uuid4())
history = [
{"role": "user", "content": f"q{i}"}
if i % 2 == 0
else {"role": "assistant", "content": f"a{i}"}
for i in range(8)
]
await world.store.append(
world.key(sid),
build_entries(history, session_id=sid, cwd=str(world.root), model="m"),
)
parent = await world.conversations.create(
kind="master", agent="a", origin="test", session_id=sid
)
child = await world.conversations.spawn(
kind="branch", agent="a", seed="copy", parent=parent, window=1
)
assert child.session_id and child.session_id != sid
copied = await world.store.load(world.key(child.session_id))
assert [e["message"]["content"] for e in copied] == [
"q6",
[{"type": "text", "text": "a7"}],
]
assert (await world.conversations.get(child.external_id)).flags["seed"] == "copy"
await world.conversations.post(child, "go")
await world.settle(child, 1)
assert ScriptedClient.instances[0].options.resume == child.session_id
prompt = ScriptedClient.instances[0].prompts[0]
assert prompt.startswith("[сид: copy] branch, ")
assert "последние 1 тёрнов" in prompt and prompt.endswith("\n\ngo")
assert (await world.conversations.get(child.external_id)).flags["seed"] is None
async def test_merge_injects_summary_into_parent(world: World) -> None:
sid = str(uuid.uuid4())
await world.store.append(
world.key(sid),
build_entries(
[{"role": "user", "content": "q"}, {"role": "assistant", "content": "a"}],
session_id=sid,
cwd=str(world.root),
model="m",
),
)
master = await world.conversations.create(kind="master", agent="a", origin="test")
branch = await world.conversations.create(
kind="branch", agent="a", origin="test", parent=master, session_id=sid
)
result = await world.conversations.merge(branch)
assert result.text.startswith("ok:")
assert (await world.conversations.get(branch.external_id)).status == "merged"
assert await world.statuses(master) == [("normal", "queued")]
item = (await world.conversations.queue.recent(master.id))[0]
assert item.origin == "слив" and item.text == result.text
async def test_recover_closes_open_tool_use_and_injects_interrupted(
world: World,
) -> None:
sid = str(uuid.uuid4())
history = [
{"role": "user", "content": "go"},
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "t9",
"name": "Bash",
"input": {"command": "sleep"},
}
],
},
]
await world.store.append(
world.key(sid),
build_entries(history, session_id=sid, cwd=str(world.root), model="m"),
)
conv = await world.conversations.create(
kind="master", agent="a", origin="test", session_id=sid
)
async with world.db.session() as session:
row = await session.get(type(conv), conv.id)
row.running_turn = "turn_dead"
session.add(row)
session.add(
InjectQueueItem(
conversation_id=conv.id,
priority="user",
origin="user",
text="go",
status="running",
turn_id="turn_dead",
)
)
await session.commit()
cut = await world.conversations.recover()
assert [c.external_id for c in cut] == [conv.external_id]
entries = await world.store.load(world.key(sid))
assert not open_tool_uses(entries)
tail = entries[-1]
assert tail["type"] == "user"
assert tail["message"]["content"][0] == {
"type": "tool_result",
"tool_use_id": "t9",
"content": "прервано",
"is_error": True,
}
assert tail["parentUuid"] == entries[-2]["uuid"]
assert (await world.conversations.get(conv.external_id)).running_turn is None
assert await world.statuses(conv) == [("user", "interrupted"), ("normal", "queued")]
note = (await world.conversations.queue.recent(conv.id))[0]
assert (
"turn_dead" in note.text
and "оборван" in note.text
and "1 незакрытых" in note.text
)
await asyncio.sleep(0.2)
assert ScriptedClient.instances == []
async def test_graceful_stop_keeps_running_turn_for_recover(world: World) -> None:
conv = await world.conversations.create(kind="master", agent="a", origin="test")
ScriptedClient.hold = asyncio.Event()
await world.conversations.post(conv, "long")
await asyncio.sleep(0.2)
assert (await world.conversations.get(conv.external_id)).running_turn is not None
await world.conversations.stop()
row = await world.conversations.get(conv.external_id)
assert row.running_turn is not None
assert row.session_id == ScriptedClient.instances[0].session_id
cut = await world.conversations.recover()
assert [c.external_id for c in cut] == [conv.external_id]
assert (await world.conversations.get(conv.external_id)).running_turn is None
assert await world.statuses(conv) == [("user", "interrupted"), ("normal", "queued")]
ScriptedClient.hold = None
async def test_read_and_bindings(world: World) -> None:
sid = str(uuid.uuid4())
history = [
{"role": "user", "content": "q1"},
{
"role": "assistant",
"content": [
{"type": "text", "text": "a1"},
{"type": "tool_use", "id": "t", "name": "Read", "input": {}},
],
},
{
"role": "user",
"content": [{"type": "tool_result", "tool_use_id": "t", "content": "x"}],
},
{"role": "assistant", "content": "a1b"},
{"role": "user", "content": "q2"},
{"role": "assistant", "content": "a2"},
]
await world.store.append(
world.key(sid),
build_entries(history, session_id=sid, cwd=str(world.root), model="m"),
)
conv = await world.conversations.create(
kind="deep", agent="d", origin="test", session_id=sid
)
assert (
await world.conversations.read(conv)
== "user:\nq1\n\nassistant:\na1\n\na1b\n(tools: Read)\n\nuser:\nq2\n\nassistant:\na2"
)
assert (
await world.conversations.read(conv, window=1) == "user:\nq2\n\nassistant:\na2"
)
await world.conversations.bind(conv, frontend="markdown", external_id="a.md")
await world.conversations.bind(conv, frontend="markdown", external_id="b.md")
bindings = await world.conversations.bindings(conv)
assert [(b.external_id, b.visible) for b in bindings] == [
("a.md", False),
("b.md", True),
]
found = await world.conversations.find_bound(
frontend="markdown", external_id="b.md"
)
assert found is not None and found.id == conv.id
assert (
await world.conversations.find_bound(frontend="markdown", external_id="a.md")
is None
)
async def test_schedule_rows_and_parse_at(world: World) -> None:
conv = await world.conversations.create(kind="master", agent="a", origin="test")
row = await world.conversations.schedule(conv, "+15m", "push X")
delta = (row.execute_at.replace(tzinfo=UTC) - datetime.now(UTC)).total_seconds()
assert 14 * 60 < delta <= 15 * 60
assert [s.text for s in await world.conversations.schedules(conv)] == ["push X"]
assert parse_at("2026-09-01T10:00:00+02:00") == datetime(
2026, 9, 1, 8, 0, tzinfo=UTC
)
with pytest.raises(ValueError, match="Invalid isoformat"):
parse_at("tomorrow")
def test_transcript_helpers() -> None:
history = [
{"role": "user", "content": "q1"},
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "hm", "signature": "s"},
{"type": "tool_use", "id": "t", "name": "Read", "input": {}},
],
},
{
"role": "user",
"content": [{"type": "tool_result", "tool_use_id": "t", "content": "x"}],
},
{"role": "assistant", "content": "a1"},
{"role": "user", "content": "q2"},
{
"role": "assistant",
"content": [{"type": "tool_use", "id": "t2", "name": "Bash", "input": {}}],
},
]
entries = build_entries(history, session_id="s", cwd="/v", model="m")
assert [b["id"] for _, b in open_tool_uses(entries)] == ["t2"]
fixes = close_open_tool_uses(entries)
assert len(fixes) == 1 and fixes[0]["sessionId"] == "s" and fixes[0]["cwd"] == "/v"
assert not open_tool_uses([*entries, *fixes])
stripped = strip_tool_entries(entries)
assert [e["message"]["content"] for e in stripped] == [
"q1",
[{"type": "text", "text": "a1"}],
"q2",
]
assert (
stripped[0]["parentUuid"] is None
and stripped[1]["parentUuid"] == stripped[0]["uuid"]
)
windowed = window_entries(entries, window=1)
assert windowed[0]["message"]["content"] == "q2" and len(windowed) == 2
assert (
render_messages(
[{"role": "user", "content": "q"}, {"role": "assistant", "content": "a"}]
)
== "user:\nq\n\nassistant:\na"
)
async def test_agent_kinds_gate_create(world: World) -> None:
with pytest.raises(ValueError, match="does not serve kind 'deep'"):
await world.conversations.create(kind="deep", agent="a", origin="test")
with pytest.raises(ValueError, match="does not serve kind 'master'"):
await world.conversations.create(kind="master", agent="d", origin="test")
with pytest.raises(ValueError, match="'d' does not serve kind 'master'"):
await world.conversations.spawn(kind="master", seed="clean", agent="d")
conv = await world.conversations.create(kind="deep", agent="d", origin="test")
assert conv.agent_name == "d"
async def test_frontend_kinds_gate_bind(world: World) -> None:
master = await world.conversations.create(kind="master", agent="a", origin="test")
with pytest.raises(ValueError, match="'markdown' does not show kind 'master'"):
await world.conversations.bind(master, frontend="markdown", external_id="m.md")
with pytest.raises(ValueError, match="unknown frontend 'telegram'"):
await world.conversations.bind(master, frontend="telegram", external_id="1")
await world.conversations.bind(master, frontend="api", external_id="x")
async def test_spawn_defaults_agent_and_materializes(world: World) -> None:
master = await world.conversations.create(kind="master", agent="a", origin="test")
deep = await world.conversations.spawn(
kind="deep", seed="brief", text="dig", title="t", parent=master, origin="mcp"
)
assert deep.agent_name == "d"
assert world.markdown.materialized == [deep.external_id]
bindings = await world.conversations.bindings(deep)
assert [(b.frontend, b.external_id) for b in bindings] == [
("markdown", f"markdown:{deep.external_id}")
]
branch = await world.conversations.spawn(kind="branch", seed="clean", parent=master)
assert branch.agent_name == "a"
assert world.markdown.materialized == [deep.external_id]
with pytest.raises(ValueError, match="no default agent for kind 'job'"):
await world.conversations.spawn(kind="job", seed="clean")
await world.settle(deep, 1)
def test_agent_kinds_follow_prompts(tmp_path: Path) -> None:
from beaver_gateway.agents.claude import Prompts
plain = ClaudeAgent(name="p", model="m", system_prompt="hi", cwd=tmp_path)
assert plain.kinds == ("deep",)
dispatcher = ClaudeAgent(
name="x", model="m", cwd=tmp_path, prompts=Prompts(master=("a.md",), fork=())
)
assert dispatcher.kinds == ("master", "fork")
assert dispatcher.prompt_for("master") == ("a.md",)
assert dispatcher.prompt_for("deep") is None
with pytest.raises(ValueError, match=r"serves \['deep'\] without a prompt"):
ClaudeAgent(
name="y",
model="m",
cwd=tmp_path,
kinds=("master", "deep"),
prompts=Prompts(master=()),
)
async def test_observer_publishes_tool_results_for_the_panel(world: World) -> None:
seen: list[dict[str, Any]] = []
conv = await world.conversations.create(kind="master", agent="a", origin="test")
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 asyncio.sleep(0)
runner = world.conversations._runner(conv.id)
runner.turn_id = "t1"
observe = world.conversations._observer(conv, runner, "t1", "user")
observe(
AssistantMessage(
content=[ToolUseBlock(id="toolu_1", name="Bash", input={"command": "ls"})],
model="m",
parent_tool_use_id="toolu_0",
)
)
observe(
UserMessage(
content=[
ToolResultBlock(tool_use_id="toolu_1", content="a\nb", is_error=False)
],
parent_tool_use_id="toolu_0",
)
)
observe(UserMessage(content="plain text, no tool result"))
await asyncio.sleep(0.05)
task.cancel()
assert [e["type"] for e in seen] == ["tool", "tool.result"]
tool, result = seen
assert tool["tool_use_id"] == "toolu_1" and tool["parent_tool_use_id"] == "toolu_0"
snapshot = runner.snapshot()
assert snapshot is not None and snapshot["tools"][0]["content"] == "a\nb"
assert result["tool_use_id"] == "toolu_1"
assert result["parent_tool_use_id"] == "toolu_0"
assert result["turn_id"] == "t1" and result["is_error"] is False
assert result["content"] == "a\nb"
+61
View File
@@ -0,0 +1,61 @@
import sqlite3
import tempfile
from pathlib import Path
from sqlmodel import select
from beaver_gateway.storage import Database
from beaver_gateway.storage.models import Conversation
async def test_create_all_adds_missing_columns() -> None:
path = Path(tempfile.mkdtemp(prefix="beaver-migrate-")) / "old.db"
raw = sqlite3.connect(path)
raw.execute(
"CREATE TABLE conversations (id INTEGER PRIMARY KEY, frontend VARCHAR NOT NULL, "
"external_id VARCHAR NOT NULL, agent_name VARCHAR NOT NULL, "
"created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL)"
)
raw.execute(
"INSERT INTO conversations VALUES (1, 'markdown', 'x', 'a', '2026-01-01', '2026-01-01')"
)
raw.commit()
raw.close()
db = Database(f"sqlite:///{path}")
await db.create_all()
async with db.session() as session:
conv = (await session.exec(select(Conversation))).one()
assert conv.session_id is None
conv.session_id = "sid"
session.add(conv)
await session.commit()
await db.dispose()
async def test_create_all_backfills_defaults_for_old_rows() -> None:
path = Path(tempfile.mkdtemp(prefix="beaver-migrate-")) / "old.db"
raw = sqlite3.connect(path)
raw.execute(
"CREATE TABLE conversations (id INTEGER PRIMARY KEY, frontend VARCHAR NOT NULL, "
"external_id VARCHAR NOT NULL, agent_name VARCHAR NOT NULL, session_id VARCHAR, "
"created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL)"
)
raw.execute(
"INSERT INTO conversations VALUES (1, 'markdown', 'x', 'a', NULL, '2026-01-01', '2026-01-01')"
)
raw.commit()
raw.close()
db = Database(f"sqlite:///{path}")
await db.create_all()
async with db.session() as session:
conv = (await session.exec(select(Conversation))).one()
assert (conv.kind, conv.status, conv.pending_question, conv.flags) == (
"deep",
"open",
False,
{},
)
assert conv.running_turn is None
await db.dispose()
+45
View File
@@ -0,0 +1,45 @@
from anthropic.types import Message, TextBlock, ThinkingBlock, ToolUseBlock, Usage
from beaver_gateway.frontends.markdown import renderer
def _message(*blocks: object) -> Message:
return Message(
id="m",
type="message",
role="assistant",
model="x",
content=list(blocks), # type: ignore[arg-type]
stop_reason="end_turn",
stop_sequence=None,
usage=Usage(input_tokens=1, output_tokens=1),
)
def test_tool_calls_leave_no_blank_lines() -> None:
message = _message(
TextBlock(type="text", text="first"),
ToolUseBlock(type="tool_use", id="t1", name="Bash", input={"command": "ls"}),
ToolUseBlock(type="tool_use", id="t2", name="Read", input={"file_path": "x"}),
ToolUseBlock(type="tool_use", id="t3", name="Grep", input={"pattern": "y"}),
TextBlock(type="text", text="second"),
)
assert renderer.render_assistant_message(message) == (
"### Assistant:\n\nfirst\n\nsecond\n"
)
def test_thinking_renders_as_collapsed_callout() -> None:
message = _message(
ThinkingBlock(type="thinking", thinking="a\nb", signature="s"),
TextBlock(type="text", text="answer"),
)
assert renderer.render_assistant_message(message) == (
"### Assistant:\n\n> [!thinking]-\n> a\n> b\n\nanswer\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")
assert renderer.render_user_text("q").startswith("### User:\n\n")
+228
View File
@@ -0,0 +1,228 @@
import asyncio
import tempfile
from pathlib import Path
import frontmatter
import httpx
import pytest
from beaver_gateway.core.auth import TokenStore
from beaver_gateway.core.conversation_store import load_messages
from beaver_gateway.core.gateway_tools import _tools
from beaver_gateway.core.registry import McpRegistry
from beaver_gateway.frontends.anthropic import AnthropicMessagesFrontend
from beaver_gateway.frontends.api import ApiFrontend
from beaver_gateway.frontends.base import GatewayRuntime
from beaver_gateway.frontends.markdown import MarkdownFrontend
from test_conversations import ScriptedClient, World
AUTH = {"Authorization": "Bearer tok"}
class Stack:
def __init__(self, world: World) -> None:
self.world = world
self.vault = world.root / "vault"
self.api = ApiFrontend(master_agent="a")
self.markdown = MarkdownFrontend(vault_path=self.vault, default_agent="d")
self.anthropic = AnthropicMessagesFrontend()
frontends = [self.api, self.anthropic, self.markdown]
world.conversations._frontends = frontends
self.runtime = GatewayRuntime(
agents=world.conversations._agents,
mcps=McpRegistry([]),
backends=world.conversations._backends,
token_store=TokenStore(bootstrap={"t": "tok"}),
db=world.db,
conversations=world.conversations,
bus=world.bus,
pool=world.pool,
frontends=tuple(frontends),
)
for fe in frontends:
fe.configure(self.runtime)
self.mirror = asyncio.create_task(self.markdown.mirror.run())
def client(self, fe) -> httpx.AsyncClient:
return httpx.AsyncClient(
transport=httpx.ASGITransport(app=fe._app), base_url="http://t"
)
async def close(self) -> None:
self.mirror.cancel()
async def file_of(self, conv_id: str) -> Path:
conv = await self.world.conversations.get(conv_id)
path = await self.markdown.mirror.bound_path(conv)
assert path is not None
return path
async def wait_file(self, path: Path, needle: str, timeout: float = 5.0) -> str:
deadline = asyncio.get_running_loop().time() + timeout
while asyncio.get_running_loop().time() < deadline:
if path.exists() and needle in path.read_text(encoding="utf-8"):
return path.read_text(encoding="utf-8")
await asyncio.sleep(0.02)
msg = f"{needle!r} never showed up in {path}"
raise AssertionError(msg)
@pytest.fixture
async def stack() -> Stack:
root = Path(tempfile.mkdtemp(prefix="beaver-routing-"))
world = await World(root).setup()
await world.conversations.start()
s = Stack(world)
yield s
await s.close()
await world.conversations.stop()
await world.pool.close_all()
await world.db.dispose()
async def test_api_rejects_deep_with_dispatcher(stack: Stack) -> None:
async with stack.client(stack.api) as c:
r = await c.post(
"/api/conversations", json={"kind": "deep", "agent": "a"}, headers=AUTH
)
assert r.status_code == 400
assert "does not serve kind 'deep'" in r.json()["error"]
r = await c.post("/api/conversations", json={"kind": "job"}, headers=AUTH)
assert r.status_code == 400
assert "no default agent" in r.json()["error"]
r = await c.get("/api/agents", headers=AUTH)
agents = {a["name"]: a["kinds"] for a in r.json()["agents"]}
assert agents == {"a": ["master", "branch", "job", "fork"], "d": ["deep"]}
homes = {f["name"]: f["default_agents"] for f in r.json()["frontends"]}
assert homes == {
"api": {"master": "a"},
"anthropic": {},
"markdown": {"deep": "d"},
}
async def test_api_spawn_deep_lands_in_vault(stack: Stack) -> None:
async with stack.client(stack.api) as c:
r = await c.post("/api/conversations", json={"kind": "master"}, headers=AUTH)
assert r.status_code == 201 and r.json()["agent"] == "a"
master = r.json()["id"]
r = await c.post(
"/api/conversations",
json={"kind": "deep", "seed": "brief", "text": "dig", "title": "Тема"},
headers=AUTH,
)
assert r.status_code == 201, r.text
body = r.json()
assert body["agent"] == "d"
assert [b["frontend"] for b in body["bindings"]] == ["markdown"]
rel = body["bindings"][0]["external_id"]
assert rel.endswith("_Тема.md") and rel.startswith("_logs/d/")
r = await c.post(
f"/api/conversations/{master}/bind",
json={"frontend": "markdown", "external_id": "x.md"},
headers=AUTH,
)
assert r.status_code == 400
path = stack.vault / rel
text = await stack.wait_file(path, "ok:[сид: brief]")
post = frontmatter.loads(text)
assert post.metadata == {"agent": "d", "conversation_id": body["id"]}
assert post.content.startswith("### User:\n\n[сид: brief] deep «Тема», ")
assert post.content.rstrip().endswith("### User:")
async def test_markdown_chat_rejects_dispatcher(stack: Stack) -> None:
async with stack.client(stack.markdown) as c:
r = await c.post(
"/chat",
json={"filename": "x.md", "agent": "a", "content": "### User:\n\nhi\n"},
headers=AUTH,
)
assert r.status_code == 400
assert "does not serve kind 'deep'" in r.json()["detail"]
async def test_spawn_tool_reports_bad_pair(stack: Stack) -> None:
master = await stack.world.conversations.create(
kind="master", agent="a", origin="test"
)
spawn = next(
t
for t in _tools(stack.world.conversations, master.external_id)
if t.name == "spawn"
)
out = await spawn.handler({"kind": "deep", "agent": "a"})
assert (
out.get("is_error")
and "does not serve kind 'deep'" in out["content"][0]["text"]
)
out = await spawn.handler({"kind": "deep", "seed": "brief", "text": "go"})
assert not out.get("is_error")
conv_id = out["content"][0]["text"].split()[-1]
path = await stack.file_of(conv_id)
assert path.exists()
async def test_anthropic_turns_become_one_deep_conversation(stack: Stack) -> None:
async with stack.client(stack.anthropic) as c:
r = await c.post(
"/v1/messages",
json={"model": "a", "messages": [{"role": "user", "content": "hi"}]},
headers=AUTH,
)
assert r.status_code == 400
first = [{"role": "user", "content": "first question"}]
r = await c.post(
"/v1/messages", json={"model": "d", "messages": first}, headers=AUTH
)
assert r.status_code == 200, r.text
reply = r.json()["content"][0]["text"]
assert reply == "ok:first question"
convs = await stack.world.conversations.find(kind="deep")
assert len(convs) == 1
conv = convs[0]
assert conv.title == "first question" and conv.frontend == "anthropic"
path = await stack.file_of(conv.external_id)
text = await stack.wait_file(path, "ok:first question")
assert frontmatter.loads(text).metadata == {
"agent": "d",
"conversation_id": conv.external_id,
}
second = [
*first,
{"role": "assistant", "content": reply},
{"role": "user", "content": "second"},
]
r = await c.post(
"/v1/messages",
json={"model": "d", "messages": second, "stream": True},
headers=AUTH,
)
assert r.status_code == 200
assert "ok:second" in r.text
assert len(await stack.world.conversations.find(kind="deep")) == 1
assert len(ScriptedClient.instances) == 1
assert ScriptedClient.instances[0].prompts == ["first question", "second"]
text = await stack.wait_file(path, "ok:second")
assert text.count("### User:") == 3 and text.count("### Assistant:") == 2
bindings = await stack.world.conversations.bindings(conv)
assert [b.frontend for b in bindings if b.visible] == ["markdown", "anthropic"]
async with stack.world.db.session() as session:
stored = await load_messages(session, conversation_id=conv.id)
assert [m["role"] for m in stored] == ["user", "assistant", "user", "assistant"]
assert stored[-1]["content"] == [{"type": "text", "text": "ok:second"}]
r = await c.post(
"/v1/messages",
json={
"model": "a",
"messages": [
*second,
{"role": "assistant", "content": "ok:second"},
{"role": "user", "content": "x"},
],
},
headers=AUTH,
)
assert r.status_code == 400 and "runs on 'd'" in r.json()["detail"]
+64
View File
@@ -0,0 +1,64 @@
import os
import tempfile
from pathlib import Path
import pytest
from claude_agent_sdk.testing import run_session_store_conformance
from sqlalchemy import delete
from beaver_gateway.storage import Database, PostgresSessionStore, TranscriptEntry
POSTGRES_URL = os.environ.get("BEAVER_TEST_DATABASE_URL")
async def test_sqlite_conformance() -> None:
root = Path(tempfile.mkdtemp(prefix="beaver-store-"))
counter = 0
async def make_store() -> PostgresSessionStore:
nonlocal counter
counter += 1
db = Database(f"sqlite:///{root / f'{counter}.db'}")
await db.create_all()
return PostgresSessionStore(db)
await run_session_store_conformance(make_store)
@pytest.mark.skipif(POSTGRES_URL is None, reason="BEAVER_TEST_DATABASE_URL not set")
async def test_postgres_conformance() -> None:
assert POSTGRES_URL is not None
db = Database(POSTGRES_URL)
await db.create_all()
async def make_store() -> PostgresSessionStore:
async with db.session() as session:
await session.execute(delete(TranscriptEntry))
await session.commit()
return PostgresSessionStore(db)
try:
await run_session_store_conformance(make_store)
finally:
await db.dispose()
async def test_append_dedups_by_uuid_and_keeps_floats() -> None:
db = Database(f"sqlite:///{tempfile.mkdtemp(prefix='beaver-store-')}/d.db")
await db.create_all()
store = PostgresSessionStore(db)
key = {"project_key": "p", "session_id": "s"}
batch = [
{"type": "x", "uuid": "a", "n": 1.5},
{"type": "y", "n": 2},
{"type": "x", "uuid": "a", "n": 999},
]
await store.append(key, batch)
await store.append(key, batch)
loaded = await store.load(key)
assert loaded == [
{"type": "x", "uuid": "a", "n": 1.5},
{"type": "y", "n": 2},
{"type": "y", "n": 2},
]
assert isinstance(loaded[0]["n"], float)
+595
View File
@@ -0,0 +1,595 @@
import asyncio
import tempfile
from pathlib import Path
from types import SimpleNamespace
from typing import Any
import pytest
from aiogram.exceptions import TelegramBadRequest, TelegramNetworkError
from aiogram.methods import SendMessage
from aiogram.types import Update
from claude_agent_sdk import PermissionResultAllow, PermissionResultDeny
from beaver_gateway.core.registry import McpRegistry
from beaver_gateway.core.transcript import build_entries
from beaver_gateway.frontends.base import GatewayRuntime
from beaver_gateway.frontends.telegram import TelegramFrontend
from beaver_gateway.frontends.telegram.render import chunks, status_label, to_html
from beaver_gateway.storage.models import ConversationBinding, Delivery, TelegramUpdate
from sqlmodel import select
from test_conversations import ScriptedClient, StubFrontend, World
USER = 42
GENERAL = f"{USER}/901"
class FakeBot:
def __init__(self) -> None:
self.updates: list[dict[str, Any]] = []
self.sent: list[dict[str, Any]] = []
self.drafts: list[dict[str, Any]] = []
self.edits: list[dict[str, Any]] = []
self.topics: list[str] = []
self.reactions: list[tuple[int, list[Any]]] = []
self.fail_sends = 0
self.reject_html = False
self.order: list[tuple[str, int | None]] = []
self._message_id = 100
self.session = SimpleNamespace(close=self._close)
async def _close(self) -> None:
pass
async def get_me(self) -> Any:
return SimpleNamespace(username="bot", has_topics_enabled=True)
async def get_updates(self, offset=None, **_: Any) -> list[Update]:
await asyncio.sleep(0.01)
return [
Update.model_validate(u)
for u in self.updates
if offset is None or u["update_id"] >= offset
]
async def send_message(
self, chat_id, text, message_thread_id=None, parse_mode=None, **kwargs: Any
) -> Any:
if self.fail_sends:
self.fail_sends -= 1
raise TelegramNetworkError(
method=SendMessage(chat_id=0, text="x"), message="boom"
)
if self.reject_html and parse_mode == "HTML":
raise TelegramBadRequest(
method=SendMessage(chat_id=0, text="x"),
message="Bad Request: can't parse entities",
)
self._message_id += 1
self.order.append(("message", message_thread_id))
self.sent.append(
{
"chat_id": chat_id,
"thread": message_thread_id,
"text": text,
"parse_mode": parse_mode,
"markup": kwargs.get("reply_markup"),
"message_id": self._message_id,
}
)
return SimpleNamespace(message_id=self._message_id)
async def send_message_draft(self, **kwargs: Any) -> bool:
await asyncio.sleep(0.08)
self.drafts.append(kwargs)
self.order.append(("draft", kwargs.get("message_thread_id")))
return True
async def create_forum_topic(self, chat_id, name, **_: Any) -> Any:
self.topics.append(name)
return SimpleNamespace(message_thread_id=900 + len(self.topics), name=name)
async def edit_forum_topic(
self, chat_id, message_thread_id, name=None, **_: Any
) -> bool:
self.topics.append(f"edit:{message_thread_id}:{name}")
return True
async def set_message_reaction(
self, chat_id, message_id, reaction=None, **_: Any
) -> bool:
self.reactions.append((message_id, reaction or []))
return True
async def answer_callback_query(self, *_: Any, **__: Any) -> bool:
return True
async def edit_message_text(self, text, chat_id, message_id, **_: Any) -> Any:
self.edits.append({"message_id": message_id, "text": text})
return True
async def edit_message_reply_markup(
self, chat_id, message_id, reply_markup=None
) -> Any:
self.edits.append({"message_id": message_id, "markup": reply_markup})
return True
async def download(self, file_id, destination=None) -> None:
Path(destination).write_bytes(b"data")
# helpers for tests
def push(self, payload: dict[str, Any]) -> None:
payload["update_id"] = 1000 + len(self.updates)
self.updates.append(payload)
def message(self, text: str, *, thread: int | None = None, uid: int = USER) -> None:
body: dict[str, Any] = {
"message_id": 10 + len(self.updates),
"date": 1700000000,
"chat": {"id": USER, "type": "private"},
"from": {"id": uid, "is_bot": False, "first_name": "h"},
"text": text,
}
if thread is not None:
body |= {"message_thread_id": thread, "is_topic_message": True}
self.push({"message": body})
def topic_created(self, name: str, thread: int) -> None:
self.push(
{
"message": {
"message_id": 10 + len(self.updates),
"date": 1700000000,
"chat": {"id": USER, "type": "private"},
"from": {"id": USER, "is_bot": False, "first_name": "h"},
"message_thread_id": thread,
"is_topic_message": True,
"forum_topic_created": {"name": name, "icon_color": 1},
}
}
)
def callback(self, data: str, message_id: int) -> None:
self.push(
{
"callback_query": {
"id": f"cb{len(self.updates)}",
"from": {"id": USER, "is_bot": False, "first_name": "h"},
"chat_instance": "ci",
"data": data,
"message": {
"message_id": message_id,
"date": 1700000000,
"chat": {"id": USER, "type": "private"},
"text": "?",
},
}
}
)
class Stack:
def __init__(self, world: World, bot: FakeBot) -> None:
self.world = world
self.bot = bot
self.tg = TelegramFrontend(
token="t",
user_id=USER,
master_agent="a",
branch_agent="a",
draft_interval=0.05,
outbox_backoff=0.01,
)
self.tg._bot = bot # noqa: SLF001
markdown = StubFrontend("markdown", ("deep",), {"deep": "d"}, home=True)
markdown.conversations = world.conversations
frontends = [self.tg, markdown]
world.conversations._frontends = frontends # noqa: SLF001
runtime = GatewayRuntime(
agents=world.conversations._agents, # noqa: SLF001
mcps=McpRegistry([]),
backends=world.conversations._backends, # noqa: SLF001
token_store=None,
db=world.db,
conversations=world.conversations,
bus=world.bus,
pool=world.pool,
frontends=tuple(frontends),
)
self.tg.configure(runtime)
self.tasks = [
asyncio.create_task(self.tg.inbox.run()),
asyncio.create_task(self.tg.outbox.run()),
asyncio.create_task(self.tg._events()), # noqa: SLF001
]
async def close(self) -> None:
for t in self.tasks:
t.cancel()
for t in self.tasks:
with pytest.raises(BaseException):
await t
async def until(self, pred, timeout: float = 5.0, what: str = "condition") -> Any:
deadline = asyncio.get_running_loop().time() + timeout
while asyncio.get_running_loop().time() < deadline:
value = pred()
if value:
return value
await asyncio.sleep(0.02)
msg = f"{what} never happened; sent={self.bot.sent}"
raise AssertionError(msg)
def sent_with(self, needle: str) -> dict[str, Any] | None:
return next((m for m in self.bot.sent if needle in m["text"]), None)
@pytest.fixture
async def stack() -> Stack:
root = Path(tempfile.mkdtemp(prefix="beaver-tg-"))
world = await World(root).setup()
await world.conversations.start()
s = Stack(world, FakeBot())
yield s
await s.close()
await world.conversations.stop()
await world.pool.close_all()
await world.db.dispose()
async def test_general_is_master_and_reply_has_no_thread(stack: Stack) -> None:
stack.bot.message("hi")
reply = await stack.until(lambda: stack.sent_with("hi"), what="reply")
assert reply["text"].startswith("ok:[сид: clean] master")
assert reply["thread"] == 901
assert reply["parse_mode"] == "HTML"
assert stack.bot.topics == ["🦫 General"]
master = await stack.world.conversations.find_bound(
frontend="telegram", external_id=GENERAL
)
assert master is not None and master.kind == "master"
assert stack.bot.drafts and stack.bot.drafts[0]["chat_id"] == USER
await asyncio.sleep(0.2)
final = max(i for i, o in enumerate(stack.bot.order) if o[0] == "message")
assert all(o[0] != "draft" for o in stack.bot.order[final:])
last = stack.bot.drafts[-1]
assert last["text"] == reply["text"] and last["parse_mode"] == "HTML"
assert stack.bot.drafts[0]["text"] == "⏳ думаю"
rows = await stack.world.conversations.queue.recent(master.id)
assert [r.origin for r in rows] == ["telegram"]
async def test_new_topic_becomes_morning_branch_and_replies_in_thread(
stack: Stack,
) -> None:
stack.bot.topic_created("план", 7)
stack.bot.message("hello topic", thread=7)
reply = await stack.until(lambda: stack.sent_with("hello topic"), what="reply")
assert reply["thread"] == 7
assert len([m for m in stack.bot.sent if m["thread"] == 7]) == 1
branch = await stack.world.conversations.find_bound(
frontend="telegram", external_id=f"{USER}/7"
)
assert branch is not None
assert branch.kind == "branch" and branch.title == "план"
master = await stack.world.conversations.find_bound(
frontend="telegram", external_id=GENERAL
)
assert branch.parent_id == master.id
prompts = [p for c in ScriptedClient.instances for p in c.prompts]
seed = next(p for p in prompts if p.startswith("[сид: morning] branch «план»"))
assert "Хендаут не приехал." in seed
assert seed.endswith("hello topic")
assert stack.bot.drafts[-1]["message_thread_id"] == 7
async def test_first_message_without_service_message_seeds_with_text(
stack: Stack,
) -> None:
stack.bot.message("сразу текстом", thread=8)
await stack.until(lambda: stack.sent_with("сразу текстом"), what="reply")
branch = await stack.world.conversations.find_bound(
frontend="telegram", external_id=f"{USER}/8"
)
assert branch.title == "сразу текстом"
client = next(
c for c in ScriptedClient.instances if c.prompts and "сразу" in c.prompts[0]
)
assert client.prompts[0].startswith("[сид: morning]")
assert client.prompts[0].endswith("сразу текстом")
assert len(client.prompts) == 1
async def test_message_into_merged_branch_rebinds_the_topic(stack: Stack) -> None:
stack.bot.message("one", thread=9)
await stack.until(lambda: stack.sent_with("ok:"), what="first reply")
old = await stack.world.conversations.find_bound(
frontend="telegram", external_id=f"{USER}/9"
)
await stack.world.conversations.set_status(old, "merged")
stack.bot.message("two", thread=9)
await stack.until(lambda: stack.sent_with("two"), what="second reply")
new = await stack.world.conversations.find_bound(
frontend="telegram", external_id=f"{USER}/9"
)
assert new.id != old.id and new.status == "open"
async with stack.world.db.session() as session:
rows = list(
(
await session.exec(
select(ConversationBinding).where(
ConversationBinding.external_id == f"{USER}/9"
)
)
).all()
)
assert {(r.conversation_id, r.visible) for r in rows} == {
(old.id, False),
(new.id, True),
}
async def test_reply_from_another_window_is_mirrored_with_marker(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
)
await stack.world.conversations.post(master, "from panel", origin="user")
await stack.until(lambda: stack.sent_with("ok:from panel"), what="mirrored reply")
texts = [m["text"] for m in stack.bot.sent]
marker = next(i for i, t in enumerate(texts) if t.startswith("📝 из панели:"))
assert "from panel" in texts[marker]
assert texts[marker + 1] == "ok:from panel"
async def test_say_is_delivered_and_inject_turns_are_silent(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
)
before = len(stack.bot.sent)
await stack.world.conversations.inject(
master, "cron tick", urgency="urgent", origin="крон"
)
await stack.world.settle(master, 2)
await asyncio.sleep(0.2)
assert len(stack.bot.sent) == before
await stack.world.conversations.say(master, "psst")
await stack.until(lambda: stack.sent_with("psst"), what="say")
async def test_question_becomes_buttons_and_callback_answers(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": "Синий", "description": "как небо"},
{"label": "Красный", "description": ""},
],
"multiSelect": False,
}
]
}
asking = asyncio.create_task(
stack.world.conversations.ask(master.external_id, payload)
)
question = await stack.until(
lambda: stack.sent_with("Какой цвет?"), what="question"
)
assert question["markup"] is not None
labels = [b.text for row in question["markup"].inline_keyboard for b in row]
assert labels == ["Синий", "Красный"]
pending = stack.world.conversations.pending_question(master.external_id)
assert pending is not None
stack.bot.callback(f"q:{pending[0]}:0:1", question["message_id"])
assert await asyncio.wait_for(asking, 5) == "Красный"
assert stack.world.conversations.answer_text("Красный") == (
"Пользователь ответил: Красный"
)
await stack.until(
lambda: any("✅ Красный" in e.get("text", "") for e in stack.bot.edits),
what="edit",
)
row = await stack.world.conversations.get(master.external_id)
assert row.pending_question is False
async def test_question_timeout_renders_text_and_free_text_answers(
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
)
stack.world.conversations._question_timeout = 0.3 # noqa: SLF001
payload = {"questions": [{"header": "Q", "question": "Сколько?", "options": []}]}
result = await stack.world.conversations.ask(master.external_id, payload)
assert result is None
assert "не ответил" in stack.world.conversations.answer_text(None)
await stack.until(
lambda: any("время вышло" in e.get("text", "") for e in stack.bot.edits),
what="timeout edit",
)
stack.world.conversations._question_timeout = 5.0 # noqa: SLF001
asking = asyncio.create_task(
stack.world.conversations.ask(master.external_id, payload)
)
await stack.until(
lambda: stack.world.conversations.pending_question(master.external_id),
what="pending",
)
stack.bot.message("семь")
assert await asyncio.wait_for(asking, 5) == "семь"
async def test_commands_status_merge_and_new(stack: Stack) -> None:
stack.bot.message("/status")
status = await stack.until(lambda: stack.sent_with("master · open"), what="status")
assert "пул:" in status["text"]
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"
)
await stack.world.store.append(
stack.world.key(branch.session_id),
build_entries(
[{"role": "user", "content": "q"}, {"role": "assistant", "content": "a"}],
session_id=branch.session_id,
cwd=str(stack.world.root),
model="m",
),
)
stack.bot.message("/merge", thread=11)
await stack.until(lambda: stack.sent_with("слито в мастер"), what="merged")
branch = await stack.world.conversations.find_bound(
frontend="telegram", external_id=f"{USER}/11"
)
assert branch.status == "merged"
stack.bot.message("/new отчёт")
await stack.until(lambda: stack.sent_with("в новом топике"), what="new topic")
assert stack.bot.topics == ["🦫 General", "отчёт"]
child = await stack.world.conversations.find_bound(
frontend="telegram", external_id=f"{USER}/902"
)
assert child is not None and child.title == "отчёт"
await asyncio.sleep(0.2)
assert await stack.world.conversations.queue.recent(child.id) == []
assert all(m["thread"] != 902 for m in stack.bot.sent)
assert all(d["message_thread_id"] != 902 for d in stack.bot.drafts)
stack.bot.message("первое в новый топик", thread=902)
reply = await stack.until(lambda: stack.sent_with("первое в новый топик"), what="r")
assert reply["thread"] == 902
assert reply["text"].startswith("ok:[сид: morning] branch «отчёт»")
assert await stack.tg.mark_topic(child)
assert stack.bot.topics[-1] == "edit:902:✅ отчёт"
async def test_brief_branch_from_the_tool_replies_in_its_topic(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
)
child = await stack.world.conversations.spawn(
kind="branch",
seed="brief",
text="найди X",
title="X",
parent=master,
origin="mcp",
)
reply = await stack.until(lambda: stack.sent_with("найди X"), what="brief reply")
assert reply["thread"] == 902
assert not any(m["text"].startswith("📝") for m in stack.bot.sent)
assert any(d["message_thread_id"] == 902 for d in stack.bot.drafts)
assert (await stack.world.conversations.get(child.external_id)).flags == {}
async def test_master_topic_survives_rotation(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
)
stack.bot.message("into general", thread=901)
await stack.until(lambda: stack.sent_with("into general"), what="reply")
assert (await stack.world.conversations.find(kind="branch")) == []
await stack.world.conversations.set_status(master, "closed")
stack.bot.message("after rotation", thread=901)
await stack.until(lambda: stack.sent_with("after rotation"), what="reply")
fresh = await stack.world.conversations.find_bound(
frontend="telegram", external_id=GENERAL
)
assert fresh.kind == "master" and fresh.id != master.id
assert stack.bot.topics == ["🦫 General"]
assert (await stack.world.conversations.find(kind="branch")) == []
async def test_inbox_stores_first_and_replays_after_restart(stack: Stack) -> None:
stack.bot.message("hi")
await stack.until(lambda: stack.sent_with("hi"), what="reply")
async with stack.world.db.session() as session:
rows = list((await session.exec(select(TelegramUpdate))).all())
assert [r.update_id for r in rows] == [1000]
assert rows[0].processed_at is not None and rows[0].error is None
async with stack.world.db.session() as session:
session.add(
TelegramUpdate(
update_id=999,
payload={
"update_id": 999,
"message": {
"message_id": 1,
"date": 1700000000,
"chat": {"id": USER, "type": "private"},
"from": {"id": USER, "is_bot": False, "first_name": "h"},
"text": "replayed",
},
},
)
)
await session.commit()
await stack.until(
lambda: stack.sent_with("replayed"), timeout=8, what="replayed reply"
)
stack.bot.message("stranger", uid=1)
await asyncio.sleep(0.3)
assert stack.sent_with("stranger") is None
async def test_outbox_retries_and_falls_back_to_plain(stack: Stack) -> None:
stack.bot.fail_sends = 2
stack.bot.reject_html = True
stack.bot.message("**bold**")
reply = await stack.until(lambda: stack.sent_with("**bold**"), what="reply")
assert reply["parse_mode"] is None
async with stack.world.db.session() as session:
rows = list((await session.exec(select(Delivery))).all())
sent = [r for r in rows if r.status == "sent"]
assert sent and sent[0].plain and sent[0].attempts >= 3
async def test_can_use_tool_answers_through_deny(stack: Stack) -> None:
seen: list[tuple[str, dict[str, Any]]] = []
async def asker(key: str, payload: dict[str, Any]) -> str:
seen.append((key, payload))
return "Пользователь ответил: да"
backend = stack.world.backend
backend._asker = asker # noqa: SLF001
can_use_tool = backend._can_use_tool("conv-1") # noqa: SLF001
allow = await can_use_tool("Bash", {"command": "ls"}, None)
assert isinstance(allow, PermissionResultAllow)
deny = await can_use_tool("AskUserQuestion", {"questions": []}, None)
assert isinstance(deny, PermissionResultDeny)
assert deny.message == "Пользователь ответил: да"
assert seen == [("conv-1", {"questions": []})]
def test_render_helpers() -> None:
assert (
to_html("**жирно** и `code` <b>")
== "<b>жирно</b> и <code>code</code> &lt;b&gt;"
)
assert to_html("# Заголовок\n- пункт") == "<b>Заголовок</b>\n• пункт"
assert to_html("```py\nx = 1\n```") == "<pre>x = 1</pre>"
assert (
to_html("[док](https://a.b/c?x=1&y=2)")
== '<a href="https://a.b/c?x=1&amp;y=2">док</a>'
)
parts = chunks("абв\n\n" + "г" * 30 + "\n\n" + "д" * 30, limit=40)
assert parts == ["абв\n\n" + "г" * 30, "д" * 30]
assert status_label("Read", {"file_path": "/x"}) == "читаю vault"
assert status_label("mcp__firefly__list_accounts", None) == "firefly: list_accounts"
assert status_label("Foo", {"command": "ls -la"}) == "Foo ls -la"
+129
View File
@@ -0,0 +1,129 @@
from beaver_gateway.core.transcript import (
CLI_VERSION,
build_entries,
messages_from_entries,
)
HISTORY = [
{"role": "user", "content": "Write бобёр to notes.txt"},
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "hm", "signature": "sig"},
{
"type": "tool_use",
"id": "toolu_1",
"name": "Write",
"input": {"file_path": "notes.txt"},
},
],
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_1",
"content": "ok",
"is_error": False,
}
],
},
{"role": "assistant", "content": [{"type": "text", "text": "Done."}]},
{"role": "user", "content": [{"type": "text", "text": "thanks"}]},
{"role": "assistant", "content": "np"},
]
def test_round_trip() -> None:
entries = build_entries(HISTORY, session_id="sid", cwd="/vault", model="claude-x")
assert messages_from_entries(entries) == [
*HISTORY[:5],
{"role": "assistant", "content": [{"type": "text", "text": "np"}]},
]
def test_chain_and_shape() -> None:
entries = build_entries(HISTORY, session_id="sid", cwd="/vault", model="claude-x")
assert [e["type"] for e in entries] == [
"user",
"assistant",
"assistant",
"user",
"assistant",
"user",
"assistant",
]
assert entries[0]["parentUuid"] is None
for prev, cur in zip(entries, entries[1:], strict=False):
assert cur["parentUuid"] == prev["uuid"]
assert len({e["uuid"] for e in entries}) == len(entries)
user = entries[0]
assert user["promptSource"] == "sdk"
assert user["entrypoint"] == "sdk-py"
assert user["permissionMode"] == "bypassPermissions"
assert user["sessionId"] == "sid"
assert user["cwd"] == "/vault"
assert user["version"] == CLI_VERSION
thinking, tool_use = entries[1], entries[2]
assert thinking["message"]["id"] == tool_use["message"]["id"]
assert thinking["requestId"] == tool_use["requestId"]
assert tool_use["message"]["stop_reason"] == "tool_use"
assert tool_use["message"]["content"] == [
{
"type": "tool_use",
"id": "toolu_1",
"name": "Write",
"input": {"file_path": "notes.txt"},
}
]
result = entries[3]
assert result["sourceToolAssistantUUID"] == tool_use["uuid"]
assert result["promptId"] == user["promptId"]
assert result["toolUseResult"] == "ok"
assert result["message"]["content"][0]["is_error"] is False
final = entries[4]
assert final["message"]["stop_reason"] == "end_turn"
assert final["message"]["usage"]["input_tokens"] == 0
assert entries[5]["promptId"] != user["promptId"]
def test_tool_results_split_and_merged_back() -> None:
history = [
{"role": "user", "content": "go"},
{
"role": "assistant",
"content": [
{"type": "tool_use", "id": "a", "name": "Read", "input": {}},
{"type": "tool_use", "id": "b", "name": "Read", "input": {}},
],
},
{
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": "a", "content": "1"},
{
"type": "tool_result",
"tool_use_id": "b",
"content": [{"type": "text", "text": "2"}],
},
],
},
]
entries = build_entries(history, session_id="s", cwd="/", model="m")
assert [e["type"] for e in entries] == [
"user",
"assistant",
"assistant",
"user",
"user",
]
assert entries[3]["parentUuid"] == entries[1]["uuid"]
assert entries[4]["parentUuid"] == entries[2]["uuid"]
assert entries[4]["toolUseResult"] == "2"
assert messages_from_entries(entries) == history
+23
View File
@@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
+1
View File
@@ -0,0 +1 @@
engine-strict=true
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode", "bradlc.vscode-tailwindcss"]
}
+57
View File
@@ -0,0 +1,57 @@
{
"files.associations": {
"*.css": "tailwindcss"
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnPaste": true,
"editor.formatOnSave": true,
"emmet.showExpandedAbbreviation": "never",
"js/ts.tsdk.path": "node_modules/typescript/lib",
"js/ts.tsdk.promptToUseWorkspaceVersion": true,
"[css]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[graphql]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[html]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[javascriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[jsonc]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[markdown]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[mdx]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[svelte]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[vue]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[yaml]": {
"editor.defaultFormatter": "biomejs.biome"
},
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit",
"source.organizeImports.biome": "explicit"
}
}
+42
View File
@@ -0,0 +1,42 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project
npx sv create my-app
```
To recreate this project with the same configuration:
```sh
# recreate this project
bun x sv@0.17.0 create --template minimal --types ts --install bun ui
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
+55
View File
@@ -0,0 +1,55 @@
{
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
"extends": ["ultracite/biome/core", "ultracite/biome/svelte"],
"javascript": {
"globals": ["Bun"]
},
"linter": {
"rules": {
"performance": {
"noNamespaceImport": "off"
}
}
},
"overrides": [
{
"includes": ["**/*.svelte"],
"linter": {
"rules": {
"correctness": {
"noUndeclaredVariables": "off",
"noUnusedFunctionParameters": "off",
"noUnusedVariables": "off"
},
"style": {
"useFilenamingConvention": "off"
},
"a11y": {
"noSvgWithoutTitle": "warn",
"useButtonType": "warn"
}
}
}
},
{
"includes": ["src/app.d.ts"],
"linter": {
"rules": {
"style": {
"noNamespace": "off"
}
}
}
},
{
"includes": ["src/app.html"],
"linter": { "enabled": false },
"formatter": { "enabled": false }
},
{
"includes": ["src/lib/components/ui/**"],
"linter": { "enabled": false },
"assist": { "enabled": false }
}
]
}
+527
View File
@@ -0,0 +1,527 @@
{
"lockfileVersion": 2,
"configVersion": 1,
"workspaces": {
"": {
"name": "ui",
"devDependencies": {
"@biomejs/biome": "2.5.9",
"@fontsource-variable/inter": "^5.3.0",
"@internationalized/date": "^3.12.3",
"@lucide/svelte": "^1.35.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.3.0",
"@types/node": "^26.4.0",
"bits-ui": "^2.19.0",
"clsx": "^2.1.1",
"mode-watcher": "^1.1.0",
"shadcn-svelte": "^1.5.1",
"svelte": "^5.56.1",
"svelte-check": "^4.6.0",
"svelte-sonner": "^1.2.1",
"tailwind-merge": "^3.6.0",
"tailwind-variants": "^3.3.1",
"tailwindcss": "^4.3.0",
"tw-animate-css": "^1.4.0",
"typescript": "^6.0.3",
"ultracite": "7.10.7",
"vite": "^8.0.16",
},
},
},
"packages": {
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="],
"@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
"@biomejs/biome": ["@biomejs/biome@2.5.9", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.9", "@biomejs/cli-darwin-x64": "2.5.9", "@biomejs/cli-linux-arm64": "2.5.9", "@biomejs/cli-linux-arm64-musl": "2.5.9", "@biomejs/cli-linux-x64": "2.5.9", "@biomejs/cli-linux-x64-musl": "2.5.9", "@biomejs/cli-win32-arm64": "2.5.9", "@biomejs/cli-win32-x64": "2.5.9" }, "bin": { "biome": "bin/biome" } }, "sha512-KkgCvdHB4IhtpHpF564plA9jo6fDOwWGQ/3jvreLzgOtRLEDoPqr7QO9qejNA8jKwDsSkAKr77hqBHnyUbIw4g=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-am22pX2aBqznqq1eMyIj/bZ++riF3Lk6ct7cbv+gQK0csFhr+d8O0RkOi2FF2qSgFgANbqNkIZ0/PxlnW2pLFg=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-l44KWDHLDvEnD0N/XcrVs7VXb3A18xL7QS3WB0eL93wbmk529ffIG55vleGCqaunpRUjLrdnjK05Qki1dsjylg=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-ICaK+IYaVZvKbBxX2rwrPT0DdUDMnE9Vm3nQGe+mltQPmUg19pONzkPWGdY4FCsoreDETWDynvdt4ysCbF5gNQ=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-7ImVPwBLCtkmpR5esd8RHhTqW94f0JLJQum6AneYcy94jRm18TaPPm7slaigGzFhfgt3QiD1Vj52LKmBAnKizA=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.9", "", { "os": "linux", "cpu": "x64" }, "sha512-z22Q/zFYSvbIJfW1CbfZPu4X8PddS6Qd2ORbc6h+aT6EcwAxUF3m6fA4HjNvA3TU4X0dTJRwNPB165ES3PJXzg=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.9", "", { "os": "linux", "cpu": "x64" }, "sha512-RXGaD0o1/pTTguYw1aeDJh9ad6Lfrui0fI7mBderTyGr7WuUJkBIttgLkR3XJyoxOkkgfBDspaUT8wXArTqLZw=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-nHK+/HHC+D0ogAHUxomgoSTdjImb6fmNNVTKmf0tyu4eDL1DqPKIHc+i+UL8+b0RnAu8224qo8F2tCVnaT0A3w=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.9", "", { "os": "win32", "cpu": "x64" }, "sha512-Yiq0H56LjXSSw/hd9YkXgSLQfzyDJzbzU2TezozxyNw+uKWAqOtqGVvBfzKRRDiaFF5avGAhHdWKx7LtDOShUw=="],
"@clack/core": ["@clack/core@1.4.3", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ=="],
"@clack/prompts": ["@clack/prompts@1.7.0", "", { "dependencies": { "@clack/core": "1.4.3", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A=="],
"@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="],
"@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="],
"@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="],
"@fontsource-variable/inter": ["@fontsource-variable/inter@5.3.0", "", {}, "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA=="],
"@internationalized/date": ["@internationalized/date@3.12.3", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-fuLX+3ZKLsxI73y8b01EG/WjHb6gE6weCqlfawPO27kBWGMh9G1yH6Csv1uU7/cac9H2GHmOMt6CjmuQ1aia4Q=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@lucide/svelte": ["@lucide/svelte@1.35.0", "", { "peerDependencies": { "svelte": "^5" } }, "sha512-1I5WeiEFc21HOLv/GR11vuN3MqN/k/mhCEMW7nEtwSAGOcZ0C5ZgI1Y2Rvs/egfKTUiOTw+R9Ys9BJ+GwAKSWw=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@oxc-project/types": ["@oxc-project/types@0.147.0", "", {}, "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg=="],
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
"@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.6", "", { "os": "android", "cpu": "arm" }, "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.6", "", { "os": "android", "cpu": "arm64" }, "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.6", "", { "os": "linux", "cpu": "arm" }, "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.6", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.6", "", { "os": "linux", "cpu": "s390x" }, "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.6", "", { "os": "none", "cpu": "arm64" }, "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
"@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="],
"@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.13", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ=="],
"@sveltejs/adapter-static": ["@sveltejs/adapter-static@3.0.10", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew=="],
"@sveltejs/kit": ["@sveltejs/kit@2.70.3", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.9", "@types/cookie": "^0.6.0", "acorn": "^8.16.0", "cookie": "^0.6.0", "devalue": "^5.8.1", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3 || ^6.0.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-UDvEYuZqAMbfB/oXIoqKvbKcb7YczK5zYrzmsGV1zRJk03jntwp8dXiYoIJotxAndsKvcPFtx9H1GRSKFdSHgg=="],
"@sveltejs/load-config": ["@sveltejs/load-config@0.2.3", "", {}, "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ=="],
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@7.3.0", "", { "dependencies": { "deepmerge": "^4.3.1", "magic-string": "^1.0.0", "obug": "^2.1.0", "vitefu": "^1.1.2" }, "peerDependencies": { "svelte": "^5.46.4", "vite": "^8.0.0-beta.7 || ^8.0.0" } }, "sha512-QbRoJyD92e9R0ufeQIWRHrCC0ObcqSv/aBDdrQMoU+sypav3cDx5wytdQ6GLdXjEMO6xjrXGzfkUygng8JMv0A=="],
"@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="],
"@tailwindcss/forms": ["@tailwindcss/forms@0.5.11", "", { "dependencies": { "mini-svg-data-uri": "^1.2.3" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" } }, "sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA=="],
"@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.3", "", { "os": "android", "cpu": "arm64" }, "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.3", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw=="],
"@tailwindcss/typography": ["@tailwindcss/typography@0.5.20", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || >=4.0.0 || insiders" } }, "sha512-hwbzQuNUfcPvbegQFatVPl/MY/tcM9KLl963hQ5laJKPh81TEZ1+dNG9PirGvcaDBkp+BCshExAyKVPW91dozw=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="],
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
"@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=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
"ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="],
"ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="],
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
"bits-ui": ["bits-ui@2.19.0", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/dom": "^1.7.1", "esm-env": "^1.1.2", "runed": "^0.35.1", "svelte-toolbelt": "^0.10.6", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-dHDm5Jw2NZJgLRvHAwPeuR4BnZij+AqTIq33OqVfo+qiMXontoC/yIvGT5TZaviv7OuBdJ2QUy2JPQ2yKBvF6w=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="],
"cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
"cli-truncate": ["cli-truncate@6.1.1", "", { "dependencies": { "slice-ansi": "^9.0.0", "string-width": "^8.2.0" } }, "sha512-06p9vyLahLa4zkGcgsGxU6iEkSOiuI4fhCH6Emhe2lPAcoUv73n72DnODsnHA+5wwXGnV0n9M9/qOQJSjYhFhw=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
"confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"devalue": ["devalue@5.9.2", "", {}, "sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w=="],
"empathic": ["empathic@2.0.1", "", {}, "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q=="],
"enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="],
"environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="],
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
"esrap": ["esrap@2.3.6", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-yc0OC12UjPqLoc+fe+v5GNs4TOjAigUw3sTikfC+xeBPGUw7gDRz3DtYaqEhxyMVJojcSWJw7jT0QWR+CbuE/A=="],
"execa": ["execa@10.0.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "figures": "^6.1.0", "get-stream": "^9.0.1", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.3.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "which-command": "^0.1.0", "yoctocolors": "^2.1.2" } }, "sha512-ge98qjkRK4IB7tL7Ju/6qmm5LHoH1eEMt5FNZrz3f4UIYhF28lggX20z3FaX1sgc67msLEn0N0BscOs29iuwyw=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="],
"fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="],
"fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="],
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"find-workspaces": ["find-workspaces@0.3.1", "", { "dependencies": { "fast-glob": "^3.3.2", "pkg-types": "^1.0.3", "yaml": "^2.3.4" } }, "sha512-UDkGILGJSA1LN5Aa7McxCid4sqW3/e+UYsVwyxki3dDT0F8+ym0rAfnCkEfkL0rO7M+8/mvkim4t/s3IPHmg+w=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
"get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="],
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
"is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
"is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
"jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
"log-update": ["log-update@8.0.0", "", { "dependencies": { "ansi-escapes": "^7.3.0", "cli-cursor": "^5.0.0", "slice-ansi": "^9.0.0", "string-width": "^8.2.0", "strip-ansi": "^7.2.0", "wrap-ansi": "^10.0.0" } }, "sha512-lddSgOt3bPASrylL54ZSpy8nBHns+vBVSoILlVOx+dei300pnLRN958rj/EdlVLKuWlSESU3qdnDZdAI7FXYGg=="],
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"magicast": ["magicast@0.5.4", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "source-map-js": "^1.2.1" } }, "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
"mini-svg-data-uri": ["mini-svg-data-uri@1.4.4", "", { "bin": { "mini-svg-data-uri": "cli.js" } }, "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg=="],
"mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="],
"mode-watcher": ["mode-watcher@1.1.0", "", { "dependencies": { "runed": "^0.25.0", "svelte-toolbelt": "^0.7.1" }, "peerDependencies": { "svelte": "^5.27.0" } }, "sha512-mUT9RRGPDYenk59qJauN1rhsIMKBmWA3xMF+uRwE8MW/tjhaDSCCARqkSuDTq8vr4/2KcAxIGVjACxTjdk5C3g=="],
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
"nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
"npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="],
"nypm": ["nypm@0.6.9", "", { "dependencies": { "citty": "^0.2.2", "pathe": "^2.0.3", "tinyexec": "^1.2.4" }, "bin": { "nypm": "./dist/cli.mjs" } }, "sha512-zxlE2yvSWZWmHcNdT3+5zV2lrCogeE9YOklHrR3dFjqutq5wO7GFDYLFDRXLsYnJzwvy/im9fYoxePvS0VTW0w=="],
"obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="],
"onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
"parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="],
"path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
"pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
"postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="],
"postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
"pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"resolve.exports": ["resolve.exports@2.0.3", "", {}, "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A=="],
"restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"rolldown": ["rolldown@1.2.6", "", { "dependencies": { "@oxc-project/types": "=0.147.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.6", "@rolldown/binding-android-arm64": "1.2.6", "@rolldown/binding-darwin-arm64": "1.2.6", "@rolldown/binding-darwin-x64": "1.2.6", "@rolldown/binding-freebsd-x64": "1.2.6", "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", "@rolldown/binding-linux-arm64-gnu": "1.2.6", "@rolldown/binding-linux-arm64-musl": "1.2.6", "@rolldown/binding-linux-ppc64-gnu": "1.2.6", "@rolldown/binding-linux-s390x-gnu": "1.2.6", "@rolldown/binding-linux-x64-gnu": "1.2.6", "@rolldown/binding-linux-x64-musl": "1.2.6", "@rolldown/binding-openharmony-arm64": "1.2.6", "@rolldown/binding-win32-arm64-msvc": "1.2.6", "@rolldown/binding-win32-x64-msvc": "1.2.6" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA=="],
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"runed": ["runed@0.35.1", "", { "dependencies": { "dequal": "^2.0.3", "esm-env": "^1.0.0", "lz-string": "^1.5.0" }, "peerDependencies": { "@sveltejs/kit": "^2.21.0", "svelte": "^5.7.0" }, "optionalPeers": ["@sveltejs/kit"] }, "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q=="],
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
"set-cookie-parser": ["set-cookie-parser@3.1.2", "", {}, "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw=="],
"shadcn-svelte": ["shadcn-svelte@1.5.1", "", { "dependencies": { "commander": "^14.0.0", "node-fetch-native": "^1.6.4", "postcss": "^8.5.10", "tailwind-merge": "^3.6.0" }, "peerDependencies": { "svelte": "^5.0.0" }, "bin": { "shadcn-svelte": "dist/index.mjs" } }, "sha512-tYwDrts10wW8nJf6Ysnqz3ofnYvEc0OGizkH3i0fTxIkq6l2ZybaA84HvY1Sm6nRmNpyKbAIGf6krMYMoD5e/g=="],
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
"slice-ansi": ["slice-ansi@9.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="],
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="],
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
"svelte": ["svelte@5.56.10", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-Lcxbj8I/KAbpY+VjtY4ENQBV0dDCipfGAhqb51XQZ67CIQqXgsv/8dPkbILaj4Fb6/b6JAEM/PIVbILXgDQy2g=="],
"svelte-check": ["svelte-check@4.7.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "@sveltejs/load-config": "^0.2.3", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.0.0 || ^6.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw=="],
"svelte-sonner": ["svelte-sonner@1.2.1", "", { "dependencies": { "runed": "^0.28.0" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-N00LOWb60fjKXULbpngku+7E9Krb16KpNq4/Eax5Vu7o1Dpbca55QpaLPsB+yp82JIEdFhfJuv5cVXLoe0ZtVA=="],
"svelte-toolbelt": ["svelte-toolbelt@0.10.6", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.35.1", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.30.2" } }, "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ=="],
"tabbable": ["tabbable@6.5.0", "", {}, "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA=="],
"tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="],
"tailwind-variants": ["tailwind-variants@3.3.1", "", { "peerDependencies": { "tailwind-merge": ">=3.0.0", "tailwindcss": "*" }, "optionalPeers": ["tailwind-merge", "tailwindcss"] }, "sha512-4pAvwUtM4HKBiRZftncAbpn6V9Hhwoa5Fl7O2u5zbp7Z5Cvu+/o/6+176WY3WCEES209543quG8zFIcXCsc5Jw=="],
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
"tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
"ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="],
"ultracite": ["ultracite@7.10.7", "", { "dependencies": { "@clack/prompts": "^1.5.1", "cli-truncate": "^6.1.1", "commander": "^15.0.0", "deepmerge": "^4.3.1", "empathic": "^2.0.1", "execa": "^10.0.1", "fast-glob": "^3.3.3", "find-workspaces": "^0.3.1", "jsonc-parser": "^3.3.1", "log-update": "^8.0.0", "magicast": "^0.5.4", "nypm": "^0.6.9", "resolve.exports": "^2.0.3", "string-width": "^8.2.2", "yaml": "^2.9.0", "zod": "^4.4.3" }, "peerDependencies": { "oxfmt": ">=0.1.0", "oxlint": "^1.79.0" }, "optionalPeers": ["oxfmt", "oxlint"], "bin": { "ultracite": "dist/index.js" } }, "sha512-rc+TG/zLCV+Uk1PL4XPmsq7CW8GtlaV4g7Q53Bt5o7UNGSKKNRoPY4syz1BDWwSoNBcvWKpSnfSVflsecok4EA=="],
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
"unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"vite": ["vite@8.2.2", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.26", "rolldown": "~1.2.4", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0 || ^0.5.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q=="],
"vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
"which-command": ["which-command@0.1.0", "", { "bin": { "which-command": "cli.js" } }, "sha512-XZyoF5/5hZtXitIwzrU4NKK+Wtbb9aB9CezUEw2Q0wlYK8NUYQxC1rRXgNueYLtBAJwXIb+/tFVk4dozciNJMA=="],
"wrap-ansi": ["wrap-ansi@10.0.1", "", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0" } }, "sha512-M0N4xzyzosiIok3svYlEo1sdLZts/8FPgYH/GPC3wvlmPoRvnoManGMrE54waYj3tISA8w6lsdesfVv67qSr8Q=="],
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
"yoctocolors": ["yoctocolors@2.2.0", "", {}, "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg=="],
"zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
"zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"@sveltejs/vite-plugin-svelte/magic-string": ["magic-string@1.2.3", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g=="],
"@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" }, "bundled": true }, "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"mode-watcher/runed": ["runed@0.25.0", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-7+ma4AG9FT2sWQEA0Egf6mb7PBT2vHyuHail1ie8ropfSjvZGtEAx8YTmUjv/APCsdRRxEVvArNjALk9zFSOrg=="],
"mode-watcher/svelte-toolbelt": ["svelte-toolbelt@0.7.1", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.23.2", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ=="],
"svelte-sonner/runed": ["runed@0.28.0", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ=="],
"ultracite/commander": ["commander@15.0.0", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="],
"@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
"@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
"@tailwindcss/node/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
"@tailwindcss/node/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
"@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"mode-watcher/svelte-toolbelt/runed": ["runed@0.23.4", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA=="],
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"$schema": "https://shadcn-svelte.com/schema.json",
"tailwind": {
"css": "src/routes/layout.css",
"baseColor": "neutral"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks",
"lib": "$lib"
},
"typescript": true,
"registry": "https://shadcn-svelte.com/registry",
"style": "luma",
"iconLibrary": "lucide",
"menuColor": "default-translucent",
"menuAccent": "subtle"
}
+43
View File
@@ -0,0 +1,43 @@
{
"name": "ui",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"dev": "vite dev",
"fix": "ultracite fix",
"lint": "ultracite check",
"prepare": "svelte-kit sync || echo ''",
"preview": "vite preview"
},
"devDependencies": {
"@biomejs/biome": "2.5.9",
"@fontsource-variable/inter": "^5.3.0",
"@internationalized/date": "^3.12.3",
"@lucide/svelte": "^1.35.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.3.0",
"@types/node": "^26.4.0",
"bits-ui": "^2.19.0",
"clsx": "^2.1.1",
"mode-watcher": "^1.1.0",
"shadcn-svelte": "^1.5.1",
"svelte": "^5.56.1",
"svelte-check": "^4.6.0",
"svelte-sonner": "^1.2.1",
"tailwind-merge": "^3.6.0",
"tailwind-variants": "^3.3.1",
"tailwindcss": "^4.3.0",
"tw-animate-css": "^1.4.0",
"typescript": "^6.0.3",
"ultracite": "7.10.7",
"vite": "^8.0.16"
}
}
+13
View File
@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+30
View File
@@ -0,0 +1,30 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta content="width=device-width, initial-scale=1, viewport-fit=cover" name="viewport" />
<meta content="light dark" name="color-scheme" />
<meta content="scale" name="text-scale" />
<title>Beaver</title>
%sveltekit.head%
</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).
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review,
the verdict, and DESIGN.md
-->
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+323
View File
@@ -0,0 +1,323 @@
import { readSse } from "./sse";
import type {
AgentsResponse,
AuditPage,
BusEvent,
ConversationInfo,
ConversationSummary,
EntriesPage,
HistoryMessage,
LimitsResponse,
MemoryFile,
MemoryTree,
Schedule,
SessionsResponse,
TokenRow,
UsageGroup,
UsageResponse,
} from "./types";
const TRAILING_SLASHES = /\/+$/;
export class ApiError extends Error {
status: number;
body: unknown;
constructor(status: number, message: string, body: unknown) {
super(message);
this.status = status;
this.body = body;
}
}
function messageOf(status: number, body: unknown): string {
if (body && typeof body === "object") {
const record = body as Record<string, unknown>;
for (const key of ["error", "detail"]) {
if (typeof record[key] === "string") {
return record[key] as string;
}
}
}
if (typeof body === "string" && body) {
return body;
}
return `HTTP ${status}`;
}
async function bodyOf(response: Response): Promise<unknown> {
const text = await response.text();
try {
return JSON.parse(text);
} catch {
return text;
}
}
export type Params = Record<string, string | number | boolean | undefined>;
function query(params?: Params): string {
if (!params) {
return "";
}
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== "") {
search.set(key, String(value));
}
}
const text = search.toString();
return text ? `?${text}` : "";
}
export interface ClientOptions {
onUnauthorized?: () => Promise<string | null>;
}
// One client per signed-in session: the gateway origin, a bearer, and a
// hook that refreshes the bearer once when the gateway rotated it.
export class ApiClient {
base: string;
token: string;
private readonly onUnauthorized?: () => Promise<string | null>;
constructor(base: string, token: string, options: ClientOptions = {}) {
this.base = base.replace(TRAILING_SLASHES, "");
this.token = token;
this.onUnauthorized = options.onUnauthorized;
}
url(path: string, params?: Params): string {
return `${this.base}${path}${query(params)}`;
}
private headers(extra: Record<string, string> = {}): Record<string, string> {
return { Authorization: `Bearer ${this.token}`, ...extra };
}
private async request<T>(
method: string,
path: string,
body?: unknown,
params?: Params,
retried = false
): Promise<T> {
const response = await fetch(this.url(path, params), {
body: body === undefined ? undefined : JSON.stringify(body),
headers: this.headers(
body === undefined ? {} : { "Content-Type": "application/json" }
),
method,
});
if (response.status === 401 && !retried && this.onUnauthorized) {
const token = await this.onUnauthorized();
if (token) {
this.token = token;
return this.request<T>(method, path, body, params, true);
}
}
if (!response.ok) {
const payload = await bodyOf(response);
throw new ApiError(
response.status,
messageOf(response.status, payload),
payload
);
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
}
get<T>(path: string, params?: Params): Promise<T> {
return this.request<T>("GET", path, undefined, params);
}
post<T>(path: string, body?: unknown): Promise<T> {
return this.request<T>("POST", path, body ?? {});
}
patch<T>(path: string, body?: unknown): Promise<T> {
return this.request<T>("PATCH", path, body ?? {});
}
async stream(
path: string,
onEvent: (event: BusEvent) => void,
signal: AbortSignal
): Promise<void> {
const response = await fetch(this.url(path), {
headers: this.headers({ Accept: "text/event-stream" }),
signal,
});
if (!response.ok) {
const payload = await bodyOf(response);
throw new ApiError(
response.status,
messageOf(response.status, payload),
payload
);
}
await readSse(response, onEvent, signal);
}
agents(): Promise<AgentsResponse> {
return this.get("/api/agents");
}
conversations(params?: {
kind?: string;
status?: string;
limit?: number;
}): Promise<{ conversations: ConversationSummary[] }> {
return this.get("/api/conversations", params);
}
conversation(id: string): Promise<ConversationInfo> {
return this.get(`/api/conversations/${id}`);
}
history(id: string): Promise<{ id: string; messages: HistoryMessage[] }> {
return this.get(`/api/conversations/${id}/history`);
}
entries(
id: string,
params?: { subpath?: string; offset?: number; limit?: number }
): Promise<EntriesPage> {
return this.get(`/api/conversations/${id}/entries`, params);
}
postMessage(
id: string,
text: string,
origin = "panel"
): Promise<{ id: string; item: number; status: string }> {
return this.post(`/api/conversations/${id}/messages`, { origin, text });
}
inject(
id: string,
text: string,
urgency: "normal" | "urgent",
origin = "panel"
): Promise<{ id: string; item: number; priority: string }> {
return this.post(`/api/conversations/${id}/inject`, {
origin,
text,
urgency,
});
}
answer(
id: string,
questionId: string,
answer: string
): Promise<{ id: string; question_id: string }> {
return this.post(`/api/conversations/${id}/answer`, {
answer,
question_id: questionId,
});
}
branch(
id: string,
body: { seed?: string; text?: string; title?: string; window?: number }
): Promise<ConversationInfo> {
return this.post(`/api/conversations/${id}/branch`, body);
}
merge(
id: string
): Promise<{ id: string; status: string; fork: string; text: string }> {
return this.post(`/api/conversations/${id}/merge`);
}
bind(
id: string,
frontend: string,
externalId: string,
visible: boolean
): Promise<ConversationInfo> {
return this.post(`/api/conversations/${id}/bind`, {
external_id: externalId,
frontend,
visible,
});
}
setFlags(
id: string,
flags: Record<string, unknown>
): Promise<ConversationSummary> {
return this.patch(`/api/conversations/${id}/flags`, flags);
}
update(
id: string,
body: { status?: string; title?: string }
): Promise<ConversationSummary> {
return this.patch(`/api/conversations/${id}`, body);
}
spawn(body: {
kind: string;
agent?: string;
seed?: string;
text?: string;
title?: string;
}): Promise<ConversationInfo> {
return this.post("/api/conversations", body);
}
sessions(): Promise<SessionsResponse> {
return this.get("/api/sessions");
}
schedules(): Promise<{ schedules: Schedule[] }> {
return this.get("/api/schedules");
}
usage(params: {
since?: string;
until?: string;
hours?: number;
group_by: UsageGroup;
}): Promise<UsageResponse> {
return this.get("/api/usage", params);
}
limits(): Promise<LimitsResponse> {
return this.get("/api/limits");
}
memory(): Promise<MemoryTree> {
return this.get("/api/memory");
}
memoryFile(path: string): Promise<MemoryFile> {
return this.get("/api/memory/file", { path });
}
tokens(includeRevoked = false): Promise<{ tokens: TokenRow[] }> {
return this.get("/api/tokens", {
include_revoked: includeRevoked ? 1 : undefined,
});
}
createToken(
name: string,
scope: string
): Promise<{ token: TokenRow; plaintext: string }> {
return this.post("/api/tokens", { name, scope });
}
revokeToken(id: number): Promise<{ id: number; revoked: boolean }> {
return this.post(`/api/tokens/${id}/revoke`);
}
audit(params?: { before?: number; limit?: number }): Promise<AuditPage> {
return this.get("/api/audit", params);
}
}
+140
View File
@@ -0,0 +1,140 @@
import { type ApiClient, ApiError } from "./client";
import type { BusEvent } from "./types";
export type LiveState = "off" | "connecting" | "open" | "retrying" | "failed";
const BACKOFF_MIN_MS = 1000;
const BACKOFF_MAX_MS = 30_000;
const JITTER_MS = 250;
const FATAL = new Set([401, 403, 404]);
function sleep(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve) => {
if (signal.aborted) {
resolve();
return;
}
const done = () => {
clearTimeout(timer);
signal.removeEventListener("abort", done);
resolve();
};
const timer = setTimeout(done, ms);
signal.addEventListener("abort", done, { once: true });
});
}
function describe(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export interface LiveOptions {
onEvent: (event: BusEvent) => void;
// Runs before every (re)connect; the snapshot that seeds state, so
// events that follow land on the truth and not on a stale tree.
prepare?: () => Promise<void>;
}
// SSE subscription with reconnect and backoff. ``state`` is reactive so a
// header can show "open" / "retrying …" without polling.
export class LiveStream {
state = $state<LiveState>("off");
detail = $state<string | null>(null);
private controller: AbortController | null = null;
private readonly client: () => ApiClient | null;
private readonly path: string;
private readonly options: LiveOptions;
constructor(
client: () => ApiClient | null,
path: string,
options: LiveOptions
) {
this.client = client;
this.path = path;
this.options = options;
}
get running(): boolean {
return this.controller !== null;
}
start(): void {
if (this.controller) {
return;
}
const controller = new AbortController();
this.controller = controller;
this.loop(controller.signal).catch((error: unknown) => {
this.state = "failed";
this.detail = describe(error);
});
}
stop(): void {
this.controller?.abort();
this.controller = null;
this.state = "off";
}
private async loop(signal: AbortSignal): Promise<void> {
let delay = BACKOFF_MIN_MS;
while (!signal.aborted) {
this.state = delay === BACKOFF_MIN_MS ? "connecting" : "retrying";
// biome-ignore lint/performance/noAwaitInLoops: one connection at a time, by design
const outcome = await this.connectOnce(signal, () => {
delay = BACKOFF_MIN_MS;
});
if (signal.aborted || outcome === "fatal") {
return;
}
await sleep(delay + Math.random() * JITTER_MS, signal);
delay = Math.min(delay * 2, BACKOFF_MAX_MS);
}
}
private async connectOnce(
signal: AbortSignal,
onOpen: () => void
): Promise<"retry" | "fatal"> {
try {
const client = this.client();
if (!client) {
throw new ApiError(401, "not signed in", null);
}
await this.options.prepare?.();
if (signal.aborted) {
return "fatal";
}
await client.stream(
this.path,
(event) => {
if (event.type === "hello") {
onOpen();
this.state = "open";
this.detail = null;
return;
}
this.options.onEvent(event);
},
signal
);
this.state = "retrying";
this.detail = "stream closed";
return "retry";
} catch (error) {
if (signal.aborted) {
return "fatal";
}
if (error instanceof ApiError && FATAL.has(error.status)) {
this.state = "failed";
this.detail = `${error.status}: ${error.message}`;
this.controller = null;
return "fatal";
}
this.state = "retrying";
this.detail = describe(error);
return "retry";
}
}
}
+66
View File
@@ -0,0 +1,66 @@
import type { BusEvent } from "./types";
const FRAME_BREAK = /\r?\n\r?\n/;
const LINE_BREAK = /\r?\n/;
function parseFrame(frame: string): BusEvent | null {
const data: string[] = [];
for (const line of frame.split(LINE_BREAK)) {
if (line.startsWith("data:")) {
data.push(line.slice(5).trimStart());
}
}
if (data.length === 0) {
return null;
}
try {
const parsed: unknown = JSON.parse(data.join("\n"));
return parsed && typeof parsed === "object" ? (parsed as BusEvent) : null;
} catch {
return null;
}
}
function drain(buffer: string, onEvent: (event: BusEvent) => void): string {
let rest = buffer;
let index = rest.search(FRAME_BREAK);
while (index >= 0) {
const event = parseFrame(rest.slice(0, index));
rest = rest.slice(index).replace(FRAME_BREAK, "");
if (event) {
onEvent(event);
}
index = rest.search(FRAME_BREAK);
}
return rest;
}
export async function readSse(
response: Response,
onEvent: (event: BusEvent) => void,
signal?: AbortSignal
): Promise<void> {
const { body } = response;
if (!body) {
throw new Error("SSE response has no body");
}
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = "";
const abort = () => {
reader.cancel().catch(() => undefined);
};
signal?.addEventListener("abort", abort, { once: true });
try {
for (;;) {
// biome-ignore lint/performance/noAwaitInLoops: a stream is read chunk by chunk
const { value, done } = await reader.read();
if (done) {
break;
}
buffer = drain(buffer + decoder.decode(value, { stream: true }), onEvent);
}
} finally {
signal?.removeEventListener("abort", abort);
}
}
+278
View File
@@ -0,0 +1,278 @@
export type Kind = "master" | "branch" | "deep" | "job" | "fork";
export type ConversationStatus = "open" | "merged" | "closed" | "archived";
export interface Binding {
external_id: string;
frontend: string;
visible: boolean;
}
export interface ToolSnapshot {
content: string | null;
ended_at: string | null;
input: unknown;
is_error: boolean | null;
name: string;
parent_tool_use_id: string | null;
started_at: string;
tool_use_id: string;
}
export interface TurnSnapshot {
id: string;
origin: string | null;
started_at: string | null;
text: string | null;
tools: ToolSnapshot[];
}
export interface QuestionOption {
description?: string;
label: string;
}
export interface Question {
header?: string;
multiSelect?: boolean;
options?: QuestionOption[];
question: string;
}
export interface PendingQuestion {
id: string;
questions: Question[];
}
export interface QueueItem {
created_at: string;
id: number;
origin: string;
priority: "urgent" | "user" | "normal";
status: "queued" | "running" | "done" | "failed" | "interrupted";
text: string;
}
export interface ConversationSummary {
agent: string;
created_at: string | null;
flags: Record<string, unknown>;
id: string;
kind: Kind;
last_activity_at: string | null;
last_user_activity_at: string | null;
origin: string;
parent_row: number | null;
pending_question: boolean;
running_turn: string | null;
session_id: string | null;
status: ConversationStatus;
title: string | null;
}
export interface ConversationInfo extends ConversationSummary {
bindings: Binding[];
busy: boolean;
live: boolean;
parent: string | null;
question: PendingQuestion | null;
queue: QueueItem[];
turn: TurnSnapshot | null;
}
export interface BusEvent {
conversation_id?: string;
seq: number;
ts: string;
turn_id?: string;
type: string;
[key: string]: unknown;
}
export interface ContentBlock {
content?: unknown;
id?: string;
input?: unknown;
is_error?: boolean;
name?: string;
text?: string;
thinking?: string;
tool_use_id?: string;
type: string;
}
export interface HistoryMessage {
content: string | ContentBlock[];
role: "user" | "assistant";
}
export interface EntriesPage {
entries: Record<string, unknown>[];
id: string;
offset: number;
subpath: string;
subpaths: string[];
total: number;
}
export interface AgentInfo {
effort: string | null;
kinds: Kind[];
model: string;
name: string;
type: string;
}
export interface FrontendInfo {
default_agents: Record<string, string>;
kinds: Kind[];
name: string;
port: number | null;
public_base_url: string | null;
type: string;
}
export interface AgentsResponse {
agents: AgentInfo[];
frontends: FrontendInfo[];
mcps: { name: string; kind: string }[];
}
export interface SessionRow {
age_seconds: number;
agent: string;
busy: boolean;
dirty: boolean;
idle_seconds: number;
key: string;
kind: string;
pending_question: boolean;
pid: number | null;
pinned: boolean;
rss: number | null;
running_turn: string | null;
session_id: string | null;
turns: number;
}
export interface SessionsResponse {
rss: number | null;
rss_limit: number | null;
sessions: SessionRow[];
}
export interface UsageTotals {
cache_creation: number;
cache_read: number;
cost_usd: number;
input: number;
output: number;
turns: number;
web_searches: number;
}
export type UsageGroup = "day" | "agent" | "conversation" | "model";
export interface UsageRow extends UsageTotals {
agent?: string;
conversation?: string;
day?: string;
key: string;
kind?: Kind;
model?: string;
status?: ConversationStatus;
title?: string | null;
}
export interface UsageResponse {
group_by: UsageGroup;
rows: UsageRow[];
since: string;
total: UsageTotals;
until: string;
}
export type LimitStatus = "allowed" | "allowed_warning" | "rejected";
export interface LimitRecord {
agent: string | null;
id: number;
overage_resets_at: string | null;
overage_status: LimitStatus | null;
resets_at: string | null;
session_id: string | null;
status: LimitStatus;
ts: string;
utilization: number | null;
window: string;
}
export interface LimitWindow extends LimitRecord {
gateway: UsageTotals & { since: string };
}
export interface LimitsResponse {
history: LimitRecord[];
windows: LimitWindow[];
}
export interface MemoryNode {
children?: MemoryNode[];
mtime: string;
name: string;
path: string;
size?: number;
type: "dir" | "file";
}
export interface MemoryTree {
root: string;
tree: MemoryNode[];
}
export interface MemoryFile {
content: string;
mtime: string;
path: string;
size: number;
}
export interface TokenRow {
created_at: string;
id: number;
last_used_at: string | null;
name: string;
revoked_at: string | null;
scope: string;
}
export interface AuditRecord {
actor: string;
agent: string | null;
detail: Record<string, unknown> | string;
id: number;
kind: string;
ts: string;
}
export interface AuditPage {
next_before: number | null;
records: AuditRecord[];
}
export interface Schedule {
conversation_row: number;
created_at: string;
delivered_at: string | null;
execute_at: string;
id: number;
text: string;
}
export interface TurnUsage {
cache_creation?: number;
cache_read?: number;
cost_usd?: number | null;
duration_ms?: number | null;
input?: number;
output?: number;
}
+15
View File
@@ -0,0 +1,15 @@
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<title>Beaver</title>
<rect fill="#191B43" height="100" rx="20" width="100" />
<text
fill="#fff"
font-family="system-ui, sans-serif"
font-size="60"
font-weight="bold"
text-anchor="middle"
x="50"
y="72"
>
狸
</text>
</svg>

After

Width:  |  Height:  |  Size: 328 B

+75
View File
@@ -0,0 +1,75 @@
<script lang="ts">
import LogOutIcon from "@lucide/svelte/icons/log-out";
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 { cn } from "$lib/utils";
const running = $derived(gateway.running.length);
async function signOut() {
await session.logout();
await goto(`${base}/login`);
}
</script>
<aside
class="hidden w-52 shrink-0 flex-col border-r bg-sidebar text-sidebar-foreground sm:flex"
>
<a
class="flex h-12 items-center gap-2 border-b px-4 font-semibold tracking-tight"
href="{base}/"
>
<span class="size-2.5 rounded-sm bg-primary"></span>
Beaver
</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}
class={cn(
"flex h-8 items-center gap-2.5 rounded-md px-2.5 text-sm transition-colors",
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}"
>
<item.icon class="size-4 shrink-0 text-icon" />
<span class="flex-1">{item.label}</span>
{#if item.href === "/" && running > 0}
<span
class="tabular rounded-full bg-signal/12 px-1.5 font-medium text-signal text-xs"
>
{running}
</span>
{/if}
</a>
{/each}
</nav>
<div class="flex flex-col gap-2 border-t p-3">
<LiveDot detail={gateway.live.detail} state={gateway.live.state} />
<div class="flex items-center justify-between gap-2">
<span class="truncate text-muted-foreground text-xs">{session.user}</span>
<div class="flex items-center">
<ThemeToggle />
<Button
aria-label="Sign out"
onclick={signOut}
size="icon-sm"
title="Sign out"
variant="ghost"
>
<LogOutIcon class="size-4" />
</Button>
</div>
</div>
</div>
</aside>
+79
View File
@@ -0,0 +1,79 @@
<script lang="ts">
import MoreHorizontalIcon from "@lucide/svelte/icons/more-horizontal";
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 * as Sheet from "$lib/components/ui/sheet";
import { gateway } from "$lib/gateway.svelte";
import { isActive, NAV } from "$lib/nav";
import { session } from "$lib/session.svelte";
import { cn } from "$lib/utils";
let moreOpen = $state(false);
const primary = NAV.filter((item) => item.mobile);
const secondary = NAV.filter((item) => !item.mobile);
async function signOut() {
moreOpen = false;
await session.logout();
await goto(`${base}/login`);
}
</script>
<nav
aria-label="Sections"
class="flex shrink-0 items-stretch border-t bg-sidebar pb-[env(safe-area-inset-bottom)] sm:hidden"
>
{#each primary as item (item.href)}
{@const active = isActive(page.url.pathname, base, item.href)}
<a
aria-current={active ? "page" : undefined}
class={cn(
"flex h-14 flex-1 flex-col items-center justify-center gap-1 text-[11px]",
active ? "text-link" : "text-muted-foreground"
)}
href="{base}{item.href}"
>
<item.icon class="size-5" />
{item.label}
</a>
{/each}
<button
class="flex h-14 flex-1 flex-col items-center justify-center gap-1 text-[11px] text-muted-foreground"
onclick={() => {
moreOpen = true;
}}
type="button"
>
<MoreHorizontalIcon class="size-5" />
More
</button>
</nav>
<Sheet.Root bind:open={moreOpen}>
<Sheet.Content class="flex flex-col gap-1 pt-10" side="bottom">
<Sheet.Title class="px-2 pb-2">More</Sheet.Title>
{#each secondary as item (item.href)}
<a
class="flex h-11 items-center gap-3 rounded-md px-3 text-sm hover:bg-muted"
href="{base}{item.href}"
onclick={() => {
moreOpen = false;
}}
>
<item.icon class="size-4 text-icon" />
{item.label}
</a>
{/each}
<div class="mt-2 flex items-center justify-between border-t px-3 pt-3">
<LiveDot detail={gateway.live.detail} state={gateway.live.state} />
<div class="flex items-center gap-1">
<ThemeToggle />
<Button onclick={signOut} size="sm" variant="ghost">Sign out</Button>
</div>
</div>
</Sheet.Content>
</Sheet.Root>
+31
View File
@@ -0,0 +1,31 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { cn } from "$lib/utils";
let {
title,
hint = "",
class: className = "",
children,
}: {
title: string;
hint?: string;
class?: string;
children?: Snippet;
} = $props();
</script>
<div
class={cn(
"flex flex-col items-start gap-1 rounded-lg border border-dashed px-4 py-5 text-sm",
className
)}
>
<p class="font-medium">{title}</p>
{#if hint}
<p class="text-muted-foreground">{hint}</p>
{/if}
{#if children}
<div class="pt-2">{@render children()}</div>
{/if}
</div>
Loaded 100 of 230 files, more files were not shown because too many files have changed in this diff. Show more