diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml index 8193e5e839..bf6f030683 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.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/architecture/2026-06-21-bounded-llm-request-recovery.md -2026-06-21-bounded-llm-request-recovery.md: 5c76ed5d754ea40f41dff78cb56ee7fc139a32b1 -2026-06-21-bounded-llm-request-recovery.zh.md: 1fa56f3fe0405cab663c2843d423a78d910170dd +2026-06-21-bounded-llm-request-recovery.md: 24725dcf300cf69e9cc72580d0c8afe937d4e2b9 +2026-06-21-bounded-llm-request-recovery.zh.md: 5f03a65b00be8d3349addce82e4f3faa2af1fe7e diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md index 5c76ed5d75..24725dcf30 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md @@ -84,7 +84,7 @@ Boundary tests prove termination at both actual transports. The hand-written ada A failed attempt may leave `assistant/chunk` events in its closed step, but it never appends `assistant/message` and never dispatches a tool. A retry closes the failed turn, opens the next numbered turn, reconstructs the request from the durable surface, and produces its own chunks. UIs may render live chunks while a step is open, then mark or clear that transient view when `llm/retry` identifies the failed step or `turn/end` records failure. Web validates the complete retry payload contract, clears the failed partial at `llm/retry`, projects consecutive retry-turn events into one stable row updated to the latest attempt, and derives scheduled, started, or cancelled status from subsequent turn facts. Its countdown anchors the scheduled delay to browser receipt rather than the Host event clock, uses ceiling-rounded seconds with a one-second floor, animates only while unresolved, and keeps exact latest failure details collapsed behind the row. Retry nodes anchor their own trajectory turn even when the failed attempt has no assistant node. Message derivation continues to ignore the failed chunks, and Web applies the same projection during history rebuild so refreshing cannot resurrect discarded partials or duplicate retry rows. -If recovery is exhausted, the final failure is stored once on `turn/end.reason` with the structured facts. If transient recovery continues, `llm/retry` is the durable home for that attempt's failure and delay. No standalone final-error event or response-id vocabulary is added. +If recovery is exhausted, the final failure is stored once on `turn/end.reason` with the structured facts. Web derives one `turn-error` node at that sequence position and renders its display-safe message and optional code inline; AUTH projections replace provider copy that may echo credential fragments with `API key is invalid`, while the raw diagnostic remains in the session log. The same fold runs for live events and history replay. If transient recovery continues, `llm/retry` is the durable home for that attempt's failure and delay, so its failed turn does not also gain a terminal error row. No standalone final-error event or response-id vocabulary is added. ## Out of scope diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md index 1fa56f3fe0..5f03a65b00 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md @@ -84,7 +84,7 @@ agent-spine 演示组�包加载该�件,因此共享的 stdio/TUI�一次 一次失败�试�以在已关闭的步骤中留下 `assistant/chunk` 事件,但��会追加 `assistant/message`,也�会分�工具。�试会关闭失败轮次,开�下一个编�轮次,从�久表层�建请求,并生�自己的分片。步骤�处于打开状�时,UI �以渲染实时分片;当 `llm/retry` 标识失败步骤,或 `turn/end` 记录失败时,UI �标记或清除这份暂时视图。Web 会验�完整的�试载�契约,在 `llm/retry` 到达时清除失败的部分输出,将连续�试轮次的事件投影为稳定的一行,并用最新一次�试更新该行,�从�续轮次事实派生 scheduled�started 或 cancelled 状�。倒计时以�览器收到事件的时刻为计划延迟的起点,而�是使用 Host 事件时钟;它按�上�整且�低于 1 秒的秒数显示,仅在�试尚未结�时显示动画,并把最近一次失败的准确详情折�在该行之�。�使失败�试没有 assistant 节点,�试节点也会锚定自身的轨迹轮次。消�派生�会忽略失败分片;Web 在�建历�时也会应用�一投影,因此刷新页��会让已丢弃的部分输出�新出现,也�会生���的�试行。 -如果��预算耗尽,最终失败会连�结构化事实在 `turn/end.reason` 中存储一次。如果暂时性��继续,`llm/retry` 就是该次�试的失败与延迟的�久归属�置。本决策�增加独立的最终错误事件或�应 id �汇。 +如果��预算耗尽,最终失败会连�结构化事实在 `turn/end.reason` 中存储一次。Web 会在该�列�置派生一个 `turn-error` 节点,并内�渲染适�展示的消�与�选错误�;AUTH 投影会把�能回显凭�片段的�供方文案替�为 `API key is invalid`,原始诊断��留在会�日志中。实时事件和历�回放使用�一套折�逻辑。如果暂时性��继续,`llm/retry` 就是该次�试的失败与延迟的�久归属�置,因此该失败轮次�会�获得终�错误行。本决策�增加独立的最终错误事件或�应 id �汇。 ## �在范围内 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml index e4b0447cdd..4530d5ee57 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.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 -2026-07-19-gui-web-client-architecture.md: cfc2a7e62358e6282148b2d024ef3b162a903642 -2026-07-19-gui-web-client-architecture.zh.md: b5b082c25f664cfcb0ddd3fcc6c4cd3d58472218 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md +2026-07-19-gui-web-client-architecture.md: 63b6f5795c3d49f25cd964cf04a0c9d41a667bfb +2026-07-19-gui-web-client-architecture.zh.md: 2d57c12ebae38aafa4e606da95af954990761b3c diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md index cfc2a7e623..63b6f5795c 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md @@ -50,7 +50,7 @@ There is no registration model besides slots — the former view and tool rings ## The data object layer (`packages/client/runtime/src/client/sessions/`) -Frames enter, snapshots exit, the fold sits between — React-free (zero React imports, grep-assertable): +Frames enter, snapshots exit, the projection sits between — React-free (zero React imports, grep-assertable): ``` mux/host 帧(ConnectionController 泵入,sinks 注入) @@ -62,17 +62,17 @@ SessionManager.handleMuxEnvelope / handleHostEnvelope Session.handleMuxEnvelope ──► events 窗�(seq 连续��) │ │ 定稿事件 │ chunk │ ▼ ▼ - │ FoldAdapter PartialAccumulator + │ TranscriptAdapter PartialAccumulator │ (→ nodes) (→ partial) ▼ Notifier 微任务�批 ──► ConversationSnapshot 缓存 ──uSES──► 组件 ``` -- **Session** (session.ts): lazily built, resident — once created it keeps eating frames in the background, so switching away and back renders instantly. Operations: `prompt`/`cancel` (RPC passthrough; failures land in the snapshot's `promptError`), `open` (pull the tail history page, idempotent), `loadOlder` (upward paging, reentry-guarded), `resync` (reconnect = clear the window and rerun open). Subscription: `subscribe`/`getSnapshot` (always the cached reference) — `implements ObservableSnapshot`, with `useSelector = bindSnapshotSelector(this)` attached at construction, so a Session is directly a uSES source. Frame dispatch is one switch: `session/event` frames dedup by seq (the only dedup key), buffer while open is in flight, otherwise append + incremental fold; open/stitch merges the live buffer by seq and backfills once if `subscribed.lastSeq` outruns the window tail. -- **ConversationSnapshot** (conversation.ts): the immutable snapshot contract — `nodes` (folded, surface-ordered), `partial`, `runningCalls`, `pending`, `running`, `removed`, `openState`, `hasMore`, `promptError` and kin. **Reference discipline** (the premise of memo and uSES): the top-level object is fresh on every change; the nodes array is rebuilt but element references come from the cache; unchanged substructures reuse the previous snapshot's references. +- **Session** (session.ts): lazily built, resident — once created it keeps eating frames in the background, so switching away and back renders instantly. Operations: `prompt`/`cancel` (RPC passthrough; failures land in the snapshot's `promptError`), `open` (pull the tail history page, idempotent), `loadOlder` (upward paging, reentry-guarded), `resync` (reconnect = clear the window and rerun open). Subscription: `subscribe`/`getSnapshot` (always the cached reference) — `implements ObservableSnapshot`, with `useSelector = bindSnapshotSelector(this)` attached at construction, so a Session is directly a uSES source. Frame dispatch is one switch: `session/event` frames dedup by seq (the only dedup key), buffer while open is in flight, otherwise append + incremental projection; open/stitch merges the live buffer by seq and backfills once if `subscribed.lastSeq` outruns the window tail. +- **ConversationSnapshot** (conversation.ts): the immutable snapshot contract — `nodes` (the human transcript, log-ordered), `partial`, `runningCalls`, `pending`, `running`, `removed`, `openState`, `hasMore`, `promptError` and kin. **Reference discipline** (the premise of memo and uSES): the top-level object is fresh on every change; an unchanged nodes projection keeps the same array reference, while a changed flow returns a new array that reuses unchanged element references; unchanged substructures reuse the previous snapshot's references. - **SessionManager** (manager.ts): instance cluster + frame entry + the session list. sessionId-bearing frames go only to existing instances (a mux broadcast must not instantiate every session); approval/question `requested` frames are the exception — they never land in history, so they buffer in `pendingBuffers` and replay on instantiation. - **Notifier** (notifier.ts): two channels chosen by change source. `markDirty()` (default; frame-driven changes always) batches per microtask — N changes, one notification, one re-render; the flush rebuilds the snapshot cache before notifying. `notifyNow()` (only direct echoes of user gestures) rebuilds and notifies in the same tick — controlled inputs roll the DOM back and jump the caret if their echo defers to a microtask. Frame-driven code using notifyNow collapses batching back to per-frame renders; banned. -- **FoldAdapter / PartialAccumulator**: the fold reuses the core SurfaceManager (`@deepseek-ai/dsh-session/surface`), padding sentinel events so a paged window starting at seq > 0 satisfies the core's `seq === index` assertion; a cross-window replace degrades to a tolerant linear scan and sets `foldDegraded`. Chunks stay out of the fold entirely (O(1) skip): the accumulator folds StreamChunks into `AssistantBlock[]`, a delta swapping only that block's reference, and the finalizing message discards the accumulator in the same batch (no flicker on promotion). Cost model: one chunk = one string concatenation + a dirty mark; an unsubscribed Session under a frame storm costs only the mark. +- **TranscriptAdapter / PartialAccumulator**: the transcript is the append-origin surface projected in log order (`isAppendSurfaceEvent` from `@deepseek-ai/dsh-session/surface`) plus one marker per landed compaction checkpoint — never the model surface, which shadows replaced ranges and would erase conversation the reader already saw. Node order is seq-monotonic by construction, so there is no core `seq === index` assertion to satisfy and no degradation branch. Chunks contribute no node (O(1) skip): the accumulator folds StreamChunks into `AssistantBlock[]`, a delta swapping only that block's reference, and the finalizing message discards the accumulator in the same batch (no flicker on promotion). Cost model: one chunk = one string concatenation + a dirty mark; an unsubscribed Session under a frame storm costs only the mark. - **ConnectionController** (in `packages/client/connection`): opens the mux/host streams, pumps with for-await, reconnects with exponential backoff (500ms doubling to 10s, jitter, unlimited) behind a generation fence; sinks are injected one-way (the Controller does not know Session). Reconnect = rebuild: `onConnected` → list refresh + per-open-session resync. The object layer faces only `IApiClient`; the Web carriage (HTTP POST for the two client→server quadrants, SSE for the two server→client) and the client class family are the layering RFC's territory. ## The React face (`packages/client/web-react`) diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md index b5b082c25f..2d57c12eba 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md @@ -62,17 +62,17 @@ SessionManager.handleMuxEnvelope / handleHostEnvelope Session.handleMuxEnvelope ──► events 窗�(seq 连续��) │ │ 定稿事件 │ chunk │ ▼ ▼ - │ FoldAdapter PartialAccumulator + │ TranscriptAdapter PartialAccumulator │ (→ nodes) (→ partial) ▼ Notifier 微任务�批 ──► ConversationSnapshot 缓存 ──uSES──► 组件 ``` -- **Session**(session.ts):懒建�常驻——建��在���续�帧,切走切回秒显。�作�:`prompt`/`cancel`(RPC �传;失败�进快照的 `promptError`)�`open`(拉尾页 history,幂等)�`loadOlder`(�上翻页,防�入)�`resync`(�连 = 清窗��跑 open)。订阅�:`subscribe`/`getSnapshot`(�返缓存引用)——`implements ObservableSnapshot`,构造时挂 `useSelector = bindSnapshotSelector(this)`,Session 本身就是 uSES �。帧分�是一个 switch:`session/event` 帧按 seq 去�(唯一去�键),open 在途时缓冲,�则追加 + 增� fold;open/��按 seq �并 live 缓冲并去�,`subscribed.lastSeq` 超出窗�尾则回补一次。 -- **ConversationSnapshot**(conversation.ts):���快照契约——`nodes`(fold 产物,surface �)�`partial`�`runningCalls`�`pending`�`running`�`removed`�`openState`�`hasMore`�`promptError` 等。**引用纪律**(memo 与 uSES 的��):顶层对象��必新;nodes 数组�建但元素引用�自缓存;未�的�结构�用上一快照的引用。 +- **Session**(session.ts):懒建�常驻——建��在���续�帧,切走切回秒显。�作�:`prompt`/`cancel`(RPC �传;失败�进快照的 `promptError`)�`open`(拉尾页 history,幂等)�`loadOlder`(�上翻页,防�入)�`resync`(�连 = 清窗��跑 open)。订阅�:`subscribe`/`getSnapshot`(�返缓存引用)——`implements ObservableSnapshot`,构造时挂 `useSelector = bindSnapshotSelector(this)`,Session 本身就是 uSES �。帧分�是一个 switch:`session/event` 帧按 seq 去�(唯一去�键),open 在途时缓冲,�则追加 + 增�投影;open/��按 seq �并 live 缓冲并去�,`subscribed.lastSeq` 超出窗�尾则回补一次。 +- **ConversationSnapshot**(conversation.ts):���快照契约——`nodes`(人类对�记录,日志�)�`partial`�`runningCalls`�`pending`�`running`�`removed`�`openState`�`hasMore`�`promptError` 等。**引用纪律**(memo 与 uSES 的��):顶层对象��必新;未�化的 nodes 投影���一数组引用,消���化时返回新数组并�用未�化的元素引用;未�的�结构�用上一快照的引用。 - **SessionManager**(manager.ts):实例簇 + 帧总入� + 会�列表。带 sessionId 的帧�投已存在实例(mux 广播�得把�个会�都实例化);例外是审批/问答 `requested` 帧——它们�� history�open 无法回补,故缓冲进 `pendingBuffers`,实例化时回放。 - **Notifier**(notifier.ts):两�通知通�,按�更���用。`markDirty()`(默认;帧驱动一律用它)按微任务�批——N 次�更�一次通知�一次�渲染;flush 先�建快照缓存�通知。`notifyNow()`(仅用户手势的直接回�)� tick �建并通知——�控输入的回�若延到微任务,DOM 会回滚�光标跳尾。帧驱动代�用 notifyNow 会让�批塌回�帧渲染;�。 -- **FoldAdapter / PartialAccumulator**:fold �用核心 SurfaceManager(`@deepseek-ai/dsh-session/surface`),垫哨兵事件使 seq > 0 起头的分页窗�满足核心的 `seq === index` 断言;跨窗� replace 时�级为容错线性扫�并置 `foldDegraded`。分片完全�进 fold(O(1) 跳过):累积器把 StreamChunk 折�� `AssistantBlock[]`,一次增���该�引用;定稿消�到达�在�一批内弃掉累积器(��无闪�)。�本模型:一个分片 = 一次字符串拼接 + 一个�标记;帧风暴下未订阅的 Session �花那个标记。 +- **TranscriptAdapter / PartialAccumulator**:对�记录是按日志顺�投影的 append �� surface(`@deepseek-ai/dsh-session/surface` 的 `isAppendSurfaceEvent`),外加�次�地的压缩检查点一个标记——��用模型 surface,�者�蔽被替�的范围,会抹掉读者已�看过的对�。节点顺�天然按 seq �调,因此既无核心 `seq === index` 断言需�满足,也没有�级分支。分片�贡献任何节点(O(1) 跳过):累积器把 StreamChunk 折�� `AssistantBlock[]`,一次增���该�引用;定稿消�到达�在�一批内弃掉累积器(��无闪�)。�本模型:一个分片 = 一次字符串拼接 + 一个�标记;帧风暴下未订阅的 Session �花那个标记。 - **ConnectionController**(在 `packages/client/connection`):开 mux/host ���for-await 泵入,代际围�之内指数退��连(500ms 翻�至 10s �顶�抖动�无��试);sinks ��注入(Controller �认识 Session)。�连 = �建:`onConnected` → 列表刷新 + �已打开会� resync。对象层��� `IApiClient`;Web 承载(HTTP POST 载两个 client→server 象��SSE 载两个 server→client 象�)与客户端类�归分层 RFC 属地。 ## React �(`packages/client/web-react`) diff --git a/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.i18n.yaml index b1a81228cf..45e1c99967 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.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 -2026-07-24-single-harness-home-resolver.md: 10ed0e9f1fd6ac4630d92a66953fdf1d52b3b5f1 -2026-07-24-single-harness-home-resolver.zh.md: 1ce56281357595de134ddea285c8c2e0c1801ce9 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.md +2026-07-24-single-harness-home-resolver.md: 159ba88b7b4a8d50f1be2cbe5d9162a654014e16 +2026-07-24-single-harness-home-resolver.zh.md: 62046abca48a3c2b07fde4180031dc2186dc101f diff --git a/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.md b/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.md index 10ed0e9f1f..159ba88b7b 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.md +++ b/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.md @@ -22,7 +22,7 @@ One resolver owns the harness home, in `@deepseek-ai/dsh-paths`, single-root: explicit configured path > $DSH_HOME > ~/.dsh ``` -An empty or whitespace-only `$DSH_HOME` is treated as unset, matching the guard telemetry's old resolver carried: without it `resolve('')` would silently place the home at the current working directory. The harness keeps all user data under one root; there is no XDG config/data/cache split. `dshHomeDisplay()` names a resolved root symbolically for user-facing paths — `~/.dsh` for the default home, `$DSH_HOME` for any configured home — so the user-global `AGENTS.md` label never leaks an absolute machine path. It replaces workspace-context's bespoke default-vs-`$DSH_HOME` check. +An empty or whitespace-only `$DSH_HOME` is treated as unset, matching the guard telemetry's old resolver carried: without it `resolve('')` would silently place the home at the current working directory. The harness keeps all user data under one root; there is no XDG config/data/cache split. `dshHomePath(...segments)` joins deployment-owned children onto that root, and `dsh-app-boot` exposes it to Loader `!!js` config expressions before mounting entries, so shipped compositions derive `sessions` and `storages` without copying the resolver. `dshHomeDisplay()` names a resolved root symbolically for user-facing paths — `~/.dsh` for the default home, `$DSH_HOME` for any configured home — so the user-global `AGENTS.md` label never leaks an absolute machine path. It replaces workspace-context's bespoke default-vs-`$DSH_HOME` check. `@deepseek-ai/dsh-home` is deleted. Its three importers (`dsh-tool-bash`, `dsh-skill-local`, `dsh-agent-spine-demo`) now import `resolveDshHome` from `dsh-paths`. `dsh-telemetry`'s `globalConfigDir` delegates to `resolveDshHome`, dropping its second resolver, the `DSH_CONFIG_HOME` override, the XDG/`%APPDATA%` branches, and the `deepseek-harness` namespace; the anonymous id now lives directly under the harness home. diff --git a/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.zh.md b/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.zh.md index 1ce5628135..62046abca4 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.zh.md @@ -22,7 +22,7 @@ Status: implemented explicit configured path > $DSH_HOME > ~/.dsh ``` -空或仅�空白的 `$DSH_HOME` 被当作未设置处�,这与 telemetry 旧解�器所带的�护一致:若无此�护,`resolve('')` 会悄悄把 home �在当�工作目录。harness 把所有用户数�都放在�一个根目录下;�存在 XDG 的 config/data/cache 拆分。`dshHomeDisplay()` 为��用户的路径以符�形�命�已解�的根目录——默认 home 显示为 `~/.dsh`,任何已�置的 home 显示为 `$DSH_HOME`——这样��用户全局的 `AGENTS.md` 标签就��会泄露机器上的�对路径。它�代了 workspace-context 中自定义的"默认值 vs `$DSH_HOME`"判断。 +空或仅�空白的 `$DSH_HOME` 被当作未设置处�,这与 telemetry 旧解�器所带的�护一致:若无此�护,`resolve('')` 会悄悄把 home �在当�工作目录。harness 把所有用户数�都放在�一个根目录下;�存在 XDG 的 config/data/cache 拆分。`dshHomePath(...segments)` 将部署负责的�路径拼接到该根目录下,`dsh-app-boot` 在挂载�目�� Loader `!!js` �置表达�暴露它,因此出厂组�无需�制解�器��派生 `sessions` 和 `storages`。`dshHomeDisplay()` 为��用户的路径以符�形�命�已解�的根目录——默认 home 显示为 `~/.dsh`,任何已�置的 home 显示为 `$DSH_HOME`——这样��用户全局的 `AGENTS.md` 标签就��会泄露机器上的�对路径。它�代了 workspace-context 中自定义的"默认值 vs `$DSH_HOME`"判断。 `@deepseek-ai/dsh-home` 被删除。它的三个引用方(`dsh-tool-bash`�`dsh-skill-local`�`dsh-agent-spine-demo`)现在从 `dsh-paths` 导入 `resolveDshHome`。`dsh-telemetry` 的 `globalConfigDir` 转而委托给 `resolveDshHome`,去掉了它的第二个解�器�`DSH_CONFIG_HOME` 覆盖项�XDG/`%APPDATA%` 分支以� `deepseek-harness` 命�空间;匿� id 现在直接存放在 harness home 之下。 diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index 27911294a6..6855a0af2b 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.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/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: 495062f910785e1bb2f421dbb25c01c399d45567 -2026-07-28-directory-picker-capability-seam.zh.md: 62fc87212ab627ea8819dab55e3a769b4a5afc42 +2026-07-28-directory-picker-capability-seam.md: 9884385cf9e0d51604bab9e4fd3c4bee77448331 +2026-07-28-directory-picker-capability-seam.zh.md: 8c229b9fb08d5052ba8a512f2153a89a9e5fd455 diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index 495062f910..9884385cf9 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -12,7 +12,7 @@ The web GUI's "Open local folder" flow was hardwired to one interaction: `host.p A three-package capability seam in `packages/host/` — `directory-picker` (interface), `directory-picker-native`, `directory-picker-browse` (backends) — with one contract method: `capability()` returns a **discriminated union**, `{ kind: 'native', pick(signal) }` or `{ kind: 'browse', list(path?), createDirectory(path, name) }`. The gateway (`dsh-host-apiproxy`) injects `directoryPicker`, serves the matching RPCs, and answers `directory-picker-unavailable` for the other kind. The union is discriminated because the backends differ in *interaction shape* — flattening them into one method set would force every backend to fake the other's shape. -**The client side is slot-composed, not advertisement-branched.** ui-workspace's two trigger surfaces each declare a `single` directory-flow hole (`conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`; two keys because a hole has exactly one declaring slot entry — same owner contract, same occupant). Backend packages are **dual-face**: the browser half registers the matching interaction into both holes — `-native` a renderless occupant driving `host.pickDirectory`, `-browse` the in-app Select Workspace Directory dialog. The hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`) carries the whole exchange: ui-workspace keeps the trigger (menu entry rendered only while the hole is occupied) and the adoption (`createWorkspace({path})`, conflict/error dialog, Choose again), the occupant owns everything between `open` and the picked path. One `cordis.yml` row therefore swaps the host capability and the client flow together; a mismatch is impossible by construction, and mounting two flow packages fails at client load (`single` hole). The earlier `host.describe.directoryPicker` advertisement and the client's kind branching are deleted — with composition wiring both sides, a wire fact for the client to branch on had no remaining consumer. The hole registry (`ctx.slots.entries`) replaces it as the per-menu-open occupancy read. +**The client side is slot-composed, not advertisement-branched.** ui-workspace's two trigger surfaces each declare a `single` directory-flow hole (`conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`; two keys because a hole has exactly one declaring slot entry — same owner contract, same occupant). Backend packages are **dual-face**: the browser half registers the matching interaction into both holes — `-native` a renderless occupant driving `host.pickDirectory`, `-browse` the in-app Select Workspace Directory dialog. The hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`) carries the whole exchange: ui-workspace keeps the trigger (menu entry rendered only while the hole is occupied) and the adoption (`createWorkspace({path})`, retryable error dialog, Choose again), the occupant owns everything between `open` and the picked path. One `cordis.yml` row therefore swaps the host capability and the client flow together; a mismatch is impossible by construction, and mounting two flow packages fails at client load (`single` hole). The earlier `host.describe.directoryPicker` advertisement and the client's kind branching are deleted — with composition wiring both sides, a wire fact for the client to branch on had no remaining consumer. The hole registry (`ctx.slots.entries`) replaces it as the per-menu-open occupancy read. Placement and policy rulings folded into this decision: diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index 62fc87212a..8c229b9fb0 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -12,7 +12,7 @@ web GUI 的"打开本地文件夹"�程被焊死在一�交互上:`host.pick 在 `packages/host/` �一个三包能力 seam——`directory-picker`(接�)�`directory-picker-native`�`directory-picker-browse`(�端)——唯一契约方法 `capability()` 返回**�辨识��**:`{ kind: 'native', pick(signal) }` 或 `{ kind: 'browse', list(path?), createDirectory(path, name) }`。网关(`dsh-host-apiproxy`)注入 `directoryPicker`,�供对应的 RPC,�一� kind 的调用以 `directory-picker-unavailable` 应答。��之所以�辨识,是因为�端差异在**交互形�**——压平�统一方法集会逼�个�端伪装�一方的形�。 -**client 侧� slot 组�,而�按广播分支。** ui-workspace 的两个触�表层�自声明一个 `single` 目录�洞(`conversation.hero.workspace.directoryFlow`�`sidebar.workspaces.directoryFlow`;之所以是两个 key,是因为一个洞�有一个声明它的 slot entry——owner 契约相���用者相�)。�端包是**��包**:browser half 把匹�的交互注册进两个洞——`-native` 是驱动 `host.pickDirectory` 的无渲染�用者,`-browse` 是应用内的选择工作区目录对�框。洞的 owner 会�(`open`/`busy`/`onPicked`/`onCancel`/`onError`)承载整个交�:ui-workspace �留触�(��入�仅在洞被�用时渲染)与接纳(`createWorkspace({path})`�冲��错误对�框��新选择),�用者�有从 `open` 到所选路径之间的一切。因此一行 `cordis.yml` �时切�宿主能力与 client �程;错�在构造上��能,�时挂两个�程包会在 client 加载期失败(`single` 洞)。早先的 `host.describe.directoryPicker` 广播与客户端 kind 分支被删除——组�已�接好两侧�,供客户端分支用的 wire 事实��有任何消费者。洞注册表(`ctx.slots.entries`)�而代之,�为�次打开��的�用读�。 +**client 侧� slot 组�,而�按广播分支。** ui-workspace 的两个触�表层�自声明一个 `single` 目录�洞(`conversation.hero.workspace.directoryFlow`�`sidebar.workspaces.directoryFlow`;之所以是两个 key,是因为一个洞�有一个声明它的 slot entry——owner 契约相���用者相�)。�端包是**��包**:browser half 把匹�的交互注册进两个洞——`-native` 是驱动 `host.pickDirectory` 的无渲染�用者,`-browse` 是应用内的选择工作区目录对�框。洞的 owner 会�(`open`/`busy`/`onPicked`/`onCancel`/`onError`)承载整个交�:ui-workspace �留触�(��入�仅在洞被�用时渲染)与接纳(`createWorkspace({path})`���试的错误对�框��新选择),�用者�有从 `open` 到所选路径之间的一切。因此一行 `cordis.yml` �时切�宿主能力与 client �程;错�在构造上��能,�时挂两个�程包会在 client 加载期失败(`single` 洞)。早先的 `host.describe.directoryPicker` 广播与客户端 kind 分支被删除——组�已�接好两侧�,供客户端分支用的 wire 事实��有任何消费者。洞注册表(`ctx.slots.entries`)�而代之,�为�次打开��的�用读�。 并入本决策的�置与策略�决: diff --git a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.i18n.yaml new file mode 100644 index 0000000000..047cccbce3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.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 .agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md +2026-07-29-projected-token-usage-and-request-context.md: 1e2c5ff067928620dee3d0937c247bec245e34f2 +2026-07-29-projected-token-usage-and-request-context.zh.md: 811d92e134b1df0fc6725e6c8d38b37efb57b3aa diff --git a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md new file mode 100644 index 0000000000..1e2c5ff067 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md @@ -0,0 +1,59 @@ +# Agent Note: Projected token usage and context occupancy + +Status: implemented + +English | [中文](2026-07-29-projected-token-usage-and-request-context.zh.md) + +## Problem + +The Web stats line derived token totals from the currently loaded conversation nodes. That window is paged, so scrolling changed the totals, and compaction replaces visible content without preserving the billing behind it. Durable provider billing needs a source that survives both. + +Context occupancy needs a numerator and a denominator that no existing surface carried to the browser: the prompt size of the latest request, and the capacity of the route it used. + +## Decision + +Both values are ordinary durable session-projection state. `@deepseek-ai/dsh-token-meter` registers two units when `ctx.sessionProjections` is present. + +`tokenUsage` folds the complete durable log into uncached input, output, cache-read, and cache-write buckets. An `assistant/chunk` usage sample survives a later failed request; an `assistant/message` usage value for the same `(turn, step)` replaces the earlier sample instead of double-counting it. Reasoning stays an output subdivision. Compaction and surface replacement do not erase earlier billing. + +`contextPressure` carries optional `pressureTokens` — the newest provider-reported prompt size, summing uncached input plus cache reads and writes, excluding output — and optional `contextWindow` from the newest `request/context` record. Neither field is synthesized before its source exists. + +`request/context` is a new log-only session event recording registration-bound metadata for the route a request resolved to. AgentLoop appends it inside the step beside `request/header`, from the context metadata `prepareCall()` now returns alongside the resolved config — the same registration-bound lookup that already validated reasoning, so no second resolve happens. It is skipped when provider, model, and capacity all match the previous record. A route whose adapter advertises no capacity is recorded with `contextWindow` absent, clearing an older route's denominator. + +Capacity deliberately stays out of `EpochHeader`. That type is the reconstruction contract — what a request was built from — and `headerEquals` compares it field-wise to decide whether a snapshot is a real `change`. Capacity is adapter metadata describing a route, so placing it there would let a capacity change masquerade as a request-envelope change and would drag it into the loop's reconstruction invariant. + +Both units ride the standard projection lifecycle: history tail baselines, `session/projection` live frames, higher-seq-wins client storage, JSON checkpoints, cache recovery, and unit unload. There is no token-specific history field, mux frame, projector, revision counter, or client fence. + +The Web `StatsLine` reads both through the standard `useProjection` seat. Window nodes still supply turn and step counts plus LLM and tool wall times — those answer "what is on screen" and are correctly window-scoped. Durable token and context groups remain when compaction leaves no visible assistant step. Cache writes count in billed input and in the cache-hit denominator. A deployment without token-meter drops the token groups; occupancy stays hidden until both pressure and capacity are known. + +## Context occupancy is approximate, and that is the decision + +`pressureTokens` and `contextWindow` are independent last-wins fields, not one atomic observation. Switching models pairs a fresh capacity with the previous route's pressure until the next request reports usage, and the numerator describes the last request rather than the surface as it currently stands. + +This was accepted deliberately. An occupancy percentage is a user-facing reference figure: nothing in the harness makes decisions from it, and compaction reads `measure()` directly instead. The TUI status line has always computed occupancy this way, dividing a `measure()` total by a capacity resolved separately for the selected model — so an atomic variant here would have been the outlier, not the norm. + +Reviewers should not treat the non-atomicity as a defect awaiting a fix. A consumer that genuinely needs an exact same-boundary figure should call `ctx.tokenMeter.measure()` at its own request boundary, where both values are available together, rather than read this projection. + +## Alternatives considered + +**An atomic request-boundary snapshot delivered as a transient mux frame (implemented, then rejected).** An earlier revision of this branch emitted `session/model-request`: one non-replayable frame carrying `contextTokens` and `contextWindow` measured at the same `agent/model-request` boundary. Being the only non-replayable class on the mux stream is what broke it. Host and mux are independent SSE streams with no cross-stream ordering, so a request emitted before a removal could arrive after `host/session-removed` and revive a dead session's telemetry, while a legitimate request for a new lifecycle reusing the same id could be fenced by a late removal. `session/subscribed` is not lifecycle proof — it says a queue began subscribing to an id, not that a new in-memory session replaced an older one — and `lastSeq` is a durable watermark two lifecycles can share. A correct fix required a monotonic lifecycle generation on the frame, on subscription, and on removal, plus a client watermark comparison. + +That cost bought a worse display: occupancy went blank after every reconnect and never moved while a conversation grew. It also made ApiProxy a measurement site calling the O(surface) `measure()` on every request, and expressed reconnect state through a synthetic `cancelled` open error the UI had to special-case. + +**Fold the loaded node window in React.** Cannot survive pagination or compaction, and makes a presentation package reconstruct log semantics. + +**Publish usage only with final assistant messages.** A request that reports a usage chunk and then fails would lose its billing. + +**Resolve capacity inside token-meter.** The package documents itself as independent of model routing and is otherwise a pure reader that never appends to the log. AgentLoop already holds the resolved metadata where the header is written. + +**Extend the `session.models` RPC with capacity.** The handler already resolves and discards it, so the field is nearly free — but `StatsLine` lives in `ui-conversation` while the model directory lives in `ui-model`, and `ui-conversation` cannot depend on `ui-model`. Delivering it would have required either a second dock entry splitting one text row across two plugins, or a cross-plugin store write. + +**Add a context circle beside the model selector.** That placement suggests selected-model state. The stats line carries the figure without a duplicate UI or data path. + +## Consequences + +Token totals stay stable across pagination, compaction, replay, restart, and reconnect, because they are ordinary durable projection state recovered through the generic paths. The cross-stream reordering race is gone by construction rather than fenced. + +Occupancy is approximate in the ways documented above. It is available immediately after restore or reconnect, since both fields are durable, at the cost of describing the last recorded request rather than an exact current boundary. + +Each session log gains one small `request/context` record per route or advertised-capacity change. The token-meter projection is the canonical owner of durable session-projection usage semantics; the TUI retains its live per-step map because it does not mount the generic projection seam, and the standalone browser fixture mirrors the unit. ApiProxy carries no token-specific code, owns no per-session metrics cache, and performs no measurement. The browser keeps two generic projection values and no connection-local telemetry, and streaming text deltas still do not force the stats line to recompute. diff --git a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md new file mode 100644 index 0000000000..811d92e134 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md @@ -0,0 +1,59 @@ +# Agent Note: token 用�投影与上下文�用率 + +Status: implemented + +[English](2026-07-29-projected-token-usage-and-request-context.md) | 中文 + +## 问题 + +Web 统计行原先从当�已加载的会�节点推导 token 总�。该窗�是分页的,因此滚动会改�总�;压缩(compaction)�会替���内容,而��留其背�的计费用�。�久的�供方计费用�需�一个能�时��这两者的数��。 + +上下文�用率需�一个分�和一个分�,而这两者都�曾由任何既有接��达�览器:最新一个请求的�示�规模,以�该请求所用路由的容�。 + +## 决策 + +这两个值都是普通的�久会�投影状�。当 `ctx.sessionProjections` 存在时,`@deepseek-ai/dsh-token-meter` 会注册两个�元。 + +`tokenUsage` 将完整�久日志归并为未缓存输入�输出�缓存读�和缓存写入四类计数项。�使�续请求失败,`assistant/chunk` 用�样本�会�留;�一 `(turn, step)` 的 `assistant/message` 用�值会替�先�样本,�会��计数。推�(reasoning)�是输出的细分项。压缩和表层替��会抹除先�的计费用�。 + +`contextPressure` �带�选的 `pressureTokens`(�供方报告的最新�示�规模,为未缓存输入加缓存读�与写入之和,��输出),以��自最新一� `request/context` 记录的�选 `contextWindow`。在�自��出现�,两个字段都�会被��。 + +`request/context` 是新增的仅入日志会�事件,记录请求所解�到的路由的�绑定注册项的元数�。AgentLoop 在步骤内紧� `request/header` 追加它,数��自 `prepareCall()` 现在与已解��置一并返回的上下文元数�:正是那次已�校验过推�的�绑定注册项的查询,因此�会�生第二次解�。当�供方�模型和容�都与上一�记录相�时会跳过。适�器�公布容�的路由会以缺失 `contextWindow` 的形�记录,从而清除较早路由的分�。 + +容�刻��进入 `EpochHeader`。该类型是�建契约,�请求由什么构建而�,而 `headerEquals` 会�字段比较它,以判定�个快照是�真的是一次 `change`。容�是�述路由的适�器元数�,把它放进去会让容��化伪装�请求�装的�化,还会把它拖进 AgentLoop 的�建���。 + +两个�元都沿用标准投影生命周期:历�尾页基线�`session/projection` 实时帧�seq 高者胜的客户端存储�JSON 检查点�缓存��和�元�载。系统没有任何 token 专用的历�字段�mux 帧�投影器�修订计数器或客户端栅�。 + +Web `StatsLine` 通过标准 `useProjection` 席�读�两者。窗�内节点��供轮次和步骤计数,以� LLM(大语言模型)与工具的墙钟时间:它们回答的是「�幕上有什么�,按窗�作用域正是正确的。压缩使�� assistant 步骤归零�,�久 token 与上下文分组�会�留。缓存写入会计入计费输入和缓存命中率分�。未部署 token-meter 时会去掉 token 分组;�有压力与容�都已知时�显示�用率。 + +## 上下文�用率是近似值,而这正是决策本身 + +`pressureTokens` 与 `contextWindow` 是两个�自�者胜的独立字段,�是一次原�观测。切�模型时,新容�会与上一路由的压力�对,直到下一个请求报告用�为止;分��述的是最�一个请求,而�是此刻的表层。 + +这是刻�接�的结果。�用率百分比是��用户的�考数字:harness 中没有任何环节��它�决策,压缩改为直接读� `measure()`。TUI 状�行一直以这�方�计算�用率,�用 `measure()` 总�除以为所选模型�独解�出的容�;因此在这里��原�版本�是异类,而�是常�。 + +评审人�应把这��原�性当作待修的缺陷。确实需��一边界精确数字的消费方,应在自己的请求边界调用 `ctx.tokenMeter.measure()`,那里两个值�时�得,而�是读�该投影。 + +## 备选方案 + +**以临时 mux 帧交付请求边界上的原�快照(已实现,���决)。** 本分支较早的一个修订版会�出 `session/model-request`:一个��回放的帧,�带在�一个 `agent/model-request` 边界测得的 `contextTokens` 与 `contextWindow`。真正让它失效的,是它�了 mux �上唯一的��回放类别。Host �与 mux �是两�独立的 SSE(Server-Sent Events)�,彼此之间没有顺���:在移除之��出的请求�能在 `host/session-removed` 之��到达,让一个已死会�的�测数��活;而�用�一 id 的新生命周期的�法请求,��能被一�迟到的移除拦下。`session/subscribed` �能�明生命周期:它�说明�个队列开始订阅�个 id,而�说明新的内存会�替�了较早的会�;`lastSeq` 则是两个生命周期�以共用的�久水�线。正确的修法需�在帧上�订阅上和移除上都带一个�调递增的生命周期代次,�加上一次客户端水�线比较。 + +这份代价��的是更差的显示:�用率在�次�连��为空白,而且会�增长期间从�移动。它还把 ApiProxy ��一个测�点,�个请求都�调用 O(surface) 的 `measure()`,并通过一个 UI 必须特殊处�的�连接打开时的�� `cancelled` 错误�表达�连状�。 + +**在 React 中归并已加载的节点窗�。** 无法跨分页或压缩�留数�,还会迫使展示包(package)�建日志语义。 + +**仅�最终 assistant 消��布用�。** 如果请求报告一个用�分片�失败,就会丢失自己的计费用�。 + +**在 token-meter 内部解�容�。** 该包自述与模型路由无关,且在其他方�是一个从��日志追加内容的纯读�方。AgentLoop 在写入请求头的�置已��有已解�的元数�。 + +**为 `session.models` RPC 增加容�字段。** 其处�器已�解�出容��将其丢弃,因此这个字段几乎是�费的;但 `StatsLine` �于 `ui-conversation`,模型目录�于 `ui-model`,而 `ui-conversation` �能�赖 `ui-model`。��达它,就得增加第二个 dock �目�把一行文本拆到两个�件里,或者�一次跨�件的 store 写入。 + +**在模型选择器�增加上下文圆环。** 该�置会让人以为这是所选模型的状�。统计行�以承载该数字,无需引入��的 UI 或数�路径。 + +## �果 + +token 总�在分页�压缩�回放���和�连期间��稳定,因为它们是通过通用路径��的普通�久投影状�。跨��排�竞�从构造上就�存在,而�是被栅�挡�。 + +�用率在上文记录的�义上是近似值。由于两个字段都是�久的,它在��或�连�立��用;代价是它�述的是最�一�已记录的请求,而�是精确的当�边界。 + +�个会�日志会为�次路由或已公布容��化增加一��型 `request/context` 记录。token-meter 投影是�久会�投影用�语义的正典所有方;TUI 未挂载通用投影 seam,因此�留自己的实时�步骤 map,而独立�览器 fixture 会镜�该�元。ApiProxy ��带任何 token 专用代�,�拥有�会�指标缓存,也�执行测�。�览器��留两个通用投影值,��留连接本地的�测数�;��文本增���会迫使统计行�新计算。 diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml index a56a91c980..2efe235e28 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.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/architecture/2026-07-30-client-locale-full-rollout.md -2026-07-30-client-locale-full-rollout.md: c080d9f240d4533ecd9694ceecfada8662c46425 -2026-07-30-client-locale-full-rollout.zh.md: 062d982e3d7ea62f3ca4c8fedb842e8336f0852c +2026-07-30-client-locale-full-rollout.md: 09baf5876029295f7a80b6a0fe6a6395d98f406c +2026-07-30-client-locale-full-rollout.zh.md: 806916aea15a21fd24fdfc4654976b3c4577a675 diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md index c080d9f240..09baf58760 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md @@ -14,7 +14,7 @@ After the typed locale standard seat landed (`locale:` on register → framework **Component copy rides the standard `t` seat; deep children take `t` as a plain prop** typed `XxxProps['t']`. The dictionary canon is unchanged: `zh satisfies Record` is the key source and `en satisfies Record` locks bilingual balance. -**Zero-cordis atoms (ui-primitives) take copy as props**: `labels` on `TerminalBlock`/`JsonTree`, `copyLabel`/`copiedLabel` on `CodeBlock`, `codeLabels` on `MarkdownText`, `truncatedLabel` on `JsonBlock`, `label` on `ConnectionBanner`, `closeLabel` on `Modal` — defaults are the previous hardcoded strings, so a consumer passing nothing renders byte-identical output. Localized plugins pass dictionary-driven labels from their own `t` seat; call sites passing object props memoize them on the `t` identity (`MarkdownText` caches its component table on the `codeLabels` identity). +**Zero-cordis atoms (ui-primitives) take copy as props**: `copyLabel`/`copiedLabel` on `HoverCard`, `labels` on `TerminalBlock`/`JsonTree`, `copyLabel`/`copiedLabel` on `CodeBlock`, `codeLabels` on `MarkdownText`, `truncatedLabel` on `JsonBlock`, `label` on `ConnectionBanner`, `closeLabel` on `Modal` — defaults are the previous hardcoded strings, so a consumer passing nothing renders byte-identical output. Localized plugins pass dictionary-driven labels from their own `t` seat; call sites passing object props memoize them on the `t` identity (`MarkdownText` caches its component table on the `codeLabels` identity). **The non-translation boundary (deliberate decisions, not debt):** @@ -25,7 +25,7 @@ After the typed locale standard seat landed (`locale:` on register → framework **Derivation layers stay pure; localization happens at render.** ui-workspace's `relativeTime` returns structured `{unit, n}` composed with dictionary templates by the renderer; blank sessions and the Ungrouped bucket keep their stored titles, with the renderer substituting localized copy off the `blank` flag / absent `workspaceId`; **blank rows are excluded from search entirely** (a bilingual display title cannot match a single-language query stably). Dates use no Intl: format templates live in the dictionaries (message clock `clock.md`/`clock.ymd`, workspace hover `date.ymd`) and the formatters take `t` as a parameter, staying pure. -**Test and e2e doctrine**: `makeTranslate(...dicts)` (dsh-client-test-runtime) mirrors the service lookup chain (first-dict-wins, key fallback, `{name}` interpolation); component specs stub the `t` seat with it, typed against real props seats. Web e2e uniformly opens through `newEnglishPage` (pins `dsh.locale=en` before boot) and the built-boot snapshot pins the same — goldens are immune to localization migrations; the settings language-switch scenario deliberately bypasses the helper to cover the zh default. +**Test and e2e doctrine**: `makeTranslate(...dicts)` (dsh-client-test-runtime) mirrors the service lookup chain (first-dict-wins, key fallback, `{name}` interpolation); component specs stub the `t` seat with it, typed against real props seats. Web e2e uniformly opens through `newEnglishPage` (pins `dsh.locale=en` before boot) and the built-boot snapshot pins the same — goldens are immune to localization migrations; the settings language-switch scenario bypasses the helper and opens a `zh-CN` browser, since the initial locale follows `navigator` ([browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md)). The "apply layer subscribes to `locale/change` and re-registers for fresh labels" mechanism in the [settings/locale/theme layering note](../../proposed/architecture/2026-07-25-client-settings-locale-theme.md) is superseded by this decision (thunk + revision lifecycle). diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md index 062d982e3d..806916aea1 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md @@ -14,7 +14,7 @@ typed locale 标准席�(`locale:` 注册声明 → 框架注入强类型 `t` **组件文案走标准 `t` 席�;深层�组件用 prop 下传**,类型写 `XxxProps['t']`。字典规范形���:`zh satisfies Record` 为 key ��`en satisfies Record` ��语平衡。 -**zero-cordis 原�组件(ui-primitives)文案 props 化**:`TerminalBlock`/`JsonTree` 的 `labels`�`CodeBlock` 的 `copyLabel`/`copiedLabel`�`MarkdownText` 的 `codeLabels`�`JsonBlock` 的 `truncatedLabel`�`ConnectionBanner` 的 `label`�`Modal` 的 `closeLabel`——默认值�原硬编�字符串,�传 props 的消费者渲染�字节��。已本地化的�件从自己的 `t` 席�传字典驱动的 label;传对象 props 的调用点按 `t` 身份 memo(`MarkdownText` 的组件表按 `codeLabels` 身份缓存)。 +**zero-cordis 原�组件(ui-primitives)文案 props 化**:`HoverCard` 的 `copyLabel`/`copiedLabel`�`TerminalBlock`/`JsonTree` 的 `labels`�`CodeBlock` 的 `copyLabel`/`copiedLabel`�`MarkdownText` 的 `codeLabels`�`JsonBlock` 的 `truncatedLabel`�`ConnectionBanner` 的 `label`�`Modal` 的 `closeLabel`——默认值�原硬编�字符串,�传 props 的消费者渲染�字节��。已本地化的�件从自己的 `t` 席�传字典驱动的 label;传对象 props 的调用点按 `t` 身份 memo(`MarkdownText` 的组件表按 `codeLabels` 身份缓存)。 **�翻译边界(刻�决定,�是欠账):** @@ -25,7 +25,7 @@ typed locale 标准席�(`locale:` 注册声明 → 框架注入强类型 `t` **派生层��纯函数,本地化�在渲染层**:ui-workspace 的 `relativeTime` 返回结构化 `{unit, n}` 由渲染组�字典模�;blank 会�/未分组桶的存储标题��,渲染按 `blank` 标志/`workspaceId` 缺席替�本地化文案;**�索� blank 行一律排除**(�语标题无法与�语查询稳定匹�)。日期�引 Intl:格�模�进字典(消�时钟 `clock.md`/`clock.ymd`,workspace hover `date.ymd`),格�化函数� `t` �数��纯。 -**测试与 e2e �径**:`makeTranslate(...dicts)`(dsh-client-test-runtime)镜��务查找链(首个命中字典胜出�key 兜底�`{name}` �值),组件测试的 `t` 桩统一用它并以真实 props 席�定型。web e2e 统一 `newEnglishPage`(boot �钉 `dsh.locale=en`),built-boot snapshot �样钉 en——golden 对语言�移�疫;settings 语言切�用例刻�绕开该 helper 覆盖 zh 默认�。 +**测试与 e2e �径**:`makeTranslate(...dicts)`(dsh-client-test-runtime)镜��务查找链(首个命中字典胜出�key 兜底�`{name}` �值),组件测试的 `t` 桩统一用它并以真实 props 席�定型。web e2e 统一 `newEnglishPage`(boot �钉 `dsh.locale=en`),built-boot snapshot �样钉 en——golden 对语言�移�疫;settings 语言切�用例绕开该 helper 并开� `zh-CN` �览器,因为�始 locale 跟� `navigator`([由�览器推导�始 locale](../feature/2026-07-31-browser-derived-initial-locale.md))。 [settings/locale/theme 分层 Note](../../proposed/architecture/2026-07-25-client-settings-locale-theme.md) 中"apply 层订阅 `locale/change` �注册刷新 label"的机制已被本决定�代(thunk + revision 生命周期)。 diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.i18n.yaml index cc25329fed..01334b2d8b 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.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/architecture/2026-07-30-session-end-seed-log-boundary.md -2026-07-30-session-end-seed-log-boundary.md: 268646e192d0b8e0a5dde03957a18ef155b7038e -2026-07-30-session-end-seed-log-boundary.zh.md: dca87e16de5e567ff85d2b32b8243f76ebed1c4a +2026-07-30-session-end-seed-log-boundary.md: ca6145f3ce404b88d2a144483c6f1145fe0a9a7e +2026-07-30-session-end-seed-log-boundary.zh.md: d492c4ac5f39328e75d991b285004e2d19c56283 diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md index 268646e192..ca6145f3ce 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md +++ b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md @@ -52,4 +52,4 @@ Cost: a seeded session's log is one event longer, including an empty resumed log `session/end-seed` joins the on-disk vocabulary. Under the pre-release stance (`SESSION_FORMAT_VERSION` pinned at `0`, no compatibility promise) older logs simply lack it, and a log without a boundary correctly classifies nothing as constructor-seed history. -Not built here: no plugin reads the boundary yet. Wiring the compaction seam's staleness check to it is the follow-up that motivated this boundary; the predicate helper belongs with that seam, where a real consumer decides its shape, rather than shipping into core untested against one. +The [queued manual compaction decision](../feature/2026-07-30-queued-manual-compaction.md) now supplies the first consumer. Its tail scan independently finds the unmatched `compact/start` and newest end-seed, treats only a start after that boundary as live, and clears the invariant trace on the same replay transition. The predicate remains in the compaction package rather than becoming a generic core helper. diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md index dca87e16de..d492c4ac5f 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md @@ -52,4 +52,4 @@ Status: implemented `session/end-seed` 加入了�盘�汇表。在预�布立场下(`SESSION_FORMAT_VERSION` 固定为 `0`,�作兼容承诺),更旧的日志�是没有它,而没有边界的日志会正确地判定没有任何内容属于构造��历�。 -此处未�:还没有任何�件读�该边界。把压缩 seam 的陈旧性检查接到它上�,是催生这�边界的�续工作;谓�辅助函数应当归属那个 seam——在那里由真实消费方决定它的形状——而�是未�真实消费方检验就先�进核心。 +[排队手动压缩决策](../feature/2026-07-30-queued-manual-compaction.md)如今�供了第一个消费方。其尾部扫�会分别查找未匹�的 `compact/start` 与最新 end-seed,�把�于该边界之�的 start 视为活动�,并在�一个回放转�上清除���追踪状�。该谓���于压缩包中,�会�为通用核心辅助函数。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml index edb6ac6e4a..8926d51744 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.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-29-human-transcript-append-origin.md -2026-07-29-human-transcript-append-origin.md: a296b93d538d9c28bd61ee8fd0530863b4bfd878 -2026-07-29-human-transcript-append-origin.zh.md: 96e0cd1038fe8904dfd4c1eceaae9b25339c5dca +2026-07-29-human-transcript-append-origin.md: a47dd49dd831cdd32d520137417bf47d2c056a09 +2026-07-29-human-transcript-append-origin.zh.md: 31639bd9aac5d6dace80392004f37c747bff2c36 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md index a296b93d53..a47dd49dd8 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md @@ -24,9 +24,9 @@ No persisted event, RPC envelope, compaction transaction, or model-visible surfa ## Deferred -The browser client still builds its conversation from the model surface through `FoldAdapter`, so compaction still collapses web history to a single context row. The same predicate is the fix there, together with an append-order transcript projection and a marker component; that work is a separate change against `packages/client/runtime` and `packages/client/ui-conversation`. +The browser client is fixed separately, in [the web transcript projection note](2026-07-30-web-transcript-log-ordered-projection.md): it projects the same append-origin transcript in log order and renders a marker component, and it closes the pagination hole this change opened — because `session.history` no longer spends quota on the checkpoint, it never cuts on the checkpoint's provenance group, so a page can carry a checkpoint citing a `surfaceOp.start` outside the window, which the browser's surface fold rejected. That hole predates this change (counting could already run past a checkpoint into the range it shadows), but the old rule accidentally covered the case where the checkpoint was the oldest counted message and pulled the whole shadowed range onto its page. -That work must handle a page whose checkpoint cites a `surfaceOp.start` outside the window: pagination no longer spends quota on the checkpoint, so it never cuts on the checkpoint's provenance group, and `FoldAdapter` pads absent events with a non-surface sentinel — so `SurfaceManager` rejects the range and `nodes()` falls back to `degradedSeqs()` with a logged error. The hole predates this change (counting could already run past a checkpoint into the range it shadows), but the old rule accidentally covered the case where the checkpoint was the oldest counted message and pulled the whole shadowed range onto its page. `degradedSeqs()` — every surface-eligible event in append order — is already close to the transcript projection A2 needs, which is the shape to build deliberately rather than reach as a degradation. Rendering compaction *progress* — a terminal indicator while a compaction runs — needs the bracket-first ordering that the queued manual `/compact` work introduces, and is likewise out of scope here. The marker also carries no scale: the checkpoint's `sourceEventSeqs` already hold the shadowed count, so a count or range would tell a reader how much each row folded. That belongs with progress, where the reader meets the other half of the same information. Whoever takes it should fold the terminal's two replacement branches — replay and the live listener, textually identical and 600 lines apart — into one `renderReplacement(event)` first, so the marker's content has a single home. +The terminal's [live compaction progress decision](../feature/2026-07-30-compaction-progress-visibility.md) uses standalone bracket events to drive the existing one-cell indicator. It does not change the completion marker owned here or add scale: the checkpoint's `sourceEventSeqs` remain available for a separately justified count or range. Progress therefore needs neither marker-content changes nor a prerequisite `renderReplacement(event)` extraction. ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md index 96e0cd1038..31639bd9aa 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md @@ -24,9 +24,9 @@ Status: implemented ## Deferred -�览器客户端�通过 `FoldAdapter` 从模型 surface 构建会�,因此压缩在 Web 端�会把历�折��一行上下文。那里的修�用的是�一个谓�,�需按追加顺�的记录投影与一个标记组件;该工作是针对 `packages/client/runtime` 与 `packages/client/ui-conversation` 的独立�更。 +�览器客户端在[Web 记录投影笔记](2026-07-30-web-transcript-log-ordered-projection.md)中�独修�:它按日志顺�投影�一份 append ��记录并渲染一个标记组件,�时闭�本次�更打开的分页缺�——因为 `session.history` ��为检查点消耗�度,它永远�会按检查点的溯�分组切分,于是一页�以�带一个引用了窗�之外 `surfaceOp.start` 的检查点,而�览器的 surface fold 会拒�该范围。这个缺�早于本次�更(此�计数就�能越过检查点进入它所�蔽的范围),但旧规则�好覆盖了这样一�情形:检查点是最旧的被计数消�,其溯�分组把整段被�蔽的范围一起拉到该页。 -该工作必须处�这样一页:其检查点引用的 `surfaceOp.start` �在窗�之外。分页��为检查点消耗�度,因此永远�会按检查点的溯�分组切分;而 `FoldAdapter` 会用一个� surface 的哨兵事件填补缺失事件——于是 `SurfaceManager` 拒�该范围,`nodes()` 退化为 `degradedSeqs()` 并记录一�错误。这个缺�早于本次�更(此�计数就�能越过检查点进入它所�蔽的范围),但旧规则�好覆盖了这样一�情形:检查点是最旧的被计数消�,其溯�分组把整段被�蔽的范围一起拉到该页。`degradedSeqs()`——按追加顺�的�个 surface �入事件——已�很接近 A2 所需的记录投影,因此那正是应当刻�构建的形�,而�是作为退化路径被动�到的结果。渲染压缩*进度*——压缩�行期间的终端指示——需�排队�手动 `/compact` 工作引入的“先开括��顺�,�样�在本次范围内。标记�样��带规模信�:检查点的 `sourceEventSeqs` 已�包�被�蔽的数�,因此一个计数或区间�以告诉读者�一行折�了多少内容。这件事属于进度那一侧,读者正是在那里�到�一份信�的�一�。接手者应当先把终端里两处替�分支——回放与实时监�器,文本完全相��相隔 600 行——�并为一个 `renderReplacement(event)`,让标记的内容�有一个归处。 +终端的[实时压缩进度决策](../feature/2026-07-30-compaction-progress-visibility.md)使用独立标记对中的事件驱动现有的�格指示器。它既�改�本文所负责的完�标记,也�添加规模信�:检查点的 `sourceEventSeqs` ��供��行论�的计数或区间使用。因此,进度显示既�需�修改标记内容,也�以�� `renderReplacement(event)` 为�置�件。 ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.i18n.yaml new file mode 100644 index 0000000000..ed2f7646bc --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.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 .agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.md +2026-07-30-hover-popup-pointer-grace.md: 3f60c98ec6453b633feebe408cbc0c0c49eedea1 +2026-07-30-hover-popup-pointer-grace.zh.md: db10e156103284383f911684b2c92977a0315275 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.md b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.md new file mode 100644 index 0000000000..3f60c98ec6 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.md @@ -0,0 +1,35 @@ +# Agent Note: Hover popup pointer grace + +Status: implemented + +English | [中文](2026-07-30-hover-popup-pointer-grace.zh.md) + +## Problem + +Both popups the workspace browser rows raise floated out of reach of the pointer. `HoverCard` closed on the first `pointerleave` from its anchor and rendered its card `pointer-events: none`, but the card sits 8px off the anchor's right edge, so every path to it crossed ground belonging to neither and killed the card before it arrived — the full workspace path and session title it exists to show could be read only in passing. The row action menus passed `closeOnPointerLeave`, whose handler sat on the portaled list: aiming back at the `...` trigger that opened the list closed it, and so did any overshoot past a list edge, with no window to come back. + +## Decision + +`usePointerGrace` ([packages/client/ui-primitives/src/pointer-grace.ts](../../../../packages/client/ui-primitives/src/pointer-grace.ts)) owns one cancelable delayed close, shared by both atoms, with `POINTER_GRACE_MS` at 200. Leaving arms the close; coming back cancels it. Transit through an anchor-to-popup gap is therefore survivable, while a pointer that has genuinely moved on still dismisses the popup. + +`HoverCard` arms the grace on leave instead of closing, and its card no longer sets `pointer-events: none`, so resting on the card holds it open. Re-entering while already open cancels the pending close without restarting the dwell, which keeps the card from blinking when the pointer crosses the gap. A press on the card starts a selection instead of dismissing it; only anchor-region presses and an owner flipping `disabled` dismiss immediately, ahead of the grace. + +`Menu` moves pointer-leave dismissal from the portaled list to the wrapper span. React's enter/leave traversal runs over the React tree, so the trigger and the portaled list are one region there: crossing the 4px gap between them, or aiming back at the trigger, no longer counts as leaving. Leaving is only armed while the list is open, and an owner-driven close (selection, Escape, outside click) disarms a pending grace close in an effect keyed on `open` alone — folding that into the outside-click effect would cancel the grace on every re-render, since owners pass a fresh `onClose` closure each time. + +## Alternatives considered + +**Close the popups only on outside click and Escape.** Rejected because both popups are hover-raised and unlabeled as dismissible; leaving them up after the pointer has moved to another row would strand a card over unrelated content. + +**Widen the anchor's hit area to abut the popup.** Rejected because the 8px and 4px offsets are the design's, and an invisible bridge element would have to track every reposition the fixed-positioned popups already do on scroll and resize. + +**Keep the hover card `pointer-events: none` and only add the grace.** Rejected because the pointer resting on the card would then hit whatever is behind it, so the grace would expire and close the card the user had just reached. + +**Give each atom its own timer.** Rejected because the two closes are the same behavior with the same tuning; a shared hook keeps them from drifting apart. + +## Consequences + +The hover card is now hit-testable and covers 244px of whatever it overlays while shown, which is the price of being reachable; it still lives only as long as the pointer is on the row or the card. Row menus survive the round trip between trigger and list, and a menu that closes for its own reason cannot be reopened into a stale pending close. Menus without `closeOnPointerLeave` are untouched — the wrapper handlers are only attached when it is set. + +## Testing + +`packages/client/ui-primitives/tests/hover-card.spec.tsx` and `tests/atoms.spec.tsx` pin the grace boundary, cancel-on-return, no-second-dwell, disarm-on-owner-close, and the no-arming-while-closed case. The reachability gestures themselves — hovering onto the card, and moving between an open list and its trigger — are pinned in the real browser by `apps/web/tests/workspace-management.e2e.ts`, since they depend on hit testing and layout that jsdom does not model. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.zh.md new file mode 100644 index 0000000000..db10e15610 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 悬浮弹层的指针宽�期 + +Status: implemented + +[English](2026-07-30-hover-popup-pointer-grace.md) | 中文 + +## 问题 + +工作区�览器行弹出的两�弹层都处于指针无法抵达的�置。`HoverCard` 在指针离开锚点的第一个 `pointerleave` 上就关闭,其�片还设置了 `pointer-events: none`;但�片�于锚点�边缘外 8px 处,因此通往�片的��路径都�穿过既�属于锚点也�属于�片的区域,�片在指针抵达之�就已被销�——它本应展示的完整工作区路径和会�标题�能匆匆一瞥。行�作��传入了 `closeOnPointerLeave`,而其处�器挂在传��的列表上:把指针移回打开该列表的 `...` 触�按钮会关闭列表,越过列表边缘的任何一次抖动�样如此,且没有任何折返窗�。 + +## 决策 + +`usePointerGrace`([packages/client/ui-primitives/src/pointer-grace.ts](../../../../packages/client/ui-primitives/src/pointer-grace.ts))�有唯一一个��消的延迟关闭,由两个原�组件共享,`POINTER_GRACE_MS` 为 200。离开会�动关闭,折返则�消它。因此指针�以安全穿越锚点与弹层之间的间隙,而真正移开的指针�会关闭弹层。 + +`HoverCard` 在离开时�动宽�期而��立�关闭,其�片也��设置 `pointer-events: none`,因此指针�在�片上��让它��打开。在已打开状�下�新进入��消待执行的关闭,而����留计时,从而��指针穿越间隙时�片闪�。在�片上按下指针用于开始文本选择,�会关闭�片;�有锚点区域内的按下和所有者将 `disabled` 置真,�会抢在宽�期之�立�关闭�片。 + +`Menu` 把指针离开关闭的处�从传��的列表移到包裹 span 上。React 的 enter/leave �历基于 React 树进行,因此触�按钮与传��的列表在这里属于�一区域:穿越两者之间 4px 的间隙�或把指针移回触�按钮,都��算作离开。�有在列表打开时�会�动离开关闭;由所有者驱动的关闭(选择�Escape�外部点击)会在一个仅以 `open` 为�赖的 effect 中解除待执行的宽�关闭——若把它折�进外部点击的 effect,则�次�新渲染都会�消宽�期,因为所有者�次都传入新的 `onClose` 闭包。 + +## 考虑过的替代方案 + +**仅通过外部点击和 Escape 关闭这两�弹层。** 之所以�决:两者都由悬�唤起,且没有��的关闭标识;在指针已移到其他行之��让它们�留,会把�片�留在无关内容之上。 + +**扩大锚点的命中区域,使其与弹层相接。** 之所以�决:8px 与 4px 的�移�自设计稿,而一个���的桥接元素还必须跟�这两个固定定�弹层已�在滚动和缩放时执行的�一次�新定�。 + +**�留悬浮�片的 `pointer-events: none`,�加入宽�期。** 之所以�决:那样指针�在�片上时命中的是�片背�的元素,宽�期�会到期,并关闭用户刚刚够到的�片。 + +**让两个原�组件�自�有计时器。** 之所以�决:这两处关闭是�一�行为��一套调�;共享 hook �以防止它们�自漂移。 + +## �果 + +悬浮�片现在�被命中,显示期间会�挡其覆盖区域的 244px——这是�抵达性的代价;它�然�在指针�于行或�片上时存在。行��现在能承�触�按钮与列表之间的往返,而因自身原因关闭的��也�会被残留的待执行关闭�新关掉。未设置 `closeOnPointerLeave` 的����影�——�有设置该属性时�会挂上包裹层处�器。 + +## 测试 + +`packages/client/ui-primitives/tests/hover-card.spec.tsx` 与 `tests/atoms.spec.tsx` 固定验�宽�期边界�折返�消�����留计时�所有者关闭时解除待执行关闭,以�列表关闭时��动关闭。�抵达性手势本身——把指针移到�片上,以�在打开的列表与其触�按钮之间移动——由 `apps/web/tests/workspace-management.e2e.ts` 在真实�览器中固定验�,因为它们�赖 jsdom 无法建模的命中测试与布局。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.i18n.yaml new file mode 100644 index 0000000000..b3d987b985 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.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 .agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.md +2026-07-30-tui-adapter-registration-race.md: fd08e7b6130bc8f7e3cd5287a9970f5fb47244a8 +2026-07-30-tui-adapter-registration-race.zh.md: 0c6bba4bbc8c3303d9c471c3164faa816438b333 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.md b/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.md new file mode 100644 index 0000000000..fd08e7b613 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.md @@ -0,0 +1,29 @@ +# Agent Note: TUI model-context resolution defers on the adapter-registration race + +Status: implemented + +English | [中文](2026-07-30-tui-adapter-registration-race.zh.md) + +## Problem + +Cordis activates plugins by service availability, not configuration order, so the TUI (whose `inject` requires only the `llm` service) can mount before a configured adapter plugin such as `dsh-llm-pi-ai` finishes registering its provider routes. The TUI's model controller resolves the selected model's context window immediately on mount; when the agent's route pointed at a not-yet-registered provider, `resolveModelInfo` rejected with `NO_ADAPTER` and every fresh session printed `Could not resolve model context: no adapter registered for provider "…"` — a spurious error for a fully working configuration (the adapter registered milliseconds later, and chatting worked). + +## Decision + +The TUI model controller treats a `NO_ADAPTER` rejection of its context-window resolution as a transient state rather than an error: it parks the resolution silently and re-resolves on the next `llm/adapters-updated` commit — the payload-free registry notification `LlmService` already fires at every route commit point. A commit that still lacks the route parks the wait again, so unrelated topology changes stay silent. Any target change re-enters the resolution and clears the pending wait, so the deferred state can never go stale against the current selection; every other resolution error still prints the notice. + +## Alternatives considered + +**Have the TUI wait for boot to settle before resolving.** The TUI has no Loader dependency (tests and embedders run without one) and "settled" is not observable from inside a plugin; adding a Loader coupling for one cosmetic resolution inverts the dependency direction. + +**Poll or retry with a timer.** A timer guesses at activation latency, still mis-prints on a slow adapter, and adds a tunable with no owner. The registry already announces every commit through `llm/adapters-updated`; subscribing is precise and free. + +**Order the config so adapters load first.** Row order carries no load semantics in the Loader (activation is service-driven by design), so this cannot be expressed in configuration. + +**Suppress NO_ADAPTER errors entirely.** A permanently missing adapter (typo in the provider name) would then never surface in the context-window path. Deferring keeps the signal: a wrong provider name still shows `model unset`-like behavior in the selector and fails loudly at dispatch, while the startup race resolves itself. + +**Resolve the context window per submitted message instead of at mount.** The send path already resolves per step (`prepareCall()`), and the indicator is displayed continuously, not only when sending; per-submit display resolution would leave the indicator blank until the first message and re-run adapter I/O for a value that only changes on route changes. + +## Consequences + +A genuinely misconfigured provider no longer prints the context-resolution error at startup — it surfaces at first dispatch instead, which is where the failure is actionable. The controller subscribes to every `llm/adapters-updated` commit but acts only while a wait is parked; the listener's disposer is released by the channel's `detachListeners()` through the controller's `detach()`, symmetric with the sibling channel listeners. Covered by three TUI tests: the deferred resolution stays silent through an unrelated commit and completes when the route's commit arrives, a target change drops the stale wait, and after channel detach a registry commit no longer re-enters resolution. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.zh.md new file mode 100644 index 0000000000..0c6bba4bbc --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.zh.md @@ -0,0 +1,29 @@ +# Agent Note: TUI 模型上下文解�在适�器注册竞争时延��试 + +Status: implemented + +[English](2026-07-30-tui-adapter-registration-race.md) | 中文 + +## Problem + +Cordis 按�务�用性而��置顺�激活�件,因此 TUI(其 `inject` ��求 `llm` �务)�能在 `dsh-llm-pi-ai` 这类已�置的适�器�件完��供方路由注册之�就挂载。TUI 的模型控制器在挂载时立�解�所选模型的上下文窗�;当 agent 的路由指�尚未注册的�供方时,`resolveModelInfo` 以 `NO_ADAPTER` 拒�,于是�个新会�都会打� `Could not resolve model context: no adapter registered for provider "…"` —— 对一份完全正常的�置报出的虚�错误(适�器几毫秒�就完�注册,对�也一切正常)。 + +## Decision + +TUI 模型控制器把上下文窗�解�中的 `NO_ADAPTER` 拒�视为瞬�状�而�错误:�默�置这次解�,并在下一次 `llm/adapters-updated` �交时�新解�——这是 `LlmService` 本就在�个路由�交点�出的无载�注册表通知。若�次�交�缺少该路由,等待会被�次�置,因此无关的拓扑�化��沉默。任何目标�更都会�新进入解�并清除挂起的等待,因此延�状���会相对当�选择�陈旧;其他所有解�错误�照常打�通知。 + +## Alternatives considered + +**让 TUI 等�动结算��解�。** TUI ��赖 Loader(测试和嵌入方在没有 Loader 的环境下�行),而且"已结算"在�件内部��观测;为一次外观性的解�引入 Loader 耦�会颠倒�赖方�。 + +**用定时器轮询或�试。** 定时器�能猜测激活延迟,�到慢适�器�会误报,还会引入一个没有归属者的�调�数。注册表本就通过 `llm/adapters-updated` 公告�次�交;订阅它既精确�零�本。 + +**调整�置顺�让适�器先加载。** Loader 中行顺��承载加载语义(激活按设计由�务驱动),因此这无法用�置表达。 + +**彻底压制 NO_ADAPTER 错误。** 那样的�,永久缺失的适�器(�供方�字拼错)在上下文窗�路径上就永远�会暴露。延��试�留了信�:错误的�供方�字�会在选择器中表现出类似 `model unset` 的行为,并在分派时大声失败,而�动竞争则自行化解。 + +**改为在�次�交消�时解�上下文窗�,而�是在挂载时。** ��路径本就按步解�(`prepareCall()`),且指示器是�续显示的,��在��时;按�交解�显示值会让指示器在首�消�之�一直空白,并为一个仅在路由�化时��的值��执行适�器 I/O。 + +## Consequences + +真正�置错误的�供方��在�动时打�上下文解�错误——它改在首次分派时暴露,那�是该失败�以被处�的地方。控制器订阅�次 `llm/adapters-updated` �交,但�在有等待被�置时�动作;监�器的 disposer �由控制器的 `detach()` 在频�的 `detachListeners()` 中释放,与�级频�监�器��对称。由三个 TUI 测试覆盖:延�的解�在无关�交中��沉默�在该路由的�交到�时完�;目标�更丢弃陈旧等待;频� detach 之�注册表�交���新进入解�。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml new file mode 100644 index 0000000000..5d12a08e36 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.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 .agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md +2026-07-30-web-transcript-log-ordered-projection.md: 4bc629bfa619a2858ec0335a6136d3a5a24b025a +2026-07-30-web-transcript-log-ordered-projection.zh.md: d011ce9453bc1dbb9fdb62372aa1126c95ffb143 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md new file mode 100644 index 0000000000..4bc629bfa6 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md @@ -0,0 +1,72 @@ +# Agent Note: The browser conversation is a log-ordered human transcript + +Status: implemented + +English | [中文](2026-07-30-web-transcript-log-ordered-projection.zh.md) + +## Problem + +The browser client built its conversation from the model-visible surface: `FoldAdapter` ran the core `SurfaceManager` over the history window and read `surface.nodes`. A successful compaction replaces a surface range with one checkpoint node, so the moment that replacement landed the web flow collapsed every message it shadowed into a single dim context row — conversation the user had already read. Nothing was lost from the log; the defect was entirely in the projection, and [the terminal and the host gateway were fixed the same way](2026-07-29-human-transcript-append-origin.md) while the browser was left for this change. + +Surface order made two further problems structural. It is not seq-ascending after a replacement — `SurfaceManager` splices the high-seq checkpoint into the position of the range it shadows — so log-only nodes merged into that array by numeric seq (slash-command rows, interrupted frozen nodes) could be flushed ahead of the checkpoint and never interleave into the retained tail again. And because pagination no longer spends `maxMessages` quota on replacement copies, a page can now carry a checkpoint whose `surfaceOp.start` lies outside the window; the core fold rejects that range, so `nodes()` fell back to a lenient linear scan behind a `console.error` and published a `foldDegraded` flag describing the failure. + +## Decision + +`TranscriptAdapter` replaces `FoldAdapter` and never consults surface order. It 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. A landed compaction therefore keeps the conversation it shadowed on the model side, and the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out of the transcript: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary in the conversation. Everything that must send exactly what the model sees keeps reading the surface; this is the human projection, and the two are now separate on both frontends. + +Node order is seq-monotonic by construction, and three things follow. The log-only `command/run` / `command/done` pair folds into `CommandNode`s that splice into an already-monotonic array by seq — no anchors, no reordering. `Session` keeps ownership of interrupted frozen nodes and merges them by their fractional seqs with a plain sort, which is now exactly flow order. And a window whose checkpoint cites a shadowed range outside it has no range to resolve, so the marker renders and nothing is logged. + +`foldDegraded` is gone from `ConversationSnapshot`, and with it the padding sentinels, the `baseSeq` arithmetic they needed, and `degradedSeqs()`. They existed only to satisfy the core fold's `seq === index` assertion and to survive its throw; the fold they describe is no longer run. Deleting the flag is part of the fix, not cleanup after it — `degradedSeqs()` was already almost the log-ordered projection, reached after a thrown error instead of intended. + +The marker's summary text comes from the checkpoint's own `compact/summary` provenance, never from the framed checkpoint payload, which is an instruction envelope written for the model. A window cut that left the provenance outside makes the row non-expandable rather than empty, the same soft-fall as a call-less tool result, and a later page supplying the provenance resolves the text. + +No persisted event, RPC envelope, compaction transaction, or model-visible surface changed, and no migration is required. + +## 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. + +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. + +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: + +```ts +import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpoint' +const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact' +``` + +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**. The test value-imports the cordis-free `@deepseek-ai/dsh-compact/checkpoint` leaf and deliberately never loads the compact package root or the host-side `Context` merges reachable through it. + +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 + +The unmerged manual-compaction-queueing branch fixes the same interleaving bug by recording a per-event anchor — the surface tail at append time — and retargeting shadowed anchors onto the checkpoint. That mechanism exists to make positional anchors survive surface **reordering**. The human transcript is never re-ordered, so anchors have nothing to retarget: the precondition is removed, not the fix discarded. The mechanism is absent from this base and is not authored here. + +## Alternatives considered + +**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. + +**Tag the checkpoint host-side** through the projection or wire contract. Rejected: most aligned with the "collaborate through cordis services" rule, but the client folds raw `SessionEvent`s today, so it means a wire contract change out of proportion to one pure predicate. + +**Move frozen-node ownership into the adapter** (`nodes(extraNodes)`), as the unmerged branch does. Rejected: the interrupted nodes come from the `turn/end` sweep `Session` already runs over the window, and with a seq-monotonic transcript the simple shape is correct — the adapter returns nodes, the session merges frozen ones by seq. Widening the adapter's signature would buy nothing and split the sweep from its product. + +**Keep `foldDegraded` as a defensive flag.** Rejected: it described a specific failure of a fold that no longer runs. A flag no consumer can act on, reachable only through a `console.error`, is a false contract. + +## Consequences + +Compaction no longer erases web history; a session compacted several times shows one marker per landed compaction, in log order, and the same window renders identically live and after a cold resume. The pagination hole is closed by construction rather than defended against, and `ConversationSnapshot` loses a published field, which touched thirteen files. + +`ConversationNode` gains an eighth arm, so every exhaustive consumer grew one case: `MessageItem` renders the marker through the new `CompactionItem`, and the trajectory layout widens its no-cell arm so a marker contributes no cell but still advances the duration cursor. + +The performance contract is unchanged and now simpler to state: one append materializes one node, an event that changes no node keeps the previous array reference — so a chunk storm costs nothing and `nodes()` is not even recomputed — and unchanged nodes keep their object identity. The window still grows with session length rather than with the surface, which is the trade the fix exists to make; a compaction used to bound the projection for exactly the long sessions compaction serves. + +The web e2e scenario now seeds a real compaction transaction over its recorded turn, so the aria golden pins both halves of the fix through the real host and a real browser: the recorded prompt and full tool output are still on screen, and one marker sits after them. The seed recording itself is untouched and stays model-authentic — replay derives the compacted turn from the recording's own surface. + +## Deferred + +The terminal's [compaction progress decision](../feature/2026-07-30-compaction-progress-visibility.md) uses the live standalone bracket to drive a one-cell indicator and does not change this browser projection. The marker still carries no **scale**: the checkpoint's `sourceEventSeqs` hold the shadowed count, so a separately justified count or range can be added without coupling it to progress. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md new file mode 100644 index 0000000000..d011ce9453 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md @@ -0,0 +1,72 @@ +# Agent Note: �览器会�是按日志顺�投影的人类对�记录 + +Status: implemented + +[English](2026-07-30-web-transcript-log-ordered-projection.md) | 中文 + +## Problem + +�览器客户端从模型��的 surface 构建会�:`FoldAdapter` 在历�窗�上�行核心 `SurfaceManager` 并读� `surface.nodes`。一次�功的压缩会用一个检查点节点替�一段 surface 范围,因此该替�一�地,Web �就把它所�蔽的��消�折��一行�暗的上下文——那是用户已�读过的对�。日志中什么都没丢失;缺陷完全在投影层,而[终端与宿主历�网关已按�一方�修�](2026-07-29-human-transcript-append-origin.md),�览器留给了本次�更。 + +surface 顺�还让�外两个问题�为结构性的。一次替�之�它并�按 seq ��——`SurfaceManager` 把高 seq 的检查点拼接到它所�蔽范围的�置上——因此按数值 seq 归并进该数组的仅日志节点(斜�命令行�被打断的冻结节点)�能被冲刷到检查点之�,�也无法交错回�留下�的尾部。而且由于分页��为 replacement 副本消耗 `maxMessages` �度,一页现在�以�带一个 `surfaceOp.start` �在窗�之外的检查点;核心 fold 拒�该范围,于是 `nodes()` 退回到一次宽容的线性扫��打�一� `console.error`,并�布一个�述该失败的 `foldDegraded` 标志。 + +## Decision + +`TranscriptAdapter` �代 `FoldAdapter`,并且从�查询 surface 顺�。它按日志顺�投影原始窗�:�个 append ��的 surface 事件(`isAppendSurfaceEvent`)�在它自己的日志�置上,外加�次�地的压缩检查点一个 `CompactionSummaryNode` 标记。于是一次�地的压缩会�留它在模型侧�蔽掉的对�,标记报告模型从哪里开始看��那段历�,而�是把它抹掉。仅模型��的 replacement 副本�进入记录:被�剪的 `tool/result` 和�新生�的 `assistant/message` �为模型�写一个节点,�在对�中标记任何边界。凡必须��模型所�内容的一切�读 surface;这是人类投影,两者现在在两个�端上都已分离。 + +节点顺�天然按 seq �调,由此有三个结果。仅日志的 `command/run` / `command/done` 对折�� `CommandNode`,按 seq �入一个本已�调的数组——无锚点,无�排。`Session` �留被打断的冻结节点的归属,用一次普通排�按其分数 seq 归并,而这现在�好就是�顺�。检查点所引被�蔽范围�在窗�之外的窗�没有范围需�解�,因此标记正常渲染且�打�任何日志。 + +`foldDegraded` 从 `ConversationSnapshot` 消失,�之消失的是哨兵填充�它们所需的 `baseSeq` 算术,以� `degradedSeqs()`。它们的存在�为满足核心 fold 的 `seq === index` 断言并在其抛错时存活;它们所�述的 fold 已���行。删除该标志是修�的一部分,而�修�之�的清�——`degradedSeqs()` 本身已几乎就是按日志顺�的投影,�是作为抛错�的�点而�本�到达。 + +标记的摘�文本�自检查点自己的 `compact/summary` 溯�,���自�框的检查点载�——那是为模型撰写的指令信�。窗�切分把溯�留在窗�外时该行��展开而�空白,与无调用的工具结果�一�软退让;�续补上溯�的分页会解�出文本。 + +没有任何�久化事件�RPC 信��压缩事务或模型�� surface �生�化,也�需��移。 + +## 识别检查点:�一份声明,在编译期钉� + +识别需�三个�件�时�立,与终端一致:`event.type === 'user/message'`�压缩�隙的检查点�件��,**以�** `isReplacementSurfaceEvent(event)`。一� append 的�件�� `user/message` 是注入上下文——跨会�引用�片——�是压缩。 + +从 `packages/client/*` 程�无法到达的是 `dsh-compact` 的**根部**,而�是这个包。根部会到达 `dsh-session` 的根部,�者的 cordis `Context` �并声明了宿主侧 `sessions: SessionStore`,与客户端的 `sessions: ISessions` 冲�——`TS2717`,� [development.md](../../../../docs/development.md#typescript-project-layout) 中�侧一个 program 的规则;这一点对仅类型导入�样�立,因为该冲�是编译器事实而�打包器事实。 + +本仓库对这一情形的既有答案是�� cordis 的���路径,本次�更就新增了一个:`COMPACT_CHECKPOINT_SOURCE` 与 `isCompactCheckpointSource` 现在�在 `packages/compact/compact/src/checkpoint.ts`,它�导入 cordis�也�增强任何模�(� `dsh-commands/brand` / `dsh-llm/message` 的形状),而包根�新导出两者,因此�个宿主侧消费方——终端的 chat helper�`dsh-session-reference` 的投影——都�需改动。适�器用仅类型导入把它的字��钉在该声明上: + +```ts +import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpoint' +const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact' +``` + +�命��隙的�件 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` 作为行为侧的�一��留,用由��**值**构造的检查点驱动适�器。该测试以值导入方�从�� cordis 的 `@deepseek-ai/dsh-compact/checkpoint` ��路径�得该值,并刻��加载 compact 包根或�由它�达的宿主侧 `Context` �并。 + +因此与终端的分歧很窄:两个�端都从�一份声明识别检查点——终端在宿主侧值导入 `isCompactCheckpointSource`(那里�适用任何门�),客户端钉�类型。 + +## #835 的�置锚点是为什么而存在,以�为什么它是被溶解而�丢失 + +尚未�并的排队�手动压缩分支用�一�方�修�一个交错缺陷:为�个事件记录一个锚点——追加时的 surface 尾部——并把被�蔽的锚点�定�到检查点上。该机制的存在是为了让�置锚点在 surface **�排**中存活。人类对�记录永�被�排,因此锚点没有任何东西需��定�:��被移除,修�并未被丢弃。该机制在本基线上并�存在,本次也�撰写它。 + +## Alternatives considered + +**从新��值导入该谓�**,并把 `dsh-compact` 加入客户端 `INLINE_SAFE` 白��。已拒�:客户端需�的是�件 id,�是谓�——一个类型就够了,而被擦除的导入根本�会抵达纯度门�,因此无需�它放行任何东西。白���在值导入时�有�义,而在那里它是笔糟糕的交�:`INLINE_SAFE` 按标识符*�缀*匹�,因此放行该包会连它那个会导入 cordis 的根部一起放行。 + +**一�纯形状规则**——任何 replacement `user/message` 都是压缩。已拒�:它今天正确�因为压缩是 replacement `user/message` 的唯一生产者,一旦这点改�便无任何机制能�获。那个 pin 测试�花一个文件,就精确消除了这一风险。 + +**在宿主侧给检查点打标**,�投影或线�议。已拒�:这最贴�“� cordis �务�作�的规则,但客户端今天折�的是原始 `SessionEvent`,因此这�味�一次线�议契约�更——为一个纯谓�付出的代价��比例。 + +**把冻结节点的归属移进适�器**(`nodes(extraNodes)`),�那个未�并分支所�的那样。已拒�:被打断的节点�自 `Session` 已�在窗�上�行的 `turn/end` 清扫,而在按 seq �调的记录之上,简�形�就是正确的——适�器返回节点,会�按 seq 归并冻结节点。加宽适�器签�什么也��到,还会把清扫与它的产物拆开。 + +**把 `foldDegraded` 留作一个防御性标志。** 已拒�:它�述的是一个已���行的 fold 的特定失败。一个消费方无法�以行动��能通过 `console.error` 到达的标志,是一份虚�契约。 + +## Consequences + +压缩��抹掉 Web 历�;一个被压缩多次的会�按日志顺�显示�次�地压缩一个标记,而�一窗�在实时与冷��之�渲染完全相�。分页缺�是被构造性闭�而�被防御,`ConversationSnapshot` 少了一个已�布字段,这触��三个文件。 + +`ConversationNode` 增加第八个分支,因此�个穷尽消费方都多一个分支:`MessageItem` 通过新的 `CompactionItem` 渲染标记,trajectory 布局加宽它的“无�元格�分支,使标记�贡献�元格但�推进耗时游标。 + +性能契约未�,且现在更易表述:一次追加物化一个节点,�改�任何节点的事件��上一次的数组引用——因此分片风暴零�本�`nodes()` 甚至�会�算——未�化的节点��其对象标识。窗���会�长度而�� surface 增长,这正是本修�存在所��的交�;一次压缩过去�好为压缩所�务的长会��制了投影规模。 + +Web e2e 场景现在在它录制的那一轮之上播�一次真实的压缩事务,因此 aria 基准�真实宿主与真实�览器钉�修�的两�:录制的�问与完整工具输出�在�幕上,其���一个标记。录制本身未被触碰���模型真实——回放从录制自身的 surface 派生出被压缩的那一轮。 + +## Deferred + +终端的[压缩进度决策](../feature/2026-07-30-compaction-progress-visibility.md)使用实时独立标记对驱动�格指示器,并�改�此�览器投影。标记���带**规模**信�:检查点的 `sourceEventSeqs` �存被�蔽的数�,因此�以�行论��添加计数或区间,而无须将其与进度耦�。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-composer-glyph-layer-tracks-the-textarea.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-composer-glyph-layer-tracks-the-textarea.i18n.yaml new file mode 100644 index 0000000000..c95b2a60b7 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-composer-glyph-layer-tracks-the-textarea.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 .agents/notes/implemented/bug-fix/2026-07-31-composer-glyph-layer-tracks-the-textarea.md +2026-07-31-composer-glyph-layer-tracks-the-textarea.md: d60a100be98683b5f7a7c88edf7585d275134730 +2026-07-31-composer-glyph-layer-tracks-the-textarea.zh.md: eab3f9e3fe3bddb426836113d08f1839329119d5 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-composer-glyph-layer-tracks-the-textarea.md b/.agents/notes/implemented/bug-fix/2026-07-31-composer-glyph-layer-tracks-the-textarea.md new file mode 100644 index 0000000000..d60a100be9 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-composer-glyph-layer-tracks-the-textarea.md @@ -0,0 +1,77 @@ +# Agent Note: The composer's glyph layer tracks the textarea's scroll offset + +Status: implemented + +English | [中文](2026-07-31-composer-glyph-layer-tracks-the-textarea.zh.md) + +## Problem + +A composer draft longer than the 14-line cap could not be scrolled. The caret moved and the selection moved, but the words stayed frozen at line 1 — no wheel gesture, drag, or arrow key brought the end of a long draft on screen, so the bottom of anything past ~14 lines was unreachable and unreadable while writing it. + +The cap itself was working. The composer paints its text in two stacked layers ([InputBar](../../../../packages/client/ui-conversation/src/client/skeleton/InputBar.tsx)): the `