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 0d6a23bebe..8193e5e839 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: 83d47e3a7d91bbcd2ceaf7b11cf13316142eb3ed -2026-06-21-bounded-llm-request-recovery.zh.md: 00dcbad3d1023ad33a22297bfe938b94bce839d4 +2026-06-21-bounded-llm-request-recovery.md: 5c76ed5d754ea40f41dff78cb56ee7fc139a32b1 +2026-06-21-bounded-llm-request-recovery.zh.md: 1fa56f3fe0405cab663c2843d423a78d910170dd 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 83d47e3a7d..5c76ed5d75 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 @@ -60,11 +60,11 @@ For an eligible failure with budget remaining, the one-based transient retry cou The plugin owns a lifetime `AbortController` and tracks every active recovery callback, including delegated waterfall work and backoff. Effect cleanup first unregisters the listener, then aborts and awaits the active callbacks; abort wins over a late delegated retry decision, and a captured callback can neither retry nor enter the rest of its waterfall after disposal. This makes HMR disposal quiescent even though Cordis has already captured the listener. -Before sleeping, `dsh-llm-retry` appends one non-surface `llm/retry` session event containing the turn, failed step, provider, policy mode, complete resolved-policy key, provider-policy retry number, mode-specific finite maximum when present, scheduled delay, and `LlmFailure`. The key sorts the code set and separates retry histories when a provider route is replaced by a behaviorally different same-mode policy. The plugin owns the `SessionEventMap` augmentation; `dsh-session` remains generic persistence and does not absorb the optional policy's vocabulary. The event says what was scheduled, not that the next request completed; cancellation during the delay is subsequently visible on `turn/end`. The event ships only with a production renderer and replay/snapshot coverage, because its purpose is operational state rather than trace collection. +Before sleeping, `dsh-llm-retry` appends one non-surface `llm/retry` session event containing the turn, failed step, provider, policy mode, complete resolved-policy key, provider-policy retry number, mode-specific finite maximum when present, scheduled delay, and `LlmFailure`. The key sorts the code set and separates retry histories when a provider route is replaced by a behaviorally different same-mode policy. The plugin owns the `SessionEventMap` augmentation and exports the payload through its browser-safe `./types` subpath; `dsh-session` remains generic persistence and does not absorb the optional policy's vocabulary. The event says what was scheduled, not that the next request completed; cancellation during the delay is subsequently visible on `turn/end`. The event ships with production renderers and replay/snapshot coverage, because its purpose is operational state rather than trace collection. The listener calls `next()` for a non-transient code, an exhausted policy budget, or an over-cap provider delay. This preserves composition with context-overflow recovery and later policy plugins. For an owned failure it records and awaits the delay, then returns `{ kind: 'retry' }` without delegating. Turn cancellation and plugin disposal end the wait without returning a retry; the loop's cancellation/disposal checks remain authoritative. -The agent-spine demo bundle loads the plugin so the shared stdio/TUI, one-shot CLI, and ACP example compositions use the same provider-routed policy. Library consumers retain explicit plugin composition: omitting the plugin leaves request failures terminal. +The agent-spine demo bundle loads the plugin so the shared stdio/TUI, one-shot CLI, ACP, and headless example compositions use the same provider-routed policy. The shipped Web composition also loads it, so browser and command-line requests use the same provider defaults. Library consumers retain explicit plugin composition: omitting the plugin leaves request failures terminal. ### Make one layer own visible attempts @@ -82,7 +82,7 @@ Boundary tests prove termination at both actual transports. The hand-written ada ### Keep attempts separate in the existing log -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; message derivation continues to ignore the failed chunks. +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. @@ -116,7 +116,7 @@ If recovery is exhausted, the final failure is stored once on `turn/end.reason` - Pure unit tests cover transient-code selection, exponential backoff and jitter bounds, valid and over-cap `Retry-After`, exhausted budgets, deterministic timer/random seams, and abort during backoff. - Real agent-loop tests cover failure before chunks, partial chunks then failure, thrown and in-band failures, retry to success in a new turn, exhaustion to structured `turn/end.reason`, and composition with `dsh-compact-basic` context-overflow recovery. - The partial-chunk integration test proves failed chunks remain attributed to the failed step, no assistant message or tool side effect is committed for that step, and the successful retry has distinct provenance. -- The plugin-owned `llm/retry` event is non-surface, survives JSONL and SQLite round trips, is ignored by message derivation, and drives TUI retraction plus scheduled-retry rendering. Keyless snapshots cover scheduling, cancellation, success, and exhaustion; ACP automation snapshots confirm that a discarded attempt stays off the wire while the recovered reply is emitted. +- The plugin-owned `llm/retry` event is non-surface, survives JSONL and SQLite round trips, is ignored by message derivation, and drives TUI and Web retraction plus scheduled-retry rendering. Client tests cover complete wire validation, clock-independent countdown, cancellation versus completed retry labels, and trajectory attribution; keyless UI snapshots cover Web scheduling and success, a real Web composition test covers partial transport failure through recovery, and ACP automation snapshots confirm that a discarded attempt stays off the wire while the recovered reply is emitted. - Idle-watchdog tests prove the stable signal is rearmed only while `next()` is outstanding, disarmed during consumer think time and in `finally`, and classified separately from a total-call deadline and an earlier caller abort; adapter tests prove the signal stops the underlying request rather than merely detaching it. - Direct `ctx.llm.stream()` callers remain single-attempt and receive the same structured failure facts. 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 00dcbad3d1..1fa56f3fe0 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 @@ -60,11 +60,11 @@ agent loop(智能体循环)会�留 `RequestError` 作为该精确的错误 �件拥有一个全生命期 `AbortController`,并跟踪�个活跃的��回调,包括委托的 waterfall(瀑布�事件)工作与退�。effect 清�会先注销监�器,�中止并等待活跃回调;中止会胜过较晚到达的委托�试决策,被�获的回调在�件释放�既�能�试,也�能进入其 waterfall 的剩余部分。尽管 Cordis 已�获该监�器,此设计�能使 HMR(热模�替�)释放达到完全�稳。 -休眠�,`dsh-llm-retry` 会追加一��进入表层的 `llm/retry` 会�事件,其中包�轮次�失败步骤��供方�策略 mode�完整的解�策略 key��供方策略�试编��该 mode 存在时的有�上��计划延迟和 `LlmFailure`。该 key 会对 code 集排�,并在�供方路由被行为��但 mode 相�的策略替�时分隔�试历�。该�件拥有 `SessionEventMap` 声明�并;`dsh-session` 继续负责通用�久化,�会�收�选策略的�汇。事件记录已安排的内容,而�是下一个请求已完�;延迟期间�消��会在 `turn/end` 中��。因为该事件的目的是表示�行状�,而�是收集跟踪数�,所以它仅与生产渲染器�回放�快照覆盖一起交付。 +休眠�,`dsh-llm-retry` 会追加一��进入表层的 `llm/retry` 会�事件,其中包�轮次�失败步骤��供方�策略 mode�完整的解�策略 key��供方策略�试编��该 mode 存在时的有�上��计划延迟和 `LlmFailure`。该 key 会对 code 集排�,并在�供方路由被行为��但 mode 相�的策略替�时分隔�试历�。该�件拥有 `SessionEventMap` 声明�并,并通过其�览器安全的 `./types` �路径导出载�;`dsh-session` 继续负责通用�久化,�会�收�选策略的�汇。事件记录已安排的内容,而�是下一个请求已完�;延迟期间�消��会在 `turn/end` 中��。因为该事件的目的是表示�行状�,而�是收集跟踪数�,所以它会与生产渲染器�回放�快照覆盖一起交付。 对�暂时性 code�耗尽的策略预算或超出上�的�供方延迟,监�器会调用 `next()`。这�留了与上下文溢出����续策略�件的组�能力。对自身处�的失败,它会记录并等待延迟,然�在�委托的情况下返回 `{ kind: 'retry' }`。轮次�消和�件释放会结�等待且�返回�试动作,此��以循环的�消�释放检查为准。 -agent-spine 演示组�包加载该�件,因此共享的 stdio/TUI�一次性 CLI(命令行界�)和 ACP(Agent Client Protocol)示例组�使用�一套按�供方路由的策略。库消费方�需显�组��件:�略该�件时,请求失败��终�。 +agent-spine 演示组�包加载该�件,因此共享的 stdio/TUI�一次性 CLI(命令行界�)�ACP(Agent Client Protocol)和 headless 示例组�使用�一套按�供方路由的策略。�产�交付的 Web 组�也会加载该�件,因此�览器请求与命令行请求使用相�的�供方默认值。库消费方�需显�组��件:�略该�件时,请求失败��终�。 ### 由�一层负责��的�试 @@ -82,7 +82,7 @@ agent-spine 演示组�包加载该�件,因此共享的 stdio/TUI�一次 ### 在现有日志中分隔�试 -一次失败�试�以在已关闭的步骤中留下 `assistant/chunk` 事件,但��会追加 `assistant/message`,也�会分�工具。�试会关闭失败轮次,开�下一个编�轮次,从�久表层�建请求,并生�自己的分片。步骤�处于打开状�时,UI �以渲染实时分片;当 `llm/retry` 标识失败步骤,或 `turn/end` 记录失败时,UI �标记或清除这份暂时视图。消�派生�会忽略失败分片。 +一次失败�试�以在已关闭的步骤中留下 `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 �汇。 @@ -116,7 +116,7 @@ agent-spine 演示组�包加载该�件,因此共享的 stdio/TUI�一次 - 纯�元测试覆盖暂时性 code 选择�指数退�和抖动边界�有效�超出上�的 `Retry-After`�耗尽的预算�确定性定时器��机数 seam,以�退�期间中止。 - 真实 agent-loop 测试覆盖分片�失败�部分分片�失败�抛出�带内失败�在新轮次中�试至�功�耗尽�写入结构化 `turn/end.reason`,以�与 `dsh-compact-basic` 上下文溢出��的组�。 - 部分分片集�测试�明:失败分片�归属于失败步骤,该步骤�会�交 assistant 消�或工具副作用,�功的�试具有��的��信�。 -- �件拥有的�进入表层的 `llm/retry` 事件�在 JSONL 和 SQLite 往返��留,被消�派生忽略,并驱动 TUI 撤回和计划�试渲染。无密钥快照覆盖调度��消��功和耗尽;ACP 自动化快照确认,被丢弃的�试�会通过�议�出,而���的回�会正常�出。 +- �件拥有的�进入表层的 `llm/retry` 事件�在 JSONL 和 SQLite 往返��留,被消�派生忽略,并驱动 TUI 和 Web 撤回�计划�试渲染。客户端测试覆盖完整的 wire 验��独立于时钟的倒计时�已�消与已完��试标签的区别以�轨迹归属;无密钥 UI 快照覆盖 Web 的调度与�功,真实 Web 组�测试覆盖部分传输失败直至��,ACP 自动化快照确认,被丢弃的�试�会通过�议�出,而���的回�会正常�出。 - 空闲看门狗测试�明:�有 `next()` 尚未完�时�会�新布防稳定信�;在消费方�考期间� `finally` 中会解除布防;它与总调用 deadline 以�更早�生的调用方中止分开分类。适�器测试�明该信�会终止底层请求,而��是与其脱离。 - `ctx.llm.stream()` 的直接调用方���试一次,并收到相�的结构化失败事实。 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml index 70618612a2..e603129715 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.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-19-gui-layering-and-rpc-protocol.md -2026-07-19-gui-layering-and-rpc-protocol.md: b9718da4725316c64686adef24827e2984d8723d -2026-07-19-gui-layering-and-rpc-protocol.zh.md: 2add148054e8f97c65600cd719fb4f8e0283f52d +2026-07-19-gui-layering-and-rpc-protocol.md: b7081591cf7e5e3c586c74c5a71b4317376135cb +2026-07-19-gui-layering-and-rpc-protocol.zh.md: 89557182ca7781f4fb59b8daf866aaca96cf20ee diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md index b9718da472..b7081591cf 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md @@ -204,7 +204,7 @@ The same domain tree as `ApiProxy`, but unary methods **take the business payloa | `callUnary` | mint → tap → POST full form → `serverResponseSchema` parse → **rpcId echo check** (mismatch throws) → tap → emit narrow form | | `readSse` | streaming fetch (not EventSource), `\n\n` framing, `data:` concatenation, ServerRequest full-form parse, tap, emit narrow `RpcRequest` | | `respond` | client-response passthrough (rpcId is an echo — never minted here); response body parsed by `rpcReceiptSchema` | -| unary timeout | `AbortSignal.timeout` (default 30s, constructor-tunable); streams have no timeout (long-lived by nature) | +| unary deadline | Ordinary unary calls use `AbortSignal.timeout` (default 30s, constructor-tunable); user-paced `host.pickDirectory` and `command.execute` omit that deadline but keep caller/connection cancellation; streams have no deadline | | `resolveBase` | browser = same-origin origin; no-location environment (Node) = the `http://dsh.internal` fake authority | ### The instance-level envelope observation aspect @@ -234,7 +234,7 @@ All four quadrant full forms pass through `onEnvelope`; the base implementation ## Consequences -Every client shape consumes one contract: adding a unary method is a five-step mechanical change radiating from a single signature, swapping a carrier touches only a `doFetch` subclass, and every wire message is zod-validated, observable through the envelope tap, and reconcilable by rpcId. The accepted costs: two groups of packages need explicit tsconfig paths entries, and the reserved seams (fork/inject/task.list/listModels/hostInstanceId) stay dormant until a real consumer arrives. +Every client shape consumes one contract: adding a unary method is a five-step mechanical change radiating from a single signature, swapping a carrier touches only a `doFetch` subclass, and every wire message is zod-validated, observable through the envelope tap, and reconcilable by rpcId. Ordinary unary calls remain bounded, while `host.pickDirectory` and `command.execute` may stay pending until the operation finishes or caller/connection cancellation arrives; this accepts that a non-cooperative user-paced operation can hang its request rather than treating valid operation duration as transport failure. The other accepted costs: two groups of packages need explicit tsconfig paths entries, and the reserved seams (fork/inject/task.list/listModels/hostInstanceId) stay dormant until a real consumer arrives. ## Alternatives considered @@ -252,3 +252,4 @@ Every client shape consumes one contract: adding a unary method is a five-step m | A DTO layer (a second wire-only structure set) | Core types reach the browser type-only at zero cost; a DTO is a permanent two-way synchronization tax | | Cursor resumption (implementing mux since) | Reconnect = rebuild (opencode-style) covers all v1 needs; the signature keeps the seat, implementation waits for a real consumer | | A createApiClient factory function (the original implementation) | Platform differences (transport/observation) are inheritance aspects, not parameters; the class family lets the fixture substitute at the protocol layer instead of wrapping a fake envelope | +| Applying the 30-second transport deadline to `command.execute` | Command duration is operation work, not a transport-health budget; the deadline kills valid long-running handlers, while caller/connection cancellation already supplies the required stop path | diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md index 2add148054..89557182ca 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md @@ -202,7 +202,7 @@ export type ResponseValue = | `callUnary` | mint → tap → POST 全形 → `serverResponseSchema` parse → **rpcId 回显校验**(�符� throw)→ tap → �窄形 | | `readSse` | streaming fetch(� EventSource)�`\n\n` 分帧�`data:` 拼接�ServerRequest 全形 parse�tap��窄形 `RpcRequest<帧>` | | `respond` | client-response �传(rpcId 是回填,此处� mint);应答体 `rpcReceiptSchema` parse | -| unary 超时 | `AbortSignal.timeout`(默认 30s,构造�数�调);��设超时(长连接本性) | +| unary 时� | 普通 unary 调用使用 `AbortSignal.timeout`(默认 30s,构造�数�调);由用户掌控节�的 `host.pickDirectory` 和 `command.execute` �设该时�,但�留调用方�连接�消;��设时� | | `resolveBase` | �览器=�� origin;无 location 环境(Node)=`http://dsh.internal` � authority | ### 实例级 envelope 观测切� @@ -232,7 +232,7 @@ export type ResponseValue = ## Consequences -所有 client 形�消费�一契约:加一个 unary 方法是从�一签��射的五步机械改动,�载体�动一个 `doFetch` �类,wire 上��消�� zod 校验��� envelope tap 观测��按 rpcId 对账。接�的代价:两组包需�显� tsconfig paths �目;预留接�(fork/inject/task.list/listModels/hostInstanceId)在真实消费者出现���休眠。 +所有 client 形�消费�一契约:加一个 unary 方法是从�一签��射的五步机械改动,�载体�动一个 `doFetch` �类,wire 上��消�� zod 校验��� envelope tap 观测��按 rpcId 对账。普通 unary 调用��时�约�,而 `host.pickDirectory` 与 `command.execute` ���挂起,直到�作完�或调用方�连接�消到�;若由用户掌控节�的�作�自行结�,请求�能一直挂起,这是为��把��的�作时长视为传输失败而接�的代价。其余接�的代价:两组包需�显� tsconfig paths �目;预留接�(fork/inject/task.list/listModels/hostInstanceId)在真实消费者出现���休眠。 ## Alternatives considered @@ -250,3 +250,4 @@ export type ResponseValue = | DTO 层(wire 专用第二套结构) | core 类型 type-only 直达�览器零�本;DTO 是永久的���步税 | | cursor 续传(mux since 实装) | �连=�建(opencode �款)覆盖 v1 全部需求;签�留座,实装等真实消费者 | | createApiClient 工厂函数(原实现) | 平�差异(传输/观测)是继承切��是�数;类体系让 fixture 在�议层替�而�是包一层�信� | +| 对 `command.execute` 应用 30 秒传输时� | 命令耗时属于�作本身,而�传输�康预算;该时�会终止本应继续�行的长时处�器,调用方�连接�消已�供所需的�止路径 | 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-25-web-input-machine-and-slash-pipeline.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml index 37b3413f41..52c7c71e1a 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.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-25-web-input-machine-and-slash-pipeline.md -2026-07-25-web-input-machine-and-slash-pipeline.md: fbff722279b7f625a650602adce6ab6d21183076 -2026-07-25-web-input-machine-and-slash-pipeline.zh.md: adc3e22334f4115373aa3e48015518a033b26d91 +2026-07-25-web-input-machine-and-slash-pipeline.md: 977df6508e1a1cd54cf1ddb469a6bfb835f60071 +2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 929a885bf54a31605805814ba1e15c901e560434 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md index fbff722279..977df6508e 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md @@ -63,7 +63,7 @@ Calls that stay un-evented (registry registration → explicit call → await): A trigger/menu/pick pipeline with zero knowledge of "commands": - The service holds only the source registry (`SlashSource{trigger: '/'|'@', name, order?, candidates, onPick, matchSpace?, matchEnter?}`; (trigger,name) unique; the optional `order` sorts the roster — lower first, default 0, ties keep registration order — and that sorted roster is both group order and polling order) and `sessionOf(sctx)`. Implementing a match hook IS the declaration of participation in space/enter adjudication; the pipeline polls in roster order, the first non-undefined answer wins, and no claimant means the default sink. matchSpace is synchronous (space fires mid-keystroke; hot cache only); matchEnter is asynchronous (it may await the source's own warmup, and a warmup failure rejects). -- The controller holds the single authoritative hit (span included; retained for Space after the menu closes), the per-session menu store, the candidate-fetch generation, keyboard arbitration (combobox mode: focus stays in the textarea, ↑↓/Enter/Escape are intercepted and all pass the IME composition guard, with the single exception Shift+Enter unconditionally going first), and pick orchestration (outcome → self-dispatched bail events); a `dismiss()` verb backs MenuView's injected `onDismiss` (a pointer down outside both the menu and the surrounding composer card closes the menu; MenuView also localizes group titles through the `slash.menu` locale namespace and clamps its height to the viewport space above the composer via ui-primitives' `useAnchoredMaxHeight`); at each session scope's birth it runs `warm(projection)` once over the source roster — within that scope the projection holds only the stable sessionId, with no published/capability transitions; the scope disposer tears down the controller. +- The controller holds the single authoritative hit (span included; retained for Space after the menu closes), the per-session menu store, the candidate-fetch generation, keyboard arbitration (combobox mode: focus stays in the textarea, ↑↓/Enter/Escape are intercepted and all pass the IME composition guard, with the single exception Shift+Enter unconditionally going first), and pick orchestration (outcome → self-dispatched bail events). `toggleSource(name, syntheticHit)` is the chrome-launch path: it seeds only that registered source over the caller's textarea selection and publishes `launcher = name` until close; ordinary typed tracking clears the launcher and restores the full trigger roster. Both paths render the same MenuView and execute the same `onPick` chain. A `dismiss()` verb backs MenuView's injected `onDismiss` (a pointer down outside both the menu and the surrounding composer card closes the menu; MenuView also localizes group titles through the `slash.menu` locale namespace and clamps its height to the viewport space above the composer via ui-primitives' `useAnchoredMaxHeight`); at each session scope's birth it runs `warm(projection)` once over the source roster — within that scope the projection holds only the stable sessionId, with no published/capability transitions; the scope disposer tears down the controller. - Trigger-detection word boundaries (`user@host` and URL `/` never trigger) and the guard tiers (plain: `/` everywhere + `@` inline / claimed: `/` suppressed, `@` live / frozen: none) are the frozen pure core. ### hub / facade: the resident shell and the strict-session input body @@ -101,7 +101,7 @@ skill/@subagent references skip the placeholder + occurrence identity chain — - `conversation.input.dock` — the stacked strip above the input (QueueDock's read-only queue list lands here), ordered by `order`. - `conversation.composer.dock` — the stats band on the composer's top edge. - `conversation.input.left` / `conversation.input.right` — the tool-row left and right regions. -- `conversation.input.plan` / `conversation.input.model` (single) — the tool row's two named control seats; the bar passes only `locked` (owner props), each stays empty until its owning plugin registers, no placeholder fallback. +- `conversation.input.plan` / `conversation.input.model` (single) — the tool row's two named control seats; the bar passes only `locked` (owner props), each stays empty until its owning plugin registers, no placeholder fallback. The plan seat stays empty while inactive because the shared Command source owns entry; an effective plan target renders the warn-state `Plan ×` status button, whose only action is `/plan off`. - `conversation.hero.workspace` (root scope) — the Workspace picker shared by the no-session and blank Hero; a pick reuses or creates the target blank session through `connectWorkspace`, moving the draft where necessary before switching current. ### Testing discipline @@ -122,6 +122,8 @@ The state machine's entire behavior is covered by pure-JS unit tests (event sequ | Space adjudication also claiming execute-kind commands | The misfire defense: after a space the whole line is an ordinary prompt; irreversible side effects keep explicit entry points only | | A generic tokenPattern decoration mechanism | Structured occurrence records replace pattern scanning | | A placeholder select resident in the tool row | Named seats stay empty until registration; a placeholder clashing with the real implementation is two sources of truth | +| An always-visible Plan on/off toggle | The shared Command source already owns entry; a second entry point turns a status seat into redundant mode chrome | +| A second plus-menu component/controller, or an Add/File group above Command | It would duplicate async candidates, keyboard highlight, focus retention, and pick state; the plus control is only a source-filtered launcher for the existing MenuView, and this scope has no file capability | | All references through U+FFFC chips (the pre-Decision-21 line) | Plain text + derived decoration carries zero identity state; the literal text IS the model projection, sparing undo/clipboard any special cases; the chip chain is kept for scenarios needing indivisible atomicity | ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md index adc3e22334..929a885bf5 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md @@ -63,7 +63,7 @@ occurrence 表与 chip 三投影: 对"命令"零知识的触�/��/pick 管线: - service �有 source 注册表(`SlashSource{trigger: '/'|'@', name, order?, candidates, onPick, matchSpace?, matchEnter?}`;(trigger,name) 唯一;�选 `order` 对 roster 排�——越�越���默认 0��值��注册�——排��的 roster �时是组�与轮询�)与 `sessionOf(sctx)`。实现 match 钩���与空格/回车�决的声明;管线按 roster �轮询,首个� undefined 应答胜出,无人认领� default sink。matchSpace �步(空格在击键中触�,�许热缓存);matchEnter 异步(� await �自身预热,预热失败� reject)。 -- controller �有唯一�� hit(� span;��关闭�为 Space �留)�per-session menu store�候选 fetch generation�键盘仲�(combobox 模�:焦点始终在 textarea,↑↓/Enter/Escape 拦截且全程过 IME composition 守�,唯一例外 Shift+Enter 无�件先行)�pick 编排(outcome → 自派 bail 事件);`dismiss()` 动�支撑 MenuView 注入的 `onDismiss`(指针�在��与所在 composer �片之外�关闭��;MenuView 还� `slash.menu` locale 命�空间本地化组标题,并� ui-primitives 的 `useAnchoredMaxHeight` 把高度收敛到 composer 上方的视�空间);�个 session scope 出生时对 source roster �一次 `warm(projection)`,projection 在该 scope 内�有稳定的 sessionId,无 published/能力跃�;scope disposer 拆除 controller。 +- controller �有唯一�� hit(� span;��关闭�为 Space �留)�per-session menu store�候选 fetch generation�键盘仲�(combobox 模�:焦点始终在 textarea,↑↓/Enter/Escape 拦截且全程过 IME composition 守�,唯一例外 Shift+Enter 无�件先行),以� pick 编排(outcome → 自派 bail 事件)。`toggleSource(name, syntheticHit)` 是 chrome launcher 路径:它基于调用方的 textarea selection,� seed 对应的已注册 source,并�布 `launcher = name` 直至关闭;普通的键入� tracking 会清除 launcher 并��完整的 trigger roster。两�路径渲染�一个 MenuView,并执行�一� `onPick` 链。`dismiss()` 动�支撑 MenuView 注入的 `onDismiss`(指针�在��与所在 composer �片之外�关闭��;MenuView 还� `slash.menu` locale 命�空间本地化组标题,并� ui-primitives 的 `useAnchoredMaxHeight` 把高度收敛到 composer 上方的视�空间);�个 session scope 出生时对 source roster �一次 `warm(projection)`,projection 在该 scope 内�有稳定的 sessionId,无 published/能力跃�;scope disposer 拆除 controller。 - 触�检测�边界(`user@host`�URL `/` 永�触�)�守�分档(plain:`/` 到处 + `@` 行内 / claimed:`/` 抑制�`@` 活 / frozen:全无)为冻结纯核。 ### hub / facade:常驻外壳与严格 session 输入体 @@ -101,7 +101,7 @@ skill/@subagent 引用�走��符 + occurrence 身份链——pick 直接把 - `conversation.input.dock`——输入上方堆��(QueueDock 的队列�读列表�此),order 定�。 - `conversation.composer.dock`——composer 上沿统计带。 - `conversation.input.left` / `conversation.input.right`——工具行左�区。 -- `conversation.input.plan` / `conversation.input.model`(single)——工具行两具�控制�;bar �传 `locked`(owner props),空到 owning �件注册为止,无�� fallback。 +- `conversation.input.plan` / `conversation.input.model`(single)——工具行两具�控制�;bar �传 `locked`(owner props),空到 owning �件注册为止,无�� fallback。plan seat 未激活时��为空,因为入�归共享 Command source 所有;有效 plan 目标会渲染 warn 状�的 `Plan ×` 状�按钮,其唯一动作是 `/plan off`。 - `conversation.hero.workspace`(root scope)——无 session / blank Hero 共用的 Workspace picker;pick � `connectWorkspace` �用或创建目标 blank session,必�时�� draft �切 current。 ### 测试纪律 @@ -122,6 +122,8 @@ skill/@subagent 引用�走��符 + occurrence 身份链——pick 直接把 | 空格�决也认领�执行型命令 | 误触�防线:空格�整行是普通 prompt;��逆副作用�留显�入� | | 通用 tokenPattern 装饰机制 | 结构化 occurrence 记录�代模�扫� | | �� select 常驻工具行 | 具���空到注册为止;��件与真实现冲�时是�真相� | +| 始终��的 Plan 开�关切� | 入�已归共享 Command source 所有;第二个入�会把状� seat ��冗余的 mode chrome | +| 第二套加���组件�controller,或在 Command 上方增加 Add/File 分组 | 这会��异步候选�键盘高亮�焦点�留与 pick 状�;加�控件�是既有 MenuView 按 source 过滤的 launcher,且此 scope 没有文件能力 | | 引用一律走 U+FFFC chip(决策 21 �旧线) | 纯文本 + 派生装饰零身份状�;原文�模型投影,undo/剪贴��特判;chip 链�留给需���分原�性的场景 | ## �果 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 d0d8d7b82f..cc25329fed 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: 837531ba0bd3ecf404eb47ee933438546c682a54 -2026-07-30-session-end-seed-log-boundary.zh.md: 33680c1845364de62e5b53ead13de418a389f908 +2026-07-30-session-end-seed-log-boundary.md: 268646e192d0b8e0a5dde03957a18ef155b7038e +2026-07-30-session-end-seed-log-boundary.zh.md: dca87e16de5e567ff85d2b32b8243f76ebed1c4a 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 837531ba0b..268646e192 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 @@ -14,13 +14,13 @@ Crash repair does not close the gap and must not: `interruptedTurnClosers` synth ## Decision -`Session`'s constructor appends the log-only `session/end-seed` event immediately after a non-empty constructor seed, as the seeded session's first live write at the seq `firstLiveSeq` names. The event is the durable projection of that field: `firstLiveSeq` answers where this lifecycle's writes start for a consumer holding the object, while `session/end-seed` answers the same question for one holding only stored bytes. Its payload is empty — position and `time` carry the whole meaning — and it is not a `SurfaceEventType`, so it produces no message and cannot perturb derived history. +`Session`'s constructor appends the log-only `session/end-seed` event immediately after an explicitly supplied constructor seed, including an empty one, as the seeded session's first live write at the seq `firstLiveSeq` names. The event is the durable projection of that field: `firstLiveSeq` answers where this lifecycle's writes start for a consumer holding the object, while `session/end-seed` answers the same question for one holding only stored bytes. Its payload is empty — position and `time` carry the whole meaning — and it is not a `SurfaceEventType`, so it produces no message and cannot perturb derived history. The seq-0 marker distinguishes an empty resumed session from a genuinely fresh session, preventing new-session defaults from being applied during resume. A bracket owner reads it positionally: an unmatched opening marker before `session/end-seed` has a smaller seq, came from the constructor seed, and belongs to a lifecycle that has ended. Core writes the boundary and reads nothing from it; each bracket's vocabulary stays with its owning plugin, so no core predicate helper ships without a consumer to shape it. The constructor is the placement because it is the single waist every seeded session passes through. All six entry points reach it: `agents.resume()`, config-driven startup on a persisted id (`restoreOrCreateConfigured`), `sessions.fork()`, a subagent fork child, `coordinator.adopt()`'s live-prefix path, and a bare `sessions.create(id, {seed})`. A boundary written at persistence load would miss both fork paths — and a forked child inheriting a still-running parent's open `compact/start` is precisely the case that must be classifiable. A boundary written at loop start would miss `fork()` and `adopt()`, and would have to fire on `SessionStartSource: 'startup'`, which is what a fork child publishes, so that field would stop discriminating. -Two guards keep the marker from becoming noise. An empty seed writes nothing because there is no seed to end. A seed already ending in one is not re-marked, which makes the write idempotent. Idempotence is load-bearing rather than tidiness — `agentFor()` resumes a cold session on first touch, so merely opening one in a client is a pickup, and without the guard browsing would grow a log by one event per visit. +Two guards keep the marker precise. An omitted seed writes nothing because the session is fresh. A seed already ending in one is not re-marked, which makes the write idempotent. Idempotence is load-bearing rather than tidiness — `agentFor()` resumes a cold session on first touch, so merely opening one in a client is a pickup, and without the guard browsing would grow a log by one event per visit. ## Persistence needs no changes @@ -48,7 +48,7 @@ The predicate holds for a bracket *this* session inherited, not as a liveness si Bought: one boundary, written in one place, correct for all six seeded-start paths — including the fork gap the persistence-layer version could not reach. The persistence packages keep a pure read path. `firstLiveSeq` gains a durable twin rather than a second, competing notion of the same boundary. -Cost: a seeded session's log is one event longer, which moved seq expectations in tests across nine packages (session, agent-loop, persistence contract, jsonl, session-query, session-title, subagent-inprocess, telemetry, token-meter). Two of those updates are load-bearing rather than mechanical: telemetry's adoption tests now assert the boundary IS exported, because it is this lifecycle's own write, and the property suite's replay invariant is restated as "seed reproduced verbatim, plus one log-only boundary" with idempotence added as its own property. +Cost: a seeded session's log is one event longer, including an empty resumed log. Seq expectations move with that boundary. Two updates are load-bearing rather than mechanical: telemetry's adoption tests assert the boundary IS exported, because it is this lifecycle's own write, and the property suite's replay invariant is "seed reproduced verbatim, plus one log-only boundary" with idempotence as its own property. `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. 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 33680c1845..dca87e16de 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 @@ -14,13 +14,13 @@ Status: implemented ## Decision -`Session` 的构造函数紧接�空构造��之�追加仅日志事件 `session/end-seed`,作为带��会�的第一次实时写入,�置正是 `firstLiveSeq` 指出的 seq。该事件是那个字段的�久投影:`firstLiveSeq` 为�有对象的消费方回答本生命周期的写入从哪里开始,`session/end-seed` 则为��有存储字节的消费方回答�一问题。它的 payload 为空——�置与 `time` 承载全部�义——并且�是 `SurfaceEventType`,因此�产生消�,也无法扰动派生历�。 +`Session` 的构造函数紧接显�传入的构造��(包括空��)之�追加仅日志事件 `session/end-seed`,作为带��会�的第一次实时写入,�置正是 `firstLiveSeq` 指出的 seq。该事件是那个字段的�久投影:`firstLiveSeq` 为�有对象的消费方回答本生命周期的写入从哪里开始,`session/end-seed` 则为��有存储字节的消费方回答�一问题。它的 payload 为空——�置与 `time` 承载全部�义——并且�是 `SurfaceEventType`,因此�产生消�,也无法扰动派生历�。这个 seq-0 标记把从空日志��的会�与真正的全新会�区分开�,从而防止��期间应用新会�默认值。 括�所有方按�置读�它:在 `session/end-seed` 之�的未�对开�标记具有更�的 seq,�自构造��,并且属于一个已结�的生命周期。核心写入该边界但�从中读�任何内容;�个括�的�汇表�归其所属�件,因此在没有消费方�塑形之�,核心�会先�布谓�辅助函数。 选择构造函数,是因为它是�一个带��会�都必�的唯一收窄处。全部六个入�都会到达它:`agents.resume()`�在已�久化 id 上的�置驱动�动(`restoreOrCreateConfigured`)�`sessions.fork()`��代� fork �会��`coordinator.adopt()` 的实时�缀路径,以�裸的 `sessions.create(id, {seed})`。在�久化加载时写入的边界会�掉两� fork 路径——而一个继承了�在�行的父会�开放 `compact/start` 的 fork �会�,��是必须�判定的场景。在 loop �动时写入的边界会�掉 `fork()` 与 `adopt()`,并且�得�在 `SessionStartSource: 'startup'` 上触�——那正是 fork �会��布的�值,于是该字段将��具有区分力。 -两�守�让这个标记�至于��噪声。空���写入任何内容,因为没有��需�结�。��本身已以该事件结尾时�会��标记,这让写入具备幂等性。幂等性是承�的,而�是为了整�——`agentFor()` 会在首次触碰时��一个冷会�,因此在客户端里仅仅打开一个会�就是一次接手;没有这�守�,�览会让日志�访问一次就增长一个事件。 +两�守�让这个标记��精确。�略��时�写入任何内容,因为这是全新会�。��本身已以该事件结尾时�会��标记,这让写入具备幂等性。幂等性是承�的,而�是为了整�——`agentFor()` 会在首次触碰时��一个冷会�,因此在客户端里仅仅打开一个会�就是一次接手;没有这�守�,�览会让日志�访问一次就增长一个事件。 ## �久化无需任何改动 @@ -48,7 +48,7 @@ Status: implemented 买到的:一�边界,在一处写入,对全部六�带���动路径都正确——包括�久化层方案触��到的 fork 缺�。�久化�包�留纯读�路径。`firstLiveSeq` 获得一个�久孪生体,而�是关于�一边界的第二套彼此竞争的概念。 -代价:带��会�的日志长了一个事件,这在�个包(session�agent-loop��久化契约�jsonl�session-query�session-title�subagent-inprocess�telemetry�token-meter)里挪动了 seq 期望。其中两处更新是承�的而�机械的:telemetry 的收养测试现在断言该边界*会*被导出,因为它是本生命周期的自有写入;而属性测试套件的�放���被�述为"���字节�现,外加一个仅日志边界",并把幂等性补�一�独立属性。 +代价:带��会�的日志长了一个事件,空日志��也包括在内。seq 期望会�这�边界移动。两处更新是承�的而�机械的:telemetry 的收养测试断言该边界*会*被导出,因为它是本生命周期的自有写入;属性测试套件的回放���则是"���字节�现,外加一个仅日志边界",并把幂等性作为独立属性。 `session/end-seed` 加入了�盘�汇表。在预�布立场下(`SESSION_FORMAT_VERSION` 固定为 `0`,�作兼容承诺),更旧的日志�是没有它,而没有边界的日志会正确地判定没有任何内容属于构造��历�。 diff --git a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.i18n.yaml index fa54d657a9..3c04e9b255 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.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-settings-write-path-integrity.md -2026-07-30-settings-write-path-integrity.md: 07bd095162879c8e7866846cf562f6a13307e5fc -2026-07-30-settings-write-path-integrity.zh.md: 5d02177073d482b61750d7bdfbbd0866bc227a6a +2026-07-30-settings-write-path-integrity.md: 7bd50adc8a812759c3ae3f80a50978d75baa712a +2026-07-30-settings-write-path-integrity.zh.md: da07745ef1de1b694fc3fd1ee3d04322cdadc992 diff --git a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.md b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.md index 07bd095162..7bd50adc8a 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.md +++ b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.md @@ -14,7 +14,7 @@ Review found the provider's write path could destroy state it never observed, an **One operation chain, and every write is a read-modify-write.** Watcher refreshes and persists from every namespace queue share a single settled chain, and `persistSection` begins by reconciling the on-disk text into the seam — publishing any unobserved difference first — before rendering against that fresh text. A write can no longer resurrect a stale document, and an on-disk document that turned invalid fails the write loud rather than being overwritten (the reload path keeps its warn-and-keep-last-good policy; the shared `reconcileFromDisk` throws and each caller picks its policy). The watcher's `ready` signal queues one extra reconcile, closing the startup gap between the initial load and the watcher becoming active. -**Writes hold a `wx`-created `.lock` sibling.** The read-render-rename cycle runs under a cross-process writer lock with exponential backoff, a 2 s acquisition deadline, and stale takeover after 5 s (a crashed holder, broken with a warning). Readers never lock — the rename commit is atomic — so contention is writer-only and resolves in milliseconds. The lock constants are protocol invariants, not config: a holder rewrites one small document, so the deadline and stale age derive from that bound, not from deployment taste. +**Writes hold a `wx`-created `.lock` sibling.** The read-render-rename cycle runs under a cross-process writer lock with exponential backoff and a 2 s acquisition deadline. A contender times out without removing the existing lock because age cannot distinguish a crashed owner from a paused live writer; orphan recovery is an operator action. Readers never lock — the rename commit is atomic — so contention is writer-only. The retry and deadline constants are protocol invariants, not deployment config. **Observer disposal is quiescent.** Watchers carry an `active` flag checked when a queued invocation would start, so a disposer that ran while the invocation waited prevents the start entirely; started invocations register in a service-level `pendingTails` set that the dispose drain awaits beside the write queues. The `settings/updated` fan-out contains a returned thenable's rejection through the same listener diagnostic as a sync throw, and the event contract now states that the `INVARIANT` rethrow serves synchronous listeners only — invariant companions must stay sync, which the shipped companion already is. @@ -24,7 +24,7 @@ Review found the provider's write path could destroy state it never observed, an ## Alternatives considered -- **`proper-lockfile` instead of a hand-rolled lock** — the dependency-over-hand-rolling policy was weighed: the library is barely maintained, its stale/retry policy is broader than this one-file protocol needs, and the shipped lock is ~40 lines with deterministic tests (including injected `EEXIST`/`stat` races). The policy favors dependencies that delete owned code; this one would replace 40 explained lines with an opaque peer. +- **`proper-lockfile` instead of a hand-rolled lock** — the dependency-over-hand-rolling policy was weighed: the library is barely maintained, its ownership and retry policy is broader than this one-file protocol needs, and the shipped lock is a small exclusive-create loop with deterministic contention tests. The policy favors dependencies that delete owned code; this one would replace a narrow protocol with an opaque peer. - **Revision/CAS instead of a lock** — rename cannot express compare-and-swap, so a CAS needs a version sidecar or content re-hash and a retry loop in every writer; the lock achieves the same serialization with one primitive and keeps readers free. - **Merging external edits into the in-flight write's own section** — the seam merges patches over the state visible at call time, so a same-namespace external edit racing a write still resolves last-write-wins; folding it in would need three-way merge semantics no consumer has asked for. The write publishes the external state first, so the loser is at least observed before being superseded. - **Declaring async `settings/updated` listeners unsupported** — the typed signature is `void` and lint flags misused promises, but an unlinted JS plugin can still register an async listener; a contract note cannot un-throw an unhandled rejection, so containment is the only defense that holds at runtime. @@ -32,4 +32,4 @@ Review found the provider's write path could destroy state it never observed, an ## Consequences -`update()` gained a documented failure mode (lock deadline, invalid on-disk document) and the rejection messages carry `$`-rooted paths. Remaining, documented in the provider README: same-namespace concurrent edits stay last-write-wins (no per-value merge or revision check), a watcher event the OS never delivers leaves the cache stale until the next signal or write, and comments inside replaced arrays or attached inline to changed scalar values go with the value they described. The [user-settings seam note](2026-07-28-user-settings-seam.md)'s deferred-lockfile alternative is superseded by this note. The same defect classes exist in `dsh-credentials-local` (two chains over one `.env`, cached whole-file write-back, post-persist emit) and in the `llm/adapters-updated` fan-out on the stacked branches; those fixes belong to the PRs that introduce the packages and follow this template on merge-up. +`update()` has documented failure modes for the lock deadline and an invalid on-disk document, and rejection messages carry `$`-rooted paths. A crashed holder can leave a lock that requires verified operator removal; automatic age-based takeover would permit overlapping writers. Remaining, documented in the provider README: same-namespace concurrent edits stay last-write-wins (no per-value merge or revision check), a watcher event the OS never delivers leaves the cache stale until the next signal or write, and comments inside replaced arrays or attached inline to changed scalar values go with the value they described. The [user-settings seam note](2026-07-28-user-settings-seam.md)'s deferred-lockfile alternative is superseded by this note. The same defect classes exist in `dsh-credentials-local` (two chains over one `.env`, cached whole-file write-back, post-persist emit) and in the `llm/adapters-updated` fan-out on the stacked branches; those fixes belong to the PRs that introduce the packages and follow this template on merge-up. diff --git a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.zh.md b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.zh.md index 5d02177073..da07745ef1 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.zh.md @@ -18,7 +18,7 @@ YAML 写入则整体替� namespace 节点,把分节内的��注释都删 **�一�作链,且�次写入都是读-改-写。**watcher 的刷新与�自� namespace 队列的�久化共享�一�结算链;`persistSection` 会先把�盘上的文本对账进 seam——任何未被观察到的差异都先�布出去——然��对照这份新鲜文本渲染。写入���能�活一份陈旧文档;�盘上已��法的文档会让写入�亮失败,而�是被覆盖(�载路径��其“告警并�留最��用值�策略;共享的 `reconcileFromDisk` 抛错,�调用方自选策略)。watcher 的 `ready` 信�会�外排入一次对账,弥��始加载与 watcher 生效之间的�动缺�。 -**写入�有以 `wx` 创建的�目录 `.lock`。**读-渲染-rename 循环在一把跨进程写�下�行:指数退��2 s 获�截止时间�5 s �陈旧接管(�有者已崩溃;打破旧�时给出告警)。读方从�加�——rename �交是原�的——因此竞争��生在写方之间,毫秒级��化解。�的�项常�是�议���,�是�置:�有者�是�写一份�文档,截止时间与陈旧时�都从这一上界推得,而�出自部署�好。 +**写入�有以 `wx` 创建的�目录 `.lock`。**读-渲染-rename 循环在一把跨进程写�下�行,采用指数退�与 2 s 获�期�。竞争者会超时,但�会移除现有�,因为�龄无法区分已�崩溃的所有者与被暂�但�存活的写入方;�留���须由�作者执行。读方从�加�——rename �交是原�的——因此竞争��生在写方之间。�试与期�常�是�议���,而�部署�置。 **观察者 dispose 达到完全�稳。**watcher �带一个 `active` 标志,排队的调用�将�动时先检查它,因此在调用等待期间已��行过的释放器能让这次�动彻底��生;已�动的调用会登记进�务级的 `pendingTails` 集�,dispose 排空除了等待�写队列,还会等待该集�。`settings/updated` 扇出会把监�器返回的 thenable 的 rejection 收容进与�步抛错相�的监�器诊断;事件契约现已写明 `INVARIANT` �抛��务�步监�器——����套�件必须���步,而已交付的那个�套�件本就是�步的。 @@ -28,7 +28,7 @@ YAML 写入则整体替� namespace 节点,把分节内的��注释都删 ## 曾考虑的替代方案 -- **用 `proper-lockfile` �代手写�**——按“�赖优先于手写�政策�过�衡:该库几乎无人维护,其陈旧��试策略比这个�文件�议所需的更宽泛,而已交付的�约 40 行并带确定性测试(�注入的 `EEXIST`/`stat` 竞�)。该政策��能删除自有代�的�赖;这个�赖�会把 40 行带解释的代���一个��明的等价物。 +- **用 `proper-lockfile` �代手写�**——按“�赖优先于手写�政策�过�衡:该库几乎无人维护,其所有�与�试策略比这个�文件�议所需的更宽泛,而已交付的��是一个�型独�创建循环,带确定性的竞争测试。该政策��能删除自有代�的�赖;这个�赖�会把一个窄�议����明的等价物。 - **用修订�/CAS �代�**——rename 表达�了 compare-and-swap,因此 CAS 需�一个版本伴�文件或内容�哈希,外加�个写方里的一个�试循环;�用一个原语实现�样的串行化,还让读方完全��。 - **把外部编辑�并进正在进行的写入自身的分节**——seam 是在调用时刻��的状�之上�并 patch 的,因此与写入竞�的� namespace 外部编辑�按�写胜出解决;�把外部编辑并进�,需�三方�并语义,而没有任何消费方�出过这�需求。写入会先�布外部状�,�败一方至少在被�代之�被观察到。 - **宣布�支�异步 `settings/updated` 监�器**——类型签�是 `void`,lint 也会标记误用的 promise,但未� lint 的 JS �件�能注册异步监�器;契约里的一�说明无法收回已�抛出的 unhandled rejection,收容是唯一在�行时守得�的防线。 @@ -36,6 +36,6 @@ YAML 写入则整体替� namespace 节点,把分节内的��注释都删 ## �果 -`update()` 有了�文的失败模�(�截止时间到期��盘文档�法),rejection 消��带以 `$` 为根的路径。�然存在�且已记录在�供方 README 中的有:� namespace 并�编辑�是�写胜出(没有�值�并,也没有修订�检查);OS 从未投递的 watcher 事件会让缓存��陈旧,直到下一个信�或下一次写入;被替�数组内部的注释�以�行内附�在被改标�值上的注释,会�其�述的值一起消失。 +`update()` 对�获�期�与�盘文档�法都有�文的失败模�,rejection 消��带以 `$` 为根的路径。�有者崩溃��能留下�,需��作者核实�移除;若按�龄自动接管,则会�许多个写入方��。�然存在�且已记录在�供方 README 中的有:� namespace 并�编辑�是�写胜出(没有�值�并,也没有修订�检查);OS 从未投递的 watcher 事件会让缓存��陈旧,直到下一个信�或下一次写入;被替�数组内部的注释�以�行内附�在被改标�值上的注释,会�其�述的值一起消失。 [用户设置 seam note](2026-07-28-user-settings-seam.md)里“延��文件�那�替代方案已被本 note �代。�类缺陷还存在于 `dsh-credentials-local`(两�链共用一个 `.env`�按缓存整文件写回��久化之���事件)与堆�分支上的 `llm/adapters-updated` 扇出;这些修�归引入相应包(package)的那些 PR(Pull Request)所有,�上�并时按本模�处�。 diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml index ac37214ebf..fedfc7e489 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.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-web-config-plane.md -2026-07-30-web-config-plane.md: 95ede6264026f7b32e95749d00fe841f57dbf867 -2026-07-30-web-config-plane.zh.md: 6e06b69218a405055621cbd40781f9fbda9f9e6b +2026-07-30-web-config-plane.md: 6d1a8c242c1888ee4fca9e21ebc814f7a345d633 +2026-07-30-web-config-plane.zh.md: c3255cacfdd1f06d12f7bb2631f95273536b7ef9 diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md index 95ede62640..6d1a8c242c 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md @@ -20,7 +20,7 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer **A hand-written editor over a schema model layer.** `dsh-client-schema-form` rehydrates the wire's `toJSON()` envelope into live schemastery nodes for validation, path resolution, and immutable draft editing — but no generic rendering: the first cut shipped a full schema-driven form renderer, and the resulting page was an unstyled schema dump (every advanced field flattened onto the card, raw field names as labels, the `retryPolicy` unsupported-fallback in the main flow). The user chose the hand-written direction over adding a hint/grouping system, and a second round removed the reference input entirely: the card's primary field is one **API key** input, a whole-section provider without a configured key opens as its setup card, and the collapsed 自定义设置 fold carries the curated per-family extras (`baseURL` for both families, plus `reasoningEffort` for deepseek / `reasoning` for pi-ai), with every other field owned by `settings.yaml`. Validation still runs the rehydrated schema before writing, so a hand-coded field that drifts from its schema fails loud on save rather than silently. -**The Models page is a three-domain join with seam-shaped apply semantics.** Rows are configured providers; the add card's select is the dormant directory remainder; badges come from route liveness. The key path stays reference-shaped without ever showing a reference: a typed key stores **write-only** through `credentials.set` under the profile's `apiKeyEnv`, deriving `_API_KEY` when none exists (the pi-ai profile records the derivation), so `settings.yaml` never carries a key value and the wholesale `settings.replace` a removal needs can never drop a sibling's secret. An edit without removals lands as a minimal `settings.update` merge patch; clearing a fold field back to inherited or deleting a row replaces the whole user section, because merge semantics cannot express removal. +**The Models page is a three-domain join with seam-shaped apply semantics.** Rows are configured providers; the add card's select is the dormant directory remainder. Route liveness still gates readiness and invalidates the join, but the page does not render it as provider status because configuration presence and runtime availability are distinct. The key path stays reference-shaped without ever showing a reference: a typed key stores **write-only** through `credentials.set` under the profile's `apiKeyEnv`, deriving `_API_KEY` when none exists (the pi-ai profile records the derivation), so `settings.yaml` never carries a key value. Profile edits and removals land as minimal path-addressed `settings.mutate` operations against the redacted user section, which never names a secret the page did not receive. Removing a user-layer provider first opens a localized model-provider confirmation dialog; cancellation, its close button, and its mask leave the profile untouched, while the destructive confirmation submits the single unset and blocks duplicate submission until it settles. ## Alternatives considered @@ -33,4 +33,4 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer ## Consequences -The whole loop is pinned keyless in the browser lane (`apps/web/tests/models-settings.e2e.ts`): the add card offers the dormant pi-ai catalog, adding `minimax-cn` with a typed key writes the reference-only profile into `settings.yaml`, stores the value into the harness home's `.env` under the derived `MINIMAX_CN_API_KEY`, registers the route live on the topology frame, and the customized fold merges `reasoning` beside the reference — zero model calls, ARIA goldens for the add-card and configured states, plus a scaffold `harnessHome` so tests never touch a real `~/.dsh` (the provider under test is one whose derived reference cannot collide with a developer's exported keys). The rename touched 239 files (fixtures, goldens, docs, python) in one commit with no compatibility alias. The renderer replacement cost one commit and no wire change: apply semantics, redaction, and the directory join were renderer-agnostic all along. Deferred: a per-row models preview (the picker already lists models), a page address for live routes that never declared configurability, and the documented reset edge — a `settings.replace` cannot re-supply a stored *literal* secret in the replaced subtree, which the reference-based default makes unreachable. +The whole loop is pinned keyless in the browser lane (`apps/web/tests/models-settings.e2e.ts`): the add card offers the dormant pi-ai catalog, adding `minimax-cn` with a typed key writes the reference-only profile into `settings.yaml`, stores the value into the harness home's `.env` under the derived `MINIMAX_CN_API_KEY`, registers the route live on the topology frame, and the customized fold merges `reasoning` beside the reference — zero model calls, ARIA goldens for the add-card, configured, and delete-confirmation states, plus a scaffold `harnessHome` so tests never touch a real `~/.dsh` (the provider under test is one whose derived reference cannot collide with a developer's exported keys). The removal scenario proves cancellation leaves the profile intact, confirmation removes it, and the intentionally retained credential survives. The rename touched 239 files (fixtures, goldens, docs, python) in one commit with no compatibility alias. The renderer replacement cost one commit and no wire change: apply semantics, redaction, and the directory join were renderer-agnostic all along. Deferred: a per-row models preview (the picker already lists models), a page address for live routes that never declared configurability, and explicit removal of a provider's retained credential. diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md index 6e06b69218..c3255cacfd 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md @@ -20,7 +20,7 @@ PR1 让 LLM(大语言模型)适�器�置在 seam 层����,但唯 **架在 schema 模型层之上的手写编辑器。**`dsh-client-schema-form` 把 wire 的 `toJSON()` 信�还原(rehydrate)为活的 schemastery 节点,用于校验�路径解�与����稿编辑——但��通用渲染:第一版交付了完整的 schema 驱动表�渲染器,得到的�是一个未加样��把 schema 原样倾倒出�的页�(�个进阶字段都平铺到�片上�原始字段�直接充当标签�`retryPolicy` 的「�支��回退�在主�程里)。用户没有�加一套�示�分组系统,而是选择了手写方�,第二轮�把引用输入框整个移除:�片的主字段是一个 **API 密钥**输入框,未�置密钥的整分节�供方会以其设置�片的形�打开,收起的「自定义设置�折�区承载按家�精选的�外字段(两个家�都有 `baseURL`,�加 deepseek 的 `reasoningEffort`�pi-ai 的 `reasoning`),其余�个字段都归 `settings.yaml` 所有。校验�会在写入��行还原出的 schema,因此�离其 schema 的手写字段会在�存时大声失败,而��默失败。 -**Models 页是一次三领域�接,应用语义与 seam �形。**�一行是一个已�置的�供方;「新增��片的选择框是��置�供方目录中剩余的休眠�目;徽标�自路由存活状�。密钥通���引用形�,�从�展示任何引用:键入的密钥� `credentials.set` **�写**存入 profile 的 `apiKeyEnv` 之下,引用�存在时便派生 `_API_KEY`(pi-ai profile 会记录该派生),因此 `settings.yaml` 从��带密钥值,删除所需的整体 `settings.replace` 也���能丢掉兄弟�目的机密。��删除的编辑以一次最�的 `settings.update` �并 patch �地;把折�区字段清回继承值或删除整行则� `settings.replace` 替�整个用户分节,因为�并语义表达�了删除。 +**Models 页是一次三领域�接,应用语义与 seam �形。**�一行是一个已�置的�供方;「新增��片的选择框是��置�供方目录中剩余的休眠�目。路由存活状��用于就绪判定,并会使该�接失效,但页��将其渲染为�供方状�,因为�置存在与�行时�用性是两个��概念。密钥通���引用形�,�从�展示任何引用:键入的密钥� `credentials.set` **�写**存入 profile 的 `apiKeyEnv` 之下,引用�存在时便派生 `_API_KEY`(pi-ai profile 会记录该派生),因此 `settings.yaml` 从��带密钥值。profile 的编辑和删除会针对脱��的用户分节,以按路径寻�的最� `settings.mutate` �作�地,��会点�页�未收到的机密。删除用户层�供方时,会先打开本地化的模型�供方确认对�框;�消�作�关闭按钮和�罩��会改动 profile,而破�性确认会�交唯一一� unset,并在其完��阻止���交。 ## 曾考虑的替代方案 @@ -33,4 +33,4 @@ PR1 让 LLM(大语言模型)适�器�置在 seam 层����,但唯 ## �果 -整�闭环以无密钥方�固定在�览器测试通�(`apps/web/tests/models-settings.e2e.ts`):「新增��片�供休眠的 pi-ai catalog,�键入的密钥添加 `minimax-cn` 会把��引用的 profile 写入 `settings.yaml`�把密钥值存入 harness 家目录 `.env` 中派生的 `MINIMAX_CN_API_KEY` 之下�路由�拓扑帧注册为存活,「自定义设置�折�区则把 `reasoning` �并到引用�边——全程零模型调用,「新增��片�与已�置��有 ARIA golden,�有脚手架�的 `harnessHome`,测试��触碰真实的 `~/.dsh`(�测�供方是派生引用��能与开�者已导出密钥相撞的那一个)。这次�命�在一次�交中触� 239 个文件(fixture(测试�置数�)�golden�文档�python),未�留兼容别�。替�渲染器�花了一次�交,且没有任何 wire �更:应用语义�脱�与目录�接从一开始就与渲染器无关。延�事项:�行的模型预览(选择器已能列出模型)�为从未声明��置性的存活路由�供页�地�,以�已记录在案的�置边界情形——`settings.replace` 无法在被替�的�树里�新补上已存储的*字��*机密,而基于引用的默认形�让这�情况根本无从出现。 +整�闭环以无密钥方�固定在�览器测试通�(`apps/web/tests/models-settings.e2e.ts`):「新增��片�供休眠的 pi-ai catalog,�键入的密钥添加 `minimax-cn` 会把��引用的 profile 写入 `settings.yaml`�把密钥值存入 harness 家目录 `.env` 中派生的 `MINIMAX_CN_API_KEY` 之下�路由�拓扑帧注册为存活,「自定义设置�折�区则把 `reasoning` �并到引用�边——全程零模型调用,「新增��片��已�置�与删除确认��有 ARIA golden,�有脚手架�的 `harnessHome`,测试��触碰真实的 `~/.dsh`(�测�供方是派生引用��能与开�者已导出密钥相撞的那一个)。删除场景�明:�消� profile ��原样,确认�会将其删除,而刻��留的凭��然存在。这次�命�在一次�交中触� 239 个文件(fixture(测试�置数�)�golden�文档�python),未�留兼容别�。替�渲染器�花了一次�交,且没有任何 wire �更:应用语义�脱�与目录�接从一开始就与渲染器无关。延�事项:�行的模型预览(选择器已能列出模型)�为从未声明��置性的存活路由�供页�地�,以�显�删除�供方所�留的凭�。 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..7ee4b1fac8 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: dcc4a786c6f1926f06dce03124ec1d8ca805d7ae +2026-07-29-human-transcript-append-origin.zh.md: 0fefc52afa52e99cdec2bcea1a86b9c28711dd67 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..dcc4a786c6 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. +Rendering compaction *progress* — a terminal indicator while a compaction runs — needs the bracket-first ordering that the queued manual `/compact` work introduces, and is 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. ## 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..0fefc52afa 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)`,让标记的内容�有一个归处。 +渲染压缩*进度*——压缩�行期间的终端指示——需�排队�手动 `/compact` 工作引入的“先开括��顺�,�在本次范围内。标记�样��带规模信�:检查点的 `sourceEventSeqs` 已�包�被�蔽的数�,因此一个计数或区间�以告诉读者�一行折�了多少内容。这件事属于进度那一侧,读者正是在那里�到�一份信�的�一�。接手者应当先把终端里两处替�分支——回放与实时监�器,文本完全相��相隔 600 行——�并为一个 `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..ff33e2219c --- /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: 9fb338643774efaeb9deab6f66920a9f4276ce67 +2026-07-30-web-transcript-log-ordered-projection.zh.md: 7962dd432b8bbf115acde9dd480eba9c91f35bfc 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..9fb3386437 --- /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 + +Compaction **progress** — an indicator while a compaction runs — needs the bracket-first ordering the queued manual-compaction work introduces, and stays out of scope here as it did in the terminal. 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. Both belong together, where the reader meets the two halves of the same information. 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..7962dd432b --- /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 + +压缩**进度**——压缩�行期间的指示——需�排队�手动压缩工作引入的“先开括��顺�,与终端一样�在本次范围内。标记�样��带**规模**信�:检查点的 `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 `