Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
645df6f2b4 | ||
|
|
b577034c83 | ||
|
|
00618ab7ad | ||
|
|
aaaf459e6d | ||
|
|
4ad014726b | ||
|
|
8c9c6eee69 | ||
|
|
21766fe2d7 | ||
|
|
1f8dae57ac | ||
|
|
66325c2879 | ||
|
|
5a44a16ba3 | ||
|
|
c711dcab13 | ||
|
|
3995741cd5 | ||
|
|
b0f280b3ca | ||
|
|
45a5eb4a94 | ||
|
|
7ae87aeeb8 | ||
|
|
fd8cbacd7b | ||
|
|
bd2d2368e1 | ||
|
|
1d8d65b69a | ||
|
|
33ccc78fec | ||
|
|
50b7057fa4 | ||
|
|
e1f242a87a | ||
|
|
ee2dc918ee | ||
|
|
33cc769ca2 | ||
|
|
e4282b38cd |
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -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/
|
||||
@@ -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)."
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"svelte": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.svelte.dev/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
-7
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
@@ -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):
|
||||
|
||||
@@ -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,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.
|
||||
"""
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 ()
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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}
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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)."""
|
||||
@@ -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
|
||||
@@ -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 ""
|
||||
@@ -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."""
|
||||
@@ -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 %}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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 ""
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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> <b>"
|
||||
)
|
||||
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&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"
|
||||
@@ -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
|
||||
@@ -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-*
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["svelte.svelte-vscode", "bradlc.vscode-tailwindcss"]
|
||||
}
|
||||
Vendored
+57
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
@@ -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=="],
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Vendored
+13
@@ -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 {};
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
Reference in new issue
Block a user