diff --git a/AGENTS.md b/AGENTS.md index 1c3bc64e30..fb1750b423 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,25 +34,37 @@ vendor/ Vendored Cordis framework source (original npm names, private). See vendor/README.md for the manifest, local-modification log, and the upstream sync procedure. Do NOT edit casually — every divergence must be logged there. -packages/ Harness packages, all named @deepseek-ai/dsh-: - llm/ abstract LLM service + content-block vocabulary - llm-deepseek/ DeepSeek API adapter (hand-rolled fetch/SSE) - llm-pi-ai/ DeepSeek adapter via @earendil-works/pi-ai (design twin) - session/ event-sourced session log + in-memory store - system-prompt/ prompt-section + tool-schema assembly registry - tools/ tool registry + tools/execute waterfall - agent/ Agent interface, registry, agent/* event vocabulary - agent-loop/ THE concrete plugin: ReactLoopAgent + the loop driver - invariants/ dev-mode event-contract invariants + session-log freeze - bash/ abstract bash executor seam (ctx.bash) — interface only - bash-local/ local-subprocess BashExecutor implementation - tool-bash/ model-facing bash/bash_output/bash_kill tool schemas - acp/ Agent Client Protocol bridge: drive the agent from an ACP - editor (Zed) over JSON-RPC stdio - ui-stdio/ minimal stdio (readline) UI plugin: renders agent/* events, - feeds stdin lines to the agent (shared by the demos) - llm-replay/ record/replay adapter: short-circuits llm/stream from a - recorded session JSONL (keyless snapshot tests) +packages/ Harness packages, grouped by role at packages///. + Every package is named @deepseek-ai/dsh-; the group dir is a + pure container (no package.json). See packages/README.md and each + group's README.md for the product-vs-support split. + core/ product API spine + session/ event-sourced session log + in-memory store + system-prompt/ prompt-section + tool-schema assembly registry + tools/ tool registry + tools/execute waterfall + agent/ Agent interface, registry, agent/* event vocabulary + agent-loop/ THE concrete plugin: ReactLoopAgent + the loop driver + llm/ LLM capability family + llm/ abstract LLM service + content-block vocabulary + llm-deepseek/ DeepSeek API adapter (hand-rolled fetch/SSE) + llm-pi-ai/ DeepSeek adapter via @earendil-works/pi-ai (design twin) + bash/ bash capability family + bash/ abstract bash executor seam (ctx.bash) — interface only + bash-local/ local-subprocess BashExecutor implementation + tool-bash/ model-facing bash/bash_output/bash_kill tool schemas + session-persistence/ persistence capability family + session-persistence/ durable persistence seam + write coordinator + session-persistence-jsonl/ JSONL-sidecar backend + session-persistence-sqlite/ SQLite backend + ui/ product integration surfaces + acp/ Agent Client Protocol bridge: drive the agent from an ACP + editor (Zed) over JSON-RPC stdio + support/ dev/test/example infrastructure (lower compat expectations) + invariants/ dev-mode event-contract invariants + session-log freeze + ui-stdio/ minimal stdio (readline) UI plugin: renders agent/* events, + feeds stdin lines to the agent (shared by the demos) + llm-replay/ record/replay adapter: short-circuits llm/stream from a + recorded session JSONL (keyless snapshot tests) examples/ Runnable demos (not workspaces; see examples/AGENTS.md). echo-agent = mock model + echo tool + stdio UI + JSONL persistence, wired via cordis.yml. coding-agent = the real thing: DeepSeek V4 + bash tools @@ -83,7 +95,7 @@ scripts/ repo maintenance scripts (vendor-manifest guard, publint runner). ```sh pnpm install # pnpm workspaces, node >= 24 pnpm run test # vitest run (packages|examples/*/tests/**/*.spec.ts) -pnpm run test:coverage # vitest run --coverage (per-file 100% gate on packages/*/src) +pnpm run test:coverage # vitest run --coverage (per-file 100% gate on packages/*/*/src) pnpm run test:e2e # real-API tests (packages|examples/*/tests/**/*.e2e.ts); # self-skips without DEEPSEEK_API_KEY — see Secrets below pnpm run test:snapshot # ACP snapshot tests (examples/*/tests/**/*.snapshot.ts): @@ -102,10 +114,10 @@ pnpm run lint # eslint . pnpm run lint:fix # eslint . --fix pnpm run build # tsc -b tsconfig.build.json && tsdown (JS bundles into lib/) pnpm run knip # dead-code / unused-dependency check -pnpm run publint # package.json publish-correctness check (publishable packages/*) +pnpm run publint # package.json publish-correctness check (every packages/*/* package) pnpm run hygiene # knip + publint + workspace constraints pnpm run doc-typecheck # typecheck every ```ts block in README.md, docs/**/*.md, - # packages/*/*.md (doc/code drift gate) + # packages/*/*.md + packages/*/*/*.md (doc/code drift gate) pnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events-and-services.md # (events + services) from the interface Events / Context source pnpm run verify-cordis-catalog # assert that generated catalog is not stale @@ -113,10 +125,12 @@ pnpm run verify-md-wrap # assert no hard-wrapped prose paragraphs in README.md, # docs/**/*.md, packages/*/*.md, AGENTS.md (one line per paragraph) pnpm run verify-doc-refs # assert every docs/*.md path cited in a packages|examples # TypeScript comment resolves (catches a moved/renamed doc) +pnpm run verify-package-paths # assert every packages/ cited in Markdown or a + # TypeScript comment resolves when it names a real (moved) package pnpm run verify-rfc-classification # assert every RFC lives in a valid # {lifecycle}/{class}/ folder and docs/rfc/README.md lists it # under the matching heading (closed class set + index completeness) -pnpm run doc-sync # doc-typecheck + verify-cordis-catalog + verify-md-wrap + verify-md-links + verify-doc-refs + verify-rfc-classification + verify-type-equiv (CI runs this) +pnpm run doc-sync # doc-typecheck + verify-cordis-catalog + verify-md-wrap + verify-md-links + verify-doc-refs + verify-package-paths + verify-rfc-classification + verify-type-equiv (CI runs this) pnpm run demo:echo # run examples/echo-agent (no API key; type "echo hi" to # see a tool call) — the mock skeleton pnpm run demo:coding # run examples/coding-agent — the real agent (needs @@ -158,7 +172,7 @@ Dev/test/demo run **unbuilt** via tsx + the `paths` map in the root `tsconfig.js - **Symmetry is usually more correct**: when two related values play parallel roles (a test fixture and its expected output, a request shape and its response shape, a buggy input and the test that checks the fix), give them parallel form — both named consts, or both inline, not one each way. Asymmetry is a smell that usually points at a missed extraction. - **Merging PRs**: always merge with a **merge commit** (`gh pr merge --merge`), never squash or rebase. The per-PR commit history is intentional — review-fix commits, regression-test commits, and the reasoning in each message are part of the record — and squashing flattens it away. - **TODO markers**: use `FIXME`/`TODO`/`XXX` to flag known issues by urgency — see [docs/development.md](docs/development.md) for the semantics of each. -- **Tests**: vitest, colocated under `packages//tests/*.spec.ts`. Every registry needs an HMR-safety test (dispose the contributing fiber, assert cleanup). **Excessive tests are welcome** — when in doubt, write the test; err on the side of covering edge cases, error paths, event ordering, and concurrency races even if they seem unlikely. Review findings get regression tests (see `packages/agent-loop/tests/review-fixes.spec.ts`). The same generosity applies to **real-API (with-key) e2e tests — inference is cheap here (we are DeepSeek), so do not ration them**: cover the agent's real flows (a real prompt that writes a file, multi-turn, tool use, cancellation) and run them frequently while developing, especially cheap **smoke tests** that boot the real example and check the world. A green mock/no-key suite proves the plumbing, not the product — the with-key smoke test is what catches "green units, broken product". See § Secrets / .env for the with-key policy and why self-skip is a CI accommodation, not a verdict that real-API tests are expensive. +- **Tests**: vitest, colocated under `packages///tests/*.spec.ts`. Every registry needs an HMR-safety test (dispose the contributing fiber, assert cleanup). **Excessive tests are welcome** — when in doubt, write the test; err on the side of covering edge cases, error paths, event ordering, and concurrency races even if they seem unlikely. Review findings get regression tests (see `packages/core/agent-loop/tests/review-fixes.spec.ts`). The same generosity applies to **real-API (with-key) e2e tests — inference is cheap here (we are DeepSeek), so do not ration them**: cover the agent's real flows (a real prompt that writes a file, multi-turn, tool use, cancellation) and run them frequently while developing, especially cheap **smoke tests** that boot the real example and check the world. A green mock/no-key suite proves the plumbing, not the product — the with-key smoke test is what catches "green units, broken product". See § Secrets / .env for the with-key policy and why self-skip is a CI accommodation, not a verdict that real-API tests are expensive. - **Prefer the REAL implementation over a mock/stand-in in tests.** When the genuine collaborator is available in the repo, wire it up instead of hand-rolling a fake — a test that registers an inline `defineTool({ name: 'bash', … })` to stand in for `dsh-tool-bash` proves the *bridge* moves bytes but not that the *shipping tool* renders the way the test asserts; the two drift and the test passes while the product is wrong. Mock only the genuinely expensive/non-deterministic boundary (the LLM adapter, the network, the clock) and keep everything downstream real: a bridge tool-call test runs the scripted mock MODEL but the REAL tool + REAL executor (e.g. `makeBridgeHarness({ withBash: true })` plugs `dsh-bash-local` + `dsh-tool-bash` and runs an actual `echo`), so it verifies the actual `presentCall`/`presentResult` an editor sees. This is the unit-test echo of "verify the world, not a synthetic stand-in" (see § Defensive patterns) — a fake you wrote will agree with whatever you assumed; the real thing won't. - **A change that affects the editor-facing transcript or end-to-end agent UX needs a snapshot test (or an explicit note in the PR why none applies).** The snapshot tier (`examples/*/tests/**/*.snapshot.ts`, `pnpm run test:snapshot`) boots the real example subprocess, replays a recorded session JSONL deterministically (keyless), and diffs the normalized stdout transcript + re-persisted session log against committed goldens — the full-transcript regression net that mock-level unit tests structurally cannot be (it is what catches a bridge-translation or loop-structure regression that leaves every unit green). When you change the ACP bridge, the agent loop's observable output, tool presentation, or anything an editor renders, add or update a scenario under `examples/acp-agent/tests/snapshots/` and re-record with `pnpm run test:snapshot:record`. Reviewing the golden diff is part of the review. The rule is scoped to transcript/UX-affecting changes — a pure internal refactor with no observable-output change does not need one, but say so. See [docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md](docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md). @@ -178,11 +192,13 @@ Each bullet is a bug class that bit us; the rule prevents the reoccurrence. ## Type Safety and Documentation -This codebase aims to be **very type-safe and well documented** for maintainability. Code that fails to compile under `strict: true` (with `noImplicitAny` enabled for all `packages/*` source) is not acceptable. Every `any` that remains must have a specific justification (a comment explaining why a narrower type is infeasible). +This codebase aims to be **very type-safe and well documented** for maintainability. Code that fails to compile under `strict: true` (with `noImplicitAny` enabled for all `packages/*/*` source) is not acceptable. Every `any` that remains must have a specific justification (a comment explaining why a narrower type is infeasible). -In the **core** packages (`packages/llm`, `packages/tools`, `packages/agent`, `packages/agent-loop`, `packages/session`, `packages/system-prompt`), **type gymnastics are acceptable when they improve the DX of plugin authors** for common plugin types. The `defineTool` typed schema DSL in `dsh-tools` is the canonical example: the `SchemaSpec` to `InferArgs` type-level mapping gives tool authors zero-cast typed `execute` args, and the cost of the conditional types stays inside the core package. +**Almost always lean toward the stricter lint rule.** In the agentic-coding era the cost/benefit of strictness has inverted: a machine writes and reads most of the code, so the one-time cost of satisfying a stricter rule is cheap and paid by a tool, while the benefit — a whole class of error caught mechanically, a consistent foundation every agent can rely on, less reviewer attention spent on what a linter could have caught — compounds across every future change. When choosing whether to enable a rule, tighten an existing one, or add a new gate (a `verify-*` script, a constraint check), default to YES unless it has a concrete, recurring false-positive problem. Prefer a narrowly-scoped escape hatch (a justified inline disable with a reason, a per-path override) over leaving the rule off globally. The same reasoning motivates this repo's many bespoke gates (`doc-sync`, `verify-package-paths`, the workspace-shape constraint): encode the invariant in a check so no human or agent has to remember it. -Verbose documentation is fine **as long as docs and code stay strictly in sync**. Out-of-sync docs are worse than no docs. **When you change code, update its docs in the SAME change** — grep the package README and the module/JSDoc comments for the old behavior (config keys, defaults, error codes, wire field names, event names) and fix every hit. CI runs `pnpm run doc-sync` (`doc-typecheck` + `verify-cordis-catalog` + `verify-md-wrap` + `verify-md-links` + `verify-doc-refs` + `verify-rfc-classification` + `verify-type-equiv`), which typechecks every fenced `ts` block in `README.md`, `docs/**/*.md`, and `packages/*/*.md`, regenerates the cordis events/services catalog from source and fails if the committed copy is stale, asserts no hard-wrapped prose paragraphs, checks that every relative Markdown cross-link resolves, checks that every `docs/*.md` path cited in a source comment resolves, checks that every RFC is filed under a valid class folder and listed in its index, and checks that every ` ```ts type-equiv ` doc block still matches its source type — across those files plus `AGENTS.md` / `packages/AGENTS.md` — but that scope does NOT catch prose drift in `AGENTS.md` / `packages/AGENTS.md` / `packages/README.md` (config keys, defaults, error codes), so keeping those in sync remains on the author. Every module has a module-level doc comment explaining its role. Every exported class, interface, type, function, and non-obvious method has a JSDoc that explains semantics (not just the name) — contracts (what events fire when), disposal behavior, error behavior, and extension intent. Internal helpers get docs only where non-obvious. Prefer one-liners when one line suffices. +In the **core** packages (`packages/llm/llm`, `packages/core/tools`, `packages/core/agent`, `packages/core/agent-loop`, `packages/core/session`, `packages/core/system-prompt`), **type gymnastics are acceptable when they improve the DX of plugin authors** for common plugin types. The `defineTool` typed schema DSL in `dsh-tools` is the canonical example: the `SchemaSpec` to `InferArgs` type-level mapping gives tool authors zero-cast typed `execute` args, and the cost of the conditional types stays inside the core package. + +Verbose documentation is fine **as long as docs and code stay strictly in sync**. Out-of-sync docs are worse than no docs. **When you change code, update its docs in the SAME change** — grep the package README and the module/JSDoc comments for the old behavior (config keys, defaults, error codes, wire field names, event names) and fix every hit. CI runs `pnpm run doc-sync` (`doc-typecheck` + `verify-cordis-catalog` + `verify-md-wrap` + `verify-md-links` + `verify-doc-refs` + `verify-package-paths` + `verify-rfc-classification` + `verify-type-equiv`), which typechecks every fenced `ts` block in `README.md`, `docs/**/*.md`, and `packages/*/*.md`, regenerates the cordis events/services catalog from source and fails if the committed copy is stale, asserts no hard-wrapped prose paragraphs, checks that every relative Markdown cross-link resolves, checks that every `docs/*.md` path cited in a source comment resolves, checks that every `packages/` reference naming a real package resolves, checks that every RFC is filed under a valid class folder and listed in its index, and checks that every ` ```ts type-equiv ` doc block still matches its source type — across those files plus `AGENTS.md` / `packages/AGENTS.md` — but that scope does NOT catch prose drift in `AGENTS.md` / `packages/AGENTS.md` / `packages/README.md` (config keys, defaults, error codes), so keeping those in sync remains on the author. Every module has a module-level doc comment explaining its role. Every exported class, interface, type, function, and non-obvious method has a JSDoc that explains semantics (not just the name) — contracts (what events fire when), disposal behavior, error behavior, and extension intent. Internal helpers get docs only where non-obvious. Prefer one-liners when one line suffices. **Tag every new event with `@mode`.** The cordis events/services catalog ([docs/cordis-catalog/events-and-services.md](docs/cordis-catalog/events-and-services.md)) is GENERATED from source by `scripts/gen-cordis-catalog.ts` — never hand-edit it; run `pnpm run gen-cordis-catalog` and commit the result. When you add an event to an `interface Events` block, its JSDoc MUST carry a `@mode emit|waterfall|parallel` tag (the generator hard-errors without it): use `waterfall` when the signature ends with a `next: () => …` parameter (the listener transforms or vetoes via `next()`), `parallel` when the loop awaits a fan-out with no veto (e.g. an awaited `Promise | void` checkpoint like `session/flush`), and `emit` for plain fire-and-forget notifications. The generator also cross-checks the tag against the signature where the shape is conclusive (a trailing `next` ⇒ waterfall) and hard-errors on a contradiction. Write the rest of the event's JSDoc to stand alone — it is the catalog entry's prose. diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md index ed40f242e4..1ac20c1c3a 100644 --- a/docs/cookbook/adding-a-package.md +++ b/docs/cookbook/adding-a-package.md @@ -6,7 +6,7 @@ The file-by-file checklist for a new `@deepseek-ai/dsh-` package. (Verifie ``` packages// - package.json # copy from packages/tools, adjust name/description/deps + package.json # copy from packages/core/tools, adjust name/description/deps tsconfig.json # extends ../../tsconfig.base.json, rootDir src, outDir lib, # references: vendor/cosmokit, vendor/cordis (+ vendor/schemastery # if you use Config, + ../ for each dsh dependency) @@ -25,7 +25,7 @@ package.json invariants (enforced by `pnpm run constraints` / `scripts/check-wor | `tsconfig.typecheck.json` | same entry (this file overrides the map wholesale) | | `tsconfig.build.json` | add `{ "path": "./packages/" }` to `references` | | `scripts/publint-all.ts` | add `'packages/'` to the array | -| `knip.json` | only if the package has non-`*.spec.ts` entries (e.g. `*.e2e.ts` → add a per-workspace override like `packages/llm-deepseek`) | +| `knip.json` | only if the package has non-`*.spec.ts` entries (e.g. `*.e2e.ts` → add a per-workspace override like `packages/llm/llm-deepseek`) | Covered automatically by globs — no edits needed: root `package.json` workspaces, `tsdown.config.ts`, `vitest.config.ts`, `eslint.config.mjs`. diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index 330a2db17c..73706c84f1 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -1,6 +1,6 @@ # Cookbook: adding a tool -How to give the model a new capability. Reference implementations: `examples/echo-agent/src/echo-tool.ts` (minimal) and `packages/tool-bash` (production-grade, three-package seam). +How to give the model a new capability. Reference implementations: `examples/echo-agent/src/echo-tool.ts` (minimal) and `packages/bash/tool-bash` (production-grade, three-package seam). ## The minimal shape @@ -50,4 +50,4 @@ Prefer not to build policy into the tool. The seam is the `tools/execute` waterf ## Tests every tool needs -Arg-validation rejections, result shaping for every outcome, the HMR disposal test, and — for tools with side effects — an integration spec that drives the tool through the agent loop with a scripted `MockAdapter` (`packages/agent-loop/tests/mock-adapter.ts`), asserting the `tool/call` / `tool/result` session events. +Arg-validation rejections, result shaping for every outcome, the HMR disposal test, and — for tools with side effects — an integration spec that drives the tool through the agent loop with a scripted `MockAdapter` (`packages/core/agent-loop/tests/mock-adapter.ts`), asserting the `tool/call` / `tool/result` session events. diff --git a/docs/cookbook/adding-an-llm-adapter.md b/docs/cookbook/adding-an-llm-adapter.md index 19bc4e9e05..aae6b0bb8b 100644 --- a/docs/cookbook/adding-an-llm-adapter.md +++ b/docs/cookbook/adding-an-llm-adapter.md @@ -1,6 +1,6 @@ # Cookbook: adding an LLM adapter -How to connect a new model provider. Reference implementations: `packages/llm-deepseek` (hand-rolled HTTP/SSE) and `packages/llm-pi-ai` (wrapping an LLM library). Read the `StreamChunk` doc in `packages/llm/src/types.ts` first — it records the protocol conventions both adapters were verified against. +How to connect a new model provider. Reference implementations: `packages/llm/llm-deepseek` (hand-rolled HTTP/SSE) and `packages/llm/llm-pi-ai` (wrapping an LLM library). Read the `StreamChunk` doc in `packages/llm/llm/src/types.ts` first — it records the protocol conventions both adapters were verified against. ## The shape diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index df40f1384a..1739acd32c 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -57,7 +57,7 @@ export function apply(ctx: Context) { A *client driver* is a UI plugin whose "user" is another program speaking a wire protocol rather than a human at a terminal. It owns the process's stdio (so it must run with **no stdout logger** — every non-protocol byte corrupts the stream), creates/resumes agents on demand through the `dsh-agent` factory seam, translates harness events (`session/event`, `agent/*`) into outbound protocol messages, and translates inbound requests back into `agent.send()` / `agent.abort()`. Two harness-specific contracts make it correct: resolve each request exactly once off a settle signal (the turn can end without its `agent/turn-end` event firing — fall back through the logged `turn/end` record), and on disposal reach quiescence (`await agent.whenIdle()` after `abort()`), not just request it. -`packages/acp` is the worked example: it bridges the agent to the Agent Client Protocol (JSON-RPC over stdio) so Zed and other ACP editors can drive it. See its README for the full method surface and the deferred-permission-gate note. +`packages/ui/acp` is the worked example: it bridges the agent to the Agent Client Protocol (JSON-RPC over stdio) so Zed and other ACP editors can drive it. See its README for the full method surface and the deferred-permission-gate note. ```ts import type { Context } from 'cordis' diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index e34a2321d1..d6ca9f9850 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -25,7 +25,7 @@ An agent was registered in the AgentRegistry and is ready to receive messages. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/agent/src/types.ts:140`](../../packages/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:140`](../../packages/core/agent/src/types.ts) #### `agent/disposed` — emit @@ -37,7 +37,7 @@ An agent was disposed and removed from the registry; its fiber and any in-flight Types: [Agent](../core-data-structures/core.md) -Source: [`packages/agent/src/types.ts:146`](../../packages/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:146`](../../packages/core/agent/src/types.ts) #### `agent/error` — emit @@ -49,7 +49,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/agent/src/types.ts:223`](../../packages/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:223`](../../packages/core/agent/src/types.ts) #### `agent/queued` — emit @@ -61,7 +61,7 @@ A message entered the agent's inbox (queued or steering). `source` is the resolv Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/agent/src/types.ts:159`](../../packages/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:159`](../../packages/core/agent/src/types.ts) #### `agent/request` — waterfall @@ -73,7 +73,7 @@ Waterfall: mutate the fully-assembled GenerateOptions before the model call (hoo Types: [Agent](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) -Source: [`packages/agent/src/types.ts:192`](../../packages/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:192`](../../packages/core/agent/src/types.ts) #### `agent/status` — emit @@ -85,7 +85,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle Types: [Agent](../core-data-structures/core.md) -Source: [`packages/agent/src/types.ts:153`](../../packages/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:153`](../../packages/core/agent/src/types.ts) #### `agent/steering` — emit @@ -97,7 +97,7 @@ Steering content was injected into a running turn. Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/agent/src/types.ts:217`](../../packages/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:217`](../../packages/core/agent/src/types.ts) #### `agent/step-end` — emit @@ -109,7 +109,7 @@ A step ended. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/agent/src/types.ts:183`](../../packages/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:183`](../../packages/core/agent/src/types.ts) #### `agent/step-result` — waterfall @@ -121,7 +121,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/agent/src/types.ts:198`](../../packages/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:198`](../../packages/core/agent/src/types.ts) #### `agent/step-start` — emit @@ -133,7 +133,7 @@ A step (one model call plus its tool dispatch) began. `step` is 1-based within t Types: [Agent](../core-data-structures/core.md) -Source: [`packages/agent/src/types.ts:178`](../../packages/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:178`](../../packages/core/agent/src/types.ts) #### `agent/stream-chunk` — emit @@ -145,7 +145,7 @@ A raw StreamChunk arrived from the model (token-level UI/log feed). Types: [Agent](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/agent/src/types.ts:212`](../../packages/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:212`](../../packages/core/agent/src/types.ts) #### `agent/turn-continuation` — waterfall @@ -157,7 +157,7 @@ Waterfall: override the turn-continuation decision. The default (computed by the Types: [Agent](../core-data-structures/core.md) -Source: [`packages/agent/src/types.ts:205`](../../packages/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:205`](../../packages/core/agent/src/types.ts) #### `agent/turn-end` — emit @@ -169,7 +169,7 @@ A turn ended. `reason` distinguishes a clean stop from a truncated or aborted on Types: [Agent](../core-data-structures/core.md) · [TurnEndReason](../core-data-structures/session.md) -Source: [`packages/agent/src/types.ts:172`](../../packages/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:172`](../../packages/core/agent/src/types.ts) #### `agent/turn-start` — emit @@ -181,7 +181,7 @@ A turn began. `turn` is the 1-based turn number within the session. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/agent/src/types.ts:166`](../../packages/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:166`](../../packages/core/agent/src/types.ts) ### `llm/*` @@ -193,7 +193,7 @@ An adapter was registered or unregistered (the model→adapter map changed). 'llm/adapter-change'(): void ``` -Source: [`packages/llm/src/index.ts:43`](../../packages/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:43`](../../packages/llm/llm/src/index.ts) #### `llm/generate` — waterfall @@ -205,7 +205,7 @@ Waterfall around every non-streaming model call. Bound to the LlmService; call ` Types: [GenerateOptions](../core-data-structures/core.md) · [GenerateResult](../core-data-structures/core.md) -Source: [`packages/llm/src/index.ts:38`](../../packages/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:38`](../../packages/llm/llm/src/index.ts) #### `llm/stream` — waterfall @@ -217,7 +217,7 @@ Waterfall around every streaming model call (retry, caching, routing). Bound to Types: [GenerateOptions](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/src/index.ts:32`](../../packages/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:32`](../../packages/llm/llm/src/index.ts) ### `session/*` @@ -229,7 +229,7 @@ A session was created in the store. 'session/created'(session: Session): void ``` -Source: [`packages/session/src/index.ts:30`](../../packages/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:30`](../../packages/core/session/src/index.ts) #### `session/event` — emit @@ -241,7 +241,7 @@ An event was appended to a session log (sync, fire-and-forget). This is the per- Types: [SessionEvent](../core-data-structures/core.md) -Source: [`packages/session/src/index.ts:36`](../../packages/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:36`](../../packages/core/session/src/index.ts) #### `session/flush` — parallel @@ -251,7 +251,7 @@ Awaited durability checkpoint. The agent loop awaits `ctx.parallel('session/flus 'session/flush'(session: Session): Promise | void ``` -Source: [`packages/session/src/index.ts:45`](../../packages/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:45`](../../packages/core/session/src/index.ts) ### `system-prompt/*` @@ -263,7 +263,7 @@ Waterfall around prompt assembly — mutate or extend the PromptAssembly (sectio 'system-prompt/assemble'(this: SystemPrompt, assembly: PromptAssembly, next: () => Promise): Promise ``` -Source: [`packages/system-prompt/src/index.ts:24`](../../packages/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:24`](../../packages/core/system-prompt/src/index.ts) #### `system-prompt/change` — emit @@ -273,7 +273,7 @@ A section or tool provider was registered or unregistered (the assembly inputs c 'system-prompt/change'(): void ``` -Source: [`packages/system-prompt/src/index.ts:30`](../../packages/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:30`](../../packages/core/system-prompt/src/index.ts) ### `tools/*` @@ -285,7 +285,7 @@ A tool was registered or unregistered (the available tool set changed). 'tools/change'(): void ``` -Source: [`packages/tools/src/index.ts:48`](../../packages/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:48`](../../packages/core/tools/src/index.ts) #### `tools/execute` — waterfall @@ -297,7 +297,7 @@ Waterfall around every tool execution — the single seam where sandbox, permiss Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) -Source: [`packages/tools/src/index.ts:43`](../../packages/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:43`](../../packages/core/tools/src/index.ts) ## Services @@ -315,7 +315,7 @@ createAgent(options: CreateAgentOptions): AgentHandle async resume(options: ResumeAgentOptions): Promise ``` -Source: [`packages/agent-loop/src/index.ts:60`](../../packages/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:60`](../../packages/core/agent-loop/src/index.ts) ### `ctx.agents` — `AgentRegistry` @@ -332,7 +332,7 @@ list(): Agent[] Types: [Agent](../core-data-structures/core.md) -Source: [`packages/agent/src/index.ts:105`](../../packages/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:105`](../../packages/core/agent/src/index.ts) ### `ctx.bash` — `BashExecutor` (abstract seam) @@ -359,7 +359,7 @@ onTaskDone(listener: BashTaskListener): () => void Types: [BashExecRequest](../core-data-structures/bash.md) · [BashExecSpec](../core-data-structures/bash.md) · [BashRunResult](../core-data-structures/bash.md) · [BashTask](../core-data-structures/bash.md) · [BashTaskRead](../core-data-structures/bash.md) -Source: [`packages/bash/src/index.ts:58`](../../packages/bash/src/index.ts) +Source: [`packages/bash/bash/src/index.ts:58`](../../packages/bash/bash/src/index.ts) ### `ctx.llm` — `LlmService` @@ -375,7 +375,7 @@ generate(options: GenerateOptions): Promise Types: [ContentBlock](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) · [GenerateResult](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/src/index.ts:81`](../../packages/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:81`](../../packages/llm/llm/src/index.ts) ### `ctx.sessionPersistence` — `SessionPersistence` (abstract seam) @@ -399,7 +399,7 @@ abstract delete(id: SessionId): Promise Types: [SessionEvent](../core-data-structures/core.md) -Source: [`packages/session-persistence/src/index.ts:98`](../../packages/session-persistence/src/index.ts) +Source: [`packages/session-persistence/session-persistence/src/index.ts:98`](../../packages/session-persistence/session-persistence/src/index.ts) ### `ctx.sessions` — `SessionStore` @@ -416,7 +416,7 @@ get(id: string): Session | undefined list(): Session[] ``` -Source: [`packages/session/src/index.ts:222`](../../packages/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:222`](../../packages/core/session/src/index.ts) ### `ctx.systemPrompt` — `SystemPrompt` @@ -428,7 +428,7 @@ tools(provider: () => ToolSchema[]): () => void assemble(): Promise ``` -Source: [`packages/system-prompt/src/index.ts:71`](../../packages/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:71`](../../packages/core/system-prompt/src/index.ts) ### `ctx.tools` — `ToolRegistry` @@ -443,7 +443,7 @@ async execute(exec: ToolExecution): Promise Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) -Source: [`packages/tools/src/index.ts:277`](../../packages/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:277`](../../packages/core/tools/src/index.ts) ## Inherited tier (cordis core + loader/hmr/timer) diff --git a/docs/core-data-structures/bash.md b/docs/core-data-structures/bash.md index 043140e25e..c601d8cd74 100644 --- a/docs/core-data-structures/bash.md +++ b/docs/core-data-structures/bash.md @@ -1,8 +1,8 @@ # Bash Executor -The bash execution seam — the canonical [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md) example, split across three packages: interface ([dsh-bash](../../packages/bash), `ctx.bash`), implementation ([dsh-bash-local](../../packages/bash-local), local subprocesses), and consumer ([dsh-tool-bash](../../packages/tool-bash), the `bash`/`bash_output`/`bash_kill` tool schemas). Bash is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A sandboxed, containerized, or remote backend is a sibling package implementing the same interface. +The bash execution seam — the canonical [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md) example, split across three packages: interface ([dsh-bash](../../packages/bash/bash), `ctx.bash`), implementation ([dsh-bash-local](../../packages/bash/bash-local), local subprocesses), and consumer ([dsh-tool-bash](../../packages/bash/tool-bash), the `bash`/`bash_output`/`bash_kill` tool schemas). Bash is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A sandboxed, containerized, or remote backend is a sibling package implementing the same interface. -Source: [`packages/bash/src/types.ts`](../../packages/bash/src/types.ts) +Source: [`packages/bash/bash/src/types.ts`](../../packages/bash/bash/src/types.ts) ## Request vs. spec: the `resolve()` split @@ -120,4 +120,4 @@ interface BashTaskRead { ## The service -`BashExecutor` (`ctx.bash`, abstract — defined in [`packages/bash/src/index.ts`](../../packages/bash/src/index.ts)) mirrors the `LlmService`/`LlmAdapter` split: `resolve` (request → spec), `run` (foreground), `start` (background), `get`/`ownerOf`/`list`/`readOutput`/`kill`, and `onTaskDone` (a `BashTaskListener` completion callback). Spawned commands get a **scrubbed env** (dropping `*KEY*`/`*SECRET*`/`*TOKEN*`) and spill files use a private 0700 dir with random names and owner-only opens — model output never gets the ambient environment or a predictable path. The implementation that provides all this is `dsh-bash-local`; the model-facing `bash`/`bash_output`/`bash_kill` schemas that call it are in `dsh-tool-bash` (and present as terminals via the [tool-presentation vocabulary](tools.md#tool-presentation-ui-vocabulary)). +`BashExecutor` (`ctx.bash`, abstract — defined in [`packages/bash/bash/src/index.ts`](../../packages/bash/bash/src/index.ts)) mirrors the `LlmService`/`LlmAdapter` split: `resolve` (request → spec), `run` (foreground), `start` (background), `get`/`ownerOf`/`list`/`readOutput`/`kill`, and `onTaskDone` (a `BashTaskListener` completion callback). Spawned commands get a **scrubbed env** (dropping `*KEY*`/`*SECRET*`/`*TOKEN*`) and spill files use a private 0700 dir with random names and owner-only opens — model output never gets the ambient environment or a predictable path. The implementation that provides all this is `dsh-bash-local`; the model-facing `bash`/`bash_output`/`bash_kill` schemas that call it are in `dsh-tool-bash` (and present as terminals via the [tool-presentation vocabulary](tools.md#tool-presentation-ui-vocabulary)). diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 3743e6e0df..bcac8010f0 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -61,7 +61,7 @@ Two large discriminated unions are the ones consumers `switch` over most: **`Str IDs that cross package boundaries are **branded** — structurally strings, but non-interchangeable at the type level (an `AgentId` can't be passed where a `CallId` is expected). Construction goes through a per-type factory; comparison, logging, and JSON behave as ordinary strings. -Source: [`packages/llm/src/brand.ts`](../../packages/llm/src/brand.ts) +Source: [`packages/llm/llm/src/brand.ts`](../../packages/llm/llm/src/brand.ts) ```ts type-equiv type Branded = string & { readonly [BRAND]: B } @@ -73,7 +73,7 @@ The three core IDs: `CallId` (correlates a tool call with its result; dsh-llm), A conversation is `Message`s; a message is an array of typed **content blocks**. The block union derives from `ContentBlockMap`. -Source: [`packages/llm/src/types.ts`](../../packages/llm/src/types.ts) +Source: [`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts) ```ts type-equiv interface ContentBlockMap { @@ -116,7 +116,7 @@ The full union, the adapter contract (usage-before-finish, raw-JSON tool argumen One model call is a fully-assembled `GenerateOptions`; the non-streaming result is `GenerateResult`. -Source: [`packages/llm/src/types.ts`](../../packages/llm/src/types.ts) +Source: [`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts) ```ts type-equiv interface GenerateOptions { @@ -180,7 +180,7 @@ The model-facing `ToolSchema` is the wire shape; the registered `ToolDefinition` A `Session` is an **append-only log** of typed `SessionEvent`s — the single source of truth. The LLM message history is *derived* from the log (`deriveMessages()`), not stored separately. The event vocabulary derives from `SessionEventMap`: -Source: [`packages/session/src/types.ts`](../../packages/session/src/types.ts) +Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts) ```ts type-equiv type SessionEvent = { @@ -201,7 +201,7 @@ The thirteen event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `Agent` is the surface every plugin (UI, hooks, orchestrators) programs against. The concrete implementation is `ReactLoopAgent` in dsh-agent-loop; nothing outside the loop depends on the implementation. -Source: [`packages/agent/src/types.ts`](../../packages/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) ```ts type-equiv interface Agent { diff --git a/docs/core-data-structures/llm-streaming.md b/docs/core-data-structures/llm-streaming.md index 2e3d4a0838..1019e84a16 100644 --- a/docs/core-data-structures/llm-streaming.md +++ b/docs/core-data-structures/llm-streaming.md @@ -1,8 +1,8 @@ # LLM Streaming -The wire-level streaming vocabulary of [dsh-llm](../../packages/llm). [core.md](core.md) introduces `StreamChunk`, `Message`, and `ContentBlock`; this page owns the full chunk protocol, the adapter contract every adapter must obey, and the shared assembler. +The wire-level streaming vocabulary of [dsh-llm](../../packages/llm/llm). [core.md](core.md) introduces `StreamChunk`, `Message`, and `ContentBlock`; this page owns the full chunk protocol, the adapter contract every adapter must obey, and the shared assembler. -Source: [`packages/llm/src/types.ts`](../../packages/llm/src/types.ts) +Source: [`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts) ## `StreamChunk` — the raw protocol @@ -45,7 +45,7 @@ interface TokenUsage { ## `BlockAssembler` -`BlockAssembler` ([`packages/llm/src/assembler.ts`](../../packages/llm/src/assembler.ts)) is the single shared implementation that folds a `StreamChunk` stream back into `ContentBlock`s and a final `Message`. The loop logs the raw chunks (for replay fidelity) while feeding the same chunks through an assembler — so the canonical log keeps token-level detail and the derived message is rebuilt deterministically. A consumer that needs the assembled result without re-implementing the fold uses this. +`BlockAssembler` ([`packages/llm/llm/src/assembler.ts`](../../packages/llm/llm/src/assembler.ts)) is the single shared implementation that folds a `StreamChunk` stream back into `ContentBlock`s and a final `Message`. The loop logs the raw chunks (for replay fidelity) while feeding the same chunks through an assembler — so the canonical log keeps token-level detail and the derived message is rebuilt deterministically. A consumer that needs the assembled result without re-implementing the fold uses this. ## The seam diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index 797299b1cb..630d38480f 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -2,7 +2,7 @@ The **durability seam** for the event log. [session.md](session.md) describes the in-memory `Session` — the append-only `SessionEvent` log that is the source of truth. This page describes how that log is made durable: the abstract `SessionPersistence` service, its backends, the flush checkpoint, crash recovery, and the metadata header that travels alongside the log. -The seam is a textbook [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md): one abstract service ([dsh-session-persistence](../../packages/session-persistence), `ctx.sessionPersistence`) defining create/append/load/list/has/delete over the existing `SessionEvent` — **no parallel persisted type** — and two interchangeable backends that pass the same `runPersistenceContract` suite. See the [session-persistence RFC](../rfc/implemented/architecture/2026-06-14-session-persistence.md). +The seam is a textbook [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md): one abstract service ([dsh-session-persistence](../../packages/session-persistence/session-persistence), `ctx.sessionPersistence`) defining create/append/load/list/has/delete over the existing `SessionEvent` — **no parallel persisted type** — and two interchangeable backends that pass the same `runPersistenceContract` suite. See the [session-persistence RFC](../rfc/implemented/architecture/2026-06-14-session-persistence.md). ## The flush checkpoint @@ -16,7 +16,7 @@ A backend that reloads a log crashed mid-turn finds an open `turn/start` with no Per-session metadata travels **separately** from the event log: format version, cwd, and lineage are storage concerns, not conversation events, so they stay out of `SessionEventMap` and never reach `deriveMessages()`. The header is attached to a `Session` via `session.header`. -Source: [`packages/session/src/types.ts`](../../packages/session/src/types.ts) +Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts) ```ts type-equiv interface SessionHeader { @@ -57,7 +57,7 @@ Replay/fork is therefore `ctx.sessions.create(id, { seed: seedEvents })`; resumi Both implement the same abstract `SessionPersistence` (create/append/load/list/has/delete over `SessionEvent`) and pass `runPersistenceContract`, proving the seam is genuinely backend-agnostic: -- **[dsh-session-persistence-jsonl](../../packages/session-persistence-jsonl)** — an append-only JSONL log per session with crash-safe atomic writes, the interrupted-turn crash recovery above, and a read/replay path. -- **[dsh-session-persistence-sqlite](../../packages/session-persistence-sqlite)** — `node:sqlite`, one row per `SessionEvent`. The row shape `(session_id, seq, type, time, data)` maps 1:1 onto the event, so there is no parallel persisted schema to keep in sync. +- **[dsh-session-persistence-jsonl](../../packages/session-persistence/session-persistence-jsonl)** — an append-only JSONL log per session with crash-safe atomic writes, the interrupted-turn crash recovery above, and a read/replay path. +- **[dsh-session-persistence-sqlite](../../packages/session-persistence/session-persistence-sqlite)** — `node:sqlite`, one row per `SessionEvent`. The row shape `(session_id, seq, type, time, data)` maps 1:1 onto the event, so there is no parallel persisted schema to keep in sync. Multiple backends sharing one on-disk session coordinate writes through the [shared persistence write-coordinator](../rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md). diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 6856daeabc..d60b738b6a 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -1,8 +1,8 @@ # Sessions -The in-memory, event-sourced model of [dsh-session](../../packages/session). A `Session` is an **append-only log** of typed `SessionEvent`s — the single source of truth for an agent's whole interaction history. The LLM message history is *derived* from the log, never stored separately; replay is re-derivation from the same events. How the log is made **durable** (the persistence seam, backends, crash recovery) is the sibling concern on [persistence.md](persistence.md). +The in-memory, event-sourced model of [dsh-session](../../packages/core/session). A `Session` is an **append-only log** of typed `SessionEvent`s — the single source of truth for an agent's whole interaction history. The LLM message history is *derived* from the log, never stored separately; replay is re-derivation from the same events. How the log is made **durable** (the persistence seam, backends, crash recovery) is the sibling concern on [persistence.md](persistence.md). -Source: [`packages/session/src/types.ts`](../../packages/session/src/types.ts) +Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts) ## `SessionEventMap` — the event vocabulary diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index 3fdc70c1d5..f255fdbc32 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -1,8 +1,8 @@ # Tools -The tool pipeline of [dsh-tools](../../packages/tools). [core.md](core.md) introduces `ToolDefinition` as the one pipeline-authoring type promoted to the spine and `ToolSchema` as the model-facing wire shape. This page owns the full `ToolDefinition`, the typed schema DSL that builds it, the waterfall execution shapes, and the UI-presentation vocabulary. +The tool pipeline of [dsh-tools](../../packages/core/tools). [core.md](core.md) introduces `ToolDefinition` as the one pipeline-authoring type promoted to the spine and `ToolSchema` as the model-facing wire shape. This page owns the full `ToolDefinition`, the typed schema DSL that builds it, the waterfall execution shapes, and the UI-presentation vocabulary. -Source: [`packages/tools/src/index.ts`](../../packages/tools/src/index.ts) · [`packages/tools/src/schema.ts`](../../packages/tools/src/schema.ts) +Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) · [`packages/core/tools/src/schema.ts`](../../packages/core/tools/src/schema.ts) ## `ToolDefinition` — a registered tool @@ -36,7 +36,7 @@ interface ToolDefinition extends ToolSchema { Plugin authors write per-property specs with a boolean `required: true`, and a type-level helper maps the spec to the `execute` argument type — zero casts. The DSL is *machinery that types* `ToolDefinition`; it is intentionally a sub-page detail, not core. -Source: [`packages/tools/src/schema.ts`](../../packages/tools/src/schema.ts) +Source: [`packages/core/tools/src/schema.ts`](../../packages/core/tools/src/schema.ts) ```ts type-equiv interface SchemaProp { @@ -109,4 +109,4 @@ How a tool wants its call shown in a UI (an editor tool-call card, a CLI log lin > These shapes carry a `FIXME(tool-presentation)` in source: they grew incrementally and the call-vs-result terminal split is muddy. Before more tools/UIs depend on them, they will be redesigned (a tagged union over card kinds) and pinned in an RFC, migrating `dsh-tool-bash` and the ACP bridge together. Treat the field-level shapes here as provisional; the source is authoritative. -The full presentation field docs live in [`packages/tools/src/index.ts`](../../packages/tools/src/index.ts). The bash tool's own schemas (`bash`/`bash_output`/`bash_kill`) and the executor they drive are on [bash.md](bash.md). +The full presentation field docs live in [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts). The bash tool's own schemas (`bash`/`bash_output`/`bash_kill`) and the executor they drive are on [bash.md](bash.md). diff --git a/docs/development.md b/docs/development.md index 3199893c3d..c2fb100177 100644 --- a/docs/development.md +++ b/docs/development.md @@ -135,7 +135,7 @@ Pick the tag that matches the urgency so anyone scanning the code can tell a rel The [core data structures](core-data-structures/core.md) docs paste real type definitions so a reader sees the exact shape. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors: ```json -{ "doc": "docs/core-data-structures/session.md", "symbol": "SessionEvent", "source": "packages/session/src/types.ts" } +{ "doc": "docs/core-data-structures/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" } ``` `pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration from source via the TypeScript parser and asserts the block matches it (whitespace- and comment-insensitive, so a doc block may show a clean definition and the prose can carry the semantics). It also enforces a 1:1 correspondence: every `ts type-equiv` block has exactly one manifest entry and vice-versa, so a block can't go silently unchecked and a stale entry can't linger. `doc-typecheck` skips `ts type-equiv` blocks (they aren't standalone-compilable) and excludes them from its opt-out ratio. When you change a documented type, the gate fails until you update the paste; when you add or remove a block, update the manifest in the same change. diff --git a/docs/postmortem/0001-acp-default-export-drops-inject.md b/docs/postmortem/0001-acp-default-export-drops-inject.md index 0f72fa2d44..12eee7a2b7 100644 --- a/docs/postmortem/0001-acp-default-export-drops-inject.md +++ b/docs/postmortem/0001-acp-default-export-drops-inject.md @@ -24,7 +24,7 @@ The ACP server could not create or load a single session — the two RPCs an edi ## Root cause #1 — `export default apply` drops the plugin's `inject` (broke `session/new`) -`packages/acp/src/index.ts` is a *namespace plugin*: it exports `name`, `inject`, `Config`, and `apply` as separate named exports — the same shape as every other plugin in the repo (`invariants`, `llm-deepseek`, `tool-bash`, `stdio-chat`, …). But it *also* ended with one extra line no other plugin had: +`packages/ui/acp/src/index.ts` is a *namespace plugin*: it exports `name`, `inject`, `Config`, and `apply` as separate named exports — the same shape as every other plugin in the repo (`invariants`, `llm-deepseek`, `tool-bash`, `stdio-chat`, …). But it *also* ended with one extra line no other plugin had: ```ts ignore-check export const name = 'acp' @@ -97,8 +97,8 @@ Both bugs share one root process gap: **no test exercised the plugin through its ## Guardrails added -- **Removed `export default apply`** (`packages/acp/src/index.ts`) — the Bug #1 fix. -- **`AgentLoop.resume` reads `this.ctx.get('sessionPersistence')`** (`packages/agent-loop/src/index.ts`) — the Bug #2 fix, with a comment explaining the shadow-walk trap. +- **Removed `export default apply`** (`packages/ui/acp/src/index.ts`) — the Bug #1 fix. +- **`AgentLoop.resume` reads `this.ctx.get('sessionPersistence')`** (`packages/core/agent-loop/src/index.ts`) — the Bug #2 fix, with a comment explaining the shadow-walk trap. - **No-key `session/new` e2e over real stdio** (`examples/acp-agent/tests/acp.e2e.ts`): boots the example as a subprocess through the real Loader and asserts `session/new` resolves. This fails loudly on Bug #1 with no API key. Verified it fails when `export default apply` is restored. - **`TSX_TSCONFIG_PATH` in the e2e spawn**: the subprocess runs from a temp cwd, where tsx cannot find the repo-root tsconfig `paths` map by searching upward — so dsh-* imports silently fell back to built `lib/`. Pointing tsx at the repo tsconfig makes resolution cwd-independent and ensures the test runs *source*, not a possibly-stale build. - **AGENTS.md defensive pattern**: "Line coverage is not behavior coverage; test the REAL entry path, not a synthetic stand-in" — codifies the lesson for every future plugin. diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 997683fab5..3778aaf4ad 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -63,7 +63,6 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r |---|---| | [Runtime schemas for the event vocabulary (Zod vs the merge-extensible-map pattern)](proposed/architecture/2026-06-16-typed-event-schemas.md) | 2026-06-16 | | [Extract a generic long-running tool runtime](proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md) | 2026-06-20 | -| [Reorganize packages into a modular hierarchy](proposed/architecture/2026-06-20-package-hierarchy.md) | 2026-06-20 | | [Extract example apps into packages](proposed/architecture/2026-06-20-extract-example-app-packages.md) | 2026-06-20 | | [Branded IDs everywhere they belong](proposed/architecture/2026-06-20-branded-ids.md) | 2026-06-20 | @@ -116,6 +115,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Every session event is enclosed in a turn](implemented/architecture/2026-06-15-turn-enclosure-invariant.md) | 2026-06-15 | | [Shared persistence write coordinator](implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md) | 2026-06-18 | | [Agent lifecycle and ownership seams](implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md) | 2026-06-18 | +| [Reorganize packages into a modular hierarchy](implemented/architecture/2026-06-20-package-hierarchy.md) | 2026-06-20 | ### Process diff --git a/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.md b/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.md new file mode 100644 index 0000000000..60e295e767 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.md @@ -0,0 +1,67 @@ +# RFC: Reorganize packages into a modular hierarchy + +Status: implemented + +## Problem + +`packages/` was flat: 18 packages all sat at `packages//`, so a package's location said nothing about whether it was core product API, a swappable capability seam, a provider adapter, a product integration, or example/test support. The package README carried a `FIXME(package-hierarchy)` and `scripts/publint-all.ts` a `TODO(package-inventory)` flagging exactly this. Core packages, provider integrations, capability seams, example UI support, and snapshot-only replay support all looked equally foundational. + +This was not just cosmetic. Because every top-level package looked like part of the same public surface, future removal was harder, and publish/lint/doc scripts had to encode intent through comments or hand-maintained static lists rather than reading it off the layout. + +## What landed + +Packages are grouped by modular role at a uniform `packages///` depth. Group directories are pure containers (no `package.json`); every package keeps its `@deepseek-ai/dsh-` name — this is repo structure and maintenance policy, not package renaming. + +```text +packages/ + core/ (product API spine) + session/ + system-prompt/ + tools/ + agent/ + agent-loop/ + llm/ (product — capability family) + llm/ + llm-deepseek/ + llm-pi-ai/ + bash/ (product — capability family) + bash/ + bash-local/ + tool-bash/ + session-persistence/ (product — capability family) + session-persistence/ + session-persistence-jsonl/ + session-persistence-sqlite/ + ui/ (product integration) + acp/ + support/ (dev/test/example infrastructure) + invariants/ + ui-stdio/ + llm-replay/ +``` + +### Placement decisions + +- **Same-name nesting for capability families.** A family's interface package sits at `packages///` (`llm/llm`, `bash/bash`, `session-persistence/session-persistence`), with implementations and consumers as flat siblings. There is no extra `adapters/`/`impls/` sub-tier — every package is exactly depth 2, which keeps the workspace glob a clean `packages/*/*` and lets one `@deepseek-ai/dsh-*` tsconfig wildcard resolve every package (unique dir names make first-on-disk-wins unambiguous). +- **`session` stays in `core/`; persistence is its own family.** The session log is core product API. Its storage backends form a parallel capability family (`session-persistence/`) mirroring `llm/` and `bash/`, rather than nesting under `core/session/`. +- **`agent-loop` is in `core/`.** It is the one concrete implementation of the `agent` seam, but it ships as the harness's default product loop, so it lives with the core spine. Plugins still depend on the `agent` vocabulary, never on `agent-loop`, so the loop stays swappable. +- **`invariants` and `ui-stdio` are `support/`, not product.** `invariants` is dev-mode contract checking. `ui-stdio` was extracted from the examples for reuse and the coverage gate — it is example-coupled, so it sits in `support/` alongside `llm-replay` (the snapshot-test replay adapter). `acp` is the only `ui/` member because it is a real product surface (the ACP bridge an editor drives), structurally distinct from the readline demo helper. + +### Deduplicating the package lists + +The package list had been enumerated in five places. The uniform depth-2 layout lets most of them be derived instead: + +- `tsconfig.base.json` and `tsconfig.typecheck.json` each map every package through a single `@deepseek-ai/dsh-*` `paths` wildcard listing one candidate per group, in place of 18 per-package entries. (One subtlety this introduced: a path candidate contains `/*/`, which a naive regex comment-stripper mistakes for a block comment — `scripts/doc-typecheck.ts` reads the `paths` map via the TypeScript JSONC API rather than stripping comments by hand for exactly this reason.) +- `scripts/publint-all.ts` derives its list by reading the hierarchy (`packages//`), resolving the `TODO(package-inventory)`. +- `tsconfig.build.json`'s project `references` stay an explicit list — TypeScript project references have no wildcard form. Generating these from a manifest is left to a follow-up (see [discover package inventories](../../proposed/process/2026-06-20-discover-package-inventory.md)). + +### Guardrails added + +Two doc-sync/hygiene gates keep the structure and its references honest, so the manual checks this restructure required do not have to be repeated by hand: + +- `scripts/verify-package-paths.ts` flags a `packages/` reference (in Markdown or a `.ts` comment/string) that does not resolve **and** names a real package in a segment — i.e. a stale path to a moved package. A path naming a package that exists nowhere (a forward-looking proposal) is left alone, so the gate applies uniformly across proposed/implemented/rejected. +- `scripts/check-workspace-constraints.ts` asserts the `packages//` shape: group dirs carry no `package.json`, and no package sits flat at the root or nests deeper. Group names stay open — a new group may be added without editing the gate; only the depth-2 shape is fixed. + +## What we gave up + +The restructure churned imports, workspace globs, doc links, build references, and package paths in one coordinated move. That churn is acceptable pre-release (per the AGENTS.md foundation-over-blast-radius stance) because it stops the flat layout from fossilizing support packages as product contracts, and it is a one-time cost: the wildcard `paths`, the glob-derived publint list, and the shape gate mean a new package needs no further structural edits. diff --git a/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md b/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md index eefc8b09ad..6e108dc4c1 100644 --- a/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md +++ b/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md @@ -4,7 +4,7 @@ Status: implemented ## Problem -The ACP bridge lets each tool own its call rendering via `presentCall`/`presentResult` (see [tool-call UI presentation](../../proposed/feature/2026-06-14-acp-agent-client-protocol.md) and `packages/tools`). For `bash` we surface the exact command as the `tool_call` title, the model's `description` as a content text block, `kind: 'execute'`, and the completed output wrapped in a fenced ` ```console ` text block. +The ACP bridge lets each tool own its call rendering via `presentCall`/`presentResult` (see [tool-call UI presentation](../../proposed/feature/2026-06-14-acp-agent-client-protocol.md) and `packages/core/tools`). For `bash` we surface the exact command as the `tool_call` title, the model's `description` as a content text block, `kind: 'execute'`, and the completed output wrapped in a fenced ` ```console ` text block. That is a correct, capability-free baseline, but not how the reference editors render a *terminal* tool at its best. An editor like Zed has a dedicated terminal tool-call card — a header showing the working directory, the command as the label, the command output rendered as a terminal, and an exit-status pill — but it only builds that card when the `tool_call` carries terminal metadata (below). With a plain text block the output appears as static markdown and there is no cwd header. (Zed also HIDES `rawInput` for `kind: 'execute'`, which is why the command IS the title — both reference adapters do the same. The human-readable description rides as a separate content block above the card; note this is a DELIBERATE divergence — claude-agent-acp DROPS the description in terminal mode and renders only the card — we keep the summary visible alongside.) diff --git a/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md b/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md index a0f6497c2a..af8c40c55e 100644 --- a/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md +++ b/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md @@ -18,13 +18,13 @@ A snapshot test boots the **real** `examples/acp-agent` subprocess, drives it ov ### The fixture is the persisted session JSONL -The per-scenario fixture is `/session.jsonl`: the exact log produced by running the scenario once against the real API (the snapshot harness harvests the file the JSONL persistence backend writes). This log already contains everything needed to reproduce the run deterministically: its `assistant/chunk` events carry every parsed `StreamChunk` (the LLM's behavior), and its `tool/call`/`tool/result`/`turn/*`/`assistant/message`/`usage` events carry the harness's behavior. One artifact captures both, and it is the format the codebase already treats as the authoritative replay record ([packages/session/src/types.ts](../../../../packages/session/src/types.ts): "raw chunks are the replay record"). +The per-scenario fixture is `/session.jsonl`: the exact log produced by running the scenario once against the real API (the snapshot harness harvests the file the JSONL persistence backend writes). This log already contains everything needed to reproduce the run deterministically: its `assistant/chunk` events carry every parsed `StreamChunk` (the LLM's behavior), and its `tool/call`/`tool/result`/`turn/*`/`assistant/message`/`usage` events carry the harness's behavior. One artifact captures both, and it is the format the codebase already treats as the authoritative replay record ([packages/core/session/src/types.ts](../../../../packages/core/session/src/types.ts): "raw chunks are the replay record"). An earlier draft used a hand-authored `llm.json` of model chunks; reusing the real session log instead means the fixture is a genuine product of the system (not a hand-built mock), and it doubles as a behavioral golden (see below). A byte-level HTTP-record library (Polly/nock/MSW) was rejected: adapter-specific, awkward with streaming SSE, and lower-level than the thing under test. ### Replay derives the model script from the log -The replay seam is the provider-agnostic `llm/stream` waterfall ([packages/llm/src/index.ts](../../../../packages/llm/src/index.ts)) — a single listener intercepts every model call regardless of adapter (deepseek, pi-ai), because the loop routes all model calls through `ctx.llm.stream()`. The `llm-replay` plugin short-circuits that waterfall (never calls `next()`) and serves back streams reconstructed from the log: `deriveReplayScript(events)` groups `assistant/chunk` events by `(turn, step)` in log order, yielding one model stream per group. This grouping is exact because the agent loop makes **exactly one `ctx.llm.stream()` call per step** and tags every chunk with the current `(turn, step)` ([packages/agent-loop/src/loop.ts](../../../../packages/agent-loop/src/loop.ts)): `step` increments once per loop iteration, so `(turn, step)` is unique per model call. A `finish {kind:'error'}` chunk is part of its group and replays naturally — no special-casing. +The replay seam is the provider-agnostic `llm/stream` waterfall ([packages/llm/llm/src/index.ts](../../../../packages/llm/llm/src/index.ts)) — a single listener intercepts every model call regardless of adapter (deepseek, pi-ai), because the loop routes all model calls through `ctx.llm.stream()`. The `llm-replay` plugin short-circuits that waterfall (never calls `next()`) and serves back streams reconstructed from the log: `deriveReplayScript(events)` groups `assistant/chunk` events by `(turn, step)` in log order, yielding one model stream per group. This grouping is exact because the agent loop makes **exactly one `ctx.llm.stream()` call per step** and tags every chunk with the current `(turn, step)` ([packages/core/agent-loop/src/loop.ts](../../../../packages/core/agent-loop/src/loop.ts)): `step` increments once per loop iteration, so `(turn, step)` is unique per model call. A `finish {kind:'error'}` chunk is part of its group and replays naturally — no special-casing. ### The in-memory replay entry honors the full LLM contract @@ -46,7 +46,7 @@ Replay is positional: the Nth `stream()` call serves the Nth `ReplayEntry`. This Recording runs the scenario with the real `llm-deepseek` adapter and the JSONL persistence backend, then copies the produced `.jsonl` into the scenario dir. Per-event appends are durable, but the harness shuts the subprocess down gracefully (close stdin → `await ctx.dispose()`) before harvesting so the final events are flushed. `llm-replay` itself does no recording — it is replay-only. -`examples/base.yml` always loads `@deepseek-ai/dsh-llm-deepseek`, whose `apply` throws when no API key is present ([packages/llm-deepseek/src/index.ts](../../../../packages/llm-deepseek/src/index.ts)). So replay cannot reuse the normal config — it uses a dedicated `examples/acp-agent/cordis.snapshot.yml` that installs `llm-replay` in place of the adapter. To avoid duplicating the rest of the tree, the providerless core is factored into `examples/base-core.yml` (shared by `base.yml = base-core + llm-deepseek` and the replay config = `base-core + llm-replay`), and the agent-loop/persistence/ACP-bridge tail into `examples/acp-agent/acp-tail.yml` (shared by `cordis.yml` and the replay config). Recording reuses the normal `cordis.yml` (real adapter) — its persistence root reads `$DSH_SNAPSHOT_SESSIONS_ROOT` when the harness sets it — so there is no separate record config. In replay mode `start.ts` skips `.env` loading so a stray key cannot trigger a live call. +`examples/base.yml` always loads `@deepseek-ai/dsh-llm-deepseek`, whose `apply` throws when no API key is present ([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts)). So replay cannot reuse the normal config — it uses a dedicated `examples/acp-agent/cordis.snapshot.yml` that installs `llm-replay` in place of the adapter. To avoid duplicating the rest of the tree, the providerless core is factored into `examples/base-core.yml` (shared by `base.yml = base-core + llm-deepseek` and the replay config = `base-core + llm-replay`), and the agent-loop/persistence/ACP-bridge tail into `examples/acp-agent/acp-tail.yml` (shared by `cordis.yml` and the replay config). Recording reuses the normal `cordis.yml` (real adapter) — its persistence root reads `$DSH_SNAPSHOT_SESSIONS_ROOT` when the harness sets it — so there is no separate record config. In replay mode `start.ts` skips `.env` loading so a stray key cannot trigger a live call. ### Two goldens: normalize, then snapshot @@ -66,7 +66,7 @@ Determinism of the tool environment comes from a per-test `mkdtemp` cwd, the exe ### The replay plugin is its own package -The replay plugin lives in its own package, `@deepseek-ai/dsh-llm-replay` (`packages/llm-replay/`), and the snapshot config references it by package name. It is the keyless replacement for the real LLM adapter: it installs an `llm/stream` waterfall listener and short-circuits it, serving model streams reconstructed from a recorded session JSONL. Its sole consumer is the ACP snapshot harness here, but it is a package (not example-local glue like echo-agent's [mock-llm.ts](../../../../examples/echo-agent/src/mock-llm.ts)) so that its derive/parse/replay branches fall under the per-file 100% coverage gate on package `src` trees — logic under `examples/` is not measured by that gate, which would leave those branches unguarded. +The replay plugin lives in its own package, `@deepseek-ai/dsh-llm-replay` (`packages/support/llm-replay/`), and the snapshot config references it by package name. It is the keyless replacement for the real LLM adapter: it installs an `llm/stream` waterfall listener and short-circuits it, serving model streams reconstructed from a recorded session JSONL. Its sole consumer is the ACP snapshot harness here, but it is a package (not example-local glue like echo-agent's [mock-llm.ts](../../../../examples/echo-agent/src/mock-llm.ts)) so that its derive/parse/replay branches fall under the per-file 100% coverage gate on package `src` trees — logic under `examples/` is not measured by that gate, which would leave those branches unguarded. ### Two subcommands, replay in the default gate diff --git a/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md b/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md index b6fc29ee90..2001147be8 100644 --- a/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md +++ b/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md @@ -51,7 +51,7 @@ The repo secret is named `DEEPSEEK_API_KEY_EXTERNAL`; it is mapped to the `DEEPS - **Step-scoped secret.** `DEEPSEEK_API_KEY` is set in the `env:` of only the preflight and e2e steps, never job-level — so checkout/setup-node/install never see it. A compromised install-time lifecycle script in a dependency cannot read a secret that isn't in its environment. - **`permissions: contents: read`.** The job only reads the repo to run tests; it needs no write scopes (no PR comments, no status writes), so the `GITHUB_TOKEN` is dropped to least privilege. -- **`DEEPSEEK_BASE_URL` pinned** to `https://api.deepseek.com` on the e2e step. The adapter would default to this when unset ([packages/llm-deepseek/src/index.ts](../../../../packages/llm-deepseek/src/index.ts) `PUBLIC_BASE_URL`), but pinning is self-documenting and hermetic — a stray repo-root `.env` (which `vitest.e2e.config.ts` loads if present) cannot silently redirect the run to another endpoint. +- **`DEEPSEEK_BASE_URL` pinned** to `https://api.deepseek.com` on the e2e step. The adapter would default to this when unset ([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts) `PUBLIC_BASE_URL`), but pinning is self-documenting and hermetic — a stray repo-root `.env` (which `vitest.e2e.config.ts` loads if present) cannot silently redirect the run to another endpoint. - **No secret echoed.** The preflight prints only `DEEPSEEK_API_KEY present.` — not the value, not its length. (An earlier draft echoed `${#KEY}`; dropped as needless metadata.) ### Scope, runtime shape diff --git a/docs/rfc/proposed/architecture/2026-06-20-branded-ids.md b/docs/rfc/proposed/architecture/2026-06-20-branded-ids.md index 6f92de519a..93a4bf6cda 100644 --- a/docs/rfc/proposed/architecture/2026-06-20-branded-ids.md +++ b/docs/rfc/proposed/architecture/2026-06-20-branded-ids.md @@ -4,21 +4,21 @@ Status: proposed ## Problem -The harness already brands three identifiers — `CallId` (`packages/llm/src/brand.ts`), `SessionId` (`packages/session/src/types.ts`), and `AgentId` (`packages/agent/src/types.ts`) — using the `Branded = string & { readonly [BRAND]: B }` machinery and a zero-cost cast factory per type. `brand.ts` also states the governing policy: *"Branding is for IDs that cross package boundaries and could plausibly be confused; not every string needs a brand."* That policy is right; the problem is that it is only half-applied. Two gaps let a structurally-identical-but-semantically-wrong string slip through the type checker today. +The harness already brands three identifiers — `CallId` (`packages/llm/llm/src/brand.ts`), `SessionId` (`packages/core/session/src/types.ts`), and `AgentId` (`packages/core/agent/src/types.ts`) — using the `Branded = string & { readonly [BRAND]: B }` machinery and a zero-cost cast factory per type. `brand.ts` also states the governing policy: *"Branding is for IDs that cross package boundaries and could plausibly be confused; not every string needs a brand."* That policy is right; the problem is that it is only half-applied. Two gaps let a structurally-identical-but-semantically-wrong string slip through the type checker today. -**Gap 1 — unbranded cross-boundary IDs in the bash seam.** The background-task id is a plain `string`: `BashTask.id: string` (`packages/bash/src/types.ts`), carried as `string` through the whole executor seam (`BashExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)` in `packages/bash/src/index.ts`) and validated/passed as `string` by the model-facing tools (`validateTaskId`, `assertTaskAccess`, the `task_id` schema arg in `packages/tool-bash/src/index.ts`). It is generated by a per-executor counter — `` `bash-${this.nextTaskId++}` `` in `packages/bash-local/src/index.ts` — which gives it **exactly the same `name-N` shape as `SessionId`'s default** (`` `session-${++counter}` `` in `packages/session/src/index.ts`). A bash task id and a session id are trivially swappable at a call site and the compiler says nothing. This is the headline case the user asked about, and it is a model-facing id (the model passes `task_id` back to `bash_output`/`bash_kill`), so a confusion here is reachable from untrusted input. +**Gap 1 — unbranded cross-boundary IDs in the bash seam.** The background-task id is a plain `string`: `BashTask.id: string` (`packages/bash/bash/src/types.ts`), carried as `string` through the whole executor seam (`BashExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)` in `packages/bash/bash/src/index.ts`) and validated/passed as `string` by the model-facing tools (`validateTaskId`, `assertTaskAccess`, the `task_id` schema arg in `packages/bash/tool-bash/src/index.ts`). It is generated by a per-executor counter — `` `bash-${this.nextTaskId++}` `` in `packages/bash/bash-local/src/index.ts` — which gives it **exactly the same `name-N` shape as `SessionId`'s default** (`` `session-${++counter}` `` in `packages/core/session/src/index.ts`). A bash task id and a session id are trivially swappable at a call site and the compiler says nothing. This is the headline case the user asked about, and it is a model-facing id (the model passes `task_id` back to `bash_output`/`bash_kill`), so a confusion here is reachable from untrusted input. -The bash **owner token** is the related sub-case: `BashExecRequest.owner?: string` and `BashExecSpec.owner: string | undefined` (`packages/bash/src/types.ts`) are documented as a deliberately *opaque* isolation key, but in every live caller the value IS the owning agent's `session.header.id` (`callerToken = (exec) => exec.agent?.session.header.id` in `packages/tool-bash/src/index.ts`) — i.e. a `SessionId` wearing a `string` disguise. It is compared for access control (`owner !== callerToken(exec)`), so a mismatched-but-well-typed string here is a cross-session isolation bug the type system currently cannot catch. This is the same `session.header.id`-as-owner alias that the [unify-the-agent-id-and-the-session-id](../simplification/2026-06-20-unify-agent-and-session-id.md) proposal calls the "bash owner-token alias hole". +The bash **owner token** is the related sub-case: `BashExecRequest.owner?: string` and `BashExecSpec.owner: string | undefined` (`packages/bash/bash/src/types.ts`) are documented as a deliberately *opaque* isolation key, but in every live caller the value IS the owning agent's `session.header.id` (`callerToken = (exec) => exec.agent?.session.header.id` in `packages/bash/tool-bash/src/index.ts`) — i.e. a `SessionId` wearing a `string` disguise. It is compared for access control (`owner !== callerToken(exec)`), so a mismatched-but-well-typed string here is a cross-session isolation bug the type system currently cannot catch. This is the same `session.header.id`-as-owner alias that the [unify-the-agent-id-and-the-session-id](../simplification/2026-06-20-unify-agent-and-session-id.md) proposal calls the "bash owner-token alias hole". -**Gap 2 — brand erosion at the seams of the *already-branded* IDs.** Even `CallId`/`SessionId`/`AgentId` decay back to bare `string` at exactly the places confusion is most likely: the registry/store `Map` key types and most public method params. Representative sites: `SessionStore.store = new Map()` and `create`/`prepare(id?: string)`/`get(id: string)` (`packages/session/src/index.ts`); `AgentRegistry.store = new Map()` and `register`/`get(id: string)` (`packages/agent/src/index.ts`); `ToolPresenter.pending = new Map()` keyed by call id and `call(callId: string)`/`result(callId: string)` (`packages/acp/src/index.ts`); the ACP session-id surface beyond the store map — `SessionRecord.sessionId: string`, `bySession = new WeakMap()`, `loadingIds = new Set()`, `requireSession(sessionId: string)`, and the exported `streamSessionEventUpdate(sessionId: string, …)` (`packages/acp/src/index.ts`); and the persistence coordinator's `Map` keyed by session id (`packages/session-persistence/src/coordinator.ts`). A brand that is dropped at the `Map` key buys nothing on lookups — the value of the existing brands is partly unrealized. +**Gap 2 — brand erosion at the seams of the *already-branded* IDs.** Even `CallId`/`SessionId`/`AgentId` decay back to bare `string` at exactly the places confusion is most likely: the registry/store `Map` key types and most public method params. Representative sites: `SessionStore.store = new Map()` and `create`/`prepare(id?: string)`/`get(id: string)` (`packages/core/session/src/index.ts`); `AgentRegistry.store = new Map()` and `register`/`get(id: string)` (`packages/core/agent/src/index.ts`); `ToolPresenter.pending = new Map()` keyed by call id and `call(callId: string)`/`result(callId: string)` (`packages/ui/acp/src/index.ts`); the ACP session-id surface beyond the store map — `SessionRecord.sessionId: string`, `bySession = new WeakMap()`, `loadingIds = new Set()`, `requireSession(sessionId: string)`, and the exported `streamSessionEventUpdate(sessionId: string, …)` (`packages/ui/acp/src/index.ts`); and the persistence coordinator's `Map` keyed by session id (`packages/session-persistence/session-persistence/src/coordinator.ts`). A brand that is dropped at the `Map` key buys nothing on lookups — the value of the existing brands is partly unrealized. ## Proposal A type-only change. Brands are zero-cost casts; nothing about runtime behavior, serialization, comparison, or the wire format changes. The work is in three parts, all honoring the existing "not every string" policy. -- **Brand the bash task id.** Add `BashTaskId = Branded<'BashTaskId'>` plus its same-named factory in `packages/bash/src/types.ts` (the package that *owns* the id), importing `Branded` from `@deepseek-ai/dsh-llm` exactly as `SessionId`/`AgentId` already do. Thread it through `BashTask.id`, the `BashExecutor` seam methods (`get`/`ownerOf`/`readOutput`/`kill`), the generation site in `dsh-bash-local` (brand the counter output once, at creation), and the `dsh-tool-bash` validate/access surface (`validateTaskId` returns a `BashTaskId`; `task_id` is branded at the tool boundary where the model's string arrives). +- **Brand the bash task id.** Add `BashTaskId = Branded<'BashTaskId'>` plus its same-named factory in `packages/bash/bash/src/types.ts` (the package that *owns* the id), importing `Branded` from `@deepseek-ai/dsh-llm` exactly as `SessionId`/`AgentId` already do. Thread it through `BashTask.id`, the `BashExecutor` seam methods (`get`/`ownerOf`/`readOutput`/`kill`), the generation site in `dsh-bash-local` (brand the counter output once, at creation), and the `dsh-tool-bash` validate/access surface (`validateTaskId` returns a `BashTaskId`; `task_id` is branded at the tool boundary where the model's string arrives). -- **Mint a distinct `OwnerToken` brand.** Add `OwnerToken = Branded<'OwnerToken'>` in `packages/bash/src/types.ts`; type `BashExecRequest.owner` / `BashExecSpec.owner` / `BashExecutor.ownerOf` as `OwnerToken | undefined`. The `dsh-tool-bash` consumer casts the agent's `session.header.id` (a `SessionId`) into an `OwnerToken` at the boundary — the one place the two vocabularies meet. The bash seam never imports `dsh-session`. (Rationale in the next section.) +- **Mint a distinct `OwnerToken` brand.** Add `OwnerToken = Branded<'OwnerToken'>` in `packages/bash/bash/src/types.ts`; type `BashExecRequest.owner` / `BashExecSpec.owner` / `BashExecutor.ownerOf` as `OwnerToken | undefined`. The `dsh-tool-bash` consumer casts the agent's `session.header.id` (a `SessionId`) into an `OwnerToken` at the boundary — the one place the two vocabularies meet. The bash seam never imports `dsh-session`. (Rationale in the next section.) - **Stop the brand erosion.** Propagate the existing brands to the `Map` key types and public method params listed under Gap 2 — `Map`, `get(id: SessionId)`, `Map`, `Map`, the ACP `SessionRecord.sessionId: SessionId` surface, the coordinator's `Map`. This is the larger mechanical share of the diff and the part that makes the *existing* brands actually load-bearing on lookups, not just on the struct fields. @@ -42,7 +42,7 @@ export function OwnerToken(id: string): OwnerToken { ## Why a distinct OwnerToken brand (not SessionId) -The obvious shortcut is to type `owner` as `SessionId` directly — it always *is* one. We reject that. The bash executor seam is a capability seam (interface `dsh-bash`, implementation `dsh-bash-local`, consumer `dsh-tool-bash`) and its owner token is *documented as deliberately opaque*: the executor "never interprets it (no access policy lives in the seam — that is the consumer's job)" (`packages/bash/src/types.ts`). Typing the seam's field as `SessionId` would import `dsh-session`'s vocabulary into a package that must not know what an owner token *means* — it would couple a generic execution backend to the session model and contradict the opaque-token design. A sandboxed or remote executor that replaces `dsh-bash-local` should not inherit a session dependency. The distinct `OwnerToken` brand keeps the seam decoupled: `dsh-bash` knows only "an owner is some opaque branded token," and the `dsh-tool-bash` consumer — which already decides the access policy — is the single boundary that casts its `SessionId` into an `OwnerToken`. The brand still delivers the safety win (you cannot pass a `BashTaskId` or a raw string where an owner is expected) without the coupling. +The obvious shortcut is to type `owner` as `SessionId` directly — it always *is* one. We reject that. The bash executor seam is a capability seam (interface `dsh-bash`, implementation `dsh-bash-local`, consumer `dsh-tool-bash`) and its owner token is *documented as deliberately opaque*: the executor "never interprets it (no access policy lives in the seam — that is the consumer's job)" (`packages/bash/bash/src/types.ts`). Typing the seam's field as `SessionId` would import `dsh-session`'s vocabulary into a package that must not know what an owner token *means* — it would couple a generic execution backend to the session model and contradict the opaque-token design. A sandboxed or remote executor that replaces `dsh-bash-local` should not inherit a session dependency. The distinct `OwnerToken` brand keeps the seam decoupled: `dsh-bash` knows only "an owner is some opaque branded token," and the `dsh-tool-bash` consumer — which already decides the access policy — is the single boundary that casts its `SessionId` into an `OwnerToken`. The brand still delivers the safety win (you cannot pass a `BashTaskId` or a raw string where an owner is expected) without the coupling. ## Out of scope / possible extensions diff --git a/docs/rfc/proposed/architecture/2026-06-20-extract-example-app-packages.md b/docs/rfc/proposed/architecture/2026-06-20-extract-example-app-packages.md index a99fa80f86..5c99e7a197 100644 --- a/docs/rfc/proposed/architecture/2026-06-20-extract-example-app-packages.md +++ b/docs/rfc/proposed/architecture/2026-06-20-extract-example-app-packages.md @@ -12,7 +12,7 @@ The deeper problem is a **coupled front-door cluster** that lives at the leaf wi Make each example **mostly an invocation of an app package**, splitting the wiring along the existing [interface / implementation / consumer seam](../../implemented/architecture/2026-06-13-capability-seams.md): the **app package owns the composition**, the leaf `cordis.yml` owns only the **swappable choices** (which LLM adapter, which bash executor, model, prompt, persistence root). -- **`@deepseek-ai/dsh-agent-core`** — a Cordis bundle plugin for the providerless, executor-less, UI-less spine: `timer` + `llm` + sessions + system-prompt + tools + agents + invariants + `tool-bash` + `agent-loop`. This is today's [base-core.yml](../../../../examples/base-core.yml) **minus** `bash-local`, **plus** `timer` and the loop, as code instead of a YAML include. The bundle **forwards** `agent-loop`'s `agents` list as its own config (default `[]`, exactly the existing `AgentLoop.Config` shape in [packages/agent-loop/src/index.ts](../../../../packages/agent-loop/src/index.ts)) — so each app supplies its own pre-created agents. This is precisely the reason [base-core.yml](../../../../examples/base-core.yml) gives today for keeping `agent-loop` *out* of the shared core ("the examples disagree — stdio needs a pre-created `main`, acp needs none"); forwarding the config dissolves that objection — the loop is shared, the agents list is per-app. +- **`@deepseek-ai/dsh-agent-core`** — a Cordis bundle plugin for the providerless, executor-less, UI-less spine: `timer` + `llm` + sessions + system-prompt + tools + agents + invariants + `tool-bash` + `agent-loop`. This is today's [base-core.yml](../../../../examples/base-core.yml) **minus** `bash-local`, **plus** `timer` and the loop, as code instead of a YAML include. The bundle **forwards** `agent-loop`'s `agents` list as its own config (default `[]`, exactly the existing `AgentLoop.Config` shape in [packages/core/agent-loop/src/index.ts](../../../../packages/core/agent-loop/src/index.ts)) — so each app supplies its own pre-created agents. This is precisely the reason [base-core.yml](../../../../examples/base-core.yml) gives today for keeping `agent-loop` *out* of the shared core ("the examples disagree — stdio needs a pre-created `main`, acp needs none"); forwarding the config dissolves that objection — the loop is shared, the agents list is per-app. - **`@deepseek-ai/dsh-stdio-agent`** and **`@deepseek-ai/dsh-acp-agent`** — app packages, each consuming `dsh-agent-core` and **baking in its coupled front-door cluster**: stdio = `ui-stdio` + console logger + `hmr` + a pre-created `main`; acp = the `acp` bridge + **no stdout logger** + no `hmr` + no pre-created agents. The coupling becomes structurally unreachable from the leaf. - **Drop `start.ts`.** Each app package exposes a `bin`; the `demo:*` scripts invoke it (e.g. `dsh-stdio-agent ./cordis.yml`). The Loader-boot tail, `.env` loading, snapshot-mode selection, and stdin-dispose lifecycle move into that bin, owned by the app. - **Collapse each leaf `cordis.yml`** to backends + config: the LLM adapter (`llm-deepseek` with apiKey/models, or `llm-replay`), the bash executor (`bash-local`), and one app-bundle entry carrying the app's config (model, system prompt, persistence root — surfaced as the app package's own `Config`, which routes each value to wherever the app wires it: stdio onto its pre-created agent, acp onto the bridge plugin). A handful of entries, no infra preamble. @@ -41,4 +41,4 @@ The `base*.yml`/`acp-tail.yml` includes already dedupe the *config*, but a YAML - Supersedes [Make the shared example base providerless](../../rejected/architecture/2026-06-20-providerless-example-base.md): renaming `base.yml` to the providerless core is moot once the spine moves into `dsh-agent-core` and the `base*.yml` files are deleted. - Builds on the [capability-seams](../../implemented/architecture/2026-06-13-capability-seams.md) interface/implementation/consumer split — backends and presentation stay leaf choices; the spine is the shared bundle. -- Complements [Reorganize packages into a modular hierarchy](2026-06-20-package-hierarchy.md): the new app/core packages slot into whatever hierarchy that RFC settles on. +- Complements [Reorganize packages into a modular hierarchy](../../implemented/architecture/2026-06-20-package-hierarchy.md): the new app/core packages slot into a group under that hierarchy (a product group for the reusable core bundle, or alongside the examples for app-specific wiring). diff --git a/docs/rfc/proposed/architecture/2026-06-20-package-hierarchy.md b/docs/rfc/proposed/architecture/2026-06-20-package-hierarchy.md deleted file mode 100644 index 5eccadaef8..0000000000 --- a/docs/rfc/proposed/architecture/2026-06-20-package-hierarchy.md +++ /dev/null @@ -1,59 +0,0 @@ -# RFC: Reorganize packages into a modular hierarchy - -Status: proposed - -## Problem - -`packages/` is flat. Core product packages, provider integrations, capability seams, example UI support, and snapshot-only replay support all sit at the same level and look equally foundational. The [package README](../../../../packages/README.md) already has a `FIXME(package-hierarchy)` noting that `ui-stdio` and `llm-replay` were extracted from examples mostly for reuse and coverage. The flat layout makes support packages appear more product-shaped than they are and forces publish/lint/doc scripts to encode intent through comments or static lists. - -This is not just cosmetic. A package's location currently says little about whether it is core API, a swappable capability, an adapter integration, an example harness helper, or test infrastructure. That makes future removal harder because every top-level package looks like part of the same public surface. - -## Proposal - -Move packages into a deliberate hierarchy under `packages/`. The exact layout is deferred to the implementing PR, but it should group packages by modular role rather than keep every package at one flat level. - -One plausible shape: - -```text -packages/ - core/ - session/ - system-prompt/ - tools/ - agent/ - agent-loop/ - invariants/ - llm/ - llm/ - adapters/ - llm-deepseek/ - llm-pi-ai/ - bash/ - bash/ - bash-local/ - tool-bash/ - session-persistence/ - session-persistence/ - session-persistence-jsonl/ - session-persistence-sqlite/ - acp/ - support/ - ui-stdio/ - llm-replay/ -``` - -The final implementation may choose different names or groupings, but it should keep the same intent: core APIs, package families such as LLM/bash/session persistence, standalone integrations such as ACP, and support/test/example packages are distinguishable from the filesystem alone. Npm package names can stay `@deepseek-ai/dsh-*`; the hierarchy is about repo structure and maintenance policy, not public package renaming. - -This proposal does not delete `llm-replay` or `ui-stdio` by itself. It makes their status honest: either they graduate into product packages with documented consumers, or they live under a support/testing/example classification where release and compatibility expectations are lower. - -## Acceptance criteria - -- Packages move from the flat `packages//` layout into a documented modular hierarchy. -- The implementing PR chooses the exact hierarchy and updates workspace globs, TypeScript paths, package docs, generated module graphs, `cordis.yml` package paths, build scripts, and publish/lint scripts in one coordinated move. -- Scripts that publish, lint publishability, or generate package inventories use the hierarchy instead of an ad hoc static list where the hierarchy is enough to express the policy. -- Docs explain which package groups are part of the product API and which groups are support/test/example infrastructure. -- New package guidance tells authors where to place a package and discourages new one-off top-level groups. - -## What we give up - -The restructure churns imports, workspace globs, docs links, and package paths. That churn is acceptable pre-release if it prevents the flat layout from fossilizing support packages as product contracts. diff --git a/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md b/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md index 7056c74977..f133cc1d82 100644 --- a/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md +++ b/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md @@ -3,7 +3,7 @@ Status: proposed -> **Implementation status (MVP landed):** steps 1, 2, 3, 4, 6, 7, 8 are implemented in `packages/acp` + `examples/acp-agent`. **Step 5 (the `session/request_permission` permission gate) is deferred** — the bridge ships a pass-through (tools run with the executor's full authority) marked `TODO(rfc010-permission-gate)`, and lays down only the `WeakMap` ownership seam the gate will build on. Status stays `proposed` until the gate lands. `session/cancel` is the queue-aware `agent.cancel()`: it aborts a running step, clears queued + steering work, and drops a turn that is about to start, so a queued-but-not-yet-started prompt never runs and a later prompt cannot be batched into the cancelled turn. **Per-session `cwd` is now honored** (lifting the original "launch the server in the workspace root" restriction — see § Deferred): `session/new` accepts any absolute `cwd`, and `session/load` requires the request `cwd` to match the persisted session `cwd` so the editor and bash executor agree on the workspace. +> **Implementation status (MVP landed):** steps 1, 2, 3, 4, 6, 7, 8 are implemented in `packages/ui/acp` + `examples/acp-agent`. **Step 5 (the `session/request_permission` permission gate) is deferred** — the bridge ships a pass-through (tools run with the executor's full authority) marked `TODO(rfc010-permission-gate)`, and lays down only the `WeakMap` ownership seam the gate will build on. Status stays `proposed` until the gate lands. `session/cancel` is the queue-aware `agent.cancel()`: it aborts a running step, clears queued + steering work, and drops a turn that is about to start, so a queued-but-not-yet-started prompt never runs and a later prompt cannot be batched into the cancelled turn. **Per-session `cwd` is now honored** (lifting the original "launch the server in the workspace root" restriction — see § Deferred): `session/new` accepts any absolute `cwd`, and `session/load` requires the request `cwd` to match the persisted session `cwd` so the editor and bash executor agree on the workspace. ## Problem @@ -17,7 +17,7 @@ This RFC has a hard prerequisite on [session persistence](../../implemented/arch A new plugin package `@deepseek-ai/dsh-acp` — a client-driver / UI plugin, the structured analogue of `stdio-chat`. It is NOT a change to the loop and NOT an [capability seams](../../implemented/architecture/2026-06-13-capability-seams.md) interface/implementation/consumer capability split; it consumes the existing `agent/*` event taxonomy and the `tools/execute` waterfall. -It depends on the official `@agentclientprotocol/sdk` (the `AgentSideConnection` class) — Apache-2.0, actively versioned. The SDK declares a `zod` peer dependency and imports `zod/v4` at runtime, so `packages/acp` must declare `zod` itself (per the workspace dependency constraints). This is the renamed successor to `@zed-industries/agent-client-protocol`, which is now deprecated on npm. +It depends on the official `@agentclientprotocol/sdk` (the `AgentSideConnection` class) — Apache-2.0, actively versioned. The SDK declares a `zod` peer dependency and imports `zod/v4` at runtime, so `packages/ui/acp` must declare `zod` itself (per the workspace dependency constraints). This is the renamed successor to `@zed-industries/agent-client-protocol`, which is now deprecated on npm. The mapping between ACP and existing harness seams — each row names the seam and any required extension: @@ -43,9 +43,9 @@ Lifecycle and disposal: the connection, listeners, and in-flight permission prom ## Plan -1. Package scaffold `packages/acp/` per [the cookbook](../../../cookbook/adding-a-package.md); add `@agentclientprotocol/sdk` and `zod`. Add the abstract create/resume factory to `dsh-agent` (the interface) so the bridge can `inject: ['agents', 'sessions', 'tools', 'sessionPersistence']` without depending on the concrete loop; `sessionPersistence` is required because `session/load` advertises `loadSession: true`. (Fallback only if the factory is judged not worth it: inject `agentLoop` directly and record the architecture-rule exception in `docs/architecture.md`.) +1. Package scaffold `packages/ui/acp/` per [the cookbook](../../../cookbook/adding-a-package.md); add `@agentclientprotocol/sdk` and `zod`. Add the abstract create/resume factory to `dsh-agent` (the interface) so the bridge can `inject: ['agents', 'sessions', 'tools', 'sessionPersistence']` without depending on the concrete loop; `sessionPersistence` is required because `session/load` advertises `loadSession: true`. (Fallback only if the factory is judged not worth it: inject `agentLoop` directly and record the architecture-rule exception in `docs/architecture.md`.) 2. Connection plus `initialize`/`session/new`: wire `AgentSideConnection` to stdin/stdout; protocolVersion negotiation; the single-session guard; create the live session through the new `{ sessionId, meta }` factory seam (so the ACP `sessionId` and validated `cwd` become the session's id and header); the `sessionId↔agent` and `Session↔sessionId` maps. -3. Internal edit — turn-end reason fidelity (sanctioned: edit internals to fit ACP). Extend `TurnEndReasonMap` in the proper places: (a) declaration-merge a `max-tokens` variant in the owning package (`packages/session/src/types.ts`, alongside `completed|aborted|error|disposed`) — add `max-tokens` because `FinishReasonMap` produces it (DeepSeek maps `length` → `max-tokens`); do not add `refusal`, since no current adapter produces it (unknown DeepSeek finish reasons collapse to `error`), but leave a comment in `TurnEndReasonMap` noting `refusal` should be added when an adapter first emits it (`FinishReasonMap` is merge-extensible); (b) make `agent-loop`'s `loop.ts` populate the reason from the model `finish` chunk — `assembler.finish` lives inside `runStep`, so `runStep` must return it up to `runTurn`, and the rule is "the last step's finish reason wins, but any `max-tokens` in the turn surfaces as `max-tokens`"; (c) no consumer exhaustively switches over `TurnEndReason` today (the invariants plugin switches on `SessionEventType`, and `deriveMessages` ignores `turn/end`), so adding `max-tokens` is a non-breaking extension — but recheck before landing; (d) update [docs/architecture.md](../../../architecture.md) (the CI-verified loop-lifecycle/event-taxonomy doc) and the affected package READMEs/JSDoc (`dsh-session`, `dsh-agent`, `dsh-agent-loop`) per the repo doc-sync policy. This replaces a fragile "observe the finish chunk in the bridge" hack with a real, documented contract. +3. Internal edit — turn-end reason fidelity (sanctioned: edit internals to fit ACP). Extend `TurnEndReasonMap` in the proper places: (a) declaration-merge a `max-tokens` variant in the owning package (`packages/core/session/src/types.ts`, alongside `completed|aborted|error|disposed`) — add `max-tokens` because `FinishReasonMap` produces it (DeepSeek maps `length` → `max-tokens`); do not add `refusal`, since no current adapter produces it (unknown DeepSeek finish reasons collapse to `error`), but leave a comment in `TurnEndReasonMap` noting `refusal` should be added when an adapter first emits it (`FinishReasonMap` is merge-extensible); (b) make `agent-loop`'s `loop.ts` populate the reason from the model `finish` chunk — `assembler.finish` lives inside `runStep`, so `runStep` must return it up to `runTurn`, and the rule is "the last step's finish reason wins, but any `max-tokens` in the turn surfaces as `max-tokens`"; (c) no consumer exhaustively switches over `TurnEndReason` today (the invariants plugin switches on `SessionEventType`, and `deriveMessages` ignores `turn/end`), so adding `max-tokens` is a non-breaking extension — but recheck before landing; (d) update [docs/architecture.md](../../../architecture.md) (the CI-verified loop-lifecycle/event-taxonomy doc) and the affected package READMEs/JSDoc (`dsh-session`, `dsh-agent`, `dsh-agent-loop`) per the repo doc-sync policy. This replaces a fragile "observe the finish chunk in the bridge" hack with a real, documented contract. 4. Prompt-turn streaming plus load: translate `agent/stream-chunk` and `session/event` into `session/update`; resolve `session/prompt` on settle, mapping the harness `TurnEndReason` to the ACP `StopReason` wire enum (`completed`→`end_turn`, `max-tokens`→`max_tokens`, `aborted`→`cancelled`) — a small total function with a test asserting the exact wire strings, since the SDK rejects an unknown `stopReason`. Concrete correlation, since the loop batches queued messages into one turn and `send()` does not synchronously flip to running: install listeners before `send()`; gate on an observed `agent/turn-start` (confirms work was accepted) then resolve on the next `agent/turn-end`; reject an empty/whitespace prompt up front rather than calling `send()` (no turn would ever start, so the RPC would hang). Implement `session/load` on the session-persistence resume seam. 5. Permission gate: a single `tools/execute` listener registered with `prepend: true`, owning a `WeakMap` of bridge-created agents; no-op (`next()`) for unowned/no-agent calls; for owned calls → `session/request_permission` → allow (`next()`) / veto; settle the stored resolver exactly once on outcome, cancel, or connection close. 6. Example wiring (extract a shared base). `@cordisjs/plugin-include` is itself a plugin entry that resets `ctx.baseUrl` and loads a path, so a child `cordis.yml` can nest-include a shared base; the extraction is safe because every dependent plugin declares `inject` (loader groups initialize via `Promise.all`, so YAML order is NOT the dependency mechanism — never rely on it). Extract the provider/tool core (`llm, sessions, system-prompt, tools, agents, invariants, llm-deepseek, bash-local, tool-bash`) into `examples/base.yml`; have both `coding-agent` and a new `examples/acp-agent/` include it and add their own UI plugin plus logger. Keep `agent-loop` per-example (NOT in the base): `AgentLoop` creates its configured agents in its constructor, and the two examples disagree — `coding-agent` needs a pre-created `main` (its `stdio-chat` calls `ctx.agents.get('main')`), while `acp-agent` must pre-create none (ACP `session/new` creates agents). So `coding-agent` declares `agent-loop` with `agents: [{ id: main, … }]` and `acp-agent` with `agents: []`. `acp-agent` loads `dsh-session-persistence-jsonl` (from [session persistence](../../implemented/architecture/2026-06-14-session-persistence.md) — required for `session/load`), omits the stdout logger (see Risks), and adds `pnpm run demo:acp` plus the Zed `agent_servers` snippet. diff --git a/docs/rfc/proposed/feature/2026-06-14-acp-multi-session.md b/docs/rfc/proposed/feature/2026-06-14-acp-multi-session.md index faa8a48f65..ed51a2ded8 100644 --- a/docs/rfc/proposed/feature/2026-06-14-acp-multi-session.md +++ b/docs/rfc/proposed/feature/2026-06-14-acp-multi-session.md @@ -3,7 +3,7 @@ Status: proposed -> **Implementation status:** the multi-session bridge (steps 1, 3, 4) and the bash task-ownership isolation are implemented in `packages/acp` + `packages/tool-bash`. **Per-session *permission* ownership is deferred** — it depends on [the ACP support permission gate](2026-06-14-acp-agent-client-protocol.md) (`TODO(rfc010-permission-gate)`), which is itself deferred; the `agent→sessionId` reverse map the gate will route through is in place. Step 2's per-session disposer scope is now implemented (see [agent lifecycle & ownership seams](../../implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md)): the factory returns a per-agent `AgentHandle` whose `dispose()` stops the loop, awaits quiescence, unregisters the agent, and removes its session, so a bare client disconnect leaves no registered agent or session-store entry. Status stays `proposed` until per-session permission ownership lands. +> **Implementation status:** the multi-session bridge (steps 1, 3, 4) and the bash task-ownership isolation are implemented in `packages/ui/acp` + `packages/bash/tool-bash`. **Per-session *permission* ownership is deferred** — it depends on [the ACP support permission gate](2026-06-14-acp-agent-client-protocol.md) (`TODO(rfc010-permission-gate)`), which is itself deferred; the `agent→sessionId` reverse map the gate will route through is in place. Step 2's per-session disposer scope is now implemented (see [agent lifecycle & ownership seams](../../implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md)): the factory returns a per-agent `AgentHandle` whose `dispose()` stops the loop, awaits quiescence, unregisters the agent, and removes its session, so a bare client disconnect leaves no registered agent or session-store entry. Status stays `proposed` until per-session permission ownership lands. > **Target-client note:** Zed is the current target ACP client, and its ACP client maintains a `HashMap` plus `pending_sessions` for concurrent `session/load` calls. The competing simplification to return to one live session per connection was rejected after checking that target-client shape; this RFC remains the path for finishing multiplexing and per-session permission ownership. See [the rejected simplification](../../rejected/simplification/2026-06-20-single-session-acp-bridge.md). diff --git a/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md b/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md index 281648eb51..784a36c593 100644 --- a/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md +++ b/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md @@ -4,20 +4,22 @@ Status: proposed ## Problem -Package and gate inventories are repeated by hand. [scripts/publint-all.ts](../../../../scripts/publint-all.ts) has a static list of publishable packages. The [package cookbook](../../../cookbook/adding-a-package.md) tells authors to update several files. The [package README](../../../../packages/README.md) carries a hand-written dependency graph. [CI](../../../../.github/workflows/ci.yml) and [development docs](../../../development.md) can drift from the actual `doc-sync` subcommands when new gates are added. These lists are small today, but every new package or gate creates another manual synchronization point. +Package and gate inventories are repeated by hand. The [package cookbook](../../../cookbook/adding-a-package.md) tells authors to update several files. The [package README](../../../../packages/README.md) carries a hand-written dependency graph. [CI](../../../../.github/workflows/ci.yml) and [development docs](../../../development.md) can drift from the actual `doc-sync` subcommands when new gates are added. `tsconfig.build.json` lists all 18 packages as explicit project `references`. These lists are small today, but every new package or gate creates another manual synchronization point. + +The [package hierarchy](../../implemented/architecture/2026-06-20-package-hierarchy.md) already removed several of these by hand: `scripts/publint-all.ts` now derives its list from the `packages//` layout, and the two `tsconfig` `paths` maps collapsed to one `@deepseek-ai/dsh-*` wildcard. What remains is the inventory that cannot be globbed away — chiefly `tsconfig.build.json`'s project `references`, which TypeScript requires as an explicit array (no wildcard form). Static lists are appropriate when they encode policy; they are needless friction when they duplicate manifest data or layout facts that already exist in `package.json`, workspace globs, or the package hierarchy. ## Proposal -Make package/gate inventories discoverable. Publishability should come from the deliberate [package hierarchy](../architecture/2026-06-20-package-hierarchy.md) plus package manifests, not from a static array in a script or the npm `private` flag. Module graph generation should read package manifests. `doc-sync` should be the one command that defines and prints its sub-gates, with docs linking to that command rather than restating a second list. +Make the remaining package/gate inventories discoverable. A single canonical source — the `packages//` hierarchy plus package manifests — should drive `tsconfig.build.json`'s `references`, the module graph, and any other full-package list, with a generate-and-verify step (the existing `gen-module-graph` / `gen-cordis-catalog` pattern: a generator writes the artifact, a `--check` mode in `hygiene`/`doc-sync` fails on a stale committed copy). Module graph generation already reads package manifests. `doc-sync` should be the one command that defines and prints its sub-gates, with docs linking to that command rather than restating a second list. The hierarchy does not need to encode every fact about a package, but it should encode the broad maintenance policy: core/product packages, integrations, capability seams, and support/test/example packages should not all require a hand-maintained exception list before scripts can tell them apart. ## Acceptance criteria -- `publint-all` discovers publishable packages from the hierarchy plus manifests instead of a hard-coded array. -- Adding a package does not require editing a static package list for every gate. +- `tsconfig.build.json` project `references` are generated from the hierarchy (a generator emits them; a `--check` gate fails when the committed copy is stale), rather than hand-maintained. +- Adding a package does not require editing a static package list for any gate. - Docs describe the source of truth rather than repeating generated inventories. - CI invokes the aggregate commands and lets those commands own their sub-gate lists. diff --git a/docs/rfc/proposed/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md b/docs/rfc/proposed/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md index be39827bf0..bf5eb3bed2 100644 --- a/docs/rfc/proposed/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md +++ b/docs/rfc/proposed/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md @@ -4,7 +4,7 @@ Status: proposed ## Problem -`LlmService.registerAdapter()` emits `llm/adapter-change` on registration and disposal ([packages/llm/src/index.ts](../../../../packages/llm/src/index.ts)). Grepping `llm/adapter-change` across `packages/*/src` and `examples/*/src` finds only the declaration, emit sites, docs, and tests; no production listener subscribes to it. +`LlmService.registerAdapter()` emits `llm/adapter-change` on registration and disposal ([packages/llm/llm/src/index.ts](../../../../packages/llm/llm/src/index.ts)). Grepping `llm/adapter-change` across `packages/*/src` and `examples/*/src` finds only the declaration, emit sites, docs, and tests; no production listener subscribes to it. This differs from `tools/change` and `system-prompt/change`. Those two events are also unconsumed today, but they are plausible registry-change signals for future live tool/prompt UIs. LLM adapter registration is more of a boot-time implementation detail: adapters are not a user-visible palette and the real model-call interception seam is `llm/stream`. Keeping an adapter-change event with no listener repeats the [drop-the-dead-summary](../../implemented/simplification/2026-06-19-drop-mutable-session-summary.md) pattern at a smaller scale. @@ -19,7 +19,7 @@ Remove only `llm/adapter-change`: - Simplify `registerAdapter()`'s effect generator: keep the mutation and rollback disposer for HMR/disposal, but drop the listener-throw rollback ordering that exists only for the removed event. - Remove the "Emits `llm/adapter-change` on registration and disposal" sentence from `LlmService.registerAdapter`'s JSDoc. - Rewrite the adapter-disposer test to assert the returned disposer removes the adapter without subscribing to `llm/adapter-change`; delete the listener-throw rollback test that exists solely for the removed event. -- Update the event taxonomy table in [docs/architecture.md](../../../architecture.md) and [packages/llm/README.md](../../../../packages/llm/README.md). The [doc-sync-enforcement RFC](../../implemented/process/2026-06-11-doc-sync-enforcement.md) should avoid using `llm/adapter-change` as an example once the event is gone. +- Update the event taxonomy table in [docs/architecture.md](../../../architecture.md) and [packages/llm/llm/README.md](../../../../packages/llm/llm/README.md). The [doc-sync-enforcement RFC](../../implemented/process/2026-06-11-doc-sync-enforcement.md) should avoid using `llm/adapter-change` as an example once the event is gone. ## Why not remove every registry change event? diff --git a/docs/rfc/proposed/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md b/docs/rfc/proposed/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md index 1f0a1efadd..cf93b3e83a 100644 --- a/docs/rfc/proposed/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md +++ b/docs/rfc/proposed/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md @@ -4,17 +4,17 @@ Status: proposed ## Problem -`LlmService` ([packages/llm/src/index.ts](../../../../packages/llm/src/index.ts)) exposes three call surfaces over a model: +`LlmService` ([packages/llm/llm/src/index.ts](../../../../packages/llm/llm/src/index.ts)) exposes three call surfaces over a model: - `stream()` — raw `StreamChunk`s, dispatched through the `llm/stream` waterfall. -- `streamBlocks()` — a "convenience view" that runs the chunks through a `BlockAssembler` and yields completed `ContentBlock`s in stream order ([index.ts:137-144](../../../../packages/llm/src/index.ts)). -- `generate()` — one fully-assembled `GenerateResult`, dispatched through a second `llm/generate` waterfall ([index.ts:151-157](../../../../packages/llm/src/index.ts)). +- `streamBlocks()` — a "convenience view" that runs the chunks through a `BlockAssembler` and yields completed `ContentBlock`s in stream order ([index.ts:137-144](../../../../packages/llm/llm/src/index.ts)). +- `generate()` — one fully-assembled `GenerateResult`, dispatched through a second `llm/generate` waterfall ([index.ts:151-157](../../../../packages/llm/llm/src/index.ts)). -The only production consumer of the LLM service is the agent loop, and it uses `stream()` exclusively — feeding raw chunks through its own `BlockAssembler` so it can log chunks for replay fidelity while assembling in parallel ([packages/agent-loop/src/loop.ts](../../../../packages/agent-loop/src/loop.ts), the `ctx.llm.stream(req)` step). Grepping `streamBlocks` and `ctx.llm.generate` across `packages/*/src` and `examples/*/src` finds no production callers. The references are the service methods, docs, and tests; adapter tests use `generate()` as a convenient driver, but they can hand-drain `stream()` through the same assembler helper without preserving a public production API. +The only production consumer of the LLM service is the agent loop, and it uses `stream()` exclusively — feeding raw chunks through its own `BlockAssembler` so it can log chunks for replay fidelity while assembling in parallel ([packages/core/agent-loop/src/loop.ts](../../../../packages/core/agent-loop/src/loop.ts), the `ctx.llm.stream(req)` step). Grepping `streamBlocks` and `ctx.llm.generate` across `packages/*/src` and `examples/*/src` finds no production callers. The references are the service methods, docs, and tests; adapter tests use `generate()` as a convenient driver, but they can hand-drain `stream()` through the same assembler helper without preserving a public production API. This is the [drop-mutable-session-summary](../../implemented/simplification/2026-06-19-drop-mutable-session-summary.md) pattern: assembled-view APIs with tested contracts, consumed by tests rather than production. They were built speculatively for consumers that do not care about token-level deltas, but the one real consumer cares about deltas precisely so it can persist high-fidelity replay data. -`streamBlocks()` drags a dedicated slice of `BlockAssembler` behind it: `flushReady()` and `flushRemaining()` ([packages/llm/src/assembler.ts:138-168](../../../../packages/llm/src/assembler.ts)) plus the `flushed` cursor field exist only to support incremental in-order yield. `generate()` drags `GenerateResult`, `BlockAssembler.result()`, and the `llm/generate` waterfall as a second interception surface over the same underlying stream. The loop's assembler usage is `push()` / `message()` / `usage` / `finish` — not streaming flush or one-shot service assembly. +`streamBlocks()` drags a dedicated slice of `BlockAssembler` behind it: `flushReady()` and `flushRemaining()` ([packages/llm/llm/src/assembler.ts:138-168](../../../../packages/llm/llm/src/assembler.ts)) plus the `flushed` cursor field exist only to support incremental in-order yield. `generate()` drags `GenerateResult`, `BlockAssembler.result()`, and the `llm/generate` waterfall as a second interception surface over the same underlying stream. The loop's assembler usage is `push()` / `message()` / `usage` / `finish` — not streaming flush or one-shot service assembly. ## Proposal @@ -34,7 +34,7 @@ Make `stream()` the only public LLM call surface: - `pnpm run test:coverage` stays at 100% per-file (the deleted methods take their dedicated tests with them; no remaining line goes uncovered). - Adapter tests still exercise both real adapters through `stream()` and the shared assembler, not through a test-only public shortcut. - The loop behaves identically — verified by unchanged ACP snapshot goldens. -- `packages/llm/README.md`, [docs/architecture.md](../../../architecture.md), and module docs no longer mention the removed convenience surfaces. +- `packages/llm/llm/README.md`, [docs/architecture.md](../../../architecture.md), and module docs no longer mention the removed convenience surfaces. ## Risks diff --git a/docs/rfc/proposed/simplification/2026-06-20-prune-dead-seam-methods.md b/docs/rfc/proposed/simplification/2026-06-20-prune-dead-seam-methods.md index de79c3f355..117fe9c72b 100644 --- a/docs/rfc/proposed/simplification/2026-06-20-prune-dead-seam-methods.md +++ b/docs/rfc/proposed/simplification/2026-06-20-prune-dead-seam-methods.md @@ -8,13 +8,13 @@ Two capability seams ([interface / implementation / consumer](../../implemented/ ### `SessionPersistence.has()` and `.delete()` -The abstract service declares four operations beyond create/append: `load`, `list`, `has`, `delete` ([packages/session-persistence/src/index.ts:142-151](../../../../packages/session-persistence/src/index.ts)). Production consumers of `ctx.sessionPersistence` use only two of them: the agent-loop resume path calls `load()` ([packages/agent-loop/src/index.ts:176-194](../../../../packages/agent-loop/src/index.ts)), and the ACP bridge calls `list()` for `session/list` ([packages/acp/src/index.ts](../../../../packages/acp/src/index.ts)). Grepping every `sessionPersistence.*` / `persistence.*` use across `packages/*/src` and `examples/` finds no `has(` and no `delete(` on the service. The `.has(`/`.delete(` calls in `packages/acp/src/index.ts` are on the in-memory `SessionStore` and a local `Set` of loading ids, not persistence. The only callers of `has`/`delete` are the contract suites and per-backend specs. +The abstract service declares four operations beyond create/append: `load`, `list`, `has`, `delete` ([packages/session-persistence/session-persistence/src/index.ts:142-151](../../../../packages/session-persistence/session-persistence/src/index.ts)). Production consumers of `ctx.sessionPersistence` use only two of them: the agent-loop resume path calls `load()` ([packages/core/agent-loop/src/index.ts:176-194](../../../../packages/core/agent-loop/src/index.ts)), and the ACP bridge calls `list()` for `session/list` ([packages/ui/acp/src/index.ts](../../../../packages/ui/acp/src/index.ts)). Grepping every `sessionPersistence.*` / `persistence.*` use across `packages/*/src` and `examples/` finds no `has(` and no `delete(` on the service. The `.has(`/`.delete(` calls in `packages/ui/acp/src/index.ts` are on the in-memory `SessionStore` and a local `Set` of loading ids, not persistence. The only callers of `has`/`delete` are the contract suites and per-backend specs. -`has()` is not just unused — it is the most intricate branch in the shared coordinator: a tracked-vs-untracked dual-probe (`loadLive(id, cwd)` for a live-tracked session vs `loadStored(id)` for an untracked one) with a multi-line rationale ([packages/session-persistence/src/coordinator.ts:298-310](../../../../packages/session-persistence/src/coordinator.ts)). `delete()` drags the `deleteStored` backend hook ([coordinator.ts:99](../../../../packages/session-persistence/src/coordinator.ts), [coordinator.ts:313-319](../../../../packages/session-persistence/src/coordinator.ts)) that every backend must implement. This is the [drop-mutable-session-summary](../../implemented/simplification/2026-06-19-drop-mutable-session-summary.md) pattern: a contract test exercises both, but no shipping code asks "is this session persisted?" or removes one. +`has()` is not just unused — it is the most intricate branch in the shared coordinator: a tracked-vs-untracked dual-probe (`loadLive(id, cwd)` for a live-tracked session vs `loadStored(id)` for an untracked one) with a multi-line rationale ([packages/session-persistence/session-persistence/src/coordinator.ts:298-310](../../../../packages/session-persistence/session-persistence/src/coordinator.ts)). `delete()` drags the `deleteStored` backend hook ([coordinator.ts:99](../../../../packages/session-persistence/session-persistence/src/coordinator.ts), [coordinator.ts:313-319](../../../../packages/session-persistence/session-persistence/src/coordinator.ts)) that every backend must implement. This is the [drop-mutable-session-summary](../../implemented/simplification/2026-06-19-drop-mutable-session-summary.md) pattern: a contract test exercises both, but no shipping code asks "is this session persisted?" or removes one. ### `BashExecutor.get()` and `.list()` -The bash seam declares `get(id)` ("look up a background task by id") and `list()` ("all tracked background tasks") ([packages/bash/src/index.ts:88-107](../../../../packages/bash/src/index.ts)), both implemented by `LocalBashExecutor` ([packages/bash-local/src/index.ts:179-191](../../../../packages/bash-local/src/index.ts)). The sole production consumer — `dsh-tool-bash` — drives tasks via `ownerOf`, `onTaskDone`, `start`, `readOutput`, `kill`, `resolve`, `run`; it never calls `get`/`list` in shipping code, and there is no `bash_list` tool exposing a task roster to the model. So both are dead production seam surface. They are used by tests, more broadly than a single idiom: the bash seam/executor specs assert them directly ([packages/bash/tests/service.spec.ts](../../../../packages/bash/tests/service.spec.ts), [packages/bash-local/tests/executor.spec.ts](../../../../packages/bash-local/tests/executor.spec.ts) both call `get()`/`list()`), and several `dsh-tool-bash` tests reach through `ctx.bash.get(id)` to await a task's `done`, read its `status`, or inspect task fields ([packages/tool-bash/tests/tools.spec.ts](../../../../packages/tool-bash/tests/tools.spec.ts), [packages/tool-bash/tests/integration.spec.ts](../../../../packages/tool-bash/tests/integration.spec.ts)). These are test-harness conveniences, not shipping consumers — but they are real test code an implementing PR must migrate or delete. +The bash seam declares `get(id)` ("look up a background task by id") and `list()` ("all tracked background tasks") ([packages/bash/bash/src/index.ts:88-107](../../../../packages/bash/bash/src/index.ts)), both implemented by `LocalBashExecutor` ([packages/bash/bash-local/src/index.ts:179-191](../../../../packages/bash/bash-local/src/index.ts)). The sole production consumer — `dsh-tool-bash` — drives tasks via `ownerOf`, `onTaskDone`, `start`, `readOutput`, `kill`, `resolve`, `run`; it never calls `get`/`list` in shipping code, and there is no `bash_list` tool exposing a task roster to the model. So both are dead production seam surface. They are used by tests, more broadly than a single idiom: the bash seam/executor specs assert them directly ([packages/bash/bash/tests/service.spec.ts](../../../../packages/bash/bash/tests/service.spec.ts), [packages/bash/bash-local/tests/executor.spec.ts](../../../../packages/bash/bash-local/tests/executor.spec.ts) both call `get()`/`list()`), and several `dsh-tool-bash` tests reach through `ctx.bash.get(id)` to await a task's `done`, read its `status`, or inspect task fields ([packages/bash/tool-bash/tests/tools.spec.ts](../../../../packages/bash/tool-bash/tests/tools.spec.ts), [packages/bash/tool-bash/tests/integration.spec.ts](../../../../packages/bash/tool-bash/tests/integration.spec.ts)). These are test-harness conveniences, not shipping consumers — but they are real test code an implementing PR must migrate or delete. ## Proposal @@ -22,7 +22,7 @@ Remove the methods nothing consumes, from the abstract seam, the implementation, - `SessionPersistence.has()` / `.delete()`: delete the abstract declarations, the coordinator's `has`/`delete`/`deleteCore`, and the `PersistenceBackend.deleteStored` hook. Remove the `has`/`delete` rows from the contract suite and the per-backend specs (jsonl + sqlite each implement `deleteStored` only to satisfy the hook — that implementation goes too). The backends are the [dual-backend](../../implemented/architecture/2026-06-14-session-persistence.md) design and otherwise out of scope, but removing a hook they implement for no consumer is part of removing the hook, not a backend redesign. - `BashExecutor.get()` / `.list()`: delete the abstract declarations and the `LocalBashExecutor` impls. The seam/executor specs that assert `get()`/`list()` directly (`bash/tests/service.spec.ts`, `bash-local/tests/executor.spec.ts`) lose those assertions (the behavior is being removed). The `dsh-tool-bash` tests that reach through `ctx.bash.get(id)` to await `done`, read `status`, or inspect task fields switch to the public completion/status seam they should use — `onTaskDone` (or the `done` promise and status the `start()` return already exposes) — keeping their coverage without the removed lookup method. -- Update every doc and source-comment reference to the removed methods — not only literal `has(`/`delete(`/`get(`/`list(`/`deleteStored` call spellings, but also `{@link has}`/`{@link delete}` JSDoc links and prose that counts the methods (removing 2 of the persistence service's 6 public methods makes any "six public methods" phrasing wrong). The implementing PR greps `has`/`delete`/`get`/`list`/`deleteStored`/`{@link `/`six ` across `docs/`, `packages/*/README.md`, and source comments, and fixes each. The known doc sites: the seam READMEs ([packages/session-persistence/README.md](../../../../packages/session-persistence/README.md)'s `has(id)`/`delete(id)` API row and its "delegates its six public service methods" prose → four, [packages/bash/README.md](../../../../packages/bash/README.md)'s `get(id)`/`list()` row), the backend READMEs that describe `has`/`list` semantics ([packages/session-persistence-sqlite/README.md](../../../../packages/session-persistence-sqlite/README.md), [packages/session-persistence-jsonl/README.md](../../../../packages/session-persistence-jsonl/README.md) — reword "absent from `has()`/`list()`" to just `list()`), the service-map / seam docs in [docs/architecture.md](../../../architecture.md), and the persistence prose in the [session-persistence RFC](../../implemented/architecture/2026-06-14-session-persistence.md) and [shared write-coordinator RFC](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md). The known source-comment sites: the abstract `create()` JSDoc's `{@link has}/{@link list}` link ([packages/session-persistence/src/index.ts](../../../../packages/session-persistence/src/index.ts) — drop the `has` link), the coordinator's "six public methods"/"six public service methods" module + class JSDoc and its lazy-materialization JSDoc justifying the `materialized` flag by "the signal `has`/`list` rely on" ([packages/session-persistence/src/coordinator.ts](../../../../packages/session-persistence/src/coordinator.ts)), the JSONL backend's `loadStored`/`deleteStored` comment, and the SQLite backend's `schema.ts` and `index.ts` comments that mention "absent from `has`/`list`" — all reworded to the surviving four-method, `list()`-only contract. +- Update every doc and source-comment reference to the removed methods — not only literal `has(`/`delete(`/`get(`/`list(`/`deleteStored` call spellings, but also `{@link has}`/`{@link delete}` JSDoc links and prose that counts the methods (removing 2 of the persistence service's 6 public methods makes any "six public methods" phrasing wrong). The implementing PR greps `has`/`delete`/`get`/`list`/`deleteStored`/`{@link `/`six ` across `docs/`, `packages/*/README.md`, and source comments, and fixes each. The known doc sites: the seam READMEs ([packages/session-persistence/session-persistence/README.md](../../../../packages/session-persistence/session-persistence/README.md)'s `has(id)`/`delete(id)` API row and its "delegates its six public service methods" prose → four, [packages/bash/bash/README.md](../../../../packages/bash/bash/README.md)'s `get(id)`/`list()` row), the backend READMEs that describe `has`/`list` semantics ([packages/session-persistence/session-persistence-sqlite/README.md](../../../../packages/session-persistence/session-persistence-sqlite/README.md), [packages/session-persistence/session-persistence-jsonl/README.md](../../../../packages/session-persistence/session-persistence-jsonl/README.md) — reword "absent from `has()`/`list()`" to just `list()`), the service-map / seam docs in [docs/architecture.md](../../../architecture.md), and the persistence prose in the [session-persistence RFC](../../implemented/architecture/2026-06-14-session-persistence.md) and [shared write-coordinator RFC](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md). The known source-comment sites: the abstract `create()` JSDoc's `{@link has}/{@link list}` link ([packages/session-persistence/session-persistence/src/index.ts](../../../../packages/session-persistence/session-persistence/src/index.ts) — drop the `has` link), the coordinator's "six public methods"/"six public service methods" module + class JSDoc and its lazy-materialization JSDoc justifying the `materialized` flag by "the signal `has`/`list` rely on" ([packages/session-persistence/session-persistence/src/coordinator.ts](../../../../packages/session-persistence/session-persistence/src/coordinator.ts)), the JSONL backend's `loadStored`/`deleteStored` comment, and the SQLite backend's `schema.ts` and `index.ts` comments that mention "absent from `has`/`list`" — all reworded to the surviving four-method, `list()`-only contract. ## Why not keep them as "the seam should be complete"? diff --git a/docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.md b/docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.md index 73dd601139..56f20c04ef 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.md +++ b/docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.md @@ -17,7 +17,7 @@ ACP `session/load` can replay prior assistant messages as complete content block ## Acceptance criteria - `SessionEventMap` drops `assistant/chunk`, or marks it as non-persisted if a transitional live event is needed. -- [Session persistence docs](../../../../packages/session-persistence/README.md) no longer require every stream chunk to be stored verbatim. +- [Session persistence docs](../../../../packages/session-persistence/session-persistence/README.md) no longer require every stream chunk to be stored verbatim. - `llm-replay` and ACP snapshots use an explicit replay fixture format or sidecar for model chunks. - `session/load` renders completed assistant messages from `assistant/message`. - Stored logs get much smaller and remain `seq`-contiguous without chunk holes. diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.md b/docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.md index c8d86066bf..f6cc3a3236 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.md +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.md @@ -18,7 +18,7 @@ For now, ACP starts fresh sessions only. `initialize` advertises `loadSession: f - `initialize` does not advertise load support. - The `session/load` handler, loading-id tracking, cwd preflight for loaded sessions, and load replay tests are removed. - Snapshot fixtures no longer rely on load replay presentation. -- [ACP docs](../../../../packages/acp/README.md) describe fresh-session support only. +- [ACP docs](../../../../packages/ui/acp/README.md) describe fresh-session support only. ## What we give up diff --git a/docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.md b/docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.md index 6731b50211..229e2810fc 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.md +++ b/docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.md @@ -20,7 +20,7 @@ The implementing PR should update the [capability seams](../../implemented/archi - `dsh-session` exports the persistence service type, coordinator, and contract helpers. - JSONL and SQLite backend packages depend on `dsh-session` directly. - `agent-loop` resume uses the session-owned service key. -- [Session persistence](../../implemented/architecture/2026-06-14-session-persistence.md), [shared persistence write coordinator](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md), and [package docs](../../../../packages/session-persistence/README.md) explain why backend implementations remain separate. +- [Session persistence](../../implemented/architecture/2026-06-14-session-persistence.md), [shared persistence write coordinator](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md), and [package docs](../../../../packages/session-persistence/session-persistence/README.md) explain why backend implementations remain separate. ## What we give up diff --git a/docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.md b/docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.md index ed8e41d598..90e2b3f7a4 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.md +++ b/docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.md @@ -19,7 +19,7 @@ This makes the persisted turn boundary simple: a completed `turn/end` is the che - `TurnEndReasonMap` drops the `interrupted` variant. - `interruptedTurnClosers()` and its tests disappear. - The persistence coordinator's repair hook truncates backend-specific torn/open tail state without appending closers. -- [Session persistence docs](../../../../packages/session-persistence/README.md) say load returns the last completed turn, plus no partial final turn. +- [Session persistence docs](../../../../packages/session-persistence/session-persistence/README.md) say load returns the last completed turn, plus no partial final turn. - Snapshot and contract tests update together with the behavior they pin. - The session format version and recorded fixtures are refreshed; non-current stored logs are rejected per the pre-release format policy, with no migration path. diff --git a/eslint.config.mjs b/eslint.config.mjs index a4f48af798..d4763d8377 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -30,7 +30,7 @@ export default tseslint.config( // --- our packages: full strictness ------------------------------------- { - files: ['packages/*/src/**/*.ts', 'examples/**/*.ts', 'scripts/**/*.ts'], + files: ['packages/*/*/src/**/*.ts', 'examples/**/*.ts', 'scripts/**/*.ts'], extends: [ ...tseslint.configs.strictTypeChecked, ], @@ -81,7 +81,7 @@ export default tseslint.config( // --- tests: same rules, minus the friction that fights test ergonomics -- { - files: ['packages/*/tests/**/*.ts'], + files: ['packages/*/*/tests/**/*.ts'], extends: [ ...tseslint.configs.strictTypeChecked, ], diff --git a/examples/acp-agent/README.md b/examples/acp-agent/README.md index f6cab28700..afaf920bfe 100644 --- a/examples/acp-agent/README.md +++ b/examples/acp-agent/README.md @@ -28,7 +28,7 @@ Add to your Zed `settings.json` under `agent_servers`: } ``` -The editor sets each session's `cwd` to the project it opens; the agent's bash tools run there (see the per-session `cwd` note in `packages/acp`), so launch the server from the harness repo with `pnpm --dir …` and let ACP carry the workspace path per session. +The editor sets each session's `cwd` to the project it opens; the agent's bash tools run there (see the per-session `cwd` note in `packages/ui/acp`), so launch the server from the harness repo with `pnpm --dir …` and let ACP carry the workspace path per session. ## Snapshot tests (record-once / replay-deterministic) @@ -36,4 +36,4 @@ This example is the home of the harness's **snapshot tests** — they boot this ## MVP limitations -The bridge supports N concurrent sessions per connection, each in its own workspace `cwd` (RFC 011). Remaining limits: prompts support ACP's baseline `text` and `resource_link` blocks only, `additionalDirectories` and `mcpServers` are rejected, and the tool-permission gate is deferred (`TODO(rfc010-permission-gate)` — tools run with the executor's full authority). See `packages/acp/README.md` for the full contract. +The bridge supports N concurrent sessions per connection, each in its own workspace `cwd` (RFC 011). Remaining limits: prompts support ACP's baseline `text` and `resource_link` blocks only, `additionalDirectories` and `mcpServers` are rejected, and the tool-permission gate is deferred (`TODO(rfc010-permission-gate)` — tools run with the executor's full authority). See `packages/ui/acp/README.md` for the full contract. diff --git a/knip.json b/knip.json index 44dd3d37bb..e2e82038e3 100644 --- a/knip.json +++ b/knip.json @@ -13,15 +13,15 @@ ], "project": ["scripts/**/*.ts", "examples/**/*.ts"] }, - "packages/*": { + "packages/*/*": { "entry": ["tests/**/*.spec.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, - "packages/llm-deepseek": { + "packages/llm/llm-deepseek": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, - "packages/llm-pi-ai": { + "packages/llm/llm-pi-ai": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] } diff --git a/package.json b/package.json index 13e289df06..499cffaf77 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ }, "workspaces": [ "vendor/*", - "packages/*" + "packages/*/*" ], "scripts": { "build": "tsc -b tsconfig.build.json && tsdown", @@ -27,6 +27,7 @@ "verify-md-wrap": "tsx scripts/verify-md-wrap.ts", "verify-md-links": "tsx scripts/verify-md-links.ts", "verify-doc-refs": "tsx scripts/verify-doc-refs.ts", + "verify-package-paths": "tsx scripts/verify-package-paths.ts", "verify-rfc-classification": "tsx scripts/verify-rfc-classification.ts", "verify-type-equiv": "tsx scripts/verify-type-equiv.ts", "gen-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts", @@ -34,7 +35,7 @@ "gen-module-graph": "tsx scripts/gen-module-graph.ts", "verify-module-graph": "tsx scripts/gen-module-graph.ts --check", "constraints": "tsx scripts/check-workspace-constraints.ts", - "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-rfc-classification && pnpm run verify-type-equiv", + "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-rfc-classification && pnpm run verify-type-equiv", "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints", "demo:echo": "node --expose-internals --import tsx examples/echo-agent/start.ts", "demo:coding": "node --expose-internals --import tsx examples/coding-agent/start.ts", diff --git a/packages/AGENTS.md b/packages/AGENTS.md index 735e23a156..62d37a3354 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -7,12 +7,12 @@ This directory contains all `@deepseek-ai/dsh-*` harness packages. When editing - **Waterfall semantics**: `ctx.waterfall` listeners receive `(...args, next)`; call `next()` to delegate, or return without it to short-circuit (veto). Never call `next()` after returning. - **Plugin export shape — namespace OR default, never both.** A *service* package exports the service class as `export default` (the Loader instantiates it). A *function/namespace* plugin exports `name` / `inject` / `Config` / `apply` as separate named exports and **must NOT add `export default`** — the cordis Loader's `unwrapExports` does `exports.default ?? exports`, so a stray default export collapses the module to the bare `apply` function and silently discards the `inject`/`name`/`Config` namespace, leaving the plugin with no injected services (it then throws `cannot get property … without inject` at load). See [docs/postmortem/0001](../docs/postmortem/0001-acp-default-export-drops-inject.md). - **Read an optional (non-injected) service via `ctx.get(name)`, not `ctx.`.** For a service a plugin reads opportunistically but deliberately leaves out of `static inject` (e.g. `AgentLoop` reading `sessionPersistence`), the `ctx.` property proxy resolves by an ancestor-only fiber walk that throws when the call arrives through a foreign traceable shadow (the service lives on a sibling fiber). `ctx.get(name)` is the topology-independent global-store lookup, strict by default (an inactive/absent backend reads as `undefined` — prefer it over the `ctx.get(name, false)` overload, which also skips the active-state check). Services that ARE in `static inject` resolve fine via `ctx.`. See [docs/postmortem/0001](../docs/postmortem/0001-acp-default-export-drops-inject.md). -- **Tests**: vitest in `packages//tests/*.spec.ts`. Every registry needs an HMR-safety test (register a plugin, dispose its fiber, assert cleanup). Err on the side of more tests — edge cases, error paths, event ordering, races. A plugin shipped via `cordis.yml` also needs at least one test that drives it through the REAL Loader/export path (hand-built `ctx.plugin({...})` mounts bypass `unwrapExports` and cannot catch a broken export shape) — see AGENTS.md § Defensive patterns "Line coverage is not behavior coverage". Real-API (with-key) e2e tests are cheap here (we are DeepSeek) and welcome — write many, especially smoke tests; see AGENTS.md § Secrets / .env. +- **Tests**: vitest in `packages///tests/*.spec.ts`. Every registry needs an HMR-safety test (register a plugin, dispose its fiber, assert cleanup). Err on the side of more tests — edge cases, error paths, event ordering, races. A plugin shipped via `cordis.yml` also needs at least one test that drives it through the REAL Loader/export path (hand-built `ctx.plugin({...})` mounts bypass `unwrapExports` and cannot catch a broken export shape) — see AGENTS.md § Defensive patterns "Line coverage is not behavior coverage". Real-API (with-key) e2e tests are cheap here (we are DeepSeek) and welcome — write many, especially smoke tests; see AGENTS.md § Secrets / .env. Naming notes: - A *service* `src/index.ts` exports the service class as `export default` + all public types; a *function/namespace plugin* `src/index.ts` exports `name`/`inject`/`Config`/`apply` as named exports and NO default (see the plugin-export-shape rule above) - `src/types.ts` contain only types — no runtime code - Tests live at package level under `tests/`, not `src/__tests__/` -- A package's README and module/JSDoc comments are part of the change: when you alter behavior (config keys, defaults, error codes, wire fields), update them in the same commit. CI runs `pnpm run doc-sync`, which typechecks fenced `ts` blocks in `packages/*/*.md`, regenerates the cordis events/services catalog from the `interface Events` / `interface Context` declarations (failing if the committed copy is stale), and checks markdown wrapping across this file too — but it does NOT catch prose drift (config keys, defaults, error codes), so those stay on the author. A new event needs an `@mode` tag on its JSDoc (the catalog generator hard-errors without it — see the root AGENTS.md). +- A package's README and module/JSDoc comments are part of the change: when you alter behavior (config keys, defaults, error codes, wire fields), update them in the same commit. CI runs `pnpm run doc-sync`, which typechecks fenced `ts` blocks in `packages/*/*.md` and `packages/*/*/*.md`, regenerates the cordis events/services catalog from the `interface Events` / `interface Context` declarations (failing if the committed copy is stale), and checks markdown wrapping across this file too — but it does NOT catch prose drift (config keys, defaults, error codes), so those stay on the author. A new event needs an `@mode` tag on its JSDoc (the catalog generator hard-errors without it — see the root AGENTS.md). Read the per-package README.md for package-specific details: service API, events, extension points, TODOs. diff --git a/packages/README.md b/packages/README.md index 68d7600c85..139050683d 100644 --- a/packages/README.md +++ b/packages/README.md @@ -2,13 +2,20 @@ Harness packages, all under the `@deepseek-ai/dsh-*` scope. Each package is a Cordis plugin (microkernel-style): it exports either a default `Service` subclass or a functional plugin that gets registered via `ctx.plugin()`, declares its ctx key/events where applicable through declaration merging, and exposes extension points through `ctx.effect()`, `ctx.on()`, and `ctx.waterfall()`. - +## Hierarchy + +Packages are grouped by modular role at `packages///`. The group directory is a pure container (no `package.json` of its own); the package name stays `@deepseek-ai/dsh-` regardless of group. Each group has a `README.md` describing its role and whether it is product or support infrastructure. + +| Group | Role | Release expectation | +|---|---|---| +| [`core/`](core/README.md) | Product API spine: session, system-prompt, tools, agent, and the concrete loop | Product — stable surface | +| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface | +| [`bash/`](bash/README.md) | Bash capability family: the executor seam, a local impl, and the model-facing tool | Product — stable surface | +| [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface | +| [`ui/`](ui/README.md) | Editor/client integration surfaces (the ACP bridge) | Product — stable surface | +| [`support/`](support/README.md) | Dev/test/example infrastructure (invariants, stdio UI, replay adapter) | Support — lower compatibility expectations | + +The split is the point: a package's group says whether it is part of the product API or support/test/example infrastructure, so release and removal decisions do not have to treat every package as an equal public contract. New packages join an existing group; adding a new top-level group is a deliberate act (extend the group READMEs and the hierarchy docs). ## Dependency graph @@ -34,23 +41,26 @@ The rule: plugins depend on interfaces, never on the concrete loop. `dsh-agent-l ## What goes where -| Package | Role | ctx key | -|---|---|---| -| `llm/` | Abstract LLM service + content-block vocabulary + chunk assembler | `ctx.llm` | -| `session/` | Event-sourced session log + in-memory store | `ctx.sessions` | -| `system-prompt/` | Prompt-section + tool-schema assembly registry | `ctx.systemPrompt` | -| `tools/` | Tool registry + `tools/execute` waterfall | `ctx.tools` | -| `agent/` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` | -| `agent-loop/` | THE concrete loop plugin: `ReactLoopAgent` + the loop driver | `ctx.agentLoop` | -| `bash/` | Abstract bash executor seam (interface + vocabulary) | `ctx.bash` | -| `bash-local/` | Local-subprocess `BashExecutor` implementation | (registers `ctx.bash`) | -| `tool-bash/` | Model-facing `bash`/`bash_output`/`bash_kill` tool schemas | (registers on `ctx.tools`) | -| `llm-deepseek/` | DeepSeek API adapter (hand-rolled fetch/SSE) | (registers on `ctx.llm`) | -| `llm-pi-ai/` | DeepSeek adapter via `@earendil-works/pi-ai` (design twin) | (registers on `ctx.llm`) | -| `invariants/` | Dev-mode event-contract invariants + session-log freeze | (listens on `session/*`, `agent/*`) | -| `acp/` | Agent Client Protocol bridge: serves the agent to an ACP editor over JSON-RPC stdio | (drives `ctx.agents`/`ctx.sessions`) | -| `ui-stdio/` | Minimal stdio (readline) UI plugin: renders `agent/*` events, feeds stdin lines to the agent | (drives `ctx.agents`) | -| `llm-replay/` | Record/replay adapter: short-circuits `llm/stream` with chunks from a recorded session JSONL (keyless snapshot tests) | (listens on `llm/stream`) | +| Package | Group | Role | ctx key | +|---|---|---|---| +| `llm/` | `llm` | Abstract LLM service + content-block vocabulary + chunk assembler | `ctx.llm` | +| `session/` | `core` | Event-sourced session log + in-memory store | `ctx.sessions` | +| `system-prompt/` | `core` | Prompt-section + tool-schema assembly registry | `ctx.systemPrompt` | +| `tools/` | `core` | Tool registry + `tools/execute` waterfall | `ctx.tools` | +| `agent/` | `core` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` | +| `agent-loop/` | `core` | THE concrete loop plugin: `ReactLoopAgent` + the loop driver | `ctx.agentLoop` | +| `bash/` | `bash` | Abstract bash executor seam (interface + vocabulary) | `ctx.bash` | +| `bash-local/` | `bash` | Local-subprocess `BashExecutor` implementation | (registers `ctx.bash`) | +| `tool-bash/` | `bash` | Model-facing `bash`/`bash_output`/`bash_kill` tool schemas | (registers on `ctx.tools`) | +| `llm-deepseek/` | `llm` | DeepSeek API adapter (hand-rolled fetch/SSE) | (registers on `ctx.llm`) | +| `llm-pi-ai/` | `llm` | DeepSeek adapter via `@earendil-works/pi-ai` (design twin) | (registers on `ctx.llm`) | +| `session-persistence/` | `session-persistence` | Persistence seam + write coordinator | `ctx.sessionPersistence` | +| `session-persistence-jsonl/` | `session-persistence` | JSONL-sidecar persistence backend | (registers `ctx.sessionPersistence`) | +| `session-persistence-sqlite/` | `session-persistence` | SQLite persistence backend | (registers `ctx.sessionPersistence`) | +| `invariants/` | `support` | Dev-mode event-contract invariants + session-log freeze | (listens on `session/*`, `agent/*`) | +| `acp/` | `ui` | Agent Client Protocol bridge: serves the agent to an ACP editor over JSON-RPC stdio | (drives `ctx.agents`/`ctx.sessions`) | +| `ui-stdio/` | `support` | Minimal stdio (readline) UI plugin: renders `agent/*` events, feeds stdin lines to the agent | (drives `ctx.agents`) | +| `llm-replay/` | `support` | Record/replay adapter: short-circuits `llm/stream` with chunks from a recorded session JSONL (keyless snapshot tests) | (listens on `llm/stream`) | Each package has its own `README.md` with purpose, service API, events, extension points, and deliberate non-goals (TODOs). @@ -61,4 +71,4 @@ Each package has its own `README.md` with purpose, service API, events, extensio - **Waterfall semantics**: `ctx.waterfall` listeners receive `(...args, next)` and MUST call `next()` to delegate; returning without it short-circuits (the veto mechanism). - **Extensible unions**: `ContentBlockMap`, `MessageSourceMap`, `FinishReasonMap`, `TurnTriggerMap`, `TurnEndReasonMap`, and `SessionEventMap` use the merge-extensible-map pattern so plugins can add variants via declaration merging. - **ESM everywhere**; imports use package names across package boundaries, `.ts` extensions within a package. -- **Tests**: vitest, colocated under `packages//tests/*.spec.ts`. Every registry needs an HMR-safety test. Err on the side of more tests. +- **Tests**: vitest, colocated under `packages///tests/*.spec.ts`. Every registry needs an HMR-safety test. Err on the side of more tests. diff --git a/packages/acp/tsconfig.json b/packages/acp/tsconfig.json deleted file mode 100644 index 83330256e3..0000000000 --- a/packages/acp/tsconfig.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib" - }, - "include": ["src"], - "references": [ - { "path": "../../vendor/cosmokit" }, - { "path": "../../vendor/cordis" }, - { "path": "../../vendor/schemastery" }, - { "path": "../llm" }, - { "path": "../session" }, - { "path": "../agent" }, - { "path": "../tools" }, - { "path": "../session-persistence" } - ] -} diff --git a/packages/agent-loop/tsconfig.json b/packages/agent-loop/tsconfig.json deleted file mode 100644 index 6751664d5c..0000000000 --- a/packages/agent-loop/tsconfig.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib" - }, - "include": ["src"], - "references": [ - { "path": "../../vendor/cosmokit" }, - { "path": "../../vendor/cordis" }, - { "path": "../../vendor/schemastery" }, - { "path": "../llm" }, - { "path": "../session" }, - { "path": "../session-persistence" }, - { "path": "../system-prompt" }, - { "path": "../tools" }, - { "path": "../agent" } - ] -} diff --git a/packages/agent/tsconfig.json b/packages/agent/tsconfig.json deleted file mode 100644 index 0806132292..0000000000 --- a/packages/agent/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib" - }, - "include": ["src"], - "references": [ - { "path": "../../vendor/cosmokit" }, - { "path": "../../vendor/cordis" }, - { "path": "../llm" }, - { "path": "../session" } - ] -} diff --git a/packages/bash-local/tsconfig.json b/packages/bash-local/tsconfig.json deleted file mode 100644 index a657d8bf8e..0000000000 --- a/packages/bash-local/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib" - }, - "include": ["src"], - "references": [ - { "path": "../../vendor/cosmokit" }, - { "path": "../../vendor/cordis" }, - { "path": "../../vendor/schemastery" }, - { "path": "../bash" } - ] -} diff --git a/packages/bash/README.md b/packages/bash/README.md index ce8816dee7..9a9dba88d5 100644 --- a/packages/bash/README.md +++ b/packages/bash/README.md @@ -1,31 +1,11 @@ -# @deepseek-ai/dsh-bash +# bash/ — bash capability family -The **bash executor seam**: an abstract `BashExecutor` service (`ctx.bash`) defining WHAT a bash backend does — run commands, manage background tasks — without saying HOW. +The canonical three-package capability seam (see [capability seams](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)): an abstract executor interface, a concrete local implementation, and the model-facing tool that consumes it. All **product** packages. -This package is one third of the bash capability, split so each concern can evolve (and be swapped) independently: +| Package | Role | ctx key | +|---|---|---| +| `bash/` | Abstract bash executor seam (interface + vocabulary) | `ctx.bash` | +| `bash-local/` | Local-subprocess `BashExecutor` implementation | (registers `ctx.bash`) | +| `tool-bash/` | Model-facing `bash`/`bash_output`/`bash_kill` tool schemas | (registers on `ctx.tools`) | -| Package | Role | -|---|---| -| `@deepseek-ai/dsh-bash` (this) | the interface: abstract service + vocabulary types | -| `@deepseek-ai/dsh-bash-local` | an implementation: local subprocesses | -| `@deepseek-ai/dsh-tool-bash` | the model-facing tool schemas over `ctx.bash` | - -The split mirrors the LLM seam (`LlmService`/`LlmAdapter`) and the agent-tool survey: pi hides execution behind a `BashOperations` interface (local shell / SSH / VM backends), Codex behind an exec-server protocol. A future sandboxed, containerized, or remote executor implements this interface and the tool schemas don't change. - -## Service API (`ctx.bash`) - -| Member | Semantics | -|---|---| -| `run(spec)` | Foreground execution. Resolves when the command finishes. **Rejects only for infrastructure failures** (unusable workdir, missing shell, pre-aborted signal); nonzero exits, timeout kills, and abort kills resolve with a descriptive `BashRunResult`. | -| `start(spec)` | Background execution. Returns a `BashTask` handle immediately; **no timeout applies** (stop tasks via `kill`). | -| `get(id)` / `list()` | Task lookup. | -| `ownerOf(id)` | The opaque OWNER token recorded for a background task at `start` (from the spec's `owner`), or `undefined` for an unknown id OR a known-but-ownerless task. The executor stores/returns it verbatim and NEVER interprets it — the access POLICY lives in the consumer (`dsh-tool-bash`), which compares `ownerOf(id)` to the caller's token. Storing ownership here (disposed with the executor's fiber) is what makes it survive a consumer HMR reload. | -| `readOutput(id)` | **Incremental** output read — consecutive reads never re-deliver. Reads that lost data to buffer bounds flag `lossy` and point at full-stream spill files. Throws for unknown ids. | -| `kill(id)` | Kill a running task. Returns `false` when it already finished; throws for unknown ids. | -| `onTaskDone(listener)` | Completion listener (effect-based, disposer returned). Fires exactly once per task; never after the service is disposed. | - -Implementations subclass `BashExecutor`, implement the abstract methods, and call `notifyTaskDone(task)` on background completion. Disposal must kill every running task (no orphan processes) — see the HMR-safety tests. - -## Vocabulary - -`BashExecRequest` (command, workdir?, timeoutMs?, signal?, owner?) resolves to `BashExecSpec` (command, workdir, timeoutMs, signal?, owner) before execution; `owner` is optional on the request and **required-but-nullable** (`string | undefined`) on the resolved spec, so a forgotten owner is a visible `undefined` rather than a silently-absent property. `run()` returns `BashRunResult` (exitCode, signal, timedOut, aborted, timeoutMs, stdout/stderr as `CollectedOutput`) and `start()`/`readOutput()` use `BashTask`/`BashTaskRead` for the background side. See `src/types.ts` for the full contracts. +The interface lives at `bash/bash/`. A sandboxed executor would replace `bash-local` without touching the interface or the tool — the split is what makes that possible. diff --git a/packages/bash-local/README.md b/packages/bash/bash-local/README.md similarity index 100% rename from packages/bash-local/README.md rename to packages/bash/bash-local/README.md diff --git a/packages/bash-local/package.json b/packages/bash/bash-local/package.json similarity index 100% rename from packages/bash-local/package.json rename to packages/bash/bash-local/package.json diff --git a/packages/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts similarity index 100% rename from packages/bash-local/src/index.ts rename to packages/bash/bash-local/src/index.ts diff --git a/packages/bash-local/src/run.ts b/packages/bash/bash-local/src/run.ts similarity index 100% rename from packages/bash-local/src/run.ts rename to packages/bash/bash-local/src/run.ts diff --git a/packages/bash-local/tests/executor.spec.ts b/packages/bash/bash-local/tests/executor.spec.ts similarity index 100% rename from packages/bash-local/tests/executor.spec.ts rename to packages/bash/bash-local/tests/executor.spec.ts diff --git a/packages/bash-local/tests/run.spec.ts b/packages/bash/bash-local/tests/run.spec.ts similarity index 100% rename from packages/bash-local/tests/run.spec.ts rename to packages/bash/bash-local/tests/run.spec.ts diff --git a/packages/bash/bash-local/tsconfig.json b/packages/bash/bash-local/tsconfig.json new file mode 100644 index 0000000000..1c27a33a89 --- /dev/null +++ b/packages/bash/bash-local/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../bash/bash" + } + ] +} diff --git a/packages/bash/bash/README.md b/packages/bash/bash/README.md new file mode 100644 index 0000000000..ce8816dee7 --- /dev/null +++ b/packages/bash/bash/README.md @@ -0,0 +1,31 @@ +# @deepseek-ai/dsh-bash + +The **bash executor seam**: an abstract `BashExecutor` service (`ctx.bash`) defining WHAT a bash backend does — run commands, manage background tasks — without saying HOW. + +This package is one third of the bash capability, split so each concern can evolve (and be swapped) independently: + +| Package | Role | +|---|---| +| `@deepseek-ai/dsh-bash` (this) | the interface: abstract service + vocabulary types | +| `@deepseek-ai/dsh-bash-local` | an implementation: local subprocesses | +| `@deepseek-ai/dsh-tool-bash` | the model-facing tool schemas over `ctx.bash` | + +The split mirrors the LLM seam (`LlmService`/`LlmAdapter`) and the agent-tool survey: pi hides execution behind a `BashOperations` interface (local shell / SSH / VM backends), Codex behind an exec-server protocol. A future sandboxed, containerized, or remote executor implements this interface and the tool schemas don't change. + +## Service API (`ctx.bash`) + +| Member | Semantics | +|---|---| +| `run(spec)` | Foreground execution. Resolves when the command finishes. **Rejects only for infrastructure failures** (unusable workdir, missing shell, pre-aborted signal); nonzero exits, timeout kills, and abort kills resolve with a descriptive `BashRunResult`. | +| `start(spec)` | Background execution. Returns a `BashTask` handle immediately; **no timeout applies** (stop tasks via `kill`). | +| `get(id)` / `list()` | Task lookup. | +| `ownerOf(id)` | The opaque OWNER token recorded for a background task at `start` (from the spec's `owner`), or `undefined` for an unknown id OR a known-but-ownerless task. The executor stores/returns it verbatim and NEVER interprets it — the access POLICY lives in the consumer (`dsh-tool-bash`), which compares `ownerOf(id)` to the caller's token. Storing ownership here (disposed with the executor's fiber) is what makes it survive a consumer HMR reload. | +| `readOutput(id)` | **Incremental** output read — consecutive reads never re-deliver. Reads that lost data to buffer bounds flag `lossy` and point at full-stream spill files. Throws for unknown ids. | +| `kill(id)` | Kill a running task. Returns `false` when it already finished; throws for unknown ids. | +| `onTaskDone(listener)` | Completion listener (effect-based, disposer returned). Fires exactly once per task; never after the service is disposed. | + +Implementations subclass `BashExecutor`, implement the abstract methods, and call `notifyTaskDone(task)` on background completion. Disposal must kill every running task (no orphan processes) — see the HMR-safety tests. + +## Vocabulary + +`BashExecRequest` (command, workdir?, timeoutMs?, signal?, owner?) resolves to `BashExecSpec` (command, workdir, timeoutMs, signal?, owner) before execution; `owner` is optional on the request and **required-but-nullable** (`string | undefined`) on the resolved spec, so a forgotten owner is a visible `undefined` rather than a silently-absent property. `run()` returns `BashRunResult` (exitCode, signal, timedOut, aborted, timeoutMs, stdout/stderr as `CollectedOutput`) and `start()`/`readOutput()` use `BashTask`/`BashTaskRead` for the background side. See `src/types.ts` for the full contracts. diff --git a/packages/bash/package.json b/packages/bash/bash/package.json similarity index 100% rename from packages/bash/package.json rename to packages/bash/bash/package.json diff --git a/packages/bash/src/index.ts b/packages/bash/bash/src/index.ts similarity index 100% rename from packages/bash/src/index.ts rename to packages/bash/bash/src/index.ts diff --git a/packages/bash/src/types.ts b/packages/bash/bash/src/types.ts similarity index 100% rename from packages/bash/src/types.ts rename to packages/bash/bash/src/types.ts diff --git a/packages/bash/tests/service.spec.ts b/packages/bash/bash/tests/service.spec.ts similarity index 100% rename from packages/bash/tests/service.spec.ts rename to packages/bash/bash/tests/service.spec.ts diff --git a/packages/bash/bash/tsconfig.json b/packages/bash/bash/tsconfig.json new file mode 100644 index 0000000000..10dabc415e --- /dev/null +++ b/packages/bash/bash/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + } + ] +} diff --git a/packages/tool-bash/README.md b/packages/bash/tool-bash/README.md similarity index 89% rename from packages/tool-bash/README.md rename to packages/bash/tool-bash/README.md index 0a2d3dd4f4..656a22cdb1 100644 --- a/packages/tool-bash/README.md +++ b/packages/bash/tool-bash/README.md @@ -34,7 +34,7 @@ The owning agent's session token (`session.header.id`) is stamped onto the task ## UI presentation -These tools own how their calls render in a UI (an editor's tool-call card) via the `dsh-tools` `presentCall`/`presentResult` seam — a UI never special-cases tool names. For `bash`: the **title** is the exact `command` ("ls -la src") and `kind` is `execute` (terminal/run treatment), matching the reference ACP adapters (claude-agent-acp, codex-acp), which both use the bare command as an execute tool's title. The command is ALSO the **rawInput** for non-terminal UIs that render it (an execute-kind card hides rawInput — Zed shows it only for non-terminal tools — so the command must BE the title to be seen). The model-written `description` rides as a **content** text block shown ABOVE the card. (claude-agent-acp DROPS the description in terminal mode and shows only the card; surfacing it as a content block is a deliberate divergence — we keep the human summary visible alongside the card.) The completed output is wrapped in a fenced ` ```console ` block as the no-terminal-capability fallback — a UI-only affordance, so the model-facing result text stays unfenced. A FOREGROUND `bash` run also flags itself as a **terminal** (the neutral `terminal` field: `presentCall` sets a `cwd` from the model `workdir` when given — absolute as-is, relative for the UI bridge to resolve against the session cwd — else leaves it for the bridge to fill from the session cwd; `presentResult` carries the raw output plus the parsed `exitCode`/`signal`) so a capable client (Zed) renders a terminal card with an exit-status pill instead of the text block — see `packages/acp` ("Terminal card"). A `run_in_background` call is NOT a terminal (it returns a task id immediately and never streams a terminal — poll with `bash_output`), and an `isError` result (spawn failure / abort) carries no exit pill (there is no real process exit); both render as the ordinary execute card / fenced text. `bash_output`/`bash_kill` present a task-scoped title ("Read output from background task bash-3" / "Kill background task bash-3") with the task id as rawInput. These methods are pure/display-only (they also run on `session/load` replay), and a malformed/older logged arg shape falls back to a generic presentation rather than throwing. See `packages/tools` ("Tool-owned UI presentation") and `packages/acp` ("Terminal card" / "Tool-call presentation"). +These tools own how their calls render in a UI (an editor's tool-call card) via the `dsh-tools` `presentCall`/`presentResult` seam — a UI never special-cases tool names. For `bash`: the **title** is the exact `command` ("ls -la src") and `kind` is `execute` (terminal/run treatment), matching the reference ACP adapters (claude-agent-acp, codex-acp), which both use the bare command as an execute tool's title. The command is ALSO the **rawInput** for non-terminal UIs that render it (an execute-kind card hides rawInput — Zed shows it only for non-terminal tools — so the command must BE the title to be seen). The model-written `description` rides as a **content** text block shown ABOVE the card. (claude-agent-acp DROPS the description in terminal mode and shows only the card; surfacing it as a content block is a deliberate divergence — we keep the human summary visible alongside the card.) The completed output is wrapped in a fenced ` ```console ` block as the no-terminal-capability fallback — a UI-only affordance, so the model-facing result text stays unfenced. A FOREGROUND `bash` run also flags itself as a **terminal** (the neutral `terminal` field: `presentCall` sets a `cwd` from the model `workdir` when given — absolute as-is, relative for the UI bridge to resolve against the session cwd — else leaves it for the bridge to fill from the session cwd; `presentResult` carries the raw output plus the parsed `exitCode`/`signal`) so a capable client (Zed) renders a terminal card with an exit-status pill instead of the text block — see `packages/ui/acp` ("Terminal card"). A `run_in_background` call is NOT a terminal (it returns a task id immediately and never streams a terminal — poll with `bash_output`), and an `isError` result (spawn failure / abort) carries no exit pill (there is no real process exit); both render as the ordinary execute card / fenced text. `bash_output`/`bash_kill` present a task-scoped title ("Read output from background task bash-3" / "Kill background task bash-3") with the task id as rawInput. These methods are pure/display-only (they also run on `session/load` replay), and a malformed/older logged arg shape falls back to a generic presentation rather than throwing. See `packages/core/tools` ("Tool-owned UI presentation") and `packages/ui/acp` ("Terminal card" / "Tool-call presentation"). ## Background completion notices diff --git a/packages/tool-bash/package.json b/packages/bash/tool-bash/package.json similarity index 100% rename from packages/tool-bash/package.json rename to packages/bash/tool-bash/package.json diff --git a/packages/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts similarity index 100% rename from packages/tool-bash/src/index.ts rename to packages/bash/tool-bash/src/index.ts diff --git a/packages/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts similarity index 99% rename from packages/tool-bash/tests/integration.spec.ts rename to packages/bash/tool-bash/tests/integration.spec.ts index d31b3a5a7e..0ab786ca85 100644 --- a/packages/tool-bash/tests/integration.spec.ts +++ b/packages/bash/tool-bash/tests/integration.spec.ts @@ -9,7 +9,7 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' -import { MockAdapter, textResponse, toolCallResponse } from '../../agent-loop/tests/mock-adapter.ts' +import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' /** * Full-loop integration: a scripted mock model drives the REAL bash tool diff --git a/packages/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts similarity index 100% rename from packages/tool-bash/tests/tools.spec.ts rename to packages/bash/tool-bash/tests/tools.spec.ts diff --git a/packages/bash/tool-bash/tsconfig.json b/packages/bash/tool-bash/tsconfig.json new file mode 100644 index 0000000000..6cd94d1d9a --- /dev/null +++ b/packages/bash/tool-bash/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../bash/bash" + } + ] +} diff --git a/packages/bash/tsconfig.json b/packages/bash/tsconfig.json deleted file mode 100644 index 2617271c44..0000000000 --- a/packages/bash/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib" - }, - "include": ["src"], - "references": [ - { "path": "../../vendor/cosmokit" }, - { "path": "../../vendor/cordis" } - ] -} diff --git a/packages/core/README.md b/packages/core/README.md new file mode 100644 index 0000000000..9c5411fd5f --- /dev/null +++ b/packages/core/README.md @@ -0,0 +1,13 @@ +# core/ — product API spine + +The packages every harness build is assembled from: the session log, the system-prompt assembly, the tool registry, the agent vocabulary, and the one concrete loop that drives them. These are **product** packages — the stable surface plugins and consumers build against. + +| Package | Role | ctx key | +|---|---|---| +| `session/` | Event-sourced session log + in-memory store | `ctx.sessions` | +| `system-prompt/` | Prompt-section + tool-schema assembly registry | `ctx.systemPrompt` | +| `tools/` | Tool registry + `tools/execute` waterfall | `ctx.tools` | +| `agent/` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` | +| `agent-loop/` | The concrete loop plugin: `ReactLoopAgent` + the loop driver | `ctx.agentLoop` | + +`agent-loop` is the one concrete implementation of the `agent` seam and lives here because it is the harness's default product loop; everything else in `core/` is interface/vocabulary. Plugins depend on the `agent` vocabulary, never on `agent-loop` directly, so the loop stays swappable. diff --git a/packages/agent-loop/README.md b/packages/core/agent-loop/README.md similarity index 91% rename from packages/agent-loop/README.md rename to packages/core/agent-loop/README.md index c51c9132b6..c7ea092c72 100644 --- a/packages/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -13,7 +13,7 @@ This is the only package in the harness that contains concrete loop logic. Every `AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface): - `ctx.agents.create({ agentId, sessionId, meta?, agentOptions? }): AgentHandle` — programmatic create on a caller-supplied `sessionId` (e.g. an ACP-generated id), NOT `${id}-session`. Returns an [`AgentHandle`](../agent/README.md) — the owner disposes it to tear down exactly this agent (stop loop + await quiescence + unregister + remove session). -- `ctx.agents.resume({ agentId, resumeSessionId, agentOptions? }): Promise` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)) and resume an agent on it. The live session id is the resumed id; turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). Returns an `AgentHandle`. +- `ctx.agents.resume({ agentId, resumeSessionId, agentOptions? }): Promise` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)) and resume an agent on it. The live session id is the resumed id; turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). Returns an `AgentHandle`. The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle) — only the programmatic factory callers (the ACP bridge) hold a handle and own per-agent teardown. diff --git a/packages/agent-loop/package.json b/packages/core/agent-loop/package.json similarity index 100% rename from packages/agent-loop/package.json rename to packages/core/agent-loop/package.json diff --git a/packages/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts similarity index 100% rename from packages/agent-loop/src/agent.ts rename to packages/core/agent-loop/src/agent.ts diff --git a/packages/agent-loop/src/inbox.ts b/packages/core/agent-loop/src/inbox.ts similarity index 100% rename from packages/agent-loop/src/inbox.ts rename to packages/core/agent-loop/src/inbox.ts diff --git a/packages/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts similarity index 100% rename from packages/agent-loop/src/index.ts rename to packages/core/agent-loop/src/index.ts diff --git a/packages/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts similarity index 100% rename from packages/agent-loop/src/loop.ts rename to packages/core/agent-loop/src/loop.ts diff --git a/packages/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts similarity index 100% rename from packages/agent-loop/tests/agent.spec.ts rename to packages/core/agent-loop/tests/agent.spec.ts diff --git a/packages/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts similarity index 100% rename from packages/agent-loop/tests/cancel.spec.ts rename to packages/core/agent-loop/tests/cancel.spec.ts diff --git a/packages/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts similarity index 100% rename from packages/agent-loop/tests/config-session-id.spec.ts rename to packages/core/agent-loop/tests/config-session-id.spec.ts diff --git a/packages/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts similarity index 100% rename from packages/agent-loop/tests/coverage-edges.spec.ts rename to packages/core/agent-loop/tests/coverage-edges.spec.ts diff --git a/packages/agent-loop/tests/inbox.spec.ts b/packages/core/agent-loop/tests/inbox.spec.ts similarity index 100% rename from packages/agent-loop/tests/inbox.spec.ts rename to packages/core/agent-loop/tests/inbox.spec.ts diff --git a/packages/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts similarity index 100% rename from packages/agent-loop/tests/loop.spec.ts rename to packages/core/agent-loop/tests/loop.spec.ts diff --git a/packages/agent-loop/tests/mock-adapter.ts b/packages/core/agent-loop/tests/mock-adapter.ts similarity index 100% rename from packages/agent-loop/tests/mock-adapter.ts rename to packages/core/agent-loop/tests/mock-adapter.ts diff --git a/packages/agent-loop/tests/properties.spec.ts b/packages/core/agent-loop/tests/properties.spec.ts similarity index 100% rename from packages/agent-loop/tests/properties.spec.ts rename to packages/core/agent-loop/tests/properties.spec.ts diff --git a/packages/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts similarity index 100% rename from packages/agent-loop/tests/resume.spec.ts rename to packages/core/agent-loop/tests/resume.spec.ts diff --git a/packages/agent-loop/tests/review-fixes.spec.ts b/packages/core/agent-loop/tests/review-fixes.spec.ts similarity index 100% rename from packages/agent-loop/tests/review-fixes.spec.ts rename to packages/core/agent-loop/tests/review-fixes.spec.ts diff --git a/packages/core/agent-loop/tsconfig.json b/packages/core/agent-loop/tsconfig.json new file mode 100644 index 0000000000..e8a471a08c --- /dev/null +++ b/packages/core/agent-loop/tsconfig.json @@ -0,0 +1,39 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../../session-persistence/session-persistence" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../core/agent" + } + ] +} diff --git a/packages/agent/README.md b/packages/core/agent/README.md similarity index 93% rename from packages/agent/README.md rename to packages/core/agent/README.md index 39fba37fd0..df4163670d 100644 --- a/packages/agent/README.md +++ b/packages/core/agent/README.md @@ -18,7 +18,7 @@ Agent *creation* is provided by whichever plugin implements `AgentFactory` (phas - `ctx.agents.setFactory(factory: AgentFactory): () => void` — register the creation factory (the loop calls this on construction). Throws on a second factory; the slot clears on dispose. - `ctx.agents.create(options: CreateAgentOptions): AgentHandle` — construct, start, AND register a new agent on a caller-supplied `sessionId` (with optional `meta.cwd`). Distinct from `register` (which only records). Throws if no factory is registered. -- `ctx.agents.resume(options: ResumeAgentOptions): Promise` — load a persisted session ([session persistence](../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)) and resume an agent on it. Async; rejects if no factory is registered, or if the factory finds session persistence unconfigured. +- `ctx.agents.resume(options: ResumeAgentOptions): Promise` — load a persisted session ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)) and resume an agent on it. Async; rejects if no factory is registered, or if the factory finds session persistence unconfigured. `AgentHandle = { agent: Agent; dispose(): Promise }`. The disposer is a **capability** — only the holder can tear this agent down. `dispose()` stops the loop, `await`s its exit (quiescence — NOT just the `disposed` status flip), unregisters the agent, and removes its session from the store, in an order that captures the loop's final `session/flush` before the session is detached. `ctx.agents.get(id)` still returns a bare `Agent` — the handle is only for the OWNER that created it. The ACP bridge is the production consumer (one handle per session, disposed on disconnect/teardown); config-created agents are owned by the loop fiber and never need a handle. @@ -55,7 +55,7 @@ The handle every plugin programs against: - `agent.send(content, options?)` — queue a message; starts a turn when idle - `agent.steer(content, options?)` — steer a running turn (inject between steps); behaves like `send` when idle -- `agent.inject(content, options?)` — inject in-session context (context/message event); the next request sees it. Does not run the model. While a turn is open it joins that turn; while idle it is wrapped in a one-shot `injection` turn so every event stays turn-enclosed ([the turn-enclosure invariant](../../docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)) +- `agent.inject(content, options?)` — inject in-session context (context/message event); the next request sees it. Does not run the model. While a turn is open it joins that turn; while idle it is wrapped in a one-shot `injection` turn so every event stays turn-enclosed ([the turn-enclosure invariant](../../../docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)) - `agent.abort(reason?)` — abort the in-flight step (the narrow, step-only verb) - `agent.cancel(reason?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP `session/cancel` maps to this. Idle with nothing pending → a safe no-op. - `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit), the signal a teardown awaits (`abort()` then `await whenIdle()`). Observes the transition without disposing the agent. diff --git a/packages/agent/package.json b/packages/core/agent/package.json similarity index 100% rename from packages/agent/package.json rename to packages/core/agent/package.json diff --git a/packages/agent/src/index.ts b/packages/core/agent/src/index.ts similarity index 100% rename from packages/agent/src/index.ts rename to packages/core/agent/src/index.ts diff --git a/packages/agent/src/types.ts b/packages/core/agent/src/types.ts similarity index 100% rename from packages/agent/src/types.ts rename to packages/core/agent/src/types.ts diff --git a/packages/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts similarity index 100% rename from packages/agent/tests/agent.spec.ts rename to packages/core/agent/tests/agent.spec.ts diff --git a/packages/agent/tests/gen-cordis-catalog.spec.ts b/packages/core/agent/tests/gen-cordis-catalog.spec.ts similarity index 96% rename from packages/agent/tests/gen-cordis-catalog.spec.ts rename to packages/core/agent/tests/gen-cordis-catalog.spec.ts index 66edb9983f..ee2ce47699 100644 --- a/packages/agent/tests/gen-cordis-catalog.spec.ts +++ b/packages/core/agent/tests/gen-cordis-catalog.spec.ts @@ -14,13 +14,13 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { collectEvents } from '../../../scripts/gen-cordis-catalog.ts' +import { collectEvents } from '../../../../scripts/gen-cordis-catalog.ts' /** Write a fixture package exposing one `interface Events` block and return the * scan root to hand `collectEvents`. */ function fixtureRoot(eventsBlock: string): string { const root = mkdtempSync(join(tmpdir(), 'cordis-catalog-')) - const dir = join(root, 'packages', 'fix', 'src') + const dir = join(root, 'packages', 'group', 'fix', 'src') mkdirSync(dir, { recursive: true }) writeFileSync( join(dir, 'index.ts'), diff --git a/packages/core/agent/tsconfig.json b/packages/core/agent/tsconfig.json new file mode 100644 index 0000000000..e7d274f2cd --- /dev/null +++ b/packages/core/agent/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + } + ] +} diff --git a/packages/session/README.md b/packages/core/session/README.md similarity index 100% rename from packages/session/README.md rename to packages/core/session/README.md diff --git a/packages/session/package.json b/packages/core/session/package.json similarity index 100% rename from packages/session/package.json rename to packages/core/session/package.json diff --git a/packages/session/src/index.ts b/packages/core/session/src/index.ts similarity index 100% rename from packages/session/src/index.ts rename to packages/core/session/src/index.ts diff --git a/packages/session/src/json.ts b/packages/core/session/src/json.ts similarity index 100% rename from packages/session/src/json.ts rename to packages/core/session/src/json.ts diff --git a/packages/session/src/repair.ts b/packages/core/session/src/repair.ts similarity index 100% rename from packages/session/src/repair.ts rename to packages/core/session/src/repair.ts diff --git a/packages/session/src/types.ts b/packages/core/session/src/types.ts similarity index 100% rename from packages/session/src/types.ts rename to packages/core/session/src/types.ts diff --git a/packages/session/tests/properties.spec.ts b/packages/core/session/tests/properties.spec.ts similarity index 100% rename from packages/session/tests/properties.spec.ts rename to packages/core/session/tests/properties.spec.ts diff --git a/packages/session/tests/repair.spec.ts b/packages/core/session/tests/repair.spec.ts similarity index 100% rename from packages/session/tests/repair.spec.ts rename to packages/core/session/tests/repair.spec.ts diff --git a/packages/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts similarity index 100% rename from packages/session/tests/session.spec.ts rename to packages/core/session/tests/session.spec.ts diff --git a/packages/core/session/tsconfig.json b/packages/core/session/tsconfig.json new file mode 100644 index 0000000000..3423a0e06c --- /dev/null +++ b/packages/core/session/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../llm/llm" + } + ] +} diff --git a/packages/system-prompt/README.md b/packages/core/system-prompt/README.md similarity index 100% rename from packages/system-prompt/README.md rename to packages/core/system-prompt/README.md diff --git a/packages/system-prompt/package.json b/packages/core/system-prompt/package.json similarity index 100% rename from packages/system-prompt/package.json rename to packages/core/system-prompt/package.json diff --git a/packages/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts similarity index 100% rename from packages/system-prompt/src/index.ts rename to packages/core/system-prompt/src/index.ts diff --git a/packages/system-prompt/tests/system-prompt.spec.ts b/packages/core/system-prompt/tests/system-prompt.spec.ts similarity index 100% rename from packages/system-prompt/tests/system-prompt.spec.ts rename to packages/core/system-prompt/tests/system-prompt.spec.ts diff --git a/packages/core/system-prompt/tsconfig.json b/packages/core/system-prompt/tsconfig.json new file mode 100644 index 0000000000..3423a0e06c --- /dev/null +++ b/packages/core/system-prompt/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../llm/llm" + } + ] +} diff --git a/packages/tools/README.md b/packages/core/tools/README.md similarity index 100% rename from packages/tools/README.md rename to packages/core/tools/README.md diff --git a/packages/tools/package.json b/packages/core/tools/package.json similarity index 100% rename from packages/tools/package.json rename to packages/core/tools/package.json diff --git a/packages/tools/src/index.ts b/packages/core/tools/src/index.ts similarity index 100% rename from packages/tools/src/index.ts rename to packages/core/tools/src/index.ts diff --git a/packages/tools/src/schema.ts b/packages/core/tools/src/schema.ts similarity index 100% rename from packages/tools/src/schema.ts rename to packages/core/tools/src/schema.ts diff --git a/packages/tools/tests/properties.spec.ts b/packages/core/tools/tests/properties.spec.ts similarity index 100% rename from packages/tools/tests/properties.spec.ts rename to packages/core/tools/tests/properties.spec.ts diff --git a/packages/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts similarity index 100% rename from packages/tools/tests/tools.spec.ts rename to packages/core/tools/tests/tools.spec.ts diff --git a/packages/core/tools/tsconfig.json b/packages/core/tools/tsconfig.json new file mode 100644 index 0000000000..27219e926d --- /dev/null +++ b/packages/core/tools/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../../core/agent" + } + ] +} diff --git a/packages/invariants/tsconfig.json b/packages/invariants/tsconfig.json deleted file mode 100644 index 54fbb4adac..0000000000 --- a/packages/invariants/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib" - }, - "include": ["src"], - "references": [ - { "path": "../../vendor/cosmokit" }, - { "path": "../../vendor/cordis" }, - { "path": "../llm" }, - { "path": "../session" }, - { "path": "../agent" } - ] -} diff --git a/packages/llm-deepseek/tsconfig.json b/packages/llm-deepseek/tsconfig.json deleted file mode 100644 index eea89a4aac..0000000000 --- a/packages/llm-deepseek/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib" - }, - "include": ["src"], - "references": [ - { "path": "../../vendor/cosmokit" }, - { "path": "../../vendor/cordis" }, - { "path": "../../vendor/schemastery" }, - { "path": "../llm" } - ] -} diff --git a/packages/llm-pi-ai/tsconfig.json b/packages/llm-pi-ai/tsconfig.json deleted file mode 100644 index eea89a4aac..0000000000 --- a/packages/llm-pi-ai/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib" - }, - "include": ["src"], - "references": [ - { "path": "../../vendor/cosmokit" }, - { "path": "../../vendor/cordis" }, - { "path": "../../vendor/schemastery" }, - { "path": "../llm" } - ] -} diff --git a/packages/llm-replay/tsconfig.json b/packages/llm-replay/tsconfig.json deleted file mode 100644 index 0806132292..0000000000 --- a/packages/llm-replay/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib" - }, - "include": ["src"], - "references": [ - { "path": "../../vendor/cosmokit" }, - { "path": "../../vendor/cordis" }, - { "path": "../llm" }, - { "path": "../session" } - ] -} diff --git a/packages/llm/README.md b/packages/llm/README.md index 02758d404c..3fc5c9cf9e 100644 --- a/packages/llm/README.md +++ b/packages/llm/README.md @@ -1,46 +1,11 @@ -# dsh-llm +# llm/ — LLM capability family -Provider-neutral LLM vocabulary and abstract service. This package defines the canonical language spoken by the agent loop, session logs, and every plugin. +The LLM seam and its provider adapters. The interface package (`llm`) owns the abstract service, the content-block vocabulary, and the stream-chunk assembler; the adapters are concrete implementations that register on `ctx.llm`. All **product** packages. -## Service: `LlmService` (ctx key: `llm`) - -An adapter registry plus streaming / non-streaming call surfaces. Both call surfaces are interceptable via waterfall events. - -### Public API - -- `ctx.llm.registerAdapter(models: string[], adapter: LlmAdapter): () => void` Register an adapter for the given model names. Disposed with the calling fiber. -- `ctx.llm.models(): string[]` — model names with a registered adapter. -- `ctx.llm.stream(options: GenerateOptions): AsyncIterable` Stream one model call as raw chunks (token-level deltas). -- `ctx.llm.streamBlocks(options: GenerateOptions): AsyncIterable` Stream as completed content blocks (convenience view). -- `ctx.llm.generate(options: GenerateOptions): Promise` One model call, fully assembled. - -### Events - -| Event | Mode | Purpose | +| Package | Role | ctx key | |---|---|---| -| `llm/stream` | waterfall | Intercept/wrap every streaming model call (retry, caching, routing) | -| `llm/generate` | waterfall | Intercept/wrap every non-streaming model call | -| `llm/adapter-change` | emit | An adapter was registered or unregistered | +| `llm/` | Abstract LLM service + content-block vocabulary + chunk assembler | `ctx.llm` | +| `llm-deepseek/` | DeepSeek API adapter (hand-rolled fetch/SSE) | (registers on `ctx.llm`) | +| `llm-pi-ai/` | DeepSeek adapter via `@earendil-works/pi-ai` (design twin) | (registers on `ctx.llm`) | -### Extension points - -- Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(models, adapter)` to add a new model provider. -- Wrap `llm/stream` or `llm/generate` via `ctx.on()` waterfall listeners for caching, retry, logging, rate-limiting, etc. - -### Content-block vocabulary (`types.ts`) - -Messages are arrays of typed content blocks: `text`, `reasoning`, `tool-call`, `tool-result`, `image`. The union is derived from the merge-extensible `ContentBlockMap`, so plugins can add block types via declaration merging. - -Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`). `BlockAssembler` is the single shared implementation that assembles chunks into blocks/messages. - -### Classes - -- `LlmAdapter` — abstract base class for provider adapters. The only required method is `stream()`. -- `BlockAssembler` — incrementally assembles raw chunks into complete content blocks and an assistant message. Used by the agent loop (raw chunks for replay - + assembled for history) and by `streamBlocks()`/`generate()`. -- `HarnessError` — base class for the harness error taxonomy: a stable `code` string (distinct from the human `message`) plus `cause` chaining. Lives here, in the leaf package every other imports, so a single base is shared without a new dependency edge. Per-package errors (`LlmError`, `ToolArgsError`, `InvariantError`, …) extend it. `isHarnessError(value)` narrows at seams. -- `LlmError` — extends `HarnessError`; `code` string (`NO_ADAPTER`, `DUPLICATE_ADAPTER`, and adapter codes like `AUTH`/`RATE_LIMIT`) plus an optional numeric `status` when the failure came from a non-2xx provider response. - -### Real adapters - -Two adapters implement `LlmAdapter` against this vocabulary, deliberately built on different internals to keep the contract honest (see [the twin LLM adapters](../../docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md)): [`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) (hand-rolled fetch/SSE) and [`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) (via `@earendil-works/pi-ai`). The pair pinned down the `StreamChunk` conventions now documented in `types.ts` (usage before finish, raw-string tool arguments, the two sanctioned error paths). +The interface lives at `llm/llm/`; adapters are flat siblings under the group. A new provider adapter joins here and registers on `ctx.llm` without touching the interface. See [twin LLM adapters](../../docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md) for why two adapters exist. diff --git a/packages/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md similarity index 100% rename from packages/llm-deepseek/README.md rename to packages/llm/llm-deepseek/README.md diff --git a/packages/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json similarity index 100% rename from packages/llm-deepseek/package.json rename to packages/llm/llm-deepseek/package.json diff --git a/packages/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts similarity index 100% rename from packages/llm-deepseek/src/adapter.ts rename to packages/llm/llm-deepseek/src/adapter.ts diff --git a/packages/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts similarity index 100% rename from packages/llm-deepseek/src/index.ts rename to packages/llm/llm-deepseek/src/index.ts diff --git a/packages/llm-deepseek/src/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts similarity index 100% rename from packages/llm-deepseek/src/serialize.ts rename to packages/llm/llm-deepseek/src/serialize.ts diff --git a/packages/llm-deepseek/src/sse.ts b/packages/llm/llm-deepseek/src/sse.ts similarity index 100% rename from packages/llm-deepseek/src/sse.ts rename to packages/llm/llm-deepseek/src/sse.ts diff --git a/packages/llm-deepseek/src/translate.ts b/packages/llm/llm-deepseek/src/translate.ts similarity index 100% rename from packages/llm-deepseek/src/translate.ts rename to packages/llm/llm-deepseek/src/translate.ts diff --git a/packages/llm-deepseek/src/types.ts b/packages/llm/llm-deepseek/src/types.ts similarity index 100% rename from packages/llm-deepseek/src/types.ts rename to packages/llm/llm-deepseek/src/types.ts diff --git a/packages/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts similarity index 100% rename from packages/llm-deepseek/tests/adapter.e2e.ts rename to packages/llm/llm-deepseek/tests/adapter.e2e.ts diff --git a/packages/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts similarity index 100% rename from packages/llm-deepseek/tests/adapter.spec.ts rename to packages/llm/llm-deepseek/tests/adapter.spec.ts diff --git a/packages/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts similarity index 100% rename from packages/llm-deepseek/tests/serialize.spec.ts rename to packages/llm/llm-deepseek/tests/serialize.spec.ts diff --git a/packages/llm-deepseek/tests/sse.spec.ts b/packages/llm/llm-deepseek/tests/sse.spec.ts similarity index 100% rename from packages/llm-deepseek/tests/sse.spec.ts rename to packages/llm/llm-deepseek/tests/sse.spec.ts diff --git a/packages/llm-deepseek/tests/translate.spec.ts b/packages/llm/llm-deepseek/tests/translate.spec.ts similarity index 100% rename from packages/llm-deepseek/tests/translate.spec.ts rename to packages/llm/llm-deepseek/tests/translate.spec.ts diff --git a/packages/llm/llm-deepseek/tsconfig.json b/packages/llm/llm-deepseek/tsconfig.json new file mode 100644 index 0000000000..b187cddf35 --- /dev/null +++ b/packages/llm/llm-deepseek/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + } + ] +} diff --git a/packages/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md similarity index 100% rename from packages/llm-pi-ai/README.md rename to packages/llm/llm-pi-ai/README.md diff --git a/packages/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json similarity index 100% rename from packages/llm-pi-ai/package.json rename to packages/llm/llm-pi-ai/package.json diff --git a/packages/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts similarity index 100% rename from packages/llm-pi-ai/src/adapter.ts rename to packages/llm/llm-pi-ai/src/adapter.ts diff --git a/packages/llm-pi-ai/src/convert.ts b/packages/llm/llm-pi-ai/src/convert.ts similarity index 100% rename from packages/llm-pi-ai/src/convert.ts rename to packages/llm/llm-pi-ai/src/convert.ts diff --git a/packages/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts similarity index 100% rename from packages/llm-pi-ai/src/index.ts rename to packages/llm/llm-pi-ai/src/index.ts diff --git a/packages/llm-pi-ai/tests/adapter.e2e.ts b/packages/llm/llm-pi-ai/tests/adapter.e2e.ts similarity index 100% rename from packages/llm-pi-ai/tests/adapter.e2e.ts rename to packages/llm/llm-pi-ai/tests/adapter.e2e.ts diff --git a/packages/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts similarity index 100% rename from packages/llm-pi-ai/tests/adapter.spec.ts rename to packages/llm/llm-pi-ai/tests/adapter.spec.ts diff --git a/packages/llm-pi-ai/tests/convert.spec.ts b/packages/llm/llm-pi-ai/tests/convert.spec.ts similarity index 100% rename from packages/llm-pi-ai/tests/convert.spec.ts rename to packages/llm/llm-pi-ai/tests/convert.spec.ts diff --git a/packages/llm/llm-pi-ai/tsconfig.json b/packages/llm/llm-pi-ai/tsconfig.json new file mode 100644 index 0000000000..b187cddf35 --- /dev/null +++ b/packages/llm/llm-pi-ai/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + } + ] +} diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md new file mode 100644 index 0000000000..4326c5a1f8 --- /dev/null +++ b/packages/llm/llm/README.md @@ -0,0 +1,46 @@ +# dsh-llm + +Provider-neutral LLM vocabulary and abstract service. This package defines the canonical language spoken by the agent loop, session logs, and every plugin. + +## Service: `LlmService` (ctx key: `llm`) + +An adapter registry plus streaming / non-streaming call surfaces. Both call surfaces are interceptable via waterfall events. + +### Public API + +- `ctx.llm.registerAdapter(models: string[], adapter: LlmAdapter): () => void` Register an adapter for the given model names. Disposed with the calling fiber. +- `ctx.llm.models(): string[]` — model names with a registered adapter. +- `ctx.llm.stream(options: GenerateOptions): AsyncIterable` Stream one model call as raw chunks (token-level deltas). +- `ctx.llm.streamBlocks(options: GenerateOptions): AsyncIterable` Stream as completed content blocks (convenience view). +- `ctx.llm.generate(options: GenerateOptions): Promise` One model call, fully assembled. + +### Events + +| Event | Mode | Purpose | +|---|---|---| +| `llm/stream` | waterfall | Intercept/wrap every streaming model call (retry, caching, routing) | +| `llm/generate` | waterfall | Intercept/wrap every non-streaming model call | +| `llm/adapter-change` | emit | An adapter was registered or unregistered | + +### Extension points + +- Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(models, adapter)` to add a new model provider. +- Wrap `llm/stream` or `llm/generate` via `ctx.on()` waterfall listeners for caching, retry, logging, rate-limiting, etc. + +### Content-block vocabulary (`types.ts`) + +Messages are arrays of typed content blocks: `text`, `reasoning`, `tool-call`, `tool-result`, `image`. The union is derived from the merge-extensible `ContentBlockMap`, so plugins can add block types via declaration merging. + +Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`). `BlockAssembler` is the single shared implementation that assembles chunks into blocks/messages. + +### Classes + +- `LlmAdapter` — abstract base class for provider adapters. The only required method is `stream()`. +- `BlockAssembler` — incrementally assembles raw chunks into complete content blocks and an assistant message. Used by the agent loop (raw chunks for replay + + assembled for history) and by `streamBlocks()`/`generate()`. +- `HarnessError` — base class for the harness error taxonomy: a stable `code` string (distinct from the human `message`) plus `cause` chaining. Lives here, in the leaf package every other imports, so a single base is shared without a new dependency edge. Per-package errors (`LlmError`, `ToolArgsError`, `InvariantError`, …) extend it. `isHarnessError(value)` narrows at seams. +- `LlmError` — extends `HarnessError`; `code` string (`NO_ADAPTER`, `DUPLICATE_ADAPTER`, and adapter codes like `AUTH`/`RATE_LIMIT`) plus an optional numeric `status` when the failure came from a non-2xx provider response. + +### Real adapters + +Two adapters implement `LlmAdapter` against this vocabulary, deliberately built on different internals to keep the contract honest (see [the twin LLM adapters](../../../docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md)): [`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) (hand-rolled fetch/SSE) and [`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) (via `@earendil-works/pi-ai`). The pair pinned down the `StreamChunk` conventions now documented in `types.ts` (usage before finish, raw-string tool arguments, the two sanctioned error paths). diff --git a/packages/llm/package.json b/packages/llm/llm/package.json similarity index 100% rename from packages/llm/package.json rename to packages/llm/llm/package.json diff --git a/packages/llm/src/assembler.ts b/packages/llm/llm/src/assembler.ts similarity index 100% rename from packages/llm/src/assembler.ts rename to packages/llm/llm/src/assembler.ts diff --git a/packages/llm/src/brand.ts b/packages/llm/llm/src/brand.ts similarity index 100% rename from packages/llm/src/brand.ts rename to packages/llm/llm/src/brand.ts diff --git a/packages/llm/src/error.ts b/packages/llm/llm/src/error.ts similarity index 100% rename from packages/llm/src/error.ts rename to packages/llm/llm/src/error.ts diff --git a/packages/llm/src/index.ts b/packages/llm/llm/src/index.ts similarity index 100% rename from packages/llm/src/index.ts rename to packages/llm/llm/src/index.ts diff --git a/packages/llm/src/never.ts b/packages/llm/llm/src/never.ts similarity index 100% rename from packages/llm/src/never.ts rename to packages/llm/llm/src/never.ts diff --git a/packages/llm/src/types.ts b/packages/llm/llm/src/types.ts similarity index 100% rename from packages/llm/src/types.ts rename to packages/llm/llm/src/types.ts diff --git a/packages/llm/tests/assembler.spec.ts b/packages/llm/llm/tests/assembler.spec.ts similarity index 100% rename from packages/llm/tests/assembler.spec.ts rename to packages/llm/llm/tests/assembler.spec.ts diff --git a/packages/llm/tests/properties.spec.ts b/packages/llm/llm/tests/properties.spec.ts similarity index 100% rename from packages/llm/tests/properties.spec.ts rename to packages/llm/llm/tests/properties.spec.ts diff --git a/packages/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts similarity index 100% rename from packages/llm/tests/service.spec.ts rename to packages/llm/llm/tests/service.spec.ts diff --git a/packages/llm/llm/tsconfig.json b/packages/llm/llm/tsconfig.json new file mode 100644 index 0000000000..10dabc415e --- /dev/null +++ b/packages/llm/llm/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + } + ] +} diff --git a/packages/llm/tsconfig.json b/packages/llm/tsconfig.json deleted file mode 100644 index 2617271c44..0000000000 --- a/packages/llm/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib" - }, - "include": ["src"], - "references": [ - { "path": "../../vendor/cosmokit" }, - { "path": "../../vendor/cordis" } - ] -} diff --git a/packages/session-persistence-jsonl/tsconfig.json b/packages/session-persistence-jsonl/tsconfig.json deleted file mode 100644 index 3595f989bd..0000000000 --- a/packages/session-persistence-jsonl/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib" - }, - "include": ["src"], - "references": [ - { "path": "../../vendor/cosmokit" }, - { "path": "../../vendor/cordis" }, - { "path": "../../vendor/schemastery" }, - { "path": "../session" }, - { "path": "../session-persistence" } - ] -} diff --git a/packages/session-persistence-sqlite/tsconfig.json b/packages/session-persistence-sqlite/tsconfig.json deleted file mode 100644 index 3595f989bd..0000000000 --- a/packages/session-persistence-sqlite/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib" - }, - "include": ["src"], - "references": [ - { "path": "../../vendor/cosmokit" }, - { "path": "../../vendor/cordis" }, - { "path": "../../vendor/schemastery" }, - { "path": "../session" }, - { "path": "../session-persistence" } - ] -} diff --git a/packages/session-persistence/README.md b/packages/session-persistence/README.md index 42fb137287..603e525ee0 100644 --- a/packages/session-persistence/README.md +++ b/packages/session-persistence/README.md @@ -1,52 +1,11 @@ -# @deepseek-ai/dsh-session-persistence +# session-persistence/ — persistence capability family -The abstract durable session-persistence seam (`ctx.sessionPersistence`). Defines WHAT a persistence backend does — durably store, reload, and list sessions — without saying HOW. Mirrors the `dsh-bash` capability-seam template ([capability seams](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)): an abstract service here, a concrete implementation in a sibling package, consumers that inject the interface. +The durable session-persistence seam and its storage backends. The interface package owns the abstract `SessionPersistence` service and the shared write coordinator; the backends are concrete implementations that register on `ctx.sessionPersistence`. All **product** packages. -The persisted unit IS the existing `SessionEvent` (event-sourced model — the log is the single source of truth), so there is no parallel "persisted message" type. Metadata that is NOT replayable conversation state (format version, cwd, lineage) travels separately as `SessionHeader`, owned by `dsh-session` and re-exported here. +| Package | Role | ctx key | +|---|---|---| +| `session-persistence/` | Persistence seam + shared write coordinator | `ctx.sessionPersistence` | +| `session-persistence-jsonl/` | JSONL-sidecar persistence backend | (registers `ctx.sessionPersistence`) | +| `session-persistence-sqlite/` | SQLite persistence backend | (registers `ctx.sessionPersistence`) | -## Service API (`ctx.sessionPersistence`) - -| Method | Contract | -|---|---| -| `create(meta): Promise` | Register a new session's metadata. MAY defer the physical write until the first `append` (lazy materialization). | -| `append(id, events): Promise` | Durably persist a batch (from the `session/flush` drain). Append-only; first event `seq` == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. | -| `load(id): Promise<{ meta; events }>` | Reload meta + log. Preserves an interrupted (unclosed) final turn and closes it with synthetic closers — an error `tool/result` per unanswered `tool-call`, then `step/end?`+`turn/end {interrupted}` (a turn can be huge — never truncated); only a torn tail fragment is dropped. Events contiguous (`events[i].seq === i`); rejects a committed-region gap/parse error or unknown `version`. | -| `list(): Promise` | Lightweight listing from metadata, no full-log parse. | -| `has(id)` / `delete(id)` | Existence / removal. A zero-event lazily-materialized session is absent from `has`/`list`. | - -## Invariants every backend must honor - -- **Append-only; a crashed turn is closed, not truncated.** Committed events (at or below a flushed `turn/end`) are never rewritten. A crash can leave an unclosed final turn whose events are real and possibly large; `load` preserves them and durably appends synthetic closers (an error `tool/result` per unanswered `tool-call`, then `step/end?`+`turn/end {interrupted}`) to balance the log and keep the rehydrated history a valid provider transcript. Only a never-fully-written torn tail fragment is discarded. -- **Contiguous seq.** `load` rejects a `seq` gap/parse error in the MIDDLE of the log; `append`'s first `seq` must equal the stored next-seq. -- **JSON-serializable data.** `append` rejects non-serializable `event.data`; backends snapshot each event when buffering (the live `session.events` object is mutable). -- **Durability.** `append` returns only once the batch is durable. - -## The write coordinator - -The two first-party backends were byte-identical (or same-algorithm) for ALL of their write-path orchestration — the in-memory bookkeeping (per-id state, write-behind buffers, per-id serialization chains, per-session init promises), the `session/event` → buffer → `session/flush` drain, lazy materialization, crash-tail repair on load, the four `session/created` adoption cases (new / HMR-adopt / collision / ownerless-claim), and dispose-time quiescence. Only the STORAGE primitives differed (write bytes vs. INSERT rows). - -`PersistenceCoordinator` owns that orchestration once. A first-party backend composes one (`new PersistenceCoordinator(ctx, this)`), implements the small `PersistenceBackend` hook interface, and delegates its six public service methods to the coordinator. This keeps the duplicated, correctness-heavy orchestration in a single place (it used to receive the same fixes twice). - -The `PersistenceBackend` hooks (the only seam between the coordinator and storage): - -| Hook | Role | -|---|---| -| `name` | Backend label for the dispose-failure `AggregateError`. | -| `loadStored(id)` | Read a stored prefix by id, scanning ANY storage scope. Used by resume/load and, via `!== undefined`, the create-collision probe. Returns an opaque `tornMarker` iff a torn tail must be truncated. | -| `loadLive(id, cwd)` | Read a stored prefix SCOPED to `cwd` (HMR live-adoption must only adopt a log at the SAME cwd; a same-id log elsewhere is a collision, not a resume). A globally-unique-id backend ignores `cwd`. | -| `appendBatch(meta, events, isMaterialized)` | Durably append a contiguous batch, lazily materializing ATOMICALLY when not yet materialized. | -| `commitRepair(meta, tornMarker, closers)` | Make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined` — a marker may be falsy, e.g. seq/offset `0`) and append `closers`. NOT required to be atomic. Used by load (truncate + closers) and live-adoption (truncate only). | -| `deleteStored(id)` / `list()` | Remove a stored artifact / list all stored metadata. | -| `close?()` | Optional lifecycle teardown (e.g. close a db handle), awaited after the dispose drain. | - -The `tornMarker` is fully OPAQUE: the coordinator only tests `!== undefined` and round-trips it to `commitRepair`, never inspecting its value (the JSONL backend uses the byte offset to truncate to, the SQLite backend the seq to delete from). The public `SessionPersistence` service shape is unchanged, so a third-party backend MAY still implement the abstract service directly without the coordinator. See [the write-coordinator RFC](../../docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md). - -## Testing backends - -Import `runPersistenceContract` from `tests/contract.ts` (the public-API contract) and `runCoordinatorContract` from `tests/coordinator-contract.ts` (the shared write-path orchestration: adoption, HMR, collision, dispose-drain, crash-tail repair) and call each with a fixture for your backend. Every backend is held to the same append-only / contiguous-seq / lazy-materialization / serializability semantics AND the same orchestration, so a backend's own spec is left with only storage-mechanics tests (path sanitization, fsync rollback; schema version, transaction rollback) on top. - -Three backends run these suites: an in-memory reference (in `tests/`), `dsh-session-persistence-jsonl` (append-only file log) and `dsh-session-persistence-sqlite` (`node:sqlite`, each `SessionEvent` one row `(session_id, seq, type, time, data)`). All passing the same contract + coordinator suite is the proof that the seam is genuinely backend-agnostic — lazy materialization, crash-tail-on-load, and contiguous-seq hold identically over file bytes and over a transactional store. - -## Metadata types - -Re-exported from `dsh-session`: `SessionHeader` (immutable session metadata: `version`, `id`, `createdAt`, `cwd?`, `parentSession?`). +The interface lives at `session-persistence/session-persistence/`; backends are flat siblings. A new storage backend joins here and registers on `ctx.sessionPersistence`. See [session persistence](../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md). diff --git a/packages/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md similarity index 97% rename from packages/session-persistence-jsonl/README.md rename to packages/session-persistence/session-persistence-jsonl/README.md index 92899de4b5..28a64c4c11 100644 --- a/packages/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -23,7 +23,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence - **Lazy materialization.** `create(meta)` writes nothing; the `.jsonl` (header + first batch) is written atomically (temp-write + `fsync` + rename) on the first `append`. A created-but-never-appended session leaves nothing on disk and is absent from `has`/`list`. - **Append-only.** Committed events (at or below a flushed `turn/end`) are never rewritten. Subsequent appends are line appends at EOF + `fsync`. -- **Crash recovery — close, don't truncate.** A crash can leave a log whose final turn never closed (real events after the last `turn/end`). `load` PRESERVES those events (a turn can be huge — they are real work) and closes the orphaned turn by durably appending synthetic boundary events: an error `tool/result` for every `tool-call` the crash left unanswered (the loop logs the assistant message before running the tools, so a mid-tool crash leaves dangling calls — and `deriveMessages()` would replay an assistant tool-call with no result, which providers reject), then a `step/end` if a step was open, then `turn/end {kind:'interrupted'}`, returning a balanced log. Only a never-fully-written **torn tail fragment** (a final line with no newline / unparseable) is `ftruncate`d away before the closers are written. See [session persistence](../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md). +- **Crash recovery — close, don't truncate.** A crash can leave a log whose final turn never closed (real events after the last `turn/end`). `load` PRESERVES those events (a turn can be huge — they are real work) and closes the orphaned turn by durably appending synthetic boundary events: an error `tool/result` for every `tool-call` the crash left unanswered (the loop logs the assistant message before running the tools, so a mid-tool crash leaves dangling calls — and `deriveMessages()` would replay an assistant tool-call with no result, which providers reject), then a `step/end` if a step was open, then `turn/end {kind:'interrupted'}`, returning a balanced log. Only a never-fully-written **torn tail fragment** (a final line with no newline / unparseable) is `ftruncate`d away before the closers are written. See [session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md). - **Contiguous-seq.** `load` rejects a mid-log parse error or `seq` gap (unloadable); `append` rejects a batch whose first `seq` does not continue the stored log, and rejects non-JSON-serializable `event.data` naming the offending event type. - **Format version.** Only v1 is supported; `load` rejects an unknown version. While the harness is unreleased a format change bumps the version and rejects non-current logs — there is no migration (no persisted user data to preserve). diff --git a/packages/session-persistence-jsonl/package.json b/packages/session-persistence/session-persistence-jsonl/package.json similarity index 100% rename from packages/session-persistence-jsonl/package.json rename to packages/session-persistence/session-persistence-jsonl/package.json diff --git a/packages/session-persistence-jsonl/src/format.ts b/packages/session-persistence/session-persistence-jsonl/src/format.ts similarity index 100% rename from packages/session-persistence-jsonl/src/format.ts rename to packages/session-persistence/session-persistence-jsonl/src/format.ts diff --git a/packages/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts similarity index 100% rename from packages/session-persistence-jsonl/src/index.ts rename to packages/session-persistence/session-persistence-jsonl/src/index.ts diff --git a/packages/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts similarity index 100% rename from packages/session-persistence-jsonl/tests/jsonl.spec.ts rename to packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts diff --git a/packages/session-persistence/session-persistence-jsonl/tsconfig.json b/packages/session-persistence/session-persistence-jsonl/tsconfig.json new file mode 100644 index 0000000000..adb2824e27 --- /dev/null +++ b/packages/session-persistence/session-persistence-jsonl/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/session" + }, + { + "path": "../../session-persistence/session-persistence" + } + ] +} diff --git a/packages/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md similarity index 90% rename from packages/session-persistence-sqlite/README.md rename to packages/session-persistence/session-persistence-sqlite/README.md index 74e79ac8f3..23916f2bfe 100644 --- a/packages/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-session-persistence-sqlite -A SQLite durable session-persistence backend — a second `SessionPersistence` implementation ([session persistence](../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), built to validate that the abstract seam and the shared `runPersistenceContract` suite are genuinely backend-agnostic. It satisfies the SAME contract as `dsh-session-persistence-jsonl` (append-only, contiguous-seq, lazy materialization, interrupted-turn close on load), expressed over `node:sqlite` rows instead of file bytes. +A SQLite durable session-persistence backend — a second `SessionPersistence` implementation ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), built to validate that the abstract seam and the shared `runPersistenceContract` suite are genuinely backend-agnostic. It satisfies the SAME contract as `dsh-session-persistence-jsonl` (append-only, contiguous-seq, lazy materialization, interrupted-turn close on load), expressed over `node:sqlite` rows instead of file bytes. > **TODO:** this backend talks to `node:sqlite` directly. If a cordis database service (`cordis/db` / a `@cordisjs` SQL driver plugin) is adopted, route through that instead of holding a raw `DatabaseSync` here — the contract surface (`SessionPersistence`) would not change, only the storage driver. diff --git a/packages/session-persistence-sqlite/package.json b/packages/session-persistence/session-persistence-sqlite/package.json similarity index 100% rename from packages/session-persistence-sqlite/package.json rename to packages/session-persistence/session-persistence-sqlite/package.json diff --git a/packages/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts similarity index 100% rename from packages/session-persistence-sqlite/src/index.ts rename to packages/session-persistence/session-persistence-sqlite/src/index.ts diff --git a/packages/session-persistence-sqlite/src/schema.ts b/packages/session-persistence/session-persistence-sqlite/src/schema.ts similarity index 100% rename from packages/session-persistence-sqlite/src/schema.ts rename to packages/session-persistence/session-persistence-sqlite/src/schema.ts diff --git a/packages/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts similarity index 100% rename from packages/session-persistence-sqlite/tests/sqlite.spec.ts rename to packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts diff --git a/packages/session-persistence/session-persistence-sqlite/tsconfig.json b/packages/session-persistence/session-persistence-sqlite/tsconfig.json new file mode 100644 index 0000000000..adb2824e27 --- /dev/null +++ b/packages/session-persistence/session-persistence-sqlite/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/session" + }, + { + "path": "../../session-persistence/session-persistence" + } + ] +} diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md new file mode 100644 index 0000000000..b21a01b763 --- /dev/null +++ b/packages/session-persistence/session-persistence/README.md @@ -0,0 +1,52 @@ +# @deepseek-ai/dsh-session-persistence + +The abstract durable session-persistence seam (`ctx.sessionPersistence`). Defines WHAT a persistence backend does — durably store, reload, and list sessions — without saying HOW. Mirrors the `dsh-bash` capability-seam template ([capability seams](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)): an abstract service here, a concrete implementation in a sibling package, consumers that inject the interface. + +The persisted unit IS the existing `SessionEvent` (event-sourced model — the log is the single source of truth), so there is no parallel "persisted message" type. Metadata that is NOT replayable conversation state (format version, cwd, lineage) travels separately as `SessionHeader`, owned by `dsh-session` and re-exported here. + +## Service API (`ctx.sessionPersistence`) + +| Method | Contract | +|---|---| +| `create(meta): Promise` | Register a new session's metadata. MAY defer the physical write until the first `append` (lazy materialization). | +| `append(id, events): Promise` | Durably persist a batch (from the `session/flush` drain). Append-only; first event `seq` == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. | +| `load(id): Promise<{ meta; events }>` | Reload meta + log. Preserves an interrupted (unclosed) final turn and closes it with synthetic closers — an error `tool/result` per unanswered `tool-call`, then `step/end?`+`turn/end {interrupted}` (a turn can be huge — never truncated); only a torn tail fragment is dropped. Events contiguous (`events[i].seq === i`); rejects a committed-region gap/parse error or unknown `version`. | +| `list(): Promise` | Lightweight listing from metadata, no full-log parse. | +| `has(id)` / `delete(id)` | Existence / removal. A zero-event lazily-materialized session is absent from `has`/`list`. | + +## Invariants every backend must honor + +- **Append-only; a crashed turn is closed, not truncated.** Committed events (at or below a flushed `turn/end`) are never rewritten. A crash can leave an unclosed final turn whose events are real and possibly large; `load` preserves them and durably appends synthetic closers (an error `tool/result` per unanswered `tool-call`, then `step/end?`+`turn/end {interrupted}`) to balance the log and keep the rehydrated history a valid provider transcript. Only a never-fully-written torn tail fragment is discarded. +- **Contiguous seq.** `load` rejects a `seq` gap/parse error in the MIDDLE of the log; `append`'s first `seq` must equal the stored next-seq. +- **JSON-serializable data.** `append` rejects non-serializable `event.data`; backends snapshot each event when buffering (the live `session.events` object is mutable). +- **Durability.** `append` returns only once the batch is durable. + +## The write coordinator + +The two first-party backends were byte-identical (or same-algorithm) for ALL of their write-path orchestration — the in-memory bookkeeping (per-id state, write-behind buffers, per-id serialization chains, per-session init promises), the `session/event` → buffer → `session/flush` drain, lazy materialization, crash-tail repair on load, the four `session/created` adoption cases (new / HMR-adopt / collision / ownerless-claim), and dispose-time quiescence. Only the STORAGE primitives differed (write bytes vs. INSERT rows). + +`PersistenceCoordinator` owns that orchestration once. A first-party backend composes one (`new PersistenceCoordinator(ctx, this)`), implements the small `PersistenceBackend` hook interface, and delegates its six public service methods to the coordinator. This keeps the duplicated, correctness-heavy orchestration in a single place (it used to receive the same fixes twice). + +The `PersistenceBackend` hooks (the only seam between the coordinator and storage): + +| Hook | Role | +|---|---| +| `name` | Backend label for the dispose-failure `AggregateError`. | +| `loadStored(id)` | Read a stored prefix by id, scanning ANY storage scope. Used by resume/load and, via `!== undefined`, the create-collision probe. Returns an opaque `tornMarker` iff a torn tail must be truncated. | +| `loadLive(id, cwd)` | Read a stored prefix SCOPED to `cwd` (HMR live-adoption must only adopt a log at the SAME cwd; a same-id log elsewhere is a collision, not a resume). A globally-unique-id backend ignores `cwd`. | +| `appendBatch(meta, events, isMaterialized)` | Durably append a contiguous batch, lazily materializing ATOMICALLY when not yet materialized. | +| `commitRepair(meta, tornMarker, closers)` | Make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined` — a marker may be falsy, e.g. seq/offset `0`) and append `closers`. NOT required to be atomic. Used by load (truncate + closers) and live-adoption (truncate only). | +| `deleteStored(id)` / `list()` | Remove a stored artifact / list all stored metadata. | +| `close?()` | Optional lifecycle teardown (e.g. close a db handle), awaited after the dispose drain. | + +The `tornMarker` is fully OPAQUE: the coordinator only tests `!== undefined` and round-trips it to `commitRepair`, never inspecting its value (the JSONL backend uses the byte offset to truncate to, the SQLite backend the seq to delete from). The public `SessionPersistence` service shape is unchanged, so a third-party backend MAY still implement the abstract service directly without the coordinator. See [the write-coordinator RFC](../../../docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md). + +## Testing backends + +Import `runPersistenceContract` from `tests/contract.ts` (the public-API contract) and `runCoordinatorContract` from `tests/coordinator-contract.ts` (the shared write-path orchestration: adoption, HMR, collision, dispose-drain, crash-tail repair) and call each with a fixture for your backend. Every backend is held to the same append-only / contiguous-seq / lazy-materialization / serializability semantics AND the same orchestration, so a backend's own spec is left with only storage-mechanics tests (path sanitization, fsync rollback; schema version, transaction rollback) on top. + +Three backends run these suites: an in-memory reference (in `tests/`), `dsh-session-persistence-jsonl` (append-only file log) and `dsh-session-persistence-sqlite` (`node:sqlite`, each `SessionEvent` one row `(session_id, seq, type, time, data)`). All passing the same contract + coordinator suite is the proof that the seam is genuinely backend-agnostic — lazy materialization, crash-tail-on-load, and contiguous-seq hold identically over file bytes and over a transactional store. + +## Metadata types + +Re-exported from `dsh-session`: `SessionHeader` (immutable session metadata: `version`, `id`, `createdAt`, `cwd?`, `parentSession?`). diff --git a/packages/session-persistence/package.json b/packages/session-persistence/session-persistence/package.json similarity index 100% rename from packages/session-persistence/package.json rename to packages/session-persistence/session-persistence/package.json diff --git a/packages/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts similarity index 100% rename from packages/session-persistence/src/coordinator.ts rename to packages/session-persistence/session-persistence/src/coordinator.ts diff --git a/packages/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts similarity index 100% rename from packages/session-persistence/src/index.ts rename to packages/session-persistence/session-persistence/src/index.ts diff --git a/packages/session-persistence/tests/contract.ts b/packages/session-persistence/session-persistence/tests/contract.ts similarity index 100% rename from packages/session-persistence/tests/contract.ts rename to packages/session-persistence/session-persistence/tests/contract.ts diff --git a/packages/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts similarity index 100% rename from packages/session-persistence/tests/coordinator-contract.ts rename to packages/session-persistence/session-persistence/tests/coordinator-contract.ts diff --git a/packages/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/session-persistence/tests/persistence.spec.ts similarity index 100% rename from packages/session-persistence/tests/persistence.spec.ts rename to packages/session-persistence/session-persistence/tests/persistence.spec.ts diff --git a/packages/session-persistence/session-persistence/tsconfig.json b/packages/session-persistence/session-persistence/tsconfig.json new file mode 100644 index 0000000000..df07556965 --- /dev/null +++ b/packages/session-persistence/session-persistence/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../core/session" + } + ] +} diff --git a/packages/session-persistence/tsconfig.json b/packages/session-persistence/tsconfig.json deleted file mode 100644 index 727294a720..0000000000 --- a/packages/session-persistence/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib" - }, - "include": ["src"], - "references": [ - { "path": "../../vendor/cosmokit" }, - { "path": "../../vendor/cordis" }, - { "path": "../session" } - ] -} diff --git a/packages/session/tsconfig.json b/packages/session/tsconfig.json deleted file mode 100644 index e226412a53..0000000000 --- a/packages/session/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib" - }, - "include": ["src"], - "references": [ - { "path": "../../vendor/cosmokit" }, - { "path": "../../vendor/cordis" }, - { "path": "../llm" } - ] -} diff --git a/packages/support/README.md b/packages/support/README.md new file mode 100644 index 0000000000..52f7f6fe25 --- /dev/null +++ b/packages/support/README.md @@ -0,0 +1,11 @@ +# support/ — dev/test/example infrastructure + +Packages that exist to serve development, testing, and the examples rather than to ship as product API. They are real workspace packages (typed, tested, under the coverage gate), but they carry **lower compatibility expectations**: they may change or be removed when the development need behind them does, without the deprecation care a product package would warrant. + +| Package | Role | ctx key | +|---|---|---| +| `invariants/` | Dev-mode event-contract invariants + session-log freeze | (listens on `session/*`, `agent/*`) | +| `ui-stdio/` | Minimal stdio (readline) UI plugin: renders `agent/*` events, feeds stdin lines to the agent | (drives `ctx.agents`) | +| `llm-replay/` | Record/replay adapter: short-circuits `llm/stream` from a recorded session JSONL (keyless snapshot tests) | (listens on `llm/stream`) | + +`invariants` runs only in dev mode (contract checks, not runtime behavior). `ui-stdio` and `llm-replay` were extracted from the examples for reuse and to bring them under the per-file coverage gate; they back the demos and the snapshot test tier. A package graduates OUT of `support/` into a product group only when it gains documented product consumers. diff --git a/packages/invariants/README.md b/packages/support/invariants/README.md similarity index 96% rename from packages/invariants/README.md rename to packages/support/invariants/README.md index a3901a0f5d..a08ccf01a7 100644 --- a/packages/invariants/README.md +++ b/packages/support/invariants/README.md @@ -44,7 +44,7 @@ On any violation it throws `InvariantError` (`code: 'INVARIANT'`). ## Why runtime, not deep-readonly types -A `DeepReadonly` is high type-noise across every log consumer, and a plugin can cast straight through it. A dev-mode freeze plus these assertions catch real corruption at zero production cost and zero type noise. The always-on half of that defense — cloning derived messages so request/adapter mutation can't reach back into the log — lives in `dsh-session`'s `deriveMessages`. This package is the dev-mode tripwire. See [dev-mode invariants](../../docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md). +A `DeepReadonly` is high type-noise across every log consumer, and a plugin can cast straight through it. A dev-mode freeze plus these assertions catch real corruption at zero production cost and zero type noise. The always-on half of that defense — cloning derived messages so request/adapter mutation can't reach back into the log — lives in `dsh-session`'s `deriveMessages`. This package is the dev-mode tripwire. See [dev-mode invariants](../../../docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md). ## Seeded sessions diff --git a/packages/invariants/package.json b/packages/support/invariants/package.json similarity index 100% rename from packages/invariants/package.json rename to packages/support/invariants/package.json diff --git a/packages/invariants/src/index.ts b/packages/support/invariants/src/index.ts similarity index 100% rename from packages/invariants/src/index.ts rename to packages/support/invariants/src/index.ts diff --git a/packages/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts similarity index 100% rename from packages/invariants/tests/invariants.spec.ts rename to packages/support/invariants/tests/invariants.spec.ts diff --git a/packages/support/invariants/tsconfig.json b/packages/support/invariants/tsconfig.json new file mode 100644 index 0000000000..76021c9ae5 --- /dev/null +++ b/packages/support/invariants/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../../core/agent" + } + ] +} diff --git a/packages/llm-replay/README.md b/packages/support/llm-replay/README.md similarity index 97% rename from packages/llm-replay/README.md rename to packages/support/llm-replay/README.md index a914ab23b6..91230cc113 100644 --- a/packages/llm-replay/README.md +++ b/packages/support/llm-replay/README.md @@ -33,4 +33,4 @@ Two failure modes are not reconstructable from `assistant/chunk` alone — a pur ## Plugin export shape -Named `name` / `inject` / `Config` / `apply`, with **no default export**: the cordis Loader's `unwrapExports` does `exports.default ?? exports`, so a stray default would collapse the module to the bare function and drop the `inject` namespace (see [docs/postmortem/0001](../../docs/postmortem/0001-acp-default-export-drops-inject.md)). +Named `name` / `inject` / `Config` / `apply`, with **no default export**: the cordis Loader's `unwrapExports` does `exports.default ?? exports`, so a stray default would collapse the module to the bare function and drop the `inject` namespace (see [docs/postmortem/0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)). diff --git a/packages/llm-replay/package.json b/packages/support/llm-replay/package.json similarity index 100% rename from packages/llm-replay/package.json rename to packages/support/llm-replay/package.json diff --git a/packages/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts similarity index 98% rename from packages/llm-replay/src/index.ts rename to packages/support/llm-replay/src/index.ts index a1c5f4f1d1..67fcf09697 100644 --- a/packages/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -10,7 +10,7 @@ * The fixture IS the persisted session log (`/session.jsonl`): its * `assistant/chunk` events carry every {@link StreamChunk}, so grouping them by * `(turn, step)` reconstructs each `stream()` call's chunk sequence (one model - * call per loop step — see packages/agent-loop/src/loop.ts). Recording is + * call per loop step — see packages/core/agent-loop/src/loop.ts). Recording is * therefore "run the real agent once and harvest the `.jsonl`", done by the * snapshot harness — this plugin does not record. * @@ -46,7 +46,7 @@ import { LlmError, assertNever } from '@deepseek-ai/dsh-llm' * so it can faithfully replay BOTH branches of the documented LLM failure * contract — an adapter may THROW from `stream()` or end with a `finish` error * chunk — plus a `hang` marker for cancellation scenarios (mirrors the - * `MockAdapter` `hang` support in packages/agent-loop/tests). + * `MockAdapter` `hang` support in packages/core/agent-loop/tests). * * A `throw` entry carries any `chunks` the adapter emitted BEFORE it threw, so * a mid-stream transport failure (partial output then `STREAM_CLOSED`) replays diff --git a/packages/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts similarity index 100% rename from packages/llm-replay/tests/llm-replay.spec.ts rename to packages/support/llm-replay/tests/llm-replay.spec.ts diff --git a/packages/support/llm-replay/tsconfig.json b/packages/support/llm-replay/tsconfig.json new file mode 100644 index 0000000000..e7d274f2cd --- /dev/null +++ b/packages/support/llm-replay/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + } + ] +} diff --git a/packages/ui-stdio/README.md b/packages/support/ui-stdio/README.md similarity index 95% rename from packages/ui-stdio/README.md rename to packages/support/ui-stdio/README.md index e49b0240b9..b65fd4d8e1 100644 --- a/packages/ui-stdio/README.md +++ b/packages/support/ui-stdio/README.md @@ -41,4 +41,4 @@ Disposal (HMR or fiber teardown) closes the readline interface, which also fires ## Plugin export shape -Named `name` / `inject` / `Config` / `apply`, with **no default export**: the cordis Loader's `unwrapExports` does `exports.default ?? exports`, so a stray default would collapse the module to the bare function and drop the `inject` namespace (see [docs/postmortem/0001](../../docs/postmortem/0001-acp-default-export-drops-inject.md)). The keyless Loader-path e2e smokes in `examples/{echo,coding}-agent` guard this end-to-end. +Named `name` / `inject` / `Config` / `apply`, with **no default export**: the cordis Loader's `unwrapExports` does `exports.default ?? exports`, so a stray default would collapse the module to the bare function and drop the `inject` namespace (see [docs/postmortem/0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)). The keyless Loader-path e2e smokes in `examples/{echo,coding}-agent` guard this end-to-end. diff --git a/packages/ui-stdio/package.json b/packages/support/ui-stdio/package.json similarity index 100% rename from packages/ui-stdio/package.json rename to packages/support/ui-stdio/package.json diff --git a/packages/ui-stdio/src/index.ts b/packages/support/ui-stdio/src/index.ts similarity index 100% rename from packages/ui-stdio/src/index.ts rename to packages/support/ui-stdio/src/index.ts diff --git a/packages/ui-stdio/tests/ui-stdio.spec.ts b/packages/support/ui-stdio/tests/ui-stdio.spec.ts similarity index 100% rename from packages/ui-stdio/tests/ui-stdio.spec.ts rename to packages/support/ui-stdio/tests/ui-stdio.spec.ts diff --git a/packages/support/ui-stdio/tsconfig.json b/packages/support/ui-stdio/tsconfig.json new file mode 100644 index 0000000000..f7e9736f77 --- /dev/null +++ b/packages/support/ui-stdio/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + } + ] +} diff --git a/packages/system-prompt/tsconfig.json b/packages/system-prompt/tsconfig.json deleted file mode 100644 index e226412a53..0000000000 --- a/packages/system-prompt/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib" - }, - "include": ["src"], - "references": [ - { "path": "../../vendor/cosmokit" }, - { "path": "../../vendor/cordis" }, - { "path": "../llm" } - ] -} diff --git a/packages/tool-bash/tsconfig.json b/packages/tool-bash/tsconfig.json deleted file mode 100644 index 4741cb67f3..0000000000 --- a/packages/tool-bash/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib" - }, - "include": ["src"], - "references": [ - { "path": "../../vendor/cosmokit" }, - { "path": "../../vendor/cordis" }, - { "path": "../llm" }, - { "path": "../tools" }, - { "path": "../agent" }, - { "path": "../bash" } - ] -} diff --git a/packages/tools/tsconfig.json b/packages/tools/tsconfig.json deleted file mode 100644 index 8e29228fc8..0000000000 --- a/packages/tools/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib" - }, - "include": ["src"], - "references": [ - { "path": "../../vendor/cosmokit" }, - { "path": "../../vendor/cordis" }, - { "path": "../llm" }, - { "path": "../system-prompt" }, - { "path": "../agent" } - ] -} diff --git a/packages/ui-stdio/tsconfig.json b/packages/ui-stdio/tsconfig.json deleted file mode 100644 index 33fa338e5f..0000000000 --- a/packages/ui-stdio/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib" - }, - "include": ["src"], - "references": [ - { "path": "../../vendor/cosmokit" }, - { "path": "../../vendor/cordis" }, - { "path": "../../vendor/schemastery" }, - { "path": "../agent" }, - { "path": "../llm" }, - { "path": "../session" } - ] -} diff --git a/packages/ui/README.md b/packages/ui/README.md new file mode 100644 index 0000000000..62b2c70855 --- /dev/null +++ b/packages/ui/README.md @@ -0,0 +1,9 @@ +# ui/ — editor/client integration surfaces + +Integrations that expose the agent to an external editor or client. These are **product** packages: a real surface a user drives the harness through. + +| Package | Role | ctx key | +|---|---|---| +| `acp/` | Agent Client Protocol bridge: serves the agent to an ACP editor (Zed) over JSON-RPC stdio | (drives `ctx.agents`/`ctx.sessions`) | + +A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The readline `ui-stdio` plugin is the unstructured analogue but lives in `support/` because it exists chiefly for the examples and the coverage gate — `ui/` is reserved for surfaces shipped as product. diff --git a/packages/acp/README.md b/packages/ui/acp/README.md similarity index 87% rename from packages/acp/README.md rename to packages/ui/acp/README.md index b0334a0a2a..ad50383542 100644 --- a/packages/acp/README.md +++ b/packages/ui/acp/README.md @@ -1,8 +1,8 @@ # @deepseek-ai/dsh-acp -The **Agent Client Protocol (ACP)** bridge: exposes the DeepSeek Harness coding agent as an ACP server over JSON-RPC stdio, so editors (Zed and other ACP clients) can drive it — streaming render, tool-call display, and resumable sessions. Zed is the current target client: baseline ACP behavior should remain reasonable for other clients, but bridge capabilities and compatibility decisions are evaluated against Zed first. **N concurrent sessions per connection** (see [ACP multi-session](../../docs/rfc/proposed/feature/2026-06-14-acp-multi-session.md)): each maps to its own `ReactLoopAgent`, and every event is demuxed strictly by session id so two sessions streaming at once never interleave. +The **Agent Client Protocol (ACP)** bridge: exposes the DeepSeek Harness coding agent as an ACP server over JSON-RPC stdio, so editors (Zed and other ACP clients) can drive it — streaming render, tool-call display, and resumable sessions. Zed is the current target client: baseline ACP behavior should remain reasonable for other clients, but bridge capabilities and compatibility decisions are evaluated against Zed first. **N concurrent sessions per connection** (see [ACP multi-session](../../../docs/rfc/proposed/feature/2026-06-14-acp-multi-session.md)): each maps to its own `ReactLoopAgent`, and every event is demuxed strictly by session id so two sessions streaming at once never interleave. -It is a **client-driver / UI plugin**, the structured analogue of the readline `stdio-chat` plugin — NOT a loop change and NOT a [capability seam](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md). It consumes the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory, and `dsh-session-persistence`. +It is a **client-driver / UI plugin**, the structured analogue of the readline `stdio-chat` plugin — NOT a loop change and NOT a [capability seam](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md). It consumes the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory, and `dsh-session-persistence`. ## Service / plugin @@ -53,7 +53,7 @@ A tool whose call IS a shell command (`bash`) can render as a real **terminal ca - `tool_call`: `content:[…, {type:'terminal', terminalId}]` + `_meta.terminal_info.{terminal_id, cwd}` — the terminal id is the harness `callId`; the cwd is the tool's explicit absolute `terminal.cwd`, else a relative `terminal.cwd` resolved against the session cwd, else the session's workspace cwd (the bridge fills the default, since the pure tool presenter can't see it). Any pending `content` the tool supplied (e.g. bash's `description`) renders BEFORE the terminal block, so the description sits above the card. - `tool_call_update`: `_meta.terminal_output.{terminal_id, data}` (the captured output) plus `_meta.terminal_exit.{terminal_id, exit_code | signal}` when the tool reported a structured exit. In terminal mode the update's `content` is OMITTED — an ACP `tool_call_update.content` REPLACES the call's content, so sending the fenced text block would clobber the terminal content block from the call. -When the client does NOT advertise the capability, none of the `_meta`/terminal content is emitted: the `tool_call` shows the `description` content block and the `tool_call_update` carries the ` ```console ` text block (above) as the rendering — so a non-Zed client is never worse off. The `_meta` object is ACP's spec-blessed extensibility point; the specific `terminal_info`/`terminal_output`/`terminal_exit` keys are a Zed convention, not the ACP `terminal/create` sub-protocol (which would make the editor execute the command, bypassing `dsh-bash`'s sandbox/env-scrub/ownership/cwd). Live incremental streaming and command classification are follow-ups. See [the terminal-rendering RFC](../../docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md). +When the client does NOT advertise the capability, none of the `_meta`/terminal content is emitted: the `tool_call` shows the `description` content block and the `tool_call_update` carries the ` ```console ` text block (above) as the rendering — so a non-Zed client is never worse off. The `_meta` object is ACP's spec-blessed extensibility point; the specific `terminal_info`/`terminal_output`/`terminal_exit` keys are a Zed convention, not the ACP `terminal/create` sub-protocol (which would make the editor execute the command, bypassing `dsh-bash`'s sandbox/env-scrub/ownership/cwd). Live incremental streaming and command classification are follow-ups. See [the terminal-rendering RFC](../../../docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md). ## Settle-exactly-once @@ -61,16 +61,16 @@ A `session/prompt` resolves (or rejects) exactly once, keyed off the canonical s ## Disposal & disconnect -Teardown reaches quiescence: for EVERY live session settle any pending prompt as `cancelled`, then run that session's [`AgentHandle`](../agent/README.md) `dispose()` — which stops the loop (sets `disposed` + aborts the in-flight step), `await`s the loop's exit (the final `turn/end` + `session/flush` are captured while the session is still attached), unregisters the agent, and removes its session from the store. A turn cut off mid-flight by teardown ends with reason `disposed` (not `aborted` — `dispose()` uses the disposed path, not `session/cancel`'s queue-aware `cancel()`). The per-session disposes run in parallel. The same teardown runs on a **client disconnect** (`conn.closed` resolves when the editor quits / the transport EOFs), so a vanished client never leaves an orphaned running — or idled-but-still-registered — agent whose `session/update` writes are silently swallowed. The two paths are idempotent and memoized (the first clears the `sessions` map; a second caller awaits the same teardown promise). +Teardown reaches quiescence: for EVERY live session settle any pending prompt as `cancelled`, then run that session's [`AgentHandle`](../../core/agent/README.md) `dispose()` — which stops the loop (sets `disposed` + aborts the in-flight step), `await`s the loop's exit (the final `turn/end` + `session/flush` are captured while the session is still attached), unregisters the agent, and removes its session from the store. A turn cut off mid-flight by teardown ends with reason `disposed` (not `aborted` — `dispose()` uses the disposed path, not `session/cancel`'s queue-aware `cancel()`). The per-session disposes run in parallel. The same teardown runs on a **client disconnect** (`conn.closed` resolves when the editor quits / the transport EOFs), so a vanished client never leaves an orphaned running — or idled-but-still-registered — agent whose `session/update` writes are silently swallowed. The two paths are idempotent and memoized (the first clears the `sessions` map; a second caller awaits the same teardown promise). ## Known limitations (tracked TODOs) -- **`TODO(rfc010-permission-gate)`** — the `tools/execute` permission gate (`session/request_permission`) is NOT implemented; tools run with the executor's full authority. The `agent→sessionId` reverse map is in place so the gate can route a permission request (which receives only `exec.agent`) back to its originating session. [ACP support](../../docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md) and [ACP multi-session](../../docs/rfc/proposed/feature/2026-06-14-acp-multi-session.md) stay `proposed` until the gate (and per-session permission ownership) land. +- **`TODO(rfc010-permission-gate)`** — the `tools/execute` permission gate (`session/request_permission`) is NOT implemented; tools run with the executor's full authority. The `agent→sessionId` reverse map is in place so the gate can route a permission request (which receives only `exec.agent`) back to its originating session. [ACP support](../../../docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md) and [ACP multi-session](../../../docs/rfc/proposed/feature/2026-06-14-acp-multi-session.md) stay `proposed` until the gate (and per-session permission ownership) land. - **`additionalDirectories`** — rejected. A session operates in its single `cwd` (see Per-session cwd); widening the tool/filesystem scope to extra roots is a separate sandbox concern, not yet implemented. ## stdout is the protocol -The JSON-RPC frames go on stdout, so this plugin MUST run in an example that loads **no stdout logger** (the console logger writes to stdout and would corrupt the frames). The guarantee is config-only — see `examples/acp-agent` (no console logger) and [ACP support risks](../../docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md#risks). A stderr exporter is fine for logging. +The JSON-RPC frames go on stdout, so this plugin MUST run in an example that loads **no stdout logger** (the console logger writes to stdout and would corrupt the frames). The guarantee is config-only — see `examples/acp-agent` (no console logger) and [ACP support risks](../../../docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md#risks). A stderr exporter is fine for logging. ## Running diff --git a/packages/acp/acp-feature-support.md b/packages/ui/acp/acp-feature-support.md similarity index 98% rename from packages/acp/acp-feature-support.md rename to packages/ui/acp/acp-feature-support.md index d5dd379bf5..6b171769d3 100644 --- a/packages/acp/acp-feature-support.md +++ b/packages/ui/acp/acp-feature-support.md @@ -92,7 +92,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs ## 5. Tool-call rendering -Tool-call presentation is **owned by each tool** (`presentCall` / `presentResult` on the `dsh-tools` definition), not special-cased in the bridge — see the [terminal-and-tool-rendering RFC](../../docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md). +Tool-call presentation is **owned by each tool** (`presentCall` / `presentResult` on the `dsh-tools` definition), not special-cased in the bridge — see the [terminal-and-tool-rendering RFC](../../../docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md). | Feature | Stable | Bridge | Claude | Codex | Notes | |---|---|---|---|---|---| @@ -130,7 +130,7 @@ The bridge rejects unsupported prompt blocks rather than silently dropping them | Feature | Stable | Bridge | Notes | |---|---|---|---| | `StopReason` mapping | S | ✅ | `turnEndToStopReason` is total over harness turn-end reasons → `end_turn`/`max_tokens`/`cancelled`. | -| Multi-session (N per connection) | S | ✅ | Strict per-session demux; concurrent streams never interleave. See the [multi-session RFC](../../docs/rfc/proposed/feature/2026-06-14-acp-multi-session.md). | +| Multi-session (N per connection) | S | ✅ | Strict per-session demux; concurrent streams never interleave. See the [multi-session RFC](../../../docs/rfc/proposed/feature/2026-06-14-acp-multi-session.md). | | Disconnect / disposal teardown | S | ✅ | Quiesces every live session on client disconnect or Cordis disposal. | | `_meta` extensibility | S | ⚠️ | Consumed (Zed terminal cap) and emitted (terminal `_meta`); no other custom extensions. | | Background-task ownership isolation | — | ✅ | `bash_output`/`bash_kill` reject another session's task via an opaque owner token. | @@ -159,4 +159,4 @@ Unstable/draft ACP features that **neither** reference adapter ships are not tra - Stable spec: `schema/v1/schema.json` (schema `1.14.0`) and `docs/protocol/v1/*.mdx` in the [agent-client-protocol](https://github.com/agentclientprotocol/agent-client-protocol) repo. - Reference adapters: [`claude-agent-acp`](https://github.com/zed-industries/claude-code-acp) and [`codex-acp`](https://github.com/zed-industries/codex-acp). -- Bridge: [`README.md`](README.md), [`src/index.ts`](src/index.ts), and the ACP RFCs under [`docs/rfc/`](../../docs/rfc/README.md). +- Bridge: [`README.md`](README.md), [`src/index.ts`](src/index.ts), and the ACP RFCs under [`docs/rfc/`](../../../docs/rfc/README.md). diff --git a/packages/acp/package.json b/packages/ui/acp/package.json similarity index 100% rename from packages/acp/package.json rename to packages/ui/acp/package.json diff --git a/packages/acp/src/codec.ts b/packages/ui/acp/src/codec.ts similarity index 100% rename from packages/acp/src/codec.ts rename to packages/ui/acp/src/codec.ts diff --git a/packages/acp/src/index.ts b/packages/ui/acp/src/index.ts similarity index 100% rename from packages/acp/src/index.ts rename to packages/ui/acp/src/index.ts diff --git a/packages/acp/tests/bridge.spec.ts b/packages/ui/acp/tests/bridge.spec.ts similarity index 100% rename from packages/acp/tests/bridge.spec.ts rename to packages/ui/acp/tests/bridge.spec.ts diff --git a/packages/acp/tests/codec.spec.ts b/packages/ui/acp/tests/codec.spec.ts similarity index 100% rename from packages/acp/tests/codec.spec.ts rename to packages/ui/acp/tests/codec.spec.ts diff --git a/packages/acp/tests/dispose.spec.ts b/packages/ui/acp/tests/dispose.spec.ts similarity index 100% rename from packages/acp/tests/dispose.spec.ts rename to packages/ui/acp/tests/dispose.spec.ts diff --git a/packages/acp/tests/edges.spec.ts b/packages/ui/acp/tests/edges.spec.ts similarity index 100% rename from packages/acp/tests/edges.spec.ts rename to packages/ui/acp/tests/edges.spec.ts diff --git a/packages/acp/tests/harness.ts b/packages/ui/acp/tests/harness.ts similarity index 100% rename from packages/acp/tests/harness.ts rename to packages/ui/acp/tests/harness.ts diff --git a/packages/acp/tests/load.spec.ts b/packages/ui/acp/tests/load.spec.ts similarity index 100% rename from packages/acp/tests/load.spec.ts rename to packages/ui/acp/tests/load.spec.ts diff --git a/packages/acp/tests/multi-session.spec.ts b/packages/ui/acp/tests/multi-session.spec.ts similarity index 100% rename from packages/acp/tests/multi-session.spec.ts rename to packages/ui/acp/tests/multi-session.spec.ts diff --git a/packages/acp/tests/properties.spec.ts b/packages/ui/acp/tests/properties.spec.ts similarity index 100% rename from packages/acp/tests/properties.spec.ts rename to packages/ui/acp/tests/properties.spec.ts diff --git a/packages/acp/tests/stream-update.spec.ts b/packages/ui/acp/tests/stream-update.spec.ts similarity index 100% rename from packages/acp/tests/stream-update.spec.ts rename to packages/ui/acp/tests/stream-update.spec.ts diff --git a/packages/acp/tests/turns.spec.ts b/packages/ui/acp/tests/turns.spec.ts similarity index 100% rename from packages/acp/tests/turns.spec.ts rename to packages/ui/acp/tests/turns.spec.ts diff --git a/packages/ui/acp/tsconfig.json b/packages/ui/acp/tsconfig.json new file mode 100644 index 0000000000..33d4d0b6f7 --- /dev/null +++ b/packages/ui/acp/tsconfig.json @@ -0,0 +1,36 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../session-persistence/session-persistence" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5026104cd9..8e79f0d6ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,105 +66,13 @@ importers: specifier: ^4.1.8 version: 4.1.8(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) - packages/acp: - dependencies: - '@agentclientprotocol/sdk': - specifier: 0.25.1 - version: 0.25.1(zod@4.4.3) - schemastery: - specifier: ^3.17.0 - version: 3.18.0 - zod: - specifier: ^4.0.0 - version: 4.4.3 - devDependencies: - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../agent - '@deepseek-ai/dsh-agent-loop': - specifier: workspace:^ - version: link:../agent-loop - '@deepseek-ai/dsh-bash-local': - specifier: workspace:^ - version: link:../bash-local - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../llm - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../session - '@deepseek-ai/dsh-session-persistence': - specifier: workspace:^ - version: link:../session-persistence - '@deepseek-ai/dsh-session-persistence-jsonl': - specifier: workspace:^ - version: link:../session-persistence-jsonl - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../system-prompt - '@deepseek-ai/dsh-tool-bash': - specifier: workspace:^ - version: link:../tool-bash - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../tools - cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) - - packages/agent: - devDependencies: - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../llm - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../session - cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) - - packages/agent-loop: - dependencies: - schemastery: - specifier: ^3.18.0 - version: 3.18.0 - devDependencies: - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../agent - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../invariants - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../llm - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../session - '@deepseek-ai/dsh-session-persistence': - specifier: workspace:^ - version: link:../session-persistence - '@deepseek-ai/dsh-session-persistence-jsonl': - specifier: workspace:^ - version: link:../session-persistence-jsonl - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../system-prompt - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../tools - cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) - - packages/bash: + packages/bash/bash: devDependencies: cordis: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) - packages/bash-local: + packages/bash/bash-local: dependencies: schemastery: specifier: ^3.18.0 @@ -177,14 +85,41 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) - packages/invariants: + packages/bash/tool-bash: devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ - version: link:../agent + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-bash': + specifier: workspace:^ + version: link:../bash + '@deepseek-ai/dsh-bash-local': + specifier: workspace:^ + version: link:../bash-local '@deepseek-ai/dsh-llm': specifier: workspace:^ - version: link:../llm + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + + packages/core/agent: + devDependencies: + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../session @@ -192,13 +127,80 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) - packages/llm: + packages/core/agent-loop: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../session + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence-jsonl + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../tools + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + + packages/core/session: + devDependencies: + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + + packages/core/system-prompt: + devDependencies: + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + + packages/core/tools: + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../agent + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../system-prompt + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + + packages/llm/llm: devDependencies: cordis: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) - packages/llm-deepseek: + packages/llm/llm-deepseek: dependencies: schemastery: specifier: ^3.18.0 @@ -211,7 +213,7 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) - packages/llm-pi-ai: + packages/llm/llm-pi-ai: dependencies: '@earendil-works/pi-ai': specifier: ^0.79.1 @@ -230,37 +232,16 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) - packages/llm-replay: - devDependencies: - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../llm - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../session - cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) - - packages/session: - devDependencies: - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../llm - cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) - - packages/session-persistence: + packages/session-persistence/session-persistence: devDependencies: '@deepseek-ai/dsh-session': specifier: workspace:^ - version: link:../session + version: link:../../core/session cordis: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) - packages/session-persistence-jsonl: + packages/session-persistence/session-persistence-jsonl: dependencies: schemastery: specifier: ^3.18.0 @@ -268,7 +249,7 @@ importers: devDependencies: '@deepseek-ai/dsh-session': specifier: workspace:^ - version: link:../session + version: link:../../core/session '@deepseek-ai/dsh-session-persistence': specifier: workspace:^ version: link:../session-persistence @@ -276,7 +257,7 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) - packages/session-persistence-sqlite: + packages/session-persistence/session-persistence-sqlite: dependencies: schemastery: specifier: ^3.18.0 @@ -284,7 +265,7 @@ importers: devDependencies: '@deepseek-ai/dsh-session': specifier: workspace:^ - version: link:../session + version: link:../../core/session '@deepseek-ai/dsh-session-persistence': specifier: workspace:^ version: link:../session-persistence @@ -292,75 +273,94 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) - packages/system-prompt: - devDependencies: - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../llm - cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) - - packages/tool-bash: + packages/support/invariants: devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ - version: link:../agent + version: link:../../core/agent + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + + packages/support/llm-replay: + devDependencies: + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + + packages/support/ui-stdio: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + + packages/ui/acp: + dependencies: + '@agentclientprotocol/sdk': + specifier: 0.25.1 + version: 0.25.1(zod@4.4.3) + schemastery: + specifier: ^3.17.0 + version: 3.18.0 + zod: + specifier: ^4.0.0 + version: 4.4.3 + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ - version: link:../agent-loop - '@deepseek-ai/dsh-bash': - specifier: workspace:^ - version: link:../bash + version: link:../../core/agent-loop '@deepseek-ai/dsh-bash-local': specifier: workspace:^ - version: link:../bash-local + version: link:../../bash/bash-local '@deepseek-ai/dsh-llm': specifier: workspace:^ - version: link:../llm + version: link:../../llm/llm '@deepseek-ai/dsh-session': specifier: workspace:^ - version: link:../session + version: link:../../core/session + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence-jsonl '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ - version: link:../system-prompt + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-bash': + specifier: workspace:^ + version: link:../../bash/tool-bash '@deepseek-ai/dsh-tools': specifier: workspace:^ - version: link:../tools - cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) - - packages/tools: - devDependencies: - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../agent - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../llm - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../system-prompt - cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) - - packages/ui-stdio: - dependencies: - schemastery: - specifier: ^3.18.0 - version: 3.18.0 - devDependencies: - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../agent - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../llm - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../session + version: link:../../core/tools cordis: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) @@ -2827,7 +2827,7 @@ snapshots: '@aws-sdk/types': 3.973.12 '@smithy/core': 3.24.7 '@smithy/fetch-http-handler': 5.4.7 - '@smithy/node-http-handler': 4.7.3 + '@smithy/node-http-handler': 4.7.8 '@smithy/types': 4.14.4 tslib: 2.8.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2f957141fe..b2b731fc58 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,6 @@ packages: - vendor/* - - packages/* + - packages/*/* peerDependencyRules: allowedVersions: diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 50b1a80078..eae8dd55f1 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -5,11 +5,16 @@ * Run: `tsx scripts/check-workspace-constraints.ts`. */ -import { readdirSync, readFileSync } from 'node:fs' +import { existsSync, readdirSync, readFileSync } from 'node:fs' import { join, relative, resolve } from 'node:path' const root = resolve(import.meta.dirname, '..') -const workspaceGlobs = ['vendor', 'packages'] as const +// vendor/* is single-level; packages// nests one level deeper +// (the group dirs — core/llm/bash/… — are pure containers with no manifest). +const workspaceGlobs = [ + { dir: 'vendor', depth: 1 }, + { dir: 'packages', depth: 2 }, +] as const const vendoredPackages = new Set([ 'cordis', 'cosmokit', @@ -42,15 +47,25 @@ function readJson(path: string): PackageManifest { return JSON.parse(readFileSync(path, 'utf8')) as PackageManifest } +/** Repo-relative dirs holding a package.json, walked to the configured depth. */ +function packageDirs(base: string, depth: number): string[] { + if (depth === 1) { + return readdirSync(join(root, base), { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => join(base, entry.name)) + } + return readdirSync(join(root, base), { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .flatMap(group => packageDirs(join(base, group.name), depth - 1)) +} + function workspaceManifests(): WorkspaceManifest[] { const manifests: WorkspaceManifest[] = [ { dir: '.', manifest: readJson(join(root, 'package.json')) }, ] - for (const workspaceDir of workspaceGlobs) { - for (const entry of readdirSync(join(root, workspaceDir), { withFileTypes: true })) { - if (!entry.isDirectory()) continue - const dir = join(workspaceDir, entry.name) + for (const { dir: base, depth } of workspaceGlobs) { + for (const dir of packageDirs(base, depth)) { manifests.push({ dir, manifest: readJson(join(root, dir, 'package.json')) }) } } @@ -90,7 +105,37 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] { return errors.map(error => `${relative(root, join(root, dir, 'package.json'))}: ${error}`) } -const errors = workspaceManifests().flatMap(checkWorkspace) +/** + * Enforce the packages/ hierarchy SHAPE: every package lives at exactly + * `packages//`. A group dir is a pure container — it holds packages, + * never sources of its own — so it must NOT carry a package.json, and a package + * must NOT sit directly at the `packages/` root (the old flat layout) nor nest a + * level deeper. The group NAMES are open on purpose: a new group may be added + * without touching this gate, but the depth-2 shape is fixed. This is what keeps + * a stray flat package or an over-nested one from regressing the hierarchy. + */ +function checkHierarchyShape(): string[] { + const errors: string[] = [] + const packagesRoot = join(root, 'packages') + for (const group of readdirSync(packagesRoot, { withFileTypes: true })) { + if (!group.isDirectory()) continue + const groupRel = join('packages', group.name) + if (existsSync(join(packagesRoot, group.name, 'package.json'))) { + errors.push(`${groupRel}: a group dir must not contain a package.json — packages live at packages//, not directly under packages/`) + continue + } + for (const pkg of readdirSync(join(packagesRoot, group.name), { withFileTypes: true })) { + if (!pkg.isDirectory()) continue + const pkgRel = join(groupRel, pkg.name) + if (!existsSync(join(packagesRoot, group.name, pkg.name, 'package.json'))) { + errors.push(`${pkgRel}: expected a package here (no package.json found) — the hierarchy is exactly packages//, no deeper nesting`) + } + } + } + return errors +} + +const errors = [...workspaceManifests().flatMap(checkWorkspace), ...checkHierarchyShape()] if (errors.length > 0) { console.error(errors.join('\n')) process.exitCode = 1 diff --git a/scripts/doc-typecheck.ts b/scripts/doc-typecheck.ts index 905adfb630..01a64b0eda 100644 --- a/scripts/doc-typecheck.ts +++ b/scripts/doc-typecheck.ts @@ -24,6 +24,7 @@ import { execFileSync } from 'node:child_process' import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { join, relative, resolve } from 'node:path' import { glob } from 'node:fs/promises' +import ts from 'typescript' const root = resolve(import.meta.dirname, '..') @@ -96,13 +97,17 @@ function extractBlocks(absPath: string): Block[] { * vendor `lib/` to exist (a fresh clone runs `pnpm run build` first; CI does too). */ function workspacePaths(): Record { - const raw = readFileSync(join(root, 'tsconfig.typecheck.json'), 'utf8') - // Strip // line comments and /* */ block comments so JSON.parse accepts it. - const stripped = raw - .replace(/\/\*[\s\S]*?\*\//g, '') - .replace(/(^|[^:])\/\/.*$/gm, '$1') - return (JSON.parse(stripped) as { compilerOptions: { paths: Record } }) - .compilerOptions.paths + const file = join(root, 'tsconfig.typecheck.json') + // Parse with TypeScript's own JSONC reader, not a hand-rolled comment strip: + // a regex strip mistakes the `/*/` in a wildcard path candidate + // (`./packages/core/*/src`) for a block comment and corrupts the map. + const result = ts.readConfigFile(file, p => readFileSync(p, 'utf8')) + if (result.error) { + throw new Error(`doc-typecheck: cannot read ${file}: ${ts.flattenDiagnosticMessageText(result.error.messageText, '\n')}`) + } + // `config` is typed `any` by the TS API; narrow it to the one field we read. + const config = result.config as { compilerOptions: { paths: Record } } + return config.compilerOptions.paths } /** The standalone tsconfig for the temp project (copies base resolution, no @@ -125,7 +130,7 @@ function tempTsconfig(): string { }) } -const markdownGlobs = ['README.md', 'docs/**/*.md', 'packages/*/*.md'] +const markdownGlobs = ['README.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md'] const files: string[] = [] for (const pattern of markdownGlobs) { diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 49c451bc20..fa6545daef 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -208,7 +208,7 @@ function memberSignature(member: ts.TypeElement | ts.ClassElement, sf: ts.Source * `scanRoot` defaults to the repo root; tests pass a fixture dir. */ export function collectEvents(scanRoot: string = root): EventEntry[] { const entries: EventEntry[] = [] - for (const rel of globSync('packages/*/src/*.ts', { cwd: scanRoot }).sort()) { + for (const rel of globSync('packages/*/*/src/*.ts', { cwd: scanRoot }).sort()) { const abs = resolve(scanRoot, rel) const text = readFileSync(abs, 'utf8') if (!text.includes('interface Events')) continue @@ -248,7 +248,7 @@ export function collectEvents(scanRoot: string = root): EventEntry[] { * `scanRoot` defaults to the repo root; tests pass a fixture dir. */ export function collectServices(scanRoot: string = root): ServiceEntry[] { const entries: ServiceEntry[] = [] - for (const rel of globSync('packages/*/src/index.ts', { cwd: scanRoot }).sort()) { + for (const rel of globSync('packages/*/*/src/index.ts', { cwd: scanRoot }).sort()) { const abs = resolve(scanRoot, rel) const text = readFileSync(abs, 'utf8') if (!text.includes('interface Context')) continue diff --git a/scripts/gen-module-graph.ts b/scripts/gen-module-graph.ts index 6314b39399..ebaf7d5db8 100644 --- a/scripts/gen-module-graph.ts +++ b/scripts/gen-module-graph.ts @@ -4,7 +4,7 @@ * The architectural shape of the harness lives implicitly in each package's * `peerDependencies` — the canonical runtime-dependency signal (devDeps mirror * these as `workspace:^` plus test-only extras, which would add noise). This - * script reads every `packages/* /package.json`, keeps only the + * script reads every `packages/* /* /package.json`, keeps only the * `@deepseek-ai/dsh-*` peer edges (dropping the `cordis` peer), and renders a * GitHub-viewable Mermaid graph plus a dependency table. * @@ -34,7 +34,7 @@ interface Pkg { /** Read every workspace package and its `@deepseek-ai/dsh-*` peer edges. */ function collect(): Pkg[] { const pkgs: Pkg[] = [] - for (const rel of globSync('packages/*/package.json', { cwd: root })) { + for (const rel of globSync('packages/*/*/package.json', { cwd: root })) { const json = JSON.parse(readFileSync(resolve(root, rel), 'utf8')) as { name: string peerDependencies?: Record diff --git a/scripts/publint-all.ts b/scripts/publint-all.ts index e0bee4393e..df13d87caa 100644 --- a/scripts/publint-all.ts +++ b/scripts/publint-all.ts @@ -1,31 +1,22 @@ import { execFileSync } from 'node:child_process' +import { readdirSync } from 'node:fs' import { resolve } from 'node:path' -// publint every publishable package (vendor/ is private upstream code and -// examples/ are not packages; both are out of scope). -// TODO(package-inventory): derive this from the deliberate package hierarchy. -const packages = [ - 'packages/llm', - 'packages/session', - 'packages/session-persistence', - 'packages/session-persistence-jsonl', - 'packages/session-persistence-sqlite', - 'packages/system-prompt', - 'packages/tools', - 'packages/agent', - 'packages/agent-loop', - 'packages/bash', - 'packages/llm-deepseek', - 'packages/llm-pi-ai', - 'packages/bash-local', - 'packages/tool-bash', - 'packages/invariants', - 'packages/acp', - 'packages/ui-stdio', - 'packages/llm-replay', -] - +// publint every harness package. Packages live at packages// +// (the group dirs — core/llm/bash/… — are pure containers); vendor/ is private +// upstream code and examples/ are not packages, both out of scope. Derived +// from the hierarchy so a new package needs no edit here. const root = resolve(import.meta.dirname, '..') +const packagesRoot = resolve(root, 'packages') + +const packages = readdirSync(packagesRoot, { withFileTypes: true }) + .filter(group => group.isDirectory()) + .flatMap(group => + readdirSync(resolve(packagesRoot, group.name), { withFileTypes: true }) + .filter(pkg => pkg.isDirectory()) + .map(pkg => `packages/${group.name}/${pkg.name}`), + ) + for (const path of packages) { execFileSync('node_modules/.bin/publint', [path], { cwd: root, stdio: 'inherit' }) } diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 5ea2916cd4..2a497289f4 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1,41 +1,41 @@ { "comment": "Maps each ` ```ts type-equiv ` block (by doc + declared symbol) to the source symbol it must match verbatim. verify-type-equiv.ts enforces a 1:1 correspondence: every type-equiv block has exactly one entry here, and every entry resolves to exactly one block. Add an entry when you add a type-equiv block; remove it when you remove the block.", "entries": [ - { "doc": "docs/core-data-structures/core.md", "symbol": "Branded", "source": "packages/llm/src/brand.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "ContentBlockMap", "source": "packages/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "Message", "source": "packages/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "MessageSourceMap", "source": "packages/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "FinishReasonMap", "source": "packages/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "GenerateOptions", "source": "packages/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "GenerateResult", "source": "packages/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "ToolSchema", "source": "packages/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "SessionEvent", "source": "packages/session/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "Agent", "source": "packages/agent/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "Branded", "source": "packages/llm/llm/src/brand.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "ContentBlockMap", "source": "packages/llm/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "Message", "source": "packages/llm/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "MessageSourceMap", "source": "packages/llm/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "FinishReasonMap", "source": "packages/llm/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "GenerateOptions", "source": "packages/llm/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "GenerateResult", "source": "packages/llm/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "ToolSchema", "source": "packages/llm/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "Agent", "source": "packages/core/agent/src/types.ts" }, - { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "StreamChunk", "source": "packages/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "TokenUsage", "source": "packages/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "ContentBlockMap", "source": "packages/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "StreamChunk", "source": "packages/llm/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "TokenUsage", "source": "packages/llm/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "ContentBlockMap", "source": "packages/llm/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEventMap", "source": "packages/session/src/types.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEvent", "source": "packages/session/src/types.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "TurnTriggerMap", "source": "packages/session/src/types.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "TurnEndReasonMap", "source": "packages/session/src/types.ts" }, + { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEventMap", "source": "packages/core/session/src/types.ts" }, + { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" }, + { "doc": "docs/core-data-structures/session.md", "symbol": "TurnTriggerMap", "source": "packages/core/session/src/types.ts" }, + { "doc": "docs/core-data-structures/session.md", "symbol": "TurnEndReasonMap", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionHeader", "source": "packages/session/src/types.ts" }, - { "doc": "docs/core-data-structures/persistence.md", "symbol": "CreateSessionOptions", "source": "packages/session/src/types.ts" }, + { "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionHeader", "source": "packages/core/session/src/types.ts" }, + { "doc": "docs/core-data-structures/persistence.md", "symbol": "CreateSessionOptions", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolDefinition", "source": "packages/tools/src/index.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaProp", "source": "packages/tools/src/schema.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaSpec", "source": "packages/tools/src/schema.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "InferArgs", "source": "packages/tools/src/schema.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecution", "source": "packages/tools/src/index.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionResult", "source": "packages/tools/src/index.ts" }, + { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolDefinition", "source": "packages/core/tools/src/index.ts" }, + { "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaProp", "source": "packages/core/tools/src/schema.ts" }, + { "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaSpec", "source": "packages/core/tools/src/schema.ts" }, + { "doc": "docs/core-data-structures/tools.md", "symbol": "InferArgs", "source": "packages/core/tools/src/schema.ts" }, + { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecution", "source": "packages/core/tools/src/index.ts" }, + { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionResult", "source": "packages/core/tools/src/index.ts" }, - { "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecRequest", "source": "packages/bash/src/types.ts" }, - { "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecSpec", "source": "packages/bash/src/types.ts" }, - { "doc": "docs/core-data-structures/bash.md", "symbol": "BashRunResult", "source": "packages/bash/src/types.ts" }, - { "doc": "docs/core-data-structures/bash.md", "symbol": "CollectedOutput", "source": "packages/bash/src/types.ts" }, - { "doc": "docs/core-data-structures/bash.md", "symbol": "BashTask", "source": "packages/bash/src/types.ts" }, - { "doc": "docs/core-data-structures/bash.md", "symbol": "BashTaskRead", "source": "packages/bash/src/types.ts" } + { "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecRequest", "source": "packages/bash/bash/src/types.ts" }, + { "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecSpec", "source": "packages/bash/bash/src/types.ts" }, + { "doc": "docs/core-data-structures/bash.md", "symbol": "BashRunResult", "source": "packages/bash/bash/src/types.ts" }, + { "doc": "docs/core-data-structures/bash.md", "symbol": "CollectedOutput", "source": "packages/bash/bash/src/types.ts" }, + { "doc": "docs/core-data-structures/bash.md", "symbol": "BashTask", "source": "packages/bash/bash/src/types.ts" }, + { "doc": "docs/core-data-structures/bash.md", "symbol": "BashTaskRead", "source": "packages/bash/bash/src/types.ts" } ] } diff --git a/scripts/verify-md-links.ts b/scripts/verify-md-links.ts index be1a80f86a..57bb824b32 100644 --- a/scripts/verify-md-links.ts +++ b/scripts/verify-md-links.ts @@ -49,6 +49,7 @@ const PATTERNS = [ 'README.md', 'docs/**/*.md', 'packages/*/*.md', + 'packages/*/*/*.md', 'AGENTS.md', 'packages/AGENTS.md', '.agents/skills/**/*.md', diff --git a/scripts/verify-md-wrap.ts b/scripts/verify-md-wrap.ts index dbb1e69235..f8acb26d78 100644 --- a/scripts/verify-md-wrap.ts +++ b/scripts/verify-md-wrap.ts @@ -36,7 +36,7 @@ import type { Nodes } from 'mdast' const root = resolve(import.meta.dirname, '..') /** Files to check: doc-typecheck's scope plus the AGENTS.md pair. */ -const PATTERNS = ['README.md', 'docs/**/*.md', 'packages/*/*.md', 'AGENTS.md', 'packages/AGENTS.md'] +const PATTERNS = ['README.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md', 'AGENTS.md', 'packages/AGENTS.md'] /** A located hard-wrap: a prose paragraph spanning more than one source line. */ interface Violation { diff --git a/scripts/verify-package-paths.ts b/scripts/verify-package-paths.ts new file mode 100644 index 0000000000..368a607a1d --- /dev/null +++ b/scripts/verify-package-paths.ts @@ -0,0 +1,149 @@ +/** + * Doc-sync gate: catch DRIFTED `packages/` references — a path to a + * package that has MOVED, written as prose in Markdown or in a TypeScript + * comment/string. Docs and comments cite package locations by root-relative + * path (`packages/core/tools/src/index.ts`, `see packages/ui/acp`); + * `verify-md-links` only parses Markdown LINK targets and `verify-doc-refs` + * only checks `docs/*.md` tokens, so a `packages/…` path sitting in backtick + * prose or a code comment goes unchecked. The package-hierarchy reorg is the + * motivating case: it moved every package under a `{group}/` folder, so a stale + * `packages/tools` (now `packages/core/tools`) reads fine to a human but points + * at nothing. + * + * The check is drift-scoped, NOT a blanket existence test: a broken + * `packages/` token is a violation ONLY when one of its path segments is + * the directory name of a package that actually exists on disk — i.e. the + * package is real and the path is merely stale. A token naming a package that + * exists NOWHERE (`packages/code-runtime` in a forward-looking proposal, an + * illustrative `packages//` skeleton) is left alone: this gate reports + * MOVED paths, not hypothetical or future ones, so it applies uniformly to + * proposed/implemented/rejected docs without per-lifecycle exclusions. This is + * checker, not fixer: it reports and never rewrites. + * + * Detection is a token scan, NOT an AST walk: package refs live in free prose, + * backticks, and comments. We match `packages/` tokens whose path is made + * of plain path characters, so a glob, a ``, or a `{brace,expansion}` + * terminates the match before those chars and is never probed. + * + * Scope mirrors the other doc gates plus repo-authored TypeScript: Markdown + * across README/docs/packages/AGENTS, and `.ts` under packages/** and + * examples/** (excluding built `lib/`, `*.d.ts`, and vendored upstream source). + * + * Run: `tsx scripts/verify-package-paths.ts`. + */ + +import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs' +import { relative, resolve } from 'node:path' +import { glob } from 'node:fs/promises' + +const root = resolve(import.meta.dirname, '..') + +/** Markdown + repo-authored TypeScript that may cite package paths. */ +const PATTERNS = [ + 'README.md', + 'docs/**/*.md', + 'packages/*/*.md', + 'packages/*/*/*.md', + 'AGENTS.md', + 'packages/AGENTS.md', + 'packages/**/*.ts', + 'examples/**/*.ts', +] + +/** Paths excluded from the scan: built output and vendored upstream source. */ +const isExcluded = (p: string): boolean => + p.includes('/lib/') || p.endsWith('.d.ts') || p.startsWith('vendor/') + +/** + * Directory names of every real package, `packages//`. A broken + * reference is only flagged when one of its segments is in this set — that is + * what scopes the gate to DRIFT (a moved real package) rather than typos or + * not-yet-existing packages named in a proposal. + */ +function realPackageNames(): Set { + const names = new Set() + const pkgRoot = resolve(root, 'packages') + for (const group of readdirSync(pkgRoot, { withFileTypes: true })) { + if (!group.isDirectory()) continue + for (const pkg of readdirSync(resolve(pkgRoot, group.name), { withFileTypes: true })) { + if (pkg.isDirectory()) names.add(pkg.name) + } + } + return names +} + +const packageNames = realPackageNames() + +/** + * Match a `packages/` reference token. The character class is plain path + * characters only, so a glob (`*`), placeholder (`<`, `>`), or brace expansion + * (`{`, `}`, `,`) terminates the match before those chars and is never probed — + * those are patterns, not real paths. A trailing `.`/`/` (e.g. a sentence-ending + * period) is trimmed before the existence check. + */ +const PKG_REF = /\bpackages\/[A-Za-z0-9._/-]+/g + +/** A broken package reference: a stale root-relative `packages/…` path. */ +interface Violation { + file: string + /** 1-based line where the reference appears. */ + line: number + ref: string +} + +/** + * Find every DRIFTED `packages/…` reference in one file: a token that does not + * resolve on disk AND names a real package in one of its segments (so it is a + * moved path, not a typo or a not-yet-existing package). The same real-package + * test also screens out a bare `packages` (no segment) and illustrative + * skeletons whose segment is not a package. + */ +function findViolations(absPath: string): Violation[] { + const file = relative(root, absPath) + const source = readFileSync(absPath, 'utf8') + const out: Violation[] = [] + const lines = source.split('\n') + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + if (line === undefined) continue + for (const m of line.matchAll(PKG_REF)) { + // Trim a trailing path separator or sentence punctuation that the greedy + // class may have swallowed (`packages/core/tools.` / `…/tools/`). + const ref = m[0].replace(/[./]+$/, '') + if (existsSync(resolve(root, ref))) continue + // Only a stale path to a REAL (moved) package is a violation; a segment + // matching a live package name is the drift signal. + const segments = ref.split('/').slice(1) + if (segments.some(seg => packageNames.has(seg))) { + out.push({ file, line: i + 1, ref }) + } + } + } + return out +} + +const all: Violation[] = [] +let checked = 0 +const seen = new Set() +for (const pattern of PATTERNS) { + for await (const match of glob(pattern, { cwd: root })) { + if (isExcluded(match)) continue + // Dedup by real path: the root/packages CLAUDE.md are symlinks to AGENTS.md. + const real = realpathSync(resolve(root, match)) + if (seen.has(real)) continue + seen.add(real) + checked++ + all.push(...findViolations(real)) + } +} + +if (all.length === 0) { + console.log(`verify-package-paths: ${checked} file(s) checked, all packages/* references resolve.`) + process.exit(0) +} + +console.error('verify-package-paths: broken packages/* references found (target does not exist):') +for (const v of all) { + console.error(` ${v.file}:${v.line} ${v.ref}`) +} +process.exit(1) diff --git a/scripts/verify-type-equiv.ts b/scripts/verify-type-equiv.ts index 55250db23f..c93383e40f 100644 --- a/scripts/verify-type-equiv.ts +++ b/scripts/verify-type-equiv.ts @@ -36,7 +36,7 @@ const root = resolve(import.meta.dirname, '..') * added to a doc with NO manifest entry is still discovered here and reported as * an orphan, instead of being silently skipped. */ -const MARKDOWN_GLOBS = ['README.md', 'docs/**/*.md', 'packages/*/*.md'] +const MARKDOWN_GLOBS = ['README.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md'] /** One manifest entry: a documented type-equiv block and its source symbol. */ interface ManifestEntry { diff --git a/tsconfig.base.json b/tsconfig.base.json index d26ab0d56d..04b65eb3a3 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -34,24 +34,19 @@ "@cordisjs/plugin-timer": ["./vendor/timer/src"], "@cordisjs/plugin-hmr": ["./vendor/hmr/src"], "@cordisjs/plugin-logger-console": ["./vendor/logger-console/src"], - "@deepseek-ai/dsh-llm": ["./packages/llm/src"], - "@deepseek-ai/dsh-session": ["./packages/session/src"], - "@deepseek-ai/dsh-session-persistence": ["./packages/session-persistence/src"], - "@deepseek-ai/dsh-session-persistence-jsonl": ["./packages/session-persistence-jsonl/src"], - "@deepseek-ai/dsh-session-persistence-sqlite": ["./packages/session-persistence-sqlite/src"], - "@deepseek-ai/dsh-system-prompt": ["./packages/system-prompt/src"], - "@deepseek-ai/dsh-tools": ["./packages/tools/src"], - "@deepseek-ai/dsh-agent": ["./packages/agent/src"], - "@deepseek-ai/dsh-agent-loop": ["./packages/agent-loop/src"], - "@deepseek-ai/dsh-bash": ["./packages/bash/src"], - "@deepseek-ai/dsh-llm-deepseek": ["./packages/llm-deepseek/src"], - "@deepseek-ai/dsh-llm-pi-ai": ["./packages/llm-pi-ai/src"], - "@deepseek-ai/dsh-bash-local": ["./packages/bash-local/src"], - "@deepseek-ai/dsh-tool-bash": ["./packages/tool-bash/src"], - "@deepseek-ai/dsh-invariants": ["./packages/invariants/src"], - "@deepseek-ai/dsh-acp": ["./packages/acp/src"], - "@deepseek-ai/dsh-ui-stdio": ["./packages/ui-stdio/src"], - "@deepseek-ai/dsh-llm-replay": ["./packages/llm-replay/src"] + // One wildcard maps every @deepseek-ai/dsh- to its source. Package + // dir names are unique across groups, so first-on-disk-wins resolution is + // unambiguous; adding a package under an existing group needs no edit + // here. The build graph's project references (tsconfig.build.json) stay + // explicit — TS project references have no wildcard form. + "@deepseek-ai/dsh-*": [ + "./packages/core/*/src", + "./packages/llm/*/src", + "./packages/bash/*/src", + "./packages/session-persistence/*/src", + "./packages/ui/*/src", + "./packages/support/*/src" + ] } } } diff --git a/tsconfig.build.json b/tsconfig.build.json index 50812b226a..6d353c1796 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -10,23 +10,23 @@ { "path": "./vendor/timer" }, { "path": "./vendor/hmr" }, { "path": "./vendor/logger-console" }, - { "path": "./packages/llm" }, - { "path": "./packages/session" }, - { "path": "./packages/session-persistence" }, - { "path": "./packages/session-persistence-jsonl" }, - { "path": "./packages/session-persistence-sqlite" }, - { "path": "./packages/system-prompt" }, - { "path": "./packages/agent" }, - { "path": "./packages/tools" }, - { "path": "./packages/agent-loop" }, - { "path": "./packages/bash" }, - { "path": "./packages/llm-deepseek" }, - { "path": "./packages/llm-pi-ai" }, - { "path": "./packages/bash-local" }, - { "path": "./packages/tool-bash" }, - { "path": "./packages/invariants" }, - { "path": "./packages/acp" }, - { "path": "./packages/ui-stdio" }, - { "path": "./packages/llm-replay" } + { "path": "./packages/llm/llm" }, + { "path": "./packages/core/session" }, + { "path": "./packages/session-persistence/session-persistence" }, + { "path": "./packages/session-persistence/session-persistence-jsonl" }, + { "path": "./packages/session-persistence/session-persistence-sqlite" }, + { "path": "./packages/core/system-prompt" }, + { "path": "./packages/core/agent" }, + { "path": "./packages/core/tools" }, + { "path": "./packages/core/agent-loop" }, + { "path": "./packages/bash/bash" }, + { "path": "./packages/llm/llm-deepseek" }, + { "path": "./packages/llm/llm-pi-ai" }, + { "path": "./packages/bash/bash-local" }, + { "path": "./packages/bash/tool-bash" }, + { "path": "./packages/support/invariants" }, + { "path": "./packages/ui/acp" }, + { "path": "./packages/support/ui-stdio" }, + { "path": "./packages/support/llm-replay" } ] } diff --git a/tsconfig.test.json b/tsconfig.test.json index 5976d5b43c..a7933dad55 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -6,5 +6,5 @@ "composite": false, "types": ["node"] }, - "include": ["vendor/*/src", "packages/*/src", "packages/*/tests", "examples"] + "include": ["vendor/*/src", "packages/*/*/src", "packages/*/*/tests", "examples"] } diff --git a/tsconfig.typecheck.json b/tsconfig.typecheck.json index 77e2326775..a2b2358a09 100644 --- a/tsconfig.typecheck.json +++ b/tsconfig.typecheck.json @@ -16,25 +16,15 @@ "@cordisjs/plugin-timer": ["./vendor/timer/lib"], "@cordisjs/plugin-hmr": ["./vendor/hmr/lib"], "@cordisjs/plugin-logger-console": ["./vendor/logger-console/lib/shared"], - "@deepseek-ai/dsh-llm": ["./packages/llm/src"], - "@deepseek-ai/dsh-session": ["./packages/session/src"], - "@deepseek-ai/dsh-session-persistence": ["./packages/session-persistence/src"], - "@deepseek-ai/dsh-session-persistence-jsonl": ["./packages/session-persistence-jsonl/src"], - "@deepseek-ai/dsh-session-persistence-sqlite": ["./packages/session-persistence-sqlite/src"], - "@deepseek-ai/dsh-system-prompt": ["./packages/system-prompt/src"], - "@deepseek-ai/dsh-tools": ["./packages/tools/src"], - "@deepseek-ai/dsh-agent": ["./packages/agent/src"], - "@deepseek-ai/dsh-agent-loop": ["./packages/agent-loop/src"], - "@deepseek-ai/dsh-bash": ["./packages/bash/src"], - "@deepseek-ai/dsh-llm-deepseek": ["./packages/llm-deepseek/src"], - "@deepseek-ai/dsh-llm-pi-ai": ["./packages/llm-pi-ai/src"], - "@deepseek-ai/dsh-bash-local": ["./packages/bash-local/src"], - "@deepseek-ai/dsh-tool-bash": ["./packages/tool-bash/src"], - "@deepseek-ai/dsh-invariants": ["./packages/invariants/src"], - "@deepseek-ai/dsh-acp": ["./packages/acp/src"], - "@deepseek-ai/dsh-ui-stdio": ["./packages/ui-stdio/src"], - "@deepseek-ai/dsh-llm-replay": ["./packages/llm-replay/src"] + "@deepseek-ai/dsh-*": [ + "./packages/core/*/src", + "./packages/llm/*/src", + "./packages/bash/*/src", + "./packages/session-persistence/*/src", + "./packages/ui/*/src", + "./packages/support/*/src" + ] } }, - "include": ["packages/*/src", "packages/*/tests", "examples", "scripts"] + "include": ["packages/*/*/src", "packages/*/*/tests", "examples", "scripts"] } diff --git a/tsdown.config.ts b/tsdown.config.ts index 3b9039cc36..6723d514b7 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'tsdown' /** - * JS bundling for all workspace packages (vendor/* + packages/*). + * JS bundling for all workspace packages (vendor and the packages hierarchy). * Declarations are NOT produced here — `tsc -b tsconfig.build.json` owns * .d.ts output (composite project references); hence `dts: false` and * `clean: false` (lib/ already holds tsc's declarations). @@ -10,9 +10,10 @@ import { defineConfig } from 'tsdown' * (schemastery: dual ESM+CJS; logger-console: extra browser entry). */ export default defineConfig({ - // Explicit globs: `workspace: true` would also discover examples/* (any - // package.json), but only vendor/* and packages/* are pnpm workspaces. - workspace: ['vendor/*', 'packages/*'], + // Explicit globs: `workspace: true` would also discover examples (any + // package.json), but only vendor and the packages hierarchy are pnpm + // workspaces. + workspace: ['vendor/*', 'packages/*/*'], entry: ['src/index.ts'], outDir: 'lib', format: ['esm'], diff --git a/vitest.config.ts b/vitest.config.ts index 0878477fb4..91257ea2bd 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -19,14 +19,14 @@ export default defineConfig({ // instead applies the one root map to every importer. plugins: [tsconfigPaths({ projects: ['./tsconfig.test.json'] })], test: { - include: ['packages/*/tests/**/*.spec.ts', 'examples/*/tests/**/*.spec.ts'], + include: ['packages/*/*/tests/**/*.spec.ts', 'examples/*/tests/**/*.spec.ts'], coverage: { provider: 'v8', // Coverage measures OUR runtime source. Types-only files carry no // executable code; vendor/ and examples/ are out of scope (examples are // exercised by the demo smoke test instead). - include: ['packages/*/src/**/*.ts'], - exclude: ['packages/*/src/types.ts'], + include: ['packages/*/*/src/**/*.ts'], + exclude: ['packages/*/*/src/types.ts'], // 100% or it doesn't merge (AGENTS.md: excessive tests are welcome). // Per-file so a well-covered big file can't subsidize a bare one. // Every v8 ignore comment must carry a reason — see AGENTS.md. diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index 9316d660da..f06806d763 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -24,7 +24,7 @@ export default defineConfig({ // through the root tsconfig paths map; the native option cannot do this. plugins: [tsconfigPaths({ projects: ['./tsconfig.test.json'] })], test: { - include: ['packages/*/tests/**/*.e2e.ts', 'examples/*/tests/**/*.e2e.ts'], + include: ['packages/*/*/tests/**/*.e2e.ts', 'examples/*/tests/**/*.e2e.ts'], // Real model calls: generous timeouts, and retries for transient flakes // (the shared internal key hits concurrency quotas). No coverage — the // unit suites own the coverage gate.