From fa331c63993db918df572b003bc8e08635824e2a Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:06:20 +0800 Subject: [PATCH] feat: dsh-session-projection seam package (ctx.sessionProjections registry) --- packages/README.md | 1 + packages/README.zh.md | 1 + packages/session-projection/README.md | 7 ++ .../session-projection/README.md | 39 ++++++ .../session-projection/package.json | 42 +++++++ .../session-projection/src/index.ts | 112 ++++++++++++++++++ .../session-projection/src/invariant.ts | 35 ++++++ .../session-projection/tests/registry.spec.ts | 83 +++++++++++++ .../session-projection/tsconfig.json | 24 ++++ pnpm-lock.yaml | 16 +++ .../verify-package-readme-model-experience.ts | 1 + tsconfig.base.json | 2 + tsconfig.host.json | 1 + 13 files changed, 364 insertions(+) create mode 100644 packages/session-projection/README.md create mode 100644 packages/session-projection/session-projection/README.md create mode 100644 packages/session-projection/session-projection/package.json create mode 100644 packages/session-projection/session-projection/src/index.ts create mode 100644 packages/session-projection/session-projection/src/invariant.ts create mode 100644 packages/session-projection/session-projection/tests/registry.spec.ts create mode 100644 packages/session-projection/session-projection/tsconfig.json diff --git a/packages/README.md b/packages/README.md index d16e395a42..65d5c38a39 100644 --- a/packages/README.md +++ b/packages/README.md @@ -35,6 +35,7 @@ Packages live at `packages///`; groups are containers, while names r | [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface | | [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface | | [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface | +| [`session-projection/`](session-projection/README.md) | Session-projection seam: domain host plugins serve whole current values of log-derived per-session state to client carriers | Product — stable surface | | [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable surface | | [`session-title/`](session-title/README.md) | Log-backed session titles: fallback service, shared LLM policy, and opt-in providers | Product — stable surface | | [`telemetry/`](telemetry/README.md) | Session reporting: capture/redact seam, OTel backend | Product — stable surface | diff --git a/packages/README.zh.md b/packages/README.zh.md index 3fb4181ce7..31b8813513 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -35,6 +35,7 @@ | [`cordis/`](cordis/README.md) | 自指运行时工具集:检查实时运行时的插件与服务,挂载/卸载模型所写插件([设计](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | 产品:稳定表面 | | [`hooks/`](hooks/README.md) | 钩子桥接 + 共享 Claude Code/Codex 协议格式库 | 产品:稳定表面 | | [`session-persistence/`](session-persistence/README.md) | 持久化能力系列:seam + JSONL/SQLite 后端 | 产品:稳定表面 | +| [`session-projection/`](session-projection/README.md) | 会话投影缝:域 host 插件向客户端载体供给日志衍生的每会话状态完整当前值 | 产品:稳定表面 | | [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定表面 | | [`session-title/`](session-title/README.md) | 日志支撑的会话标题:回退服务、共享 LLM 策略和选用提供方 | 产品:稳定表面 | | [`telemetry/`](telemetry/README.md) | 会话上报:捕获/脱敏 seam、OTel 后端 | 产品:稳定表面 | diff --git a/packages/session-projection/README.md b/packages/session-projection/README.md new file mode 100644 index 0000000000..1d1d1f7945 --- /dev/null +++ b/packages/session-projection/README.md @@ -0,0 +1,7 @@ +# session-projection/ + +Session-projection capability family: the seam through which domain host plugins serve whole current values of log-derived per-session state to client carriers. + +| Package | ctx key | Role | +|---|---|---| +| [`session-projection`](session-projection/README.md) | `sessionProjections` | The interface package: the merge-extensible `SessionProjectionMap` type table, the `ProjectionProvider` contract, and the provider registry carriers walk synchronously | diff --git a/packages/session-projection/session-projection/README.md b/packages/session-projection/session-projection/README.md new file mode 100644 index 0000000000..af7c9622bb --- /dev/null +++ b/packages/session-projection/session-projection/README.md @@ -0,0 +1,39 @@ +# @deepseek-ai/dsh-session-projection + +Session-projection seam. It owns `ctx.sessionProjections`, the registry through which a domain host plugin serves the whole current value of its log-derived per-session state, and through which a carrier (the api-proxy history tail page today; TUI/ACP/headless consumers later) reads every registered value in one synchronous, seq-consistent cut. Design authority: the [session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md). + +## Service: `SessionProjectionRegistry` (ctx key: `sessionProjections`) + +### Public API + +- `ctx.sessionProjections.register(provider): () => void` Register one domain's provider. Duplicate keys throw; the registration is an effect on the calling fiber, so an unloaded domain plugin's key disappears from subsequent walks (clients read that as capability absence). +- `ctx.sessionProjections.entries(): AnyProjectionProvider[]` Snapshot the registered providers in registration order — the carrier walk surface. + +### Key Types + +- `SessionProjectionMap` — the single merge-extensible type table for the whole chain (host provider, wire block, client cell, React hook). Values are wire-JSON whole values; rendering belongs to the slot system, never this layer. +- `ProjectionProvider` — `{ key, schema, get(agent) }`. `schema` validates the payload before it leaves the host; `get` returns the current whole value and MUST be synchronous. + +## Contract + +- **Whole-value rule (load-bearing).** A state-carrying log event MUST carry the complete post-change state, never a delta, so the client fold is last-wins by seq. A future domain logging deltas breaks last-wins silently — do not. +- **Synchronous `get`.** Carriers read `session.seq` and every provider value with no await between them; that is what makes `asOfSeq` one consistent cut across all keys. An accidentally-async `get` returns a Promise, which fails the carrier-side `schema.parse` loudly. +- **Full-log view.** `get` runs against the host's full in-memory log (`agent.session.events`); pagination exists only in the history slice served to clients. A last-wins domain may backscan (first hit from the tail terminates); an expensive fold keeps an incremental cache keyed by observed seq. +- **Optional seam.** Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected; carriers use `ctx.get('sessionProjections')` and omit the block entirely when the registry is absent. + +## Role + +This is the interface package of the capability-seam split: domain host plugins (e.g. `dsh-tool-todo`) contribute providers, carriers (`dsh-host-apiproxy`) consume the walk surface, and neither knows the other. + +## Model Experience + +None, as the registry only serves client-facing read models of already-logged session state and touches no prompt, message, schema, stream, or tool result. + +#### KV Cache effect + +None; projections never assemble or send provider requests. + +## Known Limitations and Deferred Work + +- **Every tail page carries every registered key** — there is no per-key opt-out or lazy-key request shape yet; acceptable while values are UI-scale whole states (a todo list, a goal snapshot), revisit if a domain's value grows large. +- **Synchronous-`get` discipline is only partially mechanical** — the carrier's `schema.parse` rejects a returned Promise, but a provider that blocks or reads torn non-session state is a review concern; the invariant companion documents why no runtime check exists. diff --git a/packages/session-projection/session-projection/package.json b/packages/session-projection/session-projection/package.json new file mode 100644 index 0000000000..8066645272 --- /dev/null +++ b/packages/session-projection/session-projection/package.json @@ -0,0 +1,42 @@ +{ + "name": "@deepseek-ai/dsh-session-projection", + "description": "Session-projection seam: the merge-extensible projection type table, the provider contract, and the ctx.sessionProjections registry serving whole current values of log-derived per-session state", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "dependencies": { + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/session-projection/session-projection/src/index.ts b/packages/session-projection/session-projection/src/index.ts new file mode 100644 index 0000000000..41d2793166 --- /dev/null +++ b/packages/session-projection/session-projection/src/index.ts @@ -0,0 +1,112 @@ +/** + * Session-projection seam: the merge-extensible `SessionProjectionMap` type + * table, the `ProjectionProvider` contract, and the `ctx.sessionProjections` + * registry. Domain host plugins contribute whole current values of + * log-derived per-session state; carriers (api-proxy history tail page, and + * future TUI/ACP consumers) walk the registry synchronously so every key and + * the accompanying `asOfSeq` form one consistent cut. Neither side knows the + * other (capability-seam three-way split). + * + * Whole-value rule (load-bearing): a state-carrying log event MUST carry the + * complete post-change state, never a delta, so the client-side fold is + * last-wins by seq. See the session-projection RFC + * (.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md). + * + * @module @deepseek-ai/dsh-session-projection + */ + +import { Context, Service } from 'cordis' +import type { ZodType } from 'zod' +import type { Agent } from '@deepseek-ai/dsh-agent' + +declare module 'cordis' { + interface Context { + sessionProjections: SessionProjectionRegistry + } +} + +/** + * The single projection type table for the whole chain (host provider, wire + * block, client cell, React hook). Domain packages merge their key here via + * declaration merging; values are wire-JSON whole values. How a value is + * rendered is the slot system's business, never this layer's. + */ +export interface SessionProjectionMap {} + +/** + * One domain's host-side contribution: the current whole value of its + * log-derived per-session state. + */ +export interface ProjectionProvider { + /** The projection key this provider owns (its `SessionProjectionMap` entry). */ + key: K + /** Validates the payload before it leaves the host (carriers parse each value through this). */ + schema: ZodType + /** + * Return the current whole value for one agent's session. MUST be + * synchronous — carriers read `session.seq` and every provider value with no + * await between them, so an async provider would tear the consistency cut + * (an accidentally returned Promise fails the carrier's `schema.parse` + * loudly). Runs against the host's full in-memory log + * (`agent.session.events`): a last-wins domain may backscan from the tail; a + * domain with an expensive fold keeps an incremental cache keyed by observed + * seq. + * @param agent - the agent whose session state is projected. + * @returns the whole current value for this provider's key. + */ + get(agent: Agent): SessionProjectionMap[K] +} + +/** Union-typed view of a registered provider, as seen by carriers walking the table. */ +export type AnyProjectionProvider = ProjectionProvider + +/** + * `ctx.sessionProjections`: the projection provider table. Registration is an + * effect (disposer rides the calling fiber): an unloaded domain plugin's key + * disappears from subsequent walks and clients read it as capability absence. + * Duplicate keys throw. Domain plugins register under + * `ctx.inject(['sessionProjections'], …)` so headless assemblies without the + * registry stay unaffected. + */ +export class SessionProjectionRegistry extends Service { + private readonly providers = new Map() + + /** + * Create and install the registry as `ctx.sessionProjections`. + * @param ctx - Cordis context that owns the service. + */ + constructor(ctx: Context) { + super(ctx, 'sessionProjections') + } + + /** + * Register one domain's provider. The registration is an effect on the + * calling context's fiber: disposing the fiber (or calling the returned + * disposer) removes the key from subsequent walks. + * @param provider - key, boundary schema, and synchronous whole-value read. + * @returns the exact disposer that unregisters this provider. + */ + register(provider: ProjectionProvider): () => void { + const dispose = this.ctx.effect(function* (this: SessionProjectionRegistry) { + if (this.providers.has(provider.key)) { + throw new Error(`session projection key ${JSON.stringify(provider.key)} is already registered`) + } + this.providers.set(provider.key, provider) + yield () => { + this.providers.delete(provider.key) + } + }.bind(this), 'sessionProjections.register()') + return () => void dispose() + } + + /** + * Snapshot the registered providers in registration order — the carrier + * walk surface. Each provider carries its own `key` and `schema`. + * @returns the providers registered at this moment. + */ + entries(): AnyProjectionProvider[] { + return [...this.providers.values()] + } +} + +export default SessionProjectionRegistry diff --git a/packages/session-projection/session-projection/src/invariant.ts b/packages/session-projection/session-projection/src/invariant.ts new file mode 100644 index 0000000000..36453d72cf --- /dev/null +++ b/packages/session-projection/session-projection/src/invariant.ts @@ -0,0 +1,35 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-session-projection`. + * @module @deepseek-ai/dsh-session-projection/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-session-projection' + +/** Cordis companion plugin name. */ +export const name = 'session-projection-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the registry's own contracts (duplicate-key rejection, + * effect-tied removal) are enforced synchronously at the register() boundary, + * and the served-block relation — every served key has a live registration — + * lives on each carrier's wire path, which emits no cordis event this + * companion could observe; carrier specs assert it instead. Synchronous-`get` + * discipline is enforced as far as practical by the carrier's `schema.parse` + * (a Promise value fails loudly). + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/session-projection/session-projection/tests/registry.spec.ts b/packages/session-projection/session-projection/tests/registry.spec.ts new file mode 100644 index 0000000000..d4f193b6cc --- /dev/null +++ b/packages/session-projection/session-projection/tests/registry.spec.ts @@ -0,0 +1,83 @@ +/** + * SessionProjectionRegistry behavior: registration surfaces through entries(), + * duplicate keys fail loud, and both the returned disposer and the owning + * fiber's disposal remove the key (HMR safety). + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { z } from 'zod' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import type { ProjectionProvider } from '@deepseek-ai/dsh-session-projection' + +declare module '@deepseek-ai/dsh-session-projection' { + interface SessionProjectionMap { + 'test/alpha': { value: string } + 'test/beta': number + } +} + +const alphaProvider = (value: string): ProjectionProvider<'test/alpha'> => ({ + key: 'test/alpha', + schema: z.object({ value: z.string() }), + get: () => ({ value }), +}) + +async function harness(): Promise { + const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) + return ctx +} + +describe('SessionProjectionRegistry', () => { + it('registers a provider, walks it via entries(), and serves get()', async () => { + const ctx = await harness() + ctx.sessionProjections.register(alphaProvider('a')) + const entries = ctx.sessionProjections.entries() + expect(entries.map(entry => entry.key)).toEqual(['test/alpha']) + const provider = entries[0] as ProjectionProvider<'test/alpha'> + expect(provider.get({} as Agent)).toEqual({ value: 'a' }) + expect(provider.schema.parse({ value: 'a' })).toEqual({ value: 'a' }) + }) + + it('preserves registration order across keys', async () => { + const ctx = await harness() + ctx.sessionProjections.register(alphaProvider('a')) + ctx.sessionProjections.register({ + key: 'test/beta', + schema: z.number(), + get: () => 1, + }) + expect(ctx.sessionProjections.entries().map(entry => entry.key)).toEqual(['test/alpha', 'test/beta']) + }) + + it('throws on a duplicate key and keeps the first registration', async () => { + const ctx = await harness() + ctx.sessionProjections.register(alphaProvider('first')) + expect(() => ctx.sessionProjections.register(alphaProvider('second'))) + .toThrow(/"test\/alpha" is already registered/) + const entries = ctx.sessionProjections.entries() + expect(entries).toHaveLength(1) + expect((entries[0] as ProjectionProvider<'test/alpha'>).get({} as Agent)).toEqual({ value: 'first' }) + }) + + it('register() returns a disposer that removes the key and frees it for re-registration', async () => { + const ctx = await harness() + const dispose = ctx.sessionProjections.register(alphaProvider('a')) + dispose() + expect(ctx.sessionProjections.entries()).toEqual([]) + ctx.sessionProjections.register(alphaProvider('again')) + expect(ctx.sessionProjections.entries()).toHaveLength(1) + }) + + it('removes a registration when its owning fiber unloads (HMR safety)', async () => { + const ctx = await harness() + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + inner.sessionProjections.register(alphaProvider('scoped')) + }, { inject: ['sessionProjections'] })) + expect(ctx.sessionProjections.entries()).toHaveLength(1) + await fiber.dispose() + expect(ctx.sessionProjections.entries()).toEqual([]) + }) +}) diff --git a/packages/session-projection/session-projection/tsconfig.json b/packages/session-projection/session-projection/tsconfig.json new file mode 100644 index 0000000000..8b31c9f501 --- /dev/null +++ b/packages/session-projection/session-projection/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e098d6eba..7795770e40 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3326,6 +3326,22 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/session-projection/session-projection: + dependencies: + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/session-query/session-query: devDependencies: '@deepseek-ai/dsh-brand': diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 42449f4bf9..f407c9585a 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -85,6 +85,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/sdk/helper': { kind: 'none', reason: 'The project domain edits files and registers no live agent or model surface.' }, 'packages/sdk/scripts': { kind: 'indirect', reason: 'The launcher delegates model context to the loaded project plugin tree.' }, 'packages/sdk/telemetry': { kind: 'none', reason: 'The launcher-side reporter sends developer-cycle telemetry and registers no live agent or model surface.' }, + 'packages/session-projection/session-projection': { kind: 'none', reason: 'The projection registry serves client-facing read models of already-logged session state and registers no model surface.' }, 'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' }, 'packages/session-query/session-query-sqlite': { kind: 'none', reason: 'The search backend returns hits only to callers and registers no model surface.' }, 'packages/telemetry/session-telemetry': { kind: 'none', reason: 'The seam observes the session stream and hands redacted copies outward; it registers no model surface.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index fdf890e336..46b9e07203 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -74,6 +74,7 @@ "./packages/sandbox/*/src/invariant.ts", "./packages/hooks/*/src/invariant.ts", "./packages/session-persistence/*/src/invariant.ts", + "./packages/session-projection/*/src/invariant.ts", "./packages/session-query/*/src/invariant.ts", "./packages/telemetry/*/src/invariant.ts", "./packages/acp/*/src/invariant.ts", @@ -153,6 +154,7 @@ "./packages/sandbox/*/src", "./packages/hooks/*/src", "./packages/session-persistence/*/src", + "./packages/session-projection/*/src", "./packages/session-query/*/src", "./packages/session-title/*/src", "./packages/telemetry/*/src", diff --git a/tsconfig.host.json b/tsconfig.host.json index 545f326801..525608fef3 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -55,6 +55,7 @@ { "path": "./packages/session-persistence/session-checkpoint-policy" }, { "path": "./packages/session-persistence/session-persistence-jsonl" }, { "path": "./packages/session-persistence/session-persistence-sqlite" }, + { "path": "./packages/session-projection/session-projection" }, { "path": "./packages/session-query/session-query" }, { "path": "./packages/session-query/session-query-sqlite" }, { "path": "./packages/session-query/tool-session-query" },