Просмотр исходного кода

round 2: pin checkpoint recognition at compile time

Move COMPACT_CHECKPOINT_SOURCE and isCompactCheckpointSource into a
cordis-free src/checkpoint.ts leaf, re-exported from the root so every
host-side consumer keeps its import. The client can then type-import the
leaf without reaching dsh-session's root, whose Context merge declares the
host sessions service and collides with the client's -- the dsh-commands/brand
shape. Renaming the plugin id now fails the client typecheck.

Also: keep recoverable summary text when a compact/summary mixes text with
other block types, and capture the seeded-history provenance seqs from the
pushes that produce them instead of deriving them by arithmetic.
Hypatia May 1 месяц назад
Родитель
Сommit
91ee264e16

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.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 .agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md
-2026-07-30-web-transcript-log-ordered-projection.md: 22e687ebc0a323a28eb554a9ad36749a0b6f3da6
-2026-07-30-web-transcript-log-ordered-projection.zh.md: 97004f7384aed8f9e43b0949b2fcd4b8c185ca65
+2026-07-30-web-transcript-log-ordered-projection.md: 0c58373d58e08fbfe260f16f2104b3f7d1ccc3fd
+2026-07-30-web-transcript-log-ordered-projection.zh.md: 49de0a9ecbbf2da92113f8ad63afe6478caedadd

+ 13 - 7
.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md

@@ -22,18 +22,24 @@ The marker's summary text comes from the checkpoint's own `compact/summary` prov
 
 No persisted event, RPC envelope, compaction transaction, or model-visible surface changed, and no migration is required.
 
-## Recognizing a checkpoint: the local literal and its drift trap
+## Recognizing a checkpoint: one declaration, pinned at compile time
 
 Recognition needs all three conditions, as in the terminal: `event.type === 'user/message'`, the compaction seam's checkpoint plugin source, **and** `isReplacementSurfaceEvent(event)`. A plugin-sourced `user/message` that *appends* is injected context — a session-reference card — not a compaction.
 
-The client restates that plugin source as a local literal, because `dsh-compact` is unreachable from `packages/client/runtime`'s program in **both** directions:
+What is unreachable from a `packages/client/*` program is `dsh-compact`'s **root**, not the package. The root reaches `dsh-session`'s root, whose cordis `Context` merge declares the host `sessions: SessionStore` against the client's `sessions: ISessions` — `TS2717`, the one-program-per-side rule in [development.md](../../../../docs/development.md#typescript-project-layout) — and that holds for a type-only import too, because the collision is a compiler fact rather than a bundler one.
 
-- a **value** import fails the client purity gate (`packages/client/tsdown.client.ts`), and `dsh-compact`'s root value-imports cordis, so admitting it would pull `CompactService` into the browser bundle;
-- a **type-only** import fails typecheck. `dsh-compact`'s root reaches `dsh-session`'s root, whose cordis `Context` merge declares the host `sessions: SessionStore` against this program's `sessions: ISessions` — `TS2717`, the one-program-per-side rule in [development.md](../../../../docs/development.md#typescript-project-layout). This was expected to work and does not; `import type` is erased before the *bundler* runs, but not before the *compiler* does, and the collision is a compiler fact.
+The repo's answer to exactly this is a cordis-free leaf subpath, and this change adds one: `COMPACT_CHECKPOINT_SOURCE` and `isCompactCheckpointSource` now live in `packages/compact/compact/src/checkpoint.ts`, which imports no cordis and augments no module (the `dsh-commands/brand` / `dsh-llm/message` shape), and the root re-exports both so every host-side consumer — the terminal's chat helpers, `dsh-session-reference`'s projection — is unchanged. The adapter pins its literal to that declaration with a type-only import:
 
-The drift protection therefore lives in a test, not in a type: `packages/client/runtime/tests/compact-checkpoint-pin.spec.ts` runs in the client **test** program, which carries no such collision, and drives the adapter with a checkpoint built from the canonical `COMPACT_CHECKPOINT_SOURCE` itself. Renaming the seam's plugin fails there instead of silently deleting every compaction marker from the web transcript. `dsh-compact` is a `devDependency` of `dsh-client-runtime` and a reference of `tsconfig.client.json` only — never of a `packages/client/*` package project.
+```ts
+import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpoint'
+const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact'
+```
 
-That is a deliberate divergence from the terminal, which value-imports `isCompactCheckpointSource` directly because no gate applies host-side.
+Renaming the seam's plugin id is now a compile error in the client: `TS2322: Type '"compact"' is not assignable to type '"compaction"'`. The import must stay **type-only** — a value import of any `@deepseek-ai` package that is neither a platform module nor an inline-safe wire layer is rejected by the client purity gate (`packages/client/tsdown.client.ts`), whose own message records that type-only imports are erased and never reach it. A type-only leaf import needs both a `tsconfig.base.json` `paths` entry and `{"path": "../../compact/compact"}` in `packages/client/runtime/tsconfig.json` `references`: composite `rootDir` rules apply to erased imports as well, and without the reference the diagnostic is `TS6059`/`TS6307`.
+
+`packages/client/runtime/tests/compact-checkpoint-pin.spec.ts` stays as the behavioral half, driving the adapter with a checkpoint built from the canonical **value**. It runs in the client **test** program, which may value-import the root; a `packages/client/*` package program may not.
+
+The divergence from the terminal is therefore narrow: both frontends recognize a checkpoint from the same declaration — the terminal value-imports `isCompactCheckpointSource` host-side, where no gate applies, and the client pins the type.
 
 ## What #835's positional anchors were for, and why they are dissolved rather than lost
 
@@ -41,7 +47,7 @@ The unmerged manual-compaction-queueing branch fixes the same interleaving bug b
 
 ## Alternatives considered
 
-**Add `dsh-compact` to the client `INLINE_SAFE` allowlist** and move the predicate to a cordis-free subpath. Rejected: `INLINE_SAFE` matches on specifier *prefix*, so admitting the package admits its cordis-importing root too; the allowlist is a reviewer promise about client-facing subpaths, not a purity proof. It also needs a new export and a `files` fix, and it would not have helped — the blocking collision turned out to be in the compiler, which an allowlist does not touch.
+**Value-import the predicate** from the new leaf and add `dsh-compact` to the client `INLINE_SAFE` allowlist. Rejected: the client needs the plugin id, not the predicate — a type is enough, and an erased import never reaches the purity gate, so nothing has to be admitted to it. The allowlist would only matter for a value import, and there it is a poor trade: `INLINE_SAFE` matches on specifier *prefix*, so admitting the package admits its cordis-importing root along with the leaf.
 
 **A bare shape rule** — any replacement `user/message` is a compaction. Rejected: correct today only because compaction is the sole producer of replacement `user/message`s, with nothing to catch it if that changes. The pinning spec costs one file and removes exactly that risk.
 

+ 13 - 7
.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md

@@ -22,18 +22,24 @@ surface 顺序还让另外两个问题成为结构性的。一次替换之后它
 
 没有任何持久化事件、RPC 信封、压缩事务或模型可见 surface 发生变化,也不需要迁移。
 
-## 识别检查点:本地字面量与它的漂移陷阱
+## 识别检查点:同一份声明,在编译期钉住
 
 识别需要三个条件同时成立,与终端一致:`event.type === 'user/message'`、压缩缝隙的检查点插件来源,**以及** `isReplacementSurfaceEvent(event)`。一条 append 的插件来源 `user/message` 是注入上下文——跨会话引用卡片——不是压缩。
 
-客户端把该插件来源重述为一个本地字面量,因为 `dsh-compact` 在**两个**方向上都无法从 `packages/client/runtime` 的程序到达:
+从 `packages/client/*` 程序无法到达的是 `dsh-compact` 的**根部**,而不是这个包。根部会到达 `dsh-session` 的根部,后者的 cordis `Context` 合并声明了宿主侧 `sessions: SessionStore`,与客户端的 `sessions: ISessions` 冲突——`TS2717`,即 [development.md](../../../../docs/development.md#typescript-project-layout) 中每侧一个 program 的规则;这一点对仅类型导入同样成立,因为该冲突是编译器事实而非打包器事实。
 
-- **值**导入会失败于客户端纯度门禁(`packages/client/tsdown.client.ts`),而 `dsh-compact` 的根部会值导入 cordis,因此放行它就会把 `CompactService` 拉进浏览器产物;
-- **仅类型**导入会失败于类型检查。`dsh-compact` 的根部会到达 `dsh-session` 的根部,后者的 cordis `Context` 合并声明了宿主侧 `sessions: SessionStore`,与本程序的 `sessions: ISessions` 冲突——`TS2717`,即 [development.md](../../../../docs/development.md#typescript-project-layout) 中每侧一个 program 的规则。这一点原本预期可行,实际不可行:`import type` 在**打包器**运行前被擦除,但不在**编译器**运行前被擦除,而该冲突是编译器事实。
+本仓库对这一情形的既有答案是不含 cordis 的叶子子路径,本次变更就新增了一个:`COMPACT_CHECKPOINT_SOURCE` 与 `isCompactCheckpointSource` 现在住在 `packages/compact/compact/src/checkpoint.ts`,它不导入 cordis、也不增强任何模块(即 `dsh-commands/brand` / `dsh-llm/message` 的形状),而包根重新导出两者,因此每个宿主侧消费方——终端的 chat helper、`dsh-session-reference` 的投影——都不需改动。适配器用仅类型导入把它的字面量钉在该声明上:
 
-因此漂移保护住在一个测试里,而不是一个类型里:`packages/client/runtime/tests/compact-checkpoint-pin.spec.ts` 运行在客户端**测试**程序中——那里不存在这一冲突——并用由权威 `COMPACT_CHECKPOINT_SOURCE` 本身构造的检查点驱动适配器。重命名缝隙的插件会在那里失败,而不是无声地把每个压缩标记从 Web 记录中删除。`dsh-compact` 只是 `dsh-client-runtime` 的 `devDependency` 以及 `tsconfig.client.json` 的一条引用——绝不是任何 `packages/client/*` 包工程的引用。
+```ts
+import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpoint'
+const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact'
+```
 
-这是与终端的一次刻意分歧:终端直接值导入 `isCompactCheckpointSource`,因为宿主侧不适用任何门禁。
+重命名缝隙的插件 id 现在会在客户端产生编译错误:`TS2322: Type '"compact"' is not assignable to type '"compaction"'`。该导入必须保持**仅类型**——任何既非平台模块又非 inline-safe wire 层的 `@deepseek-ai` 包值导入都会被客户端纯度门禁(`packages/client/tsdown.client.ts`)拒绝,而它自己的报错信息就记录着仅类型导入会被擦除、永不抵达该门禁。仅类型的叶子导入同时需要 `tsconfig.base.json` 的一条 `paths` 条目和 `packages/client/runtime/tsconfig.json` `references` 中的 `{"path": "../../compact/compact"}`:composite 的 `rootDir` 规则同样适用于被擦除的导入,缺少该引用时的诊断是 `TS6059`/`TS6307`。
+
+`packages/client/runtime/tests/compact-checkpoint-pin.spec.ts` 作为行为侧的另一半保留,用由权威**值**构造的检查点驱动适配器。它运行在客户端**测试**程序中,那里可以值导入包根;`packages/client/*` 包工程不可以。
+
+因此与终端的分歧很窄:两个前端都从同一份声明识别检查点——终端在宿主侧值导入 `isCompactCheckpointSource`(那里不适用任何门禁),客户端钉住类型。
 
 ## #835 的位置锚点是为什么而存在,以及为什么它是被溶解而非丢失
 
@@ -41,7 +47,7 @@ surface 顺序还让另外两个问题成为结构性的。一次替换之后它
 
 ## Alternatives considered
 
-**把 `dsh-compact` 加入客户端 `INLINE_SAFE` 白名单**,并把谓词搬到一个不含 cordis 的子路径。已拒绝:`INLINE_SAFE` 按标识符*前缀*匹配,因此放行该包也就放行了它那个会导入 cordis 的根部;该白名单是对面向客户端子路径的评审承诺,不是纯度证明。它还需要一个新导出与一处 `files` 修正,而且本来也帮不上忙——真正阻塞的冲突出在编译器,白名单碰不到那里
+**从新叶子值导入该谓词**,并把 `dsh-compact` 加入客户端 `INLINE_SAFE` 白名单。已拒绝:客户端需要的是插件 id,不是谓词——一个类型就够了,而被擦除的导入根本不会抵达纯度门禁,因此无需向它放行任何东西。白名单只在值导入时才有意义,而在那里它是笔糟糕的交换:`INLINE_SAFE` 按标识符*前缀*匹配,因此放行该包会连它那个会导入 cordis 的根部一起放行
 
 **一条纯形状规则**——任何 replacement `user/message` 都是压缩。已拒绝:它今天正确只因为压缩是 replacement `user/message` 的唯一生产者,一旦这点改变便无任何机制能捕获。那个 pin 测试只花一个文件,就精确消除了这一风险。
 

+ 49 - 36
apps/web/tests/seeded-history.e2e.ts

@@ -43,6 +43,7 @@ function withCompaction(raw: string): string {
     seq: number
     time: number
     surfaceOp?: unknown
+    data?: { turn?: unknown }
   })
   const surfaceSeqs = events
     .filter(event => event.surfaceOp === 'append'
@@ -57,44 +58,56 @@ function withCompaction(raw: string): string {
   if (first === undefined || last === undefined || tail === undefined) {
     throw new Error('seeded-history compaction requires a non-empty closed surface')
   }
+  // The transaction opens the turn after the recording's last closed one; read
+  // it from the fixture so a re-recording with a different turn count stays
+  // valid instead of appending a duplicate turn number.
+  const lastTurn = events.filter(event => event.type === 'turn/end').at(-1)?.data?.turn
+  if (typeof lastTurn !== 'number') {
+    throw new Error('seeded-history compaction requires a recording ending on a closed turn')
+  }
+  const turn = lastTurn + 1
   let seq = tail.seq + 1
   let time = tail.time + 1
-  const at = (event: Record<string, unknown>): string => JSON.stringify({ ...event, seq: seq++, time: time++ })
-  // The checkpoint's provenance names the two events appended before it.
-  const startSeq = seq + 1
-  const summarySeq = seq + 2
-  lines.push(
-    at({ type: 'turn/start', data: { turn: 2, trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'compact' } } } }),
-    at({ type: 'compact/start', data: { turn: 2 } }),
-    at({
-      type: 'compact/summary',
-      data: {
-        summary: [{
-          type: 'text',
-          text: '## Cold resume compact summary\n\n- The exact summary remains available.',
-        }],
-        shadowedRange: { start: first, end: last },
-        shadowedSeqs: surfaceSeqs,
-        shadowedTokenCount: 10_000,
-        provider: 'snapshot',
-        model: 'snapshot-compactor',
-      },
-    }),
-    at({
-      type: 'user/message',
-      data: {
-        content: [{
-          type: 'text',
-          text: '<context_checkpoint>Model-only compact checkpoint.</context_checkpoint>',
-        }],
-        source: { kind: 'plugin', plugin: 'compact' },
-      },
-      surfaceOp: { op: 'replace', start: first, end: last },
-      sourceEventSeqs: [startSeq, summarySeq, ...surfaceSeqs],
-    }),
-    at({ type: 'compact/end', data: { turn: 2 } }),
-    at({ type: 'turn/end', data: { turn: 2, reason: { kind: 'completed' } } }),
-  )
+  /**
+   * Append one event at the next seq/time.
+   * @param event - the event body, without seq/time.
+   * @returns the seq it took, so provenance cites the push instead of arithmetic over the push order below.
+   */
+  const at = (event: Record<string, unknown>): number => {
+    const taken = seq++
+    lines.push(JSON.stringify({ ...event, seq: taken, time: time++ }))
+    return taken
+  }
+  at({ type: 'turn/start', data: { turn, trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'compact' } } } })
+  const startSeq = at({ type: 'compact/start', data: { turn } })
+  const summarySeq = at({
+    type: 'compact/summary',
+    data: {
+      summary: [{
+        type: 'text',
+        text: '## Cold resume compact summary\n\n- The exact summary remains available.',
+      }],
+      shadowedRange: { start: first, end: last },
+      shadowedSeqs: surfaceSeqs,
+      shadowedTokenCount: 10_000,
+      provider: 'snapshot',
+      model: 'snapshot-compactor',
+    },
+  })
+  at({
+    type: 'user/message',
+    data: {
+      content: [{
+        type: 'text',
+        text: '<context_checkpoint>Model-only compact checkpoint.</context_checkpoint>',
+      }],
+      source: { kind: 'plugin', plugin: 'compact' },
+    },
+    surfaceOp: { op: 'replace', start: first, end: last },
+    sourceEventSeqs: [startSeq, summarySeq, ...surfaceSeqs],
+  })
+  at({ type: 'compact/end', data: { turn } })
+  at({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
   return `${lines.join('\n')}\n`
 }
 

+ 1 - 1
docs/cordis-catalog/services.md

@@ -486,7 +486,7 @@ abstract compactRegion( start: number, end: number, agent: CompactAgentContext,
 
 Types: [CompactionResult](../core-data-structures/compaction.md) · [CompactionTrigger](../core-data-structures/compaction.md)
 
-Source: [`packages/compact/compact/src/index.ts:54`](../../packages/compact/compact/src/index.ts)
+Source: [`packages/compact/compact/src/index.ts:45`](../../packages/compact/compact/src/index.ts)
 
 ## `ctx.directoryPicker` — `DirectoryPicker` (abstract seam)
 

+ 2 - 2
packages/client/runtime/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/client/runtime/README.md
-README.md: 7bf8050ea988945bead491d02528906863b129a6
-README.zh.md: ff3c91dee956b2c65f0029c97c73d73f5a627b05
+README.md: 69576f1e23a34b83915d075c4120d04368c49f43
+README.zh.md: 7243a9119d9c669c76e8490bd271b2669568bff0

+ 1 - 1
packages/client/runtime/README.md

@@ -18,7 +18,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
 
 ## The human transcript
 
-`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. That source literal is restated locally because `dsh-compact` is unreachable from this program in both directions (the client purity gate rejects a value import; a type-only import collides the host `Context.sessions` merge) — `tests/compact-checkpoint-pin.spec.ts` is the drift trap.
+`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's). `tests/compact-checkpoint-pin.spec.ts` covers the same drift behaviorally.
 
 Because the projection is log-ordered, the node array is seq-monotonic by construction: log-only `command/run` / `command/done` nodes splice in by seq, `Session` merges interrupted frozen nodes by their fractional seqs, and a window whose checkpoint cites a shadowed range outside it renders the marker with nothing logged. The marker's summary text comes from the checkpoint's `compact/summary` provenance; a window cut that left the provenance outside makes the row non-expandable rather than empty, and a later page that supplies it resolves the text. Performance contract: one append materializes one node, an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity.
 

+ 1 - 1
packages/client/runtime/README.zh.md

@@ -18,7 +18,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
 
 ## 人类对话记录
 
-`ConversationSnapshot.nodes` 是人类对话记录,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口——每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,外加每次落地的压缩检查点贡献一个 `CompactionSummaryNode` 标记——且从不查询 surface 顺序。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩缝隙插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。该来源字面量在本地重述,因为 `dsh-compact` 在两个方向上都无法从本程序到达(客户端纯度门禁拒绝值导入;仅类型导入会与 host 的 `Context.sessions` 合并冲突)——`tests/compact-checkpoint-pin.spec.ts` 是漂移陷阱
+`ConversationSnapshot.nodes` 是人类对话记录,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口——每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,外加每次落地的压缩检查点贡献一个 `CompactionSummaryNode` 标记——且从不查询 surface 顺序。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩缝隙插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩缝隙自己的声明上:在那里改名会让此处 `tsc` 失败;而对该包做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。`tests/compact-checkpoint-pin.spec.ts` 从行为侧覆盖同一漂移
 
 由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时该行不可展开而非空白,后续补上溯源的分页会解析出文本。性能契约:一次追加物化一个节点,不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。
 

+ 8 - 1
packages/client/runtime/src/client/sessions/conversation.ts

@@ -129,7 +129,14 @@ export interface CompactionSummaryNode {
   summary: string | null
 }
 
-/** Fallback for surface events this UI version does not know. */
+/**
+ * Fallback for surface events this UI version does not know: the documented
+ * default arm of `SessionEventMap`, which is merge-extensible, so the
+ * projection's switch cannot end in `assertNever`. No event produces this node
+ * today — `isAppendSurfaceEvent` admits only the four types in core's
+ * `SurfaceEventType`, and each has its own arm — and it exists so widening that
+ * set core-side degrades to a raw row instead of dropping the event silently.
+ */
 export interface UnknownSurfaceNode {
   kind: 'unknown'
   seq: number

+ 23 - 16
packages/client/runtime/src/client/sessions/transcript-adapter.ts

@@ -12,25 +12,26 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
 // browser bundle cannot resolve; surface.ts has no Node dependencies.
 import { isAppendSurfaceEvent, isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
 import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
+// Cordis-free leaf subpath (the dsh-commands/brand shape): the seam's own
+// declaration of the checkpoint source, reachable as a TYPE from this program.
+// The package ROOT is not — it reaches dsh-session's root, whose Context merge
+// declares the HOST `sessions: SessionStore` against this program's
+// `sessions: ISessions` (TS2717, the one-program-per-side rule in
+// docs/development.md).
+import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpoint'
 import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
 import type { CommandNode, CompactionSummaryNode, ConversationNode } from './conversation.ts'
 import { toAssistantBlocks } from './conversation.ts'
 
 /**
- * The compaction seam's checkpoint plugin, restated locally.
- *
- * `dsh-compact` cannot be reached from this program in any form. A VALUE import
- * fails the client purity gate (`packages/client/tsdown.client.ts`) and would
- * pull the cordis `Service` base into the browser bundle; a TYPE-ONLY import of
- * its `COMPACT_CHECKPOINT_SOURCE` fails typecheck, because `dsh-compact`'s root
- * reaches `dsh-session`'s root, whose `Context` merge declares the HOST
- * `sessions: SessionStore` against this program's `sessions: ISessions`
- * (`TS2717` — the one-program-per-side rule in docs/development.md). The
- * literal is pinned to the canonical const by
- * `tests/compact-checkpoint-pin.spec.ts`, which runs in the client TEST program
- * where that collision does not apply.
+ * The compaction seam's checkpoint plugin, pinned to the seam's own declaration
+ * at COMPILE time: renaming it there fails this annotation (`TS2322`). The
+ * import stays type-only because a value import would fail the client purity
+ * gate (`packages/client/tsdown.client.ts`) — cross-plugin value imports are
+ * forbidden in a browser bundle — while an erased type never reaches it.
+ * `tests/compact-checkpoint-pin.spec.ts` covers the same drift behaviorally.
  */
-const COMPACT_PLUGIN = 'compact'
+const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact'
 
 /** In-window tool/call index entry (result-card backfill + runningCalls material). */
 export interface CallIndexEntry {
@@ -126,14 +127,20 @@ function isTranscriptEvent(event: SessionEvent): boolean {
   return isAppendSurfaceEvent(event) || isCompactCheckpoint(event)
 }
 
-/** Concatenated text of a `compact/summary` payload, or null when it carries no usable text. */
+/**
+ * Concatenated text of a `compact/summary` payload, or null when it carries no
+ * usable text. The payload is a `ContentBlock[]` whose union is
+ * merge-extensible, so a non-text block is skipped rather than discarding the
+ * text beside it; a payload with no text block at all falls to null through the
+ * empty check.
+ */
 function compactSummaryText(event: SessionEvent): string | null {
   const summary = (event.data as unknown as { summary?: unknown }).summary
-  if (!Array.isArray(summary) || summary.length === 0) return null
+  if (!Array.isArray(summary)) return null
   let text = ''
   for (const block of summary as readonly unknown[]) {
     const candidate = block as { type?: unknown; text?: unknown }
-    if (candidate.type !== 'text' || typeof candidate.text !== 'string') return null
+    if (candidate.type !== 'text' || typeof candidate.text !== 'string') continue
     text += candidate.text
   }
   return text.trim() === '' ? null : text

+ 10 - 12
packages/client/runtime/tests/compact-checkpoint-pin.spec.ts

@@ -1,17 +1,15 @@
 /**
- * Drift trap for the compaction-checkpoint recognition rule.
+ * Behavioral half of the compaction-checkpoint drift trap.
  *
- * `TranscriptAdapter` restates the compaction seam's checkpoint source as a
- * local literal because it cannot import `dsh-compact` in any form: a VALUE
- * import fails the client purity gate, and a TYPE-ONLY import fails typecheck —
- * `dsh-compact`'s root reaches `dsh-session`'s root, whose cordis `Context`
- * merge declares the HOST `sessions: SessionStore` against the client program's
- * `sessions: ISessions` (`TS2717`). This spec runs in the client TEST program,
- * which does not carry that collision, and it is the only thing keeping the two
- * implementations from drifting: it drives the adapter with a checkpoint built
- * from the canonical `COMPACT_CHECKPOINT_SOURCE` itself, so renaming the seam's
- * plugin fails HERE instead of silently deleting every compaction marker from
- * the web transcript.
+ * `TranscriptAdapter` pins its plugin literal to the seam's own declaration at
+ * compile time through a type-only import of `dsh-compact/checkpoint`, so
+ * renaming the seam's plugin already fails `tsc`. This spec covers the same
+ * drift from the other side — end to end through the adapter, driving it with a
+ * checkpoint built from the canonical `COMPACT_CHECKPOINT_SOURCE` **value** and
+ * checking the seam's own predicate agrees. It runs in the client TEST program,
+ * which can value-import the package root; a `packages/client/*` package
+ * program cannot, because that root reaches `dsh-session`'s root and collides
+ * the host `Context.sessions` merge (`TS2717`).
  */
 
 import { COMPACT_CHECKPOINT_SOURCE, isCompactCheckpointSource } from '@deepseek-ai/dsh-compact'

+ 14 - 1
packages/client/runtime/tests/transcript-adapter.spec.ts

@@ -225,7 +225,7 @@ describe('TranscriptAdapter', () => {
 
     it.each([
       ['absent provenance', undefined],
-      ['malformed summary blocks', compactSummary(1, [{ type: 'image', data: 'nope' }])],
+      ['text-less summary blocks', compactSummary(1, [{ type: 'image', data: 'nope' }])],
       ['a whitespace-only summary', compactSummary(1, [{ type: 'text', text: '   ' }])],
       ['an empty summary array', compactSummary(1, [])],
       ['a non-array summary', compactSummary(1, 'plain string')],
@@ -240,6 +240,19 @@ describe('TranscriptAdapter', () => {
       ])
     })
 
+    it('keeps the text of a mixed-block summary, skipping the blocks it cannot render', () => {
+      // ContentBlock is merge-extensible and the payload type is ContentBlock[],
+      // so a non-text block must not discard recoverable text beside it.
+      const adapter = new TranscriptAdapter()
+      adapter.reset([
+        compactSummary(1, [{ type: 'text', text: '可用摘要' }, { type: 'image', data: 'nope' }]),
+        checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }),
+      ])
+      expect(adapter.nodes()).toEqual([
+        { kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: '可用摘要' },
+      ])
+    })
+
     it('leaves the summary null when the checkpoint records no provenance at all', () => {
       const adapter = new TranscriptAdapter()
       adapter.reset([at(2, {

+ 3 - 0
packages/client/runtime/tsconfig.json

@@ -26,6 +26,9 @@
     {
       "path": "../../ui/commands"
     },
+    {
+      "path": "../../compact/compact"
+    },
     {
       "path": "../../session-projection/session-projection"
     },

+ 3 - 3
packages/compact/compact/README.i18n.yaml

@@ -1,6 +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
-README.md: 17a5420ae9fa23ce4021b4d4927ae5b95962f979
-README.zh.md: d98251649cfdcf6b12e89a192a43b08b0f071f38
+#   pnpm run verify-translation-pairing --write packages/compact/compact/README.md
+README.md: b6386e8fed9c10cf072683fbdf78c85fb8ac8866
+README.zh.md: b59e03ccf846e88d328dfecdd76a2a490a966a0e

+ 4 - 0
packages/compact/compact/README.md

@@ -59,6 +59,10 @@ The `compact/*` events extend `SessionEventMap` (merge-extensible) via declarati
 
 Subclass `CompactService`, implement `compactIfNeeded` and `compactRegion`, and load the subclass as a plugin — it registers as `ctx.compact`. Every successful backend uses `COMPACT_CHECKPOINT_SOURCE` on its replacement user message; `isCompactCheckpointSource()` recognizes the marker after persistence or cloning without depending on backend identity. A template- or model-backed implementation can live as a sibling package without changing callers or the shared token meter.
 
+## Recognizing a checkpoint outside the host program (`./checkpoint`)
+
+`COMPACT_CHECKPOINT_SOURCE` and `isCompactCheckpointSource()` are declared on the `@deepseek-ai/dsh-compact/checkpoint` subpath and re-exported from the root, so host-side consumers keep reading them from the root. The leaf imports no cordis and declares no module augmentation (the [`dsh-commands/brand`](../../ui/commands/README.md) shape), which is what lets a client or wire program name the checkpoint source: the package **root** cannot enter such a program at all, because it reaches `dsh-session`'s root and that `Context` merge declares the host `sessions` service against the client's own (`TS2717` — one program per side, per [development.md](../../../docs/development.md#typescript-project-layout)). The web client's transcript adapter pins its plugin literal to this leaf with a type-only import, so renaming the plugin id here is a compile error there.
+
 ## Model Experience
 
 ### Conversation history, when a backend is invoked

+ 4 - 0
packages/compact/compact/README.zh.md

@@ -59,6 +59,10 @@
 
 继承 `CompactService`,实现 `compactIfNeeded` 与 `compactRegion`,再将子类作为插件加载:它会注册为 `ctx.compact`。每个成功后端都在替换 user 消息上使用 `COMPACT_CHECKPOINT_SOURCE`;`isCompactCheckpointSource()` 可在持久化或克隆后识别该标记,无需依赖后端身份。基于模板或模型的实现可以放在同级包中,不需更改调用方或共享 token meter。
 
+## 在 host 程序之外识别检查点(`./checkpoint`)
+
+`COMPACT_CHECKPOINT_SOURCE` 与 `isCompactCheckpointSource()` 声明在 `@deepseek-ai/dsh-compact/checkpoint` 子路径上,并由包根重新导出,因此 host 侧消费方仍从根读取它们。该叶子不导入 cordis、也不声明任何模块增强(即 [`dsh-commands/brand`](../../ui/commands/README.md) 的形状),这正是客户端或 wire 程序能够命名该检查点来源的原因:包的**根**根本无法进入这类程序,因为它会到达 `dsh-session` 的根,而那处 `Context` 合并会让 host 的 `sessions` 服务与客户端自己的冲突(`TS2717`——每侧一个程序,见 [development.md](../../../docs/development.md#typescript-project-layout))。Web 客户端的对话记录适配器用仅类型导入把它的插件字面量钉在该叶子上,因此在此处改插件 id 会让那边编译失败。
+
 ## 模型体验
 
 ### 调用后端时的会话历史

+ 5 - 0
packages/compact/compact/package.json

@@ -15,12 +15,17 @@
       "types": "./lib/types/invariant.d.ts",
       "default": "./lib/invariant.js"
     },
+    "./checkpoint": {
+      "types": "./lib/types/checkpoint.d.ts",
+      "default": "./lib/types/checkpoint.js"
+    },
     "./src/*": "./src/*",
     "./package.json": "./package.json"
   },
   "files": [
     "lib/index.js",
     "lib/invariant.js",
+    "lib/types/**/*.js",
     "lib/types/**/*.d.ts",
     "lib/types/**/*.d.ts.map",
     "src"

+ 27 - 0
packages/compact/compact/src/checkpoint.ts

@@ -0,0 +1,27 @@
+/**
+ * The compaction seam's canonical checkpoint source: the plugin marker every
+ * backend stamps on the replacement user message that lands a checkpoint, plus
+ * the predicate that recognizes it.
+ *
+ * The seam itself lives in `@deepseek-ai/dsh-compact`, which re-exports both of
+ * these; this module is a pure value/predicate outlet (no cordis imports, no
+ * module augmentation) so client and wire programs can name the checkpoint
+ * source without loading the host plugin's Context merges — the
+ * `dsh-commands/brand` shape.
+ *
+ * @module @deepseek-ai/dsh-compact/checkpoint
+ */
+
+import type { MessageSource } from '@deepseek-ai/dsh-llm/message'
+
+/** Canonical source for the replacement user message produced by every compaction backend. */
+export const COMPACT_CHECKPOINT_SOURCE = Object.freeze({ kind: 'plugin', plugin: 'compact' } as const)
+
+/**
+ * Test whether a persisted message source identifies a compaction checkpoint.
+ * @param source - source restored from a surface user message.
+ * @returns whether the source carries the backend-independent checkpoint marker.
+ */
+export function isCompactCheckpointSource(source: MessageSource): boolean {
+  return source.kind === 'plugin' && source.plugin === COMPACT_CHECKPOINT_SOURCE.plugin
+}

+ 4 - 13
packages/compact/compact/src/index.ts

@@ -8,24 +8,15 @@
  */
 
 import { Context, Service } from 'cordis'
-import type { MessageSource } from '@deepseek-ai/dsh-llm'
 import type { Session } from '@deepseek-ai/dsh-session'
 import type { CompactionResult } from './types.ts'
 
 export type { CompactionResult } from './types.ts'
 export { toolPairingBalancedAfter, toolPairingBalancedBefore } from './tool-pairing.ts'
-
-/** Canonical source for the replacement user message produced by every compaction backend. */
-export const COMPACT_CHECKPOINT_SOURCE = Object.freeze({ kind: 'plugin', plugin: 'compact' } as const)
-
-/**
- * Test whether a persisted message source identifies a compaction checkpoint.
- * @param source - source restored from a surface user message.
- * @returns whether the source carries the backend-independent checkpoint marker.
- */
-export function isCompactCheckpointSource(source: MessageSource): boolean {
-  return source.kind === 'plugin' && source.plugin === COMPACT_CHECKPOINT_SOURCE.plugin
-}
+// The checkpoint source and its predicate are declared on the cordis-free
+// `./checkpoint` leaf so client and wire programs can name them without this
+// root's Context merge; the root stays the host-side entry point for both.
+export { COMPACT_CHECKPOINT_SOURCE, isCompactCheckpointSource } from './checkpoint.ts'
 
 /** Why automatic policy is asking a backend to consider compaction. */
 export type CompactionTrigger = 'pressure' | 'context-overflow'

+ 1 - 0
tsconfig.base.json

@@ -54,6 +54,7 @@
       "@deepseek-ai/dsh-llm/brand": ["./packages/llm/llm/src/brand.ts"],
       "@deepseek-ai/dsh-llm/message": ["./packages/llm/llm/src/message.ts"],
       "@deepseek-ai/dsh-commands/brand": ["./packages/ui/commands/src/brand.ts"],
+      "@deepseek-ai/dsh-compact/checkpoint": ["./packages/compact/compact/src/checkpoint.ts"],
       "@deepseek-ai/dsh-tools/presentation": ["./packages/core/tools/src/presentation.ts"],
       "@deepseek-ai/dsh-user-approval/types": ["./packages/ui/user-approval/src/types.ts"],
       "@deepseek-ai/dsh-user-interaction/types": ["./packages/ui/user-interaction/src/types.ts"],

+ 9 - 5
tsconfig.client.json

@@ -36,11 +36,15 @@
     // client-side Context merges keep it out of the host program.
     { "path": "./packages/host/directory-picker-native" },
     { "path": "./packages/host/directory-picker-browse" },
-    // Test-only leaf: the client-runtime drift trap for the compaction
-    // checkpoint source reads the seam's canonical const. It may appear HERE
-    // but never in a packages/client/* package project — dsh-compact's root
-    // reaches dsh-session's root, whose Context merge declares the host
-    // `sessions: SessionStore` and collides with the client's `ISessions`.
+    // Compaction seam: the client-runtime drift trap value-imports the seam's
+    // canonical checkpoint const from the package ROOT, which may be reached
+    // from this TEST program but never from a packages/client/* package
+    // program — the root reaches dsh-session's root, whose Context merge
+    // declares the host `sessions: SessionStore` and collides with the
+    // client's `ISessions`. Package programs use the cordis-free
+    // dsh-compact/checkpoint leaf instead (a type-only import in
+    // transcript-adapter.ts), which needs this reference on the runtime
+    // package project itself.
     { "path": "./packages/compact/compact" },
     { "path": "./packages/client/ui-slots" },
     { "path": "./packages/client/ui-primitives" },