فهرست منبع

feat(session-turn-outline): whole-log turn outline projection

New turnOutline projection unit serving every started turn's number,
turn/start seq, and bounded first-prompt preview through the
session-projection seam, mounted in the web-app bundle for the chat
turn rail. Entries stay strictly increasing; previews mirror the rail's
loaded-turn preview budget.
Yichen Jiang 1 هفته پیش
والد
کامیت
7e2eacb1fe

+ 2 - 2
docs/config-catalog.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 docs/config-catalog.md
-config-catalog.md: ec077edd10962f324db242698b1d652563c3ac2f
-config-catalog.zh.md: d349575cb2884bd2e80097c8345db8d0227107c7
+config-catalog.md: 5d19ab42f0423cd92fd6227bf1c169db1269d9d4
+config-catalog.zh.md: 77fdabb9da9b78aba6c9704fd0ec5c095ea14679

+ 1 - 0
docs/config-catalog.md

@@ -3353,6 +3353,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
 - `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session/session-checkpoint-policy/src/index.ts`](../packages/session/session-checkpoint-policy/src/index.ts))
 - `@deepseek-ai/dsh-session-projection` ([`packages/session/session-projection/src/index.ts`](../packages/session/session-projection/src/index.ts))
 - `@deepseek-ai/dsh-session-stats` — requires `sessionProjections` ([`packages/session/session-stats/src/index.ts`](../packages/session/session-stats/src/index.ts))
+- `@deepseek-ai/dsh-session-turn-outline` — requires `sessionProjections` ([`packages/session/session-turn-outline/src/index.ts`](../packages/session/session-turn-outline/src/index.ts))
 - `@deepseek-ai/dsh-skill-badge` — requires `skills` ([`packages/skill/skill-badge/src/index.ts`](../packages/skill/skill-badge/src/index.ts))
 - `@deepseek-ai/dsh-storage` ([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts))
 - `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts))

+ 1 - 0
docs/config-catalog.zh.md

@@ -3355,6 +3355,7 @@ export interface Config {
 - `@deepseek-ai/dsh-session-checkpoint-policy` — 需要 `llm` · `sessionPersistence` · `sessions` · `tools`([`packages/session/session-checkpoint-policy/src/index.ts`](../packages/session/session-checkpoint-policy/src/index.ts))
 - `@deepseek-ai/dsh-session-projection`([`packages/session/session-projection/src/index.ts`](../packages/session/session-projection/src/index.ts))
 - `@deepseek-ai/dsh-session-stats` — 需要 `sessionProjections`([`packages/session/session-stats/src/index.ts`](../packages/session/session-stats/src/index.ts))
+- `@deepseek-ai/dsh-session-turn-outline` — 需要 `sessionProjections`([`packages/session/session-turn-outline/src/index.ts`](../packages/session/session-turn-outline/src/index.ts))
 - `@deepseek-ai/dsh-skill-badge` — 需要 `skills`([`packages/skill/skill-badge/src/index.ts`](../packages/skill/skill-badge/src/index.ts))
 - `@deepseek-ai/dsh-storage`([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts))
 - `@deepseek-ai/dsh-subagent`([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts))

+ 2 - 2
docs/module-graph.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 docs/module-graph.md
-module-graph.md: 2229efcd7e76b3b224eb307ee7de9ecea0ad85d7
-module-graph.zh.md: 3edca4ea261f68593973149b21e72e4a25d7a504
+module-graph.md: 1fa0aec5cf88f8deaa4e3bfd6687c7dc8c06f669
+module-graph.zh.md: a9da000b8966fa8daaca13c95af097e4b342d633

+ 6 - 0
docs/module-graph.md

@@ -296,6 +296,7 @@ flowchart TD
     pkg_session_title_all_prompts_llm["session-title-all-prompts-llm"]
     pkg_session_title_first_prompt_llm["session-title-first-prompt-llm"]
     pkg_session_title_llm["session-title-llm"]
+    pkg_session_turn_outline["session-turn-outline"]
   end
   subgraph group_settings["packages/settings"]
     pkg_settings["settings"]
@@ -530,6 +531,10 @@ flowchart TD
   pkg_session_stats --> pkg_llm
   pkg_session_stats --> pkg_session
   pkg_session_stats --> pkg_session_projection
+  pkg_session_turn_outline --> pkg_invariants
+  pkg_session_turn_outline --> pkg_llm
+  pkg_session_turn_outline --> pkg_session
+  pkg_session_turn_outline --> pkg_session_projection
   pkg_settings_file --> pkg_atomic_write
   pkg_settings_file --> pkg_home_paths
   pkg_settings_file --> pkg_invariants
@@ -1431,6 +1436,7 @@ flowchart TD
 | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
 | [`session-projection-cache`](../packages/session/session-projection-cache) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain) |
 | [`session-stats`](../packages/session/session-stats) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
+| [`session-turn-outline`](../packages/session/session-turn-outline) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
 | [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) |
 | [`shell`](../packages/shell/shell) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) |
 | [`workspace`](../packages/workspace/workspace) | `workspace` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) |

+ 6 - 0
docs/module-graph.zh.md

@@ -298,6 +298,7 @@ flowchart TD
     pkg_session_title_all_prompts_llm["session-title-all-prompts-llm"]
     pkg_session_title_first_prompt_llm["session-title-first-prompt-llm"]
     pkg_session_title_llm["session-title-llm"]
+    pkg_session_turn_outline["session-turn-outline"]
   end
   subgraph group_settings["packages/settings"]
     pkg_settings["settings"]
@@ -532,6 +533,10 @@ flowchart TD
   pkg_session_stats --> pkg_llm
   pkg_session_stats --> pkg_session
   pkg_session_stats --> pkg_session_projection
+  pkg_session_turn_outline --> pkg_invariants
+  pkg_session_turn_outline --> pkg_llm
+  pkg_session_turn_outline --> pkg_session
+  pkg_session_turn_outline --> pkg_session_projection
   pkg_settings_file --> pkg_atomic_write
   pkg_settings_file --> pkg_home_paths
   pkg_settings_file --> pkg_invariants
@@ -1433,6 +1438,7 @@ flowchart TD
 | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
 | [`session-projection-cache`](../packages/session/session-projection-cache) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain) |
 | [`session-stats`](../packages/session/session-stats) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
+| [`session-turn-outline`](../packages/session/session-turn-outline) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
 | [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) |
 | [`shell`](../packages/shell/shell) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) |
 | [`workspace`](../packages/workspace/workspace) | `workspace` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) |

+ 5 - 0
packages/bundle/web-app/cordis.patch.yml

@@ -72,6 +72,11 @@
     - id: session-stats
       name: '@deepseek-ai/dsh-session-stats'
 
+    # Whole-log turn outline for the chat turn rail (the turnOutline
+    # projection key): every turn stays navigable before its events page in.
+    - id: session-turn-outline
+      name: '@deepseek-ai/dsh-session-turn-outline'
+
     # Resolve bind host, SSH launch, and display once at boot, then mount the
     # matching dual-face directory picker. Mount -native or -browse directly in
     # an overlay to pin the interaction.

+ 1 - 0
packages/bundle/web-app/package.json

@@ -107,6 +107,7 @@
     "@deepseek-ai/dsh-session-reference": "workspace:^",
     "@deepseek-ai/dsh-session-log-export": "workspace:^",
     "@deepseek-ai/dsh-session-stats": "workspace:^",
+    "@deepseek-ai/dsh-session-turn-outline": "workspace:^",
     "@deepseek-ai/dsh-api-session-controller": "workspace:^",
     "@deepseek-ai/dsh-api-settings-controller": "workspace:^",
     "@deepseek-ai/dsh-api-workspace-controller": "workspace:^",

+ 2 - 2
packages/session/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/README.md
-README.md: 63cc118decffaec1073c75d9d8c5967f866016fa
-README.zh.md: f24883518d779dd4cd069e828d4d6ba01d8caa07
+README.md: b47bd7e6384919af4add2746417b232f6873aaf0
+README.zh.md: fa5bc08fc04d32de15f9fe357e7b18b23bb084d3

+ 1 - 0
packages/session/README.md

@@ -40,6 +40,7 @@ The group splits into four families: durable storage (persistence seam, backends
 | [`session-projection/`](session-projection/README.md) | Defines and drives projection units that fold committed events into whole current values | `ctx.sessionProjections` |
 | [`session-projection-cache/`](session-projection-cache/README.md) | Persists projection checkpoints so cold reads skip full log loads | `ctx.sessionProjectionCache` |
 | [`session-stats/`](session-stats/README.md) | Serves whole-log conversation counts and wall times through the `sessionStats` unit | registers on `ctx.sessionProjections` |
+| [`session-turn-outline/`](session-turn-outline/README.md) | Serves the whole-log turn outline (turn, `turn/start` seq, prompt preview) through the `turnOutline` unit | registers on `ctx.sessionProjections` |
 
 ### Titles
 

+ 1 - 0
packages/session/README.zh.md

@@ -40,6 +40,7 @@ session 组让 agent(智能体)的对话在实时 loop 之外持久可复用
 | [`session-projection/`](session-projection/README.zh.md) | 定义并驱动把已提交事件折叠为完整当前值的投影单元 | `ctx.sessionProjections` |
 | [`session-projection-cache/`](session-projection-cache/README.zh.md) | 持久化投影检查点,使冷读跳过全量日志加载 | `ctx.sessionProjectionCache` |
 | [`session-stats/`](session-stats/README.zh.md) | 通过 `sessionStats` 单元提供全日志会话计数与墙钟时间 | 注册到 `ctx.sessionProjections` |
+| [`session-turn-outline/`](session-turn-outline/README.zh.md) | 通过 `turnOutline` 单元提供全日志轮次大纲(轮次号、`turn/start` seq、提示词预览) | 注册到 `ctx.sessionProjections` |
 
 ### 标题
 

+ 6 - 0
packages/session/session-turn-outline/README.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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/session-turn-outline/README.md
+README.md: f56445df70fcfd637657c4f7640dd5c415af7063
+README.zh.md: f10a0a61d9df6534d8e2f9566814c6112e2e678e

+ 124 - 0
packages/session/session-turn-outline/README.md

@@ -0,0 +1,124 @@
+---
+description: "Whole-log turn outline for clients and maintainers composing or debugging the turnOutline projection unit behind full-session turn navigation."
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-session-turn-outline
+
+English | [中文](README.zh.md)
+
+## Summary
+
+`dsh-session-turn-outline` serves the whole-log turn outline — every started turn with its `turn/start` seq and a bounded first-prompt preview — as the `turnOutline` projection unit. A client that pages history in windows reads the outline to offer every turn of the session (loaded or not) and to target its backwards paging at the exact seq that brings a turn's events in. Choose it in compositions that already mount the projection registry, such as the web app bundle whose chat turn rail is the reference consumer; assemblies without the registry are unaffected and their consumers fall back to loaded-window navigation. Setup and entry semantics come first; the fold internals live in a collapsible developer section below.
+
+## Table of Contents
+
+- [Use this package](#use-this-package)
+- [Understand the implementation](#understand-the-implementation)
+- [Further Exploration](#further-exploration)
+- [Model Experience](#model-experience)
+- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
+- [Dev Note](#dev-note)
+
+-----
+
+<a id="use-this-package"></a>
+## Use this package
+
+Mount the plugin beside the session store and the projection registry when clients should navigate every turn of a session without holding its complete event log. The unit registers only when the registry is present.
+
+### Composition
+
+```yaml
+- name: '@deepseek-ai/dsh-session'
+- name: '@deepseek-ai/dsh-session-projection'
+- name: '@deepseek-ai/dsh-session-turn-outline'
+```
+
+### What an entry means
+
+| Field | Meaning |
+|---|---|
+| `turn` | Host-assigned turn number from the `turn/start` payload |
+| `seq` | The turn's `turn/start` event seq — paging a window back through this seq loads the whole turn |
+| `prompt` | Preview of the turn's first human prompt (space-joined text blocks, collapsed whitespace, 160-character cap); `''` until an eligible prompt lands |
+
+Entries are strictly increasing by `turn`, and the wire value is the complete outline (whole-value rule): consumers replace, never merge. Only `user/message` events with the human `user` source fill previews, so injected context and tool results never leak into navigation; a turn whose prompt is images-only keeps `''` and consumers label it by number. The preview budget matches the chat rail's loaded-turn preview, so a turn shows the same words before and after its events load.
+
+### Failures and recovery
+
+The unit is inert without the projection registry: `inject` keeps the fiber pending and nothing registers, so other assemblies lack the `turnOutline` key. Unmounting the plugin removes the key, because registrations are effects on the mounting fiber. Persisted-cache rows are schema-validated on restore — including the strictly-increasing turn order — so a corrupt row is discarded instead of seeding a broken fold.
+
+-----
+
+<a id="understand-the-implementation"></a>
+## Understand the implementation
+
+<details>
+<summary>Implementation internals — click to expand</summary>
+
+This section explains the fold behind the outline; the observable behavior is fully covered in [Use this package](#use-this-package).
+
+### Design concept
+
+The unit is a pure fold over committed session events. `turn/start` — not the prompt `user/message` — anchors each entry because its seq is the load-through target for a jump: the agent loop logs `turn/start` before the turn's prompt and steps, so a window paged back through that seq contains the whole turn. The preview then fills from the first human `user/message`, and only while the newest entry is still empty — later human messages in the same turn (steering) keep the first preview.
+
+### Source map
+
+| File | Role |
+|---|---|
+| [`src/index.ts`](src/index.ts) | Plugin entry: `inject`, unit registration on the mounting fiber |
+| [`src/projection.ts`](src/projection.ts) | The fold: entry append, preview fill, wire view |
+| [`src/types.ts`](src/types.ts) | One home of the `turnOutline` projection-key declaration and entry types |
+
+### Fold rules
+
+- Uninteresting events return the same state reference; the registry's `Object.is` gate keeps the change feed quiet — the outline moves at most twice per turn.
+- A `turn/start` that does not advance the turn number is skipped, keeping the outline sorted; a retried boundary's prompt then lands on the standing entry.
+- State and wire view are the same value, so the persisted-cache state schema is the wire schema.
+
+</details>
+
+-----
+
+<a id="further-exploration"></a>
+## Further Exploration
+
+Read these pages when the unit's contract is not enough. They move from the registry that drives units to adjacent session packages.
+
+- [Session projection subsystem](../../../docs/subsystems/session-projection.md) — the registry that drives units and serves snapshot and change-feed values.
+- [Session projection registry package](../session-projection/README.md) — the registry contract units register against.
+- [Session package map](../README.md) — adjacent persistence, projection, title, and telemetry packages.
+
+-----
+
+<a id="model-experience"></a>
+## Model Experience
+
+None, as the turnOutline unit folds already-logged turn boundaries into a client-facing read model and registers nothing model-facing.
+
+#### KV Cache effect
+
+None; the package never assembles or sends provider requests.
+
+## Known Limitations and Deferred Work
+
+<a id="known-limitations-and-deferred-work"></a>
+
+
+These limits define what the outline describes and when the unit is absent. They are current package constraints.
+
+- **The wire value grows with the session** — every change pushes the complete outline (whole-value rule), roughly 200 bytes per turn; splitting previews into an on-demand read is deferred until sessions with many thousands of turns need it.
+- **Previews carry the prompt only** — assistant-response previews stay window-scoped in the consumer; the outline never re-reads message bodies.
+- **A turn without an eligible text prompt keeps `''`** — images-only and command-only turns are navigable but labeled by number.
+- **Mounted only where the projection registry is composed** — other assemblies serve no `turnOutline` key, and their consumers fall back to loaded-window navigation.
+
+<a id="dev-note"></a>
+### Dev Note
+
+<details>
+<summary>Working context for maintainers — click to expand</summary>
+
+None.
+
+</details>

+ 124 - 0
packages/session/session-turn-outline/README.zh.md

@@ -0,0 +1,124 @@
+---
+description: "面向组合或调试 turnOutline 投影单元的客户端与维护者的全量轮次大纲说明,支撑整会话轮次导航。"
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-session-turn-outline
+
+[English](README.md) | 中文
+
+## 概述
+
+`dsh-session-turn-outline` 以 `turnOutline` 投影单元提供全日志的轮次大纲——每个已开始的轮次连同其 `turn/start` seq 与有界的首条提示词预览。按窗口分页历史的客户端读取大纲即可提供会话的每一轮(无论是否已加载),并把向后分页精确定位到能载入某轮事件的 seq。在已挂载投影注册表的组合中选择它,例如以聊天轮次导航栏为参考消费者的 Web 应用包;没有注册表的装配不受影响,其消费者回退到仅按已加载窗口导航。用法与条目语义在前;折叠内部细节放在下方可折叠的开发者章节中。
+
+## 目录
+
+- [使用本包](#use-this-package)
+- [理解实现](#understand-the-implementation)
+- [进一步探索](#further-exploration)
+- [模型体验](#model-experience)
+- [已知限制与延期工作](#known-limitations-and-deferred-work)
+- [开发备注](#dev-note)
+
+-----
+
+<a id="use-this-package"></a>
+## 使用本包
+
+当客户端需要在不持有完整事件日志的情况下导航会话的每一轮时,在会话存储与投影注册表旁挂载此插件。只有存在注册表时单元才会注册。
+
+### 组合
+
+```yaml
+- name: '@deepseek-ai/dsh-session'
+- name: '@deepseek-ai/dsh-session-projection'
+- name: '@deepseek-ai/dsh-session-turn-outline'
+```
+
+### 各字段含义
+
+| 字段 | 含义 |
+|---|---|
+| `turn` | `turn/start` 载荷里的宿主分配轮次号 |
+| `seq` | 该轮 `turn/start` 事件的 seq——窗口向后分页越过此 seq 即载入整轮 |
+| `prompt` | 该轮首条人类提示词的预览(文本块以空格连接、空白折叠、160 字符封顶);合格提示词落日志前为 `''` |
+
+条目按 `turn` 严格递增,wire 值是完整大纲(整值规则):消费者整体替换,从不合并。只有带人类 `user` 来源的 `user/message` 事件才会填充预览,注入的上下文与工具结果绝不进入导航;纯图片提示词的轮次保持 `''`,消费者按轮次号标注。预览预算与聊天导航栏已加载轮次的预览一致,同一轮在事件载入前后显示相同的文字。
+
+### 失败与恢复
+
+没有投影注册表时单元是惰性的:`inject` 使 fiber 保持挂起,不注册任何内容,因此其他装配缺少 `turnOutline` 键。卸载插件会移除该键,因为注册是挂载 fiber 上的 effect。持久缓存行在恢复时经受 schema 校验——包括轮次严格递增的顺序——损坏的行被丢弃而不会喂坏折叠。
+
+-----
+
+<a id="understand-the-implementation"></a>
+## 理解实现
+
+<details>
+<summary>实现细节——点击展开</summary>
+
+本节解释大纲背后的折叠;可观察行为已在[使用本包](#use-this-package)中完整说明。
+
+### 设计理念
+
+该单元是对已提交会话事件的纯折叠。锚定每个条目的是 `turn/start` 而非提示词 `user/message`,因为它的 seq 就是跳转的载入目标:agent loop 先记 `turn/start` 再记该轮的提示词与步骤,窗口向后分页越过该 seq 即包含整轮。预览随后由首条人类 `user/message` 填充,且仅当最新条目仍为空时——同一轮内后续的人类消息(steering)保留首个预览。
+
+### 源码地图
+
+| 文件 | 职责 |
+|---|---|
+| [`src/index.ts`](src/index.ts) | 插件入口:`inject`、在挂载 fiber 上注册单元 |
+| [`src/projection.ts`](src/projection.ts) | 折叠:条目追加、预览填充、wire 视图 |
+| [`src/types.ts`](src/types.ts) | `turnOutline` 投影键声明与条目类型的唯一归属 |
+
+### 折叠规则
+
+- 不相关事件返回同一状态引用;注册表的 `Object.is` 门禁保持变更流安静——大纲每轮至多变动两次。
+- 未推进轮次号的 `turn/start` 被跳过,保持大纲有序;重试边界的提示词随后落在既有条目上。
+- 状态与 wire 视图是同一个值,因此持久缓存的状态 schema 就是 wire schema。
+
+</details>
+
+-----
+
+<a id="further-exploration"></a>
+## 进一步探索
+
+当单元约定不够用时阅读以下页面。它们从驱动单元的注册表逐步进入相邻的会话包。
+
+- [会话投影子系统](../../../docs/subsystems/session-projection.zh.md)——驱动单元并提供快照与变更流值的注册表。
+- [会话投影注册表包](../session-projection/README.zh.md)——单元注册所依据的注册表约定。
+- [会话包映射](../README.zh.md)——相邻的持久化、投影、标题与遥测包。
+
+-----
+
+<a id="model-experience"></a>
+## 模型体验
+
+无,因为 turnOutline 单元把已写入日志的轮次边界折叠成面向客户端的读模型,不注册任何面向模型的内容。
+
+#### KV Cache 影响
+
+无;本包从不组装或发送提供方请求。
+
+## 已知限制与延期工作
+
+<a id="known-limitations-and-deferred-work"></a>
+
+
+这些限制说明大纲描述什么、单元何时缺失。它们是当前包约束。
+
+- **wire 值随会话增长**——每次变更推送完整大纲(整值规则),约每轮 200 字节;把预览拆成按需读取推迟到数千轮量级的会话真正需要时。
+- **预览只含提示词**——助手回复预览仍由消费者按窗口提供;大纲从不回读消息正文。
+- **没有合格文本提示词的轮次保持 `''`**——纯图片、纯命令的轮次可导航但按轮次号标注。
+- **仅在组合了投影注册表时挂载**——其他装配不提供 `turnOutline` 键,其消费者回退到仅按已加载窗口导航。
+
+<a id="dev-note"></a>
+### 开发备注
+
+<details>
+<summary>维护者的工作上下文——点击展开</summary>
+
+无。
+
+</details>

+ 62 - 0
packages/session/session-turn-outline/package.json

@@ -0,0 +1,62 @@
+{
+  "name": "@deepseek-ai/dsh-session-turn-outline",
+  "description": "Whole-log turn outline projection (turnOutline) for the DeepSeek Harness",
+  "version": "0.1.2-alpha.1",
+  "publishConfig": {
+    "access": "public"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+    "directory": "packages/session/session-turn-outline"
+  },
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./invariant": {
+      "types": "./lib/types/invariant.d.ts",
+      "default": "./lib/invariant.js"
+    },
+    "./types": {
+      "types": "./lib/types/types.d.ts",
+      "default": "./lib/types/types.js"
+    },
+    "./client": {
+      "types": "./lib/types/client.d.ts",
+      "default": "./lib/types/client.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/invariant.js",
+    "lib/types/**/*.js",
+    "lib/types/**/*.d.ts"
+  ],
+  "license": "MIT",
+  "peerDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-session-projection": "workspace:^"
+  },
+  "dependencies": {
+    "zod": "^4.4.3"
+  },
+  "devDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/cordis-plugin-include": "workspace:^",
+    "@deepseek-ai/cordis-plugin-loader": "workspace:^",
+    "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-session-projection": "workspace:^"
+  }
+}

+ 10 - 0
packages/session/session-turn-outline/src/client.ts

@@ -0,0 +1,10 @@
+/**
+ * Client-namespace projection of the turn-outline domain: a pure re-export
+ * of the package's types outlet. Client code imports ONLY the client
+ * namespace (repo discipline), so `./client` projects the same single-source
+ * content `./types` serves to host consumers — zero duplication.
+ *
+ * @module @deepseek-ai/dsh-session-turn-outline/client
+ */
+
+export type * from './types.ts'

+ 29 - 0
packages/session/session-turn-outline/src/index.ts

@@ -0,0 +1,29 @@
+/**
+ * Function plugin registering the `turnOutline` projection unit: the
+ * whole-log turn outline (turn number, `turn/start` seq, bounded prompt
+ * preview) served through the session-projection seam — registry snapshot,
+ * change feed, and every projection carrier — so a client can offer every
+ * turn of a session and target history paging at exact seqs without holding
+ * the events. The plugin owns only the fold; delivery is the seam's.
+ *
+ * @module @deepseek-ai/dsh-session-turn-outline
+ */
+
+import type { Context } from '@deepseek-ai/cordis'
+import { turnOutlineProjectionDefinition } from './projection.ts'
+
+export type * from './types.ts'
+
+/** Cordis plugin name. */
+export const name = 'session-turn-outline'
+/** The projection registry is the plugin's whole purpose; without it the fiber stays pending. */
+export const inject = ['sessionProjections']
+
+/**
+ * Register the `turnOutline` unit; the registration is an effect on this
+ * plugin's fiber, so unloading removes the key.
+ * @param ctx - registrant context carrying the projection registry.
+ */
+export function apply(ctx: Context): void {
+  ctx.sessionProjections.register(turnOutlineProjectionDefinition)
+}

+ 35 - 0
packages/session/session-turn-outline/src/invariant.ts

@@ -0,0 +1,35 @@
+/**
+ * Package-owned invariant companion for `@deepseek-ai/dsh-session-turn-outline`.
+ * @module @deepseek-ai/dsh-session-turn-outline/invariant
+ */
+
+/* jscpd:ignore-start */
+import type { Context } from '@deepseek-ai/cordis'
+import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-session-turn-outline'
+
+/** Cordis companion plugin name. */
+export const name = 'session-turn-outline-invariant'
+/** Service required before the companion can reserve package ownership. */
+export const inject = ['invariants']
+
+/**
+ * No runtime invariant: the package owns a single pure projection fold whose
+ * wire payload is schema-validated by the projection registry at every
+ * snapshot and change-feed emission (including the strictly-increasing turn
+ * order the fold maintains), and the event relations the fold relies on
+ * (host-assigned monotonic turn numbers on `turn/start`, the turn's prompt
+ * `user/message` logged after its boundary) are owned and runtime-checked by
+ * dsh-agent-loop and the session surface, not here.
+ */
+const install: InvariantInstaller = () => {}
+
+/**
+ * Register this package's invariant companion.
+ * @param ctx - Cordis context carrying the invariant service.
+ * @returns the installed registration's disposer after setup succeeds.
+ */
+export const apply = (ctx: Context): Promise<() => void> =>
+  Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
+/* jscpd:ignore-end */

+ 91 - 0
packages/session/session-turn-outline/src/projection.ts

@@ -0,0 +1,91 @@
+/**
+ * The `turnOutline` projection unit: a pure fold of `turn/start` boundaries
+ * and first human prompts into the whole-log turn outline the chat rail
+ * renders for turns outside a client's paged event window.
+ *
+ * `turn/start` — not the prompt `user/message` — anchors each entry because
+ * its seq is the load-through target for a jump: the loop logs `turn/start`
+ * before the turn's prompt and steps, so a window paged back through that seq
+ * contains the whole turn. The preview mirrors the rail's loaded-turn preview
+ * (space-joined text blocks, collapsed whitespace, 160-character cap) so a
+ * turn shows the same words before and after its events load.
+ *
+ * @module @deepseek-ai/dsh-session-turn-outline/projection
+ */
+
+import { z } from 'zod'
+import type { ZodType } from 'zod'
+import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
+import type { TurnOutlineProjection } from './types.ts'
+
+/** Preview budget per entry, matching the rail's loaded-turn preview clamp. */
+const PREVIEW_LIMIT = 160
+
+/** Space-join text blocks until the budget is met, then normalize and cap. */
+function promptPreview(content: SessionEvent<'user/message'>['data']['content']): string {
+  let text = ''
+  for (const block of content) {
+    if (block.type !== 'text') continue
+    text += text === '' ? block.text : ` ${block.text}`
+    if (text.length >= PREVIEW_LIMIT) break
+  }
+  return text.replace(/\s+/g, ' ').trim().slice(0, PREVIEW_LIMIT)
+}
+
+const turnOutlineSchema: ZodType<TurnOutlineProjection> = z.object({
+  turns: z.array(z.object({
+    turn: z.number().int().nonnegative(),
+    seq: z.number().int().nonnegative(),
+    prompt: z.string().max(PREVIEW_LIMIT),
+  }).strict()),
+}).strict().superRefine((state, context) => {
+  let previous = -1
+  for (const entry of state.turns) {
+    if (entry.turn <= previous) {
+      context.addIssue({ code: 'custom', message: 'turn outline entries must be strictly increasing by turn' })
+      return
+    }
+    previous = entry.turn
+  }
+})
+
+const EMPTY_OUTLINE: TurnOutlineProjection = { turns: [] }
+
+/** The `turnOutline` unit registered on `ctx.sessionProjections` (exported for the unit spec). */
+export const turnOutlineProjectionDefinition = {
+  key: 'turnOutline',
+  stateVersion: 1,
+  stateSchema: turnOutlineSchema,
+  init: () => EMPTY_OUTLINE,
+  apply: (state, event) => {
+    // Every uninteresting event returns the same reference (Object.is gates the change feed).
+    switch (event.type) {
+      case 'turn/start': {
+        const last = state.turns.at(-1)
+        // Order guard: a boundary that does not advance the turn number keeps
+        // the outline sorted, and a retried turn's prompt lands on the
+        // standing entry.
+        if (last !== undefined && event.data.turn <= last.turn) return state
+        return { turns: [...state.turns, { turn: event.data.turn, seq: event.seq, prompt: '' }] }
+      }
+      case 'user/message': {
+        // Only the newest turn can still be waiting for its opening human
+        // prompt; later human messages in the same turn (steering) keep the
+        // first preview.
+        if (event.data.source.kind !== 'user') return state
+        const last = state.turns.at(-1)
+        if (last === undefined || last.prompt !== '') return state
+        const prompt = promptPreview(event.data.content)
+        if (prompt === '') return state
+        return { turns: [...state.turns.slice(0, -1), { ...last, prompt }] }
+      }
+      default:
+        return state
+    }
+  },
+  wire: {
+    viewSchema: turnOutlineSchema,
+    view: state => state,
+  },
+} satisfies ProjectionDefinition<'turnOutline', TurnOutlineProjection>

+ 37 - 0
packages/session/session-turn-outline/src/types.ts

@@ -0,0 +1,37 @@
+/**
+ * Pure types of the turn-outline domain: the ONE home of the `turnOutline`
+ * projection-key declaration, free of this package's host-side value imports
+ * (zod, the projection definition). Host consumers import `./types`; client
+ * aggregates import `./client`, which re-exports this module.
+ *
+ * @module @deepseek-ai/dsh-session-turn-outline/types
+ */
+
+export {}
+
+/** One started turn's outline facts, independent of what a client has paged in. */
+export interface TurnOutlineEntry {
+  /** Host-assigned turn number (the `turn/start` payload). */
+  readonly turn: number
+  /** The turn's `turn/start` event seq — paging a window back through this seq loads the whole turn. */
+  readonly seq: number
+  /** Bounded preview of the turn's first human prompt; `''` until an eligible prompt lands. */
+  readonly prompt: string
+}
+
+/** Whole-log turn outline: every started turn, strictly increasing by `turn`. */
+export interface TurnOutlineProjection {
+  /** Started turns in ascending turn order. */
+  readonly turns: readonly TurnOutlineEntry[]
+}
+
+declare module '@deepseek-ai/dsh-session-projection/types' {
+  interface SessionProjectionStateMap {
+    /** Whole-log turn outline fold state (identical to the wire view). */
+    turnOutline: TurnOutlineProjection
+  }
+  interface SessionProjectionMap {
+    /** Every started turn with its `turn/start` seq and bounded prompt preview; see {@link TurnOutlineProjection}. */
+    turnOutline: TurnOutlineProjection
+  }
+}

+ 88 - 0
packages/session/session-turn-outline/tests/loader-composition.spec.ts

@@ -0,0 +1,88 @@
+/**
+ * REAL-composition proof: the shipped YAML shape (session + projection
+ * registry + session-turn-outline) boots through the vendored Loader, the
+ * function plugin's namespace survives (no default export), and a logged turn
+ * with its prompt serves the outline through the composed registry.
+ */
+
+import { mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { afterEach, describe, expect, it } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import Loader from '@deepseek-ai/cordis-plugin-loader'
+import Include from '@deepseek-ai/cordis-plugin-include'
+import { createUserMessage } from '@deepseek-ai/dsh-llm'
+import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
+import * as SessionTurnOutlinePlugin from '@deepseek-ai/dsh-session-turn-outline'
+
+let root: string | undefined
+let context: Context | undefined
+
+afterEach(async () => {
+  await context?.fiber.dispose()
+  context = undefined
+  if (root !== undefined) await rm(root, { recursive: true, force: true })
+  root = undefined
+})
+
+async function loadYaml(lines: readonly string[]): Promise<Context> {
+  root = await mkdtemp(join(tmpdir(), 'dsh-session-turn-outline-loader-'))
+  const configPath = join(root, 'cordis.yml')
+  await writeFile(configPath, [...lines, ''].join('\n'))
+
+  context = new Context()
+  context.baseUrl = pathToFileURL(root).href + '/'
+  await context.plugin(Loader)
+  context.loader.builtins.include = Include
+  const modules = new Map<string, unknown>([
+    ['@deepseek-ai/dsh-session', SessionStore],
+    ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry],
+    ['@deepseek-ai/dsh-session-turn-outline', SessionTurnOutlinePlugin],
+  ])
+  context.loader.internal = {
+    version: 'v2',
+    async import(specifier: string) {
+      if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
+      return modules.get(specifier)
+    },
+  } as unknown as NonNullable<typeof context.loader.internal>
+  await context.loader.create({
+    name: 'cordis:include',
+    config: { path: pathToFileURL(configPath).href },
+  })
+  await context.loader.await()
+  return context
+}
+
+describe('real Loader composition', () => {
+  it('loads the shipped session-turn-outline YAML shape and serves the outline', async () => {
+    const loaded = await loadYaml([
+      "- name: '@deepseek-ai/dsh-session'",
+      "- name: '@deepseek-ai/dsh-session-projection'",
+      "- name: '@deepseek-ai/dsh-session-turn-outline'",
+    ])
+
+    const unloaded = [...loaded.loader.entries()]
+      .filter(entry => entry.fiber === undefined && !entry.disabled)
+      .map(entry => entry.options.name)
+    expect(unloaded).toEqual([])
+
+    const session = loaded.sessions.create(SessionId('composed'))
+    const boundary = session.append('turn/start', { turn: 1 }).seq
+    session.append('user/message', createUserMessage({
+      content: [{ type: 'text', text: 'composed prompt' }],
+      source: { kind: 'user' },
+    }), { surfaceOp: 'append' })
+    expect(loaded.sessionProjections.snapshot(session).values.turnOutline)
+      .toEqual({ turns: [{ turn: 1, seq: boundary, prompt: 'composed prompt' }] })
+  })
+
+  it('keeps the function-plugin namespace free of a default export', () => {
+    // A default export beside the named form makes the Loader discard the
+    // namespace (postmortem 0001) — pin its absence.
+    expect('default' in SessionTurnOutlinePlugin).toBe(false)
+  })
+})

+ 165 - 0
packages/session/session-turn-outline/tests/projection.spec.ts

@@ -0,0 +1,165 @@
+/**
+ * The `turnOutline` projection unit: mounting the plugin beside the
+ * projection registry serves the whole-log turn outline (turn number,
+ * `turn/start` seq, bounded first-prompt preview); compositions without the
+ * registry are unaffected; unmounting the plugin removes the key (HMR
+ * safety). Narrow fold paths with fabricated envelopes (non-human sources,
+ * regressive turn numbers) run against the exported definition directly.
+ */
+
+import { describe, expect, it } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import { createUserMessage } from '@deepseek-ai/dsh-llm'
+import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
+import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
+import * as SessionTurnOutlinePlugin from '@deepseek-ai/dsh-session-turn-outline'
+import { turnOutlineProjectionDefinition } from '@deepseek-ai/dsh-session-turn-outline/src/projection.ts'
+import type { TurnOutlineProjection } from '@deepseek-ai/dsh-session-turn-outline/types'
+
+async function harness(withOutlinePlugin: boolean): Promise<{ ctx: Context; session: Session }> {
+  const ctx = new Context()
+  await ctx.plugin(SessionStore)
+  await ctx.plugin(SessionProjectionRegistry)
+  if (withOutlinePlugin) await ctx.plugin(SessionTurnOutlinePlugin)
+  return { ctx, session: ctx.sessions.create(SessionId('outlined')) }
+}
+
+/** Append one human prompt; returns its seq. */
+function appendPrompt(session: Session, text: string): number {
+  return session.append('user/message', createUserMessage({
+    content: [{ type: 'text', text }],
+    source: { kind: 'user' },
+  }), { surfaceOp: 'append' }).seq
+}
+
+function outlineOf(ctx: Context, session: Session): TurnOutlineProjection {
+  return ctx.sessionProjections.snapshot(session).values.turnOutline as TurnOutlineProjection
+}
+
+describe('turn outline projection unit', () => {
+  it('serves an empty outline before any turn starts', async () => {
+    const { ctx, session } = await harness(true)
+    expect(outlineOf(ctx, session)).toEqual({ turns: [] })
+    expect(ctx.sessionProjections.checkpoint(session).turnOutline)
+      .toEqual({ ver: 1, seq: -1, val: { turns: [] } })
+  })
+
+  it('folds each started turn with its boundary seq and first human prompt only', async () => {
+    const { ctx, session } = await harness(true)
+    const firstBoundary = session.append('turn/start', { turn: 1 }).seq
+    appendPrompt(session, 'hello world')
+    appendPrompt(session, 'a later steer must not replace the prompt')
+    const secondBoundary = session.append('turn/start', { turn: 2 }).seq
+    appendPrompt(session, 'second prompt')
+    expect(outlineOf(ctx, session)).toEqual({
+      turns: [
+        { turn: 1, seq: firstBoundary, prompt: 'hello world' },
+        { turn: 2, seq: secondBoundary, prompt: 'second prompt' },
+      ],
+    })
+  })
+
+  it('keeps an empty preview for a turn whose prompt never lands', async () => {
+    const { ctx, session } = await harness(true)
+    const boundary = session.append('turn/start', { turn: 1 }).seq
+    session.append('step/start', { turn: 1, step: 1 })
+    expect(outlineOf(ctx, session)).toEqual({ turns: [{ turn: 1, seq: boundary, prompt: '' }] })
+  })
+
+  it('collapses whitespace, joins text blocks, and caps the preview at 160 characters', async () => {
+    const { ctx, session } = await harness(true)
+    session.append('turn/start', { turn: 1 })
+    session.append('user/message', createUserMessage({
+      content: [
+        { type: 'text', text: `  first\n\nline\t${'x'.repeat(200)}` },
+        { type: 'text', text: 'never reached past the budget' },
+      ],
+      source: { kind: 'user' },
+    }), { surfaceOp: 'append' })
+    const preview = outlineOf(ctx, session).turns[0]?.prompt
+    expect(preview).toBeDefined()
+    expect(preview).toMatch(/^first line x+$/)
+    expect(preview).toHaveLength(160)
+  })
+
+  it('ignores non-human user/message sources and pre-turn prompts', async () => {
+    const { ctx, session } = await harness(true)
+    appendPrompt(session, 'queued before any turn')
+    session.append('turn/start', { turn: 1 })
+    session.append('user/message', createUserMessage({
+      content: [{ type: 'text', text: 'injected context' }],
+      source: { kind: 'plugin', plugin: 'test-injector', form: 'relay' },
+    }), { surfaceOp: 'append' })
+    expect(outlineOf(ctx, session)).toEqual({
+      turns: [{ turn: 1, seq: 1, prompt: '' }],
+    })
+  })
+
+  it('notifies the change feed only when the outline actually moves', async () => {
+    const { ctx, session } = await harness(true)
+    const changes: { key: string; seq: number }[] = []
+    ctx.sessionProjections.onChanged((_session, key, _value, seq) => {
+      if (key === 'turnOutline') changes.push({ key, seq })
+    })
+    const boundarySeq = session.append('turn/start', { turn: 1 }).seq
+    session.append('step/start', { turn: 1, step: 1 })
+    const promptSeq = appendPrompt(session, 'hello')
+    appendPrompt(session, 'second human message in the same turn')
+    session.append('step/end', { turn: 1, step: 1 })
+    expect(changes).toEqual([
+      { key: 'turnOutline', seq: boundarySeq },
+      { key: 'turnOutline', seq: promptSeq },
+    ])
+  })
+
+  it('skips a boundary that does not advance the turn number (fabricated envelope)', () => {
+    const state: TurnOutlineProjection = { turns: [{ turn: 2, seq: 5, prompt: 'kept' }] }
+    const regressive = {
+      type: 'turn/start',
+      seq: 9,
+      time: 0,
+      data: { turn: 2 },
+    } as unknown as SessionEvent
+    expect(turnOutlineProjectionDefinition.apply(state, regressive)).toBe(state)
+  })
+
+  it('folds turns already in the log when the plugin mounts late (lazy cell build)', async () => {
+    const { ctx, session } = await harness(false)
+    session.append('turn/start', { turn: 1 })
+    appendPrompt(session, 'pre-mount prompt')
+    await ctx.plugin(SessionTurnOutlinePlugin)
+    expect(outlineOf(ctx, session).turns).toEqual([{ turn: 1, seq: 0, prompt: 'pre-mount prompt' }])
+  })
+
+  it('has no key without the plugin and drops it when the plugin unloads (HMR safety)', async () => {
+    const { ctx, session } = await harness(false)
+    expect('turnOutline' in ctx.sessionProjections.snapshot(session).values).toBe(false)
+    const fiber = await ctx.plugin(SessionTurnOutlinePlugin)
+    session.append('turn/start', { turn: 1 })
+    expect('turnOutline' in ctx.sessionProjections.snapshot(session).values).toBe(true)
+    await fiber.dispose()
+    expect('turnOutline' in ctx.sessionProjections.snapshot(session).values).toBe(false)
+  })
+
+  it('rejects a persisted checkpoint whose turns are not strictly increasing', async () => {
+    const { ctx, session } = await harness(true)
+    const checkpoint = ctx.sessionProjections.checkpoint(session)
+    const row = checkpoint.turnOutline
+    expect(row).toBeDefined()
+    expect(() => ctx.sessionProjections.restore({
+      ...checkpoint,
+      turnOutline: {
+        ...row!,
+        val: { turns: [{ turn: 2, seq: 1, prompt: '' }, { turn: 2, seq: 4, prompt: '' }] },
+      },
+    }, [], 0, session.header)).toThrow(/strictly increasing/)
+    expect(() => ctx.sessionProjections.restore({
+      ...checkpoint,
+      turnOutline: {
+        ...row!,
+        val: { turns: [{ turn: 1, seq: 1, prompt: 'ok' }, { turn: 2, seq: 4, prompt: '' }] },
+      },
+    }, [], 0, session.header)).not.toThrow()
+  })
+})

+ 30 - 0
packages/session/session-turn-outline/tsconfig.json

@@ -0,0 +1,30 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cosmokit"
+    },
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../runtime-diagnostics/invariants"
+    },
+    {
+      "path": "../../llm/llm"
+    },
+    {
+      "path": "../../core/session"
+    },
+    {
+      "path": "../session-projection"
+    }
+  ]
+}

+ 31 - 0
pnpm-lock.yaml

@@ -1680,6 +1680,9 @@ importers:
       '@deepseek-ai/dsh-session-stats':
         specifier: workspace:^
         version: link:../../session/session-stats
+      '@deepseek-ai/dsh-session-turn-outline':
+        specifier: workspace:^
+        version: link:../../session/session-turn-outline
       '@deepseek-ai/dsh-subprocess':
         specifier: workspace:^
         version: link:../../subprocess/subprocess
@@ -7796,6 +7799,34 @@ importers:
         specifier: workspace:^
         version: link:../../util/timeout
 
+  packages/session/session-turn-outline:
+    dependencies:
+      zod:
+        specifier: ^4.4.3
+        version: 4.4.3
+    devDependencies:
+      '@deepseek-ai/cordis':
+        specifier: workspace:^
+        version: link:../../../vendor/cordis
+      '@deepseek-ai/cordis-plugin-include':
+        specifier: workspace:^
+        version: link:../../../vendor/include
+      '@deepseek-ai/cordis-plugin-loader':
+        specifier: workspace:^
+        version: link:../../../vendor/loader
+      '@deepseek-ai/dsh-invariants':
+        specifier: workspace:^
+        version: link:../../runtime-diagnostics/invariants
+      '@deepseek-ai/dsh-llm':
+        specifier: workspace:^
+        version: link:../../llm/llm
+      '@deepseek-ai/dsh-session':
+        specifier: workspace:^
+        version: link:../../core/session
+      '@deepseek-ai/dsh-session-projection':
+        specifier: workspace:^
+        version: link:../session-projection
+
   packages/settings/settings:
     dependencies:
       '@deepseek-ai/dsh-util-values':

+ 1 - 0
scripts/verify-package-readme-model-experience.ts

@@ -141,6 +141,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
   'packages/session/session-projection': { kind: 'none', reason: 'The projection registry serves client-facing read models of already-logged session state and registers nothing model-facing.' },
   'packages/session/session-projection-cache': { kind: 'none', reason: 'The persisted cache accelerates host-side cold reads of projection state and registers nothing model-facing.' },
   'packages/session/session-stats': { kind: 'none', reason: 'The sessionStats unit folds already-logged step boundaries into a client-facing read model and registers nothing model-facing.' },
+  'packages/session/session-turn-outline': { kind: 'none', reason: 'The turnOutline unit folds already-logged turn boundaries into a client-facing read model and registers nothing model-facing.' },
   'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers nothing model-facing.' },
   'packages/session-query/session-query-sqlite': { kind: 'none', reason: 'The search backend returns hits only to callers and registers nothing model-facing.' },
   'packages/settings/settings': { kind: 'indirect', reason: 'The seam stores and resolves user settings; consumer plugins own any model-facing content fed by a value.' },

+ 4 - 0
tsconfig.base.json

@@ -79,6 +79,8 @@
       "@deepseek-ai/dsh-util-workspace-path": ["./packages/util/workspace-path/src/index.ts"],
       "@deepseek-ai/dsh-session-stats/types": ["./packages/session/session-stats/src/types.ts"],
       "@deepseek-ai/dsh-session-stats/client": ["./packages/session/session-stats/src/client.ts"],
+      "@deepseek-ai/dsh-session-turn-outline/types": ["./packages/session/session-turn-outline/src/types.ts"],
+      "@deepseek-ai/dsh-session-turn-outline/client": ["./packages/session/session-turn-outline/src/client.ts"],
       "@deepseek-ai/dsh-token-meter/client": ["./packages/llm/token-meter/src/client.ts"],
       "@deepseek-ai/dsh-plan-mode/types": ["./packages/plan/plan-mode/src/types.ts"],
       "@deepseek-ai/dsh-plan-mode/client": ["./packages/plan/plan-mode/src/client.ts"],
@@ -438,6 +440,8 @@
       "@deepseek-ai/dsh-session-title-first-prompt-llm/invariant": ["./packages/session/session-title-first-prompt-llm/src/invariant.ts"],
       "@deepseek-ai/dsh-session-title-llm": ["./packages/session/session-title-llm/src"],
       "@deepseek-ai/dsh-session-title-llm/invariant": ["./packages/session/session-title-llm/src/invariant.ts"],
+      "@deepseek-ai/dsh-session-turn-outline": ["./packages/session/session-turn-outline/src"],
+      "@deepseek-ai/dsh-session-turn-outline/invariant": ["./packages/session/session-turn-outline/src/invariant.ts"],
       "@deepseek-ai/dsh-settings": ["./packages/settings/settings/src"],
       "@deepseek-ai/dsh-settings/invariant": ["./packages/settings/settings/src/invariant.ts"],
       "@deepseek-ai/dsh-settings-file": ["./packages/settings/settings-file/src"],

+ 1 - 0
tsconfig.host.json

@@ -180,6 +180,7 @@
     { "path": "./packages/session/session-title-llm" },
     { "path": "./packages/session/session-title-first-prompt-llm" },
     { "path": "./packages/session/session-title-all-prompts-llm" },
+    { "path": "./packages/session/session-turn-outline" },
     { "path": "./packages/session/session-telemetry" },
     { "path": "./packages/identity/anonymous-user-id" },
     { "path": "./packages/session/session-telemetry-otel" },