diff --git a/.claude/skills/create-plugin/SKILL.md b/.claude/skills/create-plugin/SKILL.md index d45c5f1c..5bb83250 100644 --- a/.claude/skills/create-plugin/SKILL.md +++ b/.claude/skills/create-plugin/SKILL.md @@ -198,7 +198,7 @@ console.log('OK'); | ----------------------------------------------- | -------------------------------------------------------------------------- | | 只有 PLUGIN.md(agent runtime + builtin tools) | 只跑 schema 校验(step 4)即可 | | 有 `tools/*.js` / `handler.js` / `hooks/*.js` | + L2 单元测试(vitest,mock store) | -| 有 `input.inject` / 多 runtime / event 链 | + L3 集成测试(`createTestHarness` + `MockLLM`)或 L4 runtime case | +| 有 `input.inject` / 多 runtime / event 链 | + L3 集成测试(手搓 turn-executor + `MockLLM`)或 L4 runtime case | | 准备发布对外(社区插件) | + L4 `pnpm test:runtime` mock/live;必要时 L5 HTTP E2E,**live 不要进 CI** | 测试文件放 `plugins//tests/*.test.{js,ts}`,跑: diff --git a/.claude/skills/create-plugin/references/plugin-testing.md b/.claude/skills/create-plugin/references/plugin-testing.md index eee8b48f..9efdb7f9 100644 --- a/.claude/skills/create-plugin/references/plugin-testing.md +++ b/.claude/skills/create-plugin/references/plugin-testing.md @@ -6,7 +6,7 @@ | ------------------ | ------------------------------------------- | ------------------------------- | ---------------------------------------------------------------- | | L1 Schema | `validatePluginManifest` | 即时 | 每个 `PLUGIN.md` | | L2 单元 | Vitest + `@covel/plugin-test-utils` | <1s | 写了 `tools/*.js`、`handler.js`、`hooks/*.js` | -| L3 In-process turn | `createTestHarness` + `MockLLM` | 1-3s | agent runtime、tool loop、`input.inject`、多 runtime/event 链 | +| L3 In-process turn | 手搓 turn-executor + `MockLLM` | 1-3s | agent runtime、tool loop、`input.inject`、多 runtime/event 链 | | L4 Runtime cases | `pnpm test:runtime` (`@covel/test-runtime`) | 1-10s mock / 真实 provider 更慢 | 第三方插件、手动 runtime、后台 follower、外部 `~/.covel/plugins` | | L5 HTTP E2E | `scripts/e2e-plugin-verify.ts` | 30s+ | 发布前验证 API/SSE/store/approval 全链路 | @@ -185,81 +185,25 @@ describe("trigger", () => { --- -## L3 — `createTestHarness` + `MockLLM` +## L3 — 手搓 turn-executor + `MockLLM` -用于跑完整 runtime pipeline:发现插件 → 排序 manifests → 组装 context → 调 LLM/tool loop → commit proposals → 写 MemoryStore。 +> 旧的 `createTestHarness` 已退役(功能不足:无法注入合成 runtime,也不 commit proposal)。需要 in-process 跑完整 turn 时,用 `@covel/runtime` 公开导出手工组装;多数插件级需求优先走 L4(`pnpm test:runtime`)。 -```ts -import { describe, expect, it } from "vitest"; -import path from "node:path"; -import { - MockLLM, - createTestHarness, - expectAssetGenerated, -} from "@covel/plugin-test-utils"; - -const PLUGINS_DIR = path.resolve(import.meta.dirname, "../../../plugins"); - -describe("my-plugin integration", () => { - it("runs one turn and writes plugin-data", async () => { - const llm = new MockLLM({ - responses: [ - { - content: null, - finishReason: "tool_calls", - toolCalls: [ - { - id: "tc-1", - name: "plugin-data-set", - arguments: - '{"namespace":"notes","key":"intro","value":{"title":"Intro"}}', - }, - ], - usage: { inputTokens: 50, outputTokens: 10 }, - }, - { - content: "Done.", - finishReason: "stop", - toolCalls: [], - usage: { inputTokens: 60, outputTokens: 4 }, - }, - ], - }); +组装步骤(完整可运行范例:`packages/runtime/tests/scene-stage-integration.test.ts` 与 `packages/runtime/tests/emit-event-integration.test.ts`): - const harness = await createTestHarness({ - pluginsDir: PLUGINS_DIR, - activePlugins: ["my-plugin"], - llm, - }); - - const result = await harness.executeTurn("continue"); - const rows = await harness.store.listPluginData( - "sess-harness", - "my-plugin", - "notes", - ); - - expect(result.runtimeResults[0]?.status).toBe("success"); - expect(llm.calls.map((call) => call.toolNames ?? [])).toContainEqual( - expect.arrayContaining(["plugin-data-set"]), - ); - expect(rows[0]?.key).toBe("intro"); - }); -}); -``` +1. `discoverPlugins` / `loadPluginManifest` / `loadRuntime`(`@covel/plugin-loader`)加载真实插件 runtime;需要合成 runtime(如模拟 narrator 发 event)时手写 `RuntimeManifest` + `LLMAdapter`。 +2. `createMemoryStore()`(`@covel/store`)做后端,按需 `setPluginData` 预置数据。 +3. `createToolExecutor` + `executeTurn`(`@covel/runtime`)执行 turn,LLM 用 `MockLLM`(`responses[]` 按调用顺序消费,耗尽后回落 `defaultResponse`,多步 tool loop 优先用它)。 +4. 对每个 runtime result 调 `processRuntimeResult`(`@covel/runtime`)把 proposal commit 进 store,再断言 `store.listPluginData(...)` 等持久化结果。 常用断言: -| 目标 | 写法 | -| --------------------- | -------------------------------------------------------------------- | -| prompt 里包含注入内容 | `llm.calls[n].messages` | -| 工具顺序 | `result.runtimeResults[0].toolCalls.map((c) => c.name)` | -| plugin_data 写入 | `harness.store.listPluginData("sess-harness", pluginId, namespace)` | -| asset.generate 输出 | `expectAssetGenerated(result, { modality: "image" })` | -| 手动触发 | `harness.executeTurn("", { manualTrigger: { runtimeId, payload } })` | -| userSettings | `harness.executeTurn("", { userSettings: { [pluginId]: {...} } })` | - -`MockLLM` 的 `responses[]` 会按调用顺序消费,耗尽后回落到 `defaultResponse`。多步 tool loop 优先用 `responses[]`,不用手写 subclass。 +| 目标 | 写法 | +| --------------------- | ------------------------------------------------------- | +| prompt 里包含注入内容 | `llm.calls[n].messages` | +| 工具顺序 | `result.runtimeResults[0].toolCalls.map((c) => c.name)` | +| plugin_data 写入 | `store.listPluginData(sessionId, pluginId, namespace)` | +| asset.generate 输出 | `expectAssetGenerated(result, { modality: "image" })` | --- diff --git a/.gitignore b/.gitignore index 80c3808f..936c3cf4 100644 --- a/.gitignore +++ b/.gitignore @@ -116,7 +116,6 @@ tmp/ *.png !apps/web/public/icon.png !apps/desktop/resources/icon.png -!apps/desktop-tauri/src-tauri/icons/**/*.png # World character portraits and scene backdrops are tracked assets (generated once, reused). !worlds/**/media/portraits/*.png !worlds/**/media/scenes/*.png diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b02bd8e6..85fdf7f1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,7 +22,7 @@ repos: - id: destroyed-symlinks - id: check-executables-have-shebangs - id: check-shebang-scripts-are-executable - exclude: ^(\.claude/|apps/desktop-tauri/src-tauri/src/main\.rs$) + exclude: ^\.claude/ - id: check-added-large-files # 3 MB: covers tracked world character portraits (~1.7–2.4 MB PNGs) # while still catching accidental large blobs (videos, dumps, bundles). diff --git a/.prettierignore b/.prettierignore index 8d6a56de..2b8c92e1 100644 --- a/.prettierignore +++ b/.prettierignore @@ -13,9 +13,7 @@ output/ audits/ debugs/ -apps/desktop-tauri/src-tauri/resources/ apps/desktop/staging/ -apps/desktop-tauri/src-tauri/target/ apps/web/src/routeTree.gen.ts pnpm-lock.yaml diff --git a/CLAUDE.md b/CLAUDE.md index 4c0dc04e..9770bd5e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -178,7 +178,7 @@ Detailed field reference, per-plugin table, and trigger semantics: [docs/referen ### Plugin UI (declarative, json-render) -All panels/blocks render through [json-render](https://github.com/vercel-labs/json-render) with a framework-defined ~25-component catalog. Plugins declare `ui: { right, message, left }` in PLUGIN.md, pointing at JSON specs under `ui/`. `GET /api/ui-specs` aggregates them; frontend discovers panels at boot. `plugin-data.changed` SSE events drive re-renders. Three-tier resolution: custom React (`.tsx`) → json-render spec (`.json`) → raw JSON fallback. Details in [docs/reference/ui-panels.md](./docs/reference/ui-panels.md). +All panels/blocks render through [json-render](https://github.com/vercel-labs/json-render) with a framework-defined ~48-component catalog ([docs/reference/ui-components.md](./docs/reference/ui-components.md)). Plugins declare `ui: { right, message, left }` in PLUGIN.md, pointing at JSON specs under `ui/`. `GET /api/ui-specs` aggregates them; frontend discovers panels at boot. `plugin-data.changed` SSE events drive re-renders. Three-tier resolution: custom React (`.tsx`) → json-render spec (`.json`) → raw JSON fallback. Details in [docs/reference/ui-panels.md](./docs/reference/ui-panels.md). ### Model slot system @@ -186,7 +186,7 @@ All panels/blocks render through [json-render](https://github.com/vercel-labs/js - Configured via `llm.toml` `[covel.]` sections. If missing, single `story` → DeepSeek fallback boots the app. - **Tag-aware fallback**: an unconfigured slot falls back to the first slot with the same tag (`text`/`image`/`embedding`/`speech`/`transcription`). Cross-tag fallback is forbidden (an image request never silently routes to text). - Supports OpenAI, Anthropic, DeepSeek, Qwen (Aliyun DashScope). -- **Model capabilities** (multimodal, features, token limits, pricing) auto-detected via: frontend localStorage override → `llm.toml` manual → `known-models.ts` (~60 common) → LiteLLM DB (2597 models, `pnpm --filter @covel/ai-provider update-model-db`) → protocol defaults. Directional modality: `input: InputModality[]` = accepts, `output: OutputModality[]` = produces. +- **Model capabilities** (multimodal, features, token limits, pricing) auto-detected via: frontend localStorage override → `llm.toml` manual → `known-models.ts` (~60 common) → LiteLLM DB (2967 models, `pnpm --filter @covel/ai-provider update-model-db`) → protocol defaults. Directional modality: `input: InputModality[]` = accepts, `output: OutputModality[]` = produces. - **Media generation (image / TTS / STT)**: `ctx.images.generate()` and `ctx.speech.generate()`/`.transcribe()` (function-runtime plugins, preferred) route through pluggable per-modality wire registries — builtin `openai-images` (default) + `dashscope-wan` for image, `openai-speech` / `openai-transcription` for speech — selectable per-slot via `llm.toml` `providerRequestMetadata.imageWire|speechWire|transcriptionWire`. Both `generate` paths dedupe on promptHash and persist to MediaStore. Plugins register vendor wires via the PLUGIN.md `wires` frontmatter field (ids namespaced `/`, trust-gated loading in `bootstrap/plugin-wires.ts`); bundled code may call `register{Image,Speech,Transcription}Wire()` directly. See [docs/reference/slots.md](./docs/reference/slots.md), [docs/reference/media-store.md](./docs/reference/media-store.md) and [docs/guide/plugin-authoring-advanced.md](./docs/guide/plugin-authoring-advanced.md#6-函数-runtime手动触发与后台执行). ## Critical Conventions (Read These) @@ -275,27 +275,27 @@ Each SQL backend keeps a thin public factory plus focused method modules: - `*-store-mappers.ts` / `*-store-values.ts` — row conversion and JSON helpers. - `*-data-crud.ts`, `*-runtime-records.ts`, `*-session-*`, `*-snapshot*`, `*-state*`, `*-world*` — focused persistence surfaces. -22 tables via Drizzle; full list and transactions contract in [docs/reference/transactions.md](./docs/reference/transactions.md). +27 tables via Drizzle; authoritative list in `packages/store/src/{sqlite,postgres}/schema.ts`, transactions contract in [docs/reference/transactions.md](./docs/reference/transactions.md). - **`sessions.runtime_model_overrides`** — JSONB map of `runtimeId → slot name`, snapshotted into `TurnInput` each turn and consulted by `runtime-slot-resolver` before `manifest.model` / gateway default. Keys still flow via `X-Provider-Keys` + localStorage. - **JSONB writes**: use `sql.json(value as JSONValue)` — **never** `JSON.stringify()` (double-serialisation bug). ## Server Bootstrap -`bootstrapApi()` in `apps/server/src/routes/api/bootstrap.ts` wires a fully composed Hono app (plugin discovery + registries + middleware injection); `app.ts` is a thin composition root (~80 lines). All endpoints under `/api/` prefix. Full endpoint reference: [docs/reference/api.md](./docs/reference/api.md). +`bootstrapApi()` in `apps/server/src/routes/api/bootstrap.ts` wires a fully composed Hono app (plugin discovery + registries + middleware injection); `app.ts` is the composition root (~420 lines: env/key loading, store + AI stack setup, middleware, then delegates to `bootstrapApi()`). All endpoints under `/api/` prefix. Full endpoint reference: [docs/reference/api.md](./docs/reference/api.md). ## Testing Conventions - **vitest** is the single runner (`vitest run` for CI, `vitest` for watch). No Node `node:test`. - **Contract tests** (`store-contract.ts` + `contract/suites/`): every `DataStore` backend must pass the shared suite. Required for any new backend. -- **Plugin tests**: use `@covel/plugin-test-utils` — `MockLLM`, `createTestHarness`, `makeTurnInput`, `makeTriggerContext`, `makeRuntimeResult`. +- **Plugin tests**: use `@covel/plugin-test-utils` — `MockLLM`, `makeManualFunctionContext`, `expectAssetGenerated`, `makeTurnInput`, `makeTriggerContext`, `makeRuntimeResult`. - **IDB tests**: `fake-indexeddb` polyfill. **PG tests**: real local DB (`pnpm db:up`). - **E2E harness**: `scripts/e2e-plugin-verify.ts` is the API-driven, plugin-level, 7-phase harness (artefacts under `debugs/e2e-logs/`) — see [docs/guide/e2e-plugin-verify.md](./docs/guide/e2e-plugin-verify.md). - Coverage via `@vitest/coverage-v8` on all packages (`--coverage` flag). ## Security & Operations -- **SSRF guard**: `validateBaseUrl()` in `ai-provider/adapters/http.ts` is **open by default** — any public https host is allowed. Blocks: RFC1918 / link-local IPs (`10.x` / `172.16-31.x` / `192.168.x` / `169.254.x` / `fc00::` / `fe80::`), cloud metadata hostnames (`metadata.google.internal`, `metadata.internal`), non-https on remote hosts, non-http(s) protocols. Loopback (`localhost` / `127.0.0.1` / `::1`) bypasses the https requirement for Ollama-style local dev. Additionally, core provider requests (`postJson` / `getJson` / `postFormData`) and the plugin `ctx.http` helper resolve DNS through a pinning dispatcher (`adapters/http/dns-safety.ts`): every A/AAAA answer must be publicly routable (loopback hostnames must resolve to loopback), closing the string-check-to-connect DNS-rebinding gap. `COVEL_ALLOWED_LLM_HOSTS` appears in env-registry as `status: 'documented'` but **is not read** by the guard — third-party plugin authors targeting custom provider hosts do not need any env shim. +- **SSRF guard**: `validateBaseUrl()` in `ai-provider/adapters/http.ts` is **open by default** — any public https host is allowed. Blocks: RFC1918 / link-local IPs (`10.x` / `172.16-31.x` / `192.168.x` / `169.254.x` / `fc00::` / `fe80::`), cloud metadata hostnames (`metadata.google.internal`, `metadata.internal`), non-https on remote hosts, non-http(s) protocols. Loopback (`localhost` / `127.0.0.1` / `::1`) bypasses the https requirement for Ollama-style local dev. Additionally, core provider requests (`postJson` / `getJson` / `postFormData`) and the plugin `ctx.http` helper resolve DNS through a pinning dispatcher (`adapters/http/dns-safety.ts`): every A/AAAA answer must be publicly routable (loopback hostnames must resolve to loopback), closing the string-check-to-connect DNS-rebinding gap. **Self-tier exemption (core provider path only)**: on the `self` tier (desktop/self-deploy default — already loopback-bound with owner/operator tokens as no-ops), the core provider path (the user's own configured LLM `baseUrl`) accepts any resolver answer for a hostname (the socket is still pinned to it). Single-user local machines run TUN proxies (Clash/mihomo/sing-box/Surge map every domain into a private/benchmark range and route by SNI) and LAN endpoints (Ollama at `192.168.x.x`) that the public-only rule wrongly rejected. The exemption is NOT granted to the plugin `ctx.http` path (third-party plugin code stays strict — no probing the local network even on a desktop install), to IP-literal URLs (url-safety's string check still blocks private literals), or to hosted tiers (`demo`/`commercial` may run inside a cloud network where private answers reach real internal services). There is **no host allowlist env** — the guard is open by design; third-party plugin authors targeting custom provider hosts do not need any env shim (the never-read `COVEL_ALLOWED_LLM_HOSTS` registry entry was removed). - **Env-key origin binding (S-01)**: server-env / platform API keys flow to the gateway as `envApiKeys`, separate from request-supplied `X-Provider-Keys` (`apiKeys`). The provider registry only attaches an env key when the resolved target's baseUrl origin matches trusted config (llm.toml / registered provider defaults) — a request-scoped custom preset (`X-Slot-Config` overlay) that redirects a provider to another origin gets no env key and no trusted default headers; it must supply its own key. - **Hosted auth (S-02/C-01/C-02)**: `demo` / `commercial` tiers enforce a per-session **owner token** (minted at session create, hash-persisted in `SessionRecord.metadata`, returned once) on every session-scoped route, and an **operator token** (`COVEL_DESKTOP_REST_TOKEN`, also a master key that passes any owner check) on global/admin routes (session create/list, world writes, AI/model, community server-code activation). `validateSecurityPosture` fails boot on a hosted tier missing the operator token / media secret / CORS origin. The server binds `127.0.0.1` by default (`COVEL_BIND_HOST` opts into `0.0.0.0`). `self` / desktop / dev are a strict no-op (single-user local play unchanged). Community server code (`entry` / handler / hook / wire / runtime JS) is import-gated behind two-phase approval (a `covel:plugin-server-code` grant, then the action grant). Full model: [docs/reference/api.md](./docs/reference/api.md) 鉴权 section. - **Signed media URLs**: `middleware/media-token.ts` signs `MediaRef` URLs with `COVEL_MEDIA_TOKEN_SECRET`. Desktop shells must provision it or generated images/portraits fail to load; web uses an ephemeral per-boot secret. @@ -305,26 +305,14 @@ Each SQL backend keeps a thin public factory plus focused method modules: - **Error sanitising**: the `app.onError` handler (`routes/api/bootstrap.ts` + `app.ts`) returns `"Internal server error"` in prod (stacks/paths only to `console.error`); dev returns `err.message`. - **Debug artefacts**: always write under `debugs/` (never repo root) — gitignored. -### Dev-mode LLM replay cache - -Only active when `COVEL_LLM_REPLAY` is set. Zero overhead / zero behaviour change when unset. - -| Mode | Behaviour | -| -------- | --------------------------------------------------------- | -| `auto` | Hit = replay, miss = call provider + record (dev default) | -| `record` | Always call provider, overwrite cache | -| `replay` | Read-only; miss throws (for breakpoint reproduction) | - -Cache dir: `COVEL_LLM_REPLAY_DIR` (default `debugs/llm-cache/`). Key = `sha256(method + url + canonicalJson(body))`. `authorization` / `api_key` are redacted in hash input and on disk. Streaming is T-eed via `TransformStream` (10 MB buffer cap, skip over). - ## Observability Trace chain: `traceId → runId → branchId → turnId → runtimeId → pluginId`. - **Runtime trace** (DB `trace_events`): structured turn hierarchy — LLM delta messages, tool calls, proposals, hooks, provider binding, context fragments. Delta recording avoids duplicating prompt history. - **Infrastructure log** (console, `[component]`-prefixed): startup, plugin loading, DB, SSE connections. -- **Consumption**: `/api/traces/*`, `TraceExporter` interface (e.g. Langfuse), JSON export for players. -- **Frontend `/debug`**: Session Timeline · Runtime Inspector · Prompt Viewer (full reconstruction + diff) · Data Explorer · Cost (token-usage aggregation from `llm.responded` / `gateway.responded` trace usage). +- **Consumption**: `/api/traces/*` endpoints, JSON export for players. +- **Frontend `/debug`**: Session Timeline · Runtime Inspector · Prompt Viewer (full reconstruction + diff) · Data Explorer · Cost (token-usage aggregation from `llm.responded` / `gateway.responded` trace usage, with per-model USD estimation via model-db pricing — model recovered by pairing each `llm.responded` with the preceding `llm.calling`). ## Deployment Tiers diff --git a/README.md b/README.md index 1286f175..1ff5aa7e 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ **English** · [简体中文](./README.zh-CN.md) -[![Version](https://img.shields.io/badge/version-v0.0.14-8b5cf6)](https://github.com/ackness/covel/releases/tag/v0.0.14) +[![Version](https://img.shields.io/badge/version-v0.0.15-8b5cf6)](https://github.com/ackness/covel/releases/tag/v0.0.15) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE) [![Stage](https://img.shields.io/badge/stage-early--access-orange)]() [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/ackness/covel) @@ -13,7 +13,7 @@ Covel is an AI RPG where the world keeps running between your turns: NPCs track how they feel about you, lore accumulates as you play, and memory carries the thread across the session. Every mechanic behind that is an **autonomous agent shipped as a plugin** — disable one, swap one, or write your own. -> **Current public release: v0.0.14**, early access — APIs, data formats, and plugin frontmatter may change between versions. Prebuilt binaries target macOS Apple Silicon and Windows x64; other platforms build from source. +> **Current public release: v0.0.15**, early access — APIs, data formats, and plugin frontmatter may change between versions. Prebuilt binaries target macOS Apple Silicon and Windows x64; other platforms build from source. ## Highlights diff --git a/README.zh-CN.md b/README.zh-CN.md index 68543d76..6f283a0f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -4,7 +4,7 @@ [English](./README.md) · **简体中文** -[![Version](https://img.shields.io/badge/version-v0.0.14-8b5cf6)](https://github.com/ackness/covel/releases/tag/v0.0.14) +[![Version](https://img.shields.io/badge/version-v0.0.15-8b5cf6)](https://github.com/ackness/covel/releases/tag/v0.0.15) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE) [![Stage](https://img.shields.io/badge/stage-early--access-orange)]() [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/ackness/covel) @@ -13,7 +13,7 @@ Covel 是一款 AI 驱动的 RPG,回合之间世界仍在运转:NPC 记录着对你的态度、世界典籍随游玩积累、记忆贯穿整局。支撑这一切的每个机制都是一个**以插件形式分发的自主 agent** —— 禁用一个、替换一个,或者自己写一个。 -> **当前公开版本:v0.0.14**,早期阶段 —— API、数据格式、插件 frontmatter 可能随版本变化。官方预编译包面向 macOS Apple Silicon 与 Windows x64,其余平台从源码构建。 +> **当前公开版本:v0.0.15**,早期阶段 —— API、数据格式、插件 frontmatter 可能随版本变化。官方预编译包面向 macOS Apple Silicon 与 Windows x64,其余平台从源码构建。 ## 亮点 diff --git a/apps/server/src/api-error.ts b/apps/server/src/api-error.ts index 52cb4256..f0a5fee3 100644 --- a/apps/server/src/api-error.ts +++ b/apps/server/src/api-error.ts @@ -1,4 +1,4 @@ -import type { ErrorHandler } from "hono"; +import type { Context, ErrorHandler } from "hono"; /** * Standard API error envelope. @@ -40,6 +40,27 @@ export function errorBody( return body; } +/** + * Parse a JSON request body, or return a 400 envelope Response so a malformed + * body converges on the standard error shape instead of throwing into the + * global 500 handler. Callers do + * `const parsed = await readJsonBody(c); if (parsed instanceof Response) return parsed;` + * then read `parsed.body`. Pass a type param to preserve the shape the route + * expects (`readJsonBody>(c)`). + */ +export async function readJsonBody( + c: Context, +): Promise<{ body: T } | Response> { + try { + return { body: (await c.req.json()) as T }; + } catch { + return c.json( + errorBody("Invalid JSON body", { code: "invalid_json_body" }), + 400, + ); + } +} + /** * Redact sensitive query params (signed media `token`, SSE `session_token` * owner auth) from a URL before it hits logs — the raw string is kept diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 44ea1730..f039fadd 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -239,6 +239,25 @@ const pluginUtils = { }; const preferredMemorySlot = resolvePreferredMemorySlot(ai.slotRegistry); +// Live budget view of the main narrative slot ("default" when configured, +// else the isDefault/first preset). Resolved per call — never cached — so +// llm.toml hot-reloads propagate to compaction thresholds and prompt budget. +// ponytail: ignores per-session runtime_model_overrides and X-Slot-Config +// overlays; wire the session's actual narrative slot if that ever matters. +const resolveNarrativeBudget = () => { + const presetId = ai.slotRegistry.resolveSlot("default"); + const capability = ai.presetRegistry.resolvePreset(presetId)?.capability; + if (!capability) return undefined; + return { + ...(capability.contextWindow !== undefined + ? { contextWindow: capability.contextWindow } + : {}), + ...(capability.maxOutputTokens !== undefined + ? { maxOutputTokens: capability.maxOutputTokens } + : {}), + }; +}; + // ── Bootstrap API ─────────────────────────────────────────────── // Bundled plugins ship inside the repo / packaged app. The desktop shell // can additionally mount a user plugins directory via COVEL_USER_PLUGINS_DIR @@ -293,6 +312,7 @@ const api = await bootstrapApi({ preferredMemorySlot, perRequestMiddleware: [perRequestLlm], sessionLock, + resolveNarrativeBudget, }); const seededWorldIds = new Set(); diff --git a/apps/server/src/env.d.ts b/apps/server/src/env.d.ts index a3db970a..97fed8eb 100644 --- a/apps/server/src/env.d.ts +++ b/apps/server/src/env.d.ts @@ -17,7 +17,7 @@ import type { } from "@covel/runtime"; import type { RpcApprovalGate } from "@covel/approval"; import type { RuntimeManifest } from "@covel/shared"; -import type { CompactorRunner } from "@covel/context"; +import type { BudgetOptions, CompactorRunner } from "@covel/context"; import type { SessionLock } from "./lib/session-lock.js"; import type { EventDirectory } from "./routes/api/bootstrap/event-directory.js"; @@ -26,10 +26,6 @@ type LoadRuntimeFn = ( locale?: string, sessionId?: string, ) => Promise; -type GetConfigFn = ( - pluginId: string, - runtimeId: string, -) => Readonly>; type ResolveModelFn = ( manifest: RuntimeManifest, apiOverride?: string, @@ -75,9 +71,14 @@ declare module "hono" { pluginUtils?: PluginRuntimeUtils; loadRuntimeFn: LoadRuntimeFn; toolExecutor: ToolExecutor; - getConfigFn: GetConfigFn; resolveModel: ResolveModelFn; compactorRunner: CompactorRunner; + /** + * Prompt-assembly hard-prune budget (`applyBudget`), derived from the + * narrative slot's model capability. Optional so hand-built test DI + * need not wire it — turn routes spread it in conditionally. + */ + turnContextBudget?: Omit; rpcExecutor: RpcExecutor; rpcRegistry: PluginRpcRegistry; rpcApprovalGate: RpcApprovalGate; @@ -88,8 +89,9 @@ declare module "hono" { * - everything else → `createInProcessSessionLock()` — `Map`-based * chain, correct for single-process deployments. * - * Route handlers MUST use this instead of the legacy `withSessionLock` - * import so PG deployments automatically get cross-pod safety. + * Route handlers MUST use this instead of a hand-rolled lock; the + * historical `withSessionLock` import was replaced by this DI-injected + * `sessionLock` so PG deployments automatically get cross-pod safety. */ sessionLock: SessionLock; /** diff --git a/apps/server/src/graceful-shutdown.ts b/apps/server/src/graceful-shutdown.ts index 9dc7d4b7..5a57834b 100644 --- a/apps/server/src/graceful-shutdown.ts +++ b/apps/server/src/graceful-shutdown.ts @@ -32,29 +32,28 @@ export const registerGracefulShutdown = ( } shuttingDown = true; - console.log(`Received ${signal}, shutting down server...`); + console.log(`[shutdown] Received ${signal}, shutting down server...`); const shutdownTimer = setTimeout(() => { - console.log(`Server shutdown timed out after ${FORCE_EXIT_AFTER_MS}ms`); + console.log( + `[shutdown] Server shutdown timed out after ${FORCE_EXIT_AFTER_MS}ms`, + ); process.exit(1); }, FORCE_EXIT_AFTER_MS); const finish = async (error?: Error): Promise => { if (error) { - console.log(`Server shutdown failed: ${error.message}`); + console.error("[shutdown] Server shutdown failed:", error); process.exit(1); return; } - console.log("Server stopped."); + console.log("[shutdown] Server stopped."); if (options.drain) { try { await options.drain(); } catch (err) { - console.error( - "[shutdown] resource drain failed:", - err instanceof Error ? err.message : String(err), - ); + console.error("[shutdown] resource drain failed:", err); } } clearTimeout(shutdownTimer); diff --git a/apps/server/src/routes/api/actions.ts b/apps/server/src/routes/api/actions.ts index 1ed299ed..1eae4306 100644 --- a/apps/server/src/routes/api/actions.ts +++ b/apps/server/src/routes/api/actions.ts @@ -19,6 +19,7 @@ import { } from "@covel/runtime"; import type { CovelEventType, RuntimeManifest } from "@covel/shared"; import { FORWARDED_EVENT_TYPES } from "@covel/shared"; +import { estimateTokens } from "@covel/context"; import type { CompactorRunner } from "@covel/context"; import { errorBody } from "../../api-error.js"; import { rateLimiter } from "../../middleware/rate-limit.js"; @@ -39,6 +40,39 @@ import { checkSessionOwner } from "./session/session-guard.js"; // SSE uses ProtocolEventType names directly — no legacy mapping. // Frontend handleSseEvent handles these standard types. +/** + * Memory-system facade injected by bootstrap via `setMemorySystem()`. Kept + * out of `Env["Variables"]` because it never flows through Hono's + * `c.set`/`c.get` — see the module-level reference below. + */ +interface MemorySystemFacade { + readonly manager: { + loadBlocks( + sid: string, + ): Promise< + readonly { label: string; content: string; updatedAt: string }[] + >; + initializeDefaults(sid: string): Promise; + }; + readonly updater: { + updateAfterTurn(p: { + sessionId: string; + narrativeText: string; + toolCallSummaries?: readonly string[]; + currentBlocks: readonly { + label: string; + content: string; + updatedAt: string; + }[]; + locale?: string; + }): Promise<{ + updated: boolean; + blocksChanged: readonly string[]; + error?: string; + }>; + }; +} + type Env = { Variables: { store: DataStore; @@ -49,10 +83,6 @@ type Env = { locale?: string, ) => Promise; toolExecutor: ToolExecutor; - getConfigFn: ( - pluginId: string, - runtimeId: string, - ) => Readonly>; resolveModel: ( manifest: RuntimeManifest, apiOverride?: string, @@ -61,33 +91,6 @@ type Env = { compactorRunner: CompactorRunner; mediaStore?: MediaStore; hookPipeline?: HookPipeline; - memorySystem?: { - readonly manager: { - loadBlocks( - sid: string, - ): Promise< - readonly { label: string; content: string; updatedAt: string }[] - >; - initializeDefaults(sid: string): Promise; - }; - readonly updater: { - updateAfterTurn(p: { - sessionId: string; - narrativeText: string; - toolCallSummaries?: readonly string[]; - currentBlocks: readonly { - label: string; - content: string; - updatedAt: string; - }[]; - locale?: string; - }): Promise<{ - updated: boolean; - blocksChanged: readonly string[]; - error?: string; - }>; - }; - }; ensureEmbeddingLock?: (sessionId: string) => Promise; }; }; @@ -97,8 +100,8 @@ export const actionRoutes = new Hono(); // Module-level memory system reference, set by bootstrap via setMemorySystem(). // Using a module variable instead of Hono context because Hono's typed // c.set/c.get doesn't support optional cross-module types cleanly. -let _memorySystem: Env["Variables"]["memorySystem"] | undefined; -export function setMemorySystem(ms: Env["Variables"]["memorySystem"]) { +let _memorySystem: MemorySystemFacade | undefined; +export function setMemorySystem(ms: MemorySystemFacade | undefined) { _memorySystem = ms; } @@ -120,10 +123,10 @@ actionRoutes.post("/", rateLimiter({ max: 30 }), async (c) => { const getPluginSource = c.get("getPluginSource"); const loadRuntimeFn = c.get("loadRuntimeFn"); const toolExecutor = c.get("toolExecutor"); - const getConfigFn = c.get("getConfigFn"); const resolveModel = c.get("resolveModel"); const eventBus = c.get("eventBus"); const compactorRunner = c.get("compactorRunner"); + const turnContextBudget = c.get("turnContextBudget"); const sessionLock = c.get("sessionLock"); const mediaStore = c.get("mediaStore"); const eventDirectory = c.get("eventDirectory"); @@ -438,11 +441,6 @@ actionRoutes.post("/", rateLimiter({ max: 30 }), async (c) => { ...(pluginGateway ? { gateway: pluginGateway } : {}), ...(pluginUtils ? { utils: pluginUtils } : {}), ...(getPluginSource ? { getPluginSource } : {}), - // bootstrapApi always supplies getConfigFn; default to a no-op so a - // minimal harness (or any caller that omits it) can't crash the turn - // executor's `deps.getConfig(...)` call. Preserves the defensiveness - // the removed /:id/turn route carried. - getConfig: getConfigFn ?? (() => ({})), store, ...(mediaStore ? { mediaStore } : {}), toolExecutor, @@ -506,6 +504,14 @@ actionRoutes.post("/", rateLimiter({ max: 30 }), async (c) => { }); }, compactor: compactorRunner, + // Prompt-assembly hard prune — last line of defense when + // compaction is skipped/vetoed/insufficient for the model window. + ...(turnContextBudget + ? { + estimator: estimateTokens, + contextBudget: turnContextBudget, + } + : {}), memorySystem: _memorySystem, // Let the turn executor construct a unified SessionContextSnapshot. capabilityPluginIds, @@ -609,11 +615,13 @@ actionRoutes.post("/", rateLimiter({ max: 30 }), async (c) => { ...(pluginGateway ? { gateway: pluginGateway } : {}), ...(pluginUtils ? { utils: pluginUtils } : {}), ...(getPluginSource ? { getPluginSource } : {}), - getConfig: getConfigFn ?? (() => ({})), ...(mediaStore ? { mediaStore } : {}), toolExecutor, resolveModel, compactor: compactorRunner, + ...(turnContextBudget + ? { estimator: estimateTokens, contextBudget: turnContextBudget } + : {}), capabilityPluginIds, ...(eventDirectory ? { eventDirectory } : {}), }, diff --git a/apps/server/src/routes/api/ai.ts b/apps/server/src/routes/api/ai.ts index 61c49ea6..55d18bb1 100644 --- a/apps/server/src/routes/api/ai.ts +++ b/apps/server/src/routes/api/ai.ts @@ -17,7 +17,7 @@ import type { LLMAdapter } from "@covel/runtime"; import type { DataStore, WorldRecord } from "@covel/store"; import { rateLimiter, singleFlight } from "../../middleware/rate-limit.js"; import { loadSingleWorld } from "../../world-seed-loader.js"; -import { errorBody } from "../../api-error.js"; +import { errorBody, readJsonBody } from "../../api-error.js"; import { checkHostedOperator } from "./session/session-guard.js"; type Env = { @@ -110,7 +110,9 @@ aiRoutes.post( async (c) => { const llm = c.get("llmAdapter"); const store = c.get("store"); - const body = await c.req.json>(); + const parsed = await readJsonBody>(c); + if (parsed instanceof Response) return parsed; + const body = parsed.body; const concept = body.concept ?? body.prompt; if (typeof concept !== "string" || !concept.trim()) { diff --git a/apps/server/src/routes/api/bootstrap.ts b/apps/server/src/routes/api/bootstrap.ts index bfa7d5c2..0f68441e 100644 --- a/apps/server/src/routes/api/bootstrap.ts +++ b/apps/server/src/routes/api/bootstrap.ts @@ -66,7 +66,11 @@ import { runtimeOutputRoutes } from "./runtime-outputs.js"; import { pluginRpcRoutes } from "./plugin-rpc.js"; import { approvalRoutes, sessionApprovalRoutes } from "./approvals.js"; export { wrapStoreWithPluginDataEvents } from "./bootstrap/plugin-data-store-events.js"; -import { createBootstrapCompactorRunner } from "./bootstrap/compactor.js"; +import { + createBootstrapCompactorRunner, + createTurnContextBudget, + type ResolveNarrativeBudgetFn, +} from "./bootstrap/compactor.js"; import { discoverAndRegisterPlugins } from "./bootstrap/plugin-discovery.js"; import { createBootstrapHookPipeline } from "./bootstrap/plugin-hooks.js"; import { setupPluginTools } from "./bootstrap/tools.js"; @@ -144,11 +148,6 @@ export interface ApiBootstrapConfig { * player-facing settings (`memory` → `plugin` → `story` → first text slot). */ readonly preferredMemorySlot?: string; - /** Optional config provider for injecting world context etc. into runtime execution. */ - readonly getConfigFn?: ( - pluginId: string, - runtimeId: string, - ) => Readonly>; /** * Optional per-request middleware inserted AFTER the default dependency * injection middleware but BEFORE route handlers execute. Intended for @@ -175,6 +174,13 @@ export interface ApiBootstrapConfig { readonly mediaStore?: MediaStore; readonly mediaBackend?: MediaStoreBackend; readonly vectorBackend?: VectorBackend; + /** + * Live view of the main narrative slot's model budget (contextWindow / + * maxOutputTokens), built by the composition root against the AI + * registries. Drives the compaction threshold and the prompt-assembly + * hard prune. Absent (tests, minimal harnesses) → fixed fallback window. + */ + readonly resolveNarrativeBudget?: ResolveNarrativeBudgetFn; } export interface ApiBootstrapResult { @@ -247,7 +253,7 @@ export async function bootstrapApi( const store = wrapStoreWithPluginDataEvents(config.store, eventBus); // One-time startup sweep of stale suspensions accumulated while the server - // was down (TODO S4-T4.c). Fire-and-forget — never blocks boot. + // was down (spec S4-T4.c). Fire-and-forget — never blocks boot. void maybeSweepExpiredSuspensions(store, { force: true }).catch( (err: unknown) => console.warn( @@ -431,15 +437,6 @@ export async function bootstrapApi( } } - // 7. getConfigFn — per-request config injection - // Actual config pre-loading happens in the actions.ts route handler - // before calling executeTurn, bridging async store reads to sync getConfig interface. - const getConfigFn = - config.getConfigFn ?? - (( - _pluginId: string, - _runtimeId: string, - ): Readonly> => ({})); const getPluginSource = (pluginId: string) => registry.get(pluginId)?.source; const { rpcRegistry, rpcExecutor, rpcApprovalGate } = @@ -493,12 +490,21 @@ export async function bootstrapApi( }; const runtimeEnv = readRuntimeEnv(); + const budgetSource = { + ...(runtimeEnv.compactorContextWindow !== undefined + ? { contextWindowOverride: runtimeEnv.compactorContextWindow } + : {}), + ...(config.resolveNarrativeBudget + ? { resolveNarrativeBudget: config.resolveNarrativeBudget } + : {}), + }; const compactorRunner = createBootstrapCompactorRunner({ manifestCache, store, llmAdapter: config.llmAdapter, - contextWindow: runtimeEnv.compactorContextWindow, + ...budgetSource, }); + const turnContextBudget = createTurnContextBudget(budgetSource); // 8. Create memory system (Letta-style three-tier memory) const bootstrapMemory = createBootstrapMemorySystem({ @@ -542,9 +548,9 @@ export async function bootstrapApi( } c.set("loadRuntimeFn", loadRuntimeFn); c.set("toolExecutor", toolExecutor); - c.set("getConfigFn", getConfigFn); c.set("resolveModel", resolveModel); c.set("compactorRunner", compactorRunner); + c.set("turnContextBudget", turnContextBudget); c.set("hookPipeline", hookPipeline); c.set("eventDirectory", eventDirectory); // memorySystem injected via module-level setter, not Hono context diff --git a/apps/server/src/routes/api/bootstrap/compactor.ts b/apps/server/src/routes/api/bootstrap/compactor.ts index 5419eb70..ad6c077f 100644 --- a/apps/server/src/routes/api/bootstrap/compactor.ts +++ b/apps/server/src/routes/api/bootstrap/compactor.ts @@ -1,25 +1,61 @@ import type { LLMAdapter } from "@covel/runtime"; import type { DataStore } from "@covel/store"; import { + estimateTokens, maybeCompact, + type BudgetOptions, type CompactorLLMAdapter, type CompactorRunner, } from "@covel/context"; import type { ParsedPluginMd } from "@covel/plugin-loader"; -export interface CreateBootstrapCompactorRunnerParams { +/** + * Last-resort context window when neither an explicit env override nor a + * model-capability lookup yields a value (e.g. an unknown model with no + * llm.toml capability block). + */ +const FALLBACK_CONTEXT_WINDOW = 32768; + +/** Matches applyBudget's own default; explicit here because getters can't omit. */ +const DEFAULT_RESERVED_FOR_RESPONSE = 4000; + +/** + * Live view of the main narrative slot's model budget. Implemented in the + * composition root (app.ts) against the AI registries so llm.toml hot-reloads + * are observed without a server restart — resolve on every call, never cache. + */ +export type ResolveNarrativeBudgetFn = () => + | { + readonly contextWindow?: number; + readonly maxOutputTokens?: number; + } + | undefined; + +interface BudgetSourceParams { + /** Explicit COVEL_COMPACTOR_CONTEXT_WINDOW override — wins over capability. */ + readonly contextWindowOverride?: number; + readonly resolveNarrativeBudget?: ResolveNarrativeBudgetFn; +} + +/** Resolution order: env override > slot model capability > fallback. */ +function resolveContextWindow(params: BudgetSourceParams): number { + return ( + params.contextWindowOverride ?? + params.resolveNarrativeBudget?.()?.contextWindow ?? + FALLBACK_CONTEXT_WINDOW + ); +} + +export interface CreateBootstrapCompactorRunnerParams extends BudgetSourceParams { readonly manifestCache: ReadonlyMap; readonly store: DataStore; readonly llmAdapter: LLMAdapter; - readonly contextWindow: number; } -export function createBootstrapCompactorRunner({ - manifestCache, - store, - llmAdapter, - contextWindow, -}: CreateBootstrapCompactorRunnerParams): CompactorRunner { +export function createBootstrapCompactorRunner( + params: CreateBootstrapCompactorRunnerParams, +): CompactorRunner { + const { manifestCache, store, llmAdapter } = params; const allSummaryFocus = new Set(); for (const [, manifests] of manifestCache) { for (const parsed of manifests) { @@ -29,15 +65,14 @@ export function createBootstrapCompactorRunner({ } } const focusSections: readonly string[] = [...allSummaryFocus]; - const simpleEstimator = (text: string): number => Math.ceil(text.length / 4); const fastSlotLlm: CompactorLLMAdapter = { - async complete(params) { + async complete(input) { const response = await llmAdapter.generate({ model: "fast", messages: [ - { role: "system", content: params.systemPrompt }, - ...params.messages.map((m) => ({ + { role: "system", content: input.systemPrompt }, + ...input.messages.map((m) => ({ role: m.role as "user", content: m.content, })), @@ -61,9 +96,10 @@ export function createBootstrapCompactorRunner({ messages, { store, - estimator: simpleEstimator, + estimator: estimateTokens, fastSlotLlm, - contextWindow, + // Resolved per run so llm.toml hot-reloads take effect immediately. + contextWindow: resolveContextWindow(params), }, { focusSections, @@ -74,3 +110,24 @@ export function createBootstrapCompactorRunner({ }, }; } + +/** + * Budget config for the prompt-assembly hard prune (`applyBudget`) — the last + * line of defense when compaction is skipped, vetoed, or insufficient. + * Getter-based so each turn observes the current llm.toml capability. + */ +export function createTurnContextBudget( + params: BudgetSourceParams, +): Omit { + return { + get maxInputTokens(): number { + return resolveContextWindow(params); + }, + get reservedForResponse(): number { + return ( + params.resolveNarrativeBudget?.()?.maxOutputTokens ?? + DEFAULT_RESERVED_FOR_RESPONSE + ); + }, + }; +} diff --git a/apps/server/src/routes/api/bootstrap/plugin-entry.ts b/apps/server/src/routes/api/bootstrap/plugin-entry.ts index 93044b8a..53c7047a 100644 --- a/apps/server/src/routes/api/bootstrap/plugin-entry.ts +++ b/apps/server/src/routes/api/bootstrap/plugin-entry.ts @@ -438,7 +438,7 @@ function warnLegacyRegistrationFields( console.warn( `[plugin-entry] ${pluginId}: PLUGIN.md field(s) ${[...legacy].join(", ")} are deprecated — ` + `migrate to a single "entry" module (export default function (covel) { ... }). ` + - `Legacy fields keep working for this release cycle.`, + `Legacy fields are planned for removal in v0.0.17.`, ); } } diff --git a/apps/server/src/routes/api/characters.ts b/apps/server/src/routes/api/characters.ts index 4696546c..499d1061 100644 --- a/apps/server/src/routes/api/characters.ts +++ b/apps/server/src/routes/api/characters.ts @@ -8,7 +8,7 @@ import type { DataStore, CharacterRecord } from "@covel/store"; import type { EventBus } from "@covel/events"; import type { Proposal } from "@covel/shared"; import type { HookPipeline } from "@covel/runtime"; -import { errorBody } from "../../api-error.js"; +import { errorBody, readJsonBody } from "../../api-error.js"; import { frameworkProposalSource } from "../../lib/framework-source.js"; import { resolveSessionParam } from "./session/session-guard.js"; @@ -38,7 +38,9 @@ characterRoutes.post("/:id/characters", async (c) => { const store = c.get("store"); const sessionId = guard.session.id; - const body = await c.req.json>(); + const parsed = await readJsonBody>(c); + if (parsed instanceof Response) return parsed; + const body = parsed.body; if (!body.id || typeof body.id !== "string") { return c.json(errorBody("id (string) is required"), 400); diff --git a/apps/server/src/routes/api/events.ts b/apps/server/src/routes/api/events.ts index d9d2929a..93da9924 100644 --- a/apps/server/src/routes/api/events.ts +++ b/apps/server/src/routes/api/events.ts @@ -9,7 +9,7 @@ import type { EventBus } from "@covel/events"; import type { DataStore } from "@covel/store"; import type { CovelMessage } from "@covel/shared"; import { isEnvTruthy, readRuntimeEnv } from "@covel/shared"; -import { errorBody } from "../../api-error.js"; +import { errorBody, readJsonBody } from "../../api-error.js"; import { checkSessionOwnerById } from "./session/session-guard.js"; type Env = { @@ -31,12 +31,14 @@ eventRoutes.post("/emit", async (c) => { } const eventBus = c.get("eventBus"); - const body = await c.req.json<{ + const parsed = await readJsonBody<{ topic?: string; payload?: Record; sessionId?: string; targetRuntime?: string; - }>(); + }>(c); + if (parsed instanceof Response) return parsed; + const body = parsed.body; if (!body.topic || !body.sessionId) { return c.json(errorBody("topic and sessionId are required"), 400); diff --git a/apps/server/src/routes/api/install/shared.ts b/apps/server/src/routes/api/install/shared.ts index d420ce61..db222d6f 100644 --- a/apps/server/src/routes/api/install/shared.ts +++ b/apps/server/src/routes/api/install/shared.ts @@ -10,14 +10,7 @@ * - Target directory must not already exist (409) — upgrades require manual removal. */ -import { - mkdir, - mkdtemp, - rename, - rm, - writeFile, - access, -} from "node:fs/promises"; +import { mkdir, mkdtemp, rename, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import yauzl, { type Entry, type ZipFile } from "yauzl"; import { errorBody } from "../../../api-error.js"; @@ -72,15 +65,6 @@ export interface ExtractedEntry { readonly content: Buffer; } -export async function pathExists(p: string): Promise { - try { - await access(p); - return true; - } catch { - return false; - } -} - /** * Inspect the Unix file mode encoded in a zip entry's external attributes. * Returns `null` when the entry was produced by a non-Unix tool (mode=0). diff --git a/apps/server/src/routes/api/plugin-data.ts b/apps/server/src/routes/api/plugin-data.ts index 635b679c..1384e51f 100644 --- a/apps/server/src/routes/api/plugin-data.ts +++ b/apps/server/src/routes/api/plugin-data.ts @@ -14,7 +14,7 @@ import { Hono } from "hono"; import { z } from "zod"; import type { DataStore } from "@covel/store"; import type { PluginRegistry } from "@covel/plugin-loader"; -import { errorBody } from "../../api-error.js"; +import { errorBody, readJsonBody } from "../../api-error.js"; import { buildPluginDataIndex } from "./discovery.js"; import { resolveSessionParam } from "./session/session-guard.js"; @@ -157,7 +157,9 @@ pluginDataRoutes.put( const accessErr = validatePluginAccess(registry, pluginId, sessionId, true); if (accessErr) return c.json(errorBody(accessErr.error), accessErr.status); - const raw = await c.req.json(); + const jsonBody = await readJsonBody(c); + if (jsonBody instanceof Response) return jsonBody; + const raw = jsonBody.body; // zod 4.4: bare z.unknown() is required; .optional() keeps 4.3 behaviour. const bodySchema = z.object({ value: z.unknown().optional() }); const parsed = bodySchema.safeParse(raw); diff --git a/apps/server/src/routes/api/plugin-rpc.ts b/apps/server/src/routes/api/plugin-rpc.ts index 0323b601..66c60ebc 100644 --- a/apps/server/src/routes/api/plugin-rpc.ts +++ b/apps/server/src/routes/api/plugin-rpc.ts @@ -34,6 +34,7 @@ */ import { Hono } from "hono"; +import { estimateTokens } from "@covel/context"; import { COMMUNITY_SERVER_CODE_ACTION } from "@covel/approval"; import { createRpcHandlerStoreView } from "@covel/runtime"; import { RpcDispatchError, RpcValidationError } from "@covel/runtime"; @@ -116,6 +117,7 @@ pluginRpcRoutes.post("/:id/plugin-rpc", rateLimiter({ max: 30 }), async (c) => { const resolveModel = c.get("resolveModel"); const eventBus = c.get("eventBus"); const compactorRunner = c.get("compactorRunner"); + const turnContextBudget = c.get("turnContextBudget"); const hookPipeline = c.get("hookPipeline"); const sessionLock = c.get("sessionLock"); const mediaStore = c.get("mediaStore"); @@ -204,9 +206,6 @@ pluginRpcRoutes.post("/:id/plugin-rpc", rateLimiter({ max: 30 }), async (c) => { pluginRegistry, sessionId, ); - const turnGetConfig = - c.get("getConfigFn") ?? ((_pluginId: string, _runtimeId: string) => ({})); - // Audit F7: player-authored plugin settings travel with the request // as a base64-encoded JSON header (`X-Plugin-User-Settings`) sourced // from the unified SettingsStore. The body map keys on pluginId so @@ -249,7 +248,6 @@ pluginRpcRoutes.post("/:id/plugin-rpc", rateLimiter({ max: 30 }), async (c) => { ...(pluginGateway ? { gateway: pluginGateway } : {}), ...(pluginUtils ? { utils: pluginUtils } : {}), ...(getPluginSource ? { getPluginSource } : {}), - getConfig: turnGetConfig, ...(mediaStore ? { mediaStore } : {}), toolExecutor, resolveModel, @@ -260,6 +258,9 @@ pluginRpcRoutes.post("/:id/plugin-rpc", rateLimiter({ max: 30 }), async (c) => { // then can't tell the runtime ever finished. Wire it through so // manual-trigger turns produce the same trace surface as auto turns. compactor: compactorRunner, + ...(turnContextBudget + ? { estimator: estimateTokens, contextBudget: turnContextBudget } + : {}), capabilityPluginIds, ...(hookPipeline ? { hookPipeline } : {}), ...(eventDirectory ? { eventDirectory } : {}), diff --git a/apps/server/src/routes/api/resume.ts b/apps/server/src/routes/api/resume.ts index 5e9a52c3..d3078efe 100644 --- a/apps/server/src/routes/api/resume.ts +++ b/apps/server/src/routes/api/resume.ts @@ -12,8 +12,10 @@ * before entering the LLM tool loop. Concurrent requests with the same * suspensionId lose the race and receive 409. This guarantees * exactly-once execution of a suspended runtime. - * - The pipeline also runs under `withSessionLock(sessionId)` so sequential - * resumes for the same session do not interleave with turn execution. + * - The pipeline also runs under the injected `sessionLock` (see + * `env.d.ts`; historically a `withSessionLock` import, now DI-provided) + * so sequential resumes for the same session do not interleave with turn + * execution. * * Expiry (S4-T4.c): the suspension-touching routes opportunistically fire a * time-gated, best-effort global sweep of stale (unresolved, older-than-TTL) @@ -53,10 +55,6 @@ type Env = { locale?: string, ) => Promise; toolExecutor: ToolExecutor; - getConfigFn: ( - pluginId: string, - runtimeId: string, - ) => Readonly>; resolveModel: ( manifest: RuntimeManifest, apiOverride?: string, @@ -286,8 +284,6 @@ resumeRoutes.post("/:id/resume", async (c) => { llm: llmAdapter, ...(pluginGateway ? { gateway: pluginGateway } : {}), ...(pluginUtils ? { utils: pluginUtils } : {}), - getConfig: - c.get("getConfigFn") ?? ((_p: string, _r: string) => ({})), store, toolExecutor, resolveModel, diff --git a/apps/server/src/routes/api/session.ts b/apps/server/src/routes/api/session.ts index 95468330..7800c06b 100644 --- a/apps/server/src/routes/api/session.ts +++ b/apps/server/src/routes/api/session.ts @@ -35,7 +35,7 @@ import { runSessionEndHook, runWithHookScope, } from "@covel/runtime"; -import { errorBody } from "../../api-error.js"; +import { errorBody, readJsonBody } from "../../api-error.js"; import { normalizeLocale } from "../../lib/validators.js"; import { signMediaTokenForSession } from "../../middleware/media-token.js"; import { @@ -216,7 +216,9 @@ sessionRoutes.post("/", async (c) => { const pluginRegistry = c.get("pluginRegistry"); const worldsDirs = c.get("worldsDirs"); const covelHome = c.get("covelHome"); - const body = await c.req.json>(); + const parsed = await readJsonBody>(c); + if (parsed instanceof Response) return parsed; + const body = parsed.body; const parsedCreate = parseCreateSessionBody(body); if (!parsedCreate.ok) { @@ -451,7 +453,9 @@ sessionRoutes.patch("/:id", async (c) => { if (!guard.ok) return guard.response; const session = guard.session; - const body = await c.req.json>(); + const parsed = await readJsonBody>(c); + if (parsed instanceof Response) return parsed; + const body = parsed.body; const now = new Date().toISOString(); const parsedPatch = buildSessionPatchUpdates(body, now); if (!parsedPatch.ok) { @@ -546,7 +550,9 @@ sessionRoutes.post("/:id/plugins/enable", async (c) => { if (!guard.ok) return guard.response; const session = guard.session; - const body = await c.req.json<{ pluginId: string }>(); + const parsed = await readJsonBody<{ pluginId: string }>(c); + if (parsed instanceof Response) return parsed; + const body = parsed.body; if (!body.pluginId || !pluginRegistry.get(body.pluginId)) { return c.json(errorBody(`Plugin "${body.pluginId}" not found`), 404); } @@ -618,7 +624,9 @@ sessionRoutes.post("/:id/plugins/disable", async (c) => { if (!guard.ok) return guard.response; const session = guard.session; - const body = await c.req.json<{ pluginId: string }>(); + const parsed = await readJsonBody<{ pluginId: string }>(c); + if (parsed instanceof Response) return parsed; + const body = parsed.body; if (!body.pluginId || typeof body.pluginId !== "string") { return c.json(errorBody("pluginId is required"), 400); } diff --git a/apps/server/src/routes/api/snapshots.ts b/apps/server/src/routes/api/snapshots.ts index eece3d7a..0c92d4b4 100644 --- a/apps/server/src/routes/api/snapshots.ts +++ b/apps/server/src/routes/api/snapshots.ts @@ -422,8 +422,9 @@ snapshotRoutes.post("/:id/fork", async (c) => { // Copy session-scoped lorebook entries. Missing before, so a fork lost // every lore/world entry the parent accumulated during play, and the - // child's `{{ config.worldEntries }}` came back empty. (sessionId, id) is - // the composite key, so keeping the original id is safe. + // child's world-entries prompt injection came back empty. + // (sessionId, id) is the composite key, so keeping the original id + // is safe. if (snapshot.payload.lorebookEntries.length > 0) { await tx.upsertLorebookEntries( snapshot.payload.lorebookEntries.map((lb) => ({ diff --git a/apps/server/src/routes/api/state.ts b/apps/server/src/routes/api/state.ts index 4d578e80..59e43882 100644 --- a/apps/server/src/routes/api/state.ts +++ b/apps/server/src/routes/api/state.ts @@ -99,8 +99,9 @@ stateRoutes.get("/:id/state", async (c) => { data, }; } - } catch { + } catch (err) { // Non-critical: characters table optional on slim stores. + console.warn(`[state] characters query failed for session ${id}:`, err); } // ── plugin_data grouped by (pluginId, namespace) ────────────────── @@ -136,8 +137,9 @@ stateRoutes.get("/:id/state", async (c) => { data: entry.data, }; } - } catch { + } catch (err) { // Non-critical: session may have no plugin_data rows yet. + console.warn(`[state] plugin_data query failed for session ${id}:`, err); } return c.json({ tables }); diff --git a/apps/server/src/routes/api/suspension-sweep.ts b/apps/server/src/routes/api/suspension-sweep.ts index b0be7a2f..40497a35 100644 --- a/apps/server/src/routes/api/suspension-sweep.ts +++ b/apps/server/src/routes/api/suspension-sweep.ts @@ -1,5 +1,5 @@ /** - * Stale-suspension TTL sweep (TODO S4-T4.c). + * Stale-suspension TTL sweep (spec S4-T4.c). * * Covel has no general scheduler, so expiry runs opportunistically — the * cutoff + cadence live ONLY here so every caller stays consistent: diff --git a/apps/server/src/routes/api/working-memory.ts b/apps/server/src/routes/api/working-memory.ts index 67a933fb..dceb9850 100644 --- a/apps/server/src/routes/api/working-memory.ts +++ b/apps/server/src/routes/api/working-memory.ts @@ -13,7 +13,7 @@ import { Hono } from "hono"; import { z } from "zod"; import type { DataStore } from "@covel/store"; import { resolveSessionParam } from "./session/session-guard.js"; -import { errorBody } from "../../api-error.js"; +import { errorBody, readJsonBody } from "../../api-error.js"; type Env = { Variables: { @@ -86,7 +86,9 @@ workingMemoryRoutes.put("/:id/working-memory/:scope/:key", async (c) => { return c.json(errorBody("Key must not be empty"), 400); } - const raw = await c.req.json(); + const jsonBody = await readJsonBody(c); + if (jsonBody instanceof Response) return jsonBody; + const raw = jsonBody.body; const bodySchema = z.object({ // zod 4.4: a bare z.unknown() field is required; .optional() preserves the // 4.3 behaviour (a missing value parsed as undefined) — don't 400 callers. diff --git a/apps/server/src/routes/api/worlds/crud.ts b/apps/server/src/routes/api/worlds/crud.ts index 11ff0995..b1bb637e 100644 --- a/apps/server/src/routes/api/worlds/crud.ts +++ b/apps/server/src/routes/api/worlds/crud.ts @@ -6,7 +6,7 @@ import { Hono } from "hono"; import { rm } from "node:fs/promises"; import path from "node:path"; import type { WorldRecord } from "@covel/store"; -import { errorBody } from "../../../api-error.js"; +import { errorBody, readJsonBody } from "../../../api-error.js"; import { resolveContainedPath } from "../../../world-data/safe-path.js"; import { checkHostedOperator } from "../session/session-guard.js"; import { type WorldEnv, resolveWorldMetadata } from "./shared.js"; @@ -36,7 +36,9 @@ worldCrudRoutes.post("/", async (c) => { const denied = checkHostedOperator(c); if (denied) return denied; const store = c.get("store"); - const body = await c.req.json>(); + const parsed = await readJsonBody>(c); + if (parsed instanceof Response) return parsed; + const body = parsed.body; if (!body.id || typeof body.id !== "string") { return c.json(errorBody("id (string) is required"), 400); @@ -77,7 +79,9 @@ worldCrudRoutes.patch("/:id", async (c) => { return c.json(errorBody("World not found"), 404); } - const body = await c.req.json>(); + const parsed = await readJsonBody>(c); + if (parsed instanceof Response) return parsed; + const body = parsed.body; const now = new Date().toISOString(); const metadataResult = resolveWorldMetadata(body, existing.metadata); if (metadataResult.error) { diff --git a/apps/server/src/routes/api/worlds/data-sync.ts b/apps/server/src/routes/api/worlds/data-sync.ts index 6dfd9188..40ce7431 100644 --- a/apps/server/src/routes/api/worlds/data-sync.ts +++ b/apps/server/src/routes/api/worlds/data-sync.ts @@ -9,7 +9,7 @@ import { Hono } from "hono"; import { FrameworkCapability } from "@covel/shared"; -import { errorBody } from "../../../api-error.js"; +import { errorBody, readJsonBody } from "../../../api-error.js"; import { syncWorldDataForSession, preflightWorldDataForSession, @@ -134,7 +134,9 @@ worldDataSyncRoutes.post("/:id/sync-dimensions", async (c) => { const pluginRegistry = c.get("pluginRegistry"); const id = c.req.param("id"); - const body = await c.req.json>(); + const parsed = await readJsonBody>(c); + if (parsed instanceof Response) return parsed; + const body = parsed.body; const sessionId = body.sessionId; if (typeof sessionId !== "string") { return c.json(errorBody("sessionId (string) is required"), 400); diff --git a/apps/server/src/routes/api/worlds/dimensions.ts b/apps/server/src/routes/api/worlds/dimensions.ts index 17179795..3b0e2e5f 100644 --- a/apps/server/src/routes/api/worlds/dimensions.ts +++ b/apps/server/src/routes/api/worlds/dimensions.ts @@ -9,7 +9,7 @@ import { Hono } from "hono"; import { stringify as stringifyYaml } from "yaml"; import { validateDimensions } from "@covel/shared"; import type { WorldRecord } from "@covel/store"; -import { errorBody } from "../../../api-error.js"; +import { errorBody, readJsonBody } from "../../../api-error.js"; import { type WorldEnv } from "./shared.js"; import { checkHostedOperator } from "../session/session-guard.js"; @@ -60,7 +60,9 @@ worldDimensionRoutes.post("/:id/dimensions/import", async (c) => { return c.json(errorBody("World not found"), 404); } - const body = await c.req.json>(); + const parsed = await readJsonBody>(c); + if (parsed instanceof Response) return parsed; + const body = parsed.body; const dimensions = body.dimensions; if (!dimensions || typeof dimensions !== "object") { diff --git a/apps/server/src/routes/config-api.ts b/apps/server/src/routes/config-api.ts index 046714c8..fe47f6c7 100644 --- a/apps/server/src/routes/config-api.ts +++ b/apps/server/src/routes/config-api.ts @@ -18,7 +18,7 @@ * X-Provider-Keys header flow driven by browser localStorage. */ -import { Hono, type Context } from "hono"; +import { Hono } from "hono"; import { resolve, join, dirname, isAbsolute } from "node:path"; import { readFileSync, @@ -35,26 +35,10 @@ import { readRuntimeEnv, toApiKeyEnvMap, } from "@covel/shared"; -import { errorBody } from "../api-error.js"; +import { errorBody, readJsonBody } from "../api-error.js"; import { parseEnvLines } from "../lib/env-file.js"; import { makeDesktopRestTokenGuard } from "./privileged-auth.js"; -/** - * Parse a JSON request body, or return a 400 envelope Response. Callers do - * `const parsed = await readJsonBody(c); if (parsed instanceof Response) return parsed;` - * then read `parsed.body`. - */ -async function readJsonBody(c: Context): Promise<{ body: unknown } | Response> { - try { - return { body: await c.req.json() }; - } catch { - return c.json( - errorBody("Invalid JSON body", { code: "invalid_json_body" }), - 400, - ); - } -} - export interface ConfigApiDeps { /** Mutable map shared with the gateway adapter. PUT handlers mutate in-place. */ apiKeys: Record; diff --git a/apps/server/src/routes/misc-api.ts b/apps/server/src/routes/misc-api.ts index 29e8a42e..ff63c545 100644 --- a/apps/server/src/routes/misc-api.ts +++ b/apps/server/src/routes/misc-api.ts @@ -1,5 +1,5 @@ /** - * Miscellaneous API routes — presets, packages, commands, block-schemas, llm-config, provider-keys. + * Miscellaneous API routes — presets, packages, commands, llm-config, provider-keys. * * These endpoints are consumed by the frontend boot sequence. */ @@ -93,28 +93,6 @@ export function createMiscApiRoutes( return c.json(payload); }); - // GET /api/block-schemas — list block schemas from plugin manifests - app.get("/api/block-schemas", (c) => { - const schemas: Record = {}; - const all = registry.getAll(); - for (const [, entry] of all) { - const manifests = - entry.manifests ?? (entry.manifest ? [entry.manifest] : []); - for (const m of manifests) { - // blockSchemas may be declared in rawFrontmatter (from PLUGIN.md) or plugin.json - const bs = (m.rawFrontmatter as Record).blockSchemas; - if (bs && typeof bs === "object") { - for (const [key, val] of Object.entries( - bs as Record, - )) { - schemas[key] = val; - } - } - } - } - return c.json({ schemas }); - }); - // GET /api/ui-specs — list UI specs from plugin manifests, grouped by slot. // When ?sessionId= is provided, filter to that session's activePlugins so the // panel only shows plugins actually enabled for the current session. diff --git a/apps/server/src/routes/misc-api/ui-spec-schema.ts b/apps/server/src/routes/misc-api/ui-spec-schema.ts index 65e7d42c..a0dbd8f1 100644 --- a/apps/server/src/routes/misc-api/ui-spec-schema.ts +++ b/apps/server/src/routes/misc-api/ui-spec-schema.ts @@ -20,7 +20,7 @@ import type { UiSlotName } from "./shared.js"; * explicit diagnostic, so a newer plugin pack on an older server degrades * loudly instead of silently rendering a broken panel. */ -export const CURRENT_UI_SPEC_VERSION = 1; +const CURRENT_UI_SPEC_VERSION = 1; const i18nTextSchema = z.union([z.string(), z.record(z.string(), z.string())]); @@ -29,7 +29,7 @@ const i18nTextSchema = z.union([z.string(), z.record(z.string(), z.string())]); * (forward compatibility) — callers pass the ORIGINAL spec through to the * frontend untouched, so Zod's stripping never mutates what ships. */ -export const uiSpecSchema = z +const uiSpecSchema = z .object({ specVersion: z .number() @@ -89,7 +89,7 @@ export interface UiSpecDiagnostic { } /** Pull a spec's declared `id` for diagnostic labelling, if any. */ -export function uiSpecId(spec: unknown): string | undefined { +function uiSpecId(spec: unknown): string | undefined { if (spec && typeof spec === "object") { const id = (spec as Record).id; if (typeof id === "string" && id.length > 0) return id; @@ -98,7 +98,7 @@ export function uiSpecId(spec: unknown): string | undefined { } /** Validate one spec, returning concrete per-field issues on failure. */ -export function validateUiSpec( +function validateUiSpec( spec: unknown, ): { ok: true } | { ok: false; issues: UiSpecIssue[] } { const result = uiSpecSchema.safeParse(spec); diff --git a/apps/server/src/routes/model-db.ts b/apps/server/src/routes/model-db.ts index f3fe7f9e..c9b8eae0 100644 --- a/apps/server/src/routes/model-db.ts +++ b/apps/server/src/routes/model-db.ts @@ -97,6 +97,7 @@ export function createModelDbRoutes(ai: AiStack): Hono { } return c.json({ ok: true, count: ai.modelDb.count, persisted }); } catch (err) { + console.error("[model-db] refresh failed:", err); const message = err instanceof Error ? err.message : String(err); return c.json({ ok: false, error: message }); } diff --git a/apps/server/src/world-seed-loader.ts b/apps/server/src/world-seed-loader.ts index 2053de55..fc71b5a8 100644 --- a/apps/server/src/world-seed-loader.ts +++ b/apps/server/src/world-seed-loader.ts @@ -374,9 +374,7 @@ async function loadCharacterBlueprints( * Validates each world.yaml against worldManifestSchema. * Returns WorldRecord[] ready for upsert. */ -export async function loadWorldPackages( - worldsDir: string, -): Promise { +async function loadWorldPackages(worldsDir: string): Promise { if (!(await fileExists(worldsDir))) return []; const entries = await readdir(worldsDir, { withFileTypes: true }); diff --git a/apps/server/tests/api/actions-commit-barrier.test.ts b/apps/server/tests/api/actions-commit-barrier.test.ts index 3eb6eeb5..bff5fdc0 100644 --- a/apps/server/tests/api/actions-commit-barrier.test.ts +++ b/apps/server/tests/api/actions-commit-barrier.test.ts @@ -156,7 +156,6 @@ describe("POST /api/actions — turn commit barrier", () => { c.set("loadRuntimeFn", async () => loaded); // eslint-disable-next-line @typescript-eslint/no-explicit-any c.set("toolExecutor", undefined as any); - c.set("getConfigFn", () => ({})); c.set("resolveModel", () => undefined); c.set("eventBus", eventBus); c.set("sessionLock", sessionLock); diff --git a/apps/server/tests/api/actions-deferred-followers.test.ts b/apps/server/tests/api/actions-deferred-followers.test.ts index ad1f74a4..d028996e 100644 --- a/apps/server/tests/api/actions-deferred-followers.test.ts +++ b/apps/server/tests/api/actions-deferred-followers.test.ts @@ -160,7 +160,6 @@ describe("POST /api/actions — deferred background followers (Audit F1, main pa c.set("loadRuntimeFn", loadRuntimeFn); // eslint-disable-next-line @typescript-eslint/no-explicit-any c.set("toolExecutor", undefined as any); - c.set("getConfigFn", () => ({})); c.set("resolveModel", () => undefined); c.set("eventBus", eventBus); c.set("sessionLock", sessionLock); diff --git a/apps/server/tests/api/actions-phase-emit.test.ts b/apps/server/tests/api/actions-phase-emit.test.ts index 6356a6fb..6081a6eb 100644 --- a/apps/server/tests/api/actions-phase-emit.test.ts +++ b/apps/server/tests/api/actions-phase-emit.test.ts @@ -137,7 +137,6 @@ describe("POST /api/actions — phase.changed hygiene (Finding 4 regression)", ( c.set("loadRuntimeFn", async (m) => loadedByName.get(m.name)); // eslint-disable-next-line @typescript-eslint/no-explicit-any c.set("toolExecutor", undefined as any); - c.set("getConfigFn", () => ({})); c.set("resolveModel", () => undefined); c.set("eventBus", eventBus); c.set("sessionLock", sessionLock); diff --git a/apps/server/tests/api/actions-turn-control-concurrency.test.ts b/apps/server/tests/api/actions-turn-control-concurrency.test.ts index 97007e9d..2643211f 100644 --- a/apps/server/tests/api/actions-turn-control-concurrency.test.ts +++ b/apps/server/tests/api/actions-turn-control-concurrency.test.ts @@ -127,7 +127,6 @@ describe("POST /api/actions — steer/abort targets the executing turn, not a qu c.set("loadRuntimeFn", async () => loaded); // eslint-disable-next-line @typescript-eslint/no-explicit-any c.set("toolExecutor", undefined as any); - c.set("getConfigFn", () => ({})); c.set("resolveModel", () => undefined); c.set("eventBus", eventBus); c.set("sessionLock", sessionLock); diff --git a/apps/server/tests/api/chat-mode-http-e2e.test.ts b/apps/server/tests/api/chat-mode-http-e2e.test.ts index b1052ce8..817f9177 100644 --- a/apps/server/tests/api/chat-mode-http-e2e.test.ts +++ b/apps/server/tests/api/chat-mode-http-e2e.test.ts @@ -6,8 +6,8 @@ import type { LLMAdapter, LLMResponse, LLMToolDefinition, - LLMMessage, } from "@covel/runtime"; +import type { LLMMessage } from "@covel/shared"; import { createMemoryStore } from "@covel/store"; import { bootstrapApi } from "../../src/routes/api/bootstrap.js"; import { loadSingleWorld } from "../../src/world-seed-loader.js"; diff --git a/apps/server/tests/api/hook-pipeline-integration.test.ts b/apps/server/tests/api/hook-pipeline-integration.test.ts index e75e70da..abda13b1 100644 --- a/apps/server/tests/api/hook-pipeline-integration.test.ts +++ b/apps/server/tests/api/hook-pipeline-integration.test.ts @@ -151,7 +151,6 @@ describe("POST /api/actions — hook pipeline wired through commit chain", () => ); // eslint-disable-next-line @typescript-eslint/no-explicit-any c.set("toolExecutor", undefined as any); - c.set("getConfigFn", () => ({})); c.set("resolveModel", () => undefined); c.set("eventBus", eventBus); c.set("hookPipeline", hookPipeline); diff --git a/apps/server/tests/api/resume.test.ts b/apps/server/tests/api/resume.test.ts index 3444b5da..b7be9b1c 100644 --- a/apps/server/tests/api/resume.test.ts +++ b/apps/server/tests/api/resume.test.ts @@ -30,7 +30,6 @@ type Deps = { execute: () => Promise; getToolInfo: () => undefined; }; - getConfigFn: () => Record; resolveModel: () => undefined; hookPipeline?: HookPipeline; }; @@ -43,7 +42,6 @@ function createTestApp(deps: Deps) { llmAdapter: Deps["llmAdapter"]; loadRuntimeFn: Deps["loadRuntimeFn"]; toolExecutor: Deps["toolExecutor"]; - getConfigFn: Deps["getConfigFn"]; resolveModel: Deps["resolveModel"]; }; }>(); @@ -58,7 +56,6 @@ function createTestApp(deps: Deps) { c.set("loadRuntimeFn", deps.loadRuntimeFn as any); // eslint-disable-next-line @typescript-eslint/no-explicit-any c.set("toolExecutor", deps.toolExecutor as any); - c.set("getConfigFn", deps.getConfigFn); // eslint-disable-next-line @typescript-eslint/no-explicit-any c.set("resolveModel", deps.resolveModel as any); c.set("sessionLock", sessionLock); @@ -187,7 +184,6 @@ function makeDefaultDeps(store: DataStore, overrides?: Partial): Deps { }), getToolInfo: () => undefined, }, - getConfigFn: () => ({}), resolveModel: () => undefined, ...overrides, }; diff --git a/apps/server/tests/api/snapshot.test.ts b/apps/server/tests/api/snapshot.test.ts index fb5b3b28..8f81e850 100644 --- a/apps/server/tests/api/snapshot.test.ts +++ b/apps/server/tests/api/snapshot.test.ts @@ -1079,7 +1079,6 @@ describe("Auto snapshot", () => { usage: { inputTokens: 0, outputTokens: 0 }, }), } as any, - getConfig: () => ({}), store, }, ); @@ -1115,7 +1114,6 @@ describe("Auto snapshot", () => { usage: { inputTokens: 0, outputTokens: 0 }, }), } as any, - getConfig: () => ({}), store, eventBus, }, diff --git a/apps/server/tests/api/start-game-flow-scenario.test.ts b/apps/server/tests/api/start-game-flow-scenario.test.ts index 28e2e3f0..a140a266 100644 --- a/apps/server/tests/api/start-game-flow-scenario.test.ts +++ b/apps/server/tests/api/start-game-flow-scenario.test.ts @@ -216,7 +216,6 @@ function makeApp( loaded.get(manifest.name), ); c.set("toolExecutor", undefined); - c.set("getConfigFn", () => ({})); c.set("resolveModel", () => undefined); c.set("eventBus", eventBus); c.set("sessionLock", sessionLock); diff --git a/apps/server/tests/bootstrap/turn-context-budget.test.ts b/apps/server/tests/bootstrap/turn-context-budget.test.ts new file mode 100644 index 00000000..99349e59 --- /dev/null +++ b/apps/server/tests/bootstrap/turn-context-budget.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import { createTurnContextBudget } from "../../src/routes/api/bootstrap/compactor.js"; + +describe("createTurnContextBudget", () => { + it("prefers the explicit env override over capability", () => { + const budget = createTurnContextBudget({ + contextWindowOverride: 8000, + resolveNarrativeBudget: () => ({ + contextWindow: 128_000, + maxOutputTokens: 8192, + }), + }); + + expect(budget.maxInputTokens).toBe(8000); + // reservedForResponse still comes from capability — the override only + // pins the window. + expect(budget.reservedForResponse).toBe(8192); + }); + + it("derives window and reserve from the narrative slot capability", () => { + const budget = createTurnContextBudget({ + resolveNarrativeBudget: () => ({ + contextWindow: 200_000, + maxOutputTokens: 16_000, + }), + }); + + expect(budget.maxInputTokens).toBe(200_000); + expect(budget.reservedForResponse).toBe(16_000); + }); + + it("falls back to 32768 / 4000 when no source is available", () => { + const budget = createTurnContextBudget({}); + + expect(budget.maxInputTokens).toBe(32_768); + expect(budget.reservedForResponse).toBe(4000); + }); + + it("re-resolves capability on every access (llm.toml hot-reload)", () => { + let window = 64_000; + const budget = createTurnContextBudget({ + resolveNarrativeBudget: () => ({ contextWindow: window }), + }); + + expect(budget.maxInputTokens).toBe(64_000); + window = 1_000_000; + expect(budget.maxInputTokens).toBe(1_000_000); + }); +}); diff --git a/apps/web/src/components/blocks/block-renderer.tsx b/apps/web/src/components/blocks/block-renderer.tsx deleted file mode 100644 index a9606fe9..00000000 --- a/apps/web/src/components/blocks/block-renderer.tsx +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Block renderer — schema + raw fallback only. - * - * 消息渲染已经全部走 json-render + plugin-data 数据流(见 - * `apps/web/src/components/session/chat-messages.tsx`)。本模块只保留两个 - * 对外契约: - * - * 1. `setBlockSchemas(schemas)` — 供 session-store 在 boot 时注入插件声明的 - * blockSchemas,schema-block-renderer 用它为没有 json-render spec 的历史 - * block 兜底渲染。 - * 2. `getBlockRenderer(blockType)` — 返回 schema-driven renderer(若有) - * 或 null;不再维护硬编码的 kebab→React 组件映射。 - * - * 所有自定义 React block 组件(action-guide-block / codex-entry-block / …) - * 已被删除——插件用自己的 `ui/*.json` spec 通过 json-render 渲染。 - */ - -import { SchemaBlockRenderer } from "./schema-block-renderer.js"; -import type { BlockSchemaDeclaration } from "@covel/shared"; - -export interface BlockRendererProps { - data: Record; - onSubmit: (value: string) => void; - onSelect?: (value: string) => void; - selectedValue?: string | null; - disabled?: boolean; -} - -type BlockRendererComponent = React.ComponentType; - -// Module-level mutable state: a singleton registry updated once at boot -// (via setBlockSchemas) and read by getBlockRenderer during render. -let blockSchemas: Record = {}; - -export function setBlockSchemas( - schemas: Record, -) { - blockSchemas = schemas; -} - -/** - * Returns a component that can render the given block type using the - * plugin-declared schema. Returns null when no schema is registered — the - * caller should fall back to json-render (chat-messages.tsx) or raw JSON. - */ -export function getBlockRenderer( - blockType: string, -): BlockRendererComponent | null { - const schema = blockSchemas[blockType]; - if (!schema) return null; - - return function SchemaBlock(props: BlockRendererProps) { - return ( - - ); - }; -} diff --git a/apps/web/src/components/blocks/schema-block-renderer.tsx b/apps/web/src/components/blocks/schema-block-renderer.tsx deleted file mode 100644 index 76ea0a5d..00000000 --- a/apps/web/src/components/blocks/schema-block-renderer.tsx +++ /dev/null @@ -1,392 +0,0 @@ -import { useState } from "react"; -import i18n from "@/i18n"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import type { BlockSchemaDeclaration } from "@covel/shared"; - -export interface SchemaBlockRendererProps { - schema: BlockSchemaDeclaration; - data: Record; - onSubmit: (value: string) => void; - disabled?: boolean; -} - -export function SchemaBlockRenderer({ - schema, - data, - onSubmit, - disabled, -}: SchemaBlockRendererProps) { - const [formValues, setFormValues] = useState>({}); - - if (schema.interactive && schema.submitSchema) { - return ( - - ); - } - - return ; -} - -// ── Display (read-only) ─────────────────────────────────────────── - -function DisplaySchemaBlock({ - schema, - data, -}: { - schema: BlockSchemaDeclaration; - data: Record; -}) { - const properties = (schema.dataSchema.properties ?? {}) as Record< - string, - Record - >; - - return ( - - - - {resolveDisplayName(schema.meta.displayName ?? "")} - - - - {Object.entries(properties).map(([key, propSchema]) => - renderDisplayField(key, propSchema, data[key]), - )} - - - ); -} - -function renderDisplayField( - key: string, - propSchema: Record, - value: unknown, -): React.ReactNode { - if (value === undefined || value === null) return null; - - const type = propSchema.type as string; - - if (type === "string") { - return ( -
-

- {key} -

-

{String(value)}

-
- ); - } - - if (type === "number" || type === "integer") { - return ( -
-

- {key} -

-

{String(value)}

-
- ); - } - - if (type === "boolean") { - return ( -
-

- {key} -

- - {value ? i18n.t("common.yes", "Yes") : i18n.t("common.no", "No")} - -
- ); - } - - if (type === "array" && Array.isArray(value)) { - const itemSchema = (propSchema.items ?? {}) as Record; - return ( -
-

- {key} -

-
- {/* Items lack stable IDs; index key is acceptable for static display */} - {value.map((item, i) => ( -
- {typeof item === "object" && item !== null - ? renderObjectFields( - item as Record, - itemSchema, - ) - : String(item)} -
- ))} -
-
- ); - } - - if (type === "object" && typeof value === "object") { - return ( -
-

- {key} -

-
- {renderObjectFields(value as Record, propSchema)} -
-
- ); - } - - return ( -
-

- {key} -

-

- {JSON.stringify(value)} -

-
- ); -} - -function renderObjectFields( - obj: Record, - schema: Record, -) { - const props = (schema.properties ?? {}) as Record< - string, - Record - >; - const keys = - Object.keys(props).length > 0 ? Object.keys(props) : Object.keys(obj); - return ( -
- {keys.map((k) => { - const val = obj[k]; - if (val === undefined || val === null) return null; - return ( -
- {k}: - - {typeof val === "object" ? JSON.stringify(val) : String(val)} - -
- ); - })} -
- ); -} - -// ── Interactive (form) ──────────────────────────────────────────── - -function InteractiveSchemaBlock({ - schema, - data, - formValues, - setFormValues, - onSubmit, - disabled, -}: { - schema: BlockSchemaDeclaration; - data: Record; - formValues: Record; - setFormValues: React.Dispatch>>; - onSubmit: (value: string) => void; - disabled?: boolean; -}) { - const properties = (schema.dataSchema.properties ?? {}) as Record< - string, - Record - >; - - const title = data.title as string | undefined; - const description = data.description as string | undefined; - - const handleFieldChange = (key: string, value: unknown) => { - setFormValues((prev) => ({ ...prev, [key]: value })); - }; - - const handleSubmit = () => { - const result = Object.keys(formValues).length > 0 ? formValues : data; - onSubmit(JSON.stringify(result)); - }; - - // Render interactive fields from data's array-type fields - const arrayFields = Object.entries(properties).filter( - ([, ps]) => ps.type === "array", - ); - - return ( - - - - {title ?? resolveDisplayName(schema.meta.displayName ?? "")} - - {description && ( -

{description}

- )} -
- - {/* Render simple string/number fields from data */} - {Object.entries(properties).map(([key, propSchema]) => { - if (propSchema.type === "array") return null; - const value = data[key]; - if (value === undefined || value === null) return null; - return renderDisplayField(key, propSchema, value); - })} - - {/* Render interactive form fields from array data */} - {arrayFields.map(([key, propSchema]) => { - const items = data[key] as Array> | undefined; - if (!items || !Array.isArray(items)) return null; - const itemSchema = (propSchema.items ?? {}) as Record< - string, - unknown - >; - - return ( -
- {items.map((item, idx) => { - const fieldKey = - (item.key as string) ?? (item.id as string) ?? String(idx); - const label = (item.label as string) ?? fieldKey; - const fieldType = (item.type as string) ?? "text"; - const placeholder = item.placeholder as string | undefined; - const options = item.options as string[] | undefined; - const required = item.required as boolean | undefined; - - return ( -
- - {renderFormInput({ - id: `schema-field-${fieldKey}`, - fieldType, - placeholder, - options, - value: formValues[fieldKey] as string | undefined, - onChange: (v) => handleFieldChange(fieldKey, v), - disabled, - })} -
- ); - })} -
- ); - })} - - -
-
- ); -} - -function renderFormInput({ - id, - fieldType, - placeholder, - options, - value, - onChange, - disabled, -}: { - id: string; - fieldType: string; - placeholder?: string; - options?: string[]; - value?: string; - onChange: (value: string) => void; - disabled?: boolean; -}) { - const baseClass = - "w-full bg-background border border-border px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-primary transition-all disabled:opacity-50"; - - if (fieldType === "select" && options) { - return ( - - ); - } - - if (fieldType === "textarea") { - return ( -