Files
imccyu abc0e7092b Merge remote-tracking branch 'origin/release/dsh-0.2.2' into merge/release-dsh-0.2.2-rc.1-to-master
# Conflicts:
#	packages/client/ui-agent-team/package.json
#	packages/client/ui-brand-official/package.json
#	packages/client/ui-open-in-app/package.json
#	packages/client/ui-settings-web-search/package.json
#	packages/experimental/agent-team-profile/package.json
#	packages/team/agent-team/package.json
#	packages/web/web-search-exa/package.json
#	packages/web/web-search-perplexity/package.json
2026-11-04 15:59:50 +08:00
..
2026-11-04 15:50:06 +08:00

description, kind
description kind
The session-projection registry for developers serving whole current values of log-derived per-session state to client carriers, and for maintainers of the drive contract. package-reference

@deepseek-ai/dsh-session-projection

English | 中文

Summary

Use dsh-session-projection when clients need current per-session state—such as todos, goals, or conversation statistics—without replaying the raw event log. Domains define synchronous projections from committed session events, and clients receive complete, schema-validated JSON values through snapshots and change notifications. Snapshots identify the last event reflected by every returned value, so carriers can pair state with the matching history cut. Projection state can be checkpointed for faster cold reads, while host-only projections remain private to the host.

Table of Contents


Use this package

Mount dsh-session-projection wherever client carriers need current values of log-derived session state. Domain plugins register units; carriers read snapshots and subscribe to the change feed; neither knows the other.

When to choose it

Choose it when a domain keeps state that clients should see without re-deriving it — a todo list, a goal snapshot, conversation statistics. The registry drives units eagerly over committed events, so any registered unit's value is current by construction. A unit without a wire block keeps its state host-only. Domain owners declare sessionProjections in their plugin dependencies and await registration before exposing synchronous service methods. Other plugins read those methods through the owning service.

Define a projection unit

A domain contributes one ProjectionDefinition per state key: a key, a state schema, an initial state, a synchronous fold apply(state, event), an optional wire block that projects state to a client view, and a stateVersion for checkpoint invalidation. Bump it when state fields or fold semantics change. An optional addition may retain the version only when absence preserves historical meaning and a regression test verifies that restoring an old checkpoint plus its suffix matches full replay; schema acceptance alone is insufficient:

const definition = {
  key: 'todo',
  stateSchema: todoStateSchema,
  stateVersion: 1,
  init: (_header, _inheritedEventCount) => ({ items: [] }),
  apply: (state, event) => event.type === 'todo/upsert'
    ? { items: event.data.items }
    : state,
  wire: {
    viewSchema: todoViewSchema,
    view: state => ({ items: state.items }),
  },
}

init(header, inheritedEventCount) receives both lightweight metadata and the exact fork-inherited cut; it must not infer that cut from firstLiveSeq or session/end-seed. apply must be synchronous and must return the same state reference for events that do not concern the unit — an unchanged reference means zero downstream work. The registry compares consecutive raw wire.view results with Object.is; an object or array view must reuse its reference to suppress publication across internal-only state changes, while a structurally equal new object is still a change. A state-carrying log event must carry the complete post-change state, never a bare delta.

Register and read

await register(definition) infers the host state type from stateSchema, installs the unit, and returns a handle with synchronous stateOf(session) reads and dispose(): Promise<void>. Await dispose() to release the registration; repeated calls are safe. Registrants with the same key and stateVersion share cells; incompatible or invalid versions reject registration. Registration belongs to the calling fiber. The last unregister removes the unit from global event drives, snapshots, restores, and change notifications; checkpoint(session) keeps each Session's last cell of it, until the key registers again and the new unit refolds the log. The registry no longer advances that cell, but a retained handle read still advances it forward, and the checkpoint serves the advanced cell. Existing handles remain readable: each retains its original unit and folds new Session events on demand, even if another version later registers the same key. Reading a handle does not initialize unrelated units or register it again. Carriers use snapshot(session) for a consistent client-view cut, onChanged(listener) for one call per changed unit, and onEventChanged(listener) for one call per committed event that lists every client-visible unit whose view it changed.

const todoProjectionHandle = await ctx.sessionProjections.register(definition)
const state = todoProjectionHandle.stateOf(session)
const { asOfSeq, values } = ctx.sessionProjections.snapshot(session)

Bind several states to one Session

Call handle.readerFor(session) on an awaited registration handle to obtain a () => S callback. Calling it reads the current state without a Session argument. An owner composing several states passes a typed object of these callbacks to its runtime. New states add properties instead of constructor arguments; ordinary plugins still use domain-service methods.

const projections = {
  todo: todoProjectionHandle.readerFor(session),
  goal: goalProjectionHandle.readerFor(session),
}
const todo = projections.todo()

Each reader captures the exact Session and delegates to the original handle's stateOf(session), preserving readiness requirements, null and undefined values, state references, and synchronous failures. Creating a reader does not read state, prepare the Session, perform storage I/O, or create a state cache. Readers own no registration or disposal. The original handle keeps its disposal responsibility; a reader keeps reading the original unit after deregistration or version replacement. One handle can create readers for several Sessions independently. Registration and Session preparation remain the owner's responsibility; client snapshots remain separate.

A domain that owns projected state declares sessionProjections as a Cordis service dependency and keeps its handle private. Consumers inject that domain service and use its methods, such as ctx.agents.turnBoundaryOf(session), without importing definitions or retaining projection readers. Optional contributors may register under ctx.inject(['sessionProjections'], …). Carriers use ctx.get('sessionProjections') and omit their block or frames when the registry is absent.

Deprecated plugin compatibility

register(definition) keeps its public return type Promise<ProjectionHandle<S>>, but the runtime value is a callable thenable, not a native Promise (typeof is "function"; instanceof Promise is false). Awaiting it delivers the retained handle; calling it releases the registration through the original Cordis effect disposer. Calling the return value is deprecated; use handle.dispose() in new code. Use Promise.resolve(registration) when an API requires a native Promise or treats functions as callbacks, including Vitest Promise assertions. See the upgrade guide.

Legacy synchronous return register(...), ctx.effect(() => register(...)), and synchronous generator yields retain cleanup ownership. Returning the registration from an async function assimilates the thenable into a handle, which Cordis rejects as Invalid effect. Yielding it from an async generator also yields the handle: that effect fails with Invalid effect when disposed and never adopts the registration, while the plugin stays active and its fiber releases the registration on unload. Async plugins and effects await registration and return void; the owning fiber releases the registration automatically.

Legacy stateOf(session, key) is deprecated and returns unknown. Reading a registered key materializes every registered unit at the Session cursor without producing unrelated wire views. An unregistered or fully released key returns undefined, while retained handles remain readable. Compatibility covers runtime calls; TypeScript consumers migrate their type declarations.

The first legacy stateOf(session, key) read of each key logs one ctx.logger.warn deprecation warning per registry instance. Registration, calls to its result, handle methods, and owner cleanup do not warn.

The registry's cachedSnapshot(session, keys) is deprecated. It views only cells another read already computed, may omit selected keys, and returns undefined when no selected client-visible cell exists; existing callers keep working. Use snapshot(session, keys) for complete values at the Session cursor, or the registration's handle.stateOf(session) for Host state. The persisted cache read ctx.sessionProjectionCache.cachedSnapshot(header, keys) is a separate method and is unchanged.

Definitions install synchronously, and handle delivery remains asynchronous. Registration errors reject the returned value. When no caller observes a rejection before the current macrotask and its microtasks finish, the registry logs it as an error instead of leaving an unhandled rejection, and the plugin stays active; after a key conflict, its keyed reads return the other registrant's state; a rejection whose owner is no longer loading or active is dropped. New plugins await registration before exposing readers. Synchronous try/catch cannot catch asynchronous registration errors.

Persisted checkpoints

Every unit's state is checkpointed — client-visible and host-only alike — through checkpoint(session), and the sibling session-projection-cache persists those checkpoints so cold reads skip full log loads. Checkpoint watermarks use SessionSeqCursor (-1 for an empty log), while replay starts use SessionLogOffset; restoreFloor and restore implement the read recipe without conflating an existing event with a log gap. restore folds only live units, so its refreshed rows omit released units, and a write-back of them drops their stored rows.

Session summaries

A Session summary is every registered client view except the keys whose non-nullish SessionProjectionMap entry carries the SessionProjectionSummaryExcluded<T> mark. summaryKeys() returns that set, derived from the live registrations and stable — the same instance — until a registration or its disposal changes the key table. A summary consumer passes it to the existing key-taking reads — snapshot(session, keys) for live Sessions, viewCheckpoint(rows, keys) for checkpoint rows, and cachedSnapshot(header, keys) through the persisted cache — which treat a set as already selected, so there is no summary-specific read face, no copy per read, and no second definition of the set. Because the registry derives it, a summary consumer cannot request an excluded key through the derived set; a complete face still serves an explicitly named key, which is how an opened Session keeps every value. A list row carries the summary projection and never the full projection.

The mark decides the client view type; wire.summary decides the runtime key set, and omitting it means true. Declare wire.summary: false on a key whose map entry carries the mark so the two agree; the field is a plain boolean, not a type-level interlock. A nullable entry keeps null outside the wrapper (SessionProjectionSummaryExcluded<TodoItem[]> | null) so the pre-first-write value stays expressible. The keys currently marked are turnOutline, sessionStats, todos, imageLimits, contextPressure, and contextBreakdown: an outline grows with the whole log, no list row renders the stats, todo list, or context meter, and the image limits are one host-wide boot constant every Session shares.

Summaries serve the Session list rows, which list Sessions without opening them. The @ reference lists Sessions the same way but reads only its title and subagent label keys through the key-taking snapshot(session, keys) and cachedSnapshot(header, keys) faces. snapshot(session, keys) computes only the units it returns; without a key subset it computes every client-visible unit and no Host-only unit, so an opened Session receives every projected value, and viewCheckpoint likewise serves every wire key without a key subset.


Understand the implementation

Implementation internals — click to expand

This section explains the drive machinery and the unit contract; the observable contract is covered in Use this package.

Design concept

The package is the Service Definition and drive role of a capability seam: the framework drives, the domain computes. The registry subscribes to session/event once; every committed event passes every registered unit's apply eagerly (cells build lazily on first touch). The first Object.is gate skips view work when the state reference is unchanged; a two-slot live-drive cache reuses the previous raw view and a second Object.is gate suppresses publication while the raw view reference is unchanged. Carriers read snapshot() in the same tick as their page slice, which is what makes asOfSeq one consistent cut; an accidentally async view returns a Promise and fails wire.viewSchema.parse.

Source map

File Role
src/index.ts Plugin entry: SessionProjectionRegistry service, ProjectionDefinition, snapshot and checkpoint machinery
src/types.ts The merge-extensible client SessionProjectionMap type table, plus the summary-exclusion mark and its derived view and summary types

Drive and checkpoint flow

One committed event drives every registered unit in registration order; a client-visible unit whose raw view changes by Object.is notifies each onChanged listener with its schema-validated view and the causing seq. After every unit has been driven, each onEventChanged listener receives one call with one { key, val } entry per changed client-visible unit in registration order, where val is the schema-validated view, and the causing seq; an event that changes no client-visible view makes no event-level call. If a unit or an onChanged listener throws during the drive, event listeners still receive the changes made before the throw, and the error then propagates to the Session's listener containment. The live drive retains its previous and current raw views; snapshots and cold reads remain complete independent reads. checkpoint(session) returns one detached (key → {ver, seq, val}) row per unit for the persisted cache; restoreFloor anchors a tail read one event below the lowest usable watermark so a shrunk log is detected, and restore refolds persisted rows over a stored suffix, discarding any row whose ver does not match or that claims events past the stored end.


Further Exploration

Read these pages when the package-level contract is not enough. They move from the unit contract to the read-model subsystem and the persisted cache.


Model Experience

None, as the projection registry serves client-facing read models of already-logged session state and registers nothing model-facing.

KV Cache effect

None; projections never assemble or send provider requests.

Known Limitations and Deferred Work

These limits define where the projection registry needs care at scale. They are current package constraints, not a task backlog.

  • Only a key-subset read omits keys; a tail page still carries every client-visible key and there is no lazy per-value request — a summary omits exactly the keys whose SessionProjectionMap entry carries SessionProjectionSummaryExcluded<T> and whose registration declares wire.summary: false. The opt-out is per key, not per value: no read defers one large value.
  • The unit table is process-wide, so key presence is not a per-session capability signal — a key registered by any agent preset appears in every session's snapshot; a client must read the value rather than treat an absent key as absence of the feature.
  • Eager drive touches every unit per event — cheap by construction (whole-value rule and state/view reference gates), but a hot path would justify per-unit event-type prefilters.
  • A released unit's checkpoint row stops advancing — after the last registrant releases a key, checkpoint(session) keeps each Session's last cell until the key registers again. Outside graceful shutdown, events appended meanwhile leave the stored row behind the log, and cold listings serve that older value.
  • A released predecessor keeps its definition referenced — the registry holds the released unit's definition closures until the same key registers again, so a key that a reloaded plugin renames or stops registering keeps the old module referenced for the process lifetime. Cells are keyed weakly by Session and retain no Session.
  • Registry cells live in memory only — a restart rebuilds by folding the log on first touch; compositions that mount dsh-session-projection-cache seed that fold from persisted rows instead.
  • Synchronous unit discipline is only partially mechanical — wire.viewSchema.parse rejects a Promise-returning view, but an apply that blocks or reads torn non-session state is a review concern.

Dev Note

Working context for maintainers — click to expand

None.