From 9ae3e1a9ad4e220d04516651bdd3361cccb63148 Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Tue, 28 Jul 2026 02:11:31 +0800
Subject: [PATCH] docs: regenerate catalogs and graphs for the projection-cache
seam; classify its types
gen-cordis-catalog/api, config and persistence catalogs, and doc graphs
regenerated over the new sessionProjectionCache service and the registry's
checkpoint faces. Classifications: ProjectionCheckpoint joins the type-link
exemptions (owned by the projection package source), Partial joins the
foundation names, the cache service gets its capability-seam role row, and
the package takes the one-sentence Model Experience contract (host-side
read-model accelerator, no model surface).
---
docs/capability-seams.md | 5 +
docs/config-catalog.md | 21 +++
docs/cordis-catalog/services.md | 147 +++++++++++++++++-
docs/event-producer-consumer.md | 4 +-
.../cordis/tool-cordis/src/api-catalog.ts | 50 ++++++
.../session-projection-cache/README.i18n.yaml | 4 +-
.../session-projection-cache/README.md | 12 +-
.../session-projection-cache/README.zh.md | 12 +-
scripts/gen-cordis-catalog.ts | 2 +
scripts/gen-doc-graphs.ts | 8 +
.../verify-package-readme-model-experience.ts | 1 +
11 files changed, 242 insertions(+), 24 deletions(-)
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.' },