diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 87efb87c87..8f75b634b5 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -77,6 +77,8 @@ flowchart LR pkg_session_projection["session-projection"] svc_sessionProjections["ctx.sessionProjections
Session projection units"] pkg_host_apiproxy["host-apiproxy"] + pkg_session_projection_cache["session-projection-cache"] + svc_sessionProjectionCache["ctx.sessionProjectionCache
Persisted projection cache"] svc_tui["ctx.tui
Mounted-terminal interaction service"] pkg_skill["skill"] svc_skills["ctx.skills
Skill provider registry"] @@ -184,6 +186,7 @@ flowchart LR pkg_session_persistence_jsonl --> svc_sessionPersistence pkg_session_persistence_sqlite --> svc_sessionPersistence pkg_session_projection --> svc_sessionProjections + pkg_session_projection_cache --> svc_sessionProjectionCache pkg_session_query --> svc_sessionQuery pkg_session_query_sqlite --> svc_sessionQuery pkg_session_reference --> svc_sessionReferences @@ -261,6 +264,7 @@ flowchart LR svc_sessionPersistence --> pkg_session_query svc_sessionPersistence --> pkg_session_query_sqlite svc_sessionPersistence --> pkg_tool_bash + svc_sessionProjectionCache --> pkg_host_apiproxy svc_sessionProjections --> pkg_host_apiproxy svc_sessionProjections --> pkg_session_title svc_sessionProjections --> pkg_tool_todo @@ -336,6 +340,7 @@ flowchart LR | `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | - | - | Folds logged plan/mode state, flushes user selections at turn boundaries, renders deployment-owned guidance, registers /plan, and keeps the plan-exit schema stable across transitions. | | `ctx.commands` | `core` | [`commands`](../packages/ui/commands) | - | [`tui`](../packages/ui/tui) | - | Plugins register direct human commands; TUI consumes the effective per-agent catalog without sending invocations to the model. | | `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session-projection/session-projection) | - | [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session-title/session-title), [`host-apiproxy`](../packages/host/apiproxy) | - | Domains register state-driven fold units; the eager drive keeps per-session watermark states and api-proxy serves baselines and pushes changed values. | +| `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session-projection/session-projection-cache) | - | [`host-apiproxy`](../packages/host/apiproxy) | - | Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs. | | `ctx.tui` | `bundle` | [`tui`](../packages/ui/tui) | - | - | - | One TUI front door provides a FIFO overlay host; injected plugins receive caller-fiber ownership without access to pi-tui or terminal lifecycle state. | | `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. | | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tui-demo`](../packages/examples/tui-demo) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 676054c215..b5e320f7b7 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1048,6 +1048,27 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:58`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) +## `@deepseek-ai/dsh-session-projection-cache` + +Requires: `storageDomain` · `sessionProjections` · `sessionPersistence` · `sessions` + +```ts config-catalog +/** + * Plugin config. Both throttle triggers are deployment choices with no + * universally correct value, so the composition states them explicitly + * (cordis.yml); the two mandatory write points (`turn/end` and session + * disposal) are policy, not tunables, and always fire. + */ +export interface Config { + /** Committed events per session that force a durable checkpoint write between mandatory points. */ + writeEveryEvents: number + /** Longest time (milliseconds) a dirty checkpoint may stay unwritten between mandatory points. */ + writeIntervalMs: number +} +``` + +Source: [`packages/session-projection/session-projection-cache/src/index.ts:42`](../packages/session-projection/session-projection-cache/src/index.ts) + ## `@deepseek-ai/dsh-session-query-sqlite` Requires: `sessions` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 7bb03af778..a14a365da2 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1063,6 +1063,25 @@ abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEven */ abstract inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> +/** + * Read the stored events from `fromSeq` onward — the read-from-seq + * primitive for read models that resume from a watermark (e.g. a persisted + * projection cache folding only the tail past its checkpoint). Like + * {@link inspect} it is non-mutating and detached: no torn-tail truncation, + * no synthetic closers, no coordinator-state publication; only events from + * the valid contiguous stored prefix are returned, so a torn fragment never + * reaches the caller. `fromSeq` at or beyond the stored prefix returns an + * empty event list (never an error). Backends whose medium can seek by seq + * (SQLite) read only the suffix; sequential media (JSONL, both encodings) + * still parse the whole artifact and skip forward — the primitive bounds + * what is RETURNED and refolded, not every backend's physical read. + * @param id - the persisted session to read. + * @param fromSeq - first event seq to include; a non-negative safe integer. + * @param signal - optional cancellation for queued and backend read work. + * @returns the header and the stored events with `seq >= fromSeq`. + */ +abstract readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> + /** * Lightweight listing from metadata, without a full-log parse. * @param signal - optional cancellation for backend listing work. @@ -1087,6 +1106,60 @@ Types: [SessionEvent](../core-data-structures/core.md) · [SessionHeader](../cor Source: [`packages/session-persistence/session-persistence/src/index.ts:52`](../../packages/session-persistence/session-persistence/src/index.ts) +## `ctx.sessionProjectionCache` — `SessionProjectionCache` + +The persisted projection cache service. Opens the `session_projcache` domain at init, checkpoints live sessions on a throttled write-behind (count/interval triggers from Config) plus two mandatory points — `turn/end` and session disposal (the live-to-cold moment) — and serves the cold-read ladder: cached row, persistence `readFrom` tail, registry `restore`, durable write-back. Every durable write is fail-soft: failures log a warning and the cache self-heals on the next write or cold read. + +```ts cordis-catalog +/** + * The stored checkpoint rows for one session, or an empty checkpoint when + * none is stored. Synchronous from the domain's in-memory state. + * @param id - the session whose cached rows are read. + * @returns the persisted `key → row` checkpoint (possibly empty). + */ +checkpointOf(id: SessionId): ProjectionCheckpoint + +/** + * The zero-I/O listing read: whole values viewed straight from the stored + * rows (version-matching keys only), as stale as the last durable + * checkpoint but never wrong. Synchronous — a listing over every stored + * session touches no log. Fresher paths (the history tail baseline, + * {@link coldSnapshot}) supersede these values whenever a session is + * actually opened. + * @param id - the session whose cached values are viewed. + * @returns whole values per key with a usable row; empty when none stored. + */ +cachedValues(id: SessionId): Partial + +/** + * Durably checkpoint one live session NOW (both mandatory points call + * this; tests and carriers may too). The registry cut is snapshotted at + * this boundary (states are live references), then the whole record is + * replaced. NOT fail-soft — callers on the fail-soft paths contain it. + * @param session - the live session to checkpoint. + * @returns resolution after durability and event emission. + */ +async write(session: Session): Promise + +/** + * Cold-read one persisted session's projections with zero full-log load: + * cached rows + a persistence `readFrom` tail from the registry's restore + * floor, refolded by the registry and written back (fail-soft) so the next + * cold read starts closer. A cache row invalidated by a shrunk log + * (crash-repair truncation) triggers one full re-read from seq 0 — the + * ladder's slow rung, still no crash. Rejects when the session has no + * persisted log (`not found` from the persistence seam). + * @param id - the persisted session to read. + * @param signal - optional cancellation for the persistence reads. + * @returns the snapshot cut at the stored log end. + */ +async coldSnapshot(id: SessionId, signal?: AbortSignal): Promise +``` + +Types: [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) + +Source: [`packages/session-projection/session-projection-cache/src/index.ts:71`](../../packages/session-projection/session-projection-cache/src/index.ts) + ## `ctx.sessionProjections` — `SessionProjectionRegistry` `ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference notifies the change feed with the schema-validated view. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots 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. @@ -1119,11 +1192,81 @@ onChanged(listener: ProjectionChangeListener): () => void * @returns the snapshot; `values` is empty when no unit is registered. */ snapshot(session: Session): ProjectionSnapshot + +/** + * State-level checkpoint of every registered unit for one session, read + * from the watermark cache (missing cells fold lazily over the in-memory + * log). This is the write side of the persisted projection cache: the + * returned rows are the `(key → {stateVersion, observedSeq, state})` part + * of the durable `(sessionId, key, stateVersion, observedSeq, state)` + * rows. Every `state` is a DETACHED structured clone — never the live + * cell reference: the watermark cache is this registry's authoritative + * mutable state, and a caller reaching the live reference could corrupt + * every subsequent snapshot and frame through it (plain JSON by the unit + * contract, so the clone is total). + * @param session - the session whose unit states are checkpointed. + * @returns one row per registered key; empty when no unit is registered. + */ +checkpoint(session: Session): ProjectionCheckpoint + +/** + * The stored seq a {@link restore} tail read over `checkpoint` must start + * at: one event BELOW the lowest usable watermark (a row is usable when + * its `stateVersion` matches the live unit; an absent or mismatched row + * pulls the floor to `0` — that key must refold the full log). The + * one-below anchor is load-bearing: the tail then proves how far the + * stored log still extends, so {@link restore} can detect a log that + * shrank below a row's watermark (crash-repair truncation) instead of + * serving the stale row as current — an empty tail read from the anchor + * yields an end below every watermark and the restore rejects for a full + * re-read. + * @param checkpoint - persisted rows for one session (possibly stale or empty). + * @returns the seq to hand the persistence `readFrom`, or `undefined` + * when no unit is registered (no read needed — {@link restore} would + * serve empty values regardless). + */ +restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined + +/** + * View a checkpoint's rows without any log read: for every registered + * unit whose row's `stateVersion` matches, serve the schema-validated + * `view` of the stored state; mismatched or absent rows leave their key + * absent (a cold or listing consumer treats it as not-yet-available and a + * fuller read path refolds it). The zero-I/O rung of the read ladder — + * values are as stale as their rows, never wrong. + * @param checkpoint - persisted rows for one session (possibly stale or empty). + * @returns whole values per key with a usable row; empty when none. + */ +viewCheckpoint(checkpoint: ProjectionCheckpoint): Partial + +/** + * Cold read: fold every registered unit over a stored log suffix, seeding + * each from its checkpoint row when usable — the one read recipe (cached + * state + forward tail replay + `view`) applied without a live `Session`. + * Call with the events returned by a persistence + * `readFrom(id, restoreFloor(checkpoint))` and that same floor as + * `baseSeq`; the floor's one-below anchor makes the supplied end honest, + * so a shrunk log is detected here. A row is usable iff its + * `stateVersion` matches the live unit, it does not predate `baseSeq` + * (`observedSeq >= baseSeq - 1`), and it does not claim events past the + * supplied end (`observedSeq <= endSeq`); an unusable row is discarded + * and its key refolds from `init` — which is only sound over the full + * log, so a discarded row with `baseSeq > 0` throws (the caller re-reads + * from seq 0, e.g. after a crash-repair truncation shrank the log below + * a row's watermark). + * @param checkpoint - persisted rows for one session (possibly stale or empty). + * @param events - the stored events with `seq >= baseSeq`, in seq order. + * @param baseSeq - the seq `events` starts at (its first event's seq when non-empty). + * @returns the snapshot cut at the supplied log end (`asOfSeq` is the last + * supplied event's seq, `baseSeq - 1` for an empty tail) plus the + * refreshed checkpoint rows at that cut, ready for a durable write-back. + */ +restore(checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint } ``` -Types: [Session](../core-data-structures/session.md) +Types: [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) -Source: [`packages/session-projection/session-projection/src/index.ts:136`](../../packages/session-projection/session-projection/src/index.ts) +Source: [`packages/session-projection/session-projection/src/index.ts:157`](../../packages/session-projection/session-projection/src/index.ts) ## `ctx.sessionQuery` — `SessionQueryService` (abstract seam) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index dabfeb2517..55210a40b3 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -32,8 +32,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:169`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:58`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:103`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | | `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:230`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:244`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 30ff0534ca..33179837b7 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -524,6 +524,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'abstract inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }>', jsDoc: '/**\n * Inspect a header and its valid contiguous stored prefix without repairing\n * a torn tail, closing an interrupted turn, or publishing coordinator state.\n * This read is serialized with writes for the same id and returns detached\n * values with upgraded, deeply frozen identified messages, so observers\n * cannot mutate message identity/content or backend-owned state. Other\n * malformed messages reject.\n * @param id - the persisted session to inspect.\n * @param signal - optional cancellation for queued and backend read work.\n * @returns the header and valid stored event prefix exactly as observed.\n */', }, + { + signature: 'abstract readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }>', + jsDoc: '/**\n * Read the stored events from `fromSeq` onward — the read-from-seq\n * primitive for read models that resume from a watermark (e.g. a persisted\n * projection cache folding only the tail past its checkpoint). Like\n * {@link inspect} it is non-mutating and detached: no torn-tail truncation,\n * no synthetic closers, no coordinator-state publication; only events from\n * the valid contiguous stored prefix are returned, so a torn fragment never\n * reaches the caller. `fromSeq` at or beyond the stored prefix returns an\n * empty event list (never an error). Backends whose medium can seek by seq\n * (SQLite) read only the suffix; sequential media (JSONL, both encodings)\n * still parse the whole artifact and skip forward — the primitive bounds\n * what is RETURNED and refolded, not every backend\'s physical read.\n * @param id - the persisted session to read.\n * @param fromSeq - first event seq to include; a non-negative safe integer.\n * @param signal - optional cancellation for queued and backend read work.\n * @returns the header and the stored events with `seq >= fromSeq`.\n */', + }, { signature: 'abstract list(signal?: AbortSignal): Promise', jsDoc: '/**\n * Lightweight listing from metadata, without a full-log parse.\n * @param signal - optional cancellation for backend listing work.\n * @returns one header per materialized session.\n */', @@ -534,6 +538,28 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'sessionProjectionCache', + summary: 'The persisted projection cache service.', + methods: [ + { + signature: 'checkpointOf(id: SessionId): ProjectionCheckpoint', + jsDoc: '/**\n * The stored checkpoint rows for one session, or an empty checkpoint when\n * none is stored. Synchronous from the domain\'s in-memory state.\n * @param id - the session whose cached rows are read.\n * @returns the persisted `key → row` checkpoint (possibly empty).\n */', + }, + { + signature: 'cachedValues(id: SessionId): Partial', + jsDoc: '/**\n * The zero-I/O listing read: whole values viewed straight from the stored\n * rows (version-matching keys only), as stale as the last durable\n * checkpoint but never wrong. Synchronous — a listing over every stored\n * session touches no log. Fresher paths (the history tail baseline,\n * {@link coldSnapshot}) supersede these values whenever a session is\n * actually opened.\n * @param id - the session whose cached values are viewed.\n * @returns whole values per key with a usable row; empty when none stored.\n */', + }, + { + signature: 'async write(session: Session): Promise', + jsDoc: '/**\n * Durably checkpoint one live session NOW (both mandatory points call\n * this; tests and carriers may too). The registry cut is snapshotted at\n * this boundary (states are live references), then the whole record is\n * replaced. NOT fail-soft — callers on the fail-soft paths contain it.\n * @param session - the live session to checkpoint.\n * @returns resolution after durability and event emission.\n */', + }, + { + signature: 'async coldSnapshot(id: SessionId, signal?: AbortSignal): Promise', + jsDoc: '/**\n * Cold-read one persisted session\'s projections with zero full-log load:\n * cached rows + a persistence `readFrom` tail from the registry\'s restore\n * floor, refolded by the registry and written back (fail-soft) so the next\n * cold read starts closer. A cache row invalidated by a shrunk log\n * (crash-repair truncation) triggers one full re-read from seq 0 — the\n * ladder\'s slow rung, still no crash. Rejects when the session has no\n * persisted log (`not found` from the persistence seam).\n * @param id - the persisted session to read.\n * @param signal - optional cancellation for the persistence reads.\n * @returns the snapshot cut at the stored log end.\n */', + }, + ], + }, { key: 'sessionProjections', summary: '`ctx.sessionProjections`: the projection unit table and its drive.', @@ -550,6 +576,22 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'snapshot(session: Session): ProjectionSnapshot', jsDoc: '/**\n * One consistent cut over every registered unit for one session, read from\n * the watermark cache (missing cells fold lazily over the in-memory log).\n * Fully synchronous — every value and `asOfSeq` reflect the same log\n * position. Each value passes its unit\'s schema before leaving.\n * @param session - the session whose projection values are read.\n * @returns the snapshot; `values` is empty when no unit is registered.\n */', }, + { + signature: 'checkpoint(session: Session): ProjectionCheckpoint', + jsDoc: '/**\n * State-level checkpoint of every registered unit for one session, read\n * from the watermark cache (missing cells fold lazily over the in-memory\n * log). This is the write side of the persisted projection cache: the\n * returned rows are the `(key → {stateVersion, observedSeq, state})` part\n * of the durable `(sessionId, key, stateVersion, observedSeq, state)`\n * rows. Every `state` is a DETACHED structured clone — never the live\n * cell reference: the watermark cache is this registry\'s authoritative\n * mutable state, and a caller reaching the live reference could corrupt\n * every subsequent snapshot and frame through it (plain JSON by the unit\n * contract, so the clone is total).\n * @param session - the session whose unit states are checkpointed.\n * @returns one row per registered key; empty when no unit is registered.\n */', + }, + { + signature: 'restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined', + jsDoc: '/**\n * The stored seq a {@link restore} tail read over `checkpoint` must start\n * at: one event BELOW the lowest usable watermark (a row is usable when\n * its `stateVersion` matches the live unit; an absent or mismatched row\n * pulls the floor to `0` — that key must refold the full log). The\n * one-below anchor is load-bearing: the tail then proves how far the\n * stored log still extends, so {@link restore} can detect a log that\n * shrank below a row\'s watermark (crash-repair truncation) instead of\n * serving the stale row as current — an empty tail read from the anchor\n * yields an end below every watermark and the restore rejects for a full\n * re-read.\n * @param checkpoint - persisted rows for one session (possibly stale or empty).\n * @returns the seq to hand the persistence `readFrom`, or `undefined`\n * when no unit is registered (no read needed — {@link restore} would\n * serve empty values regardless).\n */', + }, + { + signature: 'viewCheckpoint(checkpoint: ProjectionCheckpoint): Partial', + jsDoc: '/**\n * View a checkpoint\'s rows without any log read: for every registered\n * unit whose row\'s `stateVersion` matches, serve the schema-validated\n * `view` of the stored state; mismatched or absent rows leave their key\n * absent (a cold or listing consumer treats it as not-yet-available and a\n * fuller read path refolds it). The zero-I/O rung of the read ladder —\n * values are as stale as their rows, never wrong.\n * @param checkpoint - persisted rows for one session (possibly stale or empty).\n * @returns whole values per key with a usable row; empty when none.\n */', + }, + { + signature: 'restore(checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }', + jsDoc: '/**\n * Cold read: fold every registered unit over a stored log suffix, seeding\n * each from its checkpoint row when usable — the one read recipe (cached\n * state + forward tail replay + `view`) applied without a live `Session`.\n * Call with the events returned by a persistence\n * `readFrom(id, restoreFloor(checkpoint))` and that same floor as\n * `baseSeq`; the floor\'s one-below anchor makes the supplied end honest,\n * so a shrunk log is detected here. A row is usable iff its\n * `stateVersion` matches the live unit, it does not predate `baseSeq`\n * (`observedSeq >= baseSeq - 1`), and it does not claim events past the\n * supplied end (`observedSeq <= endSeq`); an unusable row is discarded\n * and its key refolds from `init` — which is only sound over the full\n * log, so a discarded row with `baseSeq > 0` throws (the caller re-reads\n * from seq 0, e.g. after a crash-repair truncation shrank the log below\n * a row\'s watermark).\n * @param checkpoint - persisted rows for one session (possibly stale or empty).\n * @param events - the stored events with `seq >= baseSeq`, in seq order.\n * @param baseSeq - the seq `events` starts at (its first event\'s seq when non-empty).\n * @returns the snapshot cut at the supplied log end (`asOfSeq` is the last\n * supplied event\'s seq, `baseSeq - 1` for an empty tail) plus the\n * refreshed checkpoint rows at that cut, ready for a durable write-back.\n */', + }, ], }, { @@ -1855,6 +1897,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ProjectionChangeListener', declaration: 'export type ProjectionChangeListener = (session: Session, key: Extract, value: unknown, seq: number) => void;', }, + { + name: 'ProjectionCheckpoint', + declaration: 'export type ProjectionCheckpoint = Record;', + }, + { + name: 'ProjectionCheckpointRow', + declaration: 'export interface ProjectionCheckpointRow {\n stateVersion: number;\n observedSeq: number;\n state: unknown;\n}', + }, { name: 'ProjectionDefinition', declaration: 'export interface ProjectionDefinition {\n key: K;\n schema: ZodType;\n init(): S;\n apply(state: S, event: SessionEvent): S;\n view(state: S): SessionProjectionMap[K];\n stateVersion: number;\n}', diff --git a/packages/session-projection/session-projection-cache/README.i18n.yaml b/packages/session-projection/session-projection-cache/README.i18n.yaml index 57df7abd74..43e9dcc641 100644 --- a/packages/session-projection/session-projection-cache/README.i18n.yaml +++ b/packages/session-projection/session-projection-cache/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session-projection/session-projection-cache/README.md -README.md: 81d6094c19538f5559becbff51b00a7bfacb4206 -README.zh.md: f403ac1a897f5bac840b43dab6fc0f98bf7b7a46 +README.md: 662504aad83824546cd79977f3d87dbd06281038 +README.zh.md: 963a1dc07388e2d881902a942872cf94bf734d1f diff --git a/packages/session-projection/session-projection-cache/README.md b/packages/session-projection/session-projection-cache/README.md index 81d6094c19..662504aad8 100644 --- a/packages/session-projection/session-projection-cache/README.md +++ b/packages/session-projection/session-projection-cache/README.md @@ -43,17 +43,11 @@ Injects `storageDomain`, `sessionProjections`, `sessionPersistence`, `sessions`. ## Model Experience -### What the model sees +None, as the cache only persists and restores host-side read models of already-logged session state and touches no prompt, message, schema, stream, or tool result. -Nothing. The cache is a host read-model accelerator; no prompt, schema, or tool surface. +#### KV Cache effect -### Token effect - -Zero. - -### KV Cache effect - -None — no request content changes. +None; the cache never assembles or sends provider requests. ## Known Limitations and Deferred Work diff --git a/packages/session-projection/session-projection-cache/README.zh.md b/packages/session-projection/session-projection-cache/README.zh.md index f403ac1a89..963a1dc073 100644 --- a/packages/session-projection/session-projection-cache/README.zh.md +++ b/packages/session-projection/session-projection-cache/README.zh.md @@ -43,17 +43,11 @@ ## 模型体验 -### 模型看到什么 +无,因为缓存只持久化并恢复 host 侧的、由已入日志会话状态派生的读模型,不触碰任何提示词、消息、schema、流或工具结果。 -什么都看不到。缓存是 host 侧读模型加速器;没有提示词、schema 或工具表面。 +#### KV 缓存影响 -### Token 影响 - -零。 - -### KV 缓存影响 - -无——不改变任何请求内容。 +无;缓存从不组装或发送提供方请求。 ## 已知局限与延后工作 diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index f35c5b4547..43f48019c5 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -209,6 +209,7 @@ const FOUNDATION_TYPE_NAMES = new Set([ 'AsyncIterable', 'Context', 'Error', + 'Partial', 'Pick', 'Promise', 'Readonly', @@ -237,6 +238,7 @@ const TYPE_LINK_EXEMPTIONS: Readonly> = { SessionProjectionMap: 'merge-extensible projection key map is owned by packages/session-projection/session-projection/src/types.ts', ProjectionChangeListener: 'change-feed listener contract is owned by packages/session-projection/session-projection/src/index.ts', ProjectionSnapshot: 'watermark snapshot shape is owned by packages/session-projection/session-projection/src/index.ts', + ProjectionCheckpoint: 'persisted checkpoint row map is owned by packages/session-projection/session-projection/src/index.ts', CommandExecution: 'executor return contract is owned by packages/ui/commands/src/index.ts', InvariantInstaller: 'service-local contribution contract is owned by packages/support/invariants/README.md', LocaleDict: 'service-local dictionary shape is owned by packages/client/i18n/src/index.ts', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 67dbee1e77..075ca3a69e 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -244,6 +244,14 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['tool-todo', 'session-title', 'host-apiproxy'], note: 'Domains register state-driven fold units; the eager drive keeps per-session watermark states and api-proxy serves baselines and pushes changed values.', }, + { + key: 'sessionProjectionCache', + pkg: 'session-projection-cache', + title: 'Persisted projection cache', + mode: 'core', + consumers: ['host-apiproxy'], + note: 'Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs.', + }, { key: 'tui', pkg: 'tui', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 55108269a3..b5ada214a3 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -89,6 +89,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/sdk/sdk-protocol': { kind: 'none', reason: 'Client-facing wire library; the runtime plugins behind the serving entry own the model surface.' }, '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-projection/session-projection-cache': { kind: 'none', reason: 'The persisted cache accelerates host-side cold reads of projection 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.' },