diff --git a/.agents/notes/README.i18n.yaml b/.agents/notes/README.i18n.yaml index fa9c0d9a21..649c2f1d87 100644 --- a/.agents/notes/README.i18n.yaml +++ b/.agents/notes/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: 4db9f16956b9c569cf5f9b53f04cb650f6058668 -README.zh.md: 60ec5421e7f271460daebc966aa6548f6ef8a511 +README.md: d2f6d216b151673d818337c67a78dbe908786c8b +README.zh.md: 4c7f785ba7478f35cade742409d87746ddcdf8ec diff --git a/.agents/notes/README.md b/.agents/notes/README.md index 4db9f16956..d2f6d216b1 100644 --- a/.agents/notes/README.md +++ b/.agents/notes/README.md @@ -37,7 +37,11 @@ The `architecture` / `process` line: **architecture** is about the source we shi Every non-trivial change MUST add or update at least one Agent Note in the same PR. A change is non-trivial when it alters behavior, architecture, a cross-file or cross-package contract, process or tooling, testing strategy, an on-disk, wire, or configuration format, or another decision a maintainer may reasonably revisit. A proposal for substantial future work starts in `proposed/`; a decision already made starts in `implemented/`. Pick the class folder that matches the decision (see [Classification](#classification)). -Updating the Agent Note that already owns the decision satisfies the rule; do not create a duplicate. Only a purely mechanical or local edit with no behavioral, contractual, structural, process, or rationale change is exempt. An Agent Note is never edited into a *different decision*: supersede it with a new one and cross-link. Editing an `implemented/` Agent Note to track where its existing decision lives is required, not forbidden; see [implemented/AGENTS.md](implemented/AGENTS.md). +Updating the Agent Note that already owns the decision satisfies the rule; do not create a duplicate. Only a purely mechanical or local edit with no behavioral, contractual, structural, process, or rationale change is exempt. An Agent Note is never edited into a *different decision*: supersede it with a new one, and keep both notes cross-linked unless the old note is later fully consolidated under the rule below. Editing an `implemented/` Agent Note to track where its existing decision lives is required, not forbidden; see [implemented/AGENTS.md](implemented/AGENTS.md). + +An implemented Agent Note that is fully superseded may be consolidated into the current owning note and deleted. Before deletion, the owner must preserve every unique rationale, alternative, consequence, verification contract, and named coverage gap; repair every inbound link; and delete the Chinese counterpart and consistency record in the same change. Partial supersession does not qualify: keep both notes cross-linked and update every fact that remains current. Consolidation must not rewrite the old file into its opposite or rely on git history as the only copy of rationale. + +A feature-addition note may be consolidated into the later removal note only when the feature is absent from production code, configuration, schemas, durable or wire formats, migration, and compatibility behavior; no current documentation presents it as available; and no test exercises it as supported behavior. Removal rationale and tests that verify absence may remain. The removal owner preserves the original motivation, why it no longer justified the feature, alternatives to full removal, the capability given up, conditions for reintroduction, and verification of complete absence. Obsolete implementation inventories and tests that only verified the deleted behavior are not current verification contracts. Removing one transport, default, implementation, or presentation is partial supersession, as is any surviving durable data or compatibility handling. ## The file format diff --git a/.agents/notes/README.zh.md b/.agents/notes/README.zh.md index 60ec5421e7..4c7f785ba7 100644 --- a/.agents/notes/README.zh.md +++ b/.agents/notes/README.zh.md @@ -39,7 +39,11 @@ 每个非平凡变更都必须在同一 PR(Pull Request)中新增或更新至少一份 Agent Note。如果变更修改了行为、架构、跨文件或跨包契约、流程或工具、测试策略、磁盘、协议或配置格式,或者其他维护者可能合理重新审视的决策,就属于非平凡变更。对未来重大工作的提案从 `proposed/` 开始;已经做出的决策从 `implemented/` 开始。选择与决策匹配的类别文件夹(见[分类](#classification))。 -更新已经拥有该决策的 Agent Note 即可满足规则;不要创建重复记录。只有不涉及行为、契约、结构、流程或理由变化的纯机械性或局部编辑才可豁免。Agent Note 永远不会被编辑为一个*不同的决策*:用新 Agent Note 取代旧的,并互相链接。编辑 `implemented/` Agent Note 以跟踪其现有决策的所在位置是必需的,而非禁止的;见 [implemented/AGENTS.md](implemented/AGENTS.md)。 +更新已经拥有该决策的 Agent Note 即可满足规则;不要创建重复记录。只有不涉及行为、契约、结构、流程或理由变化的纯机械性或局部编辑才可豁免。Agent Note 永远不会被编辑为一个*不同的决策*:用新 Agent Note 取代旧记录,并让两个记录保持互相链接,除非后续依据下方规则完全合并旧记录。编辑 `implemented/` Agent Note 以跟踪其现有决策的所在位置是必需的,而非禁止的;见 [implemented/AGENTS.md](implemented/AGENTS.md)。 + +被完全取代的 implemented Agent Note 可以合并到当前持有该决策的记录中,并删除原文件。删除前,当前记录必须保存所有独有的决策依据、备选方案、影响、验证契约和明确指出的覆盖缺口;修复所有入站链接;并在同一变更中删除中文对侧文件和一致性记录。仅部分被取代的记录不符合此条件:保留两个记录并让它们互相链接,同时更新所有仍然适用的事实。合并不得将旧文件改写成与其相反的决策,也不得让 git 历史成为决策依据的唯一副本。 + +只有当一项功能已从生产代码、配置、schema、持久化格式或协议格式、迁移和兼容行为中完全消失,当前文档不再将其描述为可用,且没有测试把它作为受支持行为来执行时,新增该功能的 Agent Note 才可合并进后续的移除记录。移除决策的依据和验证该功能已不存在的测试可以保留。移除决策的持有记录必须保留最初动机、为什么该动机已不足以证明保留该功能的合理性、完全移除之外的备选方案、放弃的能力、重新引入的条件,以及证明已彻底移除的验证。过时的实现清单和只验证已删除行为的测试不属于当前验证契约。仅移除一种传输、默认值、实现或展示属于部分取代;仍有任何持久数据或兼容处理也同样如此。 diff --git a/.agents/notes/implemented/AGENTS.md b/.agents/notes/implemented/AGENTS.md index 5fb3fde8e2..c34e1a49b8 100644 --- a/.agents/notes/implemented/AGENTS.md +++ b/.agents/notes/implemented/AGENTS.md @@ -8,4 +8,4 @@ Keep paths, symbols, defaults, and mechanisms current in the same change that al ### This is not a license to rewrite the *decision* -Update factual realization in place. A reversal of the decision or its rationale requires a new Agent Note and cross-link; see the [Agent Note contract](../README.md). +Update factual realization in place. A reversal of the decision or its rationale requires a new Agent Note and cross-link; a fully superseded old note may be deleted only through the consolidation rule in the [Agent Note contract](../README.md). diff --git a/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.md b/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.md deleted file mode 100644 index 947d53555d..0000000000 --- a/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.md +++ /dev/null @@ -1,23 +0,0 @@ -# Agent Note: Custom typed tool-schema DSL instead of schemastery - -Status: implemented - -English | [中文](2026-06-11-custom-schema-dsl.zh.md) - -## Problem - -Tool parameters must reach the model as standard JSON Schema while giving tool authors typed `execute(args)` without casts. Schemastery already serves plugin config, but the tool-author API needs per-property `required: true` booleans rather than JSON Schema's separate `required` array. - -## Decision - -This decision is superseded by the [unified JSON-value schema DSL](2026-07-20-unified-json-value-schema-dsl.md), which retains the small authoring surface while making parameters and typed values share one vocabulary. `ParameterSchemaSpec` keeps per-property `required: true`; `InferArgs` maps required keys to non-optional properties; `parameterSchemaSpecToJsonSchema()` compiles the implicit open object root; and `defineTool()` ties inference, compilation, and validation together. Raw JSON-Schema `ToolDefinition`s remain accepted by `ToolRegistry.register()` for MCP and other external tools. - -## Alternatives considered - -**Schemastery** (already vendored, used for plugin Config) was evaluated and rejected for this use: it targets validation / transformation against StandardSchema, not JSON Schema *generation*, so it would add indirection without producing the wire format cleanly. - -## Consequences - -- First-party tool authors get zero-cast typed args; the type gymnastics cost stays inside the core package (sanctioned by the AGENTS.md type-safety policy). -- The owning unified note defines the current nodes, literal constraints, unions, JSON-value boundary, and object-openness rules. -- The `InferArgs` mapping is regression-tested at the type level after an early optionality bug. diff --git a/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.zh.md b/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.zh.md deleted file mode 100644 index 26ebfe2fb1..0000000000 --- a/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.zh.md +++ /dev/null @@ -1,23 +0,0 @@ -# Agent Note: 使用自定义类型化工具 schema DSL 替代 schemastery - -Status: implemented - -[English](2026-06-11-custom-schema-dsl.md) | 中文 - -## 问题 - -工具参数必须以标准 JSON Schema 形式到达模型,同时让工具作者在 `execute(args)` 中获得类型化的参数而无需类型断言。Schemastery 已用于插件配置,但工具作者 API 需要逐属性的 `required: true` 布尔值,而非 JSON Schema 的独立 `required` 数组。 - -## 决策 - -该决策已由[统一 JSON 值 schema DSL](2026-07-20-unified-json-value-schema-dsl.md)取代;新设计保留小型编写接口,同时让参数与类型化值共享一套词汇。`ParameterSchemaSpec` 保留逐属性的 `required: true`;`InferArgs` 将必需键映射为非可选属性;`parameterSchemaSpecToJsonSchema()` 编译隐式开放的对象根;`defineTool()` 则将类型推导、编译与校验串联起来。原始 JSON Schema 的 `ToolDefinition` 仍是 `ToolRegistry.register()` 接受的输入,供 MCP 和其他外部工具使用。 - -## 曾考虑的替代方案 - -**Schemastery**(已作为 vendor 引入,用于插件 Config)经评估后被否决:它面向的是基于 StandardSchema 的校验/转换,而非 JSON Schema *生成*,因此会增加间接层却无法干净地产出协议格式(wire format)。 - -## 后果 - -- 第一方工具作者获得零类型断言的类型化参数;类型体操的成本留在核心包内部(符合 AGENTS.md 的类型安全策略)。 -- 当前节点、字面量约束、联合类型、JSON 值边界与对象开放性规则均由上述统一说明定义。 -- `InferArgs` 映射在类型层面有回归测试,源于早期一个可选性 bug。 diff --git a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.i18n.yaml index 7db4f97aa4..03a271a9c3 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.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 -2026-06-20-package-hierarchy.md: 7cd07ff90225872f2a17b9a678e52fcee416b09a -2026-06-20-package-hierarchy.zh.md: 9ef89bd56144b39bb3240a22a2bb1e9216e24115 +2026-06-20-package-hierarchy.md: 4e05e3487483ab8d710959c1888ec1f5c3b37432 +2026-06-20-package-hierarchy.zh.md: f57704ad082c4961aa48056af1b4b279d2f2c055 diff --git a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.md b/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.md index 7cd07ff902..4e05e34874 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.md +++ b/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-06-20-package-hierarchy.zh.md) -The later [fold-stdio-helper](../simplification/2026-07-04-fold-stdio-ui-helper.md) decision superseded the original `support/ui-stdio` placement, and the [redundant-agent removal](../simplification/2026-07-20-remove-stdio-and-echo-agents.md) subsequently removed that surface entirely. The [automation-only ACP decision](../simplification/2026-07-23-acp-automation-only-protocol.md) places ACP under `packages/acp/acp` instead of the human-UI group. The uniform depth-two hierarchy remains the decision owned here. +The [redundant-agent removal](../simplification/2026-07-20-remove-stdio-and-echo-agents.md) deletes the original `support/ui-stdio` surface instead of relocating it, and the [automation-only ACP decision](../simplification/2026-07-23-acp-automation-only-protocol.md) places ACP under `packages/acp/acp` instead of the human-UI group. The uniform depth-two hierarchy remains the decision owned here. ## Problem diff --git a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.zh.md b/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.zh.md index 9ef89bd561..f57704ad08 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-06-20-package-hierarchy.md) | 中文 -后续的[折叠 stdio helper](../simplification/2026-07-04-fold-stdio-ui-helper.md)决策取代了最初的 `support/ui-stdio` 放置方式,[冗余 agent 移除](../simplification/2026-07-20-remove-stdio-and-echo-agents.md)随后又彻底移除了该接口。[仅面向自动化的 ACP 决策](../simplification/2026-07-23-acp-automation-only-protocol.md)把 ACP 放在 `packages/acp/acp` 下,而不是面向人类的 UI 组。这里拥有的决策仍是统一的二层目录深度。 +[冗余 agent 移除](../simplification/2026-07-20-remove-stdio-and-echo-agents.md)直接删除最初的 `support/ui-stdio` 接口,而不是将其迁移;[仅面向自动化的 ACP 决策](../simplification/2026-07-23-acp-automation-only-protocol.md)把 ACP 放在 `packages/acp/acp` 下,而不是面向人类的 UI 组。这里拥有的决策仍是统一的二层目录深度。 ## 问题 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 new file mode 100644 index 0000000000..1c6f4e06ac --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.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 +2026-06-21-bounded-llm-request-recovery.md: 22a56dc6d69340ca1b5f7b77edb4731066c9b2f5 +2026-06-21-bounded-llm-request-recovery.zh.md: 09ebce376a206591ac766067cc41497b74ed1545 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 932318da4f..22a56dc6d6 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 @@ -2,6 +2,8 @@ Status: implemented +English | [中文](2026-06-21-bounded-llm-request-recovery.zh.md) + ## Problem `dsh-llm` can report provider failures either by throwing during adapter dispatch or iteration or by ending with `finish { kind: 'error' | 'aborted' }`. The final adapter boundary tags thrown failures so `dsh-agent-loop` can distinguish them from middleware and result-processing defects, and the loop normalizes both delivery forms into `agent/request-error` after closing the failed step. The default decision is `fail`; `dsh-compact-basic` is the only shipped recovery listener, and it retries a canonical context-window overflow only after compaction proves that the durable surface shrank. 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 new file mode 100644 index 0000000000..09ebce376a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md @@ -0,0 +1,148 @@ +# Agent Note: LLM(大语言模型)暂时性请求失败的有界恢复 + +Status: implemented + +[English](2026-06-21-bounded-llm-request-recovery.md) | 中文 + +## 问题 + +`dsh-llm` 可能在适配器分发或迭代时抛出异常,也可能以 `finish { kind: 'error' | 'aborted' }` 结束,以这两种形式报告提供方失败。最终适配器边界会标记抛出的失败,使 `dsh-agent-loop` 能将其与中间件和结果处理缺陷区分开。循环关闭失败步骤后,会把两种交付形式统一规范化为 `agent/request-error`。默认决策为 `fail`;`dsh-compact-basic` 是唯一已交付的恢复监听器,它仅在压缩(compaction)证明持久表层已缩减后,才会对规范化的上下文窗口溢出进行重试。 + +该边界已能安全地再次发起请求。原始 `assistant/chunk` 事件携带失败的 `turn` 和 `step`;除非某条成功的 `assistant/message` 引用这些事件,否则消息派生会忽略它们。只有终止性 finish 成功且组装完成后,系统才会分发工具调用;重试则会从持久日志开启新的编号步骤。因此,harness 无需引入第二套响应生命周期或暂定输出协议,即可分隔两次尝试。 + +此前的边界还留有三个较窄的缺口。 + +- 提供方失败只保留消息,通常还会保留一个 code。HTTP 状态、重试延迟和提供方请求 id 会被丢弃,或者只能通过提供方专用错误对象恢复,因此通用恢复机制如果不解析文本,便无法作出决策或解释决策。 +- 重试的归属因适配器而异。手写 DeepSeek 适配器只尝试一次,pi-ai profile 则可以启用库内部的不透明重试。如果把隐藏的传输重试与 `agent/request-error` 监听器结合,尝试次数会成倍增加,中间失败也不会记入会话日志。 +- 恢复后的失败没有持久状态事实。失败的步骤和分片仍可重建,但观察者无法得知 agent(智能体)是否在有意退避、将等待多久,以及等待原因。长时间的静默等待看起来与循环停滞无异。 + +本决策的目标是从同一个显式提供方/模型请求的暂时性失败中进行有界恢复。提供方或模型故障转移、响应拼接和语义输出修复都属于其他问题,目前没有消费方。 + +## 决策 + +### 保留失败事实,不嵌入策略 + +`@deepseek-ai/dsh-llm` 导出唯一的可 JSON 序列化 `LlmFailure` 载荷: + +```ts ignore-check +type ProviderRequestId = Branded<'ProviderRequestId'> + +interface LlmFailure { + message: string + code: string + status?: number + providerRetryAfterMs?: number + requestId?: ProviderRequestId +} +``` + +`code` 仍是 `HarnessError` 建立的提供方无关机器路由分类体系;新字段是在提供方边界观测到的事实。`ProviderRequestId` 由 `dsh-llm` 拥有并构造,序列化后为提供方发放的字符串。该载荷有意不包含 `retryable`、`failover`、`partialOutput`、提供方、模型、阶段或路由 id 字段。是否可重试属于策略,提供方/模型已位于持久请求头中,部分输出则从失败步骤的 `assistant/chunk` 事件派生。 + +`LlmError` 携带 `failure: LlmFailure`,并保持 `failure.code === error.code`。`FinishReasonMap.error` 和 `FinishReasonMap.aborted` 携带同一载荷,而不是并行的失败形状。适配器抛出的 `Error` 保留其精确的对象标识:最终适配器 scope 在调用局部的伴随状态中把规范化事实与该对象关联,然后原样重新抛出;非 `Error` 抛出值则依旧被包装。`llmFailureOf(stream, error)` 会在现有来源检查旁取回这些事实,而没有错误对象的带内 finish 则会成为新的 `LlmError`。这既保留了按错误类型或标识分流的监听器,又使所有最终适配器失败(包括未知 SDK 异常)都获得 `UNKNOWN` 终止载荷。 + +agent loop(智能体循环)会保留 `RequestError` 作为该精确的错误对象,并将 `LlmFailure` 作为独立参数传给 `agent/request-error`;它不会改动可能已冻结的第三方错误。在转换带内 finish 以及记录未恢复的 `turn/end.reason` 时,循环也会使用该载荷。 + +适配器会先提取结构化事实,再回退到消息检查。它们会验证 HTTP 状态,将 `Retry-After` 的秒数或日期解析为正的有限毫秒延迟,在提供方公开请求 id 时将其品牌化,并区分自身超时与调用方中止。提供方专用 code 和消息可以细化映射,但恢复监听器不会解析它们。 + +共享的暂时性 code 集有意保持很小:适配器针对 `RATE_LIMIT` 和 `SERVER` 的映射,远程失败使用的显式 `TIMEOUT` 和 `TRANSPORT` code,以及提供方响应已完成却没有内容块时使用的 `EMPTY_RESPONSE`。两个适配器都会把最后一种情况归类为错误 finish;详见[空模型响应可重试](../bug-fix/2026-07-24-empty-model-response-is-retryable.md)。身份验证、配额、无效请求、上下文溢出、协议、中止和未知失败都保留不同的稳定 code,且默认不属于暂时性失败。新增 code 需要适配器 fixture(测试前置数据)和已记录的策略决策;无需扩展第二个失败类枚举。 + +### 将重试策略放在现有失败步骤 seam 上 + +`@deepseek-ai/dsh-llm-retry` 是监听 `agent/request-error` 的函数插件。它不引入服务或新的循环分支;agent-loop 包仅会更改通过现有失败步骤恢复控制流携带的数据。 + +`agent/request-error` seam 携带当前 `LlmFailure`,以及在这段连续恢复序列中导致再次请求的不可变先前失败列表。`dsh-llm-retry` 只计数 code 位于已配置暂时性集合中的先前失败,`dsh-compact-basic` 则只计数先前的上下文溢出失败。模型请求成功后会清空历史。因此,暂时性失败与上下文溢出交替出现时,两种策略会独立消耗各自预算;最大请求数等于 1 加上已加载恢复策略的有限预算总和。 + +该插件在加载时解析并验证以下部署配置: + +```ts ignore-check +interface Config { + maxTransientRetries?: number + initialDelayMs?: number + maxDelayMs?: number + jitterRatio?: number + retryableCodes?: string[] +} +``` + +默认值为两次暂时性重试、500 毫秒初始延迟、10 秒延迟上限、10% 抖动,以及上述五个暂时性 code(`RATE_LIMIT`、`SERVER`、`TIMEOUT`、`TRANSPORT` 和 `EMPTY_RESPONSE`)。计数与延迟边界参考了所调查实现中较保守的一端:[OpenCode 使用两次请求重试,延迟边界为 500 毫秒/10 秒](https://github.com/anomalyco/opencode/blob/9976269ab1accfc9f9dc98a4a688c516934de422/%70ackages/llm/src/route/executor.ts#L36-L39);[Pi 将三次 agent 级重试与提供方重试分开,且提供方重试默认为零](https://github.com/earendil-works/pi/blob/3da591ab74ab9ab407e72ed882600b2c851fae21/%70ackages/coding-agent/docs/settings.md#L139-L147);[Codex 使用有限请求/流预算以及五分钟空闲超时](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/model-provider-info/src/lib.rs#L25-L33)。10% 抖动参考 [Codex 的有界抖动](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/codex-client/src/retry.rs#L40-L47)。在没有其他恢复策略时,两次重试表示最多发起三次提供方请求。`maxTransientRetries` 是非负整数,延迟是正的有限数且满足 `initialDelayMs <= maxDelayMs`,`jitterRatio` 位于 `[0, 1]`,code 非空且不重复。这些都是 Cordis 配置字段,而不是隐藏常量,使部署能够选择不同的成本与延迟预算。 + +对于预算未耗尽的合格失败,从 1 开始的暂时性重试计数使用有界指数退避。有效的 `providerRetryAfterMs` 只有在不超过 `maxDelayMs` 时才会取代指数退避;提供方延迟更长时,系统会委托给下一监听器,而不会违反提供方指令提前重试。本地退避乘以 `[1 - jitterRatio, 1 + jitterRatio]` 内的注入随机因子,并将最终值限制到 `maxDelayMs`;提供方延迟不加抖动。 + +插件拥有一个全生命期 `AbortController`,并跟踪每个活跃的退避回调。每次等待都会融合 waterfall(瀑布式事件)的轮次信号与该生命期信号。effect 清理会先注销监听器,再中止并等待活跃回调;被捕获回调的生命期信号中止时,回调会返回 `fail`,既不能重试,也不能在插件释放后进入其捕获 waterfall 的剩余部分。尽管 Cordis 已捕获该监听器,此设计仍能使 HMR(热模块替换)释放达到完全停稳。 + +休眠前,`dsh-llm-retry` 会追加一条不进入表层的 `llm/retry` 会话事件,其中包含轮次、失败步骤、从 1 开始的暂时性重试编号、已配置上限、计划延迟和 `LlmFailure`。该插件拥有 `SessionEventMap` 声明合并;`dsh-session` 继续负责通用持久化,不会吸收可选策略的词汇。事件记录已安排的内容,而不是下一个请求已完成;延迟期间取消随后会在 `turn/end` 中可见。因为该事件的目的是表示运行状态,而不是收集跟踪数据,所以它仅与生产渲染器及回放/快照覆盖一起交付。 + +对非暂时性 code、耗尽的策略预算或超出上限的提供方延迟,监听器会调用 `next()`。这保留了与上下文溢出恢复及后续策略插件的组合能力。只有在两个信号下完成延迟后,它才会返回 `{ action: 'retry' }`;轮次取消和插件释放会返回 `fail`,此后仍以循环的取消/释放检查为准。 + +agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次性 CLI(命令行界面)和 ACP(Agent Client Protocol)示例组合使用同一有界策略。库消费方仍需显式组合插件:省略该插件时,`agent/request-error` 保持现有的 fail 默认值。 + +### 由单一层负责可见的尝试 + +适配器每次调用 `stream()` 只执行一次提供方请求。pi-ai 适配器移除公开的 `maxRetries` 和 `maxRetryDelayMs` profile 字段,并禁用库内部重试;手写适配器保持现有的单次尝试行为。这样既避免 SDK 预算成倍放大 agent 预算,又能确保每次暂时性重试都由一个已关闭的失败步骤加 `llm/retry` 表示。 + +`ctx.llm.stream()` 仍是原始的单次尝试 waterfall。压缩摘要等直接调用方会收到结构化失败,但不会自动获得重试,因为它们没有 agent 步骤边界,也没有可供分隔尝试的通用持久位置。未来的直接调用消费方可能会需要一个缓冲辅助函数,仅在尚未发出任何分片时重试;本决策不增加此类辅助函数。 + +### 在能够终止停滞流的位置施加边界 + +每个适配器都公开一个经过验证的 `streamIdleTimeoutMs` 配置字段,默认值采用上文引用的五分钟先例。该间隔不超过 Node 的最大定时器延迟,因此不会被钳制为 1 毫秒。它覆盖每个尚未完成的迭代器 `next()`:从消费方请求下一项开始,到下一条有效 `StreamChunk` 到达为止;消费方在两次 `next()` 调用之间花费的时间不属于提供方空闲时间。 + +`@deepseek-ai/dsh-timeout` 公开一个可重新布防的空闲看门狗原语。一个稳定的局部 `AbortController` 会与调用方信号融合,并在整个适配器调用期间传给传输层;每个尚未完成的 `next()` 都会布防看门狗,该调用完成时解除布防,下一次请求数据时再重新布防。超时会使用能力自身拥有的 `TimeoutReason` 中止这个稳定控制器,`finally` 则会清除定时器。适配器将自身看门狗归类为 `TIMEOUT`,将更早发生的上游中止归类为 `ABORTED`。现有的一次性 `deadline()` 不会被描述为滑动计时器。 + +边界测试证明两个实际传输层都能终止。手写适配器会中止其 fetch/reader,pi-ai 适配器会把稳定信号映射到 SDK,并证明 SDK 会关闭响应。如果定时器只拒绝消费方 promise,却让请求继续运行,就不满足此契约。 + +### 在现有日志中分隔尝试 + +一次失败尝试可以在已关闭的步骤中留下 `assistant/chunk` 事件,但绝不会追加 `assistant/message`,也不会分发工具。重试会开启下一个编号步骤,从持久表层重建请求,并生成自己的分片。步骤仍处于打开状态时,UI 可以渲染实时分片;当 `llm/retry` 标识失败步骤,或 `turn/end` 记录终止失败时,UI 再标记或清除这份暂时视图。消息派生仍会忽略失败分片。 + +如果恢复预算耗尽,最终失败会连同结构化事实在 `turn/end.reason` 中存储一次。如果暂时性恢复继续,`llm/retry` 就是该次尝试的失败与延迟的持久归属位置。本决策不增加独立的最终错误事件或响应 id 词汇。 + +## 不在范围内 + +- 自动提供方或模型故障转移。请求已显式选择一个提供方和模型,提供方注册表也有意规定每个提供方只由一个适配器负责。 +- 在成功的终止性 finish 后重试或继续,或将两次尝试的分片拼接成一条 assistant 消息。 +- 修复格式错误的工具参数、拒答、内容过滤或其他语义模型输出。 +- 无界重试、无人值守地持续重试直至取消、熔断器、共享提供方健康状态或跨 agent 重试预算。 +- 在没有生产消费方的情况下,把 `llm/stream` 改造成响应生命周期或增加便利的生成 API。 + +## 考虑过的替代方案 + +- **在 `llm/stream` 或提供方 SDK 内部重试**:拒绝采用,因为原始流一旦发出分片便没有持久尝试边界,隐藏的 SDK 重试会成倍放大预算,而且两条路径都无法一致地记录每次失败尝试。 +- **向 `dsh-llm` 增加响应开始、中断、丢弃、失败和提交事件**:拒绝采用,因为 agent 日志已经分隔原始分片、成功消息和编号尝试。第二套状态机会重复归属关系,又不能支持有界的同路由重试。 +- **增加逻辑路由、能力矩阵和故障转移选择**:拒绝采用,因为当前请求已经显式指定提供方和模型,每个提供方由一个适配器负责,而且没有当前消费方要求自动回退或能够证明语义兼容性。 +- **把 `retryable` 或 `failover` 放在 `LlmFailure` 上**:拒绝采用,因为适配器报告事实,部署策略决定动作。同一个 429 可以在交互式组合包中重试,也可以在成本受限的批处理中被拒绝。 +- **只要调用方仍处于活跃状态就无限重试**:拒绝采用,因为这会让一次请求产生无界成本和延迟。可见状态能使有界等待易于理解,却不能让无限预算变得安全。 +- **只通过进程 logger 记录重试状态**:拒绝采用,因为进程日志无法重建会话行为,也不能驱动回放后的 UI 状态。 +- **只保留扁平 code**:拒绝采用,因为重试延迟和提供方请求 id 是结构化的提供方事实,而当不同协议失败共用一个稳定 code 时,诊断还需要 HTTP 状态。 + +## 验证 + +- `LlmFailure` 是最终适配器抛出失败、错误 finish 和中止 finish 使用的唯一可序列化载荷;在可用时,规范化保留稳定 code、状态、重试延迟、品牌化的提供方请求 id、错误原因,以及调用方中止与适配器超时之间的分类。 +- 适配器抛出的 `Error` 会以完全相同的对象抵达 `agent/request-error`,其伴随的 `LlmFailure` 则抵达相邻参数;测试保留针对可扩展及冻结第三方错误的现有对象标识断言。 +- DeepSeek 和 pi-ai 适配器测试覆盖具有代表性的 400、401/403、429、5xx、连接、格式错误/截断流、超时、中止、Retry-After 秒数/日期、请求 id 和未知 SDK 错误路径,恢复策略无需解析消息文本。 +- Pi 将 SDK 选项固定为零次重试,并针对可重试的提供方响应执行一次可观测的实际网络请求;独立测试确保移除任一边界都会失败。 +- `agent/request-error` 携带当前失败事实以及不可变的先前已重试失败事实;成功会清除该历史,暂时性失败/上下文溢出交替发生的集成测试证明两种策略只消耗各自的有限预算。 +- `dsh-llm-retry` 在 Loader 启动时验证每个配置字段,使用 `next()` 委托所有不合格路径,而且在没有其他策略时最多发起 `maxTransientRetries + 1` 次提供方请求。 +- 退避期间执行 HMR 的测试证明:释放过程会注销监听器、中止并等待其捕获的回调,释放后不发出重试决策,也不留下存活的定时器或 promise。 +- 纯单元测试覆盖暂时性 code 选择、指数退避和抖动边界、有效及超出上限的 `Retry-After`、耗尽的预算、确定性定时器/随机数 seam,以及退避期间中止。 +- 真实 agent-loop 测试覆盖分片前失败、部分分片后失败、抛出及带内失败、在新步骤中重试至成功、耗尽后写入结构化 `turn/end.reason`,以及与 `dsh-compact-basic` 上下文溢出恢复的组合。 +- 部分分片集成测试证明:失败分片仍归属于失败步骤,该步骤不会提交 assistant 消息或工具副作用,成功的重试具有不同的来源信息。 +- 插件拥有的不进入表层的 `llm/retry` 事件可在 JSONL 和 SQLite 往返后保留,被消息派生忽略,并驱动 TUI 撤回和计划重试渲染。无密钥快照覆盖调度、取消、成功和耗尽;ACP 自动化快照确认,被丢弃的尝试不会通过协议发出,而恢复后的回复会正常发出。 +- 空闲看门狗测试证明:只有 `next()` 尚未完成时才会重新布防稳定信号;在消费方思考期间及 `finally` 中会解除布防;它与总调用 deadline 以及更早发生的调用方中止分开分类。适配器测试证明该信号会终止底层请求,而不只是与其脱离。 +- `ctx.llm.stream()` 的直接调用方仍只尝试一次,并收到相同的结构化失败事实。 + +## 后果 + +- 每次暂时性恢复尝试都以一个已关闭步骤加 `llm/retry` 的形式可见,有界策略还会防止隐藏的 SDK 重试成倍增加成本。即使没有分片到达,重试仍可能造成提供方重复计费;有限的尝试预算只能限制而无法消除此风险。 +- 提供方 SDK 可能隐藏状态或重试标头。适配器会保留 SDK 公开的稳定事实,否则使用粗粒度 code,而不会让恢复策略解析脆弱的文本。 +- 持久重试事件扩展了会话协议和 UI 状态机。事件与其消费方一同交付,可避免产生无人使用的遥测词汇;但以后更改 schema 仍需要同步完成持久化和回放工作。 +- 清除失败步骤的实时分片可能会明显撤回输出。与把丢弃的文本或不完整工具 JSON 呈现为已提交历史相比,这是更好的选择;快照固定这一转换。 +- 适配器局部的空闲强制机制可以终止停滞的传输,而不会计入消费方思考时间。每个传输边界的契约测试会防止 SDK 漂移。 +- 多个恢复插件会叠加各自的有限预算。此处它们的分类器互不重叠;重叠的分类器会形成依赖注册顺序的策略,必须由引入它们的插件记录并测试。 + +## 相关资料 + +- [结构化错误分类体系](../../implemented/architecture/2026-06-11-structured-error-taxonomy.md)负责稳定、可供机器路由的 code 与 cause chaining。 +- [可重建请求](../../implemented/architecture/2026-07-05-reconstructable-requests.md)使提供方/模型和完整请求输入在分发前持久化。 +- [超时 deadline 库](../../implemented/architecture/2026-07-06-timeout-deadline-library.md)将共享的 deadline 分类与能力自身拥有的终止操作分开。 +- [调用后压缩压力与上下文溢出恢复](../../implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)负责当前已关闭步骤的请求恢复 seam 与有界溢出重试。 +- [提供方路由的 LLM 适配器](../../implemented/architecture/2026-07-14-provider-routed-llm-adapters.md)负责显式提供方/模型路由与每个提供方仅有一个适配器的不变量。 diff --git a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml index 9871c7a528..d2ac37af62 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.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 -2026-07-02-tool-render-intent-union.md: 6cfd8921decbe16343f963574edd52173c2f8698 -2026-07-02-tool-render-intent-union.zh.md: d0414c5f15995192df898e968d054933f82d2ab4 +2026-07-02-tool-render-intent-union.md: 84423e9000526848a111591c1bb2ab92067bbe50 +2026-07-02-tool-render-intent-union.zh.md: 43873c622fc8483b4a7033d17b4b4fab1342a56b diff --git a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md index 6cfd8921de..84423e9000 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md +++ b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md @@ -54,6 +54,8 @@ interface TerminalResultView { card: 'terminal'; title?: string; output?: string `TerminalResultView` carries only `output`/`exitCode`/`signal`. A UI without the terminal capability needs a fenced ` ```console ` text fallback; that derivation moves to the **bridge** (it wraps `output` in a fenced block on the no-capability path), rather than the tool double-encoding it. This keeps the bash tool's result a single structured shape and preserves the existing capability-gated behavior byte-for-byte. +The terminal intent is display-only. The harness still executes the command through its bash service, preserving sandboxing, environment scrubbing, task ownership, and per-session cwd; a UI projects the completed call and never becomes a second execution backend. + ### Purity preserved `presentCall`/`presentResult` remain pure functions of `args` (+ the result for `presentResult`) — they run on live streaming AND session-log replay, so they must be replay-deterministic. Every view is derived from args alone: write's diff is new-file style (`oldText:null`) because the tool has no old content at call time; edit's diff is `old_string`→`new_string`. @@ -61,6 +63,7 @@ interface TerminalResultView { card: 'terminal'; title?: string; output?: string ## Alternatives considered - **Delete tool-owned presentation entirely** — [the rejected collapse proposal](../../rejected/simplification/2026-06-20-generic-tool-rendering.md); its own verdict deferred to exactly this union once two real tools and two real consumers existed, and that bar is now met. +- **Let a UI execute terminal intents** — rejected because it would bypass the harness's bash policy and ownership contracts and fork command execution across backends. A terminal card describes harness-owned execution; it never authorizes client-side execution. - **A merge-extensible union** (the `ContentBlockMap` pattern) — rejected: a new render intent needs new bridge code to render it anyway, so a plugin-added variant the bridge silently drops would be worse than the compile error the closed union raises at the bridge's `assertNever` switch. - **Keeping the optional-field bag** — the status quo the Problem dissects: invalid states representable, undocumented field interactions, and no way to ask for a diff card at all. diff --git a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md index d0414c5f15..43873c622f 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md @@ -54,6 +54,8 @@ interface TerminalResultView { card: 'terminal'; title?: string; output?: string `TerminalResultView` 只携带 `output`/`exitCode`/`signal`。不具备终端能力的 UI 需要一个围栏 ` ```console ` 文本回退;该推导移至 **bridge**(在无能力路径上将 `output` 包裹在围栏代码块中),而非由工具双重编码。这使 bash 工具的结果保持单一结构化形状,并逐字节保留既有的能力门控行为。 +terminal 意图只用于展示。harness 仍通过自身的 bash 服务执行命令,从而保留沙箱、环境清理、任务归属和每会话 cwd;UI 只呈现已完成的调用,绝不会成为第二个执行后端。 + ### 纯函数性保持不变 `presentCall`/`presentResult` 仍然是 `args`(`presentResult` 还有 result)的纯函数——它们在实时流式输出和会话日志回放中都会运行,因此必须具备回放确定性。每个 view 仅从 args 推导:write 的 diff 是新文件风格(`oldText:null`),因为工具在调用时没有旧内容;edit 的 diff 是 `old_string`→`new_string`。 @@ -61,6 +63,7 @@ interface TerminalResultView { card: 'terminal'; title?: string; output?: string ## 曾考虑的替代方案 - **完全删除工具自有的展示**:即[被否决的 collapse 提案](../../rejected/simplification/2026-06-20-generic-tool-rendering.md);其自身的结论正是推迟到两个真实工具和两个真实消费方存在后再做此联合类型,该条件现已满足。 +- **让 UI 执行 terminal 意图**:否决。这样会绕过 harness 的 bash 策略与归属契约,并把命令执行分裂到不同后端。terminal 卡片描述的是 harness 拥有的执行,绝不授权客户端侧执行。 - **可合并扩展的联合类型**(`ContentBlockMap` 模式):否决。新的渲染意图无论如何需要新的 bridge 代码来渲染,因此一个被 bridge 静默丢弃的插件添加变体,比封闭联合类型在 bridge 的 `assertNever` switch 处引发的编译错误更糟糕。 - **保留可选字段集合**:即「问题」一节所剖析的现状:无效状态可表达、字段交互无文档、且完全无法请求 diff 卡片。 diff --git a/.agents/notes/implemented/architecture/2026-07-05-windows-fs-permissions.md b/.agents/notes/implemented/architecture/2026-07-05-windows-fs-permissions.md deleted file mode 100644 index 932b6ddbf4..0000000000 --- a/.agents/notes/implemented/architecture/2026-07-05-windows-fs-permissions.md +++ /dev/null @@ -1,31 +0,0 @@ -# Agent Note: Windows write-permission semantics — inherited DACLs, not mode bits - -Status: implemented - -The replacement-file decision in this record is superseded by [Windows DACL preservation](../bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md). - -## Problem - -`writeFileAtomic` in `@deepseek-ai/dsh-fs-local` protects write-in-progress content with POSIX mode bits: the staging directory is created `0o700`, the temp file is opened `0o600`, and new files default to `0o600`. On POSIX this keeps temporary content owner-only regardless of the parent directory's permissions. - -Windows has no working equivalent behind the same API. Node's `chmod` there drives only the read-only attribute (every mode this package passes carries owner-write, so the calls are benign no-ops), and `stat().mode` reports synthetic `0o666`/`0o444` bits. The real security state is the file's DACL: a newly created file or directory inherits from its parent, while replacement needs the explicit handling owned by the superseding Agent Note. - -## Decision - -New Windows files use directory inheritance rather than synthetic mode bits: the staging directory is created inside the target's parent directory (`dirname(absolutePath)`), so it and the temp file inherit the destination directory's DACL. Replacement files follow the stricter [DACL preservation contract](../bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md). - -Tests assert mode bits on POSIX only. Native Windows coverage pins the package-owned replacement behavior; new-file inheritance remains an operating-system contract rather than a machine-specific ACL allowlist. - -## Alternatives considered - -**Explicit owner-only DACLs for new files.** Rejected because they would break inheritance and surprise users whose project directories are deliberately shared. Replacement writes copy the target's existing DACL rather than inventing an owner-only policy. - -**Test-side ACL verification.** A `Get-Acl` SID allowlist or `icacls` would verify Windows inheritance and the machine's `%TEMP%` ACL rather than package behavior; `icacls` also localizes well-known account names, making parsing locale-fragile. - -**Skip `chmod` on Windows.** Platform-guarding benign no-op calls adds branches without changing behavior. - -## Consequences - -POSIX keeps owner-only temp content regardless of the parent directory. A new Windows target inside a broadly accessible directory inherits that accessibility by design; a replacement retains the target's narrower DACL when one exists. - -Mode preservation across a replace degenerates to a no-op on Windows: a writable file probes as `0o666`, and replaying that through `chmod` leaves the read-only attribute clear. A read-only target cannot be replaced there because publication fails before the synthetic mode would matter. diff --git a/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.i18n.yaml new file mode 100644 index 0000000000..4322906bb9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.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 +2026-07-05-windows-jsonl-durable-publish.md: 38c4adc7a4f85d45e53e70fcac84073ab4e50775 +2026-07-05-windows-jsonl-durable-publish.zh.md: 8dc77a0ab1b9273cf3f6ecb26916c7861ee81ec4 diff --git a/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.md b/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.md index 23e5f630d3..38c4adc7a4 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.md +++ b/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.md @@ -2,6 +2,8 @@ Status: implemented +English | [中文](2026-07-05-windows-jsonl-durable-publish.zh.md) + ## Problem `dsh-session-persistence-jsonl` publishes a session log lazily on the first append. The POSIX protocol writes a temp file, fsyncs it, links it to the final name, fsyncs the parent directory, and then removes the temp link. The parent-directory fsync is part of the durability contract: a crash after the namespace change must not lose the committed final name while leaving callers believing the session log materialized. diff --git a/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.zh.md b/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.zh.md new file mode 100644 index 0000000000..8dc77a0ab1 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.zh.md @@ -0,0 +1,35 @@ +# Agent Note: Windows 原生持久 JSONL 发布 + +Status: implemented + +[English](2026-07-05-windows-jsonl-durable-publish.md) | 中文 + +## 问题 + +`dsh-session-persistence-jsonl` 在首次追加时延迟发布会话日志。POSIX 协议会写入临时文件,对其执行 fsync,将其链接至最终名称,对父目录执行 fsync,然后移除临时链接。对父目录执行 fsync 是持久性契约的一部分:命名空间变更后发生崩溃时,已经提交的最终名称不能丢失,否则调用方会误以为会话日志已经物化。 + +Windows 具备原子命名空间操作,但 Node 没有暴露与 POSIX 等价的父目录 fsync 契约。如果把 Windows 目录同步失败视为成功,就会在无提示的情况下削弱持久化后端。因此,Windows 路径需要采用不同的发布原语,而不是在 POSIX 的 `syncDir` 辅助函数中添加条件分支。 + +## 决策 + +JSONL 后端会在 `materialize()` 内部、任何命名空间变更之前分流。共享代码计算会话目录、最终日志路径,以及编码后的 header 和初始事件批次;随后 POSIX 与 Windows 分别执行各自的发布协议。 + +POSIX 保留现有协议:创建根目录、项目目录与会话目录,并对其父目录执行 fsync;写入临时文件并对其执行 fsync;使用 `link()` 发布,确保绝不覆盖已有的最终日志;对会话目录执行 fsync;最后移除多余的临时硬链接。 + +Windows 通过持久的暂存发布来创建缺失目录:在固定的 `.dsh-mkdir-` 前缀下创建一个随机同级目录,其名称与目标基本名无关;随后使用 `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` 将其发布为最终目录名称,且不使用 `MOVEFILE_REPLACE_EXISTING` 或 `MOVEFILE_COPY_ALLOWED`。文件物化先写入临时日志并对其执行 fsync,再以同一个启用写穿透的 `MoveFileExW` 调用将临时文件发布到最终路径,并且同样不允许替换。`koffi` 是覆盖这组 API 所需的最小 Win32 桥接层;`pnpm-workspace.yaml` 允许执行它的安装脚本,因为该包(package)会分发原生 loader 和预构建的平台模块。 + +## 考虑过的替代方案 + +**忽略 Windows 目录同步失败。** 不予采纳,因为这会在没有强制将已发布的命名空间条目写入稳定存储时,就把首次追加报告为持久化成功。 + +**使用 `CreateHardLinkW`。** 不予采纳,因为硬链接依赖文件系统、不能发布目录,并且没有提供写穿透选项。 + +**使用替换或事务型 API。** `ReplaceFileW` 的替换语义与拒绝同一 id 冲突的要求相悖,而新应用设计不应使用 Transactional NTFS。 + +## 影响 + +该后端在各平台上维持同一项外部契约:首次追加要么把完整日志发布到最终名称,要么失败且不覆盖已有日志。平台分流只是实现细节;`SessionPersistence` API 和 JSONL 逻辑记录格式均不改变。后续的 [Zstandard 编码决策](2026-07-19-zstandard-jsonl-session-logs.md)会先作用于不透明字节,然后才由任一平台执行发布。 + +Windows 测试会在原生 Windows 上执行真实的 Win32 发布路径。断电行为属于 API 契约属性,单元测试无法证明;可测试的不变量包括:Windows 物化不会调用目录 fsync、最终路径冲突会失败、达到最大长度的目标路径组件仍可物化、临时日志在发布前已经执行 fsync,并且生成的日志可以正常加载。 + +两个平台的追加和修复仍使用普通文件句柄 fsync。追加失败后,系统会关闭仅追加句柄,以读写模式重新打开日志,将文件截断到追加前的大小,并对回滚结果执行 fsync,因为 Windows 不允许在仅追加句柄上调用 `ftruncate`。 diff --git a/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.i18n.yaml new file mode 100644 index 0000000000..6663606c3c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.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 +2026-07-06-tool-result-retention-library.md: 5e42660360e5a23b419c75b9c8006bec459bc322 +2026-07-06-tool-result-retention-library.zh.md: 6e824667f17b361efb57b173c44f489da2cab3b3 diff --git a/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md index 2962f1006e..5e42660360 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md +++ b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md @@ -2,6 +2,8 @@ Status: implemented +English | [中文](2026-07-06-tool-result-retention-library.zh.md) + ## Problem Several model-facing tools already bound the amount of context they return, but each one owns a different local mechanism and vocabulary: bash keeps a tail plus spill files, web search caps source lists, web fetch caps body content, and `glob` / `grep` discovery needs an inline first page while keeping exact omission metadata for the full result set. A single `truncate(text)` helper cannot cover those cases: item tools need item counts and grouping outside the primitive, while text tools need byte budgets and UTF-8-safe head/tail cuts. diff --git a/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.zh.md b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.zh.md new file mode 100644 index 0000000000..6e824667f1 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.zh.md @@ -0,0 +1,157 @@ +# Agent Note: 工具结果保留库 + +Status: implemented + +[English](2026-07-06-tool-result-retention-library.md) | 中文 + +## 问题 + +多个面向模型的工具已经限制其返回的上下文量,但每个工具都拥有不同的局部机制和词汇:bash 保留尾部并提供落盘文件;web search 限制来源列表;web fetch 限制正文内容;`glob`/`grep` 发现工具需要在行内提供第一页,同时为完整结果集保留精确的省略元数据。单一的 `truncate(text)` 辅助函数无法覆盖这些情况:条目型工具需要条目计数,并在原语之外分组;文本型工具则需要字节预算和 UTF-8 安全的首尾裁切。 + +这些工具需要共享的抽象是**保留**,而不是通用集合。调用方向一个有界对象输入条目或文本分片,稍后取得保留内容与精确的省略元数据。工具专用代码仍负责业务语义:文件分组、行号、退出码、提供方错误状态、落盘文件和面向模型的说明。公共库只负责一个机械问题:「保留了什么,又省略了什么?」 + +## 决策 + +`@deepseek-ai/dsh-retention` 位于 `packages/util/` 下,与 `dsh-brand` 和 `dsh-timeout` 同级,负责有界的模型可见输出。它是一组纯类与函数构成的库,**不是** Cordis 服务或插件:不接收 `ctx`、不注册任何内容、不持有跨调用状态,也不发出事件。各工具包(package)需要限制输出时直接导入它。 + +该库包含两个相互独立的 retainer: + +- `ItemRetainer` 处理有序逻辑单元,例如路径、grep 匹配项或搜索来源。v1 只支持 `head` 保留,同时维持 retainer 形态,以便未来加入其他保留策略。 +- `TextRetainer` 处理面向字节的文本流,例如 bash stdout/stderr 或 web 响应正文。它支持 `head`、`tail` 和 `headTail` 保留,并在 `finish()` 时维持 UTF-8 边界。 + +两个 retainer 都会返回一个小型 `PushDecision`;每次调用 `push()` 后,调用方都能得知该单元/分片是否完整保留,以及累积结果此时是否已被截断。因为调用方会继续输入每一个已观察到的条目/分片,所以省略计数是精确的。 + +```ts ignore-check +/** + * How much content the retainer omitted. + * + * `unknown` is reserved for callers that omit without a count; the retainers + * themselves return `none` or `exact`. + */ +type Omitted = + | { kind: 'none' } + | { kind: 'exact'; count: number } + | { kind: 'unknown' } + +interface PushDecision { + kept: boolean + truncated: boolean +} + +/** + * Final result for ordered logical units. + */ +interface RetainedItems { + items: T[] + truncated: boolean + seen: number + kept: number + omitted: Omitted +} + +/** + * Final result for text streams. + * + * The returned `text` is safe to send to a formatter; the retainer does not add + * tool-specific headers, exit markers, XML tags, or recovery instructions. + */ +interface RetainedText { + text: string + truncated: boolean + omittedBytes: Omitted +} +``` + +### 策略 + +条目保留支持头部窗口。文本保留支持头部、尾部与首尾字节窗口。 + +```ts ignore-check +type ItemRetentionStrategy = + | { + /** Keep the first `maxItems` units. Use for `glob`, `grep`, and web sources. */ + kind: 'head' + maxItems: number + } + +type TextRetentionStrategy = + | { + /** Keep the first `maxBytes` bytes. */ + kind: 'head' + maxBytes: number + } + | { + /** Keep the final `maxBytes` bytes. Requires reading to the end. */ + kind: 'tail' + maxBytes: number + } + | { + /** Keep a stable prefix and suffix, omitting the middle. Requires reading to the end. */ + kind: 'headTail' + headBytes: number + tailBytes: number + } +``` + +### 工具映射 + +`read` 被有意排除在 v1 保留库之外。它的 `read-render` 辅助函数拥有文件专用的分页契约:`offset`/`limit`、行号、`totalLines`、offset 越界错误、逐行预览截断,以及能够在窗口中途停止扫描的所选输出字节上限。这是行窗口渲染器,不是通用保留原语。它未来可以共享中性的提示辅助函数,但不应把已经选定的窗口再传入 `ItemRetainer`。 + +下文的 `FsGlobEntry` 与 `FlatGrepMatch` 是预期由发现工具使用的条目形态,不是现有保留库的导出。`FsGlobEntry` 是一个由后端派生的路径;`FlatGrepMatch` 是后端将保留匹配项按文件分组之前的一条未分组 grep 匹配。 + +`glob` 收集完整的排序路径列表后,使用 `ItemRetainer`,并将其配置为 `{ kind: 'head', maxItems: globMaxResults }`。工具在行内保留第一页,并可以通过落盘 seam 保存完整列表。路径映射、跳过的候选项与 `incomplete` 均位于 retainer 之外。 + +`grep` 在分组前使用 `ItemRetainer`,并将其配置为 `{ kind: 'head', maxItems: grepMaxMatches }`。执行器解析 ripgrep 输出、映射路径、应用逐行预览截断,并输入扁平匹配项。调用 `finish()` 后,工具按文件对保留的匹配项分组;如果行内结果达到上限,还可以通过落盘 seam 保存完整匹配列表。分组不属于 retainer,因为上限针对匹配总数,而不是文件数;逐匹配项的预览截断和 `incomplete` 也与结果级保留相互独立。 + +`bash` 可以使用 `TextRetainer`,配置为 `tail` 或 `headTail`,并读取至进程结束。bash 执行器仍负责落盘文件、退出状态、信号、超时与后台任务行为;保留辅助函数只在需要该行为时替换临时实现的内存首尾核算。长时间运行任务的所有权与[通用长时间运行工具的运行时](2026-06-20-generic-long-running-tool-runtime.md)相互独立。 + +`web_fetch` 可以使用 `TextRetainer`,配置为 `head` 或 `headTail`;如果提供方必须在内部读取和解码,也可以保留由提供方负责的正文上限。无论采用哪种方式,fetch 结果中的 `truncated` 仍是提供方/工具事实,该库只提供保留文本与省略元数据。 + +`web_search` 可以使用 `ItemRetainer`,配置为 `head`。当前提供方通常返回数组,所以这属于事后处理,但仍能统一提示信息。 + +### 提示 + +该库公开一个中性的提示结构和一个小型格式化钩子,但面向用户的措辞由工具提供。grep 页脚会提示「缩小 pattern、path 或 include」;web fetch 页脚会提示「获取更具体的 URL 或章节」;bash 则可以指向落盘文件。retainer 无法得知这些恢复操作。 + +```ts ignore-check +interface RetentionNotice { + scope: string + strategy: 'head' | 'tail' | 'headTail' + unit: 'items' | 'bytes' | 'chars' | 'lines' + limit: number | { head: number; tail: number } + kept: number + omitted: Omitted +} + +const formatGrepNotice = (notice: RetentionNotice): string => + formatRetentionNotice( + notice, + ({ kept }) => `Results capped at ${kept}. Narrow the pattern, path, or include to see more.`, + ) +``` + +格式化钩子刻意保持精简:工具把 `RetentionNotice` 转换为自己的页脚文本。辅助函数可以统一省略措辞,但不负责恢复指引。 + +`truncated` 表示 retainer 因预算省略了原本可用的内容,不表示上游结果不完整。工具会为权限失败、跳过的二进制文件、提供方局部失败、不可读候选项、无效 UTF-8,以及其他任何「无法检查」状况保留独立字段。 + +## 影响 + +**已交付内容。** `@deepseek-ai/dsh-retention` 导出 `ItemRetainer`、`TextRetainer`、结果类型(`RetainedItems`、`RetainedText`)、策略类型(`ItemRetentionStrategy`、`TextRetentionStrategy`)、`Omitted`、`PushDecision`、`RetentionNotice`,以及中性的提示辅助函数 `describeOmitted`/`formatRetentionNotice`,且不依赖 Cordis 或任何工具包。单元测试覆盖具有精确省略计数的条目头部保留、文本头部保留、文本尾部保留、首尾字节保留、零预算、UTF-8 边界处理(2、3、4 字节码位,以及每个裁切位置上的无效起始字节)和未知省略量的措辞。 + +**已记录但尚未迁移的内容。** `glob`、`grep`、`bash`、`web_fetch` 与 `web_search` 的映射已记录在[包 README](../../../../packages/util/retention/README.md) 中,但本次改动并未把每个工具都迁移到该库;迁移工作刻意留作独立的后续任务。`read` 被明确记录为不在范围内:其 `read-render` 行窗口契约(`offset`/`limit`、`totalLines`、offset 范围错误、逐行预览截断,以及针对所选窗口的字节上限)不属于通用保留,而一个 `Omitted` 计数也无法同时表达行窗口两侧。 + +**该库维持的边界。** `truncated` 表示 retainer 因预算省略了原本可用的内容,绝不表示上游不完整。工具专用状态,包括 `incomplete`、权限失败、提供方局部失败、跳过二进制文件、bash 落盘路径恢复和无效 UTF-8,均留在工具领域字段中、位于 retainer 之外。未来改动迁移某项工具时,该包的 README 与测试必须证明,除了有意改变的提示措辞外,模型可见的结果文本没有变化。 + +**接受的取舍。** v1 接口刻意只支持条目的 `head` 保留,以及文本的 `head`/`tail`/`headTail` 保留;窗口、分组预算、感知排序的上限和上游停止控制,要等第二个消费方证明需求后再引入。文本保留按字节计数,以保障进程/正文安全;字符级和行级预览预算继续由具体工具负责。 + +## 考虑过的替代方案 + +**只进行事后 `truncate(text)`。** 不予采纳:它适合 Codex 的历史/工具输出截断场景,却会丢失条目计数、分组边界、UTF-8 安全的字节窗口与精确省略元数据。 + +**使用一个带可插拔回调的通用 `Collector`。** v1 不予采纳,因为它会掩盖两种重要的资源模式。逻辑条目保留按条目计数;文本保留按字节计数并维持 UTF-8 边界。独立的 `ItemRetainer` 与 `TextRetainer` 名称明确表达这种差异,同时保持 API 精简。 + +**把 `read` 窗口交给 `ItemRetainer`。** v1 不予采纳:`read` 是当前唯一的窗口消费方,其语义属于文件分页,而不是通用保留。一个 `Omitted` 计数无法表示行窗口两侧,而且 `read` 还携带 `totalLines`、offset 范围错误、逐行预览截断和针对所选输出的字节上限。让 `read-render` 由工具所有,可以避免共享库围绕一项特例膨胀。 + +**让截断成为 `ToolExecutionResult` 的一部分。** 不予采纳:工具注册表将不得不理解工具专用的恢复指引、分组、行号、退出状态和提供方语义。保留是由工具的 Native renderer 使用的库;模型可见投影继续由工具所有,而[规范值](2026-07-20-canonical-tool-output-contract.md)可以保留完整的已采集结果。 + +**在每个面向模型的工具 schema 中公开上限。** 不作为默认方案:Claude Code 的 grep 公开 `head_limit`/`offset`,但本 harness 会把常规预算保留为部署配置,除非模型确实需要控制分页。未来可以为具体工具增加类似 read 的续传字段;它不属于共享保留原语。 diff --git a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml new file mode 100644 index 0000000000..c241ef4ece --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.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 +2026-07-08-tool-output-spill-files.md: 7c0ca90452645d251559be25108d12883210d00e +2026-07-08-tool-output-spill-files.zh.md: 917d710eb8650e2797287578edd1b0d62813bbd3 diff --git a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md index e2df9a902b..7c0ca90452 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md +++ b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md @@ -2,6 +2,8 @@ Status: implemented +English | [中文](2026-07-08-tool-output-spill-files.zh.md) + ## Problem Tool outputs need bounded model-facing previews, but some oversized results are still useful later. A fetched page body or a verbose tool response should not consume the next model request in full, but the model should be able to inspect the complete formatted result later with existing file-reading tools. diff --git a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md new file mode 100644 index 0000000000..917d710eb8 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md @@ -0,0 +1,195 @@ +# Agent Note: 工具输出落盘策略 + +Status: implemented + +[English](2026-07-08-tool-output-spill-files.md) | 中文 + +## 问题 + +工具输出需要有界的模型可见预览,但部分超大结果仍可能在之后有用。抓取的页面正文或冗长的工具响应不应完整占用下一次模型请求,但模型应能使用现有文件读取工具,在之后查看经过格式化的完整结果。 + +这项改动之前的行为并不一致。`dsh-bash-local` 已经会在内存尾部溢出时,把完整 stdout/stderr 流写入私有的临时落盘文件;普通文本工具结果则仍以内联形式返回,除非工具自行临时实现上限。[工具结果保留库](2026-07-06-tool-result-retention-library.md)负责预览机制,但不负责存储,也不负责把这些机制应用于最终工具结果的执行流水线策略。 + +其形态与超时策略设计一致:工具作者声明规范值与 Native renderer,由策略插件在渲染后的内容上执行部署默认的上下文预算。工具仍可在提供方采集上限处提前落盘;由工具负责的展示落盘可以保留已完整采集的规范值,而只替换展示内容。[规范工具输出契约](2026-07-20-canonical-tool-output-contract.md)规定了这项区分。 + +## 决策 + +在新的 `packages/spill/` 分组下增加一层轻量落盘存储 seam 和一个默认落盘策略插件: + +| 包(package) | 角色 | +|---|---| +| `@deepseek-ai/dsh-spill` | 接口:`ctx.spillStore`、词汇类型,不包含存储实现。 | +| `@deepseek-ai/dsh-spill-local` | 本地后端:在宿主文件系统中提供私有、会话作用域的文件存储。 | +| `@deepseek-ai/dsh-spill-policy` | 工具结果策略插件:包装分发后的最终文本结果,并以保留预览和落盘定位符替换超大结果。 | + +系统不增加专用的面向模型消费方包。消费方是现有 `ctx.tools` 执行流水线:`dsh-spill-policy` 通过 `tools/post-execute` waterfall(瀑布式事件)使用最终工具结果,模型则按照后端随定位符返回的检索提示读取内容。 + +### 落盘 seam + +存储 seam 保持最小化:保存文本,并返回定位符与检索提示。 + +```ts ignore-check +interface SpillStore { + saveText(input: SaveTextSpill): Promise +} + +interface SpillSource { + toolName: string + callId: CallId + label: string +} + +interface SaveTextSpill { + owner: { sessionId: SessionId } + source: SpillSource + suggestedName: string + content: string +} + +type SpillLocator = Branded<'SpillLocator'> + +interface SpillRef { + locator: SpillLocator + bytes: number + retrievalHint: string +} +``` + +`SpillLocator` 是一个[品牌化的](../../../../packages/util/brand)模型可见句柄,由后端返回。本地后端将其渲染为文件系统路径;远程或数据库后端可以渲染 URI、键或命令 token。消费方把它视为不透明值,并使用 `retrievalHint` 渲染,而不是假定 `read` 始终是正确的检索机制。`SpillOwner.sessionId` 是保存时的存储命名空间:fork 后的会话会从种子日志继承已有的落盘定位符,无需复制它们或重新取得所有权;fork 后的新落盘使用子会话 id。保留期清理可以连同其他旧会话产物一起使旧定位符失效;落盘 seam 不定义逐会话的清理策略。 + +`dsh-spill-local` 只负责存储细节:选择会话作用域的目录、安全名称、防止路径遍历、执行写入,以及返回 `{ locator, bytes, retrievalHint }`。它不负责保留策略、工具结果替换、搜索或文件检查。文件写入 `/session-/-`:`root` 是配置路径,或延迟创建的私有(0700)进程级临时目录;会话子目录是 `sha256(sessionId)` 的短前缀;叶节点由随机十六进制前缀与调用方的 `suggestedName` 组成,后者会被清理成单一路径段(与 JSONL 后端的 `encodeSegment` 一致)。系统使用 `open(path, 'wx', 0o600)` 写入,确保独占且仅所有者可访问,因此预先植入的符号链接无法重定向写入。定位符就是该路径,检索提示则告知模型可以在该路径上使用 `read` 或 `grep`。 + +### 落盘策略 + +`dsh-spill-policy` 是一个 `tools/post-execute` 结果转换器,只提供一个配置项: + +```ts ignore-check +interface Config { + /** Omitted means no automatic spill policy. Present means apply to oversized plain text tool results. */ + maxInlineBytes?: number +} +``` + +省略 `maxInlineBytes` 时,插件不会注册任何内容,是真正的无操作。设置该值后,它会对最终的纯文本工具结果应用默认策略: + +1. 让工具正常运行,通过 `next()` 委托,使下游监听器先结算结果。 +2. 仅当已接受的最终 `ContentBlock[]` 全部是纯文本时,才将其展平;含任何非文本块的结果保持不变。 +3. 如果 UTF-8 字节大小不超过 `maxInlineBytes`,保持不变。 +4. 如果超出上限,使用完整的最终文本调用 `ctx.spillStore.saveText()`。 +5. 把模型可见结果替换为保留的首尾预览和落盘引用。 + +预览属于策略所有的实现默认值:以 `maxInlineBytes` 为上限,使用保留库的 `TextRetainer` 进行首尾分割。只有第二种部署证明有此需求后,未来配置才会公开预览大小。 + +替换文本刻意保持通用,因为策略只知道最终格式化的工具结果,不了解工具的内部资源: + +```text + + +(Omitted N bytes. Full formatted result stored at: /.../session-.../....txt. Use read with offset/limit, or grep this path to search within it.) +``` + +如果 `ctx.spillStore.saveText()` 失败(权限、ENOSPC、后端不可用),或调用没有会话所有者,或未加载后端,插件会记录原因并原样返回结果。落盘失败绝不会把成功的工具调用变为 `isError` 结果,也不会隐藏内联结果。 + +策略跳过 `read`,以避免形成 `read -> spill file -> read again` 循环。额外的选择退出配置要等确实出现第二个有此需求的工具后再引入。 + +## 示例:web_fetch + +`web_fetch` 是首个示例,因为它天然会返回较大的文本结果,而且无需工具专用的落盘代码。该工具本身无需特殊处理: + +```ts ignore-check +ctx.tools.register(defineTool({ + name: 'web_fetch', + output: { + schema: WEB_FETCH_RESULT_SCHEMA, + render: (_args, value) => [{ type: 'text', text: formatFetchOutput(value) }], + }, + async execute(args, exec) { + const result = await ctx.web.fetch({ url: args.url }, exec.signal ? { signal: exec.signal } : undefined) + return result + }, +})) +``` + +配置 `dsh-spill-policy` 后,格式化后的大型 fetch 结果会自动保留并落盘。部署通过把提供方资源上限设得高于策略上限来展示此行为: + +```yaml +- id: web-fetch-local + name: '@deepseek-ai/dsh-web-fetch-local' + config: + maxBodyChars: 500000 + +- id: spill-local + name: '@deepseek-ai/dsh-spill-local' + +- id: spill-policy + name: '@deepseek-ai/dsh-spill-policy' + config: + maxInlineBytes: 50000 +``` + +这项分离很重要。`web-fetch-local` 仍负责资源上限(`maxResponseBytes`、`maxBodyChars`),用来保护网络、内存和解码工作。`spill-policy` 只负责结果已经存在后针对模型上下文的上限。如果提供方已经返回 `truncated: true`,落盘文件包含的是工具返回的完整格式化结果,而不是原始网页全文;策略不会做出其他承诺。 + +## 与保留和提前落盘的关系 + +保留与落盘存储相互独立: + +- `@deepseek-ai/dsh-retention` 负责预览机制(`TextRetainer`、`ItemRetainer` 和省略元数据)。 +- `@deepseek-ai/dsh-spill` 负责保存最终文本,并返回定位符与检索提示。 +- `@deepseek-ai/dsh-spill-policy` 在工具流水线中应用默认的最终结果策略,将前两者组合起来。 + +最终结果策略不能取代由工具负责的提前落盘。部分有用内容并不存在于最终 `ToolExecutionResult.content` 中: + +- `bash` 的最终输出已经是尾部内容加临时落盘路径;完整的 stdout/stderr 流位于执行器文件中。 +- `subagent` 的最终输出是子 agent(智能体)的最终回答,而不是子 agent 的执行轨迹。 +- 未来的工具可能生成从未出现在最终 `ToolExecutionResult.content` 中的运行时产物。 + +这些场景可以在后续工作中直接使用 `ctx.spillStore`,不属于首个示例的范围。 + +## 非目标 + +- v1 不增加面向模型的 `artifact_read` 或 `artifact_search` 工具。 +- v1 不增加逐工具的保留配置。 +- 不增加面向模型的超时/截断参数。 +- 不把 `read` 输出迁移到落盘文件。 +- 不取代 `web-fetch-local.maxBodyChars` 等提供方/资源上限。 +- 第一版不统一 bash 临时文件,也不采集 subagent 执行轨迹。 + +## 延后事项 + +- 用于现有执行器落盘文件的 `saveFile()`/`linkOrCopy`,这是统一 bash 行为所必需的。 +- 由工具负责的 subagent 执行轨迹落盘(`await run.result`,在 `run.dispose()` 前读取进程内子会话,保存 JSONL)。 +- 如果内置的 `read` 跳过规则不足,再增加逐工具退出或逐工具策略声明。 +- 面向 ACP(Agent Client Protocol)或远程环境的远程/数据库存储后端,因为本地路径在这些环境中没有意义。 +- 旧落盘文件的清理和保留策略,很可能与会话清理绑定。 + +## 测试 + +- `dsh-spill` 单元测试锁定 seam 契约:注册为 `ctx.spillStore`、每个上下文只允许一种实现,并在 dispose(资源释放)时释放。 +- `dsh-spill-local` 单元测试覆盖 `saveText`、`encodeSegment` 清理(分隔符/波浪号/完整路径段的点/空值)、会话哈希目录、仅所有者权限、每次保存生成不同路径、配置根目录/私有根目录,以及存储失败时的拒绝。 +- `dsh-spill-policy` 单元测试通过 `ctx.tools.execute` 驱动真实工具:禁用模式下无操作、替换超大文本、小结果/非文本结果保持不变、跳过 `read`、尽力回退(保存失败/无后端/无所有者),以及下游组合(限制已替换结果、保留 `additionalContexts`)。 +- `dsh-tool-web` 集成测试驱动 `web_fetch`,其实际执行路径经过 `ctx.tools.execute`,并使用真实的 `spill-local` 后端与策略;测试证明只有刻意加入的落盘提示会改变模型可见文本,而落盘文件保存完整的格式化结果。 +- `tui-agent` 示例加载 `spill-local` 与 `spill-policy`,因此其无密钥 Loader/PTY 冒烟测试会执行真实加载路径(namespace-plugin 导出形态与 `inject`)。 + +## 影响 + +默认策略只能看见最终格式化文本。它无法保留已经由提供方限制的内部内容,也无法保留从未成为结果一部分的运行时产物。第一版聚焦最终结果落盘而不是提前落盘,因此可以接受这一限制;由工具负责的提前落盘仍属于后续工作。 + +本地后端返回真实路径,使 v1 保持简单并符合已经验证的 agent 工具行为;seam 本身只承诺一个不透明定位符加检索提示,所以远程后端可以返回非文件定位符。 + +本地后端的价值取决于现有 `read`/`grep` 工具能否检查返回的本地路径,即使落盘目录位于会话 cwd 之外。目前这一条件成立,因为文件系统策略会记录观察结果并设置写保护,但不会把读取限制在工作区内。未来的工作区限制策略必须显式允许本地落盘路径,或改用检索提示指向受支持读取器的非文件落盘后端。 + +**快照缺口。** 目前没有 ACP 快照场景覆盖 transcript(文本记录)可见的 `web_fetch` 落盘提示。ACP 快照 harness 在无密钥环境中回放,无法访问实时 web,而 `web_fetch` 落盘需要一个真实的超上限 HTTP 正文;确定性场景需要一个预置的 loopback fetch 目标,但当前回放树尚未接线(示例根本没有加载 `tool-web`)。该行为改由 `dsh-tool-web` 针对 loopback server 的集成测试覆盖。弥补该缺口属于后续工作:把 `tool-web` 和预置 fetch 目标接入 ACP 示例,然后录制 `web-fetch-spill` 场景。 + +如果策略开始负责工具专用语义,就会膨胀得过大。它必须保持狭窄:只处理纯文本最终结果。由工具负责的提前落盘仍留作未来工作。 + +## 考虑过的替代方案 + +**要求每个工具通过保留声明选择加入。** v1 不予采纳,因为目标是实现类似 Claude Code 通用工具结果持久化的默认行为。只需一个 `maxInlineBytes` 部署配置项即可验证该形态。 + +**把 `tool-results` 建成宽泛的工具结果平台。** 不予采纳:宽泛的包名会诱使系统把保留策略、结果替换、预览措辞、搜索和提前落盘合并进一个 seam。可共享的存储部分更小:保存文本,并返回定位符与检索提示。 + +**使用 `ctx.fs.writeText` 或面向模型的 `write` 工具。** 不予采纳:工作区文件系统写入带有项目文件语义、写入/编辑策略、观察状态和面向用户的副作用。落盘文件是运行时产物,不是由模型编写的工作区改动。现有 `read` 工具之后可以检查它们,但创建操作属于运行时落盘 seam。 + +**让 `web-fetch-local` 不受限地抓取,只依靠 spill-policy。** 不予采纳:spill-policy 在最终工具结果已经存在之后才运行,无法保护网络、内存或解码资源。提供方资源上限仍然必须存在。 + +**把保留合并进落盘机制。** 不予采纳:保留与落盘职责不同。`TextRetainer`/`ItemRetainer` 决定保留哪部分预览、又省略了什么;落盘存储只负责保存策略要求的最终文本。 diff --git a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.i18n.yaml index 16852c1004..19a6b628c3 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.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 -2026-07-20-unified-json-value-schema-dsl.md: 09945c413ffe5924c74076648cdf3da60c3e18c9 -2026-07-20-unified-json-value-schema-dsl.zh.md: 00a7a199613ea857a7815f1c7794781f143a3896 +2026-07-20-unified-json-value-schema-dsl.md: 5de3523eab15a91ea32dc09e2e239146fadea6f1 +2026-07-20-unified-json-value-schema-dsl.zh.md: 321136c31a6aa6c0268150fcde2d97dcdbb0ac58 diff --git a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.md b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.md index 09945c413f..5de3523eab 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.md +++ b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.md @@ -21,6 +21,7 @@ Object-rooting is a consumer rule rather than a vocabulary restriction. Subagent ## Alternatives considered - **Keep separate parameter and structured-output schema systems:** rejected because every added output construct would require parallel inference, compilation, validation, and code-generation changes with no useful ownership boundary. +- **Use Schemastery for tool parameters:** rejected because Schemastery targets validation and transformation through Standard Schema rather than JSON Schema generation. It would add an adapter layer without producing the model-facing wire schema or the shared output vocabulary. - **Adopt full JSON Schema or Ajv:** rejected because the harness must fail on every construct it cannot project into its generated SDK and validators; accepting a larger language would make enforcement and model guidance dishonest. - **Make every object implicitly open or closed:** rejected because either choice hides a consequential author decision. Only the legacy-shaped implicit parameter root and external raw schema retain an intentional default. - **Define `oneOf` as first-match:** rejected because branch ordering would change validation semantics and allow overlapping branches to hide ambiguous values. @@ -32,4 +33,5 @@ Object-rooting is a consumer rule rather than a vocabulary restriction. Subagent - Explicit object openness and type-correct literal constraints make malformed declarations fail during authoring or registration rather than during a later model call. - Bounded type inference retains useful exact types for ordinary declarations and degrades unusually deep tails to `JsonValue`; runtime schema enforcement remains exact at every depth. - Raw tools may still register broader JSON Schema directly, but unified code generation treats unsupported schemas as unknown instead of pretending to enforce them. +- Per-property `required: true` remains the tool-author contract, and type-level regression coverage pins required keys as non-optional after the original inference path exposed an optionality bug. - Runtime and compile-time tests cover every root, exact-one overlap/no-match behavior, raw open defaults, explicit openness, lossy JSON values, inference, deep nesting across core and dynamic projections, JSON-invisible dynamic keys, and exotic schema arrays. diff --git a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.zh.md b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.zh.md index 00a7a19961..321136c31a 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.zh.md @@ -21,6 +21,7 @@ Status: implemented ## 备选方案 - **保留两套独立的参数与结构化输出 schema 系统:**不予采纳。每新增一种输出结构,都必须分别修改类型推导、编译、校验和代码生成,而这种重复并未形成有意义的职责边界。 +- **使用 Schemastery 处理工具参数:**不予采纳。Schemastery 通过 Standard Schema 面向校验与转换,而不是生成 JSON Schema。采用它会增加一层适配器,却不能产出面向模型的协议 schema 或共享的输出词汇。 - **采用完整 JSON Schema 或 Ajv:**不予采纳。harness 必须拒绝所有无法投影到生成 SDK 和校验器中的结构;如果接受更大的语言子集,强制执行能力和模型指引就会与事实不符。 - **让所有对象默认开放或默认封闭:**不予采纳。这两种选择都会隐藏一项影响重大的作者决策。只有保持旧有形态的隐式参数根对象和外部原始 schema 才有意保留默认值。 - **把 `oneOf` 定义为首个匹配分支:**不予采纳。这样一来,分支顺序会改变校验语义,重叠分支也会掩盖值的歧义。 @@ -32,4 +33,5 @@ Status: implemented - 显式的对象开放方式和类型正确的字面量约束会让格式错误的声明在编写或注册阶段快速失败,而不是拖到后续模型调用时才失败。 - 有界类型推导会为常规声明保留有用的精确类型,并将异常深的尾部结构退化为 `JsonValue`;运行时 schema 强制执行在任意深度仍保持精确。 - 原始工具仍可直接注册范围更广的 JSON Schema,但统一代码生成会把不受支持的 schema 视为未知类型,不会假装自己能够强制执行。 +- 每个属性的 `required: true` 仍是工具作者契约;原有推导路径暴露可选性缺陷后,类型级回归覆盖会锁定必填键不得为可选。 - 运行时和编译期测试覆盖所有根类型、恰好匹配一个分支时的重叠/无匹配行为、原始 schema 的默认开放语义、显式开放方式、有损 JSON 值、类型推导、核心投影和动态投影中的深层嵌套、动态注册中 JSON 不可见的键,以及非普通 schema 数组。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.i18n.yaml index a813a94c95..dbd259b67a 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.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 -2026-07-19-windows-atomic-write-dacl-preservation.md: 013119508da9be426c417797cf7a0ec14e276814 -2026-07-19-windows-atomic-write-dacl-preservation.zh.md: 8ae82884c3b80409d07d3bbcfc8c273e8b227dc8 +2026-07-19-windows-atomic-write-dacl-preservation.md: be9f82174300a7d605c6a6e63878728f08cb37be +2026-07-19-windows-atomic-write-dacl-preservation.zh.md: fc6ec5232c992f3a230ee0de89439c869b8b46f9 diff --git a/.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md b/.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md index 013119508d..be9f821743 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md +++ b/.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md @@ -6,13 +6,13 @@ English | [中文](2026-07-19-windows-atomic-write-dacl-preservation.zh.md) ## Problem -On Windows, creating the staging directory and temp file under the target's parent and relying only on inherited DACLs is sufficient for a new file, but not for replacing an existing file whose explicit or protected DACL is narrower than its parent: content is written under the broader parent DACL, and rename carries that staging descriptor onto the replacement. +Atomic writes protect POSIX staging directories with `0o700` and temp files with `0o600`, but Windows mode bits expose only a synthetic read-only view of the actual DACL. Creating staging under the target's parent and relying on inheritance is sufficient for a new file, but not for replacing an existing file whose explicit or protected DACL is narrower than its parent: content is written under the broader parent DACL, and rename carries that staging descriptor onto the replacement. ## Decision -`dsh-fs-local` reads an existing target's DACL with `GetFileSecurityW`, applies it to the empty temp file with inheritance protected before writing content, and publishes the closed temp with `ReplaceFileW`. The protected staging descriptor prevents the temp directory's inherited entries from broadening access; `ReplaceFileW` preserves the original target access policy and other replacement metadata. Its ACL merge may reserialize auto-inheritance state or duplicate equivalent ACEs, so self-relative descriptor buffers are not a stable equality contract. New files have no prior descriptor to preserve and continue to inherit the destination directory's DACL. +`dsh-fs-local` reads an existing target's DACL with `GetFileSecurityW`, applies it to the empty temp file with inheritance protected before writing content, and publishes the closed temp with `ReplaceFileW`. The protected staging descriptor prevents the temp directory's inherited entries from broadening access; `ReplaceFileW` preserves the original target access policy and other replacement metadata. Its ACL merge may reserialize auto-inheritance state or duplicate equivalent ACEs, so self-relative descriptor buffers are not a stable equality contract. New Windows files have no prior descriptor to preserve and continue to inherit the destination directory's DACL; their staging directory therefore lives beside the target. POSIX keeps the owner-only staging modes and preserves an existing target mode. -Native Windows coverage protects a target DACL, inspects the written staging file, and compares the final replacement's ordered, de-duplicated ACE policy. Host-independent binding tests cover Win32 error translation and every native call boundary. +Native Windows coverage protects a target DACL, inspects the written staging file, and compares the final replacement's ordered, de-duplicated ACE policy. Host-independent binding tests cover Win32 error translation and every native call boundary. Mode-bit assertions remain POSIX-only; new-file DACL inheritance is an operating-system contract rather than a machine-specific account allowlist. ## Alternatives considered @@ -22,6 +22,10 @@ Native Windows coverage protects a target DACL, inspects the written staging fil **Install an owner-only DACL for every write.** Rejected because it would discard deliberate project sharing. Copying the target DACL preserves the deployment's existing access policy instead of inventing one. +**Assert inherited accounts with `Get-Acl` or `icacls`.** Rejected because such a test verifies machine policy rather than package behavior, and localized well-known account names make the output unstable across hosts. + +**Skip the existing `chmod` calls on Windows.** Rejected because Node maps these writable modes to benign no-ops; platform guards add branches without changing DACL behavior. + ## Consequences -Replacing a Windows file now requires permission to read the target DACL and set the temp DACL; failure is loud before content is written. The package carries Koffi for the narrow Win32 calls, loaded only on Windows replacement paths. New-file behavior remains directory-inherited, and POSIX mode behavior is unchanged. +Replacing a Windows file now requires permission to read the target DACL and set the temp DACL; failure is loud before content is written. The package carries Koffi for the narrow Win32 calls, loaded only on Windows replacement paths. A new Windows file inherits broad directory access when the directory is broad by design, while POSIX temp content stays owner-only; a read-only Windows target still fails publication before synthetic mode replay could matter. diff --git a/.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.zh.md b/.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.zh.md index 8ae82884c3..fc6ec5232c 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.zh.md @@ -6,13 +6,13 @@ Status: implemented ## 问题 -在 Windows 上,在目标文件的父目录下创建暂存目录和临时文件,并且只依赖继承的 DACL,足以满足新建文件的需要,但无法安全替换显式或受保护 DACL 比父目录更严格的现有文件:内容会在权限更宽松的父目录 DACL 下写入,而重命名又会把这个暂存安全描述符带到替换后的文件上。 +原子写入在 POSIX 上以 `0o700` 保护暂存目录、以 `0o600` 保护临时文件,但 Windows mode 位只呈现实际 DACL 的合成只读视图。在目标文件的父目录下创建暂存目录和临时文件,并且只依赖继承的 DACL,足以满足新建文件的需要,但无法安全替换显式或受保护 DACL 比父目录更严格的现有文件:内容会在权限更宽松的父目录 DACL 下写入,而重命名又会把这个暂存安全描述符带到替换后的文件上。 ## 决策 -`dsh-fs-local` 通过 `GetFileSecurityW` 读取现有目标文件的 DACL,在写入内容前将其以禁止继承的形式应用到空临时文件,并通过 `ReplaceFileW` 发布已关闭的临时文件。受保护的暂存安全描述符可防止暂存目录中的继承条目扩大访问权限;`ReplaceFileW` 会保留原目标文件的访问策略及其他替换元数据。其 ACL 合并过程可能重新序列化自动继承状态或复制等价 ACE,因此不能把自相对安全描述符缓冲区的逐字节相等作为稳定契约。新建文件没有既有描述符需要保留,因此仍继承目标目录的 DACL。 +`dsh-fs-local` 通过 `GetFileSecurityW` 读取现有目标文件的 DACL,在写入内容前将其以禁止继承的形式应用到空临时文件,并通过 `ReplaceFileW` 发布已关闭的临时文件。受保护的暂存安全描述符可防止暂存目录中的继承条目扩大访问权限;`ReplaceFileW` 会保留原目标文件的访问策略及其他替换元数据。其 ACL 合并过程可能重新序列化自动继承状态或复制等价 ACE,因此不能把自相对安全描述符缓冲区的逐字节相等作为稳定契约。新的 Windows 文件没有既有描述符需要保留,因此仍继承目标目录的 DACL;其暂存目录也因此位于目标文件旁。POSIX 继续使用仅所有者可访问的暂存 mode,并保留现有目标文件的 mode。 -Windows 原生覆盖率测试会保护目标文件的 DACL、检查写入完成的暂存文件,并对比最终替换文件中保持顺序且去重后的 ACE 策略。与宿主平台无关的绑定测试覆盖 Win32 错误转换以及每个原生调用边界。 +Windows 原生覆盖率测试会保护目标文件的 DACL、检查写入完成的暂存文件,并对比最终替换文件中保持顺序且去重后的 ACE 策略。与宿主平台无关的绑定测试覆盖 Win32 错误转换以及每个原生调用边界。mode 位断言仍仅适用于 POSIX;新文件的 DACL 继承由操作系统契约规定,不应通过特定机器的账户允许列表来断言。 ## 备选方案 @@ -22,6 +22,10 @@ Windows 原生覆盖率测试会保护目标文件的 DACL、检查写入完成 **每次写入都设置仅所有者可访问的 DACL。** 不予采用,因为这会破坏项目有意设置的共享权限。复制目标文件的 DACL 可以保留部署中已有的访问策略,无需另行创设策略。 +**使用 `Get-Acl` 或 `icacls` 断言继承账户。** 不予采用,因为这类测试验证的是机器策略,而不是包行为;系统内置账户名会本地化,使输出在不同宿主上不稳定。 + +**在 Windows 上跳过现有 `chmod` 调用。** 不予采用,因为 Node 会把这些可写 mode 映射为无害的空操作;平台条件判断只会增加分支,不会改变 DACL 行为。 + ## 影响 -替换 Windows 文件现在要求调用方有权读取目标 DACL 并设置临时文件 DACL;如果权限不足,系统会在写入内容前明确失败。该包(package)引入 Koffi 以执行少量 Win32 调用,并且只在 Windows 替换路径上加载。新建文件仍按目录继承,POSIX mode 行为保持不变。 +替换 Windows 文件现在要求调用方有权读取目标 DACL 并设置临时文件 DACL;如果权限不足,系统会在写入内容前明确失败。该包(package)引入 Koffi 以执行少量 Win32 调用,并且只在 Windows 替换路径上加载。新的 Windows 文件会在目录按设计开放较宽访问权限时继承该权限,而 POSIX 临时内容仍仅允许所有者访问;只读 Windows 目标文件仍会在发布时失败,早于重放合成 mode 可能产生影响的时点。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-26-intent-draft-same-tick-echo.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-26-intent-draft-same-tick-echo.i18n.yaml new file mode 100644 index 0000000000..390f118a55 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-26-intent-draft-same-tick-echo.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 +2026-07-26-intent-draft-same-tick-echo.md: 1a4fdb48c0434bd37d7771dddb640720e1b610e6 +2026-07-26-intent-draft-same-tick-echo.zh.md: 9ecdf7154f5014de242021f99d2e51959c2a3169 diff --git a/.agents/notes/implemented/bug-fix/2026-07-26-intent-draft-same-tick-echo.md b/.agents/notes/implemented/bug-fix/2026-07-26-intent-draft-same-tick-echo.md new file mode 100644 index 0000000000..1a4fdb48c0 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-26-intent-draft-same-tick-echo.md @@ -0,0 +1,27 @@ +# Agent Note: Intent draft echoes in the same tick + +Status: implemented + +English | [中文](2026-07-26-intent-draft-same-tick-echo.zh.md) + +## Problem + +The hero composer ("Let's start building") is a controlled textarea whose value is the frontend Session Intent's retained prompt, read from the sessions **list** snapshot (`EmptyState` binds `intent.prompt` via `useSessions`). Typing routed through `SessionManager.updateIntent → Session.updatePendingPrompt`, which flushes the **Session's own** notifier synchronously — but the list snapshot the composer actually renders from only heard about the change through the intent watch subscription in `startIntent`, which calls `markDirty()`, a microtask-deferred flush. + +A deferred echo violates the controlled-input contract documented on the Notifier (see the [web client architecture note](../architecture/2026-07-19-gui-web-client-architecture.md)): React compares the DOM value against the still-stale snapshot during the same tick as `onChange` and rolls the textarea back. With plain typing this shows as caret jumps; with an IME it corrupts input — every composition update gets rolled back and re-applied against a stale value, so typing Pinyin "nihao" commits fragments like "nnini hni hani hao你好". The resident composer (`ConversationRoot`) was not affected: its draft lives in the chat store (sync flush) or comes from `updateSessionPrompt`, which reads the Session snapshot directly rather than the list projection. + +## Decision + +`SessionManager.updateIntent` calls `this.notifier.notifyNow()` after `updatePendingPrompt`, flushing the list snapshot in the same tick as the change event. This matches the Notifier's channel rule: a direct echo of a user gesture whose controlled input renders from this snapshot uses `notifyNow`; the intent watch keeps `markDirty` for every other (async) intent transition. + +## Alternatives considered + +**Change the intent watch callback in `startIntent` to `notifyNow`.** Wrong channel for that seam: the watch also fires on frame-driven Session changes (publication, send phases), and the architecture note bans `notifyNow` for frame-driven sources because it collapses batching. + +**Have `EmptyState` read the prompt from the Session snapshot instead of the list.** Restructures the slot contract (EmptyState is deliberately bound to the standard `useSessions` feed and has no session scope yet — the frontend Session is page-local) for no gain over flushing the projection it already reads. + +**Suppress the rollback in `InputBar` with local uncontrolled state.** Hides the symptom, forfeits the single-source-of-truth draft (the retained prompt must survive workspace retargeting and send/retry), and leaves every other list-snapshot-controlled input exposed. + +## Consequences + +Typing in the hero composer, IME composition included, echoes synchronously. `updateIntent` on a no-intent state stays a no-op with no notification. The web workspace-flow snapshot's composer helper now asserts the same-tick echo instead of waiting for it, so a regression to a deferred echo fails the keyless snapshot gate; a runtime unit test pins the same contract at the manager seam. diff --git a/.agents/notes/implemented/bug-fix/2026-07-26-intent-draft-same-tick-echo.zh.md b/.agents/notes/implemented/bug-fix/2026-07-26-intent-draft-same-tick-echo.zh.md new file mode 100644 index 0000000000..9ecdf7154f --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-26-intent-draft-same-tick-echo.zh.md @@ -0,0 +1,27 @@ +# Agent Note: Intent draft echoes in the same tick + +Status: implemented + +[English](2026-07-26-intent-draft-same-tick-echo.md) | 中文 + +## Problem + +hero composer(「Let's start building」)是一个受控(controlled)的 textarea,它的值取自前端 Session Intent 保留下来的提示词,读自会话**列表**快照(`EmptyState` 通过 `useSessions` 绑定 `intent.prompt`)。输入经由 `SessionManager.updateIntent → Session.updatePendingPrompt`,后者会同步刷新 **Session 自身的** notifier——但 composer 实际渲染所依据的那份列表快照,只能通过 `startIntent` 中的 intent watch 订阅得知这次变更,而该订阅调用的是 `markDirty()`,即一次延迟到微任务的刷新。 + +延迟的回显违反了 Notifier 上所记录的受控输入契约(见 [web 客户端架构笔记](../architecture/2026-07-19-gui-web-client-architecture.md)):React 在与 `onChange` 相同的 tick 内,把 DOM 值与仍然陈旧的快照相比对,随后把 textarea 回滚。普通输入时,这表现为光标跳动;使用输入法(IME)时,它会损坏输入——每一次 composition 更新都会被回滚,并针对陈旧的值重新应用,因此输入拼音「nihao」会提交出类似「nnini hni hani hao你好」这样的片段。resident composer(`ConversationRoot`)不受影响:它的草稿存放在 chat store 中(同步刷新),或来自 `updateSessionPrompt`,后者直接读取 Session 快照,而不是列表投影。 + +## Decision + +`SessionManager.updateIntent` 在 `updatePendingPrompt` 之后调用 `this.notifier.notifyNow()`,从而在与变更事件相同的 tick 内刷新列表快照。这符合 Notifier 的通道规则:当某个用户手势的受控输入正是从该快照渲染时,对它的直接回显使用 `notifyNow`;而 intent watch 对其余所有(异步的)intent 状态转换仍保留 `markDirty`。 + +## Alternatives considered + +**把 `startIntent` 中的 intent watch 回调改为 `notifyNow`。** 对那个 seam 而言是错误的通道:该 watch 也会在帧驱动的 Session 变更(发布、发送阶段)时触发,而架构笔记禁止对帧驱动的来源使用 `notifyNow`,因为那会瓦解批处理。 + +**让 `EmptyState` 从 Session 快照而非列表读取提示词。** 这会重构槽位契约(EmptyState 有意绑定到标准的 `useSessions` 数据源,且尚无 session 作用域——前端 Session 是页面本地的),相比刷新它本就读取的那份投影并无收益。 + +**在 `InputBar` 中用本地的非受控状态抑制回滚。** 这只是掩盖症状,放弃了单一真源的草稿(保留下来的提示词必须在工作区重定向以及发送/重试后依然存在),并让其余每一个由列表快照控制的输入都暴露在同一问题之下。 + +## Consequences + +在 hero composer 中输入(包括输入法 composition 在内)会同步回显。在无 intent 的状态上调用 `updateIntent` 仍是一次空操作,不发出任何通知。web workspace-flow 快照的 composer 辅助函数现在断言的是同一 tick 内的回显,而不是等待它,因此一旦回退成延迟回显,就会让无密钥快照门禁失败;一个运行时单元测试在 manager 这一 seam 处钉住了同一份契约。 diff --git a/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.md b/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.md deleted file mode 100644 index da23bbfa24..0000000000 --- a/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.md +++ /dev/null @@ -1,61 +0,0 @@ -# Agent Note: Agent Client Protocol (ACP) support — drive the coding agent from external editors - -Status: implemented - -English | [中文](2026-06-14-acp-agent-client-protocol.zh.md) - -> Superseded by [ACP as an automation-only protocol](../simplification/2026-07-23-acp-automation-only-protocol.md). This note records the retired editor-facing bridge design. - -## Problem - -The harness originally exposed agents only through a readline loop. That surface could carry text, but it gave an editor no structured way to create or resume sessions, correlate prompt completion, stream reasoning and tool activity, render tool-specific UI, ask for permission, or cancel one conversation without disturbing another. ACP defines those interactions as JSON-RPC over stdio, and Zed is the target client used to make concrete compatibility decisions. - -The bridge must preserve the harness's existing ownership boundaries. It cannot depend on the concrete agent loop, bypass the tool registry, execute shell commands in the editor, or invent a second source of session truth. stdout is also the protocol transport, so any accidental log output corrupts the connection. - -## Decision - -`@deepseek-ai/dsh-acp` was a UI/client-driver plugin in the `ui` package group (it now lives in `acp`). It used `@agentclientprotocol/sdk`'s `AgentSideConnection` over stdin/stdout and programmed only interface services: the agent create/resume factory, session persistence, tool registry, user interaction, and optional approval/bash capabilities. It did not change the agent loop and was not a capability-seam implementation. - -The bridge implements the following stable session path: - -- `initialize` negotiates the protocol version, advertises text plus `resource_link` prompts, and advertises `loadSession`. -- `session/new` validates an absolute `cwd`, stores it in `SessionHeader`, creates an agent through `ctx.agents`, and returns any composition-backed config options. -- `session/load` validates the requested cwd against persisted metadata before constructing an agent, reserves the id across the asynchronous resume, replays user/assistant/tool events as ACP updates, and reports the resumed config-option fold. -- `session/prompt` accepts text and resource links, rejects unsupported or empty content, allows one in-flight prompt per session, and settles against that prompt's owning `turn/end`. An error turn rejects the RPC; other closed turn reasons map through a total ACP stop-reason codec. -- `session/cancel` calls the queue-aware agent cancel path and settles only the addressed session's prompt. - -Tool-call presentation remains tool-owned. A tool's `presentCall` and `presentResult` return the `generic`, `terminal`, or `diff` render-intent variants; the bridge switches on that union and maps it to ACP. Presenter-less tools receive a generic fallback. Bash terminal cards use Zed's capability-gated `_meta.terminal_info`, `_meta.terminal_output`, and `_meta.terminal_exit` convention; the harness still executes the command through `ctx.bash`, preserving sandbox, environment scrub, ownership, and cwd. Clients without that extension receive ordinary text content. Filesystem tools provide diff cards and file locations without hard-coded tool-name branches in the bridge. - -Permission handling is an answerer on the [user-approval seam](2026-07-06-approval-seam.md), not an ask-every-tool policy in ACP. An `approval/request` for a bridge-owned agent with a call id becomes `session/request_permission` on that agent's editor session, with one-shot allow/reject choices. Foreign or call-less requests delegate; a missing or failed answerer remains fail-closed. The plugin that asks—such as a pre-execute policy or bash escalation—owns the decision to ask. - -When `ctx.permission` is composed, the bridge exposes one `permission` select from the deployment's preset table. The shipped `workspace-write` and `danger-full-access` presets each bundle a sandbox mode with an approval policy; unmatched effective knobs produce the switch-away-only `custom` state. `session/set_config_option` validates through `PermissionService.set()` and writes both owning knob events. A switch during an open turn appends immediately; an idle switch is overlaid in responses and anchored at the next `agent/prompt-submit`, before request assembly. Until then it is memory-only, so a crash restores the durable fold. ACP session modes are not modeled because config options are the forward protocol surface; `AcpConfig.model` remains connection-wide. - -The bridge also provides the ACP-backed `UserInteractionProvider`: `ask_user_question` requests become form elicitations on the owning session. Select, multi-select, option descriptions, and custom-answer override semantics are preserved. - -Lifecycle ownership is explicit. The bridge holds an `AgentHandle` per live session. Disconnect and Cordis disposal cancel pending prompts, dispose every handle in parallel, await loop quiescence and persistence flush, and then remove the records. Stream notification failures are contained so a vanished client cannot corrupt an agent turn. The ACP app composition loads no stdout logger; a test guards stdout as framed JSON-RPC only. - -The current protocol contract lives in the [`dsh-acp` package README](../../../../packages/acp/acp/README.md). - -## Alternatives considered - -**A prepended `tools/execute` listener that asks on every ACP-owned call** — rejected. It would hard-code permission policy into the UI bridge, ask even when no policy requires it, and could not serve approval requests that arise after execution begins. The shared user-approval seam keeps mechanism, asking policy, and UI answerer separate. - -**Inject the concrete `agentLoop`** — rejected. Agent creation, resume, idle observation, and disposal are interface-level ownership operations on `dsh-agent`; a UI plugin does not need a dependency-rule exception. - -**Execute bash through ACP `terminal/*`** — rejected. That would move execution outside the harness and bypass its sandbox, credential scrub, task ownership, cwd resolution, and session log. Terminal metadata is presentation only. - -**Represent permission presets as ACP session modes** — rejected. The deployment-defined preset is already one config-option select, while session modes are the legacy surface slated for removal in ACP v2. - -**Hijack stdout defensively** — rejected. Process-wide monkey-patching is outside Cordis effect ownership and races the protocol transport. The app composition owns stdout purity. - -## Consequences - -Editors can create, load, prompt, cancel, render, ask, and reconfigure multiple harness sessions over one ACP connection without a loop-specific dependency. The session event log remains the durable source for replay, prompt settlement, cwd, and per-session configuration. Tool presentation and human-answer channels remain extensible plugin contracts instead of ACP-specific behavior. - -The bridge deliberately does not implement session list/delete/resume/close capabilities, MCP passthrough, additional directories, image/audio/embedded-resource prompts, plans, slash commands, usage updates, editor filesystem delegation, or the ACP terminal execution sub-protocol. Runtime model selection was added later through standard session config options by the [LLM catalog and ACP selection Agent Note](../architecture/2026-07-15-llm-model-catalog-and-acp-selection.md). - -An idle config selection is truthful in the live response but not durable until the next `agent/prompt-submit` anchors it inside the open turn. Crashing before that boundary loses the pending selection; this is the cost of keeping session events turn-enclosed and replay-safe. - -## Verification - -The ACP suites cover the in-memory protocol codec, create/load replay, exact prompt settlement, cancellation races, unsupported content, tool presentation, terminal capability fallback, permission outcome mapping, config-option validation and persistence, multi-session isolation, disconnect/disposal quiescence, and HMR cleanup. Snapshot and built-bin tests exercise the app composition, while the real-API e2e self-skips without a key. diff --git a/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.zh.md b/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.zh.md deleted file mode 100644 index ec55922e0a..0000000000 --- a/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.zh.md +++ /dev/null @@ -1,61 +0,0 @@ -# Agent Note: Agent Client Protocol(ACP)支持——从外部编辑器驱动编码 agent - -Status: implemented - -[English](2026-06-14-acp-agent-client-protocol.md) | 中文 - -> 已被 [ACP 作为仅面向自动化的协议](../simplification/2026-07-23-acp-automation-only-protocol.md)取代。本 Agent Note 记录已退役的面向编辑器的桥接层设计。 - -## 问题 - -harness 最初仅通过 readline 循环暴露 agent。该接口能传输文本,但编辑器无法以结构化方式创建或恢复会话、关联提示词完成、流式输出推理(reasoning)与工具活动、渲染工具专属 UI、请求权限,或在不干扰其他对话的前提下取消某个对话。ACP(Agent Client Protocol)将这些交互定义为基于 stdio 的 JSON-RPC,Zed 是用于做出具体兼容性决策的目标客户端。 - -桥接层必须保持 harness 既有的所有权边界。它不能依赖具体的 agent loop(智能体循环),不能绕过工具注册表,不能在编辑器中执行 shell 命令,也不能发明第二个会话真源。stdout 同时也是协议传输通道,因此任何意外的日志输出都会破坏连接。 - -## 决策 - -`@deepseek-ai/dsh-acp` 曾是 `ui` 包组中的 UI/客户端驱动插件(现位于 `acp`)。它使用 `@agentclientprotocol/sdk` 的 `AgentSideConnection`(基于 stdin/stdout),仅编排接口服务:agent 创建/恢复工厂、会话持久化、工具注册表、用户交互,以及可选的审批/bash 能力。它不修改 agent loop,也不是能力 seam 的实现。 - -桥接层实现以下稳定的会话路径: - -- `initialize` 协商协议版本,声明支持 text 与 `resource_link` 类型的提示词,并声明 `loadSession` 能力。 -- `session/new` 校验绝对路径 `cwd`,将其存入 `SessionHeader`,通过 `ctx.agents` 创建 agent,并返回由组合层支持的配置选项。 -- `session/load` 在构造 agent 之前校验请求的 cwd 与持久化元数据是否一致,在异步恢复期间保留 id,将用户/助手/工具事件作为 ACP update 回放,并报告恢复后的 config-option 折叠结果。 -- `session/prompt` 接受文本和 resource link,拒绝不支持的或空的内容,每个会话同时只允许一个 in-flight 提示词,并在该提示词所属的 `turn/end` 时结算。错误轮次拒绝 RPC;其他关闭轮次的原因通过一个全覆盖的 ACP stop-reason 编解码器映射。 -- `session/cancel` 调用队列感知的 agent 取消路径,仅结算被寻址会话的提示词。 - -工具调用的展示仍由工具自身负责。工具的 `presentCall` 和 `presentResult` 返回 `generic`、`terminal` 或 `diff` 渲染意图变体;桥接层对该联合类型做 switch 并映射到 ACP。没有 presenter 的工具获得通用回退。Bash 终端卡片使用 Zed 的能力门控约定 `_meta.terminal_info`、`_meta.terminal_output` 和 `_meta.terminal_exit`;harness 仍通过 `ctx.bash` 执行命令,保留沙箱、环境清洗、所有权和 cwd。不支持该扩展的客户端收到普通文本内容。文件系统工具提供 diff 卡片和文件位置,桥接层中无需硬编码工具名分支。 - -权限处理是[用户审批 seam](2026-07-06-approval-seam.md)上的一个 answerer,而非 ACP 中的「每次工具调用都询问」策略。对桥接层所属 agent 且带有 call id 的 `approval/request`,会变为该 agent 编辑器会话上的 `session/request_permission`,提供一次性允许/拒绝选项。外部请求或无 call id 的请求委托给下游;缺失或失败的 answerer 会在故障时保持拒绝。发起询问的插件(如预执行策略或 bash 升级)拥有「是否询问」的决策权。 - -当 `ctx.permission` 被组合时,桥接层从部署的预设表中暴露一个 `permission` select。已发布的 `workspace-write` 和 `danger-full-access` 预设各自捆绑一个沙箱模式与一条审批策略;无法匹配的有效旋钮组合产生只能切走的 `custom` 状态。`session/set_config_option` 通过 `PermissionService.set()` 校验并写入两个所属旋钮事件。在开放轮次中的切换立即追加;空闲时的切换叠加在响应中,并在下一次 `agent/prompt-submit` 时锚定到开放轮次之前的请求组装阶段。在此之前它仅存于内存,因此崩溃后恢复的是持久化的折叠结果。ACP session mode 不被建模,因为 config option 是面向未来的协议表面;`AcpConfig.model` 保持连接级别。 - -桥接层还提供基于 ACP 的 `UserInteractionProvider`:`ask_user_question` 请求变为所属会话上的表单引导。select、multi-select、选项描述与自定义回答覆盖语义均被保留。 - -生命周期所有权是显式的。桥接层为每个活跃会话持有一个 `AgentHandle`。断连和 Cordis dispose(资源释放)会取消待处理的提示词,并行 dispose 所有 handle,等待循环完全停稳与持久化刷写,然后移除记录。流通知失败被隔离,因此消失的客户端不会破坏 agent 轮次。ACP 应用组合不加载 stdout logger;一个测试守卫 stdout 仅包含帧化的 JSON-RPC。 - -当前的协议契约见 [`dsh-acp` 包 README](../../../../packages/acp/acp/README.md)。 - -## 曾考虑的替代方案 - -**在 `tools/execute` 监听器前置一层,对每个 ACP 所属调用都询问权限**:否决。这会将权限策略硬编码到 UI 桥接层,即使没有策略要求也会询问,且无法服务于执行开始后才产生的审批请求。共享的 user-approval seam 将机制、询问策略和 UI answerer 分离。 - -**注入具体的 `agentLoop`**:否决。agent 的创建、恢复、空闲观察与释放是 `dsh-agent` 上的接口级所有权操作;UI 插件不需要依赖规则例外。 - -**通过 ACP `terminal/*` 执行 bash**:否决。这会将执行移到 harness 之外,绕过其沙箱、凭证清洗、任务所有权、cwd 解析与会话日志。终端元数据仅用于展示。 - -**将权限预设表示为 ACP session mode**:否决。部署定义的预设已经是一个 config-option select,而 session mode 是 ACP v2 计划移除的遗留接口。 - -**防御性劫持 stdout**:否决。进程级 monkey-patching 超出 Cordis 副作用所有权范围,且与协议传输存在竞争。应用组合拥有 stdout 纯净性。 - -## 后果 - -编辑器可以通过一条 ACP 连接创建、加载、提交提示词、取消、渲染、询问和重新配置多个 harness 会话,无需依赖特定的循环实现。会话事件日志仍是回放、提示词结算、cwd 与每会话配置的持久真源。工具展示与人工回答通道仍是可扩展的插件契约,而非 ACP 专属行为。 - -桥接层有意不实现会话列表/删除/恢复/关闭能力、MCP 透传、附加目录、图片/音频/嵌入资源提示词、plan、斜杠命令、用量更新、编辑器文件系统委托或 ACP 终端执行子协议。后续已通过标准会话配置选项加入运行时模型选择,见 [LLM 目录与 ACP 选择 Agent Note](../architecture/2026-07-15-llm-model-catalog-and-acp-selection.md)。 - -空闲时的配置选择在实时响应中是真实的,但在下一次 `agent/prompt-submit` 将其锚定到开放轮次之前不具持久性。在该边界之前崩溃会丢失待定选择;这是保持会话事件封闭于轮次内且回放安全的代价。 - -## 验证 - -ACP 测试套件覆盖内存协议编解码器、创建/加载回放、精确的提示词结算、取消竞争、不支持的内容、工具展示、终端能力回退、权限结果映射、config-option 校验与持久化、多会话隔离、断连/释放后的完全停稳,以及 HMR(热模块替换)清理。快照测试与 built-bin 测试验证应用组合,真实 API 的 e2e 测试在无 key 时自动跳过。 diff --git a/.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md b/.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md deleted file mode 100644 index e8426dbf1a..0000000000 --- a/.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md +++ /dev/null @@ -1,50 +0,0 @@ -# Agent Note: Rich ACP bash rendering — the terminal card via the `_meta` convention - -Status: implemented - -English | [中文](2026-06-18-acp-terminal-and-tool-rendering.zh.md) - -> Superseded for ACP by [ACP as an automation-only protocol](../simplification/2026-07-23-acp-automation-only-protocol.md). Tool render intents remain available to UI transports, but ACP no longer projects them into terminal cards. - -## Problem - -The ACP bridge lets each tool own its call rendering via `presentCall`/`presentResult` (see [tool-call UI presentation](2026-06-14-acp-agent-client-protocol.md) and `packages/core/tools`). For `bash` we surface the exact command as the `tool_call` title, the model's `description` as a content text block, `kind: 'execute'`, and the completed output wrapped in a fenced ` ```console ` text block. - -Reference editors render terminal metadata as a dedicated card with cwd, command, live-style output, and exit status; plain text loses that structure. The command is the title because execute cards hide raw input, while the human-readable description remains a separate block above the card. - -## Key finding: agent-executed terminals use a `_meta` convention, NOT `terminal/create` - -The ACP spec has a *client-side* terminal sub-protocol — the agent calls the client's `terminal/create` with `{ command, args, cwd, env }` and the **editor** executes the process, then the agent reads `terminal/output` / `wait_for_exit`. That model is wrong for us: our harness executes bash itself through `dsh-bash` (sandboxed env-scrub, background-task ownership, per-session cwd). Routing execution to the editor would bypass all of that and fork execution into two backends. - -Studying the two reference agents (2026-06-18) shows neither uses `terminal/create` for their own shell tool — **both keep agent-side execution and emit a `_meta` convention** that Zed special-cases: - -- **`claude-agent-acp`** (`tools.ts`, `acp-agent.ts`): gated on `clientCapabilities._meta.terminal_output`. The `tool_call` carries `content: [{ type: 'terminal', terminalId }]` and `_meta.terminal_info.{ terminal_id, cwd }`; output/exit arrive on the `tool_call_update`'s `_meta.terminal_output.{ terminal_id, data }` and `_meta.terminal_exit.{ terminal_id, exit_code, signal }`. -- **`codex-acp`** (`CodexToolCallMapper.ts`, `TerminalOutputMode.ts`): same `terminal_info` on the call; output via `_meta.terminal_output` (full) or `_meta.terminal_output_delta` (incremental), selected from the same `_meta.terminal_output` capability. - -Zed's side (`crates/agent_servers/src/acp.rs`, verified): on a `ToolCall` whose `_meta.terminal_info.terminal_id` is set, it registers a **display-only** terminal (header = `terminal_info.cwd`, label = `tool_call.title`); on a `ToolCallUpdate`, `_meta.terminal_output.data` writes to that terminal and `_meta.terminal_exit.{exit_code,signal}` sets the status. It advertises the capability as `clientCapabilities._meta.terminal_output = true`. `_meta` itself is a spec-blessed ACP extensibility point (typed `{[k]: unknown} | null` on `ToolCall`/`ToolCallUpdate`); the *specific keys* here (`terminal_info`/`terminal_output`/`terminal_exit`) are a Zed convention, not part of the ACP spec — but they are the de-facto contract for the Zed integration and the only way to get the terminal card while keeping execution agent-side. - -## Decision - -Keep `dsh-bash` agent-side execution; render the terminal card via the `_meta` convention, capability-gated, with the ` ```console ` text block as the guaranteed fallback. - -1. **Capability.** `initialize` reads `clientCapabilities._meta.terminal_output` and the bridge remembers it per connection. -2. **Neutral presentation vocabulary.** `dsh-tools` gains a terminal-shaped presentation a tool can return — provider-neutral (`cwd`, the output `data`, an `exitCode`/`signal`), NO ACP types. `dsh-tool-bash` returns it for `bash` (cwd from the resolved workdir; output + exit parsed from the run result). -3. **Bridge mapping.** When the client advertised the capability, the bridge maps that presentation to: on `tool_call`, `content:[…, {type:'terminal', terminalId}]` (any tool `content`, e.g. the description, rendered BEFORE the terminal block) + `_meta.terminal_info.{terminal_id,cwd}`; on `tool_call_update`, `_meta.terminal_output.{terminal_id,data}` (the captured output) + `_meta.terminal_exit.{terminal_id, exit_code|signal}` (the parsed exit), with the update's text `content` OMITTED (an ACP `tool_call_update.content` REPLACES the call's content collection, so re-sending the fenced block would clobber the terminal content block). `terminalId` is derived from the harness `callId` (stable, unique per call). When the capability is absent, the bridge sends the description content block on the call and the existing ` ```console ` text content on the update — unchanged. -4. **The exit pill is parsed from the rendered output; no new execution path, no live streaming.** Output is attached at completion (from the agent's own `tool/result`), not streamed token-by-token. The exit-status pill (`_meta.terminal_exit.{exit_code,signal}`) IS emitted: the pure `presentResult(args, result)` seam sees only content blocks, so `dsh-tool-bash` recovers the structured exit by parsing the status markers (`[exit code: N]` / `[killed by signal: …]`) that `renderResult` appended — the parse is the exact inverse of the marker emission, the two co-evolve in one file, and a round-trip test guards the pair. Disposal is unaffected: nothing new to tear down, since the bridge never creates a client-side terminal. - -## Alternatives considered - -- **The ACP client-side terminal sub-protocol (`terminal/create`)** — explicitly rejected: the editor would execute the process, bypassing `dsh-bash`'s env scrub, background-task ownership, and per-session cwd, and forking execution into two backends. Both reference agents reject it the same way (the key finding above); agent-side execution plus the `_meta` convention is the only shape that yields the terminal card while keeping the harness's execution policy. -- **Threading a structured exit through the event schema** — rejected in favor of the marker round-trip: the pure `presentResult(args, result)` seam sees only content blocks, and the parse is the exact inverse of the marker emission, co-evolving in one file under a round-trip test. - -## Consequences - -- **Zed-convention `_meta` keys.** The terminal card rides on Zed-specific keys (`terminal_info`/`terminal_output`/`terminal_exit`) inside ACP's spec-blessed `_meta` extensibility point, NOT on the ACP terminal sub-protocol. A client that doesn't recognize the keys still gets the text fallback (the capability gate ensures we only emit them when the client opted in via `_meta.terminal_output`), so a non-Zed client is never worse off. If ACP later standardizes agent-executed terminals, migrate to that and drop the convention keys. -- **Capability honesty.** Emit terminal metadata ONLY when the client advertised `_meta.terminal_output`; the text fallback is the contract for everyone else and must never regress. Covered by a no-capability test asserting the ` ```console ` path. -- **terminalId collisions.** Deriving it from the per-call `callId` keeps it unique within a session and stable across the call/result pair; never reuse one across calls. -- **Exit parsed from rendered text.** The exit pill recovers `exit_code`/`signal` by parsing `renderResult`'s status markers rather than threading a structured exit through the event schema (which the pure `presentResult` seam never sees). The parse is the exact inverse of the marker emission and lives in the same file; a round-trip test pins the pair so a marker-format change that breaks the parse fails the suite. If the markers ever need to diverge from what the pill wants, surface a structured exit on the result event instead. -- **Provider-neutral vocabulary creep.** The terminal presentation widens the `dsh-tools` surface; keep it neutral (no ACP types leak into `dsh-tools`) and only as rich as a second UI consumer would also want. - -## Out of scope / non-goals - -The text-block baseline stays the no-capability default. Two follow-ups are deliberately NOT built here and would each warrant their own Agent Note when someone takes them on: **live incremental streaming** (`_meta.terminal_output_delta` as chunks arrive, which needs an incremental-output seam on `dsh-bash`), and **command classification** (parsing a `cat`/`sed` as a `read` card with a file location, a `grep` as a `search`, etc., falling back to the terminal card — display-only, must never change what executes). diff --git a/.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.zh.md b/.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.zh.md deleted file mode 100644 index ff269e002a..0000000000 --- a/.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.zh.md +++ /dev/null @@ -1,50 +0,0 @@ -# Agent Note: 富 ACP bash 渲染——通过 `_meta` 约定实现终端卡片 - -Status: implemented - -[English](2026-06-18-acp-terminal-and-tool-rendering.md) | 中文 - -> 就 ACP 而言已被 [ACP 作为仅面向自动化的协议](../simplification/2026-07-23-acp-automation-only-protocol.md)取代。工具渲染意图对 UI 传输层仍然可用,但 ACP 不再将其投影为终端卡片。 - -## 问题 - -ACP(Agent Client Protocol)桥接层允许每个工具通过 `presentCall`/`presentResult` 自行控制调用渲染(见[工具调用 UI 呈现](2026-06-14-acp-agent-client-protocol.md)与 `packages/core/tools`)。对于 `bash`,我们将确切命令作为 `tool_call` 标题呈现,模型的 `description` 作为一个内容文本块,`kind: 'execute'`,完成后的输出包裹在 ` ```console ` 围栏文本块中。 - -参考编辑器将终端元数据渲染为一张专用卡片,包含 cwd、命令、实时风格的输出和退出状态;纯文本则丢失了这些结构。命令之所以作为标题,是因为执行卡片隐藏原始输入,而人类可读的描述保留为卡片上方的独立块。 - -## 关键发现:agent 执行的终端使用 `_meta` 约定,而非 `terminal/create` - -ACP 规范有一个*客户端侧*终端子协议:agent(智能体)调用客户端的 `terminal/create`(传入 `{ command, args, cwd, env }`),由**编辑器**执行进程,然后 agent 读取 `terminal/output` / `wait_for_exit`。这个模型不适合我们:我们的 harness 通过 `dsh-bash` 自行执行 bash(沙箱化的环境清理、后台任务所有权、按会话的 cwd)。将执行路由到编辑器会绕过所有这些机制,并将执行分叉到两个后端。 - -研究两个参考 agent(2026-06-18)发现,二者都没有为自己的 shell 工具使用 `terminal/create`——**两者都保持 agent 侧执行,并发出一套 `_meta` 约定**,由 Zed 特殊处理: - -- **`claude-agent-acp`**(`tools.ts`、`acp-agent.ts`):以 `clientCapabilities._meta.terminal_output` 为门控。`tool_call` 携带 `content: [{ type: 'terminal', terminalId }]` 与 `_meta.terminal_info.{ terminal_id, cwd }`;输出和退出通过 `tool_call_update` 的 `_meta.terminal_output.{ terminal_id, data }` 与 `_meta.terminal_exit.{ terminal_id, exit_code, signal }` 到达。 -- **`codex-acp`**(`CodexToolCallMapper.ts`、`TerminalOutputMode.ts`):调用上同样携带 `terminal_info`;输出通过 `_meta.terminal_output`(完整)或 `_meta.terminal_output_delta`(增量),由同一个 `_meta.terminal_output` 能力选择。 - -Zed 侧(`crates/agent_servers/src/acp.rs`,已验证):收到 `ToolCall` 且其 `_meta.terminal_info.terminal_id` 已设置时,注册一个**仅展示**的终端(header = `terminal_info.cwd`,label = `tool_call.title`);收到 `ToolCallUpdate` 时,`_meta.terminal_output.data` 写入该终端,`_meta.terminal_exit.{exit_code,signal}` 设置状态。客户端通过 `clientCapabilities._meta.terminal_output = true` 声明此能力。`_meta` 本身是 ACP 规范认可的扩展点(在 `ToolCall`/`ToolCallUpdate` 上类型为 `{[k]: unknown} | null`);这里的*具体键*(`terminal_info`/`terminal_output`/`terminal_exit`)是 Zed 约定,不属于 ACP 规范,但它们是 Zed 集成的事实契约,也是在保持 agent 侧执行的前提下获得终端卡片的唯一方式。 - -## 决策 - -保持 `dsh-bash` 的 agent 侧执行;通过 `_meta` 约定渲染终端卡片,以能力声明为门控,以 ` ```console ` 文本块作为保底回退。 - -1. **能力声明。** `initialize` 读取 `clientCapabilities._meta.terminal_output`,桥接层按连接记住它。 -2. **提供方无关的展示词汇。** `dsh-tools` 新增一种终端形态的展示结构,工具可返回它——提供方无关(`cwd`、输出 `data`、`exitCode`/`signal`),不含 ACP 类型。`dsh-tool-bash` 为 `bash` 返回该结构(cwd 来自解析后的工作目录;输出与退出从运行结果解析)。 -3. **桥接映射。** 当客户端声明了该能力时,桥接层将展示结构映射为:在 `tool_call` 上,`content:[…, {type:'terminal', terminalId}]`(工具的任何 `content`,如描述,渲染在终端块之前)+ `_meta.terminal_info.{terminal_id,cwd}`;在 `tool_call_update` 上,`_meta.terminal_output.{terminal_id,data}`(捕获的输出)+ `_meta.terminal_exit.{terminal_id, exit_code|signal}`(解析后的退出),且 update 的文本 `content` 被省略(ACP 的 `tool_call_update.content` 会替换调用的 content 集合,因此重新发送围栏块会覆盖终端内容块)。`terminalId` 由 harness 的 `callId` 派生(稳定、每次调用唯一)。当能力未声明时,桥接层在调用上发送描述内容块,在 update 上发送既有的 ` ```console ` 文本内容——行为不变。 -4. **退出信息从渲染输出中解析;无新执行路径,无实时流式传输。** 输出在完成时附加(来自 agent 自身的 `tool/result`),不逐 token 流式传输。退出状态(`_meta.terminal_exit.{exit_code,signal}`)确实会发出:纯 `presentResult(args, result)` seam 只能看到内容块,因此 `dsh-tool-bash` 通过解析 `renderResult` 追加的状态标记(`[exit code: N]` / `[killed by signal: …]`)来恢复结构化退出信息——解析是标记发出的精确逆操作,二者在同一文件中共同演进,一个往返测试守护这对关系。资源释放不受影响:无需新增拆除逻辑,因为桥接层从未创建客户端侧终端。 - -## 曾考虑的替代方案 - -- **ACP 客户端侧终端子协议(`terminal/create`)**:明确否决。编辑器将执行进程,绕过 `dsh-bash` 的环境清理、后台任务所有权和按会话的 cwd,并将执行分叉到两个后端。两个参考 agent 以同样的方式否决了它(见上述关键发现);agent 侧执行加 `_meta` 约定是在保持 harness 执行策略的同时获得终端卡片的唯一形态。 -- **通过事件 schema 传递结构化退出信息**:否决,改用标记往返方案。纯 `presentResult(args, result)` seam 只能看到内容块,而解析是标记发出的精确逆操作,二者在同一文件中共同演进,由往返测试守护。 - -## 后果 - -- **Zed 约定的 `_meta` 键。** 终端卡片依赖 Zed 特有的键(`terminal_info`/`terminal_output`/`terminal_exit`),位于 ACP 规范认可的 `_meta` 扩展点内,而非 ACP 终端子协议。不识别这些键的客户端仍然获得文本回退(能力门控确保我们仅在客户端通过 `_meta.terminal_output` 声明支持时才发出这些键),因此非 Zed 客户端不会变差。如果 ACP 日后标准化了 agent 执行的终端,则迁移到该标准并移除约定键。 -- **能力诚实。** 仅在客户端声明了 `_meta.terminal_output` 时才发出终端元数据;文本回退是对其他所有客户端的契约,绝不可退化。由一个无能力测试覆盖,断言 ` ```console ` 路径。 -- **terminalId 冲突。** 从每次调用的 `callId` 派生,保证在会话内唯一且在 call/result 对之间稳定;绝不跨调用复用。 -- **退出信息从渲染文本解析。** 退出信息通过解析 `renderResult` 的状态标记恢复 `exit_code`/`signal`,而非通过事件 schema 传递结构化退出(纯 `presentResult` seam 看不到后者)。解析是标记发出的精确逆操作,且位于同一文件中;往返测试固定了这对关系,标记格式变更若破坏解析则测试套件失败。如果标记格式日后需要与退出信息分道扬镳,则改为在 result 事件上暴露结构化退出。 -- **提供方无关词汇的蔓延。** 终端展示结构扩大了 `dsh-tools` 的接口面;保持其中立性(不让 ACP 类型泄漏到 `dsh-tools`),且只提供第二个 UI 消费方同样需要的丰富度。 - -## 超出范围 / 非目标 - -文本块基线仍为无能力声明时的默认行为。以下两项后续工作有意不在此处构建,各自需要单独的 Agent Note:**实时增量流式传输**(在分片到达时发出 `_meta.terminal_output_delta`,需要在 `dsh-bash` 上新增增量输出 seam);**命令分类**(将 `cat`/`sed` 解析为带文件位置的 `read` 卡片,将 `grep` 解析为 `search`,回退到终端卡片——仅展示,绝不改变实际执行内容)。 diff --git a/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.i18n.yaml b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml similarity index 65% rename from .agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.i18n.yaml rename to .agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml index 41265a5b0f..e8e4c59638 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.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 -2026-06-11-custom-schema-dsl.md: 947d53555df078bfa9f3dac48eab4b8c0074007c -2026-06-11-custom-schema-dsl.zh.md: 26ebfe2fb15a6c034e809b3f51187342fa500193 +2026-06-24-workspace-context.md: 6acdb6241bcc57250e217cfc8856e8b0598d4622 +2026-06-24-workspace-context.zh.md: f165d08108931df21697c7895523f10ef816297f diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md index 394c0de708..6acdb6241b 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md @@ -2,6 +2,8 @@ Status: implemented +English | [中文](2026-06-24-workspace-context.zh.md) + ## Problem Repository guidance such as `AGENTS.md` belongs in a coding session's effective context so project conventions, build commands, and review rules arrive without repeated user pasting. The stdio and ACP products need the same behavior, isolated by session cwd: a global system-prompt section leaks one workspace's files into another live ACP session. diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md new file mode 100644 index 0000000000..f165d08108 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md @@ -0,0 +1,89 @@ +# Agent Note: 工作区上下文指令文件 + +Status: implemented + +[English](2026-06-24-workspace-context.md) | 中文 + +## 问题 + +`AGENTS.md` 等仓库指引应当进入编码会话的有效上下文,使项目约定、构建命令和评审规则无需由用户反复粘贴即可生效。stdio 与 ACP(Agent Client Protocol)产品需要具备相同行为,并按会话 cwd 隔离:全局系统提示词章节会把一个工作区的文件泄漏到另一个仍在运行的 ACP 会话中。 + +相邻产品形成了值得借鉴的约定,但具体做法各不相同。Codex 原生使用 `AGENTS.md`;Claude Code 使用 `CLAUDE.md`,并采用熟悉的 system-reminder 风格用户上下文;opencode 同时支持这两个名称,每个目录只选一个胜出者,并延迟发现嵌套文件。harness 需要跨工具兼容,同时避免从同一作用域加载重复或互相矛盾的文件。 + +生命周期中有两类截然不同的内容。初始适用文件链足够稳定,可以放入请求前缀并受益于提供方前缀缓存。嵌套文件、编辑、候选项切换和移除都发生在会话启动后,应进入持久的仅追加历史,而不是被冻结的前缀。 + +## 决策 + +该实现在 `packages/context/workspace-context` 中,包(package)名为 `@deepseek-ai/dsh-workspace-context`。它是请求上下文扩展,不是核心服务或文件系统后端。共享 demo 主干与 Host Runtime 根据显式的 `{ maxBytes } | false` 部署选择挂载它;`dsh web` 启用 65,536 字节预算,Host Runtime 的 headless 消费方则禁用它。该插件使用 `agent/session-prefix`、`tools/post-execute` 和可选的 `ctx.fs` 功能。 + +插件不会静态注入 `fs`。因此,不带提供方的产品树仍能正常启动;在文件系统提供方出现之前,插件保持无操作。所有生产读取都通过该提供方完成。候选项探测会解析每个路径并对结果执行 stat,因此会跟随最终路径组件的符号链接至其目标:指向普通文件的链接会被加载,缺失路径或非文件目标则确认为不存在。允许仓库拥有的链接跨越信任边界,是对最初不跟随探测方式的刻意反转;[跟随指令符号链接记录](2026-07-21-follow-instruction-symlinks.md)负责说明该决策及其残余风险。会话前缀信号与动态工具执行信号会贯穿解析、元数据探测和流式读取,因此取消不会等待无关的文件系统扫描。解析或 stat 异常归类为不可用:它只跳过该候选项,绝不被解释为已经加载的作用域被删除。 + +### 文件名与优先级 + +默认的逐目录候选列表是 `['AGENTS.md', 'CLAUDE.md']`。该列表可通过 `instructionFileCandidates` 配置;`AGENTS.md` 是普通的第一候选项,而不是隐藏优先级。一个目录中只加载第一个存在的普通文件候选项。使用默认值时,`AGENTS.md` 是原生文件,`CLAUDE.md` 是兼容性回退。第二个列表 `localInstructionFileCandidates`(默认为 `['AGENTS.local.md', 'CLAUDE.local.md']`)会在同一目录的基础文件后加载叠加式本地覆盖层;[默认本地覆盖层记录](2026-07-21-local-instruction-overlay.md)负责说明该决策。 + +候选条目必须是同一目录中的文件名。空条目、`.`/`..`,以及包含 `/` 或 `\` 的条目会被忽略。其他同目录名称可以显式选择加入;规则目录和导入语义不属于本契约。 + +用户全局文件固定为 `$DSH_HOME/AGENTS.md`,不受任一候选列表影响,也没有本地覆盖层。`$DSH_HOME` 默认为 `~/.dsh`,与 `~/.codex` 或 `~/.claude` 在 harness 层的 home 角色一致,而不会引入插件专用 home。波浪号展开与默认值位于 `dsh-paths` 中,以便未来的 harness 功能共享同一约定。 + +### 基线前缀 + +agent loop(智能体循环)实例的第一次请求会让插件通过 `agent/session-prefix` 提供一条 user 角色消息。它先加载用户全局文件,再从 `agent.session.header.cwd` 向上遍历至配置的根标记(默认为 `.git`)以确定项目根目录,随后从根目录至 cwd 的每级目录各加载一个候选项。`.git` 文件与 `.git` 目录都是有效标记,因而能覆盖链接 worktree 和 submodule。找不到标记时,cwd 本身就是根目录。 + +插件会在 `await next()` 返回前前置其贡献,因此会话前缀贡献按插件注册顺序出现。在产品主干中,工作区指令的注册先于 skill 目录,所以它排在前面。循环会深度冻结组合后的前缀,将其记录在 `EpochHeader.messagePrefix` 中,并在该实例内逐字复用。它是请求状态,不是 `Session.deriveMessages()` 历史。 + +恢复 agent 会创建新的循环实例,并使用当前文件重新组合基线;新的前缀由恢复请求 header 锚定。这样,恢复时可以使用当前基线内容,而无需修改先前实例已经使用过的前缀。 + +基线是一条 user 角色的 ``,包含 `Instructions from: ` 章节,以及明确的权威性与优先级说明。这种熟悉的模型可见框架避免引入 harness 专用的 XML 词汇。项目路径相对于根目录;使用默认 home 时,用户全局路径为 `~/.dsh/AGENTS.md`,使用已配置 home 时则为 `$DSH_HOME/AGENTS.md`。文件内容中的字面量 `` 会被转义。包 README 负责规定当前准确的[提示词形态](../../../../packages/context/workspace-context/README.md#prompt-shape)。 + +### 动态发现与刷新 + +第一方 `read`、`write` 或 `edit` 调用成功后,`tools/post-execute` 监听器会协调被触及的后代路径链,以及该会话已经知道的每个作用域。新到达的作用域通过 `additionalContexts` 返回,并在下一次请求中使用 `Additional instructions from: ` system-reminder。在 Code Mode 下,`run_code` 会把子分发上下文延后至其外层结果,因此同一更新只会在父结果之后追加,而不会在调用中途注入。 + +内容编辑会追加 `Updated instructions from: `,说明新内容取代先前内容,并包含当前的完整文件。如果优先级从一个候选项变为另一个,消息还会指出先前路径并说明它不再适用。如果没有候选项保留,插件会追加 `Instructions removed: `,并说明先前加载的指令不再适用。 + +动态消息在 `content` 中携带完整的 system-reminder 框架;每个 `context/message` 都作为 user 角色消息逐字抵达模型,核心层不会再添加可选择退出的包装。`context/message.meta` 携带不透明 JSON 状态,该状态会持久化,但绝不会渲染给模型。 + +shell 命令不会触发发现。本地 bash 调用会启动全新的 shell,而从任意命令字符串推断已到达路径,需要实现提示词插件并不拥有的 shell 语义。 + +### 重复抑制与变更检测 + +每个动态工作区上下文事件都会存储带版本的元数据,其形态为 `{ action, scope, path, digest? }`;`digest` 是对已加载内容计算的 SHA-1。模型可见提示词中没有 HTML 注释、隐藏标记,也没有会被解析回状态的标题。 + +协调时,插件扫描自身拥有的 `context/message` 事件,并派生每个可见作用域的最新状态。一个简短的逐会话待处理映射只会在不可变的顶层 `tools/result` 证明某个 `additionalContexts` 条目经过所有 post-execute 监听器后仍然保留时开始记录;随后,它覆盖循环将该上下文追加到日志之前的间隔。每个条目记录开启状态的 `{ turn, step }`:如果相同的持久 `context/message` 出现在其序列边界或之后,该条目得到确认并被移除;如果匹配的 `step/end` 先到达,则说明循环丢弃了上下文缓冲区,插件会同时移除待处理条目及其版本缓存快速路径。嵌套的 Code Mode 结果会把变更暂存在父级的不透明执行 token 下,确保一次运行中的重复子分发不会产生重复项;父级结果会回滚这份临时状态,并且只提交外层策略保留的上下文。 + +路径和 digest 均未变化时会被抑制。日志中的移除操作是一条墓碑记录,因此重新出现的候选项会成为新的 `set`。恢复操作从持久化元数据继续工作。如果压缩(compaction)从可见表面移除某条指令事件,该状态不再抑制后续加载,这与模型已经无法看见它的事实一致。只有真正纳入字节预算的变更才会进入元数据或待处理状态,因此被省略的文件在之后的触碰中仍有资格加载。 + +被冻结的基线会保留一个内存中的 path/digest map 以供比较。后续成功的文件系统触碰会把基线编辑或移除操作追加为动态消息,绝不重写前缀。恢复时重新组合前缀的过程中,插件还会协调可见的动态作用域,因此 agent 离线期间发生的嵌套变更可以在第一次恢复请求前追加更新。 + +系统刻意不使用文件监视器。检测发生在下一次成功的结构化文件系统触碰或恢复时的前缀组合。提供方失败不会产生移除;只有该作用域中的全部已配置候选项都成功完成探测后,系统才接受「不存在」这一结论。 + +### 字节预算与有界读取 + +`maxBytes` 是必填项,分别作用于渲染后的基线或单个动态协调批次;系统不存在隐式或无界的渲染预算。非正数或非有限值会禁用加载。内容超过预算时,系统会先省略较宽泛的文件,再截断最具体的文件。可见的 `Workspace instruction budget ...` 提示会指出被省略和截断的路径与字节数,并且输出绝不超过配置字节数。 + +`maxSourceBytes` 是正数的逐文件上限,默认为 1 MiB。loader 会在读取前检查报告的大小,同时仍通过 `streamText()` 消费内容并持续统计 UTF-8 字节数,因此缺失/陈旧的元数据无法迫使其进行无界分配。过大的胜出候选项会被视为不可用,而不是改为同目录中的下一个名称。插件刻意不保留进程级缓存,也绝不保留指令正文。它只为每个有效作用域保存 `{ path, version, digest }`,并将这些状态放在 `WeakMap>` 中:提供方 `FsVersion` 与有效提示词状态同时匹配时跳过读取;版本变化则触发有界读取和 SHA-1 确认。SHA-1 仍是持久化在可见结构化元数据中的跨提供方内容标识;提供方版本只作为内存中的失效快速路径。模型可见变更的缓存转换只有在相应上下文通过完整的工具结果策略链后才会提交;如果该已接受上下文随后与中止步骤一起被丢弃、未能进入日志,缓存转换就会失效。 + +## 考虑过的替代方案 + +**使用全局 `ctx.systemPrompt.section()`。** 不予采纳,因为同一个 Cordis 上下文可以承载 cwd 不同的多个会话,而仓库所有的文本属于低权威用户上下文,不是最高权威的提供方系统内容。 + +**在每次 `agent/pre-step` 时注入基线。** 不予采纳,因为重复注入历史会浪费 token、使重复状态复杂化,并妨碍提供方前缀保持结构稳定。前缀组合提供冻结、已记录且逐实例的基线,动态仅追加消息则负责变更。 + +**在一个目录中同时加载 `AGENTS.md` 和 `CLAUDE.md`。** 不予采纳,因为正在迁移的仓库通常会在两个文件中重复指引。按顺序排列的候选项让优先级显式且可配置。 + +**解析渲染后的标题或隐藏注释以恢复已加载状态。** 不予采纳,因为指令正文可能包含相同文本,导致无提示的误报。持久化 JSON 元数据提供明确且对模型不可见的状态通道。 + +**使用模型总结文件。** 不予采纳,因为指令文件本身已经是经过整理的摘要;再执行一次模型调用既不确定,也可能抹掉边界情况要求。使用带字节预算的确定性全文更简单。 + +## 影响 + +工作区指引按会话隔离,并由 demo 前端、Web Host 与每一种工具展示模式共享。初始指令受益于稳定的前缀缓存,嵌套与变更内容则保持持久且可回放。通用的 session/agent 上下文契约通过 prompt-submit 与工具执行后的 `additionalContexts` 数组携带 JSON 元数据,而不会把条目展平。 + +仓库文本仍是不受信任的输入。低权威 user 角色框架、显式优先级说明和分隔符转义可以降低风险,但无法消除提示词注入。跟随候选符号链接到目标,会把该接口扩大至树外内容;因此,把 `ctx.fs` 限制在可信根目录内的权限与沙箱层才是真正的边界,它们让系统把工作区文件当作数据而不是权威([跟随指令符号链接记录](2026-07-21-follow-instruction-symlinks.md)负责说明残余风险)。 + +系统由事件驱动,而不是文件监视器驱动。除非文件系统变更通过结构化工具完成,否则编辑不会在确切的文件系统变更时刻可见;外部文件变更会在下一次成功的结构化触碰或恢复时被发现。这使设计保持确定性并且与提供方无关。 + +## 延后事项 + +从 bash 派生路径报告、递归启动扫描、文件监视器、小写默认名称、`.claude/CLAUDE.md`、`.claude/rules/*.md`、导入指令、ACP `additionalDirectories`、信任确认和模型生成摘要均延后处理。项目目录中的 `.local.` 覆盖层现已默认加载([默认本地覆盖层记录](2026-07-21-local-instruction-overlay.md)负责说明该决策);用户全局覆盖层、目录规则系统和导入仍需要各自的优先级与信任设计。 diff --git a/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml index 6ac2ad45f6..d225ddf2d5 100644 --- a/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-06-approval-seam.md: 852108f22be22eeba4578032924adb546ee10985 -2026-07-06-approval-seam.zh.md: 9a08f333e0859e6d71f40b039f4b441028c38dc3 +2026-07-06-approval-seam.md: 70ccd4d486ad6e0126fa2eb638a064e9fc89bba6 +2026-07-06-approval-seam.zh.md: d218f79888957735305db14cd97cc74480297d29 diff --git a/.agents/notes/implemented/feature/2026-07-06-approval-seam.md b/.agents/notes/implemented/feature/2026-07-06-approval-seam.md index 852108f22b..70ccd4d486 100644 --- a/.agents/notes/implemented/feature/2026-07-06-approval-seam.md +++ b/.agents/notes/implemented/feature/2026-07-06-approval-seam.md @@ -69,7 +69,7 @@ The seam also owns the session-scoped `'ask' | 'never'` policy described by [the The ACP bridge answers only for an exact agent object owned by its session map. It sends `session/request_permission` with the existing `callId`, advertises one-shot allow/reject options, maps cancellation separately, and never grants an unknown option. Foreign or call-less requests delegate; a failed client RPC becomes `unavailable`. Hooks and `tools/pre-execute` decide whether a call asks at all. This channel is machine policy between an automated client and its agent, not ACP presentation. -The answerer routes through the bridge's exact-agent ownership check described by [the ACP support Agent Note](2026-06-14-acp-agent-client-protocol.md), preserving the per-session permission ownership required by [the multi-session Agent Note](2026-06-14-acp-multi-session.md). +The answerer routes through the bridge's exact-agent ownership check described by [the automation-only ACP Agent Note](../simplification/2026-07-23-acp-automation-only-protocol.md), preserving the per-session permission ownership required by [the multi-session Agent Note](2026-06-14-acp-multi-session.md). #### Audit, and what the model sees @@ -135,5 +135,5 @@ In-repo precedents this design copies or contrasts with: - The `fs/write-intent` gate (`packages/fs/fs/`) — the documented single-occupancy decision-slot waterfall semantics (first answer wins, delegate via `next()`) the answerer contract reuses. - `hook/invoked`/`hook/result` — the log-only audit-pair precedent `approval/asked`/`approval/decided` follows; [the hook-bridges Agent Note](2026-06-30-hook-bridges.md) ships `permissionDecision: ask`, the first producer. - [The interception-seams Agent Note](2026-06-30-interception-seams.md) — the `tools/pre-execute` `allow`/`deny`/`ask` vocabulary whose `ask` this seam services. -- [The ACP support Agent Note](2026-06-14-acp-agent-client-protocol.md) — the exact-agent ownership check against the session map that the answerer routes through; [the multi-session Agent Note](2026-06-14-acp-multi-session.md) — the per-session permission-ownership blocker this implements. +- [The automation-only ACP Agent Note](../simplification/2026-07-23-acp-automation-only-protocol.md) — the exact-agent ownership check against the session map that the answerer routes through; [the multi-session Agent Note](2026-06-14-acp-multi-session.md) — the per-session permission-ownership blocker this implements. - The opportunistic `ctx.get()` consumption pattern (`tool-bash`'s owner-token lookup, the loop's persistence probe) — how `dsh-tools` consumes the seam without gating its fiber on it. diff --git a/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md b/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md index 9a08f333e0..d218f79888 100644 --- a/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md @@ -69,7 +69,7 @@ seam 还拥有[沙箱 Agent Note](2026-07-06-sandbox.md) 所描述的会话级 ` ACP 桥只应答其会话映射所拥有的精确 agent 对象。它携带既有 `callId` 发送 `session/request_permission`,声明一次性的 allow/reject 选项,单独映射取消,并且绝不批准未知选项。外部或无调用标识的请求会委派;客户端 RPC 失败变为 `unavailable`。钩子和 `tools/pre-execute` 决定一次调用是否需要询问。该通道是自动化客户端与其 agent 之间的机器策略,不是 ACP 展示层。 -应答者通过 [ACP 支持 Agent Note](2026-06-14-acp-agent-client-protocol.md) 描述的桥精确 agent 归属检查进行路由,保留了[多会话 Agent Note](2026-06-14-acp-multi-session.md) 要求的每会话权限归属。 +应答者通过[仅面向自动化的 ACP Agent Note](../simplification/2026-07-23-acp-automation-only-protocol.md)描述的桥精确 agent 归属检查进行路由,保留了[多会话 Agent Note](2026-06-14-acp-multi-session.md) 要求的每会话权限归属。 #### 审计,以及模型看到什么 @@ -135,5 +135,5 @@ ACP 桥只应答其会话映射所拥有的精确 agent 对象。它携带既有 - `fs/write-intent` 门禁(`packages/fs/fs/`)——文档化的单占用决策槽 waterfall 语义(先到先得,通过 `next()` 委派),应答者契约复用了它。 - `hook/invoked`/`hook/result`——仅日志审计对先例,`approval/asked`/`approval/decided` 沿用了它;[钩子桥 Agent Note](2026-06-30-hook-bridges.md) 交付了 `permissionDecision: ask`,即第一个生产者。 - [拦截 seam Agent Note](2026-06-30-interception-seams.md)——`tools/pre-execute` 的 `allow`/`deny`/`ask` 词汇,本 seam 服务其中的 `ask`。 -- [ACP 支持 Agent Note](2026-06-14-acp-agent-client-protocol.md)——应答者路由时对会话映射执行的精确 agent 归属检查;[多会话 Agent Note](2026-06-14-acp-multi-session.md)——本设计实现的每会话权限归属阻塞项。 +- [仅面向自动化的 ACP Agent Note](../simplification/2026-07-23-acp-automation-only-protocol.md)——应答者路由时对会话映射执行的精确 agent 归属检查;[多会话 Agent Note](2026-06-14-acp-multi-session.md)——本设计实现的每会话权限归属阻塞项。 - 机会性 `ctx.get()` 消费模式(`tool-bash` 的 owner-token 查找、loop 的持久化探测)——`dsh-tools` 消费该 seam 而不阻塞其 fiber 的方式。 diff --git a/.agents/notes/implemented/feature/2026-07-07-plan-mode.md b/.agents/notes/implemented/feature/2026-07-07-plan-mode.md deleted file mode 100644 index f5b76fae5b..0000000000 --- a/.agents/notes/implemented/feature/2026-07-07-plan-mode.md +++ /dev/null @@ -1,194 +0,0 @@ -# Agent Note: Plan mode — a logged per-agent session mode - -Status: implemented - -> **Superseded vocabulary (2026-07-22):** [Collapse named session modes into plan mode](../simplification/2026-07-22-plan-specific-collaboration-state.md) replaces this note's generic `dsh-mode`, `mode/set`, definition map, and `ctx.modes` design with the current plan-specific `dsh-plan-mode`, `plan/mode`, `{ section }`, and `ctx.planMode` contract. The review, boundary, reconstructability, and sandbox-orthogonality decisions below remain in force; generic API examples are retained as the historical design this simplification removed. - -> **Superseded ACP mapping:** [ACP as an automation-only protocol](../simplification/2026-07-23-acp-automation-only-protocol.md) removes the picker, config-option, and elicitation mappings described below. Plan mode remains available to human-facing interfaces. - -## Problem - -Before this change, the harness had no durable way to put one agent into a distinct working stance. Plan mode needs the agent to explore and design under planning guidance, produce a reviewable artifact, cross an explicit approval boundary, and restore that state across resume and fork without making the model-visible request diverge from the session log. - -The extension seams already supplied the surrounding pieces: [`system-prompt/assemble`](../../../../packages/core/system-prompt/README.md) shapes guidance per step and the shipped request is logged in `request/header*` events ([reconstructability](../../implemented/architecture/2026-07-05-reconstructable-requests.md)); [`ctx.userInteraction`](../../../../packages/ui/user-interaction/README.md) carries the approval question and corrective feedback ([ask-user precedent](../../implemented/feature/2026-06-25-ask-user-question.md)); `SessionEventMap` carries durable per-agent facts ([the `todo/write` precedent](../../implemented/feature/2026-06-29-todo-write-tool.md)). The missing piece was the named session state that joins those seams while leaving execution enforcement on the independent sandbox and approval axes. - -## Decision - -The deliverable is **plan mode**. It ships as the first **session mode** — a named, logged, per-agent COLLABORATION state: a mode definition is deployment-configured guidance the model sees, while the mode IN FORCE for an agent is session state folded from its log. Modes are one axis and the enforcement knobs — the sandbox mode, the approval policy — are others: they never read or write each other, matching how Codex keeps its Plan/Default collaboration presets separate from its sandbox and approval settings. One new product package, `@deepseek-ai/dsh-mode` at `packages/mode/mode/`, owns the event vocabulary, a thin `ctx.modes` service, and every listener; the loop does not change. `plan` is the only required definition — the mode-shaped vocabulary exists so a second mode never renames durable event types, not because more modes ship now. - -The state is one `SessionEventMap` member: **`mode/set`**, a log-only, non-surface event carrying `{ mode: string }` with whole-value-replace semantics, plus a pure `foldMode(events)` that returns the mode in force — the last `mode/set`, or the default mode when none exists. Because [the log is the fact channel](../../implemented/architecture/2026-06-30-event-domain-semantics.md), resume, fork, and compaction restore the mode with no extra machinery, and UIs read flips off `session/event`. The default mode is the absence of mode guidance — no section, filtering, or gate. Loading `dsh-mode` still contributes one stable `exit_plan_mode` schema in every mode; that fixed cost avoids tool-catalog churn at mode boundaries. - -A mode's whole surface is soft: a `mode:policy` prompt section renders the active definition's guidance, while `exit_plan_mode` remains in the registered tool catalog across every mode and rejects at execution unless the folded mode is `plan`. A transition therefore changes only the system-prompt portion of the attributable `request/header` on the next step, keeping [reconstructability](../../implemented/architecture/2026-07-05-reconstructable-requests.md) green without changing native schemas or Code Mode's SDK. A mode deliberately enforces NOTHING: no execution gate, no tool filtering, no reach into the sandbox or approval knobs — a user who wants a hard read-only floor while planning switches the sandbox-mode option beside the mode picker, in either order, and neither axis disturbs the other. There is likewise NO per-mode tool allow/deny list — which tools a mode admits is an effects question, parked until tool definitions declare their effects ([Deferred](#deferred)); a mode's restraint is its section's guidance plus the exit review. - -The model leaves plan mode through the **`exit_plan_mode`** tool: its single argument is the plan text, which makes the plan reconstructable from the log, and the tool conducts the review itself through the user-interaction seam — a question whose supporting detail carries the exact plan, with options and a free-text channel, not a bare permission — so an approval flips the logged mode back to the default, and a rejection becomes the corrective error carrying the user's feedback verbatim, which keeps the model planning with direction. A user flips the mode from any surface through `ctx.modes.set()`; the flip is applied at the next turn boundary (session events are turn-enclosed) and narrated to the model once, only when the model-visible state actually changed. - -## High-level API - -### A plan-mode session end to end - -The user switches the session to plan mode through the ACP mode picker or `/plan [message]` in a terminal front door, and from the next step every request ships the configured plan guidance section. When the optional message is present, that same command submits it into the affected step. The `exit_plan_mode` schema was already present in default and remains byte-identical. - -The model explores and designs; the section's guidance is what defers changes into the plan. The sandbox and approval knobs keep whatever the user set them to — a deployment (or user) that wants kernel-enforced read-only during planning pairs plan mode with the independent sandbox-mode option. - -When ready, the model calls `exit_plan_mode` with the plan markdown as its argument; the review question carries that exact markdown as supporting detail — approve, or keep planning, with free-text feedback welcome. A native call also renders the plan card; a Code Mode nested dispatch has no native card, so the review detail is the common presentation surface. - -On approve, the tool flips the logged mode back to the default: the next step drops the plan section while retaining the same tool catalog (the changed header is in the log), and execution tracking from there is already `todo_write`'s job. On keep-planning, the model receives a corrective error carrying the user's feedback text, revises, and re-presents. - -### Deployment configuration - -Mode definitions are validated plugin Config — per repo convention, changeable from `cordis.yml` with no code edit. The deployment must provide the complete `plan` section; the package embeds no model instructions. Additional modes use the same config map: - -```yaml -- id: mode - name: '@deepseek-ai/dsh-mode' - config: - modes: - plan: - section: | - You are in plan mode: explore and design, then present the - plan for approval through exit_plan_mode. -``` - -A definition is exactly `{ section }` — there is deliberately no per-mode tool list and no enforcement field ([FAQ](#faq)). Definition names use the lowercase slash-command subset `/^[a-z][a-z0-9_-]*$/u`; `default` is reserved (the absence of policy) and rejected as a key. An invalid name or unknown definition key — a `tools` list or an `access` cap included — fails validation at load; an unknown mode name fails loudly at `set()` time. - -### In the terminal - -Terminal front doors get one entry command per configured definition through the plugin-owned command registry (`@deepseek-ai/dsh-commands`): `dsh-mode` registers `/plan [message]` for the required definition and, for example, `/review [message]` when `review` is configured. Each command records its named switch; a non-empty optional message is trimmed and passed to `agent.steer()`, which places it in a running agent's next step or delegates to `send()` for a new idle turn. The command name and result stay out of model history, while that explicit message is logged as an ordinary user message under the selected mode. The synthetic `default` entry contributes no command. The exit review prompts right in the terminal with no new machinery: it is an ordinary user-interaction question, so it rides the composed user-interaction provider's prompt queue that `ask_user_question` already uses. - -### Over ACP - -The mode PICKER is this package's surface: `session/new`/`session/load` advertise `availableModes`/`currentModeId` from `ctx.modes` (consumed opportunistically via `ctx.get`, the `tool-bash` pattern), `session/set_mode` calls `set()` and notifies `current_mode_update` optimistically (the pending mode IS the user's selection; the logged `mode/set` follows at the boundary), and a `session/event` listener re-notifies on each logged flip that differs from the last sent. The exit tool reuses the user-interaction ACP provider's elicitation flow; its ACP mapping carries the review `detail` because Code Mode nested dispatches have no native plan card, while native calls may additionally stream the plan card. Individual environment knobs — sandbox mode, approval policy, the model — are NOT modes and belong to `session/set_config_option` ([FAQ](#faq)). - -### For agent creators - -`ctx.modes` is the whole programmatic surface: `list()` returns the configured definitions plus the synthetic `default` entry (for pickers), `get(agent)` returns the folded mode plus any pending intent, and `set(agent, mode)` validates the name against `list()`'s vocabulary and records the boundary-applied intent — `default` is always a valid target, so exiting a mode is the same call as entering one. There is no creation-time mode option — a caller selects through `set()` before the first turn, which flushes identically. There is no live `agent/*` mirror to subscribe: UIs read `mode/set` off `session/event`, per [event-domain semantics](../../implemented/architecture/2026-06-30-event-domain-semantics.md). - -## Detailed design - -### Vocabulary - -```text -'mode/set': { mode: string } // SessionEventMap merge in dsh-mode: log-only, non-surface, - // whole-value replace — the last one in the log wins -DEFAULT_MODE = 'default' // the fold of a log with no mode/set; reserved, not definable -``` - -The payload carries no reason/provenance field: a tool-driven flip sits next to its `tool/call` in the log and a user flip sits at its turn boundary, so the cause is log-adjacent — the same "narrative fields are derivable" call the [reconstructability Agent Note](../architecture/2026-07-05-reconstructable-requests.md) made for request-header facts (the in-flight `env/state` event carries a `source` precisely because its drift variant has NO log-adjacent cause — a contrast, not a conflict). Mode names are config-declared vocabulary, not opaque cross-boundary ids, so they stay bare strings (no `Branded`). - -### Config and the resolve step - -```text -interface ModeDefinition { section: string } // prompt text — a mode's whole vocabulary -interface ModeConfig { modes: Record } // plan is required and owns its complete prompt -resolveConfig(config): ResolvedModes // explicit resolve (the dsh-bash template), fail-loud: - // missing plan, 'default', blank sections, and unknown keys rejected -``` - -The one-field shape is deliberate minimalism, not the final vocabulary: a per-tool policy dimension returns as effects metadata on tool definitions ([Deferred](#deferred)), read here rather than re-declared per mode — the config shape must not need a migration when it arrives. - -### The fold, the service, and the flush - -`foldMode(events)` is pure (exported for reconstructors and tests) and folds the append-only session log directly; `mode/set` is not a surface node, so compaction cannot shadow it. `set(agent, mode)` validates the name against `list()`'s vocabulary — the configured definitions plus the reserved `default`, which is rejected as a config KEY but always accepted as a `set()` TARGET — drops a no-op (target equals pending, else current), and otherwise records `{ mode, narrate }` in a `WeakMap` pending-intent slot. It cannot append immediately because [every session event is turn-enclosed](../../implemented/architecture/2026-06-15-turn-enclosure-invariant.md) and an idle agent has no open turn. - -Contained listeners on the loop's interception seams ([defensive patterns](../../../../docs/defensive-patterns.md): a policy plugin must not block a prompt or a turn) flush the pending intent as a `mode/set` append — `agent/prompt-submit` fires inside the just-opened turn before its first assembly, and `agent/turn-continuation` fires after an ordinary step closes before its successor. Automatic request recovery bypasses continuation, so a prepended `agent/request-error` wrapper delegates through the composed policy and asynchronous backoff, then flushes only a `retry` decision before the waterfall returns to the loop; an effect-scoped lifetime guard suppresses a captured wrapper that resumes after plugin disposal. All three paths sit outside tool execution and log publication (post-commit `session/event` observers are observe-only), so every step runs under the mode its assembly folded. When the flushed mode differs from the fold at the last `request/header`, the flush appends one coalesced `context/message` notice in the same frame ("The user switched this session to plan mode."); the user-visible narration cases are enumerated in the [FAQ](#faq). - -### The soft layer: a computed section and a stable exit schema - -The registered prompt section reads the calling agent's mode from `AssembleContext.agent` and resolves to the active definition's guidance or `''`. The loop renders per step and logs a complete `request/header` whenever the rendered header changes, so entering or leaving a mode is attributable. The section is static per mode and the plan itself stays in the conversation as messages and tool arguments; re-injecting separate plan state on every request ([Prior art](#prior-art)'s compaction-survival hack) is unnecessary prompt churn. - -The guidance contribution is `{ name: 'mode:policy', order: 50, text: context => … }`: after persona (0), before tool guidance (100–199), and empty for default or agent-less assembly. `exit_plan_mode` is registered once through `ctx.tools` and never filtered, so native schemas and Code Mode's generated SDK remain byte-identical across mode switches; a deployment without `dsh-mode` lacks that one binding. There is NO `tools/pre-execute` listener: a mode gates nothing, while the exit tool's own folded-mode check rejects out-of-plan calls. The exit review is a question with options and feedback, not a permission, so it lives inside the tool's execution over the user-interaction seam. - -### `exit_plan_mode` - -`defineTool` has one required `plan: string` argument. Native execution records it in the ordinary `tool/call`; Code Mode records the outer `run_code` source before execution and appends the normalized nested arguments in `tool/code-dispatch` after the dispatch settles. `execute` rejects an agent-less call (the [`todo_write` precedent](../../implemented/feature/2026-06-29-todo-write-tool.md)), rejects any folded mode other than `plan`, rejects an empty or heading-less plan before asking the reviewer, then conducts one single-select `ctx.userInteraction.ask()` review whose `detail` is the exact plan — approve or keep planning — with free-text feedback open. Only exactly one `Approve` selection consents; every other shape fails closed. Approval records a SILENT boundary-applied intent to switch to `default` and returns a short confirmation. The deployment guidance tells the model to make this the only and final tool call in its response; if a model violates that rule, the runtime still holds plan guidance for the rest of the batch, and the next step logs a changed header with the guidance removed and tool schemas unchanged. Every non-approval outcome returns a corrective `isError` and leaves the mode in `plan`. - -Its [render intent](../../implemented/architecture/2026-07-02-tool-render-intent-union.md), decided up front: `presentCall` is a `generic` card titled by the plan's first heading with the plan markdown as content, plus a `generic` result card. Native front doors show that card before the question; Code Mode nested dispatches do not produce native call-card events, so the user-interaction `detail` independently carries the same plan on every provider. The seam is consumed opportunistically (`ctx.get('userInteraction')`), so `dsh-mode` composes without it and degrades to the manual exit pinned in the [FAQ](#faq). - -### Dependencies and surfaces - -`dsh-mode` is one product package, not a capability-seam trio ([Alternatives considered](#alternatives-considered)): it peers on `cordis`, `dsh-session`, `dsh-agent`, `dsh-tools`, and `dsh-system-prompt`, injects `['tools', 'systemPrompt']`, and reads `ctx.userInteraction` opportunistically at execute time (a type-only peer edge on `dsh-user-interaction`); its only UI-facing edges are optional type-only peers (`dsh-commands` for the per-definition entry commands). Beyond the `ctx.modes` call surface everything participates through listeners, so dropping the package gracefully removes modes rather than breaking a consumer. Terminal front doors need no mode-specific code: `dsh-mode` itself registers each definition's command on the command registry when one is composed (an optional type-only peer edge on `dsh-commands`), and the exit review rides the composed user-interaction provider's prompt queue. The ACP wire mapping is pinned in [High-level API](#over-acp); package-wise the bridge takes a type-only peer edge on `dsh-mode` and reads the service opportunistically, so a bridge without the plugin behaves exactly as today. - -### The recorded scenario and the harness op - -`input.json` gains one step op, `{ "op": "setMode", "modeId": "plan" }`, driven through the real `session/set_mode` RPC, and a scripted `elicitationAnswers` queue. The `plan-mode` scenario enters plan before turn 1, runs a real `cat` under the independently configured sandbox, presents a plan through `exit_plan_mode`, receives scripted approval, then edits on the next step. The first `request/header` contains the full stable toolset plus the configured mode section; the post-approval changed header retains byte-identical tool schemas and removes only that section. `plan-mode-reject` pins corrective free-text feedback and the unchanged plan state. Both recordings replay host commands under Seatbelt or bwrap; backend-specific sandbox denial stays at the bash-tool unit tier. - -### The mechanical tail - -No new cordis event is declared (`mode/set` rides `session/event`; the listeners attach to existing waterfalls), so the events catalog is untouched. Regenerated in the same change: the persistence log catalog (`mode/set`), the services catalog (`ctx.modes`, JSDoc-complete), the config catalog (`ModeConfig`), the tool catalog (`exit_plan_mode`), the producer/consumer map and doc graphs, and the module graph. Repo plumbing: a root tsconfig `paths` entry, the new group's README plus a [packages map](../../../../packages/README.md) row (a new top-level group is the deliberate act that table names), an `architecture.md` capability-services row for `ctx.modes` (budget-checked), and the cookbook row upgrade. - -## Deferred - -Each behind its own decision: subagent mode inheritance via a forwarded creation-time mode option (removed as unconsumed; it returns with its first consumer), preset modes beyond `plan` (read-only, accept-edits), the idle-record primitive if pending-intent loss proves real, and — the big one — **effects self-declaration on tool definitions**: a per-tool read-only/mutating classification (the MCP `ToolAnnotations` vocabulary — `readOnlyHint`/`destructiveHint` — is the natural template, with its untrusted-hint caveat implying trust tiers). That item is what a general per-mode tool policy waits on: this Agent Note first shipped an interim per-mode name allowlist and removed it before release — a hand-maintained list mislabels the effects question, must track every tool a deployment composes, and rots silently as tools arrive — so mode-scoped tool availability (and per-tool `ask` policies) returns as a CONSUMER of declared effects, which is its restart trigger. - -The ACP automation composition does not mount plan mode or the question tool. Human-facing compositions own plan selection and review; focused plan-mode tests and interactive-interface snapshots pin its logged state, guidance, review, and stable tool schemas. - -## FAQ - -Behavioral clarifications of the chosen design; rejected designs live in [Alternatives considered](#alternatives-considered), accepted costs in [Consequences](#consequences). - -**When does a user's mode flip take effect?** At the next pre-assembly boundary: `agent/prompt-submit` covers the first step, `agent/turn-continuation` covers a normal successor, and the post-composed `agent/request-error` retry decision covers automatic recovery. A mode selected while a request or retry backoff is in flight therefore shapes the following model request. This is the "applies to subsequent requests" semantics every product in [Prior art](#prior-art) ships. - -**When is a mode change narrated to the model?** Only when the model-visible state actually changed: the flush compares the flushed mode against the fold at the last `request/header` and narrates once, coalesced. A net-zero flip sequence (plan then back, all before the boundary) narrates nothing; a tool-driven exit narrates through its own tool result instead; a mode set before the first turn narrates nothing — the section is the state statement. The principle is the in-flight env-state proposal's boundary narration: a silently flipped prompt surface leaves the transcript arguing from a state the header no longer has. - -**What happens on resume when the config no longer defines the folded mode?** A folded mode name the current config no longer defines behaves as the default mode without a notice, so the session neither gains a substitute restriction nor becomes unusable. `set()`'s loud validation covers only the write path; a resumed log answers to the config it finds. - -**What if a deployment composes no user-interaction provider?** Plan mode stays safe but manual: `ctx.userInteraction.ask()` throws `NO_PROVIDER` (and an absent seam never resolves at all), the tool returns the corrective `isError`, and the exit degrades to the user toggling modes — never to an unreviewed exit. The mode section tells the model to present its plan through `exit_plan_mode` — and to ask the user in prose if that fails — so it keeps presenting instead of stalling. - -**Why is there no per-mode tool allowlist?** Because "which tools are safe in a planning mode" is a property of each TOOL (its effects), not of the mode — a per-mode name list re-declares that fact in the wrong home, must enumerate every tool the deployment composes (MCP servers included), and rots silently as tools arrive. Until tool definitions declare their effects ([Deferred](#deferred), where the removed interim allowlist is archived with its restart trigger), a mode restrains by its section and the exit review; the exposure is an accepted cost ([Consequences](#consequences)). - -**Do subagents inherit the parent's mode?** A fork child inherits for free — the parent's `mode/set` is inside the seeded prefix. A spawn child starts in the default mode; a creation-time mode option and automatic forwarding by subagent providers are deferred together ([Deferred](#deferred)). - -**How does plan mode relate to the sandbox's read-only mode?** They are separate axes that never touch: the mode is the collaboration stance (a `mode/set` fold), the sandbox mode is an enforcement knob (a `bash/sandbox-mode` fold, [the sandbox Agent Note](2026-07-06-sandbox.md)) — plan mode neither reads nor caps it, exactly as Codex keeps its Plan/Default presets separate from its sandbox and approval settings. A user who wants kernel-enforced read-only while planning sets both: flip the mode picker AND the sandbox-mode option, in either order; each switch changes only its own fold, so there is no interference and no restore step to crash out of. The log attributes each axis to its own event — the stance to `mode/set`, the confinement to `bash/sandbox-mode`. - -**Why aren't sandbox mode, approval policy, or the model themselves modes?** They are individual environment knobs independent of collaboration state. The retired ACP mapping is recorded by the [automation-only protocol decision](../simplification/2026-07-23-acp-automation-only-protocol.md). A mode definition may later bundle env facts (applied through `ctx.envState` where mounted) so a Codex-style preset stays a single mode; fusing approval policy into the mode CONCEPT itself is rejected in [Alternatives considered](#alternatives-considered). - -## Prior art - -A survey of shipped plan modes (Claude Code, Cursor, Copilot, OpenCode, Gemini CLI, Cline, Windsurf, Codex) shows the same five parts everywhere — the low-authority tool policy, plan artifact, approval moment, execution-state switch, and durable state that [Problem](#problem) builds on. - -The mode surface is a LIST everywhere it is advertised, never a boolean: Claude Code's picker offers `plan` beside `acceptEdits` (plus an auto-mode entry into plan), and Codex exposes `Plan` beside `Default` as collaboration-mode presets while keeping approval and sandbox settings separate. The ACP transport does not advertise this human-facing control. - -The deployment-owned example prompt borrows the instrumental behavior, not product-specific mechanics. From Codex: remain in plan mode despite imperative implementation language, explore before asking, distinguish repository facts from user-owned choices, and make the plan decision-complete across APIs, data flow, failures, tests, and assumptions. From Claude Code: prohibit mutations and commits, prefer existing patterns, use questions only for requirements or approach choices, and finish through the exit tool rather than a prose approval request. It deliberately omits Codex protocol tags and Claude's plan-file or phased-subagent machinery because those belong to their runtimes, not this plugin contract. - -The ecosystems that leave modes to convention show the failure shapes to avoid. Pi-style mode extensions fight over a last-wins global active-tool list, enforce "read-only" by prompt text alone (a hallucinated call to a still-registered tool executes), and re-inject plan state into every request to survive compaction. The contested global list and the re-injection hack close structurally here — per-agent folded state, and a log-only non-surface event compaction cannot shadow. The prompt-only shape, by contrast, is deliberately KEPT — it is what Codex ships for Plan, and it is why the mode axis composes freely with the enforcement axes: a deployment that wants a hard floor pairs the mode with the independent sandbox knob instead of the mode carrying its own enforcement ([FAQ](#faq)). - -## Alternatives considered - -**Permission modes as the concept (the Claude Code shape).** One `permissionMode` fusing approval policy and tool policy. Here those are two axes with two owners: the approval seam owns "who answers this question", modes own "what surface does the model get". ACP models them as related but distinct (a mode may select an approval policy later — a mode definition gains a field, not a merger). - -**A capability-seam trio.** Interface/implementation/consumer fits a swappable backend; a mode's variable parts are config values, not implementations. Splitting would manufacture an empty implementation package — the same "don't split preemptively" call the approval seam and [`todo/`](../../implemented/feature/2026-06-29-todo-write-tool.md) made. - -**Loop-owned mode state.** Rejected on the standing rule (plugins, not loop changes): every hook the feature needs — assemble, pre-execute, turn boundaries, session events — is already a documented seam, so a loop edit would buy nothing but coupling. - -**A per-mode tool allowlist with a deny-by-default gate (the first shipped shape).** Removed before release. A hand-maintained name list re-declares a per-TOOL fact (its effects) per MODE: it must enumerate every tool the deployment composes — MCP servers and future registrations included — and it rots silently as tools arrive (a new read-only tool is blocked until someone edits every mode; the author burden lands on whoever knows the mode, not whoever knows the tool). It also over-promises: the list looks like a security boundary while the real boundary for anything non-shell does not exist. The general dimension is parked on effects self-declaration ([Deferred](#deferred)); the consequence — plan mode is guidance-only, the very Pi hole the gate once closed — is accepted deliberately, priced in [Consequences](#consequences). - -**An `access` sandbox cap on the mode (the second shipped shape).** Also removed before release. `ModeDefinition.access` clamped the bash seam's per-call sandbox resolution to a mode-declared ceiling (a `bash/resolve-mode` waterfall + ladder-min listener, with guards withholding bash under an unconfinable executor and denying escalation mid-mode). The state stayed orthogonal — the clamp never wrote the sandbox knob — but the AXES did not: entering plan changed what the sandbox enforced, fusing the collaboration stance with an enforcement level and contradicting the Codex-shaped separation the review converged on (Plan/Default presets never touch sandbox or approval settings). One user-visible symptom of the fusion: flipping the sandbox option to `workspace-write` while planning silently did nothing. The cap, the waterfall, and the mode→bash dependency edge were removed together; a deployment gets kernel-enforced read-only planning by pairing the mode with the independent sandbox-mode option, and a mode-triggered PRESET (a mode definition bundling suggested knob values, applied as ordinary knob switches) can return later without re-fusing the axes. - -**Runtime-only mode (UI- or bridge-local, unlogged).** Resume and fork would silently drop the mode, and the header deltas a mode causes would have no attributable cause in the log. Logged state is what makes the mode auditable and restorable for free. - -**Mode flips as `context/message` via `agent.inject()`.** Reuses an existing turn-enclosure path, but puts policy state into the model transcript — the model does not need to be told twice (the section already tells it), and a log-only fact should not occupy surface. - -**A plan-file store (`.plans/` directory).** A second durable home for what the log already carries replayably; a deployment wanting files can add a tool that writes them. One home per fact. - -**A boolean `planMode` instead of named modes.** Too narrow for the surface the repo already tracks: ACP advertises a mode LIST and the shipped pickers fill it with more than plan ([Prior art](#prior-art)); generalizing later would rename durable event vocabulary. The string-shaped mechanism costs nothing extra now; only `plan` ships as a definition. - -**A tool-policy-stack service (the Pi-critique remedy).** A dedicated composition service for tool policies is premature: this implementation performs no mode-scoped tool filtering, and future effect policies can compose through the existing guarded execution seams. Formalize only when declared tool effects create a concrete composition requirement. - -**Exit approval through the approval seam (a `{ kind: 'ask' }` gate decision).** The original sketch, natural while the approval seam was the only asking machinery in flight — but it seats a review in a permission chair: the seam's outcome vocabulary is deliberately closed and one-shot (`allowed-once`/`rejected`), so a rejection carries no feedback and an approval can never grow options (approve-and-accept-edits). The exit moment is a question, not a permission — the user-interaction seam gives it options plus the free-text channel, and the rejection feedback reaches the model verbatim. The approval seam remains the right seat for genuine permission gates (the sandbox escalation), and the registry's `ask` vocabulary stays available to deployments that want one there. - -**Exit by prose or steering instead of a tool.** No artifact and no approval moment — the tool's argument IS the reviewable plan, and its review question is what gives the human a structured yes/no attached to the exact transition. - -## Consequences - -What holds now, pinned by the unit, protocol, snapshot, and real-API tiers: - -- The mode in force is a pure function of the session log: resume and fork restore it with no extra machinery, and a `mode/set` is followed by a matching complete `request/header` on the next changed step. -- A user-driven flip narrates exactly once at the next boundary and a net-zero flip sequence narrates nothing; a tool-driven exit narrates only through its tool result. -- In default mode the plugin contributes no mode section but does contribute the stable `exit_plan_mode` schema; a deployment without `dsh-mode` lacks that binding. -- Native tool schemas and Code Mode's SDK stay byte-identical across default, plan, and custom-mode transitions; only the configured guidance section changes. -- Plan mode changes nothing on the enforcement axes: the toolset, the sandbox mode, escalation, and the approval policy behave identically in plan and default — pairing the mode with the independent sandbox/approval knobs is how a deployment hardens planning. -- Mode definitions are changeable from `cordis.yml` with no code edit; the complete plan instructions are required there, while missing plan config, malformed definitions, and unknown keys fail at load and unknown mode names fail at `set()`. -- `exit_plan_mode` is always advertised, rejects outside plan, drops only plan guidance after approval, and carries keep-planning feedback in a corrective `isError`; each human-facing surface's user-interaction provider carries the review. -- The docs tail shipped with the landing: READMEs, regenerated catalogs (persistence log, config, cordis services, tools), the packages map and architecture rows, and the cookbook row. - -The accepted costs: a pending user flip set while idle is lost if the process dies before the next turn (the UI re-applies; the idle-record primitive is the escape hatch if this bites in practice). A mode transition changes the system prompt at order 50, so the cache path from that point onward changes, but the tool schemas and Code Mode SDK no longer churn. **A mode restrains by guidance alone**: a model that ignores the section CAN mutate during plan — the review moment, the session log, and independent sandbox, approval, and filesystem policies are the containment surface. Hardening planning means setting those knobs, not widening the mode; the removed enforcement shapes and their effects-declaration restart trigger remain in [Alternatives considered](#alternatives-considered) and [Deferred](#deferred). Human-facing interfaces own the plan picker and review interaction; the ACP automation transport carries neither. diff --git a/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.i18n.yaml b/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.i18n.yaml similarity index 63% rename from .agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.i18n.yaml rename to .agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.i18n.yaml index 26ef840cd0..fb39bd26db 100644 --- a/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.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 -2026-06-14-acp-agent-client-protocol.md: da23bbfa247bc2423072477cc4b6277485df1c9c -2026-06-14-acp-agent-client-protocol.zh.md: ec55922e0aee57169d8bbf44c3d91b18ea83041e +2026-07-08-background-subagent-tasks.md: 12b34f2a28cfa311a48904cd5396ec16d9123641 +2026-07-08-background-subagent-tasks.zh.md: 58f035a14d55bc0b4e7f670111909074c3f53b2b diff --git a/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md b/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md index 83ea99dc75..12b34f2a28 100644 --- a/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md +++ b/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md @@ -2,6 +2,8 @@ Status: implemented +English | [中文](2026-07-08-background-subagent-tasks.zh.md) + ## Problem The [subagent seam](2026-06-21-subagent-capability-seam.md) returns a `SubagentRun`, but the model-facing tool originally collected every run synchronously. Independent, slow delegations therefore held the parent call open or ran serially. diff --git a/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.zh.md b/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.zh.md new file mode 100644 index 0000000000..58f035a14d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.zh.md @@ -0,0 +1,64 @@ +# Agent Note: 后台 subagent 任务 + +Status: implemented + +[English](2026-07-08-background-subagent-tasks.md) | 中文 + +## 问题 + +[subagent seam](2026-06-21-subagent-capability-seam.md) 会返回 `SubagentRun`,但原先面向模型的工具会同步收集每一次运行。因此,各自独立的慢速委派要么一直占用父调用,要么按串行方式运行。 + +subagent 需要与其他长时间运行的工具相同的启动、收集、列出、停止、归属、通知和清理行为,但不应采用进程流语义。子会话仍是详细记录;父级只需最终答案和任务状态。后台子级的存活时间还会超过启动它的工具调用,因此必须明确其取消和拥有者资源释放契约。 + +## 决策 + +每个 `dsh-tool-subagent` 实例都可以公开 `run_in_background`,由 `enableRunInBackground` 控制,且默认启用。禁用该功能的实例不包含此参数,并会在执行时拒绝强制传入的后台参数。提供方选择仍属于部署配置,因此一个实例仍然只为一个提供方注册一个名称可区分的工具。 + +后台 subagent 使用[通用后台任务运行时](../architecture/2026-06-20-generic-long-running-tool-runtime.md)。`task_output`、`task_list` 和 `task_kill` 负责收集、列出、取消、完成通知和提示词引导;系统不提供 subagent 专用的配套工具。 + +前台调用保留其同步契约:等待提供方启动和 `run.result`;仅当状态为 `completed` 时返回最终文本;将其他终止原因映射为出错的工具结果;并且始终在返回前释放该运行。 + +对于后台调用,工具会验证父级,并在调用 `ctx.tasks.start()` 前拒绝已中止的执行信号。任务运行时会在调用生产者启动器前,预检控制接口和拥有者清理。该启动器创建独立的 `AbortController` 并启动 `ctx.subagents.start()`;返回 id 之后,工具调用的信号不再拥有该子级。 + +任务注册按以下方式映射 subagent seam: + +- `kind` 为 `subagent`,`label` 为模型提供的描述,`owner` 为父 agent(智能体)。 +- `cancel(reason?)` 中止任务自有的控制器。同一个信号同时覆盖尚未完成的提供方启动和已就绪的子级。 +- `done` 等待提供方启动、子级结果和 `run.dispose()`。已完成的运行返回最终文本,已中止的运行变为 `killed`,其他停止原因变为 `failed`。启动、结果和资源释放失败会转换为失败结果,而不是被拒绝的任务 Promise。 +- `readOutput` 不存在。任务存活期间,`task_output` 只返回状态;结算后,它以幂等方式返回最终输出。中间的子级活动仍保留在子会话中。 + +## 生命周期 + +后台 subagent 归属于其父 agent,不会在拥有者关闭后持久存续。任务运行时将清理附加到对应拥有者的确切作用域。agent 资源释放会取消任务,并在 `AgentHandle.dispose()` 完成前等待启动回滚或子级资源释放,避免泄漏子 agent 和会话。 + +完成通知会发送给启动时捕获的确切拥有者。如果拥有者清理过程已经释放了注入目标,该通知将被丢弃;生命周期保证是清理,而不是通知。 + +## 模型引导 + +通用任务提示词教会模型一套共享的做法:保留 id;继续独立工作,而不是忙等轮询;在回答前收集相关任务;终止无关工作。subagent schema 只补充说明:后台模式返回 task id,且 `task_output` 用于收集结果。无论模型是否遵循提示词,授权和拥有者清理都会强制执行运行时边界。 + +## 备选方案 + +### subagent 专用的等待、输出和停止工具 + +能力专用工具会重复任务协议,再教一套收集与停止习惯,并增加多个提供方实例的复杂度。通用运行时在不改变工具「每个实例对应一个提供方」形态的前提下,提供了所需行为。 + +### 在拥有者关闭后存续 + +该方案需要持久化的任务状态、子会话恢复、延迟结果交付通道,以及对被遗弃拥有者的处理策略。以拥有者为作用域的清理为进程内工作界定了明确生命周期。持久作业需要单独设计。 + +### 隔离客户端不做拥有者检查 + +agent 和日志可能以会话为作用域,但任务注册表和可预测 id 属于运行时全局范围。因此,通用拥有者防线同样适用于 subagent 和所有其他生产者。 + +### 增量子 transcript 输出 + +将子级历史以流式方式写入父级,会模糊日志边界,并使提供方行为分化。此接口只公开最终输出;更丰富的观察应由会话或 UI 工具承担。 + +## 测试 + +单元测试覆盖固定了停止原因映射、在报告前释放资源、启动与结果失败、对预中止的拒绝、从启动调用信号分离、在提供方就绪前后取消、通过真实任务工具收集、无控制接口的预检防线、运行时缺失失败,以及每实例 schema 开关。快照覆盖固定了面向模型的 schema。 + +## 影响 + +父级可以并行分派慢速委派任务,并通过与 bash 共用的任务控制来收集结果。子级工作不再占用启动它的工具调用,但在收集、终止或拥有者释放之前可以继续消耗资源。提示词引导鼓励收集;拥有者清理则提供硬性生命周期边界。需要同步委派的部署可以按工具实例禁用后台模式。 diff --git a/.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.i18n.yaml b/.agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.i18n.yaml similarity index 61% rename from .agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.i18n.yaml rename to .agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.i18n.yaml index f9049645c3..db23b997c0 100644 --- a/.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.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 -2026-06-18-acp-terminal-and-tool-rendering.md: e8426dbf1a0e3e4f9d9857baa19945cee6eee4b3 -2026-06-18-acp-terminal-and-tool-rendering.zh.md: ff269e002a0ecea8c0bacf53fe85e553a6b9b9d5 +2026-07-09-bash-backed-grep-glob-discovery.md: 9c25afb44885ca2519c5d74a3d721b34fe3561de +2026-07-09-bash-backed-grep-glob-discovery.zh.md: d0dee8e49e0a500c87afbd59423fb65416c1dcc8 diff --git a/.agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md b/.agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md index 64fa232831..9c25afb448 100644 --- a/.agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md +++ b/.agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md @@ -2,6 +2,8 @@ Status: implemented +English | [中文](2026-07-09-bash-backed-grep-glob-discovery.zh.md) + ## Problem The harness needs model-facing `glob` and `grep` tools, but making them `ctx.fs` provider methods turns a local product convenience into a universal filesystem backend contract. Local workspace discovery is naturally a process-backed `rg` workflow; remote or virtual filesystem backends may expose their own search API, may not share a local `ripgrep` view, or may not support discovery at all. The v1 should not require every filesystem backend to implement search before the file read/write/edit seam has proven that need. diff --git a/.agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.zh.md b/.agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.zh.md new file mode 100644 index 0000000000..d0dee8e49e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.zh.md @@ -0,0 +1,170 @@ +# Agent Note: 由 Bash 支持的 grep 与 glob 发现工具 + +Status: implemented + +[English](2026-07-09-bash-backed-grep-glob-discovery.md) | 中文 + +## 问题 + +harness 需要面向模型的 `glob` 和 `grep` 工具,但如果将它们实现为 `ctx.fs` 提供方的方法,就会把本地产品便利功能变成所有文件系统后端都必须实现的契约。本地工作区发现天然适合由进程支持的 `rg` 工作流;远程或虚拟文件系统后端可能公开自己的搜索 API,可能无法共享本地 `ripgrep` 视图,也可能完全不支持发现。文件读取/写入/编辑 seam 尚未证明此需求前,v1 不应要求每个文件系统后端都实现搜索。 + +搜索输出还受到两层不同的预算约束。工具需要足够多的原始 `rg` 输出,才能计算稳定的逻辑结果;模型则只能收到有界预览,并在格式化结果超出内联预算时获得恢复路径。通用落盘策略只能看到最终工具结果,因此无法恢复搜索工具已经省略的匹配项。搜索工具必须自行负责保留,并尽力落盘格式化结果。 + +## 决策 + +`glob` 和 `grep` 是 `@deepseek-ai/dsh-tool-fs-search` 中的条件式面向模型工具,由 bash seam 支持,不会成为新的 `ctx.fs` 提供方方法。加载插件时,该包(package)执行 `command -v rg >/dev/null 2>&1`:先通过 `ctx.bash.resolve(request)` 解析请求,再通过 `ctx.bash.run(spec)` 运行;如果命令以非零状态退出,该包会记录警告,并且既不注册工具,也不注册提示词章节。如果探针无法启动、超时、中止、被终止,或没有产生退出码,插件加载会明确失败,因为这意味着 bash 执行器损坏,而不是可选二进制文件缺失。注册后,执行流程同样依次调用 `ctx.bash.resolve(request)` 与 `ctx.bash.run(spec)`,并使用工具组装的固定 `rg` 命令模板。工具层负责 schema、参数验证、shell 引用、结果解析、结果格式化、保留、格式化结果落盘交接,以及超时声明。bash 执行器负责请求默认值解析与上限控制、子进程执行、进程组终止、环境清理、原始输出捕获,以及在本地、沙箱或远程 bash 实现之间替换后端。 + +这些工具不使用 `ctx.bash.start()`,也不创建模型可见的后台任务。从 agent loop(智能体循环)的视角看,它们是普通前台工具:只有当 `rg` 命令退出、超时、中止或失败后,工具调用才返回。`defineTool({ timeoutMs })` 声明协作式工具调用预算,`@deepseek-ai/dsh-timeout-policy` 通过 `exec.signal` 强制执行;工具会在 `resolve()`/`run()` 前将该信号转发给 bash 请求。bash 后端自身的超时仍作为第二层安全上限;先触发的中止生效。 + +这些工具使 `path` 与 Claude Code 的搜索工具保持一致,但将解析绑定到 bash workdir,而不是 `ctx.fs`。工具从 `exec.agent?.session.header.cwd` 派生 bash 请求 workdir,与 `dsh-tool-bash` 和 `dsh-tool-fs` 一致;如果会话没有 cwd,它会省略 `request.workdir`,由 bash 实现通过 `resolve()` 应用其配置的 cwd 或进程 cwd。对于 `grep`,`path` 是可选的 ripgrep 目标,可以是文件或目录;省略时使用已解析的 bash workdir。对于 `glob`,`path` 是可选的目录搜索根;省略时同样使用已解析的 bash workdir。相对 `path` 值基于该 workdir 解析。只要可行,返回路径就会显示为相对于已解析 bash workdir 的形式;只有在共置部署中,bash workdir 与文件系统 `read` 根指向同一个工作区时,这些路径才保证可以继续读取。v1 会记录这项部署要求,但不执行跨服务运行时验证。在形成共享工作区/根契约或提供方专用搜索后端之前,远程或虚拟文件系统搜索保持暂缓。 + +该包不注入 `fs`,而是注入 `tools`、`systemPrompt` 和 `bash`;它有意读取 `spillStore` 时使用 `ctx.get('spillStore')`,而不使用静态注入,因为格式化结果落盘是可选功能。现有 `@deepseek-ai/dsh-tool-fs` 部署若只需要 `read`/`write`/`edit`,则无需加载 bash。加载搜索功能的部署则必须让 bash 执行器环境可以使用 `rg`,这些工具才会进入模型可见 schema。 + +### 包结构 + +v1 包保持精简。`@deepseek-ai/dsh-tool-fs-search` 内部的源代码布局如下: + +```text +src/index.ts +src/glob.ts +src/grep.ts +src/search-core.ts +src/shell-quote.ts +``` + +`glob.ts` 和 `grep.ts` 各自负责参数验证、命令构造、结果解析、格式化和注册。`shell-quote.ts` 是一个共享辅助模块,因为 shell 引用是两个工具都必须经过的安全边界;`search-core.ts` 是另一个共享模块(实现时对原四文件方案所作的修订):`SEARCH_*` 错误词汇、bash 运行与原始输出获取、格式化结果落盘交接,以及 workdir 相对显示,在两个工具中完全相同。若在每个工具中重复这套精细管道,正是对称性约定所指出的漏提取问题。命令构造器禁止自行拼凑引用,也不能把未经引用、由模型控制的值直接连接到 shell 命令中。 + +### Schema 与配置 + +`glob` 公开精简的发现形状: + +```ts +interface GlobArgs { + pattern: string + path?: string +} +``` + +`grep` 公开 OpenCode 风格的最小形状: + +```ts +interface GrepArgs { + pattern: string + path?: string + include?: string +} +``` + +常规预算不会进入面向模型的 schema。`@deepseek-ai/dsh-tool-fs-search` 拥有以下带默认值并经过验证的配置字段: + +| 字段 | 默认值 | 作用 | +|---|---:|---| +| `globMaxResults` | `100` | 内联保留的最大路径数;与 Claude Code 的默认 `GlobTool` 结果上限一致。 | +| `grepMaxMatches` | `250` | 内联保留的最大扁平匹配数;与 Claude Code 的默认 `GrepTool` `head_limit` 一致。 | +| `grepMaxLineBytes` | `2000` | 每条匹配行预览保留的最大字节数,通过 `TextRetainer({ kind: 'head', maxBytes: grepMaxLineBytes })` 应用。 | +| `rawOutputMaxBytes` | `20000000` | 工具会解析的完整原始 `rg` stdout 最大字节数;与 Claude Code 的 ripgrep 原始缓冲区一致。 | +| `timeoutMs` | `30000` | 附加到两个工具定义并由 `@deepseek-ai/dsh-timeout-policy` 强制执行的工具调用超时。 | + +`globMaxResults` 和 `grepMaxMatches` 使用 `ItemRetainer({ kind: 'head' })`。`grepMaxLineBytes` 针对每条匹配行使用 `TextRetainer({ kind: 'head', maxBytes: grepMaxLineBytes })`,使预览截断保留 UTF-8 边界。这遵循[工具结果保留库](../architecture/2026-07-06-tool-result-retention-library.md)对发现条目的映射:收集完整结果,在内联结果中保留头部条目,并将路径映射、分组和逐行预览放在保留器外部。v1 的 `grep` 不公开 `case_insensitive`、`head_limit`、`offset`、`count`、多行、上下文行、输出模式或文件类型过滤器。模型如需周边上下文,可使用 `read` 读取匹配文件;如需后续结果,则遵循返回的落盘定位符所给出的检索提示。 + +Claude Code 的数值只是两层预算的参考点,并非面向模型 schema 的先例。其专用搜索工具会缓冲最多 20 MB 的原始 ripgrep 输出用于内部处理;在非 WSL 平台上使用 20 秒 ripgrep 超时,在 WSL 上使用 60 秒,之后才在模型看到结果前应用搜索专用上限:`GrepTool` 默认 `head_limit = 250`,并持久化超过 20,000 个字符的格式化结果;`GlobTool` 默认最多 100 条路径,并持久化超过 100,000 个字符的格式化结果。此 Agent Note 采用同样的原始缓冲区和内联数量默认值,将默认搜索超时设为 30 秒,并通过本 harness 的 `ctx.spillStore.saveText()` 路径恢复格式化结果。 + +`path` 字段沿用与 Claude Code 相同的区分方式:`grep.path` 是文件或目录形式的 ripgrep 目标,`glob.path` 则是目录搜索根。v1 不为这些工具公开单独的 cwd/workdir 参数。 + +`include` 是一个正向 glob 过滤器,不是列表,也不是排除语法。系统会预先拒绝逗号分隔或取反的 include 模式,并返回结构化参数错误。shell 命令中使用的每个模型控制值,包括 `pattern`、`path` 和 `include`,都必须经过包私有的 shell 引用辅助模块。 + +### 执行 + +`glob` 构建固定的 `rg --files` 命令,并以解析后的目录搜索根为根(提供 `path` 时使用该值,否则使用 bash workdir):`rg --files --glob --sort=modified --no-ignore --hidden`,另加针对 `.git`、`.svn`、`.hg`、`.bzr`、`.jj` 和 `.sl` 的 VCS 元数据排除项。这样既与 Claude Code 的隐藏/忽略文件发现和修改时间排序保持一致,也避免宽泛搜索包含 VCS 内部文件。工具逐行解析路径,只要可行就将结果映射为相对于 bash workdir 的路径,将每条路径推入 `ItemRetainer({ kind: 'head', maxItems: globMaxResults })`;当保留结果达到上限时,它会格式化完整的已排序路径列表,作为落盘产物。 + +`grep` 构建固定的逐行 `rg --json` 命令,作用于所提供的文件/目录目标(提供 `path` 时使用该值,否则使用 bash workdir),从而无需按冒号拆分,就能解析文件路径、行号和行文本。它消费 `match` 记录,将格式错误的 JSON 或匹配记录视为 `SEARCH_FAILED`;只要可行,就将结果路径映射为相对于 bash workdir 的路径;通过 `grepMaxLineBytes` 应用逐行预览保留,将每个匹配推入 `ItemRetainer({ kind: 'head', maxItems: grepMaxMatches })`,然后只按文件分组内联输出中保留的预览匹配。落盘产物保存完整的格式化匹配列表,而不是只保存省略的尾部,因此检索提示指向模型已经看到的同一逻辑结果。 + +原始 `rg` stdout 是内部传输细节。工具请求 `stdoutMaxBytes: rawOutputMaxBytes`,并通过 `ctx.bash.resolve()` 解析;只有当执行器在该上限内返回未截断的 stdout 时,工具才解析 `stdout.text`。如果 stdout 超过 `rawOutputMaxBytes`,或者执行器仍返回 `stdout.truncated`,工具会以明确的搜索错误失败,要求模型缩小 `pattern`、`path` 或 `include`。工具绝不向模型公开原始 `rg` 输出或 bash 原始落盘路径。 + +只有 stdout 是解析源。对于无效模式、注册后运行时 `rg` 消失,以及搜索失败,stderr 作为诊断文本;如果 bash 截断 stderr,工具会使用保留的 stderr 尾部并附加截断说明,不会读取 `stderr.spillPath`。 + +如果 `ctx.bash.run()` 因工具超时或调用方取消触发而报告 `aborted`,工具会返回结构化失败,而不是假装没有匹配项。如果 bash 自身的超时先触发,工具同样会以明确的超时消息失败。非零 ripgrep 退出语义由工具负责:退出码 0 表示存在匹配并成功;退出码 1 表示没有匹配但成功;无效模式、运行时 `rg` 消失或无法访问搜索 workdir 则表示失败。 + +搜索失败使用包自有的 `HarnessError` 子类与 `SEARCH_*` 代码,而不使用 `FsErrorCode`,因为这些工具不是 `ctx.fs` 提供方操作。v1 的词汇包括 `SEARCH_INVALID_PATTERN`、`SEARCH_FAILED`、`SEARCH_RAW_OUTPUT_OVERFLOW` 和 `SEARCH_ABORTED`。缺少必填字段、空字符串或不支持的取反/列表式 `include` 值等模型参数验证失败,仍作为普通工具参数错误处理。 + +### 格式化结果落盘 + +`ctx.spillStore` 是可选服务,仅用于面向模型的格式化结果。这是代码库中首个工具自有落盘调用模式;此设计有意为之,因为搜索保留属于条目级策略:`globMaxResults` 限制路径数,`grepMaxMatches` 限制匹配数,而工具此时仍持有完整逻辑结果。通用 `dsh-spill-policy` 会在 `tools/post-execute` 阶段限制最终文本字节数;到那时搜索工具已经省略后续路径或匹配,策略无法恢复它们。 + +当搜索产生的逻辑结果数超过内联上限,且 `ctx.spillStore` 存在时,工具会通过 `saveText()` 保存完整的格式化结果。落盘所有者是调用 agent 的会话头 id(`exec.agent?.session.header.id`);缺少该所有者时,搜索会保留内联结果,并报告完整结果无法保存。落盘来源是工具执行身份:`{ toolName: exec.name, callId: exec.callId, label: 'result' }`。建议文件名为 `grep-results.txt` 和 `glob-results.txt`;落盘后端仍将它们视为提示,而不是路径。 + +如果落盘存储不存在、调用没有会话所有者,或保存失败,工具仍返回内联页和页脚,说明完整结果无法保存。格式化结果落盘存储不可用本身绝不能把搜索成功变为 `isError` 结果。 + +bash 原始输出流与格式化搜索落盘产物是两个不同的产物。原始 `rg` stdout 只会在所请求的 bash stdout 上限内于内存中解析;格式化落盘产物则是 `ctx.spillStore.saveText()` 生成的稳定、面向模型的恢复定位符。 + +### 结果形状 + +带有成功格式化落盘的受限 `glob` 结果会返回内联页与落盘通知: + +```text + + +(Showing N of M paths. Full sorted result stored at: /.../session-abc123/9f8e7d-glob-results.txt. Use read with offset/limit, or grep this path to search within it.) +``` + +带有成功格式化落盘的受限 `grep` 结果会返回分组后的预览匹配与落盘通知: + +```text +Found N of M matches + + +Line 12: ... + +(Full grep result stored at: /.../session-abc123/9f8e7d-grep-results.txt. Use read with offset/limit, or grep this path to search within it.) +``` + +如果完整逻辑结果未超过内联上限,系统不会创建格式化落盘产物。如果完整逻辑结果过大但无法格式化落盘,页脚会说明结果已受限,完整结果无法保存。`truncated`/省略计数是预算事实,并不表示搜索不完整;超时、无效正则表达式、运行时 `rg` 消失、无法访问 workdir、原始输出溢出、跳过二进制文件和解析失败,仍属于工具领域的错误或不完整字段。 + +## 考虑过的替代方案 + +**将 `glob`/`grep` 放在 `ctx.fs` 上。** v1 不采用:这会迫使每个文件系统后端增加搜索 API,并使本地 ripgrep 行为成为提供方 seam 的一部分。搜索是有用的产品行为,但不像 `readText` 或 `writeText` 那样属于通用文本存储原语。 + +**直接从 `dsh-fs-local` spawn ripgrep。** 此 Agent Note 的 v1 不采用:直接 spawn 提供最简洁的 argv 边界、stdout/stderr 控制与提前停止控制,但会重复 bash seam 已负责的进程执行事项,包括环境清理、进程组终止、超时传播、沙箱/远程执行器替换,以及有界输出捕获。如果 bash 支持的搜索被证明过于依赖 shell 字符串,或必须支持前台流式输出,该方案仍是合理优化。 + +**通过 `ctx.bash.start()` 实现流式提前停止。** 不采用:`start()` 会创建模型可见的后台任务语义,包括 task id、所有者 token、`bash_output`、`bash_kill`、完成通知,并且没有内置超时。`grep` 需要前台工具结果,而不是后台 bash 工作流。如果将来必须流式搜索,正确的抽象是在 bash/进程 seam 上增加前台流式进程句柄,而不是借用公开后台任务 API。 + +**向模型公开 bash 原始落盘路径。** 不采用:bash 原始落盘路径包含原始 `rg` stdout(对于 grep 即 `rg --json` 记录),并非稳定的格式化搜索结果。搜索只把原始 stdout 当作内部传输;模型恢复使用通过 `ctx.spillStore.saveText()` 保存的格式化结果。 + +**先为 bash 输出规范化增加 `spillStore.saveFile()`。** 此 Agent Note 的 v1 不采用:未来规范化 bash 时,`saveFile()` 可以帮助将现有执行器落盘文件移动到会话范围的落盘存储,但搜索只需在生成面向模型的产物前,在内存中获取有界的原始 `rg` stdout。`saveText()` 足以保存格式化搜索结果。 + +**依赖通用 `dsh-spill-policy`。** 不采用:通用 post-execute 落盘只能看到最终工具结果。如果 `grep`/`glob` 内联返回第一页,通用策略无法恢复省略的结果。搜索工具必须在返回有界的面向模型文本前,自行保存完整的格式化结果。 + +**公开 Claude Code 的完整 `GrepTool` schema。** v1 不采用:`output_mode`、上下文标志、多行、`head_limit`、`offset`、`case_insensitive` 和类型过滤器会使面向模型的接口变成 ripgrep 包装层。本 harness 将常规预算与续传机制保留在部署策略和落盘产物中。 + +**保留提前停止搜索,并省略格式化落盘产物。** 此提案不采用:提前停止效率更高,却不给模型检查后续结果的路径。所选 v1 优先保证结果可恢复性与实现简洁性,并以 `timeoutMs`、`rawOutputMaxBytes`、bash 后端上限和格式化落盘产物作为安全后备。 + +**先扩展 bash seam,增加原始输出读取器。** 不采用:可移植的 `readRawOutput(ref, maxBytes)` API 会增加引用生命周期、权限和后端存储语义。逐次运行的 `stdoutMaxBytes` 请求是更窄的 seam:搜索要么在 `rawOutputMaxBytes` 内收到完整 stdout,要么明确失败。 + +**始终注册,只有执行时才报告缺少 `rg`。** 不采用:模型可见工具 schema 是部署能够尝试该能力的承诺。如果 bash 执行器在加载时找不到 ripgrep,更安全的接口是完全没有 `glob`/`grep` 工具或提示词指引。对于注册后发生环境变化的情况,执行时的 `rg` 缺失分类仍作为防御性回退。 + +## 测试 + +- 测试覆盖注册时 `rg` 探测(探测成功会注册两个工具和提示词章节;非零探测会跳过工具与提示词章节并发出警告;基础设施探测失败会拒绝插件加载),证明中止的 `exec.signal` 会到达 bash 后端(通过同一引用的 spec 断言和 `SEARCH_ABORTED` 结果),并覆盖命令构造/引用(恶意模式、带空格路径、以短横线开头的值、引号、换行、glob 元字符:既有单元断言,也针对每个恶意值执行真实 `bash -c` 往返)、将 `grep.path` 用作文件与目录目标、将 `glob.path` 用作目录搜索根、无效模式处理、无匹配、格式错误的 `rg --json` 输出、匹配行预览截断、原始输出溢出、超时/中止、格式化落盘成功/失败、包自有 `SEARCH_*` 错误代码,以及无后台任务不变量。 +- 直接覆盖第一方工具自有落盘先例:落盘后端存在、落盘后端缺失、`saveText()` 失败,以及缺少落盘所有者。 +- 该包通过真实 Loader 路径覆盖命名空间插件的导出形状(`name`、`inject`、`Config` 和 `apply`,且没有默认导出)。 +- 真实执行器集成测试(`dsh-bash-local` + 真实 `rg`)验证外部世界:恶意模式保持惰性、逐会话 cwd 解析、VCS 元数据排除、按修改时间排序,以及真实 ripgrep stderr 分类。如果测试进程的 PATH 中没有 `rg`,该测试会自行跳过(这是与无密钥 e2e 跳过相似的 CI 兼容措施);伪执行器测试覆盖注册和执行时缺少 `rg`,并由逐文件 100% 覆盖率门禁兜底。 +- transcript 可见落盘通知仍有快照缺口:此功能合入时记录了缺口说明,没有快照。快照层会回放 acp-agent 树;在其中加入搜索插件会改变组装的系统提示词,必须使用真实密钥重新录制每一份预期输出,而实现环境没有密钥。落盘通知的确切 transcript 文本由单元测试固定(`formatGlobOutput`/`formatGrepOutput` 以及通过注册表执行的落盘测试);下一次拥有密钥的会话应把插件接入 acp-agent 树,并运行一次 `test:snapshot:record`。 + +## 后果 + +- `glob` 和 `grep` 是 `@deepseek-ai/dsh-tool-fs-search` 中的条件式面向模型工具,不是 `ctx.fs` 提供方方法,也不属于现有 `@deepseek-ai/dsh-tool-fs` 根插件。只有 bash 执行器能找到 `rg` 时才会注册;该包注入 `tools`、`systemPrompt` 和 `bash`,不注入 `fs`,并使 `ctx.spillStore` 保持可选,读取时使用 `ctx.get('spillStore')`。 +- Schema 严格为 `glob(pattern, path?)` 和 `grep(pattern, path?, include?)`;搜索上限与超时是带默认值并经过验证的 Config 字段(`globMaxResults`、`grepMaxMatches`、`grepMaxLineBytes`、`rawOutputMaxBytes`、`timeoutMs`)。 +- 工具通过 `ctx.bash.resolve(request)` → `ctx.bash.run(spec)` 执行,转发 `exec.signal`,绝不调用 `ctx.bash.start()`,也绝不公开 bash task id。如果存在 `exec.agent?.session.header.cwd`,bash 请求 workdir 来自该值;解析后的 `spec.workdir` 决定执行与相对路径显示。 +- 工具向 bash seam 请求 `stdoutMaxBytes: rawOutputMaxBytes`,只解析上限内未截断的 stdout,并将超限或仍被截断的原始输出视为明确的搜索失败;绝不向模型公开原始 `rg` 输出。 +- 只要可用,过大的完整格式化结果会通过 `ctx.spillStore.saveText()` 保存,而内联结果保持有界;落盘失败、后端缺失或所有者缺失时,系统保留内联结果并报告未保存的剩余内容,绝不会返回 `isError`。 +- 包 README、生成的配置目录与导出 JSDoc 会记录 Config 字段和 `SEARCH_*` 代码;tui-agent 示例会提供条件式工具插件(acp-agent 树等待完成上述快照重新录制);fs 组 README 会记录 `rg` 可用性以及 bash/文件系统共置部署要求。 + +## 风险 + +在宽泛模式下,完整运行的 `grep` 可能比提前停止搜索更慢。v1 为了简化实现并恢复完整结果而接受这项成本,同时通过工具超时、bash 超时、`rawOutputMaxBytes` 和输出上限加以约束。如果实际运行过慢,仍可采用直接 ripgrep 或前台流式替代方案。 + +Shell 命令构造是最尖锐的安全边界。`ctx.bash` 接受命令字符串而不是 argv 向量,因此实现必须集中处理 shell 引用,并测试恶意模式、带空格路径、以短横线开头的模式、引号、换行和 glob 元字符。 + +v1 假设 bash 与文件系统共置部署。如果 bash 搜索一个工作区,而 `read` 工具基于另一个根解析路径,返回路径可能无法继续读取。该包会记录这项要求,但不在运行时验证。 + +落盘定位符由后端负责。当前本地后端返回本地文件系统路径,适用于 `read`/`grep` 能打开这些文件的部署;远程或工作区受限部署可以使用另一种后端,让其定位符和检索提示指向受支持的检索机制。 diff --git a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.i18n.yaml b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.i18n.yaml new file mode 100644 index 0000000000..2e7b5b1d58 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.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 +2026-07-10-agent-session-identity-and-log-location.md: a55bf276bff998a94f84ec1af078022e4881903c +2026-07-10-agent-session-identity-and-log-location.zh.md: 84b8b22187ccd1078ff13e39f4a345efbecfaeac diff --git a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md index d4c5154ed2..a55bf276bf 100644 --- a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md +++ b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md @@ -2,6 +2,8 @@ Status: implemented +English | [中文](2026-07-10-agent-session-identity-and-log-location.zh.md) + ## Problem An agent can identify its workspace through `session.header.cwd`, but a model using bash cannot reliably identify the session that owns the call or the durable transcript that records it. Searching `./.sessions` guesses deployment config and JSONL layout; custom roots, alternate persistence backends, resume, forks, and concurrent parent/child agents make that guess unreliable. Hooks have the same need for transcript location, while future plugins may need to expose other harness-owned environment facts to shell commands. diff --git a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md new file mode 100644 index 0000000000..84b8b22187 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md @@ -0,0 +1,87 @@ +# Agent Note: 向工具与钩子公开 agent(智能体)会话标识和 JSONL 位置 + +Status: implemented + +[English](2026-07-10-agent-session-identity-and-log-location.md) | 中文 + +## 问题 + +agent 可以通过 `session.header.cwd` 识别其工作区,但使用 bash 的模型无法可靠识别当前调用所属的会话,也无法找到记录该调用的持久 transcript(文本记录)。搜索 `./.sessions` 等同于猜测部署配置和 JSONL 布局;自定义根目录、替代持久化后端、恢复、fork,以及并发运行的父子 agent,都会让这种猜测失效。钩子同样需要 transcript 位置,而未来的插件也可能需要向 shell 命令公开其他由 harness 所有的环境事实。 + +这项边界必须维持两个属性:事实的所有者决定如何解析该事实;每个子进程接收每次执行的快照,而不是进程级可变全局状态。尤其是嵌套 harness 不能把环境中的 `DSH_*` 值泄漏给当前 agent、持久化后端或配置均可能不同的子进程。 + +## 决策 + +在 [`SessionPersistence`](../architecture/2026-06-14-session-persistence.md) seam 上增加同步、无副作用的位置查询: + +```ts +import type { SessionHeader } from '@deepseek-ai/dsh-session' + +interface SessionLocation { + readonly kind: string + readonly path: string +} + +interface SessionPersistence { + locate(meta: SessionHeader): SessionLocation | undefined +} +``` + +`path` 是指向该后端为 `meta` 保留的专用日志的绝对本地路径;`kind` 标识其表示形式。JSONL 使用解析后的根目录和路径辅助函数返回 `{ kind: 'jsonl', path }`。SQLite 以及任何无法诚实提供逐会话本地产物的后端均返回 `undefined`。该查询不会创建或刷写任何内容,因此即使文件尚不存在,也可以报告延迟创建的目标路径。 + +面向模型的 bash 包(package)拥有一个 `ctx.bashEnv` 注册表。贡献方声明稳定名称、它可能返回的每个 `DSH_*` 键、每个键的说明,以及 `resolve(execution: ToolExecution)`。贡献方名称重复、键所有权重复、使用保留键、声明格式错误、运行时输出未声明或输出不是字符串时,系统都会明确失败。注册属于 Cordis effect,并随贡献插件的 fiber 一同移除。`list()` 无需运行解析器即可公开声明,从而让环境接口可供诊断工具和未来的提示词/UI 消费方枚举。 + +注册表会为每次前台和后台 bash `ToolExecution` 重新构建受信任的覆盖层: + +- `DSH_HOME` 始终是配置的 Harness home 绝对路径。独立的 [`@deepseek-ai/dsh-paths`](../../../../packages/util/paths/README.md) 工具库规定其优先级:显式 `dshHome`,其次是环境中的 `$DSH_HOME`,最后是 `~/.dsh`。 +- `DSH_SHELL=1` 始终存在,用于标识由 DeepSeek Harness 管理、面向模型的 bash 子进程。 +- 执行具有关联 agent 时,`DSH_SESSION_ID` 存在并等于 `agent.session.header.id`。 +- 内置的持久化转换层提供 `DSH_SESSION_JSONL` 的条件是 `ctx.sessionPersistence.locate(header)` 返回 `kind: 'jsonl'`。 + +会话持久化仍然是事实所有者:JSONL 不依赖 tool-bash,也不会自行注册 shell 变量;钩子继续直接使用 `locate()`。tool-bash 是把持久化事实转换为 shell 约定的转换层。其他需要向 shell 公开事实的插件依赖该注册表,并注册各自的键;它们不修改 `process.env`。 + +bash seam 导出 `DSH_ENV_PREFIX` 作为唯一的命名空间来源,并派生 `DshEnvironmentKey`,其来源是该常量的 `typeof`。tool-bash 从该常量派生内置名称与模型指引,执行器则使用该常量进行过滤和通道校验。seam 通过 `BashExecRequest.dshEnv`/`BashExecSpec.dshEnv` 单独传递受管理的覆盖层。普通 `env` 仍是钩子所用的通用进程内插件接口,但不能包含受管理的键;对称地,`dshEnv` 不能包含普通键。本地执行器会在 spawn 前拒绝任一错误通道,移除环境中继承的全部受管理键,依次应用普通清理、终端环境和显式 `env`,最后合并受信任的 `dshEnv` 快照。这保证了值缺失表示它当前确实不存在,而不是从外层或先前的 harness 继承而来。面向模型的工具仍忽略模型提供的 `env`/`stdin` 参数。 + +bash 工具说明只讲解持久约定:当前 harness 环境事实通过受管理的 `$DSH_*` 变量提供,可以在需要时查看。它不会枚举持久化专用键,也不会添加永久的系统提示词章节。工具 schema 已记录在请求 header 中,工具输出则记录为 `tool/result`,因此无需新增会话事件。 + +[Claude Code 和 Codex 钩子桥接层](2026-06-30-hook-bridges.md)在构造 payload 时,从同一持久化 seam 解析 transcript 位置。Codex 使用 `transcript_path: string | null`;Claude Code 保留其字符串字段,并回退为 `''`。钩子查询不会物化或刷写会话。 + +## 同类产品调研 + +同类产品把稳定标识与物理存储分开处理。Codex 向 spawn 的 shell 注入稳定的 `CODEX_THREAD_ID`,而 recorder 和钩子接口负责提供 transcript 路径。Claude Code 通过结构化的钩子/状态输入提供 `session_id` 和 `transcript_path`。OpenCode 在结构化工具上下文中携带标识;Kimi Code 展开会话占位符;Reasonix 则把活动会话路径保存在控制器上。可移植的规则是:在调用边界注入标识,由存储层解析位置,绝不在并发 harness 中使用进程级的当前会话全局变量。 + +## 生命周期与持久化语义 + +新会话在第一个轮次之前获得 id,因此它的首次 bash 调用即可读取 `DSH_SESSION_ID` 和 JSONL 目标。JSONL 文件可能要等到第一次成功的轮次结束检查点后才存在,而且在一个轮次仍未结束时,它只包含上次刷写的前缀。`DSH_SESSION_JSONL` 是位置提示,不是授权凭据或新鲜度保证。 + +恢复操作复用已加载的 header,因此 id 和位置不变。fork 和 spawn 会创建新的会话 id 与位置。父子调用分别从自己的 `ToolExecution.agent` 解析事实;即使调用重叠,每条命令也会收到不可变快照。替换持久化服务会影响后续收集,因为转换层在执行时查询 `ctx.get('sessionPersistence')`;注册表本身受 effect 作用域约束,并且可安全用于 HMR(热模块替换)。 + +`dshHome` 是与会话无关的部署上下文。agent-core 通过 `@deepseek-ai/dsh-paths` 解析出一个值,并将其同时传给 tool-bash 和本地 skill(技能)发现;独立消费方调用同一解析器。如果顶层 `dshHome` 与 `skills.local.dshHome` 均已提供但解析结果不同,组合会失败,而不会公开互相矛盾的 home。持久化可以独立变更,无需把其事实冻结到会话前缀中。 + +## 测试 + +单元测试覆盖注册表声明校验、effect 释放、逐次执行收集、`dshHome` 优先级,以及本地执行器清理并重建 `DSH_*` 的顺序。请求录制测试覆盖前台/后台快照、无 agent 调用、持久化不存在或为 JSONL、忽略模型 `env`,以及父子隔离。JSONL/SQLite 定位器契约测试与两套钩子桥接测试均锁定 transcript 可用和不可用两种方言。 + +一项无密钥的完整循环集成测试会在第一个轮次驱动真实的 agent loop、JSONL 持久化、tool-bash 与 bash-local。子进程打印 `DSH_HOME`、`DSH_SHELL`、会话 id、JSONL 目标和继承的陈旧哨兵值;测试校验当前值、陈旧变量不存在、刷写前文件不存在,并最终检查持久化 header。快照覆盖会锁定录制请求 header 中的通用 bash 说明。该契约属于确定性的本地执行,不涉及模型选择,因此无需带密钥测试。 + +## 考虑过的替代方案 + +**只提供 id,再用 `find`。** 搜索无法得知自定义根目录或后端布局,并且在多会话环境下存在竞态。 + +**只提供绝对路径。** 路径可能不可用、延迟创建或取决于表示形式,不能作为稳定的会话标识。 + +**使用全局 `process.env`。** 并发 agent 会互相覆盖,嵌套 harness 也会继承陈旧的当前会话值。 + +**把持久化说明放入会话前缀。** 活动服务可以在 HMR 或未来的后端切换中改变,而会话前缀保持冻结;持久化专用指引会因此变得陈旧。 + +**使用类型化 waterfall 事件。** 监听器不运行就无法声明所有权,而后续监听器可以无提示地覆盖键。注册表能在注册时检测键冲突,并且保持可枚举。 + +**让每个持久化后端直接注册 bash 环境。** 这会反转依赖方向,让存储层依赖某一个消费方,并迫使未使用 bash 的部署也引入它。钩子仍然需要 `locate()`。 + +**增加面向模型的 `session_info` 工具。** bash 已经提供查询接口,新增工具只会多出 schema 和一次调用;注册表可以扩展至未来的环境事实,无需为每项事实增加一个工具。 + +## 影响 + +每个面向模型的 bash 子进程都会收到当前 Harness home 和 shell 标识,关联 agent 的调用还会收到稳定的会话标识。使用 JSONL 后端的调用可以获得可选的目标路径;非文件持久化会如实省略该值。这些子进程中的完整 `DSH_*` 命名空间由 harness 管理:系统移除环境中已有的受管理值、重新加入当前受信任的值,并禁止普通调用方通过 `env` 绕过所有权检查。 + +该命名空间可被发现,但并非秘密。路径可能泄露配置的根目录,延迟创建的目标也可能不存在或处于陈旧状态,而且命令可以在自己的 shell 语法中覆盖变量。消费方应把这些值视为关联信息和环境事实,在归属关系重要时校验 transcript 元数据,并依靠沙箱/文件系统策略而不是变量保密性来完成授权。 diff --git a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml new file mode 100644 index 0000000000..0c7363827e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.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 +2026-07-10-parallel-tool-call-execution.md: c67ae61939a3e7974f9bf729058a57f5576308a1 +2026-07-10-parallel-tool-call-execution.zh.md: a80317aa951cbf3a9cae0651348c99712a4193d5 diff --git a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md index 4904da5bd5..c67ae61939 100644 --- a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md +++ b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md @@ -2,6 +2,8 @@ Status: implemented +English | [中文](2026-07-10-parallel-tool-call-execution.zh.md) + ## Problem An assistant message may contain several sibling `tool-call` blocks. Running them serially adds the latency of independent reads and web requests even though the model has already requested them together. diff --git a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md new file mode 100644 index 0000000000..a80317aa95 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md @@ -0,0 +1,103 @@ +# Agent Note: 按单次调用安全性并行执行工具调用 + +Status: implemented + +[English](2026-07-10-parallel-tool-call-execution.md) | 中文 + +## 问题 + +一条 assistant 消息可以包含多个并列的 `tool-call` 块。尽管模型已经同时请求了这些调用,串行执行仍会叠加各个独立读取和 Web 请求的延迟。 + +并发属于宿主调度范畴,不是面向模型的工具元数据。循环需要在不硬编码工具名称、不向 JSON Schema 暴露调度策略的前提下,判断哪些调用可以重叠执行。 + +会话日志仍是权威记录:每个已启动的调用都有审计事件,都会获得结果;无论完成顺序如何,模型历史都按原始调用顺序观察结果。 + +## 决策 + +每个工具都可以提供可选的 `isConcurrencySafe(args)` 分类器。该分类器必须是同步纯函数:它只检查当前调用已解析的参数,不执行 I/O 或任何变更。只有显式返回 `true` 才表示选择并行;分类器缺失、参数无效、分类器抛错或返回任何其他值,都会使该调用按独占方式执行。规范类型契约见[工具数据结构](../../../../docs/core-data-structures/tools.md)。 + +分类器有意设计为一元函数。返回 `true` 表示工具承诺:此调用可以与任何同样返回 `true` 的并列调用重叠执行。调度器不会比较调用,也不会证明它们的资源访问相容。 + +这个一元分类器仍然可以感知输入。工具可以将只读操作分类为并行,将变更操作分类为独占。该接口无法表达「仅当路径不同时,这些写入才安全」之类的关系规则,因此,安全性依赖并列调用的调用仍按独占方式执行。 + +`defineTool()` 先验证参数,再调用类型化分类器。无效参数会被归为独占,且只有该调用真正执行时才会产生常规参数错误。`ctx.tools.executionMode(exec)` 会解析当前有效的工具定义,并返回带标签的 `parallel` 或 `exclusive` 模式;未知工具将以安全方式退化为独占。 + +使用带标签的模式,而不是公开布尔型调度器 API,使得以后可以表达感知资源的变体,无需改变分类器契约。 + +## 调度与顺序 + +循环会等待完整的 assistant 消息,对每个调用只解析一次,为每个调用创建独立的 `ToolExecution`,再按模型顺序扫描。连续的并行调用组成一组;每个独占调用单独组成一组,并构成顺序屏障。各组按顺序执行。分类采用惰性方式:每经过一个屏障,调度器都会解析下一个调用;补充并行池之前,还会重新分类每个后续调用。如果注册表变更使该调用变为独占,当前池会先完全排空,然后该调用才作为下一个屏障启动。 + +例如: + +```text +[parallel read(A), parallel read(B), exclusive write(A), parallel read(C)] + +→ [read(A), read(B)] +→ [write(A)] +→ [read(C)] +``` + +`read(A)` 和 `read(B)` 可以重叠执行。`write(A)` 要等两者都完成后才启动,`read(C)` 则要等写入完成后才启动。 + +每组都使用一个由 `maxParallelToolCalls` 限制上限的滚动池:循环先按模型顺序启动调用,直到达到上限;每有一个调用结算,就再启动一个。独占组是容量为 1 的池。将上限设为 `1` 可保持串行执行。 + +只有派发和工具主体会重叠执行。`tools/pre-execute` 和 `tools/post-execute` 按模型顺序运行,因为中间件可能维护对顺序敏感的状态。`tools/execute` 包装层会环绕并发派发运行,因此必须能在不同执行之间重入。 + +每个已启动的调用都会在进入 pre-execute 门禁之前立即追加 `tool/call`。已完成的派发占据模型顺序的槽位;提交游标只有在下一个槽位就绪时,才会追加 `tool/result` 并收集 `additionalContexts`。实时界面可以显示多个待处理调用,但结果和工具执行后的上下文仍按模型顺序排列。 + +如果在一组启动前中止,系统不会记录该组的任何调用。如果在一组执行期间中止,系统会停止补充池,等待已启动的调用,按顺序提交其结果,在这些结果之后排空已接受的批次上下文,然后通过现有中止路径结束该步骤。从未启动的调用没有审计事件。 + +Code Mode 仍不使用此调度器,因为模型只会发出一个原生 `run_code` 调用。`run_code` 及其内部派发队列仍按串行方式执行;`mode: 'both'` 中的原生并列调用使用常规调度器。 + +## 安全契约 + +工具返回 `true` 即承诺:其主体可以与其他并行调用同时运行。它不得直接变更父会话或其他由父级拥有的状态;它将输出返回给循环,由循环按模型顺序提交。 + +执行期间触及的任何共享状态都必须支持并发。这也包括工具包装层和提供方:它们可以在内部串行化,也可以实施自身容量限制,但必须在并发派发时不破坏状态。 + +## 配置与声明 + +`maxParallelToolCalls` 是 AgentLoop 的正整数部署上限,由工厂创建的所有 agent(智能体)共享。默认值为 `10`;`1` 保持串行执行。字段和默认值的精确定义见生成的[配置目录](../../../../docs/config-catalog.md)。 + +当前实现中的声明保持保守。Web 搜索、Web 获取和文件系统读取选择并行。文件系统写入与编辑、bash 工具、subagent 委派、工作流、用户交互、todo 变更、Code Mode 以及 Cordis 变更工具仍按独占方式执行。subagent 可能共享父级的工作区或外部资源,而一元分类器无法证明并列委派的作用互不重叠。Bash 没有已证明的输入敏感分类器,因此仍按独占方式执行。 + +文件系统读取依赖一个范围很窄的记录器例外:其同步观察更新可以不按顺序结算,但写入和编辑在变更前会重新检查已观察的版本,因此陈旧状态只会导致 `FS_STALE_VERSION`。 + +## 验证 + +单元测试覆盖固定了安全退化的分类、类型化参数验证、分组、屏障、替换注册表后的实时重新分类、滚动上限、独立执行对象、中间件顺序、有序结果与上下文,以及中止排空。第一方测试固定每项并行声明。 + +快照覆盖固定了可见的多调用 transcript(文本记录):待处理调用可以重叠执行,已完成结果仍按模型顺序排列。Code Mode 覆盖固定其串行边界。此调度属于确定性循环行为,因此无需依赖提供方的 e2e 测试。 + +## 备选方案 + +**保持串行执行。** 这可以避免新的顺序和中止情形,但会保留独立并列调用所产生的不必要延迟。 + +**使用一个工具级布尔值。** 固定的 `supportsParallelToolCalls` 标志更小,但无法区分同一工具的只读操作和变更操作。感知参数的分类器保留了这项区分。 + +**使用有状态的分类。** 向分类器提供实时 agent、注册表或 I/O 访问,会使决策依赖分类器的运行时机,并在分类与派发之间留下缺口。可变授权和陈旧状态检查仍属于执行时职责。 + +**使用感知并列调用或感知资源的分类。** 调度器可以成对比较调用,或让每个调用声明资源读写要求。这样可以并行化不冲突的写入,却要求不相关工具共享资源标识和冲突语义。一元契约选择放弃这部分并发性,并在安全性取决于关系时安全退化。 + +**并行执行完整的工具流水线。** 这样可以让循环继续使用公开的单调用 API,但会并发运行 pre-execute 和 post-execute 中间件。现有防护和钩子桥可能承载有序状态,因此只允许派发重叠。 + +**公开分阶段方法或调度 waterfall。** 公开的 `prepare` / `dispatch` / `finalize` 方法或 `tools/execution-mode` 事件,会在出现另一个消费方之前扩大扩展接口。循环使用内部调度器视图,而 `executionMode(exec)` 为策略 seam 保留了插入点。 + +**在模型流式输出时启动调用。** 这可能进一步降低延迟,但会改变 assistant 消息的权威性、回放以及调用/结果配对。调度器只在 assistant 消息完成后才启动。 + +**使用固定大小的窗口。** 如果在启动下一个窗口前等待当前窗口的每个调用,一个缓慢调用就会使容量闲置。滚动池在保持上限的同时避免了这项延迟。 + +**向模型暴露并发元数据。** 模型已可以发出并列调用。宿主调度元数据会扩大请求,却无助于工具选择。 + +## 影响 + +该设计以安全退化为原则,对工具作者而言也很简单,但无法利用必须通过比较并列调用才能确认安全的并发性。工具过于宽泛地选择并行,可能暴露潜在的共享状态竞态。 + +在某些情形下,并行调用会先行启动,而串行执行原本会在轮到这些调用之前中止。因此,调度器只记录已启动的调用,在中止时将其排空,且取消后绝不启动替换调用。 + +有序提交可能会让快速结果等待较慢的早期并列调用。这保留了回放和模型历史顺序,同时实时界面仍可显示待处理进度。 + +并发外部调用可能会争用配额或进程容量。提供方负责自身容量控制;循环上限只限制一个 agent 步骤中的调用数量。 + +工具注册是调度边界。调度器会在每个屏障之后以及每次补充池之前重新分类,因此注册表变更会影响尚未启动的调用。已启动的调用保留它们进入池时所依据的调度决策。 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.i18n.yaml b/.agents/notes/implemented/feature/2026-07-13-session-query-tracing.i18n.yaml similarity index 64% rename from .agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.i18n.yaml rename to .agents/notes/implemented/feature/2026-07-13-session-query-tracing.i18n.yaml index 737a9da6ca..ca8877d5a7 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-13-session-query-tracing.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 -2026-07-21-tui-auto-pane-title.md: 069fd33a8874d9ad3d4472dd13f5130b2df65f08 -2026-07-21-tui-auto-pane-title.zh.md: 580f36b2563e21231a22cab3f0c1689c6f3e8d9d +2026-07-13-session-query-tracing.md: 47c12824a331546676d3bc79920f861afe648431 +2026-07-13-session-query-tracing.zh.md: 485060f9e57b5644f7b364e2120bfe30607b1945 diff --git a/.agents/notes/implemented/feature/2026-07-13-session-query-tracing.md b/.agents/notes/implemented/feature/2026-07-13-session-query-tracing.md index 08f856863e..47c12824a3 100644 --- a/.agents/notes/implemented/feature/2026-07-13-session-query-tracing.md +++ b/.agents/notes/implemented/feature/2026-07-13-session-query-tracing.md @@ -2,6 +2,8 @@ Status: implemented +English | [中文](2026-07-13-session-query-tracing.zh.md) + ## Problem Session relationships are encoded across immutable headers, positional surface operations, and logged provenance arrays. A consumer reconstructing those relationships directly would need to duplicate corpus precedence, surface folding, malformed-log handling, deterministic lineage ordering, and cloning. Positional replacement and provenance are different graphs, so collapsing them into one generic edge type would also lose meaning. diff --git a/.agents/notes/implemented/feature/2026-07-13-session-query-tracing.zh.md b/.agents/notes/implemented/feature/2026-07-13-session-query-tracing.zh.md new file mode 100644 index 0000000000..485060f9e5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-13-session-query-tracing.zh.md @@ -0,0 +1,36 @@ +# Agent Note: 会话查询关系追踪 + +Status: implemented + +[English](2026-07-13-session-query-tracing.md) | 中文 + +## 问题 + +会话关系分散编码在不可变 header、位置式表面操作和已记录的来源数组中。消费方如果直接重建这些关系,就必须重复实现语料优先级、表面折叠、格式错误日志的处理、确定性的谱系顺序和克隆。位置替换与来源属于不同的图,因此把两者合并为一种通用边类型也会丢失含义。 + +## 决策 + +`ctx.sessionQuery` 除精确读取外,还公开 `traceSession(sessionId)` 和 `traceEvent({ sessionId, seq })`。两者都是基于现有「实时数据优先」语料的一次性视图:会话追踪读取一次完整语料列表,事件追踪读取一份逻辑日志并执行一次规范表面折叠。服务在调用结束后不会保留谱系、反向索引或替换状态。 + +`SessionLineageTrace` 返回目标、按从直接父级到外层父级排序的已知父级,以及递归的后代树;同级节点先按创建时间排序,再按 session id 排序。`complete: true` 会携带已知根节点;`complete: false` 会携带第一个无法解析的父级 id。与目标相连的循环会以 `SESSION_QUERY_INVALID_LINEAGE` 失败。 + +`SessionEventTrace` 将位置关系与来源关系分开保留。`replacedBy` 是直接的位置替换者,`replacementChain` 沿替换者追踪至最终节点,`replacedEventSeqs` 则列出目标直接移除的真实表面节点。`sourceEventSeqs` 保留日志中直接来源的顺序,而 `derivedEventSeqs` 按日志顺序列出后续的直接反向引用。来源关系不会传递展开。 + +## 校验边界 + +事件追踪会在分析表面之前检查目标是否存在。随后,事件列表与追踪都会使用 `dsh-session` 的单遍表面折叠,对加载的日志整体进行接受或拒绝:事件 seq 从零开始且连续;表面标记符合事件类型的适用范围;只有表面事件类型可以携带来源;存在的数组必须非空且没有重复项;每个来源必须是更早的 seq;每次位置替换必须指明并引用它所移除的全部表面节点。任何契约违例都使用 `SESSION_QUERY_INVALID_SURFACE`;系统不存在只用于分类、要求更弱的表面标准。 + +所有返回的记录与数组都与内部状态分离。已知的实时事件追踪绝不查询持久化;持久化事件追踪保留精确读取所要求的列表/加载一致性检查。会话谱系必然属于跨语料操作,因此也保留跨语料的持久化失败语义。 + +## 考虑过的替代方案 + +- **公开独立的追踪辅助函数**:不予采纳,因为源优先级与状态分离边界属于 `ctx.sessionQuery`;公开辅助函数会诱使调用方绕过该边界。 +- **合并替换边与来源边**:不予采纳,因为位置替换可以遮蔽表面节点,同时引用不在表面上的构造输入,而消费方需要区分这两种含义。 +- **返回传递来源闭包**:不予采纳,因为这会掩盖日志中直接记录的证据、增大结果,并让一条遥远的格式错误边改变原本局部的输出。 +- **在格式错误的来源关系上返回尽力而为的追踪结果**:不予采纳,因为结构上看似合理的局部图会显得具有权威性。当规范的关系契约损坏时,精确检查必须快速失败。 + +## 影响 + +消费方无需缓存或引入第二份语料,即可获得确定性的关系视图。事件追踪每次调用都会执行全日志校验和分配,而谱系追踪每次调用都会列出完整的逻辑语料。这些成本让真源保持明确,并且与承载内容的全文搜索及过滤 API 相互独立。 + +该功能具备单元测试和服务层覆盖率,但没有快照或端到端 fixture(测试前置数据),因为它没有引入面向模型的消费方、transcript(文本记录)变更或跨进程协议。 diff --git a/.agents/notes/implemented/feature/2026-07-14-time-context-plugin.md b/.agents/notes/implemented/feature/2026-07-14-time-context-plugin.md deleted file mode 100644 index 189f75fc12..0000000000 --- a/.agents/notes/implemented/feature/2026-07-14-time-context-plugin.md +++ /dev/null @@ -1,59 +0,0 @@ -# Agent Note: Optional time-context plugin - -Status: implemented - -English | [中文](2026-07-14-time-context-plugin.zh.md) - -## Problem - -The dynamic system-prompt storage and refresh decision in this record is superseded by [Durable per-step time context](2026-07-16-durable-per-step-time-context.md). The opt-in package, zoned formatting, and validation remain; the follow-up owns the current model-visible and durability contract. - -An agent request has no live clock unless a deployment puts one in prompt text or gives the model a query tool. Static text becomes stale, while a tool call adds overhead to ordinary reasoning about dates, deadlines, or idle time. Without elapsed time, the model cannot distinguish an immediate follow-up from one sent hours after the preceding message. - -Prompt assembly can derive both facts per step from durable session timestamps, and request-header logging can record the exact rendered value. Accumulating stale readings in conversation history or waking idle agents would violate the existing request lifecycle. - -## Decision - -`@deepseek-ai/dsh-time-context` is an opt-in function plugin at `packages/context/time-context/`. The `context/` product group holds bounded request-context enrichments that define neither a tool nor a service. `dsh-agent-spine-demo` and shipped examples do not load the package; deployments mount it explicitly when its token and disclosure costs are acceptable. - -The plugin registers the global `context:time` system-prompt section at order 10, after the deployment persona and before tool guidance. For an active turn it emits an ISO-shaped timestamp with numeric UTC offset and IANA zone, plus a compact whole-second duration since the last model-visible message before the turn opened. Bare and idle assemblies receive an empty section. - -### Previous-message baseline - -At a turn's first assembly, the provider scans before `turn/start` for the latest `user/message`, `assistant/message`, `tool/result`, `context/message`, or `steering/message`. It excludes the current prompt so the duration expresses the inter-turn gap instead of approximately zero. Every refresh in that turn keeps the same baseline, and the first turn reports `unavailable (no earlier message in this session)`. - -The baseline is the session event's append time, not an unlogged client timestamp. Resume and fork behavior are therefore deterministic from the durable log, and the model-visible value remains reconstructable without a new event. A backward wall-clock adjustment clamps the duration to zero. - -### Refresh policy - -`refreshIntervalMs` defaults to 60,000 and must be a non-negative safe integer. Every turn's first request refreshes. Later assemblies in that turn reuse the block until its age reaches the interval; `0` refreshes every step. No timer creates work during model calls, tools, or idle time because refresh is request-bound. - -When `timeZone` is omitted, `Intl.DateTimeFormat` resolves the Node process's system zone once at plugin load. Node honors `TZ`; without that override, the host or container supplies the zone. An explicit value must be an IANA identifier and is validated at load. The captured zone remains stable until plugin reload, and the ISO-shaped local timestamp includes its current numeric offset so daylight-saving changes stay explicit. This is the deployment process's zone, not a remote user's zone. - -### Logging and token shape - -The loop records the temporal block in full `request/header` snapshots before transmission, satisfying the [reconstructable-requests contract](../architecture/2026-07-05-reconstructable-requests.md). Each request carries one current block; earlier readings do not remain in conversation history. The plugin owns the fact and contributes it through the prompt registry, following the [prompt-variables Agent Note](../architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md) without a loop special case. - -## Testing - -Unit tests pin formatting, baselines, refresh policy, validation, per-agent state, disposal, and load-time system-zone capture. A real agent-loop test pins the transmitted prompt and full `request/header` snapshots. A keyless subprocess e2e boots a test-only `cordis.yml` through the real Loader and stdio app, omits `timeZone` under a controlled `TZ`, drives two turns, and verifies the persisted request headers externally. Default snapshot compositions omit the plugin, so their transcript fixtures contain no temporal block. - -## Alternatives considered - -- **Append a `context/message` on every turn or refresh** — rejected because readings and token cost would accumulate in history. Replacing a prior surface node would preserve its old position, while replacing the tail would hide intervening conversation. -- **Use `agent/session-prefix`** — rejected because the session-stable prefix cannot represent a per-turn or per-step clock. -- **Mutate requests in `agent/request`** — rejected because that seam shapes call config after the message boundary; inserted model content would bypass prompt-pressure accounting and request-header logging. -- **Register separate `{{current_time}}` and `{{elapsed}}` variables** — rejected because independent providers can sample different instants and require shared caching. One section records the pair atomically without a deployment-authored template. -- **Refresh from a background timer** — rejected because a new value has no consumer outside request assembly. Timer-driven `agent.inject()` would create turns and wake idle sessions merely to report time passing. -- **Keep UTC as the omitted default** — rejected because an explicitly enabled clock should follow its deployment environment unless the operator chooses UTC. `timeZone: UTC` remains available when a deployment requires it. -- **Add a time-zone detection library** — rejected because Node's `Intl` runtime already exposes the process's IANA zone. Another dependency cannot infer a remote user's zone either. -- **Mount the plugin in `dsh-agent-spine-demo`** — rejected because time zone, disclosure, token budget, and freshness are deployment policy. Opt-in keeps default context stable. -- **Place the package in `core/`** — rejected because `core/` owns the product API spine, while this plugin is an optional leaf with no service key. - -## Consequences - -- Opted-in models receive a zoned clock and inter-turn duration without a tool call. The system-prompt cost is fixed per request instead of growing with the session. -- An omitted `timeZone` follows the process's `TZ`, host, or container zone as observed at plugin load. Operators must configure an explicit zone when the deployment environment does not represent the intended user. -- A refresh changes the request header and can add a full `request/header` snapshot with reason `change`. `refreshIntervalMs` trades freshness against the number and size of durable full snapshots; `0` records a new value on every step whose whole-second rendering changes. -- No request exists solely to refresh time. A long-running tool leaves the prior reading until the next step assembles. -- Duration reflects harness processing time at durable append boundaries, not client-network latency before logging. Preserving a client-origin timestamp requires a separate durable input contract. diff --git a/.agents/notes/implemented/feature/2026-07-14-time-context-plugin.zh.md b/.agents/notes/implemented/feature/2026-07-14-time-context-plugin.zh.md deleted file mode 100644 index 12671cb891..0000000000 --- a/.agents/notes/implemented/feature/2026-07-14-time-context-plugin.zh.md +++ /dev/null @@ -1,59 +0,0 @@ -# Agent Note:可选时间上下文插件 - -Status: implemented - -[English](2026-07-14-time-context-plugin.md) | 中文 - -## 问题 - -本记录中的动态系统提示词存储和刷新决策已由[持久的逐步骤时间上下文](2026-07-16-durable-per-step-time-context.md)取代。需要显式启用的包(package)、分区时间格式和校验仍然保留;后续 Agent Note 负责当前的模型可见与持久性契约。 - -如果部署方既未在提示词中提供时钟,也未给模型提供查询工具,agent(智能体)请求就无法获得实时准确的时间。静态文本会变得陈旧,而对于日期、截止时间或闲置时长等常规推理,调用工具会增加开销。缺少已经过去的时长时,模型无法区分紧接着发送的消息与上一条消息几小时后才发送的消息。 - -提示词组装流程可以在每个步骤中根据持久会话时间戳派生这两项信息,请求头日志则可以记录实际渲染的确切值。在会话历史中累积陈旧读数或唤醒空闲 agent 都会违反现有请求生命周期。 - -## 决策 - -`@deepseek-ai/dsh-time-context` 是位于 `packages/context/time-context/`、需要显式启用的函数插件。`context/` 产品分组用于容纳既不定义工具、也不定义服务的有界请求上下文增强。`dsh-agent-spine-demo` 和仓库提供的示例都不会加载该包;只有当 token 与信息披露成本可接受时,部署方才显式挂载它。 - -该插件注册顺序值为 10 的全局系统提示词区段 `context:time`,位置在部署方角色设定之后、工具指导之前。对于活跃轮次,它会输出带数字 UTC 偏移和 IANA 时区、形似 ISO 的时间戳,以及从轮次开始前最后一条模型可见消息起算的紧凑整秒时长。未绑定 agent 或 agent 处于空闲状态时,该区段为空。 - -### 上一条消息基线 - -在轮次首次组装时,提供方会在 `turn/start` 之前查找最近的 `user/message`、`assistant/message`、`tool/result`、`context/message` 或 `steering/message`。它会排除当前提示词,使时长表达轮次间隔,而不是接近零。同一轮次中的每次刷新都保留这条基线;首个轮次报告 `unavailable (no earlier message in this session)`。 - -基线采用会话事件的追加时间,而不是日志中不存在的客户端时间戳。因此,恢复和 fork 行为可以从持久日志中确定性重现,模型可见值也无需新增事件即可重建。系统挂钟向后调整时,插件会将时长钳制为零。 - -### 刷新策略 - -`refreshIntervalMs` 默认值为 60,000,并且必须是非负安全整数。每个轮次的首次请求都会刷新。同一轮次中的后续组装会复用该区块,直至其存在时间达到该间隔;设为 `0` 时每个步骤都刷新。刷新仅由请求驱动,因此在模型调用、工具运行或空闲期间,计时器不会创建任务。 - -省略 `timeZone` 时,`Intl.DateTimeFormat` 会在插件加载时解析一次 Node 进程的系统时区。Node 会遵循 `TZ`;没有该覆盖值时,时区由主机或容器提供。显式值必须是 IANA 标识符,并在加载时接受校验。捕获的时区在插件重新加载前保持稳定,形似 ISO 的本地时间戳包含其当前数字偏移,使夏令时变化保持显式可见。该默认值代表部署进程的时区,而不是远程用户的时区。 - -### 日志与 token 形态 - -agent loop(智能体循环)会在发送前通过完整的 `request/header` 快照记录时间区块,从而满足[可重建请求契约](../architecture/2026-07-05-reconstructable-requests.md)。每个请求只携带一个当前区块;先前的读数不会保留在会话历史中。该插件拥有时间信息,并按照[提示词变量 Agent Note](../architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)通过提示词注册表贡献该信息,无需为循环添加特殊分支。 - -## 测试 - -单元测试固定格式化、基线、刷新策略、校验、逐 agent 状态、资源释放行为,以及系统时区在加载时的捕获行为。使用真实 agent loop 的测试固定实际发送的提示词和完整的 `request/header` 快照。无密钥子进程端到端测试通过真实 Loader 和 stdio 应用启动测试专用 `cordis.yml`,在受控 `TZ` 下省略 `timeZone`,驱动两个轮次,并从外部校验持久请求头。默认快照组合不包含该插件,因此其中的 transcript(文本记录)fixture(测试前置数据)不包含时间区块。 - -## 考虑过的替代方案 - -- **每个轮次或每次刷新都追加一条 `context/message`**——不予采纳,因为读数和 token 成本会在历史中累积。替换先前的表层节点会保留其旧位置,而替换尾部节点会隐藏中间的会话内容。 -- **使用 `agent/session-prefix`**——不予采纳,因为会话期间保持稳定的前缀无法表示逐轮次或逐步骤变化的时钟。 -- **在 `agent/request` 中修改请求**——不予采纳,因为该边界在消息边界之后塑造调用配置;插入模型可见内容会绕过提示词压力核算和请求头日志。 -- **注册独立的 `{{current_time}}` 和 `{{elapsed}}` 变量**——不予采纳,因为独立提供方可能在不同时间点采样,并且需要共享缓存。单个区段会以原子方式记录两项信息,也不需要部署方编写时间模板。 -- **通过后台计时器刷新**——不予采纳,因为请求组装之外没有消费新值的对象。由计时器驱动 `agent.inject()` 会创建轮次,并且只为报告时间流逝就唤醒空闲会话。 -- **省略配置时仍默认使用 UTC**——不予采纳,因为显式启用的时钟应跟随部署环境,除非运维方选择 UTC。需要 UTC 的部署仍可配置 `timeZone: UTC`。 -- **引入时区探测库**——不予采纳,因为 Node 的 `Intl` 运行时已经能够提供进程的 IANA 时区,而且额外依赖同样无法推断远程用户的时区。 -- **在 `dsh-agent-spine-demo` 中挂载插件**——不予采纳,因为时区、信息披露、token 预算和新鲜度都属于部署策略。选择加入能保持默认上下文稳定。 -- **将包放入 `core/`**——不予采纳,因为 `core/` 负责产品 API 主干,而该插件是没有服务键的可选叶节点。 - -## 后果 - -- 选择加入的模型无需调用工具,即可获得分区时钟和轮次间隔时长。每个请求的系统提示词成本固定,不会随会话增长。 -- 省略 `timeZone` 时,插件采用加载时观察到的进程 `TZ`、主机或容器时区。当部署环境不能代表目标用户时,运维方必须显式配置时区。 -- 刷新会改变请求头,并可能新增一份 reason 为 `change` 的完整 `request/header` 快照。`refreshIntervalMs` 用新鲜度换取完整持久快照的数量与大小;设为 `0` 时,每个整秒渲染结果发生变化的步骤都会记录新值。 -- 系统不会仅为刷新时间而创建请求。长时间运行的工具会保留先前读数,直至下一步骤开始组装。 -- 时长反映持久追加边界处的 harness 处理时间,不包含消息进入日志之前的客户端网络延迟。若要保留客户端来源时间戳,需要单独的持久输入契约。 diff --git a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.i18n.yaml index f037660761..910872881a 100644 --- a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.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 -2026-07-16-durable-per-step-time-context.md: 2d7076d51dbe1a64e5042230bddc6844141ff265 -2026-07-16-durable-per-step-time-context.zh.md: 432e0305cf44dcce1053c6580c9f0039309a7af4 +2026-07-16-durable-per-step-time-context.md: 4bc17b3c08707fcaa4f0f431e71ddbe567a03c9e +2026-07-16-durable-per-step-time-context.zh.md: 836c0f83fbe9d6120741a261cf25ce7d8c227bdf diff --git a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md index 2d7076d51d..4bc17b3c08 100644 --- a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md +++ b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md @@ -12,13 +12,13 @@ A process-local refresh cache makes displayed time depend on state that cannot s ## Decision -`@deepseek-ai/dsh-time-context` is an opt-in function plugin in `packages/context/time-context/`. It registers a prepended `agent/pre-step` listener and, when an injection is due, calls `agent.inject()` for a pre-step attempt whose signal is not already aborted. The injected `context/message` carries source `{ kind: 'plugin', plugin: 'time-context' }` and append surface metadata; a suppressed attempt appends nothing. +`@deepseek-ai/dsh-time-context` is an opt-in function plugin in `packages/context/time-context/`. The `context/` group holds bounded request-context enrichments that define neither a tool nor a service, and shipped examples do not mount this plugin because its time-zone disclosure and token cost are deployment policy. It registers a prepended `agent/pre-step` listener and, when an injection is due, calls `agent.inject()` for a pre-step attempt whose signal is not already aborted. The injected `user/message` carries source `{ kind: 'plugin', plugin: 'time-context' }` and append surface metadata; a suppressed attempt appends nothing. The listener records preparation context before a possible `step/start`. Its prepended registration runs before ordinary automatic compaction listeners, so pressure estimation and any resulting surface rewrite observe a newly appended reading. A later pre-step listener can cancel or fail the attempt before the step opens; the reading remains because the durable log is append-only and this plugin performs no rollback. The optional `timeZone` config resolves the Node process's IANA zone once at plugin load when omitted; an explicit value is validated by `Intl.DateTimeFormat`. The timestamp includes the numeric UTC offset and resolved IANA zone. -The optional `refreshIntervalMs` config is manually validated at plugin load as a non-negative safe integer. Omission or `0` injects on every eligible preparation attempt. A positive value scans the raw session events for the most recent `context/message` with this plugin's source and injects when none exists, wall time moved backward, or the event is at least the configured age. The raw event timestamp governs even after compaction shadows the message, so scheduling persists across turns and process resume without a timer or process-local cache. +The optional `refreshIntervalMs` config is manually validated at plugin load as a non-negative safe integer. Omission or `0` injects on every eligible preparation attempt. A positive value scans the raw session events for the most recent `user/message` with this plugin's source and injects when none exists, wall time moved backward, or the event is at least the configured age. The raw event timestamp governs even after compaction shadows the message, so scheduling persists across turns and process resume without a timer or process-local cache. ### Text and elapsed baselines @@ -29,7 +29,7 @@ Time sampled while preparing turn , step 1: Elapsed since the preceding model-visible message: . ``` -The baseline is the latest preceding user, assistant, tool-result, context, or steering message. This includes the accepted prompt that opened an ordinary message turn. If no model-visible message exists, the duration is `unavailable`. +The baseline is the latest preceding user, assistant, tool-result, or steering message. This includes the accepted prompt that opened an ordinary message turn. If no model-visible message exists, the duration is `unavailable`. An injected later-step reading is: @@ -48,11 +48,7 @@ The plugin contributes nothing to system-prompt assembly. `request/header` conta ## Testing -Unit and real-loop tests pin formatting, both elapsed baselines, interval omission and zero, threshold boundaries, cross-turn and per-session scheduling, backward-clock behavior, invalid config, resumed raw-event lookup after compaction, aborted-signal behavior, later-listener cancellation and failure, listener disposal, source and surface metadata, cumulative multi-step visibility, and absence from request headers. A keyless subprocess e2e boots the real Loader and stdio app, drives two turns, and verifies the persisted context events externally. - -## Supersedes - -This decision supersedes the dynamic system-prompt storage and refresh policy in [Optional time-context plugin](2026-07-14-time-context-plugin.md). It keeps the package location, opt-in deployment stance, timestamp formatting, process-zone default, and load-time validation. Durable history replaces the `context:time` prompt section, process-local refresh cache, and request-header deltas; `refreshIntervalMs` controls durable append frequency instead of prompt replacement. +Unit and real-loop tests pin formatting, both elapsed baselines, interval omission and zero, threshold boundaries, cross-turn and per-session scheduling, backward-clock behavior, invalid config, resumed raw-event lookup after compaction, aborted-signal behavior, later-listener cancellation and failure, listener disposal, source and surface metadata, cumulative multi-step visibility, and absence from request headers. A keyless subprocess e2e boots the real Loader with the Headless composition, drives two ordered one-shot turns, and verifies the persisted plugin-attributed messages externally. ## Alternatives considered @@ -61,10 +57,13 @@ This decision supersedes the dynamic system-prompt storage and refresh policy in - **Inject from a background timer** — rejected because idle time has no pending request to consume the value, and timer-driven injection would create durable turns solely to report time passing. - **Expose time only through a tool** — rejected because ordinary temporal reasoning would require an avoidable tool round trip and would not guarantee a reading before every step. - **Use `agent/session-prefix`** — rejected because one loop-instance prefix cannot represent distinct step timestamps and does not accumulate historically attributable readings. +- **Mutate assembled requests or register independent prompt variables** — rejected because request-local insertion bypasses the durable surface and separate providers can sample different instants. One attributed context message records the timestamp and elapsed baseline atomically. +- **Default to UTC or add a time-zone detection dependency** — rejected because an explicitly mounted plugin follows its process environment unless the operator selects an IANA zone, while no server-side library can infer a remote user's zone. +- **Mount the plugin in shipped compositions or place it in `core/`** — rejected because disclosure, time zone, freshness, and history cost are deployment choices for an optional context leaf, not product-spine policy. ## Consequences - Omission or `0` records every eligible preparation attempt; a positive interval reduces append frequency and history growth while preserving durable scheduling across resume. - Timing context remains append-only until compaction shadows older surface nodes, including a preparation reading left by a later cancellation or failure. - The first-step duration normally measures from the prompt that opened the turn, while later-step durations measure model and tool processing since the preceding step context. -- An omitted `timeZone` still reflects the deployment process rather than a remote user, and elapsed time still uses durable harness append boundaries rather than client-origin timestamps. +- An omitted `timeZone` still reflects the deployment process rather than a remote user, and elapsed time still uses durable harness append boundaries rather than client-origin timestamps. Supporting client-origin time requires a separate durable input contract. diff --git a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.zh.md b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.zh.md index 432e0305cf..836c0f83fb 100644 --- a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.zh.md @@ -12,13 +12,13 @@ Status: implemented ## 决策 -`@deepseek-ai/dsh-time-context` 是位于 `packages/context/time-context/`、需要显式启用的函数插件。它注册一个前置的 `agent/pre-step` 监听器,并在需要注入时,为信号尚未取消的预步骤尝试调用 `agent.inject()`。注入的 `context/message` 携带来源 `{ kind: 'plugin', plugin: 'time-context' }` 和追加表层元数据;受间隔抑制的尝试不会追加任何内容。 +`@deepseek-ai/dsh-time-context` 是位于 `packages/context/time-context/`、需要显式启用的函数插件。`context/` 分组容纳有界的请求上下文增强,这些增强既不定义工具也不定义服务;已交付示例不挂载此插件,因为时区披露与 token 成本属于部署策略。它注册一个前置的 `agent/pre-step` 监听器,并在需要注入时,为信号尚未取消的预步骤尝试调用 `agent.inject()`。注入的 `user/message` 携带来源 `{ kind: 'plugin', plugin: 'time-context' }` 和追加表层元数据;受间隔抑制的尝试不会追加任何内容。 监听器在可能出现的 `step/start` 之前记录准备上下文。它采用前置注册,因此先于普通自动压缩监听器运行,使压力估算和由此产生的表层重写都能观察到新追加的读数。后续预步骤监听器可能在步骤开启前取消尝试或使其失败;持久日志仅追加,且本插件不执行回滚,因此该读数会保留下来。 省略可选配置 `timeZone` 时,插件在加载时解析一次 Node 进程的 IANA 时区;显式值由 `Intl.DateTimeFormat` 校验。时间戳包含数字 UTC 偏移和解析后的 IANA 时区。 -插件在加载时手动校验可选配置 `refreshIntervalMs`,其值必须为非负安全整数。省略或设为 `0` 时,每次符合条件的准备尝试都会注入。设为正数时,插件扫描原始会话事件,查找来源属于本插件的最新 `context/message`;不存在此类事件、系统挂钟向后移动,或该事件已达到配置时长时,插件执行注入。即使压缩已隐藏消息,调度仍以原始事件时间戳为准,因此该机制无需计时器或进程本地缓存,也能跨轮次和进程恢复持续生效。 +插件在加载时手动校验可选配置 `refreshIntervalMs`,其值必须为非负安全整数。省略或设为 `0` 时,每次符合条件的准备尝试都会注入。设为正数时,插件扫描原始会话事件,查找来源属于本插件的最新 `user/message`;不存在此类事件、系统挂钟向后移动,或该事件已达到配置时长时,插件执行注入。即使压缩已隐藏消息,调度仍以原始事件时间戳为准,因此该机制无需计时器或进程本地缓存,也能跨轮次和进程恢复持续生效。 ### 文本与时长基线 @@ -29,7 +29,7 @@ Time sampled while preparing turn , step 1: Elapsed since the preceding model-visible message: . ``` -基线是前一条用户消息、助手消息、工具结果、上下文消息或 steering(中途引导)消息。对于普通消息轮次,这包括开启轮次的已接受提示词。如果不存在模型可见消息,时长为 `unavailable`。 +基线是前一条用户消息、助手消息、工具结果或 steering(中途引导)消息。对于普通消息轮次,这包括开启轮次的已接受提示词。如果不存在模型可见消息,时长为 `unavailable`。 后续步骤的注入读数为: @@ -48,11 +48,7 @@ Elapsed since the preceding step context: . ## 测试 -单元测试和真实 agent loop(智能体循环)测试固定格式化、两种时长基线、间隔省略和零值、阈值边界、跨轮次和各会话独立调度、挂钟后退行为、无效配置、压缩后基于恢复会话的原始事件查找、已取消信号行为、后续监听器取消和失败、监听器 dispose(资源释放)、来源与表层元数据、多步骤累计可见性,以及请求头中不存在时间上下文。无密钥子进程 e2e 测试通过真实 Loader 和 stdio 应用启动,驱动两个轮次,并从外部校验持久化的上下文事件。 - -## 取代的决策 - -本决策取代[可选时间上下文插件](2026-07-14-time-context-plugin.md)中的动态系统提示词存储和刷新策略。它保留包位置、选择加入式部署、时间戳格式、进程时区默认值和加载时校验。持久历史取代 `context:time` 提示词区段、进程本地刷新缓存和请求头增量;`refreshIntervalMs` 用于控制持久追加频率,而非提示词替换。 +单元测试和真实 agent loop(智能体循环)测试固定格式化、两种时长基线、间隔省略和零值、阈值边界、跨轮次和各会话独立调度、挂钟后退行为、无效配置、压缩后基于恢复会话的原始事件查找、已取消信号行为、后续监听器取消和失败、监听器 dispose(资源释放)、来源与表层元数据、多步骤累计可见性,以及请求头中不存在时间上下文。无密钥子进程 e2e 测试使用 Headless 组合启动真实 Loader,依次驱动两个单次任务轮次,并从外部校验持久化且来源归属于插件的消息。 ## 考虑过的替代方案 @@ -61,10 +57,13 @@ Elapsed since the preceding step context: . - **通过后台计时器注入**——不予采纳,因为空闲期间没有待处理请求消费该值,而且计时器驱动的注入会仅为报告时间流逝而创建持久轮次。 - **只通过工具提供时间**——不予采纳,因为普通时间推理会产生本可避免的工具往返,也不能保证每个步骤之前都有读数。 - **使用 `agent/session-prefix`**——不予采纳,因为一个 loop 实例前缀无法表示不同的步骤时间戳,也不会累计具有历史归属的读数。 +- **修改已组装的请求或注册独立提示词变量**——不予采纳,因为请求内插入会绕过持久表层,不同提供方也可能在不同时间采样。一条带来源归属的上下文消息会原子地记录时间戳和时长基线。 +- **默认使用 UTC 或增加时区检测依赖**——不予采纳,因为显式挂载的插件默认遵循其进程环境,除非操作方选择 IANA 时区,而任何服务端库都无法推断远程用户的时区。 +- **在已交付组合中挂载插件,或把它放进 `core/`**——不予采纳,因为披露内容、时区、新鲜度和历史成本是可选上下文叶节点的部署选择,不是产品主干策略。 ## 后果 - 省略 `refreshIntervalMs` 或设为 `0` 时,每次符合条件的准备尝试都会留下记录;正数间隔会减少追加频率和历史增长,同时使持久调度在恢复后继续生效。 - 时间上下文仅追加并保留到压缩隐藏旧表层节点为止,其中也包括后续取消或失败所留下的准备读数。 - 第一个步骤的时长通常从开启轮次的提示词起算,后续步骤的时长则反映自上一条步骤上下文以来的模型与工具处理时间。 -- 省略 `timeZone` 时仍采用部署进程而非远程用户的时区,时长仍采用 harness 的持久追加边界而非客户端来源时间戳。 +- 省略 `timeZone` 时仍采用部署进程而非远程用户的时区,时长仍采用 harness 的持久追加边界而非客户端来源时间戳。若要支持客户端来源的时间,需要另行建立持久输入契约。 diff --git a/.agents/notes/implemented/feature/2026-07-20-tui-startup-slogans.md b/.agents/notes/implemented/feature/2026-07-20-tui-startup-slogans.md deleted file mode 100644 index a2a22baafd..0000000000 --- a/.agents/notes/implemented/feature/2026-07-20-tui-startup-slogans.md +++ /dev/null @@ -1,39 +0,0 @@ -# Agent Note: Startup slogans replace the configured TUI welcome line - -Status: implemented - -English | [中文](2026-07-20-tui-startup-slogans.zh.md) - -> **Superseded** for the slogan/animation half by the [banner sweep Agent Note](2026-07-21-tui-banner-sweep.md): the slogan bank and typewriter reveal shipped, read as weird in use, and were replaced by a subtitle-free banner with a whole-banner sweep. The removal of the configured demo welcome and the animation-lifecycle groundwork (start after `ui.start()`, clear through `detachListeners`) stand. - -## Problem - -The TUI header subtitle came from a `welcome` config the demo leaf set to "TUI agent ready. Give it a coding task." — instructional filler that told a returning user nothing, restated what the product is on every boot, and had a hardcoded twin (`'ready.'`) as the schema default in two packages. The product wanted a startup moment with some character instead of a static banner caption. - -## Decision - -- `examples/tui-agent/cordis.yml` no longer configures `welcome`; the config key stays for deployments and fixtures that need a fixed, deterministic subtitle (the Code Mode overlay and every snapshot/scripted fixture keep theirs). -- When `welcome` is unset, `dsh-tui` picks one member of an exported `STARTUP_SLOGANS` bank per boot (`pickStartupSlogan`, injectable random source) and reveals it with a typewriter animation: one character per 40 ms frame, a `▌` block cursor trailing until complete. The reveal starts only after `ui.start()` succeeds and its interval is cleared on dispose alongside the other listeners. -- The slogan bank is presentation copy, deliberately not config: deployments that want controlled wording already have `welcome`. Slogans are ASCII-only by contract because the reveal slices per character. -- `dsh-tui-demo` forwards `welcome` only when configured instead of defaulting it, so the app no longer decides the TUI's idle subtitle. -- The keyless PTY boot scenario now waits for the reveal cursor (`▌` — the only source of that glyph in an empty transcript) instead of the removed welcome text. - -The same change restores `packages/ui/tui/src/index.ts` to 100 % per-file coverage, which the color-scheme merge had broken on the integration branch: the editor border-color reassignment inside `applyColorScheme` was dead (the `setStatus` call right after re-derives it) and is removed, and the color-scheme query's `.then`/`.catch` arrows became named, tested handlers (`applyReportedScheme`, `ignoreSchemeQueryFailure` — the latter pinned by a test whose terminal throws on the DSR query write). - -## Alternatives considered - -**A fixed cooler slogan.** Rejected: one string re-read on every boot decays into wallpaper exactly like the line it replaces; a small rotating bank keeps the moment alive at no complexity cost. - -**Making the bank and reveal speed configurable.** Rejected: that is two new knobs for presentation copy; `welcome` is already the escape hatch for deployments with an opinion, and the no-hardcoded-tunables rule targets deployment-varying behavior, not brand copy. - -**Animating in `HeaderComponent` itself.** Rejected: the component would need a TUI handle and its own lifecycle; the chat already owns a render loop, timers, and a disposal path, so the reveal lives beside the other `createTuiChat` effects and `detachListeners` clears it. - -## Consequences - -- Boot output is no longer byte-deterministic when `welcome` is unset (random slogan, timed frames). Every recorded or snapshot surface pins `welcome` explicitly, so no snapshot changed; the PTY smoke anchors on the reveal cursor and the session-id line instead. -- The `welcome` schema default disappeared from both `dsh-tui` and `dsh-tui-demo`; a direct caller passing no welcome now gets a slogan, not `'ready.'`. -- Adding a slogan is a one-line bank edit; tests assert membership, not specific text. - -## Testing - -`packages/ui/tui/tests/tui.spec.ts` pins deterministic bank selection with an injected random source, the reveal (a bank member fully rendered, cursor frames observed), the configured-welcome path rendering verbatim with no cursor, and dispose stopping a mid-reveal animation. `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` boots the real tree in a PTY and waits on the reveal cursor. Verified live in tmux (mid-reveal frame `no map below▌` then the full slogan). diff --git a/.agents/notes/implemented/feature/2026-07-20-tui-startup-slogans.zh.md b/.agents/notes/implemented/feature/2026-07-20-tui-startup-slogans.zh.md deleted file mode 100644 index 58fa5790f3..0000000000 --- a/.agents/notes/implemented/feature/2026-07-20-tui-startup-slogans.zh.md +++ /dev/null @@ -1,39 +0,0 @@ -# Agent Note: 启动 slogan 取代配置化的 TUI 欢迎语 - -Status: implemented - -[English](2026-07-20-tui-startup-slogans.md) | 中文 - -> **已被取代**:slogan/动画的那一半由[横幅扫入 Agent Note](2026-07-21-tui-banner-sweep.md)取代:slogan 库和打字机动画上线后实际使用中显得怪异,已替换为无副标题的横幅加整体扫入。移除示例配置中欢迎语的决定与动画生命周期基础设施(`ui.start()` 后启动、经 `detachListeners` 清除)保持不变。 - -## Problem - -TUI 头部副标题来自一个 `welcome` 配置,示例叶子配置把它设为 "TUI agent ready. Give it a coding task."——一句说明书式的填充语,对老用户毫无信息量,每次启动都在复述产品是什么,而且它还有一个硬编码的孪生兄弟(`'ready.'`)作为两个包里的 schema 默认值。产品需要的是一个有性格的启动时刻,而不是一条静态横幅说明。 - -## Decision - -- `examples/tui-agent/cordis.yml` 不再配置 `welcome`;该配置键保留给需要固定、确定性副标题的部署与 fixture(Code Mode overlay 和所有快照/脚本化 fixture 都保留各自的欢迎语)。 -- `welcome` 未设置时,`dsh-tui` 每次启动从导出的 `STARTUP_SLOGANS` 库里挑选一条(`pickStartupSlogan`,随机源可注入),并以打字机动画逐字显示:每帧 40 ms 一个字符,完成前尾随一个 `▌` 块状光标。动画只在 `ui.start()` 成功后启动,其定时器与其他监听器一起在 dispose 时清除。 -- slogan 库是展示文案,刻意不做成配置:想控制措辞的部署已经有 `welcome` 这个出口。按契约 slogan 只含 ASCII,因为逐字显示按字符切片。 -- `dsh-tui-demo` 只在配置了 `welcome` 时才转发它,不再填默认值,应用不再替 TUI 决定空闲副标题。 -- 无 key 的 PTY 启动场景改为等待逐字显示的光标(`▌`——空 transcript 里该字形的唯一来源),不再等待已删除的欢迎文本。 - -同一变更把 `packages/ui/tui/src/index.ts` 恢复到 100% 的单文件覆盖率(颜色方案合并曾在集成分支上破坏它):`applyColorScheme` 里对编辑器边框颜色的重新赋值是死代码(紧随其后的 `setStatus` 调用会重新推导它),已删除;颜色方案查询的 `.then`/`.catch` 箭头函数改为具名、有测试的处理器(`applyReportedScheme`、`ignoreSchemeQueryFailure`——后者由一个让终端在 DSR 查询写入时抛错的测试固定)。 - -## Alternatives considered - -**换一条更酷的固定 slogan。** 否决:一条每次启动都重读的字符串会和它取代的那行一样退化成墙纸;一个小的轮换库以零复杂度代价让这个时刻保持新鲜。 - -**把 slogan 库和显示速度做成配置。** 否决:那是为展示文案新增两个旋钮;对措辞有主张的部署已经有 `welcome` 这个出口,而「插件里不许硬编码可调参数」规则针对的是随部署变化的行为,不是品牌文案。 - -**在 `HeaderComponent` 内部做动画。** 否决:组件将需要持有 TUI 句柄和自己的生命周期;聊天层已经拥有渲染循环、定时器和释放路径,所以逐字显示与 `createTuiChat` 的其他资源放在一起,由 `detachListeners` 清除。 - -## Consequences - -- `welcome` 未设置时启动输出不再字节级确定(随机 slogan、定时帧)。所有录制或快照表面都显式固定 `welcome`,因此没有快照变化;PTY 冒烟测试改为锚定逐字显示光标和会话 id 行。 -- `welcome` 的 schema 默认值从 `dsh-tui` 和 `dsh-tui-demo` 中消失;不传 welcome 的直接调用方现在得到的是 slogan,而不是 `'ready.'`。 -- 新增一条 slogan 只需在库里加一行;测试断言成员归属,不断言具体文本。 - -## Testing - -`packages/ui/tui/tests/tui.spec.ts` 固定以下行为:注入随机源后的确定性选取、逐字显示(库中某条完整渲染、观察到光标帧)、配置了 welcome 时逐字动画不启动且原文渲染、以及 dispose 停止进行中的动画。`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 在 PTY 里启动真实配置树并等待显示光标。已在 tmux 中实机验证(中途帧 `no map below▌`,随后是完整 slogan)。 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.md b/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.md deleted file mode 100644 index 069fd33a88..0000000000 --- a/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.md +++ /dev/null @@ -1,41 +0,0 @@ -# Agent Note: Auto-titled terminal from the first message - -Status: implemented - -English | [中文](2026-07-21-tui-auto-pane-title.zh.md) - -> **Superseded** by the [session-title consolidation Agent Note](../simplification/2026-07-22-tui-titles-from-session-title-service.md): the TUI-local `autoTitle` generation is removed; titles come from the log-backed session-title service, and the terminal rename consumes `session/title` events. - -> **Superseded** for the default and the resume behavior by the [auto-title default-on Agent Note](2026-07-21-tui-auto-title-default-on.md): `autoTitle` now defaults on, and a resumed session re-derives its title from the stored first message instead of keeping the static one. The OSC 0 path, the one-shot latch, the model-summary shape, the fire-and-forget call, and every failure fallback below stand. - -## Problem - -The TUI's terminal title is a single static string (`title`, default `DeepSeek Harness`) shared by every session. A user who runs one agent per tmux pane or terminal tab sees the same label on all of them, so panes are indistinguishable at a glance and the tab bar carries no signal about what each session is doing. - -## Decision - -- `TuiConfig` gains an `autoTitle` boolean (default `false`). When it is on, the TUI issues one background model call after the first user message of a fresh session and replaces the terminal title with a short, model-generated label; the static `title` is the pre-title and the fallback. -- The label is a model summary, not a truncation of the prompt. The request carries a fixed task instruction (summarize the request as a short title of two to five lowercase words, no punctuation) plus the user's first message and no tools; the TUI takes the first non-empty line of the reply and caps it at 40 characters (39 plus an ellipsis). -- The title is set through `runtime.terminal.setTitle`, the same OSC 0 path the static `title` already uses. No new terminal-control surface is introduced, and pi-tui keeps ownership of terminal writes. -- The call is fire-and-forget and one-shot per session. A `titleSettled` latch guards it: with `autoTitle` off it is pre-settled and never runs; on a resumed session whose first `user/message` is already logged it is pre-settled so the static title stands; a whitespace-only first message is skipped without consuming the slot. Any failure, an empty reply, a missing `llm` service, or a missing agent provider/model leaves the static title untouched. A dedicated `AbortController` cancels an in-flight request on shutdown. -- The title call reaches `ctx.llm.stream` directly rather than through `agent.send`, so it never appends to the session or transcript and cannot perturb the agent loop. -- The feature defaults off and is enabled only in the interactive product config (`examples/tui-agent/cordis.yml`) and the scripted PTY fixture. Enabling it in the shared `dsh-tui-demo` schema default would fire an extra model call in keyless replay and boot scenarios that send no user message. - -## Alternatives considered - -**Truncate the first user message instead of a model title.** Rejected: the user chose a short model-made label; a truncated raw prompt is noisy, often begins with boilerplate, and rarely reads as a title. - -**Rename the window (OSC 2) or the tmux window.** Rejected: OSC 0 sets only `pane_title`, so it labels the pane without renaming or leaking into the user's window title; the user confirmed OSC is the right lever. - -**Default the feature on.** Rejected: enabling it in the shared demo schema perturbs keyless replay and boot snapshots and spends a model call on every fresh session; opt-in per deployment keeps the default surface inert. - -**Fold this into the log-backed session-title work (PR #451).** Rejected: that change is session metadata persisted to the log; this is a terminal label with no persistence. Keeping them independent leaves each self-contained and avoids a shared dependency. - -**Block the first turn until the title resolves.** Rejected: awaiting the title before sending the user's message adds latency to the actual request; fire-and-forget makes the rename invisible to the turn. - -## Consequences - -- When enabled, a fresh session spends one extra, tool-less model call with a single short user message and a few output tokens; off by default, it costs nothing. -- Because the title call stamps `sessionId`, it shares the session's `llm-replay` cursor: enabling `autoTitle` in a replay-backed snapshot scenario would consume a recorded script entry. This is why the default is off and the scripted PTY fixture answers the call with a tool-branching adapter rather than replay. -- `packages/ui/tui/tests/tui.spec.ts` pins the behavior with a mock `llm` adapter: a generated title replaces the static one, over-long output is truncated with an ellipsis, a whitespace-only first message keeps the one-shot slot, empty or failing replies leave the title, a resumed session never fires, and the feature-off / no-service / missing-provider / missing-model paths keep the static title. A shutdown test asserts the in-flight request is aborted. -- `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` proves the real Loader-booted path: the scripted adapter answers the tool-less title call with a fixed string, and the conversation scenario asserts the OSC 0 sequence reaches the PTY. Boot scenarios send no user message, so they never fire the call. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.zh.md deleted file mode 100644 index 580f36b256..0000000000 --- a/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.zh.md +++ /dev/null @@ -1,41 +0,0 @@ -# Agent Note: 从首条消息自动命名终端 - -Status: implemented - -[English](2026-07-21-tui-auto-pane-title.md) | 中文 - -> **已被取代**:见[标题归一 Agent Note](../simplification/2026-07-22-tui-titles-from-session-title-service.md)。TUI 本地的 `autoTitle` 生成已移除;标题来自日志承载的 session-title 服务,终端重命名消费 `session/title` 事件。 - -> **已被取代**(就默认值与恢复行为而言),见[自动标题默认开启 Agent Note](2026-07-21-tui-auto-title-default-on.md):`autoTitle` 现默认开启,恢复会话会从已存储的首条消息重新推导标题,而非保留静态标题。下文的 OSC 0 路径、一次性门闩、模型概括形态、发出后不等待其返回的调用,以及每一条失败兜底,均仍然成立。 - -## Problem - -TUI 的终端标题是一个所有会话共用的静态字符串(`title`,默认 `DeepSeek Harness`)。在 tmux 每个窗格或每个终端标签页各跑一个 agent(智能体)的用户看来,它们的标签全都一样,因此窗格一眼看去无从区分,标签栏也不携带任何关于各会话正在做什么的信号。 - -## Decision - -- `TuiConfig` 新增布尔字段 `autoTitle`(默认 `false`)。开启后,TUI 会在全新会话的首条用户消息之后发起一次后台模型调用,并用一个简短的、模型生成的标签替换终端标题;静态 `title` 是替换前的初值,也是兜底。 -- 该标签是模型概括,而非对提示词的截断。请求携带一段固定的任务指令(将该请求概括为两到五个小写单词、不含标点的简短标题)加上用户的首条消息,且不带工具;TUI 取回复的首个非空行并截断到 40 个字符(39 个字符加一个省略号)。 -- 标题通过 `runtime.terminal.setTitle` 设置——静态 `title` 已经在用的同一条 OSC 0 路径。不引入任何新的终端控制面,终端写入仍归 pi-tui 所有。 -- 该调用发出后不等待其返回,且每会话仅一次。一个 `titleSettled` 门闩守护它:`autoTitle` 关闭时它预先置为已结算、从不运行;在首条 `user/message` 已入日志的恢复会话中它预先结算,因此静态标题得以保留;仅含空白的首条消息被跳过且不消耗名额。任何失败、空回复、缺少 `llm` 服务、或缺少 agent 的 `provider` 或 `model`,都会让静态标题保持不动。一个专用的 `AbortController` 在关闭时取消尚在进行的请求。 -- 标题调用直接抵达 `ctx.llm.stream`,而非经由 `agent.send`,因此它从不追加进会话或 transcript(文本记录),也无法扰动 agent loop(智能体循环)。 -- 该功能默认关闭,仅在交互式产品配置(`examples/tui-agent/cordis.yml`)与脚本化 PTY fixture(测试前置数据)中开启。若在共享的 `dsh-tui-demo` schema 默认值里开启,会在不发送任何用户消息的无密钥回放与启动场景中多发一次模型调用。 - -## Alternatives considered - -**截断首条用户消息,而非用模型生成标题。** 否决:用户选择的是简短的、模型制作的标签;截断后的原始提示词嘈杂、常以样板文字开头,且很少读起来像标题。 - -**重命名窗口(OSC 2)或 tmux 窗口。** 否决:OSC 0 只设置 `pane_title`,因此它标记窗格而不重命名、也不泄漏进用户的窗口标题;用户确认 OSC 是正确的手段。 - -**让该功能默认开启。** 否决:在共享的 demo schema 里开启会扰动无密钥回放与启动快照,并在每个全新会话上花掉一次模型调用;按部署选择性开启可让默认面保持惰性。 - -**并入日志支撑的会话标题工作(PR #451)。** 否决:那项改动是持久化到日志的会话元数据;本项是不做持久化的终端标签。让二者相互独立可使各自自成一体,并避免共享依赖。 - -**阻塞首轮直到标题就绪。** 否决:在发送用户消息前先等待标题,会给实际请求增加延迟;发出后不等待其返回可让重命名对该轮次不可见。 - -## Consequences - -- 开启时,全新会话会多花一次无工具的模型调用,只带单条简短的用户消息和少量输出 token;默认关闭时它不产生任何开销。 -- 由于标题调用会打上 `sessionId`,它与会话的 `llm-replay` 游标共享:在以回放支撑的快照场景中开启 `autoTitle` 会消耗一条录制脚本条目。这正是它默认关闭、且脚本化 PTY fixture 用按工具分支的适配器而非回放来回答该调用的原因。 -- `packages/ui/tui/tests/tui.spec.ts` 用一个 mock `llm` 适配器固定该行为:生成的标题替换静态标题、过长输出以省略号截断、仅含空白的首条消息保留一次性名额、空回复或失败回复保留标题、恢复的会话从不触发,以及功能关闭 / 无服务 / 缺提供方 / 缺模型各路径都保留静态标题。一项关闭测试断言尚在进行的请求被中止。 -- `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 证明真实的经 Loader 启动的路径:脚本化适配器以固定字符串回答无工具的标题调用,对话场景断言 OSC 0 序列抵达 PTY。启动场景不发送用户消息,因此它们从不触发该调用。 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.i18n.yaml deleted file mode 100644 index 830ca3e2e0..0000000000 --- a/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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-21-tui-auto-title-default-on.md: 35809e1ef6bade3e09c34b17608eff5f8fb5bd22 -2026-07-21-tui-auto-title-default-on.zh.md: aa20cfde1359605f2ac5a8f0427f4518c611ecd1 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.md b/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.md deleted file mode 100644 index 35809e1ef6..0000000000 --- a/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.md +++ /dev/null @@ -1,32 +0,0 @@ -# Agent Note: Auto-title on by default, re-derived on resume - -Status: implemented - -English | [中文](2026-07-21-tui-auto-title-default-on.zh.md) - -> **Superseded** by the [session-title consolidation Agent Note](../simplification/2026-07-22-tui-titles-from-session-title-service.md): the TUI-local `autoTitle` generation is removed; titles come from the log-backed session-title service, and the terminal rename consumes `session/title` events. - -## Problem - -The [auto-title Agent Note](2026-07-21-tui-auto-pane-title.md) shipped `autoTitle` off by default and, on a resumed session, kept the static title because the first `user/message` was already logged. In use both choices defeated the feature's purpose. A per-session descriptive pane title is what makes one tmux pane or terminal tab distinguishable from the next; leaving it off by default means the product ships an inert feature that almost no user turns on, and skipping re-derivation on resume means a resumed session — exactly the long-lived session most worth labelling — falls back to the shared static string. The user asked for a descriptive per-session name to be the normal experience. - -## Decision - -- `autoTitle` defaults **on** (`z.boolean().default(true)`, mirrored by `resolveTuiConfig`'s `?? true`). A deployment with an `llm` service and an agent provider/model gets a model-made pane title on every session without opting in; one without them keeps the static title, so default-on is inert where the call cannot run. -- A **resumed** session re-derives the title on mount from its already-logged first `user/message`: `createTuiChat` scans `agent.session.events` for the first such event and feeds its text to the same one-shot `generateTitle`. The title is never persisted (the session header carries no title field), so it is always derived, never restored. -- The one-shot latch is now simply `titleSettled = !resolved.autoTitle`. The prior pre-settle-on-resume clause is gone: on resume `generateTitle` runs once from the stored first message and then latches, so a message that arrives *after* the resume does not re-title. A fresh session has no stored `user/message` at mount, so the resume scan is a no-op and the live `session/event` listener titles the first message instead. -- Everything else from the [auto-title Agent Note](2026-07-21-tui-auto-pane-title.md) stands unchanged: the OSC 0 `runtime.terminal.setTitle` path, the model-summary shape (two-to-five lowercase words, first non-empty line, 40-char cap), the fire-and-forget `ctx.llm.stream` call that never touches the session or transcript, the shutdown `AbortController`, and every failure fallback (empty reply, missing `llm`, missing provider/model, whitespace-only prompt). - -## Alternatives considered - -**Keep the feature off by default.** Rejected: this is a direct reversal of the [auto-title Agent Note](2026-07-21-tui-auto-pane-title.md)'s "default off" decision at the user's request. Off-by-default ships an inert feature; the descriptive name is only useful if it is the normal experience. The keyless-replay concern that motivated off-by-default is addressed by pinning `autoTitle: false` in the replay-backed snapshot scenarios rather than by suppressing it for every deployment. - -**Persist the derived title in the session header.** Rejected: the header has no title field and adding one would make a terminal label into session metadata — the boundary the [auto-title Agent Note](2026-07-21-tui-auto-pane-title.md) already drew against the log-backed session-title work. Re-deriving from the stored first message costs one tool-less call on resume and keeps the label a pure function of the conversation. - -**Re-derive on resume from the latest message instead of the first.** Rejected: the title summarises what the session is *about*, which its opening request captures; a mid-conversation message would make the pane label drift as the work moves on. - -## Consequences - -- A fresh session with a working `llm` now spends one extra tool-less model call by default (previously only when opted in); a resumed session spends one on mount. Deployments without an `llm` or provider/model are unaffected. -- The replay-backed `examples/tui-agent/tests/tui.snapshot.ts` must opt **out**: it pins `autoTitle: false`, because a default-on title request is not among the recorded turns and `installLlmReplay` fails loud on an unrecorded request. The unit `packages/ui/tui/tests/tui.snapshot.ts` needs no opt-out — it mounts no `llm` service, so `generateTitle` short-circuits and the default flip is inert there. The interactive `examples/tui-agent/cordis.yml` and the scripted PTY fixture already set `autoTitle: true`, so the keyless smoke's OSC 0 assertion is unchanged. -- `packages/ui/tui/tests/tui.spec.ts` pins the new defaults: the config-default test expects `autoTitle: true`; the disabled-path test now sets `autoTitle: false` explicitly; and the former "resumed session never fires" test is rewritten to assert re-derivation from the stored first message and that a later live message does not re-title. `docs/config-catalog.md` regenerates to "On by default". diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.zh.md deleted file mode 100644 index aa20cfde13..0000000000 --- a/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.zh.md +++ /dev/null @@ -1,32 +0,0 @@ -# Agent Note: 自动标题默认开启,恢复时重新推导 - -Status: implemented - -[English](2026-07-21-tui-auto-title-default-on.md) | 中文 - -> **已被取代**:见[标题归一 Agent Note](../simplification/2026-07-22-tui-titles-from-session-title-service.md)。TUI 本地的 `autoTitle` 生成已移除;标题来自日志承载的 session-title 服务,终端重命名消费 `session/title` 事件。 - -## Problem - -[自动标题 Agent Note](2026-07-21-tui-auto-pane-title.md) 交付时 `autoTitle` 默认关闭,并且在恢复会话中因首条 `user/message` 已入日志而保留静态标题。实际使用中这两个选择都违背了该功能的初衷。让一个 tmux 窗格或终端标签页区别于下一个的,正是每会话各异的描述性窗格标题;默认关闭意味着产品交付了一个几乎无人开启的惰性功能,而恢复时不重新推导,则意味着恢复会话——恰恰是最值得标记的长命会话——退回到共用的静态字符串。用户要求把每会话的描述性名称做成常态体验。 - -## Decision - -- `autoTitle` 默认**开启**(`z.boolean().default(true)`,`resolveTuiConfig` 以 `?? true` 与之对齐)。带有 `llm` 服务与 agent 提供方/模型的部署无需选择性开启即可在每个会话获得模型制作的窗格标题;不具备它们的部署保留静态标题,因此在调用无法运行处,默认开启是惰性的。 -- **恢复**会话在挂载时从其已入日志的首条 `user/message` 重新推导标题:`createTuiChat` 在 `agent.session.events` 中扫描首个此类事件,并把其文本喂给同一个一次性的 `generateTitle`。标题从不持久化(会话头不携带标题字段),因此它始终是推导得来,而非恢复而来。 -- 一次性门闩现在只是 `titleSettled = !resolved.autoTitle`。此前"恢复即预先结算"的分句已删除:恢复时 `generateTitle` 从已存储的首条消息运行一次随后上闩,因此恢复*之后*到达的消息不会再改标题。全新会话在挂载时没有已存储的 `user/message`,因此恢复扫描是空操作,改由实时的 `session/event` 监听器为首条消息命名。 -- [自动标题 Agent Note](2026-07-21-tui-auto-pane-title.md) 的其余一切保持不变:OSC 0 的 `runtime.terminal.setTitle` 路径、模型概括形态(两到五个小写单词、首个非空行、40 字符上限)、从不触碰会话或 transcript(文本记录)的发出后不等待其返回的 `ctx.llm.stream` 调用、关闭时的 `AbortController`,以及每一条失败兜底(空回复、缺 `llm`、缺提供方/模型、仅含空白的提示词)。 - -## Alternatives considered - -**让该功能保持默认关闭。** 否决:这是应用户要求,对[自动标题 Agent Note](2026-07-21-tui-auto-pane-title.md)"默认关闭"决策的直接反转。默认关闭交付的是惰性功能;只有当描述性名称成为常态体验时它才有用。当初促成默认关闭的无密钥回放顾虑,改由在以回放支撑的快照场景中固定 `autoTitle: false` 来处理,而非为每个部署都压制该功能。 - -**把推导出的标题持久化进会话头。** 否决:会话头没有标题字段,加一个会把终端标签变成会话元数据——正是[自动标题 Agent Note](2026-07-21-tui-auto-pane-title.md)已经对日志支撑的会话标题工作划出的边界。从已存储的首条消息重新推导,代价是恢复时一次无工具调用,并让标签保持为对话的纯函数。 - -**恢复时从最新消息而非首条消息重新推导。** 否决:标题概括的是会话*关于什么*,而这由其开场请求捕获;一条对话中途的消息会让窗格标签随工作推进而漂移。 - -## Consequences - -- 带可用 `llm` 的全新会话现在默认多花一次无工具的模型调用(此前只在选择性开启时才有);恢复会话在挂载时花掉一次。不具备 `llm` 或提供方/模型的部署不受影响。 -- 以回放支撑的 `examples/tui-agent/tests/tui.snapshot.ts` 必须选择**关闭**:它固定 `autoTitle: false`,因为默认开启的标题请求不在录制轮次之列,而 `installLlmReplay` 对未录制的请求会显式报错。单元 `packages/ui/tui/tests/tui.snapshot.ts` 无需选择关闭——它不挂载 `llm` 服务,因此 `generateTitle` 提前短路,默认值的翻转在那里是惰性的。交互式的 `examples/tui-agent/cordis.yml` 与脚本化 PTY fixture(测试前置数据)已设 `autoTitle: true`,因此无密钥冒烟测试的 OSC 0 断言保持不变。 -- `packages/ui/tui/tests/tui.spec.ts` 固定新的默认值:config 默认测试期望 `autoTitle: true`;关闭路径测试现在显式设 `autoTitle: false`;此前的"恢复会话从不触发"测试改写为断言从已存储首条消息重新推导,并断言之后的实时消息不会再改标题。`docs/config-catalog.md` 重新生成为"On by default"。 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-banner-sweep.md b/.agents/notes/implemented/feature/2026-07-21-tui-banner-sweep.md deleted file mode 100644 index c146424d53..0000000000 --- a/.agents/notes/implemented/feature/2026-07-21-tui-banner-sweep.md +++ /dev/null @@ -1,35 +0,0 @@ -# Agent Note: The banner sweeps in; the subtitle line is gone - -Status: implemented - -English | [中文](2026-07-21-tui-banner-sweep.zh.md) - -> **Superseded** by the [no-banner Agent Note](2026-07-21-tui-no-banner.md): the banner itself was removed, taking the sweep with it. - -## Problem - -The [startup-slogans Agent Note](2026-07-20-tui-startup-slogans.md) replaced the instructional welcome line with a random slogan bank revealed by a per-character typewriter. In use the quotes read as weird — random flavor text in a tool's header — and the animation was slow (40 ms/char over a full sentence) while animating only one line of a four-line banner. This note supersedes that decision's slogan half; the removal of the configured demo welcome and the animation-lifecycle groundwork stand. - -## Decision - -- The slogan bank, `pickStartupSlogan`, and the typewriter reveal are deleted. When `welcome` is unset the banner simply has **no subtitle line** — title and model/session detail only. The `welcome` config remains for deployments and fixtures that want a fixed subtitle, rendered frame-deterministically with no animation. -- The startup animation is now the **whole banner**: `HeaderComponent` gains a `revealWidth` clip, and the header box wipes in left-to-right over ~24 frames at 15 ms (~360 ms total, ~60 fps), started after `ui.start()` succeeds and cleared through the same `detachListeners` path the typewriter used. `stopBannerReveal` also resets the clip so a disposed-mid-sweep header re-renders whole. -- The PTY smoke's boot marker changes from the typewriter cursor (`▌`) to the banner's top-right corner (`╮`), which only renders once the sweep completes. - -## Alternatives considered - -**Keep the animation as-is and only change the copy.** Rejected: any fixed or rotating phrase re-read on every boot decays into wallpaper; the user's judgment was that the quotes themselves, not just their content, were wrong for the surface. - -**Animate per banner line (top-down) instead of a left-right sweep.** Rejected: with only four lines the animation would have four visible steps — closer to a flicker than a reveal; the horizontal sweep uses the full terminal width for a smooth motion at the same total duration. - -**Character-level clipping via `revealWidth` on styled text.** Adopted with `truncateToWidth` from pi-tui, the same ANSI-aware clipper the header already uses for width overflow, so the sweep cannot tear escape sequences. - -## Consequences - -- Boot output with `welcome` unset is again animation-dependent but no longer random: every boot sweeps the same banner. Configured welcomes (all snapshot/scripted fixtures, the Code Mode overlay) stay frame-deterministic and unchanged. -- The `STARTUP_SLOGANS`/`pickStartupSlogan` exports are gone; no consumer outside the deleted tests referenced them. -- The default banner is one line shorter (no subtitle), so PTY assertions anchored on banner geometry use the corner glyph rather than any subtitle text. - -## Testing - -`packages/ui/tui/tests/tui.spec.ts` pins: the sweep completes to a full banner (both corners + title) and produced at least one clipped mid-sweep frame; a configured welcome renders verbatim with no clipped frames; the unset-welcome banner has no subtitle; and dispose clears the sweep's own interval handle. The PTY smoke boots on the `╮` completion marker across the tui-demo bin, the dsh CLI, and the personal-overlay scenarios. Verified live in tmux. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-banner-sweep.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-banner-sweep.zh.md deleted file mode 100644 index 01cc153e88..0000000000 --- a/.agents/notes/implemented/feature/2026-07-21-tui-banner-sweep.zh.md +++ /dev/null @@ -1,35 +0,0 @@ -# Agent Note: 横幅整体扫入;副标题行移除 - -Status: implemented - -[English](2026-07-21-tui-banner-sweep.md) | 中文 - -> **已被取代**:由[移除启动横幅 Agent Note](2026-07-21-tui-no-banner.md)取代:横幅本身已移除,扫入动画随之移除。 - -## Problem - -[启动 slogan Agent Note](2026-07-20-tui-startup-slogans.md) 用随机 slogan 库加逐字打字机动画取代了说明书式的欢迎行。实际使用中这些引语显得怪异——工具头部出现随机的风味文案——而且动画很慢(每字符 40 ms,扫完一整句),却只动画四行横幅中的一行。本 note 取代该决定中 slogan 的那一半;移除示例配置中欢迎语的决定与动画生命周期的基础设施保持不变。 - -## Decision - -- 删除 slogan 库、`pickStartupSlogan` 和打字机动画。`welcome` 未设置时横幅直接**没有副标题行**——只有标题和模型/会话详情。`welcome` 配置保留给想要固定副标题的部署与 fixture,无动画、逐帧确定地渲染。 -- 启动动画现在作用于**整个横幅**:`HeaderComponent` 增加 `revealWidth` 裁剪,头部盒子以约 24 帧、每帧 15 ms(总计约 360 ms、约 60 fps)从左到右扫入,在 `ui.start()` 成功后启动,经打字机动画用过的同一条 `detachListeners` 路径清除。`stopBannerReveal` 同时重置裁剪,因此扫入中途被 dispose 的头部会重新完整渲染。 -- PTY 冒烟测试的启动标记从打字机光标(`▌`)改为横幅右上角(`╮`),它只在扫入完成后才渲染。 - -## Alternatives considered - -**保留动画原样、只改文案。** 否决:任何每次启动都被重读的固定或轮换语句都会退化成墙纸;用户的判断是引语本身——而不只是内容——对这个表面来说就是错的。 - -**按横幅行逐行(自上而下)动画而非左右扫入。** 否决:只有四行时动画只有四个可见步骤——更像闪烁而不是展开;水平扫入用满终端宽度,在相同总时长内动作更平滑。 - -**用 `revealWidth` 对带样式文本做字符级裁剪。** 采用 pi-tui 的 `truncateToWidth`——头部处理宽度溢出时已在使用的同一个 ANSI 感知裁剪器——因此扫入不可能撕裂转义序列。 - -## Consequences - -- `welcome` 未设置时启动输出再次依赖动画但不再随机:每次启动扫入同一幅横幅。配置了欢迎语的场景(全部快照/脚本化 fixture、Code Mode overlay)保持逐帧确定且不变。 -- `STARTUP_SLOGANS`/`pickStartupSlogan` 导出移除;除被删除的测试外没有消费者引用它们。 -- 默认横幅少一行(无副标题),因此锚定横幅几何的 PTY 断言使用角落字形而非任何副标题文本。 - -## Testing - -`packages/ui/tui/tests/tui.spec.ts` 固定:扫入完成为完整横幅(两个角 + 标题)且产生了至少一个裁剪的中途帧;配置的欢迎语原文渲染且无裁剪帧;未设置欢迎语的横幅没有副标题;dispose 清除扫入自己的定时器句柄。PTY 冒烟测试在 tui-demo bin、dsh CLI 和个人 overlay 场景中以 `╮` 完成标记启动。已在 tmux 中实机验证。 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-borderless-banner.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-borderless-banner.i18n.yaml index 8732101ab2..5d3ddbd972 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-borderless-banner.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-tui-borderless-banner.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 -2026-07-21-tui-borderless-banner.md: 37263854b6cc77283215c3c1378f9908ff966611 -2026-07-21-tui-borderless-banner.zh.md: ca796e49cb9d3a9abc0acd64a39448bc3f9ad50e +2026-07-21-tui-borderless-banner.md: 2fcb414c11f91df0914b17aa973e45746bbdfc67 +2026-07-21-tui-borderless-banner.zh.md: 8f80b21e6425bb38fff52529f1df8d262c34338f diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-borderless-banner.md b/.agents/notes/implemented/feature/2026-07-21-tui-borderless-banner.md index 37263854b6..2fcb414c11 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-borderless-banner.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-borderless-banner.md @@ -6,34 +6,41 @@ English | [中文](2026-07-21-tui-borderless-banner.zh.md) ## Problem -The [no-banner Agent Note](2026-07-21-tui-no-banner.md) removed the boxed startup banner: it deleted `HeaderComponent` and its sweep, moved the model into the footer, dropped the session id, and rendered `welcome` as the transcript's first line. The user's verdict reversed that: bring the banner back — "just remove the border". The four-row box frame was the objectionable chrome, not the identifying facts it carried (model, session id) nor the sweep-in motion. +An intermediate no-banner design removed the boxed startup banner: it deleted `HeaderComponent` and its sweep, moved the model into the footer, dropped the session id, and rendered `welcome` as the transcript's first line. The user's verdict reversed that: bring the banner back — "just remove the border". The four-row box frame was the objectionable chrome, not the identifying facts it carried (model, session id) nor the sweep-in motion. ## Decision -- `HeaderComponent` and its left-to-right sweep return, but render **borderless**: no `╭─╮`/`╰─╯` corners and no `│` side bars. Each line is a single leading space plus `truncateToWidth`-clipped content, so the sweep's width clip can never tear an escape sequence and no fixed frame is drawn. -- The header carries the title (`DEEPSEEK HARNESS`), a `` detail line, and — when `welcome` is set — a muted subtitle. With `welcome` unset the header is title + detail only. -- The model **also** stays in the footer's left segment. The no-banner note's footer model prefix is kept, not reverted, so the driving model stays glanceable after the transient banner scrolls out of view. +- `HeaderComponent` and its left-to-right sweep return, but render **borderless**: no `╭─╮`/`╰─╯` corners and no `│` side bars. Each line is a single leading space plus `truncateToWidth`-clipped content, so the sweep's width clip can never tear an escape sequence and no fixed frame is drawn. The reveal advances through about 24 frames at 15 ms each. +- The header carries the title (`DEEPSEEK HARNESS`), a `` detail line, and — when `welcome` is set — a muted subtitle. With `welcome` unset the header is title + detail only: there is no fixed or random slogan. +- The model **also** stays in the footer's left segment, so the driving model remains glanceable after the transient banner scrolls out of view. - `welcome` reverts to a banner subtitle; the transcript-first-line notice is removed from `rebuildTranscript`. - The sweep animates only when `welcome` is unset. A configured `welcome` renders the whole banner immediately, keeping fixtures and snapshots frame-deterministic. The sweep starts after `ui.start()` succeeds and is cleared through the same `detachListeners` path via `stopBannerReveal`, which also resets the clip so a header disposed mid-sweep re-renders whole. -This supersedes the [no-banner Agent Note](2026-07-21-tui-no-banner.md) (which superseded the [banner-sweep Agent Note](2026-07-21-tui-banner-sweep.md)): the banner and its sweep return borderless, while the model's footer home the no-banner note added stays. +This note owns the current result of the discarded startup variants: random slogans with a per-character typewriter, a boxed whole-banner sweep, and no banner. The example composition does not set `welcome`; deployments and deterministic fixtures may still provide one. The model's persistent footer home from the no-banner variant remains. ## Alternatives considered **Keep the box but thin it or use lighter glyphs.** Rejected: the instruction was "just remove the border"; any surrounding glyph is the frame chrome the user objected to. -**Drop the model from the footer now that the banner shows it again.** Rejected: the banner is transient and scrolls away with the transcript, while the footer keeps the model visible for the whole session — the reason the no-banner note put it there, deliberately preserved. +**Keep a random or fixed slogan when `welcome` is unset.** Rejected because repeated flavor copy becomes wallpaper and the per-character reveal was slow while animating only one line. An unset welcome therefore produces no subtitle, and the whole banner supplies the startup motion. -**Leave the session id out, as the no-banner note decided.** Rejected: with the box gone the detail line costs one row, and the user asked for the banner "as before", which carried `model • session-id`. +**Remove the banner entirely.** Rejected because the persistent footer is a good home for the model but not for the full identifying detail, while putting `welcome` in the transcript makes presentation configuration behave like conversation content. + +**Reveal the banner top-down.** Rejected because four row-sized steps read as a flicker. The horizontal width clip uses the terminal span for smooth motion and reuses the ANSI-aware truncation path. + +**Drop the model from the footer now that the banner shows it again.** Rejected: the banner is transient and scrolls away with the transcript, while the footer keeps the model visible for the whole session; that persistent location is deliberately preserved. + +**Leave the session id out of the banner.** Rejected: with the box gone the detail line costs one row, and the user asked for the banner "as before", which carried `model • session-id`. ## Consequences - Boot output with `welcome` unset is animation-dependent again (the sweep); configured welcomes stay frame-deterministic, so every snapshot and scripted fixture keeps a fixed subtitle. +- The demo no longer supplies instructional welcome filler; an unset `welcome` means a subtitle-free banner, while the config remains the deterministic escape hatch for deployments and fixtures. - The model now appears twice at boot — banner detail and footer — intended redundancy: the banner is transient, the footer persistent. -- `/clear` empties the transcript but not the header, so the banner and its configured subtitle survive `/clear`, unlike the no-banner welcome line that `/clear` wiped. +- `/clear` empties the transcript but not the header, so the banner and its configured subtitle survive `/clear`, unlike a transcript-based welcome line. - All pi-tui terminal snapshots and the examples/tui-agent replay snapshots re-recorded (`test:snapshot:refresh`): banner rows return with no box glyphs; footer rows keep the model prefix. - Anything that anchored on banner absence re-anchors on its presence: the PTY smoke boots on the detail line's `main-session-` id (revealed late in the sweep) and asserts `DEEPSEEK`/`HARNESS` present with no box corners. ## Testing -`packages/ui/tui/tests/tui.spec.ts` pins: the borderless banner sweeps to natural completion — no box corners, title and `main-session` detail present — with at least one clipped mid-sweep frame; a configured `welcome` renders the whole banner with no clipped frame; the unset-welcome banner has no subtitle; and dispose clears the sweep interval mid-sweep. The tui-agent and dsh-CLI PTY smokes boot on the `main-session-` detail marker and assert no box corners. Snapshots verify the full frames. +`packages/ui/tui/tests/tui.spec.ts` pins: the borderless banner sweeps to natural completion — no box corners, title and `main-session` detail present — with at least one clipped mid-sweep frame; a configured `welcome` renders the whole banner with no clipped frame; the unset-welcome banner has no subtitle; and dispose clears the sweep interval mid-sweep. Independent color-scheme cases cover reported light/dark transitions, a same-scheme no-op, and a terminal that throws on the DSR query write; `applyColorScheme` relies on `setStatus` to rederive the editor border instead of repeating the dead assignment that had broken per-file coverage. The tui-agent and dsh-CLI PTY smokes boot on the `main-session-` detail marker and assert no box corners. Snapshots verify the full frames. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-borderless-banner.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-borderless-banner.zh.md index ca796e49cb..8f80b21e64 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-borderless-banner.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-borderless-banner.zh.md @@ -6,34 +6,41 @@ Status: implemented ## Problem -[移除横幅 Agent Note](2026-07-21-tui-no-banner.md) 删掉了带框的启动横幅:它删除了 `HeaderComponent` 及其扫入动画,把模型移入页脚,丢弃了会话 id,并把 `welcome` 渲染为 transcript 的第一行。用户的裁决把这一切反转:把横幅拿回来——"just remove the border"。令人反感的装饰是那四行盒子边框,而不是它承载的识别信息(模型、会话 id),也不是扫入动效。 +一个中间的无横幅设计删掉了带框的启动横幅:它删除了 `HeaderComponent` 及其扫入动画,把模型移入页脚,丢弃了会话 id,并把 `welcome` 渲染为 transcript 的第一行。用户的裁决把这一切反转:把横幅拿回来——"just remove the border"。令人反感的装饰是那四行盒子边框,而不是它承载的识别信息(模型、会话 id),也不是扫入动效。 ## Decision -- `HeaderComponent` 及其从左到右的扫入动画回归,但以**无边框**方式渲染:没有 `╭─╮`/`╰─╯` 边角,也没有 `│` 侧边。每一行都是一个前导空格加上经 `truncateToWidth` 裁剪的内容,因此扫入的宽度裁剪永远不会撕裂转义序列,也不绘制任何固定边框。 -- 头部承载标题(`DEEPSEEK HARNESS`)、一条 `` 详情行,以及——当设置了 `welcome` 时——一条弱化的副标题。`welcome` 未设置时头部只有标题加详情。 -- 模型**同时**保留在页脚的左段。移除横幅那版 note 加入的页脚模型前缀被保留而非回退,因此在短暂的横幅滚出视野后,会话使用的模型仍可一瞥可见。 +- `HeaderComponent` 及其从左到右的扫入动画回归,但以**无边框**方式渲染:没有 `╭─╮`/`╰─╯` 边角,也没有 `│` 侧边。每一行都是一个前导空格加上经 `truncateToWidth` 裁剪的内容,因此扫入的宽度裁剪永远不会撕裂转义序列,也不绘制任何固定边框。扫入大约经过 24 帧完成,每帧间隔 15 ms。 +- 头部承载标题(`DEEPSEEK HARNESS`)、一条 `` 详情行,以及——当设置了 `welcome` 时——一条弱化的副标题。`welcome` 未设置时头部只有标题加详情:不含固定或随机标语。 +- 模型**同时**保留在页脚的左段,因此在短暂的横幅滚出视野后,会话使用的模型仍可一瞥可见。 - `welcome` 恢复为横幅副标题;transcript 第一行的通知从 `rebuildTranscript` 中移除。 - 仅当 `welcome` 未设置时才播放扫入动画。配置了 `welcome` 会立即渲染整个横幅,使 fixture 和快照保持帧确定性。扫入在 `ui.start()` 成功后启动,并经与之前相同的 `detachListeners` 路径通过 `stopBannerReveal` 清理;后者还会重置裁剪,使扫入中途被销毁的头部重新完整渲染。 -本 note 取代[移除横幅 Agent Note](2026-07-21-tui-no-banner.md)(后者取代了[横幅扫入 Agent Note](2026-07-21-tui-banner-sweep.md)):横幅及其扫入动画以无边框方式回归,而移除横幅那版 note 为模型设立的页脚归宿得以保留。 +本 Agent Note 统一记录几种已弃用启动方案的当前结论:带逐字打字机效果的随机标语、带边框的整幅横幅扫入动画,以及完全移除横幅。示例组装不设置 `welcome`;部署和确定性 fixture 仍可提供该值。无横幅方案为模型设置的常驻页脚位置继续保留。 ## Alternatives considered **保留盒子但做细或改用更轻的字符。** 否决:指令是 "just remove the border";任何环绕的字符都是用户所反对的边框装饰。 -**既然横幅重新显示模型,就把模型从页脚移除。** 否决:横幅是短暂的,会随 transcript 滚走,而页脚在整个会话中保持模型可见——这正是移除横幅那版 note 把它放在那里的原因,此处刻意保留。 +**在未设置 `welcome` 时保留随机或固定标语。** 否决:反复出现的氛围文案很快失去信息价值,而逐字揭示仅为一行制作动画,速度又慢。因此,未设置 `welcome` 时不显示副标题,由整个横幅提供启动动效。 -**像移除横幅那版 note 那样,把会话 id 留在外面。** 否决:盒子去掉后详情行只占一行,且用户要求横幅"和以前一样",而以前它承载 `model • session-id`。 +**完全移除横幅。** 否决:常驻页脚很适合显示模型,却无法承载完整识别详情;把 `welcome` 放入 transcript 还会使展示配置表现成对话内容。 + +**自上而下揭示横幅。** 否决:按四行分成四步看起来像闪烁。横向宽度裁剪利用终端横向空间实现平滑动效,并复用 ANSI 感知的截断路径。 + +**既然横幅重新显示模型,就把模型从页脚移除。** 否决:横幅是短暂的,会随 transcript 滚走,而页脚在整个会话中保持模型可见;这个常驻位置被刻意保留。 + +**将会话 id 留在横幅之外。** 否决:盒子去掉后详情行只占一行,且用户要求横幅"和以前一样",而以前它承载 `model • session-id`。 ## Consequences - `welcome` 未设置时的启动输出再次依赖动画(扫入);配置了欢迎语则保持帧确定性,因此每个快照和脚本 fixture 都保留一个固定副标题。 +- demo 不再提供教学性质的欢迎填充文案;`welcome` 未设置就表示横幅没有副标题,而该配置仍是部署和 fixture 获得确定性输出的配置手段。 - 模型现在在启动时出现两次——横幅详情与页脚——这是有意的冗余:横幅短暂,页脚常驻。 -- `/clear` 清空 transcript 但不清头部,因此横幅及其配置的副标题在 `/clear` 后存活,不同于被 `/clear` 清掉的移除横幅那版的欢迎行。 +- `/clear` 清空 transcript 但不清头部,因此横幅及其配置的副标题在 `/clear` 后存活,不同于基于 transcript 的欢迎行。 - 全部 pi-tui 终端快照与 examples/tui-agent 回放快照重新录制(`test:snapshot:refresh`):横幅行以无盒子字符方式回归;页脚行保留模型前缀。 - 一切锚定横幅缺失的内容改为锚定其存在:PTY 冒烟测试以详情行的 `main-session-` id 为启动标记(它在扫入后段才被揭示),并断言 `DEEPSEEK`/`HARNESS` 出现且无盒子角。 ## Testing -`packages/ui/tui/tests/tui.spec.ts` 固定:无边框横幅扫入至自然完成——无盒子角、标题与 `main-session` 详情出现——且至少有一帧扫入中途被裁剪;配置的 `welcome` 完整渲染横幅且无裁剪帧;未设置 `welcome` 的横幅无副标题;销毁会在扫入中途清掉扫入定时器。tui-agent 与 dsh CLI 的 PTY 冒烟测试以 `main-session-` 详情标记为启动标记并断言无盒子角。快照验证完整帧。 +`packages/ui/tui/tests/tui.spec.ts` 固定:无边框横幅扫入至自然完成——无盒子角、标题与 `main-session` 详情出现——且至少有一帧扫入中途被裁剪;配置的 `welcome` 完整渲染横幅且无裁剪帧;未设置 `welcome` 的横幅无副标题;销毁会在扫入中途清掉扫入定时器。独立的配色方案用例覆盖终端报告的浅色/深色转换、相同方案下的空操作,以及写入 DSR 查询时抛出异常的终端;`applyColorScheme` 依靠 `setStatus` 重新推导编辑器边框,而不再重复那个导致逐文件覆盖率未达标的无效赋值。tui-agent 与 dsh CLI 的 PTY 冒烟测试以 `main-session-` 详情标记为启动标记并断言无盒子角。快照验证完整帧。 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.md b/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.md deleted file mode 100644 index a6e0956f28..0000000000 --- a/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.md +++ /dev/null @@ -1,39 +0,0 @@ -# Agent Note: No startup banner - -Status: implemented - -English | [中文](2026-07-21-tui-no-banner.zh.md) - -> **Superseded** by the [borderless-banner Agent Note](2026-07-21-tui-borderless-banner.md): the banner and its sweep return without the box. The model's footer home this note added stays. - -## Problem - -The TUI opened with a boxed product banner ("DEEPSEEK HARNESS" + model/session detail), most recently with a sweep-in animation ([banner sweep Agent Note](2026-07-21-tui-banner-sweep.md)). The user's verdict: remove it. A product title re-read on every boot is chrome, the box spends four rows before any content, and the identifying facts it carried (model, session) have better homes. - -## Decision - -- `HeaderComponent`, the sweep animation, and its lifecycle wiring are deleted. The TUI mounts straight into the transcript; startup renders nothing above the separator. -- The model name moves into the footer status line's left segment (` ↑tokens ↓tokens`), so the session's driving model stays visible at all times, not just at boot. The session id is no longer displayed — it lives in the session log and `./.sessions` filenames, where `dsh --resume ` and the `/resume` selector retrieve it. -- `welcome`, when configured, renders as the transcript's first line (a muted notice) inside `rebuildTranscript`, so palette swaps preserve it. Unset renders nothing. Fixtures keep their configured welcomes; the PTY smoke's boot marker becomes the footer's model name, the only mounted-TUI text guaranteed to render regardless of cwd length. - -This supersedes the [banner sweep Agent Note](2026-07-21-tui-banner-sweep.md) entirely: both the sweep and the banner it animated are gone. - -## Alternatives considered - -**Keep a one-line header (no box).** Rejected: the only load-bearing fact was the model name, and the footer already aggregates session status; a dedicated header row for one fact is the same chrome, smaller. - -**Show the session id in the footer too.** Rejected: a 36-char UUID dominates the 100-column footer and clips the status segment; it identifies the session for resume, which is a log/filesystem concern, not a glanceable one. - -**Print the welcome outside the transcript (above the separator).** Rejected: any fixed region above the transcript is a banner again; as a transcript line it scrolls away naturally and survives rebuilds through the same path as every other transcript element. - -## Consequences - -- Startup output is fully deterministic again — no animation frames at all; the interval-lifecycle machinery from the two animation iterations is gone. -- All 26 pi-tui terminal snapshots re-recorded (`test:snapshot:refresh`): banner rows gone, footer rows gain the model prefix. -- Anything that anchored on banner text (`DEEPSEEK`, box corners) re-anchors on the footer model name; `main-session-` no longer appears in boot output. -- `/clear` now wipes the welcome line too: it is an ordinary transcript line, and `/clear` empties the transcript (the old banner survived `/clear` only by sitting outside it). -- The footer's left segment is wider; on narrow terminals the right status segment clips earlier. - -## Testing - -`packages/ui/tui/tests/tui.spec.ts` pins: no box corners/product title and an empty transcript when `welcome` is unset, with the model in the footer; a configured welcome as the first transcript line without a banner; and the welcome surviving a palette-swap transcript rebuild. The PTY smoke boots on the footer model name and asserts `DEEPSEEK HARNESS` is absent. Snapshots verify the full frames. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.zh.md deleted file mode 100644 index acc5614727..0000000000 --- a/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.zh.md +++ /dev/null @@ -1,39 +0,0 @@ -# Agent Note: 移除启动横幅 - -Status: implemented - -[English](2026-07-21-tui-no-banner.md) | 中文 - -> **已被取代**,见[无边框横幅 Agent Note](2026-07-21-tui-borderless-banner.md):横幅及其扫入动画回归,只是去掉了盒子。本 note 为模型设立的页脚归宿得以保留。 - -## Problem - -TUI 启动时展示一个带框的产品横幅("DEEPSEEK HARNESS" + 模型/会话详情),最近一版还带扫入动画([横幅扫入 Agent Note](2026-07-21-tui-banner-sweep.md))。用户的裁决:删掉它。每次启动都被重读的产品标题是装饰,盒子在任何内容之前先占掉四行,而它承载的识别信息(模型、会话)有更好的去处。 - -## Decision - -- 删除 `HeaderComponent`、扫入动画及其生命周期接线。TUI 直接挂载进 transcript;启动时分隔线之上不渲染任何东西。 -- 模型名移入页脚状态行的左段(` ↑tokens ↓tokens`),会话使用的模型因此始终可见,而不只是启动时。会话 id 不再显示——它存在于会话日志和 `./.sessions` 文件名中,`dsh --resume ` 和 `/resume` 选择器会从中获取该 id。 -- 配置了 `welcome` 时,它作为 transcript 的第一行(一条弱化的通知)在 `rebuildTranscript` 内渲染,因此调色板切换会保留它。未设置则什么也不渲染。fixture 保留各自配置的欢迎语;PTY 冒烟测试的启动标记改为页脚的模型名——无论 cwd 多长都保证渲染的唯一挂载后文本。 - -本 note 完全取代[横幅扫入 Agent Note](2026-07-21-tui-banner-sweep.md):扫入动画和它所动画的横幅都已移除。 - -## Alternatives considered - -**保留单行头部(去掉盒子)。** 否决:唯一有承载价值的信息是模型名,而页脚已经聚合会话状态;为一条信息保留专用头部行仍是同一种装饰,只是小一点。 - -**把会话 id 也放进页脚。** 否决:36 字符的 UUID 会占满 100 列页脚并裁掉状态段;它的用途是恢复会话的标识,属于日志/文件系统关注点,不是需要一瞥可见的信息。 - -**把欢迎语渲染在 transcript 之外(分隔线上方)。** 否决:transcript 上方任何固定区域都会再次变成横幅;作为 transcript 行它自然滚走,并通过与其他 transcript 元素相同的路径在重建后保留。 - -## Consequences - -- 启动输出再次完全确定——没有任何动画帧;两轮动画迭代留下的定时器生命周期机制全部移除。 -- 全部 26 个 pi-tui 终端快照重新录制(`test:snapshot:refresh`):横幅行消失,页脚行增加模型前缀。 -- 锚定横幅文本(`DEEPSEEK`、盒子角)的内容改为锚定页脚模型名;启动输出中不再出现 `main-session-`。 -- `/clear` 现在也会清掉欢迎行:它是普通的 transcript 行,而 `/clear` 清空 transcript(旧横幅能在 `/clear` 后存活只因为它在 transcript 之外)。 -- 页脚左段变宽;窄终端上右侧状态段更早被裁剪。 - -## Testing - -`packages/ui/tui/tests/tui.spec.ts` 固定:`welcome` 未设置时无盒子角/产品标题、transcript 为空、模型在页脚;配置的欢迎语作为 transcript 第一行且无横幅;欢迎语在调色板切换的 transcript 重建后保留。PTY 冒烟测试以页脚模型名为启动标记并断言 `DEEPSEEK HARNESS` 不出现。快照验证完整帧。 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-verbose-status-line.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-verbose-status-line.i18n.yaml index 319f28ea61..8cac245f00 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-verbose-status-line.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-tui-verbose-status-line.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 -2026-07-21-tui-verbose-status-line.md: f277afd3a874b30a29dc0ef193740f636d22290b -2026-07-21-tui-verbose-status-line.zh.md: 9fa7cf29c67245382bbee6b72f2710c5550d7f54 +2026-07-21-tui-verbose-status-line.md: 71584ee91a911cc8652512ec26b00dae8c818f36 +2026-07-21-tui-verbose-status-line.zh.md: bda3c5e8394f7707916c6fc76045b1a6f38fa95b diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-verbose-status-line.md b/.agents/notes/implemented/feature/2026-07-21-tui-verbose-status-line.md index f277afd3a8..71584ee91a 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-verbose-status-line.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-verbose-status-line.md @@ -13,7 +13,7 @@ While a turn ran, the [full-screen TUI](2026-07-17-dedicated-full-screen-tui-fro - While a turn runs, the status line above the editor shows a derived phase label with elapsed time, keeping the trailing `— Enter sends steering, Esc cancels` hint. The four phases and their labels are `waiting` → "Waiting for the first token", `thinking` → "Thinking", `responding` → "Responding", and `executing` → "Executing tools". - The phase is presentation state the TUI derives from live session events, not a session event or agent status of its own. `step/start` enters `waiting`; an `assistant/chunk` reasoning delta or reasoning block-start enters `thinking`; a text delta or text block-start enters `responding`; a `tool/call` enters `executing`. The event map is merge-extensible, so every other event kind falls through a default and leaves the phase unchanged. - The label reports two clocks — ` · total ` — except `waiting`, which shows only the step total. The phase clock resets on a genuine phase change or a new step; the step clock resets on `step/start`. Durations format as `8s` below a minute and `1m05s` at or above one. Tool time between `step/end` and the next `step/start` accrues to the finishing step's total. -- A single `RunningStatus` controller — the loader, the phase, the two baselines, and a refresh timer — exists only while a turn runs. A one-second `setInterval` refreshes the elapsed time; a phase event refreshes it immediately. `clearStatus` clears the interval, stops the loader, and drops the controller, so any transition to idle or disposed leaves no live timer, matching the [banner sweep](2026-07-21-tui-banner-sweep.md)'s timer hygiene. A mid-turn palette rebuild (`setStatus` re-derives the editor border on a terminal color-scheme change) carries the phase and both baselines across, so a running status never snaps back to `waiting`. +- A single `RunningStatus` controller — the loader, the phase, the two baselines, and a refresh timer — exists only while a turn runs. A one-second `setInterval` refreshes the elapsed time; a phase event refreshes it immediately. `clearStatus` clears the interval, stops the loader, and drops the controller, so any transition to idle or disposed leaves no live timer, matching the [borderless banner](2026-07-21-tui-borderless-banner.md)'s timer hygiene. A mid-turn palette rebuild (`setStatus` re-derives the editor border on a terminal color-scheme change) carries the phase and both baselines across, so a running status never snaps back to `waiting`. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-verbose-status-line.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-verbose-status-line.zh.md index 9fa7cf29c6..bda3c5e839 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-verbose-status-line.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-verbose-status-line.zh.md @@ -13,7 +13,7 @@ Status: implemented - 轮次运行期间,编辑器上方的状态行显示一个派生的阶段标签及已用时长,并保留末尾的 `— Enter sends steering, Esc cancels` 提示。四个阶段及其标签为 `waiting` → "Waiting for the first token"、`thinking` → "Thinking"、`responding` → "Responding"、`executing` → "Executing tools"。 - 阶段是 TUI 从实时会话事件派生出的呈现状态,而非它自有的会话事件或 agent 状态。`step/start` 进入 `waiting`;`assistant/chunk` 的 reasoning 分片或 reasoning 块开始(`block-start`)进入 `thinking`;text 分片或 text 块开始进入 `responding`;`tool/call` 进入 `executing`。该事件映射可合并扩展,因此其余任何事件类型都落入默认分支,保持阶段不变。 - 标签汇报两个时钟——` · total `——但 `waiting` 只显示步骤总时长。阶段时钟在真正发生阶段切换或进入新步骤时重置;步骤时钟在 `step/start` 时重置。时长在不足一分钟时格式化为 `8s`,达到或超过一分钟时格式化为 `1m05s`。`step/end` 与下一个 `step/start` 之间的工具时间计入结束步骤的总时长。 -- 单一的 `RunningStatus` 控制器——loader、阶段、两个基准时刻以及一个刷新定时器——仅在轮次运行期间存在。一个每秒触发的 `setInterval` 刷新已用时长;阶段事件则立即刷新。`clearStatus` 清除该 interval、停止 loader 并丢弃控制器,因此任何向 idle 或 disposed 的转变都不会遗留活动定时器,与 [banner 扫入动画](2026-07-21-tui-banner-sweep.md)的定时器清理保持一致。轮次进行中的调色板重建(终端颜色方案变化时 `setStatus` 会重新派生编辑器边框)会将阶段与两个基准时刻一并沿用过来,因此运行中的状态绝不会退回 `waiting`。 +- 单一的 `RunningStatus` 控制器——loader、阶段、两个基准时刻以及一个刷新定时器——仅在轮次运行期间存在。一个每秒触发的 `setInterval` 刷新已用时长;阶段事件则立即刷新。`clearStatus` 清除该 interval、停止 loader 并丢弃控制器,因此任何向 idle 或 disposed 的转变都不会遗留活动定时器,与[无边框横幅](2026-07-21-tui-borderless-banner.md)的定时器清理保持一致。轮次进行中的调色板重建(终端颜色方案变化时 `setStatus` 会重新派生编辑器边框)会将阶段与两个基准时刻一并沿用过来,因此运行中的状态绝不会退回 `waiting`。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml new file mode 100644 index 0000000000..590d9227b8 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.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 +2026-07-25-session-list-browsing-and-manual-order.md: 586995bf459aeaee88672863977f7acf2a7061a3 +2026-07-25-session-list-browsing-and-manual-order.zh.md: 432d5167a57d30bc04a0b4faf213e4341f07bd2f diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md new file mode 100644 index 0000000000..586995bf45 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md @@ -0,0 +1,60 @@ +# Agent Note: Session List Browsing and Manual Workspace Order + +Status: implemented + +English | [中文](2026-07-25-session-list-browsing-and-manual-order.zh.md) + +## Problem + +[Workspace UI Complete Product Flow](2026-07-25-workspace-ui-product-flow.md) shipped the first form of the grouped session list and explicitly scoped out operations such as Rename and drag ordering. The design file (figma 239-10458 and its companion screens) has since filled in those interactions: the list must switch to an ungrouped flat view, session rows need a hover detail card and an action menu, workspaces need renaming, and sessions need manual ordering inside their group. + +Two existing mechanisms stood in the way. First, the host durably promoted the active session to the front of its workspace account on every `session/event` (activity pinning), so any manual order would be scrambled by the next activity — two ordering authorities cannot coexist. Second, the browsing area was split across two packages: ui-sidebar owned the list, search, and header rows while ui-workspace only borrowed a picker slot for its popover; every new workspace-domain dialog required cross-package wiring, and ownership grew more twisted with each one. + +## Decision + +### Flat view and viewing state + +The group-by menu offers two modes, WorkSpace / In one list. Flat mode renders every session (fork children included) as a top-level row, strictly newest-first by `updatedAt`, with no parent/child adjacency; the Intent placeholder renders as the first row. The mode choice persists in the browser (`dsh.workspace.view`) across reloads. + +### Row interactions + +- Session rows show a detail card after a 500ms hover dwell (full title / relative time / status line; the status line has only running/idle until the wire grows a status field). The card and the row menu are mutually exclusive: no card while a menu is open or a drag is in flight. +- Session-row … menu: Rename / Fork session / Delete session, visual-only this iteration; workspace-header … menu: Rename (wired) / Delete workspace (visual-only). Menus close when the pointer leaves them. +- Supporting primitives: `Menu` gains label entries, danger rows, and `closeOnPointerLeave`; a new `HoverCard` (portaled placement, open delay, disabled guard). + +### workspace.rename + +`workspace.rename({ workspaceId, title })`: the title is trimmed and must be non-blank; both the same-title no-op and the duplicate check evaluate inside the host's serialized workspace-creation chain (shared with create, so concurrent create/rename cannot interleave a duplicate or an out-of-order fake success), and a conflict returns `workspace-name-conflict`. Durability goes through `setTitle`'s mutate path, and the `domain/changed` listener broadcasts the `host/workspace-changed` frame automatically. The UI is a standard modal with a client-side duplicate pre-check. + +### Manual order: insertSessionBefore replaces activity pinning + +The `session/event` → `touchSession` activity-pinning chain is deleted wholesale; the workspace account order is now manually owned — new sessions prepend at attach, and explicit reordering goes through `workspace.insertSessionBefore({ workspaceId, sessionId, beforeSessionId? })` (DOM insertBefore semantics: with an anchor it inserts before it, omitted appends to the end). The entity throws a typed `WorkspaceMoveInvalidError` only for unaccounted session/anchor ids; the handler maps exactly that to the business code `workspace-move-invalid`, while storage failures stay internal. + +The UI is HTML5 drag on root rows inside a group (workspace grouping only, outside search; fork children ride with their parent and are not draggable). Order authority stays entirely host-side: drop only sends the RPC, the client performs zero local reordering, and the view refreshes from the response upsert and the changed frame; a failed move changes nothing. The client's upsert rejects snapshots older (`updatedAt`) than the installed projection so a late unary response cannot roll back a newer frame. + +### Shell/region split + +ui-sidebar shrinks to the column-geometry shell: brand row, fold state machine, New Session, Settings, and one `sidebar.workspaces` hole; the shell↔region contract is two facts, `{ wide, expandSidebar }`. ui-workspace fully owns the browsing region (section header, search, grouped tree and flat list, every workspace dialog, drag) plus its groupBy store; the rail-state search/new-workspace icons belong to the region too and request shell expansion via `expandSidebar()`. The picker splits into the core `WorkspaceCreateFlow` (composed directly inside the region) and the thin `WorkspacePicker` wrapper (still filling ui-conversation's hero slot); the old `sidebar.workspace` picker slot and its declaration-aware deferral are deleted with it. + +## Alternatives considered + +**Keep activity pinning; treat drag as a transient adjustment** — the manual order would be scrambled by the next session activity, making it a fiction; two coexisting ordering authorities cannot be explained to the user. A middle ground — freeze pinning per workspace after the first drag — adds a state tier with murkier semantics; deleting outright is cleaner. + +**Numeric index in the reorder payload** — `{ index }` drifts during the drag window: after the host prepends a new session (e.g. Intent materialization) the same index points at a different row. Anchor-style insertBefore is naturally immune to prepends and filtered projections. + +**Optimistic reordering on drop** — client-first reordering needs failure rollback, one more entangled state in the object layer; local/LAN round-trips are millisecond-scale, so waiting for the host response is imperceptible. With a single order authority (trust the host completely), the frontend never invents an order. + +**Keep the rename dialog in ui-sidebar (smallest change)** — that is the problem itself: workspace-domain dialogs scattered in a borrowed slot, with each addition (the Delete confirmation is coming) repeating the cross-package wiring. Review first considered moving only the rename modal; the ruling was to give the whole browsing region to ui-workspace and leave the shell geometry-only. + +**Keep parent/child adjacency in flat mode** — contradicts strict recency (a child newer than its parent's sibling cannot slot adjacently), and the flat view's purpose is dropping the hierarchy; flattening fully and disabling drag in flat mode (no persistence carrier) is more consistent. + +## Consequences + +- Manual order is the sole authority over the workspace account: an order the user arranges is never scrambled by activity; the cost is losing float-to-top-on-activity, whose signal now rides the row status dot and time label. The `WorkspaceView.sessionIds` wire contract is reworded to the manual-order semantics. +- The two-fact shell/region contract funnels every future workspace-domain feature (Delete confirmation, cross-group moves, Ungrouped adoption) into the single ui-workspace package; ui-sidebar no longer evolves with session-list features. +- Flat mode supports neither reordering nor a create-in-workspace entry point (switching back to grouped view is required) — an accepted scope reduction. +- Wiring the three session-menu items and workspace Delete, and growing the wire status enum, remain future iterations. + +## Testing + +Package-level suites cover the derivations (deriveGroups/deriveFlat), row components, both apply registrations and passthroughs, host entity move semantics, and the rename/insertSessionBefore RPC implementations with their fixture stubs; the `apps/web` keyless snapshots regress the assembled application; delivery acceptance additionally runs a 12-item playwright (chromium headless) checklist (grouped default, flat switch and persistence, hover-card appearance and suppression, both menus, the full rename chain, drag persistence) and drives the real host over the wire for rename success / duplicate rejection / `workspace-move-invalid`. diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md new file mode 100644 index 0000000000..432d5167a5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md @@ -0,0 +1,60 @@ +# Agent Note: Session List Browsing and Manual Workspace Order + +Status: implemented + +[English](2026-07-25-session-list-browsing-and-manual-order.md) | 中文 + +## Problem + +[Workspace UI 完整产品流](2026-07-25-workspace-ui-product-flow.md)交付了分组 session 列表的首个形态,并把 Rename、拖拽排序等操作明确划出当期范围。设计稿(figma 239-10458 及关联画面)随后补齐了这些交互:列表要能切换成不分组的平铺视图、session 行悬停要出详情卡与操作菜单、workspace 要能改名、组内 session 要能手动排序。 + +两条既有机制挡在前面。其一,host 在每条 `session/event` 上把活跃 session durable 地提到 workspace 账本最前(活动置顶),任何手动排序都会被下一次活动打乱——两种排序权威不可调和。其二,浏览区域被劈在两个包里:ui-sidebar 拥有列表、搜索和组头行,ui-workspace 只借一个 picker 坑放弹层;每加一个 workspace 域的对话框都要跨包接线,归属越来越拧。 + +## Decision + +### 平铺视图与浏览态 + +group-by 菜单提供 WorkSpace / In one list 两种模式。平铺模式把所有 session(含 fork 子)一律作为顶层行,严格按 `updatedAt` 新→旧排序,不保持父子相邻;Intent 占位行渲染在列表首行。模式选择持久化在浏览器(`dsh.workspace.view`),刷新保持。 + +### 行交互 + +- session 行悬停 500ms 出详情卡(全名/相对时间/状态行;状态本期只有 running/idle 两态,枚举扩展待 wire 增补 status 字段)。卡片与行菜单互斥:菜单开启或拖拽进行中不出卡。 +- session 行 … 菜单:Rename / Fork session / Delete session,本期纯视觉;workspace 组头 … 菜单:Rename(已接线)/ Delete workspace(纯视觉)。菜单鼠标移出即关。 +- 支撑件:`Menu` 新增 label 条目、danger 行、`closeOnPointerLeave`;新增 `HoverCard`(portal 定位、开启延时、disabled 守卫)。 + +### workspace.rename + +`workspace.rename({ workspaceId, title })`:title trim 后非空;同名 no-op 与重名查重都在 host 的 workspace 创建串行链内求值(与 create 共链,并发 create/rename 不能穿插出重名或乱序假成功),冲突回 `workspace-name-conflict`。落盘经 `setTitle` 的 mutate 通道,`domain/changed` 监听自动广播 `host/workspace-changed` 帧。UI 为标准 Modal,client 侧另做重名预检。 + +### 手动排序:insertSessionBefore 取代活动置顶 + +`session/event` → `touchSession` 活动置顶链整体删除;workspace 账本序改为纯手动拥有——新 session attach 时前插,显式重排走 `workspace.insertSessionBefore({ workspaceId, sessionId, beforeSessionId? })`(DOM insertBefore 语义:锚给了插锚前,缺省 append 到末尾)。实体只对不在账的 session/锚抛类型化的 `WorkspaceMoveInvalidError`,handler 仅把它映射为业务码 `workspace-move-invalid`,存储故障保持 internal。 + +UI 为组内 root 行的 HTML5 拖拽(仅 workspace 分组、非搜索态;fork 子随父不单独拖)。顺序权威完全在 host:drop 只发 RPC,client 零本地重排,视图靠响应体 upsert 与 changed 帧刷新;失败即无事发生。client 的 upsert 拒绝比已装载投影更旧(`updatedAt`)的快照,防迟到的一元响应回滚更新的帧。 + +### 壳/区域切分 + +ui-sidebar 缩为列几何壳:品牌行、折叠状态机、New Session、Settings,以及一个 `sidebar.workspaces` 洞;壳与区域的契约只有两个事实 `{ wide, expandSidebar }`。ui-workspace 全权拥有浏览区域(section header、搜索、分组树与平铺、全部 workspace 对话框、拖拽)及其 groupBy store;rail 态的搜索/新建图标也归区域,经 `expandSidebar()` 请求壳展开。picker 拆为核心件 `WorkspaceCreateFlow`(区域内直接组件组合)与薄包装 `WorkspacePicker`(继续填 ui-conversation 的 hero 坑);原 `sidebar.workspace` picker 坑与声明感知延迟注册随之删除。 + +## Alternatives considered + +**保留活动置顶、拖拽仅作临时调整** —— 手动序在下一次 session 活动即被打乱,形同虚设;两种排序权威并存无法向用户解释。也考虑过「拖过一次即冻结该 workspace 的活动置顶」的折中,状态多一档、语义更难讲,直接删除更干净。 + +**排序报文用数字下标** —— `{ index }` 在拖拽窗口期会漂移:host 前插新 session(如 Intent 材料化)后同一下标指向别的行。锚点式 insertBefore 对前插与过滤投影天然免疫。 + +**drop 后乐观重排** —— client 先行重排需失败回滚,对象层多一块纠缠态;本地/局域网往返毫秒级,等 host 响应的简单方案肉眼无感。顺序权威单一化(完全信 host)后,前端永不发明顺序。 + +**rename 对话框留在 ui-sidebar(最小改动)** —— 正是问题本身:workspace 域的对话框散落在借来的坑里,每加一个(Delete 确认框将至)都重演跨包接线。评审中先议了「只挪 rename Modal」的中间态,最终裁定整个浏览区域归 ui-workspace,壳只留几何。 + +**平铺模式保持父子相邻成组** —— 与「严格按时间」矛盾(子新于兄则插不进相邻位),且平铺本意就是取消层级;拉平并禁用平铺下的拖拽(无持久化载体)更一致。 + +## Consequences + +- 手动序是唯一的 workspace 账本序权威:用户排好的顺序不再被活动打乱;代价是「最近活跃浮到最上」的行为消失,活跃感知转由行内状态点与时间标签承担。`WorkspaceView.sessionIds` 的 wire 契约随之改为手动序措辞。 +- 壳/区域两事实契约把 workspace 域的后续功能(Delete 确认、跨组移动、Ungrouped 收编)全部收进 ui-workspace 单包;ui-sidebar 不再随 session 列表功能演进。 +- 平铺模式不支持排序与分组入口(建到指定 workspace 需切回分组视图),是拍板接受的范围收窄。 +- session 菜单三项与 workspace Delete 的功能接线、状态枚举扩 wire,留待后续迭代。 + +## Testing + +包级用例覆盖派生(deriveGroups/deriveFlat)、行组件、两处 apply 注册与透传、host 实体移位语义、rename/insertSessionBefore 的 RPC 实现与 fixture 桩;`apps/web` keyless snapshot 回归覆盖装配后的应用;交付验收另以 playwright(chromium headless)过 12 项清单(分组默认、平铺切换与持久化、hover 卡出现与抑制、双菜单、rename 全链、拖拽落盘),并对真 host 直打 wire 验证 rename 成功/重名拒绝/`workspace-move-invalid` 三径。 diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml b/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml index 80f7838d45..d8530ac919 100644 --- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.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 -2026-06-17-ts-build-config.md: 1275a635242ea887c941db9dc0554fd33acd4102 -2026-06-17-ts-build-config.zh.md: 7691118dd152874e2849f1ace686069c9ea3f61f +2026-06-17-ts-build-config.md: 17036438b83a77f72b49f55abf29632af3f4ffef +2026-06-17-ts-build-config.zh.md: 70e49c61deba418894a48be3016898d1d85c78a0 diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.md b/.agents/notes/implemented/process/2026-06-17-ts-build-config.md index 1275a63524..17036438b8 100644 --- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.md +++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.md @@ -43,6 +43,8 @@ In-package relative imports use explicit `.ts` specifiers. - Referenced package/vendor projects keep the same emit behavior as build, so typecheck refreshes their `lib/types` outputs instead of using a separate no-emit graph. Project-specific strictness changes live in the owning `packages/*/*/tsconfig.json` or `vendor/*/tsconfig.json`. - The no-emit aggregates disable `rewriteRelativeImportExtensions`; they emit nothing and include tests that import helpers across project-reference boundaries. Package/vendor emit projects keep the rewrite enabled. +Composite projects keep their incremental build information inside their project-local `lib/` output. `pnpm run clean` derives live output directories from the root TypeScript project-reference graph, removes legacy root build information, and removes deleted `packages/*/*` directories that contain only known generated residue. It preserves `node_modules` for every package that still has a `package.json`, and refuses to remove a manifest-less directory containing unknown files. Build does not invoke clean automatically, so ordinary builds retain incremental state. + The command orchestration shape is: ```sh @@ -55,6 +57,9 @@ tsx scripts/verify-node-next-types.ts pnpm run typecheck: tsc -b + +pnpm run clean: +tsx scripts/clean.ts ``` `pnpm run demo:*` still runs `src` directly through tsx and root paths, without a compile step. @@ -63,6 +68,8 @@ tsc -b - **Keep `tsdown`/oxc as the TypeScript transformer** — oxc's transform is not `tsc` behavior (decorator transform differs, bundled JS differs from per-file emit), and its bundled `.d.ts` conflicts with Cordis' internal relative module augmentation shape. - **One root strict program over packages, vendor, examples, tests, and scripts** — vendor source triggers type errors outside this project's ownership under the root strict flags; project references with per-project strictness are the boundary that works. +- **Clean before every build** — this would discard the incremental state owned by `tsc` and the bundler even when the workspace layout is unchanged. +- **Remove every package-level `node_modules`** — valid package dependency links do not cause the workspace-discovery failure, and deleting them would turn build cleanup into dependency reinstallation. ## Consequences @@ -76,5 +83,6 @@ Build responsibilities are clearer: - `lib/index.*` is the publish runtime output and is generated by the bundler, currently `tsdown`. - `pnpm run verify-node-next-types` scans built declarations for relative specifiers without file extensions, then typechecks a temporary external ESM consumer with `moduleResolution: "NodeNext"` against the built `types`/`exports` surface, so declaration specifier regressions fail before publish. - The `typecheck` command uses `tsconfig.json`. Examples, tests, and scripts are checked by the root no-emit project, while packages and vendor modules keep the same emit behavior as `build`. Package and vendor source stays behind project-reference boundaries. +- After changing branches or updating a checkout that deleted packages, contributors can run `pnpm run clean` to remove stale package directories before rebuilding. Unknown files in a manifest-less package directory require manual classification instead of being deleted. The Cordis vendor copy now has one more type-structure divergence from upstream. During upstream sync, that divergence must be reapplied or explicitly retired. diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md b/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md index 7691118dd1..70e49c61de 100644 --- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md +++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md @@ -43,6 +43,8 @@ Status: implemented - 被引用的包/vendor 项目保持与构建相同的输出行为,因此类型检查会刷新它们的 `lib/types` 输出,而无需使用独立的 no-emit 图。项目特定的严格度变更放在各自的 `packages/*/*/tsconfig.json` 或 `vendor/*/tsconfig.json` 中。 - 两个 no-emit 聚合禁用 `rewriteRelativeImportExtensions`;它们不输出任何文件,且包含跨 project-reference 边界导入 helper 的测试。包/vendor 的 emit 项目保持重写开启。 +复合项目将增量构建信息保存在各项目本地的 `lib/` 输出中。`pnpm run clean` 会根据根 TypeScript project-reference 图确定当前有效的输出目录,删除遗留的根目录构建信息,并删除已删除包留下且仅包含已知生成残留的 `packages/*/*` 目录。对于仍有 `package.json` 的每个包,该命令都会保留 `node_modules`;如果不含 `package.json` 的目录中存在未知文件,则拒绝删除。构建不会自动调用 clean,因此常规构建会保留增量状态。 + 命令编排结构如下: ```sh @@ -55,6 +57,9 @@ tsx scripts/verify-node-next-types.ts pnpm run typecheck: tsc -b + +pnpm run clean: +tsx scripts/clean.ts ``` `pnpm run demo:*` 仍通过 tsx 和根路径直接运行 `src`,无需编译步骤。 @@ -63,6 +68,8 @@ tsc -b - **继续使用 `tsdown`/oxc 作为 TypeScript 转换器**:oxc 的转换行为与 `tsc` 不同(装饰器转换有差异、打包 JS 与逐文件输出不同),且其打包 `.d.ts` 与 Cordis 内部的相对模块增强结构冲突。 - **用一个根目录严格程序覆盖包、vendor、示例、测试和脚本**:vendor 源码在根目录严格标志下会触发不属于本项目所有权范围的类型错误;带有逐项目严格度的 project references 才是可行的边界。 +- **每次构建前都执行清理**:即使工作区布局没有变化,这也会丢弃 `tsc` 和打包器拥有的增量状态。 +- **删除所有包级 `node_modules`**:有效的包依赖链接不会导致工作区发现失败,而删除这些链接会使构建清理变成重新安装依赖。 ## 后果 @@ -76,5 +83,6 @@ tsc -b - `lib/index.*` 是发布用的运行时输出,由打包器(当前为 `tsdown`)生成。 - `pnpm run verify-node-next-types` 扫描构建出的声明文件,检查是否存在缺少文件扩展名的相对说明符,然后以 `moduleResolution: "NodeNext"` 对构建出的 `types`/`exports` 接口进行临时外部 ESM 消费方的类型检查,确保声明说明符的回归在发布前被捕获。 - `typecheck` 命令使用 `tsconfig.json`。示例、测试和脚本由根 no-emit 项目检查,包和 vendor 模块保持与 `build` 相同的输出行为。包和 vendor 源码始终处于 project-reference 边界之后。 +- 切换分支或更新工作副本后,如果其中删除了包,贡献者可在重新构建前运行 `pnpm run clean`,删除残留的包目录。不含 `package.json` 的包目录如果存在未知文件,必须手动判定其类别,不能直接删除。 Cordis 的 vendor 副本现在与上游多了一处类型结构差异。在上游同步时,该差异必须被重新应用或明确废弃。 diff --git a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml index f59c523dce..0149872c3c 100644 --- a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-core-data-structures-catalog.md: d7e9d3d9b14fe723e3396c8167fe714b7613e2b5 -2026-06-20-core-data-structures-catalog.zh.md: 8d2f16a46216cba8df0539be0abd2f1ad840eec3 +2026-06-20-core-data-structures-catalog.md: ef100f96b06c454cfd1ec092cc7fd23e712bdf7a +2026-06-20-core-data-structures-catalog.zh.md: 4ace2b8c8a6b08e7721c1df8003ccfbdb128daf1 diff --git a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.md b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.md index d7e9d3d9b1..ef100f96b0 100644 --- a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.md +++ b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.md @@ -33,7 +33,7 @@ The durability requirement was specific: the doc shows the **literal** current t - Complete type declarations and their JSDoc are pasted verbatim into a dedicated ` ```ts type-equiv ` fence. A concise ` ```ts public-api ` fence carries the source-equivalent ambient projection for a class whose implementation bodies do not belong in the catalog. `doc-typecheck` recognizes both and skips them (the bare declarations are not standalone-compilable), and **excludes them from the opt-out ratio** — they are a separately-checked category, not unchecked sketches. - A new `scripts/verify-type-equiv.ts` extracts each block via the TypeScript parser and asserts that its declaration structure and every JSDoc comment match the declared symbol, ignoring only formatting whitespace and non-JSDoc comments. Ordinary blocks retain the complete declaration. A `public-api` projection retains a class's public fields, constructor, accessors, and methods with their original JSDoc while removing implementation bodies and private or protected members. This is chosen over a compiled `_Check` assertion because source names and documentation identity, not assignability, are the properties the catalog preserves. -- Provenance lives in a central `scripts/type-equiv.manifest.json` (`{ doc, symbol, source }` entries), **not** in directive comments in the prose. The script enforces a **1:1 correspondence**: every type-equiv block has exactly one manifest entry and vice versa, so a block can never be silently unchecked and an entry can never rot. +- Provenance lives in a central `scripts/type-equiv.manifest.json` (`{ doc, symbol, source }` entries), **not** in directive comments in the prose. The script enforces a **1:1 correspondence** between each primary type-equiv block and one manifest entry, so a block can never be silently unchecked and an entry can never rot. A paired `.zh.md` block reuses the unsuffixed sibling's entry only when the complete tracked fence sequence matches in order, kind, and byte-exact body; otherwise the gate checks it independently, finds no manifest entry, and fails. - Wired into `doc-sync`, so relevant documentation changes run it locally and CI runs it with the other documentation checks. ### Maintenance is the author's job, with a gate backstop diff --git a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md index 8d2f16a462..4ace2b8c8a 100644 --- a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md +++ b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md @@ -33,7 +33,7 @@ Status: implemented - 完整的类型声明及其 JSDoc 会逐字粘贴到专用的 ` ```ts type-equiv ` 围栏中。简洁的 ` ```ts public-api ` 围栏承载与源码等价的类环境投影,用于实现体不应进入目录的类。`doc-typecheck` 会识别并跳过这两种围栏(裸声明无法独立编译),并且**将它们排除在 opt-out 比例之外**——它们是单独受检的类别,而不是未经检查的草图。 - 新增的 `scripts/verify-type-equiv.ts` 通过 TypeScript 解析器提取每个块,并断言其声明结构和每条 JSDoc 注释都与所声明的符号匹配,只忽略格式空白和非 JSDoc 注释。普通块保留完整声明。`public-api` 投影保留类的公共字段、构造函数、访问器和方法及其原始 JSDoc,同时移除实现体以及私有或受保护成员。之所以选择它而非编译式 `_Check` 断言,是因为目录所保留的是源码名称与文档一致性,而不是可赋值性。 -- 来源信息存放在集中的 `scripts/type-equiv.manifest.json`(`{ doc, symbol, source }` 条目)中,**而非**行文中的指令注释。脚本强制执行 **1:1 对应**:每个 type-equiv 块恰好有一条 manifest 条目,反之亦然;因此一个块永远不会被静默漏检,一条条目也永远不会腐烂。 +- 来源信息存放在集中的 `scripts/type-equiv.manifest.json`(`{ doc, symbol, source }` 条目)中,**而非**行文中的指令注释。脚本在每个主 type-equiv 块与一条 manifest 条目之间强制执行 **1:1 对应**,因此一个块永远不会被静默漏检,一条条目也永远不会腐烂。只有当配对 `.zh.md` 块的完整受跟踪围栏序列在顺序、类型和按字节精确的正文上均与无后缀兄弟文件匹配时,才会复用后者的条目;否则门禁会独立检查该块,发现没有 manifest 条目后失败。 - 接入 `doc-sync`,因此相关文档变更会在本地运行它,CI 也会与其他文档检查一起运行它。 ### 维护是作者的职责,门禁作为兜底 diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml index 3bfc8414e7..1458f7ff50 100644 --- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.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 -2026-07-02-bilingual-docs-and-pairing-gate.md: 4bc02878a0ea3f998e411ecc2b064c1626eacf3c -2026-07-02-bilingual-docs-and-pairing-gate.zh.md: 90a0c2f07f68b0fb4e26cd1c4b537a30a829b10f +2026-07-02-bilingual-docs-and-pairing-gate.md: 3732e6812a3f1f40242aa5a83a0bf1d1bc4d6139 +2026-07-02-bilingual-docs-and-pairing-gate.zh.md: a870e063230a34b807eed2f4ffc1c6067cb3aedc diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md index 4bc02878a0..3732e6812a 100644 --- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md +++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md @@ -6,14 +6,14 @@ English | [中文](2026-07-02-bilingual-docs-and-pairing-gate.zh.md) ## Problem -This repo's README and docs tree are read by people and agents inside and outside the company, in both English and Chinese. Maintaining a second language by hand, with no mechanism, is how translations rot: one side moves on, the other silently lies, and no gate notices. The repo's standing answer to invariants of this kind is to encode them as a mechanical check (see [quality gates](2026-06-11-quality-gates.md) and [doc-sync enforcement](2026-06-11-doc-sync-enforcement.md)), so the bilingual policy ships with one. +This repo's documentation corpus is read by people and agents inside and outside the company, in both English and Chinese. Maintaining a second language by hand, with no mechanism, is how translations rot: one side moves on, the other silently lies, and no gate notices. The repo's standing answer to invariants of this kind is to encode them as a mechanical check (see [quality gates](2026-06-11-quality-gates.md) and [doc-sync enforcement](2026-06-11-doc-sync-enforcement.md)), so the bilingual policy ships with one. ## Decision - **Paired sibling files with equal authority.** A documentation pair is three sibling files: English `foo.md`, Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`. Neither language is canonical — a document may be authored and reviewed Chinese-first and translated to English afterwards, or the reverse; what binds the pair is that both sides must say the same thing, and pairs merge whole (both languages plus the record, never one alone). Policy: [docs/i18n/README.md](../../../../docs/i18n/README.md); translation rules: [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md); terminology source of truth: [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md). - **A sidecar record of both blob hashes makes consistency checkable.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last confirmed-consistent state. An edit to either side without re-confirming the pair is then mechanically detectable as a pure content comparison — no history lookup — and the hashes are computable for files edited in the same PR, which a commit-hash record is not. Re-recording (`verify-translation-pairing --write`) produces a reviewable yaml diff: confirming consistency is an explicit, visible act in the PR. -- **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: required pairs exist, every existing pair is complete (all three files) and consistent (both hashes match, switcher links both ways, structural signatures identical), excluded (generated or bilingual-by-construction) files stay unpaired, and date-named documents on or after the manifest's `requiredSince` cutoff have complete pairs. The `required` list in [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) is a ratchet: each merged translation batch adds its files, so coverage only grows. -- **The enforcement frontier advances in coherent review batches.** A related set enters `required` only when reviewers can evaluate it as a unit. The core frontier groups [architecture](../../../../docs/architecture.md), the [Cordis primer](../../../../docs/cordis-primer.md), [defensive patterns](../../../../docs/defensive-patterns.md), the [glossary](../../../../docs/glossary.md), and [testing](../../../../docs/testing.md) because their terminology, links, and contributor contracts inform one another; admitting only part would leave the enforced corpus internally inconsistent. A site-published pair uses `pairedPages()` so the root locale projects `.zh.md` and `/en/` projects `.md`; creating a counterpart alone does not publish it. +- **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: every discovered, non-excluded source has a complete pair; every existing pair is complete (all three files) and consistent (both hashes match, switcher links both ways, structural signatures identical); and excluded generated, instruction, or bilingual-by-construction files stay unpaired. [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) contains only explicit exclusions, so no requirement can bypass discovery and receive a weaker check. Source-oriented code gates consume a `.zh.md` fence sequence as a derivative only when its unsuffixed sibling has the same tracked fences in the same order with byte-identical bodies; an incomplete, reordered, reclassified, or changed sequence stays independent, so the owning code gate or pairing gate reports the mismatch. +- **One corpus-wide requirement.** Every document in scope requires a complete pair from creation; the policy has no per-file rollout state, date cutoff, or README-specific class. README discovery covers every case-insensitive README basename outside vendored, dependency, and ignored build-output trees, including future top-level directories. A site-published pair uses `pairedPages()` so the root locale projects `.zh.md` and `/en/` projects `.md`; creating a counterpart alone does not publish it. - **Pairing records are metadata, not Cordis Loader configuration.** Cordis configuration discovery accepts actual `.cordis.yml` and `.cordis.yaml` files while excluding `*.i18n.yaml`, even when the document name contains `cordis`. This preserves validation of executable Loader entries without parsing translation hashes as configuration. - **Translation is agent work with human review.** The committed workflow is [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md), following the same pattern as [dsh-code-review](../../../skills/dsh-code-review/SKILL.md): the skill carries the workflow and defers to the docs as sources of truth. The skill directs the orchestrating agent to delegate translation writing to a subagent. @@ -40,5 +40,5 @@ Paired sibling files with locale suffixes are the dominant Chinese big-tech conv - Every pair adds a third file to the tree. The record is machine-written (`--write`), so the cost is directory noise, not maintenance effort; in exchange, "who confirmed these consistent, and when" is answerable from git blame on the yaml. - When the two sides disagree, no mechanical rule picks a winner — the PR review does. That is the price of equal authority, accepted deliberately: the alternative (a canonical language) forbids Chinese-first authoring. - Generated docs (`cordis-catalog/`, `tool-catalog/`, `module-graph.md`) are excluded for now; the planned follow-up is to teach their generators to emit Chinese alongside English, at which point they leave the exclusion list. -- Rollout is incremental by design: documents outside `required` are visible backlog (`--list`), not red CI, so pairs land in reviewable batches without a big-bang PR. A date-named document dated on or after the manifest's `requiredSince` cutoff merges bilingual or not at all, so new date-named Agent Notes do not enlarge that backlog. +- The exclusions-only manifest makes every current and future in-scope document mandatory through the same path. There is no explicit requirement, cutoff, or class entry that can fall outside discovery while appearing enforced. - The recorded hashes double as the update tool (`git cat-file -p ` recovers either side's last-confirmed text for a minimal diff-based update), so re-translation of whole files is never forced by the mechanism. diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md index 90a0c2f07f..a870e06323 100644 --- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md +++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md @@ -6,14 +6,14 @@ Status: implemented ## 问题 -本仓库的 README 与 docs 目录树会被公司内外的人和 agent(智能体)以中英两种语言阅读。在没有机制的情况下纯靠手工维护第二语言,正是译文腐烂的根源:一侧持续演进,另一侧默默失实,而没有门禁会注意到。对于这类不变式,本仓库一贯的做法是将其编码为机械检查(见[质量门禁](2026-06-11-quality-gates.md)与 [doc-sync 强制](2026-06-11-doc-sync-enforcement.md)),因此双语政策随附一道门禁一起交付。 +本仓库的文档语料会被公司内外的人和 agent(智能体)以中英两种语言阅读。在没有机制的情况下纯靠手工维护第二语言,正是译文腐烂的根源:一侧持续演进,另一侧默默失实,而没有门禁会注意到。对于这类不变式,本仓库一贯的做法是将其编码为机械检查(见[质量门禁](2026-06-11-quality-gates.md)与 [doc-sync 强制](2026-06-11-doc-sync-enforcement.md)),因此双语政策随附一道门禁一起交付。 ## 决策 - **配对兄弟文件,两种语言同权。** 一对文档由三个兄弟文件组成:英文 `foo.md`、中文 `foo.zh.md`,以及一份一致性记录 `foo.i18n.yaml`。没有哪种语言是正典:一篇文档可以先用中文撰写和评审、之后再译成英文,反之亦可;约束配对的是:两侧必须表达相同的内容,且配对整体合并(两种语言加记录,绝不单独落一侧)。政策见 [docs/i18n/README.md](../../../../docs/i18n/README.md);翻译规则见 [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md);术语真源见 [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md)。 - **伴随记录保存两侧 blob hash,使一致性可检查。** `foo.i18n.yaml` 保存两侧文件在上一次确认一致时各自的完整 git blob hash。此后修改了任一侧而未重新确认配对,都能被机械检测出来(纯内容比较,无需查询历史),而且同一个 PR(Pull Request)内改动的文件也能计算出 hash,commit hash 式的记录做不到这一点。重新记录(`verify-translation-pairing --write`)会产生一份可评审的 yaml diff:确认一致在 PR 中是一个显式、可见的动作。 -- **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:required 的配对必须存在;任何已存在的配对必须完整(三个文件齐全)且一致(两个 hash 匹配、切换行双向互链、结构签名一致);被排除的文件(生成物或本身即双语的)不得配对;凡文件名以日期开头且日期不早于 manifest(元数据清单)中 `requiredSince` 分界日期的文档,也必须有完整配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 中的 `required` 清单只进不退:每个合并的翻译批次将自己的文件加入其中,覆盖面只增不减。 -- **执行红线按连贯的评审批次推进。** 一组相关文档只有在评审者能够将其作为整体评估时,才进入 `required`。核心红线将[架构](../../../../docs/architecture.md)、[Cordis 入门](../../../../docs/cordis-primer.md)、[防御性模式](../../../../docs/defensive-patterns.md)、[术语表](../../../../docs/glossary.md)和[测试](../../../../docs/testing.md)归为一组,因为它们的术语、链接和贡献者契约相互关联;只纳入其中一部分会使受门禁约束的文档集合内部不一致。发布到文档站的配对使用 `pairedPages()`,由根 locale 投影 `.zh.md`,由 `/en/` 投影 `.md`;仅创建对侧文件并不会发布它。 +- **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:每个已发现且未排除的源文档都有完整配对;每个现有配对都完整(三个文件齐全)且一致(两个 hash 匹配、切换行双向互链、结构签名一致);被排除的生成文档、指令文档或本身即双语的文档不得配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 只包含显式排除项,因此任何要求都无法绕过发现流程而接受较弱的检查。只有当 `.zh.md` 围栏序列与其无后缀兄弟文件拥有顺序相同、正文按字节一致的同一组受跟踪围栏时,面向源码的代码门禁才会将其作为派生内容消费;不完整、顺序变更、重分类或已改动的序列仍会独立受检,因此由其所属的代码门禁或配对门禁报告不匹配。 +- **全语料统一要求。** 范围内的每篇文档从创建起就必须有完整配对;政策没有逐文件推进状态、日期分界或 README 专用类别。README 发现会覆盖 vendor 源码、依赖目录与被忽略的构建产物目录之外所有文件名不区分大小写匹配 README 的文件,包括今后新增的顶层目录。发布到文档站的配对使用 `pairedPages()`,由根 locale 投影 `.zh.md`,由 `/en/` 投影 `.md`;仅创建对侧文件并不会发布它。 - **配对记录是元数据,而不是 Cordis Loader 配置。** Cordis 配置发现会接受实际的 `.cordis.yml` 和 `.cordis.yaml` 文件,同时排除 `*.i18n.yaml`,即使文档名中包含 `cordis` 也不例外。这样既能继续校验可执行的 Loader 配置项,又不会把翻译 hash 当作配置来解析。 - **翻译是 agent 的工作,由人评审。** 仓库内置的工作流是 [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md),与 [dsh-code-review](../../../skills/dsh-code-review/SKILL.md) 模式相同:skill(技能)承载工作流,并将文档作为真源。该 skill 要求编排 agent 把翻译写作委派给 subagent。 @@ -40,5 +40,5 @@ Status: implemented - 每个配对给目录树多添一个文件。记录由机器写入(`--write`),代价是目录噪音而非维护负担;换来的是「谁在何时确认过这对文档一致」可以从 yaml 的 git blame 直接回答。 - 两侧说法冲突时,没有机械规则裁决谁赢,由 PR 评审裁决。这是同权的代价,且是有意接受的:另一个选项(正典语言)会禁止中文先行撰写。 - 生成文档(`cordis-catalog/`、`tool-catalog/`、`module-graph.md`)暂被排除;计划中的后续工作是让生成器在输出英文的同时输出中文,届时将这些文件移出排除清单。 -- 推进天然是渐进的:`required` 之外的文档是可见的 backlog(待翻清单,`--list`),而非红色的 CI;因此配对按可评审的批次落地,无需一个巨型 PR。凡文件名以日期开头且日期不早于 manifest 中 `requiredSince` 分界日期的文档,都必须配齐双语文件,因此新建的日期命名 Agent Note 不会增加这份 backlog。 +- 只含排除项的 manifest 通过同一路径,要求当前及今后纳入范围的每篇文档都必须配对。不存在显式要求、分界或类别条目可以落在发现范围之外,却看似已经强制执行。 - 记录的 hash 兼作更新工具(`git cat-file -p ` 能还原任一侧上次确认的文本,用于基于 diff 的最小更新),因此这套机制从不强迫整篇重译。 diff --git a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.i18n.yaml b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.i18n.yaml index 45455444d5..0a8609229e 100644 --- a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.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 -2026-07-04-doc-tiers-and-budgets.md: 7acdba8bfd96d183c418b3935d7b8e7f237d3607 -2026-07-04-doc-tiers-and-budgets.zh.md: 0f07a92740d31dd13cb523d73ac8a3699d666d30 +2026-07-04-doc-tiers-and-budgets.md: a52c40a9a147fd39fdec4c61079822f1b1115227 +2026-07-04-doc-tiers-and-budgets.zh.md: d03c2046c9963e4d62d2a7221d4563f60d3f4953 diff --git a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.md b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.md index 7acdba8bfd..a52c40a9a1 100644 --- a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.md +++ b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.md @@ -12,7 +12,7 @@ Standing docs accumulated repeated rules, retold incidents, duplicated package m - **A tier taxonomy with one home per fact.** [docs/AGENTS.md](../../../../docs/AGENTS.md) is the documentation standard: it assigns every Markdown tier a single job (standing orders, system map, type catalog, decision records, incident stories, how-tos, per-package contracts, generated catalogs, workflows), forbids restating a fact outside its home tier (link instead), and carries the slop checklist used when writing or reviewing any doc. - **A narrow, hard budget gate.** [scripts/verify-doc-budgets.ts](../../../../scripts/verify-doc-budgets.ts) joins `doc-sync`: every doc listed in [scripts/doc-budgets.manifest.json](../../../../scripts/doc-budgets.manifest.json) must stay under its word ceiling (`wc -w` semantics, whole file), and a budgeted file that is missing fails the gate so a rename cannot silently orphan its budget. Scope is deliberately only the accretion-prone standing docs — the root and subtree `AGENTS.md` files, `architecture.md`, `packages/README.md`, and the standing policy docs they evict content into (`docs/testing.md`, `docs/defensive-patterns.md`). Reference docs, Agent Notes, and package READMEs are unbudgeted: length is legitimate there when every row is a fact, and review plus the slop checklist govern them. -- **Ceilings are an enforcement frontier that ratchets.** A ceiling sits at least 5% above the doc's current size — working headroom, so routine wording edits pass while real growth still trips the gate — and ratchets down, keeping that margin, as the doc is brought to its target budget (root `AGENTS.md` ≤ 1,500 words; `architecture.md` ≤ 1,800; subtree `AGENTS.md` ≤ 600; `packages/README.md` ≤ 600) — the same rollout mechanism as the [translation-pairing `required` list](2026-07-02-bilingual-docs-and-pairing-gate.md). When the gate goes red the fix is to relocate or condense per the taxonomy; raising a ceiling is permitted only with explicit justification in the PR description, the manifest diff being the reviewable act. +- **Ceilings are an enforcement frontier that ratchets.** A ceiling sits at least 5% above the doc's current size — working headroom, so routine wording edits pass while real growth still trips the gate — and ratchets down, keeping that margin, as the doc is brought to its target budget (root `AGENTS.md` ≤ 1,500 words; `architecture.md` ≤ 1,800; subtree `AGENTS.md` ≤ 600; `packages/README.md` ≤ 600). When the gate goes red the fix is to relocate or condense per the taxonomy; raising a ceiling is permitted only with explicit justification in the PR description, the manifest diff being the reviewable act. - **A thin workflow skill, contracts in docs.** [.agents/skills/dsh-doc-standards](../../../skills/dsh-doc-standards/SKILL.md) carries the placement/audit/red-gate workflow and defers to the standard as its source of truth, the same split as [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) over the i18n contract. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md index 0f07a92740..d03c2046c9 100644 --- a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md +++ b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md @@ -12,7 +12,7 @@ Status: implemented - **每项事实只归属一处的层级分类。**[docs/AGENTS.md](../../../../docs/AGENTS.md) 是文档标准:它为每种 Markdown 层级分配单一职责(常设指令、系统图、类型目录、决策记录、事件故事、操作指南、各包契约、生成式目录、工作流),禁止在事实归属层级之外重复陈述(应改为链接),并包含编写或评审任何文档时使用的赘余检查清单。 - **范围窄且严格的预算门禁。**[scripts/verify-doc-budgets.ts](../../../../scripts/verify-doc-budgets.ts) 接入 `doc-sync`:[scripts/doc-budgets.manifest.json](../../../../scripts/doc-budgets.manifest.json) 列出的每份文档都必须低于其字数上限(采用 `wc -w` 语义,统计整个文件);预算内文件缺失也会使门禁失败,使重命名无法悄然遗落其预算。范围刻意只涵盖容易膨胀的常设文档——根目录和子树中的 `AGENTS.md` 文件、`architecture.md`、`packages/README.md`,以及它们将内容移入的常设策略文档(`docs/testing.md`、`docs/defensive-patterns.md`)。参考文档、Agent Note 和包 README 不设预算:只要每一行都是事实,长度在这些位置就是合理的;评审和赘余检查清单负责约束它们。 -- **上限是只进不退的执行红线。** 上限设定为文档当前字数的至少 105%(留出工作余量,使日常措辞调整能通过,而真正的膨胀仍会触发门禁),并随着文档被精简到目标预算而同步下调、保持该余量(根 `AGENTS.md` ≤ 1,500 词;`architecture.md` ≤ 1,800;子树 `AGENTS.md` ≤ 600;`packages/README.md` ≤ 600)。推进机制与[翻译配对的 `required` 清单](2026-07-02-bilingual-docs-and-pairing-gate.md)相同。门禁变红时,修复方式是按分类体系迁移或压缩内容;只有在 PR(Pull Request)描述中给出明确理由时才允许提高上限,manifest(元数据清单)的 diff 本身即为可评审的动作。 +- **上限是只进不退的执行红线。** 上限设定为文档当前字数的至少 105%(留出工作余量,使日常措辞调整能通过,而真正的膨胀仍会触发门禁),并随着文档被精简到目标预算而同步下调、保持该余量(根 `AGENTS.md` ≤ 1,500 词;`architecture.md` ≤ 1,800;子树 `AGENTS.md` ≤ 600;`packages/README.md` ≤ 600)。门禁变红时,修复方式是按分类体系迁移或压缩内容;只有在 PR(Pull Request)描述中给出明确理由时才允许提高上限,manifest(元数据清单)的 diff 本身即为可评审的动作。 - **精简的工作流 skill(技能),契约归文档。**[.agents/skills/dsh-doc-standards](../../../skills/dsh-doc-standards/SKILL.md) 承载放置/审计/红灯门禁工作流,并以文档标准为真源,与 [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) 和 i18n 契约之间的分工相同。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.i18n.yaml b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.i18n.yaml deleted file mode 100644 index 9aa8a03c52..0000000000 --- a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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-06-parallel-github-ci-gates.md: 5c276f6a75936021369bc5ad9494c9aa6e4e3fc3 -2026-07-06-parallel-github-ci-gates.zh.md: 7d98f842ef1d60a3a5b727f975cb1d93ea6f253c diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md deleted file mode 100644 index 5c276f6a75..0000000000 --- a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md +++ /dev/null @@ -1,50 +0,0 @@ -# Agent Note: Parallel GitHub CI gates - -Status: implemented - -English | [中文](2026-07-06-parallel-github-ci-gates.zh.md) - -## Problem - -The keyless GitHub CI gates are mostly orthogonal: typecheck, lint, documentation freshness, coverage, snapshot replay, build, package-publication hygiene, demo smoke, and built-bin smoke fail for different reasons and do not need each other's runtime state. Running them as one ordered command chain makes the workflow wall clock equal the sum of those gates, while splitting every short leaf into its own GitHub job repeats checkout, Node setup, pnpm restore, and install work until orchestration overhead becomes the bottleneck. - -The original broad-lane split stopped meeting that balance as the workspace grew. On the merge of PR #404, Linux static, coverage, snapshot, and artifact jobs took 148, 195, 94, and 230 seconds; Windows static and artifacts took 251 and 482 seconds. Package-manager packing once per package dominated both artifact validators, coverage needlessly rebuilt output before a source-only suite, and CPU-heavy gates contended inside the static and coverage lanes. - -The artifact boundary remains load-bearing. `publint`, `verify-node-next-types`, compiled invariant loading, and built-bin smoke tests need emitted `lib/` output. Sharding cannot race those consumers ahead of build or replace their published-artifact signal with source execution. - -## Decision - -The production topology below is historical and is superseded by [Evidence-based larger hosted runners](2026-07-22-evidence-based-larger-hosted-runners.md). The larger-runner decision removes its shard selectors and workflow jobs; this note preserves why that earlier topology was implemented. - -[CI](../../../../.github/workflows/ci.yml) treats one minute for non-Windows jobs and three minutes for Windows jobs as observed performance targets, not cancellation deadlines. Hosted-runner variance should leave complete timing evidence and useful failure logs instead of cancelling an otherwise-correct gate. The [serial cross-platform CI reference](2026-07-21-serial-cross-platform-ci-reference.md) independently runs the complete unsharded primary Node aggregate on Linux, macOS, and Windows so the optimized lane inventory is not its own completeness oracle. - -In that topology, [scripts/run-gates.ts](../../../../scripts/run-gates.ts) was the common bounded scheduler and GitHub supplied explicit shard names for the expensive gate families. `scripts/static-shards.ts` partitioned static gates into foundation, documentation-type, API-contract, catalog, prose, documentation-projection, and documentation-build ownership and rejected a missing or duplicate gate assignment. Linux lint used disjoint A-C, D-M, N-S, and T-Z package-source and package-test lanes, while Windows used complete package-source and package-test lanes; both included a repository complement starting from `.` so new top-level targets could not disappear between shards and owned the single cross-file duplication run. `scripts/coverage-shards.ts` assigned every workspace package to exactly one source-coverage lane. Directory filters retained a trailing separator because Vitest positional filters match substrings and would otherwise admit prefix-named siblings. Each coverage lane included only its owned source files, repeated the exhaustive companion topology test, and ran without a preceding build because the complete coverage suite passes from a tree with every generated `lib/` removed. - -Snapshot replay used two explicit multi-file lanes and eight scenario partitions of the large ACP file. `scripts/snapshot-shards.ts` owned that inventory, and its test discovered every file admitted by the snapshot config. Each snapshot job installed dependencies while its Linux runner prepared Bubblewrap, built the shipped runtime, and ran only its assigned replay surface. The suite retained bounded concurrency of five subprocesses because replay spent most of its time waiting on child protocol I/O. Fixture guards still inspected the complete ACP scenario table in every partition. - -Cold standalone documentation typechecking rebuilds the complete project-reference graph, so a dedicated documentation-type lane builds once and checks Markdown blocks against those declarations. The Linux documentation lane uses VitePress's MPA build to retain page rendering and dead-link validation within the observed non-Windows target; separate blocking Windows build and production-site lanes preserve the emitted-package and shipped-site checks without putting both critical paths in one job. - -Artifacts use two lanes: one metadata lane for `publint`, NodeNext declarations, and compiled invariant loading, plus one built-bin smoke lane. Each lane produces its own build before its consumers. Repeating the short build costs runner minutes but avoids an upload/download dependency and keeps each job's critical path bounded. - -[scripts/publint-all.ts](../../../../scripts/publint-all.ts) calls publint's supported API in-process against an in-memory publication view made from each manifest's declared files and npm's mandatory metadata files. This preserves the distinction between workspace files and published files without spawning a package-manager pack command 103 times. [scripts/verify-built-package-invariants.mjs](../../../../scripts/verify-built-package-invariants.mjs) stages those structurally validated manifest-declared `lib/` files below the real package, then imports the compiled self-reference through plain Node and Cordis Loader normalization. A companion that reaches an undeclared runtime chunk still fails. - -Compatibility lanes run the source worker and Zstandard runtime smokes on every advertised Node line. TypeScript checks the source graph once in a dedicated primary Node 24 lane; repeating the same compiler analysis in runtime compatibility jobs added time without runtime-specific signal. - -The workflow caches the pnpm store, keys each immutable ESLint cache to its owning lint shard, preserves native PowerShell for Windows measurements, and retains one aggregate `all checks passed` status for branch protection. Windows reuses the three exhaustive lint partitions and groups foundation/catalog/prose plus documentation-type/API-contract gates behind shared runner setups; only scheduling differs from the Linux partitions. Windows build and production-site validation remain blocking, while the wider Windows static, lint, and artifact matrix remains observational. - -## Alternatives considered - -- **Keep the broad lanes** - minimizes workflow YAML, but it preserves the measured multi-minute feedback loop. -- **Run every leaf gate as a separate GitHub job** - maximizes fan-out, but short generators and prose checks would spend more time preparing a runner than checking the repository. -- **Upload one build to artifact consumers** - avoids repeated compilation, but upload/download and dependency scheduling lengthen wall time; the clean build is short enough to repeat inside bounded lanes. -- **Keep package-manager packing in both publication gates** - delegates inventory selection to pnpm, but repeats more than 200 package-manager processes. The manifest structural gate plus publication-view fixtures make the optimized inventory contract explicit and fail on an on-disk but unpublished dependency. -- **Keep build before coverage** - provides emitted output the source suite no longer consumes; a clean-tree coverage proof showed it was pure latency. -- **Typecheck on every Node version** - repeats compiler work while the compatibility smokes already exercise actual Node-specific loading and compression behavior. - -## Consequences - -The shard inventories and matrix jobs described above are not part of the current repository contract. The superseding larger-runner decision keeps the complete primary inventory in one process and uses the serial suite as its independent completeness oracle. - -The optimized publication validators rely on the manifest `files` contract enforced by `verify-package-invariants`. If publication rules grow beyond that contract, the structural gate and both staged views must change together. - -Compatibility jobs no longer claim that TypeScript itself was exercised under every Node runtime. They prove runtime-sensitive source loading on Node 22, 24, and 26, while the primary runtime owns the single source-graph typecheck. diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.zh.md b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.zh.md deleted file mode 100644 index 7d98f842ef..0000000000 --- a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.zh.md +++ /dev/null @@ -1,50 +0,0 @@ -# Agent Note: 并行 GitHub CI 门禁 - -Status: implemented - -[English](2026-07-06-parallel-github-ci-gates.md) | 中文 - -## 问题 - -无密钥 GitHub CI 门禁大多相互正交:类型检查、lint、文档新鲜度、覆盖率、快照重放、构建、包(package)的发布卫生检查、demo 冒烟和已构建二进制冒烟会因不同原因失败,也不需要彼此的运行时状态。将它们作为一条有序命令链运行,会使工作流墙钟时间等于所有门禁耗时之和;而把每个短小叶子拆成独立 GitHub job,又会反复执行 checkout、Node 设置、pnpm 恢复和安装,直到编排开销成为瓶颈。 - -随着 workspace 增长,原有的宽车道拆分不再满足这一平衡。PR(Pull Request)#404 合并时,Linux 的静态、覆盖率、快照和产物 job 分别耗时 148、195、94 和 230 秒;Windows 的静态和产物 job 分别耗时 251 和 482 秒。每个包都调用一次包管理器打包,主导了两个产物验证器的耗时;覆盖率在仅运行源码的套件前无谓地重建输出;CPU 密集型门禁则在静态与覆盖率车道内争用资源。 - -产物边界仍然承载关键约束。`publint`、`verify-node-next-types`、已编译不变量加载和已构建二进制冒烟测试都需要生成的 `lib/` 输出。分片不能让这些消费方抢在构建前运行,也不能用源码执行取代它们对已发布产物的信号。 - -## 决策 - -下述生产拓扑已经成为历史,并由[基于证据采用更大的托管 runner](2026-07-22-evidence-based-larger-hosted-runners.md) 取代。更大 runner 的决策移除了其分片选择器和工作流 job;本文保留早期拓扑为何被实现的记录。 - -[CI](../../../../.github/workflows/ci.yml) 将非 Windows job 的一分钟和 Windows job 的三分钟视为观测所得的性能目标,而非取消截止时间。托管 runner 的波动应留下完整计时证据和有用的失败日志,而不是取消本来正确的门禁。[串行跨平台 CI 参考](2026-07-21-serial-cross-platform-ci-reference.md)会在 Linux、macOS 和 Windows 上独立运行完整、未分片的主 Node 聚合,使优化后的车道清单不会成为自身完整性的唯一判据。 - -在该拓扑中,[scripts/run-gates.ts](../../../../scripts/run-gates.ts) 是通用的有界调度器,GitHub 则为昂贵的门禁族提供显式分片名称。`scripts/static-shards.ts` 将静态门禁划分为基础、文档类型、API 契约、目录、正文、文档投影和文档构建等归属,并拒绝缺失或重复的门禁分配。Linux lint 使用互不重叠的 A-C、D-M、N-S、T-Z 包源码和包测试车道,Windows 则使用完整的包源码与包测试车道;两者都包含从 `.` 开始的仓库补集,使新增顶层目标无法消失在分片之间,并负责唯一一次跨文件重复检查。`scripts/coverage-shards.ts` 把每个 workspace 包恰好分配给一个源码覆盖率车道。目录过滤器保留尾部分隔符,因为 Vitest 位置过滤器按子字符串匹配,否则会纳入具有同名前缀的相邻项。每个覆盖率车道只包含其拥有的源码文件,重复运行穷尽式伴随拓扑测试,并且不先执行构建,因为从删除了所有生成式 `lib/` 的树开始,完整覆盖率套件仍可通过。 - -快照重放使用两个显式多文件车道,以及大型 ACP(Agent Client Protocol)文件的八个场景分区。`scripts/snapshot-shards.ts` 拥有该清单,其测试会发现快照配置允许的每个文件。每个快照 job 在其 Linux runner 准备 Bubblewrap 的同时安装依赖,随后构建已发布运行时,并且只运行分配给它的重放表面。该套件保留五个子进程的有界并发,因为重放的大部分时间都在等待子进程协议 I/O。fixture(测试前置数据)守卫仍会在每个分区中检查完整 ACP 场景表。 - -冷启动的独立文档类型检查会重建完整的项目引用图,因此专用文档类型车道只构建一次,再用这些声明检查 Markdown 块。Linux 文档车道使用 VitePress 的 MPA 构建,在观测所得的非 Windows 目标内保留页面渲染与死链接验证;单独的阻塞式 Windows 构建和生产站点车道保留已生成包与已发布站点检查,同时避免把两条关键路径放进同一个 job。 - -产物使用两个车道:一个元数据车道负责 `publint`、NodeNext 声明和已编译不变量加载,另一个负责已构建二进制冒烟。每个车道都会在其消费方之前自行构建。重复短时构建会消耗 runner 分钟数,但避免了上传/下载依赖,并使每个 job 的关键路径保持有界。 - -[scripts/publint-all.ts](../../../../scripts/publint-all.ts) 在进程内针对内存发布视图调用 publint 支持的 API;该视图由每份清单声明的文件和 npm 强制元数据文件构成。这样无需生成 103 次包管理器打包命令,也能保留 workspace 文件与已发布文件之间的区别。[scripts/verify-built-package-invariants.mjs](../../../../scripts/verify-built-package-invariants.mjs) 在真实包下暂存这些经过结构验证、由清单声明的 `lib/` 文件,再通过纯 Node 和 Cordis Loader 规范化导入已编译的自引用。若伴随项触及未声明的运行时分片,仍会失败。 - -兼容性车道会在每条声明支持的 Node 版本线上运行源码 worker 和 Zstandard 运行时冒烟。TypeScript 在专用的主 Node 24 车道中只检查一次源码图;在运行时兼容性 job 中重复同一编译器分析只会增加耗时,不会提供运行时特有信号。 - -工作流缓存 pnpm store,将每个不可变 ESLint 缓存的键绑定到其所属 lint 分片,为 Windows 测量保留原生 PowerShell,并保留一个聚合的 `all checks passed` 状态用于分支保护。Windows 复用三个穷尽式 lint 分区,并在共享 runner 设置后组合基础/目录/正文门禁与文档类型/API 契约门禁;只有调度方式与 Linux 分区不同。Windows 构建和生产站点验证继续阻塞,而更广泛的 Windows 静态、lint 和产物矩阵仍为观察性检查。 - -## 曾考虑的替代方案 - -- **保留宽车道**:最大限度减少工作流 YAML,但会保留观测到的数分钟反馈周期。 -- **让每个叶子门禁分别成为 GitHub job**:最大化扇出,但短小的生成器和正文检查准备 runner 的时间会超过检查仓库的时间。 -- **向产物消费方上传一次构建**:避免重复编译,但上传/下载和依赖调度会延长墙钟时间;干净构建足够短,可以在有界车道内重复。 -- **在两个发布门禁中保留包管理器打包**:把清单选择委托给 pnpm,但会重复启动 200 多个包管理器进程。清单结构门禁加发布视图 fixture 使优化后的清单契约显式化,并会在存在磁盘上有但未发布的依赖时失败。 -- **在覆盖率前保留构建**:提供源码套件已不再消费的生成输出;干净树覆盖率证明表明这只是纯粹的延迟。 -- **在每个 Node 版本上执行类型检查**:重复编译器工作,而兼容性冒烟已经验证实际的 Node 特有加载与压缩行为。 - -## 后果 - -上述分片清单和矩阵 job 不属于当前仓库契约。取而代之的更大 runner 决策在单个进程中保留完整主清单,并以串行套件作为独立完整性判据。 - -优化后的发布验证器依赖由 `verify-package-invariants` 强制执行的清单 `files` 契约。如果发布规则超出该契约,结构门禁和两个暂存视图必须一起变化。 - -兼容性 job 不再声称 TypeScript 本身已在每个 Node 运行时下执行。它们证明 Node 22、24 和 26 上对运行时敏感的源码加载,而主运行时负责唯一一次源码图类型检查。 diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml new file mode 100644 index 0000000000..9a0f4cfbda --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-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 +2026-07-13-documentation-site-projection.md: 2452c9dfa53e05061446df2fe650f3b4d6428c01 +2026-07-13-documentation-site-projection.zh.md: 9df230ea8adeb8744387a5f7efdf288d6a1f6eaa diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md index a5c15686ae..2452c9dfa5 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md @@ -2,6 +2,8 @@ Status: implemented +English | [中文](2026-07-13-documentation-site-projection.zh.md) + ## Problem The repository needs a navigable documentation website without turning the website directory into a second documentation source. Copying package guides, architecture pages, or generated catalogs into a site-specific tree allows the two copies to drift, while pointing VitePress directly at the repository root couples public URLs and navigation to the internal file layout. Repository-relative links also need different destinations on the website: published pages stay inside the site, but source files and unpublished contributor documents belong on GitHub. diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md new file mode 100644 index 0000000000..9df230ea8a --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md @@ -0,0 +1,47 @@ +# Agent Note: 将规范文档投影到网站 + +Status: implemented + +[English](2026-07-13-documentation-site-projection.md) | 中文 + +## 问题 + +仓库需要一个可导航的文档网站,但不能让网站目录成为第二个文档源。把包(package)指南、架构页面或生成目录复制到网站专用目录树,会使两份副本发生漂移;让 VitePress 直接指向仓库根目录,又会把公开 URL 和导航与内部文件布局耦合。仓库相对链接在网站上也需要指向不同位置:已发布页面应留在站内,源文件和未发布的贡献者文档则应指向 GitHub。 + +## 决策 + +规范 Markdown 保留在拥有它的仓库层级中。面向产品的指南位于 `docs/user/`,生成的参考资料保留在现有生成目录中,架构页面和实操手册(cookbook)页面也保留在现有的 `docs/` 路径。 + +`website/docs.ts` 是一份显式的发布 manifest(元数据清单)。每个条目将一个规范源文件映射到稳定的公开路由、侧边栏、分区和顺序。因此,新增或移除已发布页面是一项可评审的 manifest 变更,而不是隐式目录扫描的结果。 + +在 VitePress 启动或构建之前,`scripts/project-doc-site.ts` 会把 manifest 投影到被忽略的 `website/.generated/` 目录。生成目录树遵循公开路由,使 VitePress 导航、locale 检测和本地搜索使用同一套路由词汇。每个页面都会获得一个指向其规范仓库文件的 `editSource` frontmatter 字段;编辑链接回调只读取该页面的数据,因此公开 URL 与源文件布局彼此独立。 + +各 locale 的首页投影只保留规范 YAML frontmatter。面向仓库的正文可以保留其 H1 和双语源文件链接,而 VitePress 首页主题负责渲染 hero 与功能区,网站导航负责切换 locale。 + +投影器解析 Markdown 链接,但不会重新序列化文档。指向另一个已发布源文件的链接会变成站内相对路由;指向未发布仓库文件的链接会变成 GitHub 源文件链接;仓库图片会变成 GitHub raw URL。相对目标不存在时,投影快速失败。单元测试固定这些转换,`docs:check` 则运行投影器测试和 VitePress 生产构建,并将二者纳入 `doc-sync` 和并行文档门禁。 + +`website/AGENTS.md` 是网站子树中唯一维护的 Markdown 文件。投影器测试会枚举已跟踪且未被忽略的文件,并拒绝网站中的任何其他 Markdown,因此网站专用的 locale、路由、API 或生成源文件副本无法绕过发布 manifest。 + +Mermaid 渲染规范图表。网站工作区显式声明 `vitepress-plugin-mermaid` 要求 Vite 预打包的 5 个包,因为 pnpm 的严格依赖隔离会使本地开发服务器无法使用这些传递依赖;Knip 将这种仅运行时使用记录为有意的依赖例外。 + +网站发布与网站构建保持分离。专用 GitHub Actions 工作流运行现有文档门禁,将 `website/.dist` 作为 Pages 产物上传,并只在构建成功后部署。`actions/configure-pages` 在构建时向 VitePress 提供目标位置的 base path,因此私有 Pages 源站、未来的公开项目路径和自定义域名不需要各自的检入配置。Pages 可见性仍是仓库托管设置,而不是工作流权限。 + +## 考虑过的替代方案 + +**在 `website/` 下提交复制的 Markdown。** 这种方式让 VitePress 设置更直接,但每份复制的指南或 API 表格都会多出一个所有者,并且需要一套无法识别权威副本的同步约定。 + +**让 `website/` 成为每个已发布页面的规范归属。** 这种方式仍只有一份副本,却只是为了满足渲染器,就把架构、生成的参考资料和面向贡献者的材料移出了各自的仓库归属层级。 + +**自动发现所有 Markdown 文件。** 这种方式最大限度减少 manifest 维护,却会意外发布内部文档、把源文件移动暴露为 URL 变更,并根据偶然的目录顺序生成导航。 + +**使用文件系统符号链接。** 符号链接保留单一来源,却无法解决公开路由或仓库相对链接问题,而且在本地开发、包工具和托管 CI 环境中的行为不够可预测。 + +**只在部署工作流中构建。** 部署作业可以在合并后发现渲染故障。把生产构建纳入 `doc-sync`,则无论是否存在公开部署,同一个故障都能在本地和常规 CI 中暴露。 + +**硬编码公开项目路径。** 固定的 `/deepseek-harness/` base 适用于公开项目 URL,却不适用于私有 Pages 站点分配的唯一源站,也不适用于未来的自定义域名。使用 Pages 元数据可让这些目标位置共享同一份构建契约。 + +## 后果 + +文档事实只有一个可编辑归属,公开路由在源文件移动后仍保持稳定,网站也能纳入生成的参考资料而无需提交另一份生成副本。本地开发会监视规范输入并重新生成一次性投影。布局门禁会把陈旧的网站专用 Markdown 目录树变成合并失败,而不是被忽略的构建输入。影响文档网站的合并会把检查过的结果部署到 Pages,手动触发则提供恢复与验证入口。 + +发布 manifest 是一份需要维护的 allowlist,链接投影也引入了一层仓库专用的构建适配器。新增一种 Markdown 链接行为时,需要增加投影器测试。Mermaid 支持也会增大客户端 bundle,但能保留规范文档中已经使用的图表。 diff --git a/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.i18n.yaml b/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.i18n.yaml index ae5ed9b11e..b2297f4fb4 100644 --- a/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.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 -2026-07-19-require-agent-notes-for-non-trivial-changes.md: f2645832ebcdd0b81cbff5415c7eb6f60b6fa8cf -2026-07-19-require-agent-notes-for-non-trivial-changes.zh.md: 659aa7cad0823fa0082be1827f8c083037376a4c +2026-07-19-require-agent-notes-for-non-trivial-changes.md: 162ae61affb4c1b0ad526fa0da41f84ebbb02089 +2026-07-19-require-agent-notes-for-non-trivial-changes.zh.md: cd015ba62f1f2b1e9e5e6c36d1cde5bd35cba84c diff --git a/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.md b/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.md index f2645832eb..162ae61aff 100644 --- a/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.md +++ b/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.md @@ -14,6 +14,10 @@ Every non-trivial change adds or updates at least one Agent Note in the same PR. Updating the note that already owns a decision satisfies the rule; a new note is required only when no note owns it. Purely mechanical or local edits with no behavioral, contractual, structural, process, or rationale change are exempt. The [Agent Notes README](../../README.md#when-to-write-one) owns this boundary, while root `AGENTS.md` carries the standing order. +A fully superseded implemented note may be consolidated into the current owning note and deleted only after that owner preserves every unique rationale, alternative, consequence, verification contract, and named coverage gap. The same change repairs inbound links and removes the Chinese counterpart and consistency record. Partial supersession keeps both notes cross-linked and current; consolidation neither rewrites an old decision into its opposite nor leaves git history as the only copy of rationale. + +When a later decision removes an earlier feature completely, the removal note becomes the current owner only after the feature is absent from production code, configuration, schemas, durable or wire formats, migration, and compatibility behavior; no current documentation presents it as available; and no test exercises it as supported behavior. Removal rationale and tests that verify absence may remain. The removal owner preserves the feature's original motivation, why that motivation no longer justified the surface, alternatives to full removal, the capability given up, conditions for reintroduction, and verification of complete absence. Implementation inventories and tests that only described the deleted behavior are obsolete rather than current verification contracts. A removal limited to one transport, default, implementation, or presentation remains partial supersession. + Review enforces the semantic boundary. No automated gate attempts to classify a diff as trivial or non-trivial, so this policy adds no gate stage or runtime. ## Alternatives considered @@ -22,10 +26,21 @@ Review enforces the semantic boundary. No automated gate attempts to classify a **Require a new note for every change.** This duplicates an existing note when it already owns the decision and adds empty ceremony to purely mechanical edits. +**Keep every fully superseded note indefinitely.** A cross-linked record is necessary while part of its decision remains current, but a wholly obsolete implemented note contradicts the current-state contract and duplicates rationale that can have one owner. + +**Add a `superseded/` lifecycle.** Another lifecycle would retain the obsolete record and expand the tree, format gate, and maintenance rules without reducing duplication. + +**Rewrite the old note into the replacement decision.** This erases the decision boundary and its rejected alternatives. Consolidation instead preserves those facts in the current owner before deleting the obsolete file. + +**Preserve every implementation and test detail from a removed feature.** This recreates the obsolete note inside its replacement. The removal owner keeps the rationale and verification needed to understand or revisit the current absence, while deleted mechanics remain available in git history. + **Add a CI diff-classification gate.** A mechanical check cannot reliably determine whether a semantic change is trivial, while another gate adds runtime and invites false positives or superficial compliance. ## Consequences - Every substantial change preserves its rationale and rejected alternatives beside the implementation. - Contributors maintain an existing owning note instead of creating duplicate records. +- Fully superseded records can collapse into one current owner without losing their unique rationale or verification contract. +- Features that were later removed can have one current owner without carrying obsolete implementation and test inventories forward. +- Partial supersession remains explicit and cross-linked, while deletion requires link and bilingual-pair cleanup in the same change. - Mechanical edits remain lightweight, and the gate topology and runtime remain unchanged. diff --git a/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.zh.md b/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.zh.md index 659aa7cad0..cd015ba62f 100644 --- a/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.zh.md +++ b/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.zh.md @@ -14,6 +14,10 @@ Status: implemented 更新已经持有该决策的 Agent Note 即满足规则;仅当没有 Agent Note 持有该决策时才新增记录。完全机械或局部、且不改变行为、契约、结构、流程或决策依据的编辑可豁免。[Agent Notes README](../../README.md#when-to-write-one) 持有这条边界,根目录 `AGENTS.md` 则携带常驻指令。 +只有在当前持有该决策的记录保存了所有独有的决策依据、备选方案、影响、验证契约和明确指出的覆盖缺口后,才可将被完全取代的 implemented Agent Note 合并到该记录中并删除。同一变更还要修复入站链接,并删除中文对侧文件和一致性记录。仅部分被取代时,两个记录仍需互相链接并保持与现状一致;合并既不将旧决策改写成与其相反的决策,也不让 git 历史成为决策依据的唯一副本。 + +后续决策完全移除较早的功能时,只有该功能已从生产代码、配置、schema、持久化格式或协议格式、迁移和兼容行为中消失,当前文档不再将其描述为可用,且没有测试把它作为受支持行为来执行,移除记录才会成为当前持有记录。移除决策的依据和验证该功能已不存在的测试可以保留。它必须保留该功能的最初动机、为什么该动机已不足以证明继续保留该功能、完全移除之外的备选方案、放弃的能力、重新引入的条件,以及证明已彻底移除的验证。只描述已删除行为的实现清单和测试已经过时,不属于当前验证契约。仅移除一种传输、默认值、实现或展示仍属于部分取代。 + 评审负责执行这条语义边界。自动化门禁不尝试把差异分类为平凡或实质性变更,因此这项政策不会增加门禁阶段或运行时间。 ## 备选方案 @@ -22,10 +26,21 @@ Status: implemented **每项变更都必须新增 Agent Note。** 当现有 Agent Note 已经持有该决策时,这会产生重复记录,也会让纯机械编辑承担空洞的流程负担。 +**永久保留每份被完全取代的 Agent Note。** 只要旧决策仍有部分适用,就需要保留互相链接的记录;但完全失效的 implemented Agent Note 与记录当前状态的契约相矛盾,并重复保存本可由一个记录持有的决策依据。 + +**新增 `superseded/` 生命周期。** 新增生命周期仍会保留过时记录并扩张目录树、格式门禁和维护规则,却无法减少重复内容。 + +**将旧 Agent Note 改写为替代它的决策。** 这样会抹去决策边界及其否决的备选方案。合并做法是在删除过时文件前,先由当前持有决策的记录保存这些事实。 + +**保留已移除功能的每一项实现与测试细节。** 这会在替代记录中重建过时记录。移除决策的持有记录只保留理解或重新审视当前已移除状态所需的决策依据与验证,已删除机制仍可从 git 历史查看。 + **添加 CI 差异分类门禁。** 机械检查无法可靠判断语义变更是否平凡,额外门禁还会增加运行时间,并引入误报或表面合规。 ## 影响 - 每项实质性变更都会在实现旁保留其决策依据和被放弃的备选方案。 - 贡献者维护现有的决策持有记录,而不是创建重复记录。 +- 被完全取代的记录可以归并到一个当前持有记录中,同时不丢失其独有的决策依据或验证契约。 +- 后来被移除的功能可以只有一个当前持有记录,而无需继续保留过时的实现与测试清单。 +- 仅部分被取代的情况仍需明确记录并互相链接;删除记录则必须在同一变更中清理链接和双语配对。 - 机械编辑仍保持轻量,门禁拓扑和运行时间也保持不变。 diff --git a/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.i18n.yaml b/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.i18n.yaml index 8bd4745529..46073fc52b 100644 --- a/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.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 -2026-07-21-doc-sync-through-gate-scheduler.md: b7e41ba4aeac8ea03c706acadd481eee26abd5c2 -2026-07-21-doc-sync-through-gate-scheduler.zh.md: 56699747b1ba97fd90f7d53ab0deebc73ac775ef +2026-07-21-doc-sync-through-gate-scheduler.md: d66d9dc75ee4e8268d55e344a53c51c0bcf5f4d4 +2026-07-21-doc-sync-through-gate-scheduler.zh.md: 8c4c4595c2bc6e9439ed24cda1e70ae5a5ccd146 diff --git a/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.md b/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.md index b7e41ba4ae..d66d9dc75e 100644 --- a/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.md +++ b/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.md @@ -10,7 +10,7 @@ English | [中文](2026-07-21-doc-sync-through-gate-scheduler.zh.md) ## Decision -`doc-sync` in `package.json` delegates to the existing bounded scheduler — `tsx scripts/run-gates.ts doc-sync` — like the `check:ci:*` scripts ([parallel gate scheduling](2026-07-06-parallel-pre-push-gates.md), [parallel GitHub CI gates](2026-07-06-parallel-github-ci-gates.md)). The `doc-sync` mode expands to exactly `docSyncLeafGates()`, making the leaf list in `run-gates.ts` the single source of truth for the member set. The local mode caps default concurrency at four workers because several doc gates each build a full `ts.Program`; `DSH_GATE_CONCURRENCY` still overrides. +`doc-sync` in `package.json` delegates to the existing bounded scheduler — `tsx scripts/run-gates.ts doc-sync` — like the `check:ci:*` scripts ([parallel gate scheduling](2026-07-06-parallel-pre-push-gates.md), [current CI topology](2026-07-22-evidence-based-larger-hosted-runners.md)). The `doc-sync` mode expands to exactly `docSyncLeafGates()`, making the leaf list in `run-gates.ts` the single source of truth for the member set. The local mode caps default concurrency at four workers because several doc gates each build a full `ts.Program`; `DSH_GATE_CONCURRENCY` still overrides. `docSyncLeafGates` includes `verify-cordis-api`, so relevant local documentation checks and CI gate the generated runtime API catalog alongside the other generated docs. diff --git a/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.zh.md b/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.zh.md index 56699747b1..8c4c4595c2 100644 --- a/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.zh.md +++ b/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -`package.json` 中的 `doc-sync` 委托给既有的有界调度器——`tsx scripts/run-gates.ts doc-sync`——与各 `check:ci:*` 脚本的做法一致([并行门禁调度](2026-07-06-parallel-pre-push-gates.md)、[并行 GitHub CI 门禁](2026-07-06-parallel-github-ci-gates.md))。`doc-sync` 模式恰好展开为 `docSyncLeafGates()`,使 `run-gates.ts` 里的叶子列表成为成员集合的唯一真源。本地模式把默认并发上限设为四个 worker,因为多个文档门禁各自要构建完整的 `ts.Program`;`DSH_GATE_CONCURRENCY` 仍可覆盖。 +`package.json` 中的 `doc-sync` 委托给既有的有界调度器——`tsx scripts/run-gates.ts doc-sync`——与各 `check:ci:*` 脚本的做法一致([并行门禁调度](2026-07-06-parallel-pre-push-gates.md)、[当前 CI 拓扑](2026-07-22-evidence-based-larger-hosted-runners.md))。`doc-sync` 模式恰好展开为 `docSyncLeafGates()`,使 `run-gates.ts` 里的叶子列表成为成员集合的唯一真源。本地模式把默认并发上限设为四个 worker,因为多个文档门禁各自要构建完整的 `ts.Program`;`DSH_GATE_CONCURRENCY` 仍可覆盖。 `docSyncLeafGates` 包含 `verify-cordis-api`,因此相关的本地文档检查与 CI 会同其他生成文档一起把关生成的运行时 API 目录。 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index 9d87cb9ad3..3d8e6fc395 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.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 -2026-07-22-evidence-based-larger-hosted-runners.md: aaeab4ed9ae9687598f9f1d4a862120405697672 -2026-07-22-evidence-based-larger-hosted-runners.zh.md: 72b69c85908990a9f35b60f4c0a2ce213f9c8134 +2026-07-22-evidence-based-larger-hosted-runners.md: fe11e6929545923d27fbf41f5a39f7dd2b9c3fbf +2026-07-22-evidence-based-larger-hosted-runners.zh.md: 47879284532a537cbe7e78aa2c495c4ef0be26c4 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index aaeab4ed9a..fe11e69295 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -20,6 +20,10 @@ The former gate-level and coarse primary shard jobs are absent from the workflow Linux primary work uses three independent 32-core jobs. Coverage runs alone with its own worker bound, and the static scheduler runs alone so its result has no post-build consumer tail. After static gates finish, that job publishes its emitted `apps/*/lib`, `packages/*/*/lib`, and `vendor/*/lib` tree as a run-scoped artifact. The third job restores that exact tree, then starts lint, Node 24 runtime compatibility, build-backed snapshots, and all artifact consumers without repeating the build. Generated NodeNext consumer directories are excluded from ESLint discovery because the artifact check removes them while these processes overlap. The pnpm store and ESLint cache are restored without putting cache uploads on the pull-request critical path. Performance reports use each job's `startedAt` to `completedAt` interval; runner queue delay is capacity evidence, not repository execution time. +The gate dependencies remain explicit. Coverage consumes source and does not wait for build. Documentation typechecking builds its complete project-reference graph once. Snapshot replay and publication consumers wait for emitted output, while Node-version compatibility jobs exercise runtime-sensitive source loading without repeating the primary source-graph typecheck. PTY and subprocess suites keep their bounded inner concurrency rather than inheriting the runner's core count. + +The artifact boundary remains explicit. `scripts/publint-all.ts` calls publint's supported API against an in-memory publication view formed from each manifest's declared files plus npm's mandatory metadata, avoiding one package-manager pack process per package. `scripts/verify-built-package-invariants.mjs` stages the declared `lib/` files below the real package and imports its compiled self-reference through plain Node and Cordis Loader normalization; a runtime chunk omitted from the publication contract still fails. + Windows shares one 32-core setup across the blocking build and production site plus observational built-artifact contracts. Linux owns the duplicate lint, coverage, and snapshot inventories because running those observational copies on Windows extends the paid critical path without adding a blocking platform claim. An [exact-head all-size benchmark](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29908491351) ran the complete unsharded primary Node aggregate on every Linux pool before the eager-build correction: @@ -54,6 +58,10 @@ Complete serial Linux, macOS, and Windows references run only when `master` move **Keep the former gate-level shard topology as a manual reference.** A dormant second topology kept hundreds of workflow lines, selector modules, and scenario-partition behavior alive. The all-size and serial suites provide timing and completeness controls without preserving production code that no required job exercises. +**Return to package-manager packing in each publication validator.** Rejected because it repeats a package-manager subprocess for every package. The manifest-derived publication view and staged compiled self-reference preserve the published-file contract with one in-process inventory. + +**Build before coverage or typecheck on every Node version.** Rejected because coverage is source-only and compiler analysis is not runtime-specific. Build-backed consumers still wait for emitted output, and compatibility jobs exercise the runtime-sensitive paths on every advertised Node line. + **Use the 64-core pool for the complete primary aggregate.** Its sampled active time was three seconds lower than the 96-core result because hosted setup was nine seconds faster, but its repository gates were 5.72 seconds slower. The benchmark suite retains both pools because a sustained image or pricing change can reverse the comparison. **Keep build behind typecheck.** This orders independent compiler invocations and turns snapshot replay into a three-stage critical chain. Build output has its own success dependency, so only snapshot and publication consumers wait for it. diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index 72b69c8590..4787928453 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -20,6 +20,10 @@ Status: implemented Linux 主流程使用 3 个相互独立的 32 核作业。覆盖率单独运行,并设有自己的工作线程上限;静态调度器也单独运行,因此构建后的消费方不会拖延其结果。静态门禁完成后,该作业将其生成的 `apps/*/lib`、`packages/*/*/lib` 和 `vendor/*/lib` 目录树作为仅供本次运行使用的产物发布。第三个作业恢复完全相同的目录树,再让 lint、Node 24 运行时兼容性、依赖构建产物的快照和所有产物消费方基于构建完成后的工作树启动,而不重复构建。生成的 NodeNext 消费方目录不会纳入 ESLint 的文件发现范围,因为这些进程重叠执行时,产物检查会删除这些目录。pnpm store 和 ESLint 缓存会得到恢复,但缓存上传不会进入拉取请求关键路径。性能报告采用每个作业从 `startedAt` 到 `completedAt` 的区间;运行器排队延迟是容量证据,而非仓库执行时间。 +门禁依赖关系保持显式。覆盖率消费源码,不等待构建。文档类型检查只构建一次完整的 project-reference 图。快照回放和发布消费方等待生成的输出,而 Node 版本兼容性作业会验证对运行时敏感的源码加载,且不重复主源码项目图的类型检查。PTY 和子进程套件继续使用自身有界的内部并发,不继承运行器的核心数。 + +产物边界保持显式。`scripts/publint-all.ts` 对内存中的发布视图调用 publint 支持的 API;该视图由每个 manifest(元数据清单)声明的文件和 npm 强制要求的元数据组成,从而避免为每个包启动一次包管理器 pack 进程。`scripts/verify-built-package-invariants.mjs` 将已声明的 `lib/` 文件暂存到真实包下,并通过普通 Node 和 Cordis Loader 规范化导入其已编译的自身引用;发布契约只要遗漏一个运行时分片,检查仍会失败。 + Windows 以一次 32 核环境设置同时承载阻塞性构建、生产网站和观测性的构建产物契约。重复的 lint、覆盖率和快照清单由 Linux 承担,因为在 Windows 上运行这些观测性副本会延长付费关键路径,却不会新增任何阻塞性平台契约。 一次[分支头精确的全规格基准测试](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29908491351)在修正构建尽早启动逻辑前,对每种 Linux 池都运行了完整且未分片的主 Node 聚合流程: @@ -54,6 +58,10 @@ Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完 **将原有的门禁级分片拓扑保留为手动参考。** 一套闲置的第二拓扑会让数百行工作流、选择器模块和场景分区行为继续存活。全规格和串行套件无需保留任何必需作业都不执行的生产代码,也能提供计时与完整性对照。 +**在每个发布校验器中恢复使用包管理器打包。** 不予采用,因为这会为每个包重复启动一个包管理器子进程。根据 manifest 构建的发布视图和已暂存的编译后自身引用,只需一份进程内清单即可保留发布文件契约。 + +**在每个 Node 版本上先构建,再运行覆盖率或类型检查。** 不予采用,因为覆盖率只消费源码,编译器分析也不依赖运行时。依赖构建产物的消费方仍等待生成的输出,兼容性作业则在每个已声明支持的 Node 版本上验证对运行时敏感的路径。 + **使用 64 核池运行完整主聚合流程。** 由于托管设置快了 9 秒,其采样活动耗时比 96 核结果少 3 秒,但仓库门禁慢了 5.72 秒。基准测试套件保留两种规格,因为映像或定价的持续变化可能反转比较结果。 **让构建继续等待类型检查。** 此方案会给相互独立的编译器调用排定先后顺序,并把快照回放变成 3 阶段关键链。构建输出本身有独立的成功依赖关系,因此只有快照和发布消费方需要等待它。 diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml new file mode 100644 index 0000000000..8bc3f87435 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.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 +2026-07-25-semantic-pr-label-taxonomy.md: 61b7a829b8c44836cf9c6d0d8a7df463df309d89 +2026-07-25-semantic-pr-label-taxonomy.zh.md: cc0c5e7a8953bc97de51f90349a0e2542be5b77e diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md new file mode 100644 index 0000000000..61b7a829b8 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md @@ -0,0 +1,71 @@ +# Agent Note: Semantic pull request label taxonomy + +Status: implemented + +English | [中文](2026-07-25-semantic-pr-label-taxonomy.zh.md) + +## Problem + +Pull requests need two different signals: what kind of change they make and which repository domains they affect. A flat or broadly named label set conflates those questions, hides work in distinct areas such as `session` and `llm`, and gives reviewers and automation weak inputs. + +The repository also gains new domains over time. Treating today's area labels as a closed set would force future work into inaccurate labels or a generic catch-all. + +## Decision + +Every open or merged pull request carries exactly one kind and every materially affected area. Closed pull requests that were never merged are outside the maintained historical corpus. Other operational labels may coexist, but they do not satisfy either dimension. + +### Kinds + +| Kind | Meaning | +|---|---| +| `feature` | Adds or intentionally changes behavior. | +| `bug-fix` | Corrects incorrect behavior. | +| `doc` | Makes documentation the dominant intent. | +| `testing` | Changes tests or testing infrastructure without changing product behavior. | +| `cleanup` | Preserves behavior while maintaining or simplifying the implementation or repository process. | + +The kind records the change's dominant intent: accompanying tests and documentation do not turn a feature or bug fix into a testing or documentation change. + +Areas record semantic repository domains rather than temporary initiatives, ownership, or every path touched incidentally. Area labels are not a hierarchy: a pull request may carry several when it changes distinct contracts, but an umbrella and a narrower label do not both describe the same work. + +### Current areas + +The 45 current areas are listed below. The group names organize the list for readability; they are not labels or another taxonomy level. + +| Group | Areas | +|---|---| +| Agent and model | `agent`, `agent-loop`, `session`, `llm`, `model-context`, `compaction`, `tools`, `persistence` | +| Orchestration | `subagent`, `workflow`, `planning`, `tasks`, `schedule`, `telemetry`, `storage`, `workspace` | +| Capabilities | `bash`, `pty`, `filesystem`, `lsp`, `skills`, `web-search`, `code-mode`, `artifact`, `attachment`, `sandbox`, `mcp`, `hooks`, `cordis` | +| Interfaces | `ui`, `gui`, `tui`, `acp`, `json-rpc`, `cli`, `python-sdk`, `vscode`, `website` | +| Repository and release | `dev-infra`, `ci`, `build`, `dependencies`, `platform`, `i18n`, `release` | + +`gui` covers browser and Electron graphical applications, including standalone graphical developer tools; `vscode` remains the editor extension integration. `ui` covers shared cross-interface commands, approval interaction, presentation, and app boot; it coexists with `gui`, `tui`, or a protocol area only when the pull request also changes that shared contract. + +`tasks` owns background work tied to a running process, while `schedule` owns durable time-triggered jobs. `tools` owns generic registry, schema, and execution contracts; a concrete capability receives `tools` only when it changes one of those contracts. `attachment` owns durable media references and multimodal input delivery, while `artifact` owns model-declared deliverable identity and preview lifecycle; neither borrows `tools` or `ui` for its implementation parts. + +Names follow semantic ownership rather than lexical resemblance. `hooks` means the Claude Code and Codex agent bridges, not local Git hooks; `platform` means product portability, not CI runner selection; and `build` means compilation, bundling, and built package artifacts, not documentation generators. + +### Extensibility + +The area set is intentionally extensible. Add an area when a recurring, meaningful repository domain is missing; do not add a label for one pull request, a temporary project, a status, or a person or team. Rename, split, or retire an area when the domain model changes, and update this list and the affected open and merged pull requests together. + +The kind set stays narrow because kinds are mutually exclusive. A new kind requires a distinct change intent that cannot be represented by the current five; it is not a substitute for an area. + +## Alternatives considered + +- **One undifferentiated label set.** Rejected because kind and area answer different questions; mixing them makes the presence of one label say nothing about whether the other dimension was considered. +- **A fixed, closed area set.** Rejected because repository domains evolve. A closed set would preserve spelling at the cost of semantic accuracy. +- **One broad `core` area or package-derived labels.** Rejected because domains such as `session`, `llm`, and `agent` remain independently meaningful across package boundaries, while incidental file paths are not the scope reviewers or automation need. +- **Separate browser and desktop areas.** Rejected because browser delivery and Electron packaging expose one graphical client domain; splitting them classifies the delivery shell rather than the semantic work. +- **Broad implementation areas in place of a domain.** Rejected because a durable scheduled job is not a background task, an attachment is not merely its source interface or filesystem implementation, and an artifact is not merely its declaring tool or preview interface. +- **Umbrella and leaf areas for the same contract.** Rejected because duplicate labels inflate scope without adding information. Multiple areas remain correct when a pull request changes genuinely distinct contracts. +- **Exactly one area per pull request.** Rejected because coherent changes can legitimately span several domains, and dropping secondary areas hides affected contracts. + +## Consequences + +- Reviewers and automation receive one stable intent signal plus a complete semantic scope. +- `gui` queries cover browser and desktop delivery together, while `ui` queries retain only shared cross-interface contracts. +- `schedule`, `attachment`, and `artifact` queries identify those domains directly instead of approximating them through implementation dependencies. +- Selecting labels remains a judgment call: paths and title prefixes can suggest areas, but they cannot replace reading the change. +- Taxonomy changes carry maintenance work. Area additions, renames, splits, and removals update this decision record and backfill open and merged pull requests so historical queries keep their meaning. diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md new file mode 100644 index 0000000000..cc0c5e7a89 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md @@ -0,0 +1,71 @@ +# Agent Note: 语义化 PR 标签分类体系 + +Status: implemented + +[English](2026-07-25-semantic-pr-label-taxonomy.md) | 中文 + +## 问题 + +PR(Pull Request)需要传达两个不同的信号:它带来哪一类变更,以及会影响仓库中的哪些领域。一套扁平或命名宽泛的标签会混淆这两个问题,掩盖 `session`、`llm` 等不同领域的工作,也让评审人和自动化流程得到的输入缺乏有效信息。 + +仓库还会随时间发展出新的领域。如果把当前的领域标签视为封闭集合,未来的工作就只能归入不准确的标签或通用兜底标签。 + +## 决策 + +每项开放或已合并的 PR 都带有恰好一个类型标签,以及所有受到实质影响的领域标签。未合并即关闭的 PR 不属于持续维护的历史记录集合。其他管理用途的标签可以并存,但都不能满足这两个维度中的任一个。 + +### 类型 + +| 类型 | 含义 | +|---|---| +| `feature` | 新增行为或有意改变行为。 | +| `bug-fix` | 修正错误行为。 | +| `doc` | 以文档变更为主要意图。 | +| `testing` | 修改测试或测试基础设施,但不改变产品行为。 | +| `cleanup` | 在保持行为不变的前提下,维护或简化实现或仓库流程。 | + +类型记录变更的主要意图:配套测试与文档并不会把一项功能或缺陷修复变成测试或文档变更。 + +领域记录仓库中的语义领域,而不是临时项目、归属关系或偶然触及的每条路径。领域标签不构成层级:一项 PR 修改不同契约时可以带有多个领域标签,但不能用一个总括标签和一个较窄标签重复描述同一项工作。 + +### 当前领域 + +当前的 45 个领域如下。分组名称仅用于提高列表的可读性;它们既不是标签,也不是分类体系中的另一个层级。 + +| 分组 | 领域 | +|---|---| +| agent(智能体)与模型 | `agent`, `agent-loop`, `session`, `llm`, `model-context`, `compaction`, `tools`, `persistence` | +| 编排 | `subagent`, `workflow`, `planning`, `tasks`, `schedule`, `telemetry`, `storage`, `workspace` | +| 能力 | `bash`, `pty`, `filesystem`, `lsp`, `skills`, `web-search`, `code-mode`, `artifact`, `attachment`, `sandbox`, `mcp`, `hooks`, `cordis` | +| 接口 | `ui`, `gui`, `tui`, `acp`, `json-rpc`, `cli`, `python-sdk`, `vscode`, `website` | +| 仓库与发布 | `dev-infra`, `ci`, `build`, `dependencies`, `platform`, `i18n`, `release` | + +`gui` 涵盖浏览器和 Electron 图形应用,包括独立的图形化开发者工具;`vscode` 仍表示编辑器扩展集成。`ui` 涵盖共享的跨接口命令、审批交互、呈现和应用启动;只有当 PR 还修改这项共享契约时,它才与 `gui`、`tui` 或某个协议领域并用。 + +`tasks` 负责与运行中进程绑定的后台工作,`schedule` 则负责持久化的定时作业。`tools` 负责通用的注册表契约、schema 契约和执行契约;具体能力只有在修改其中一项契约时才带有 `tools`。`attachment` 负责持久化的媒体引用和多模态输入传递,`artifact` 则负责模型声明的交付物标识和预览生命周期;二者都不会因实现包含工具或界面部分而借用 `tools` 或 `ui`。 + +标签名称以语义归属为准,而不是词面相似性。`hooks` 指 Claude Code 和 Codex 的 agent 桥接,而不是本地 Git 钩子;`platform` 指产品可移植性,而不是 CI 运行器选择;`build` 指编译、打包和已构建的包(package)产物,而不是文档生成器。 + +### 可扩展性 + +领域集合有意保持可扩展。当分类体系缺少一个会反复涉及且具有实际意义的仓库领域时,就新增领域;不要仅为一项 PR、临时项目、状态、个人或团队新增标签。当领域模型发生变化时,重命名、拆分或退役相应领域,同时更新本列表以及所有受影响的开放和已合并 PR。 + +类型集合保持精简,因为各类型互斥。新增类型的前提是存在一种当前五类无法表达的独立变更意图;类型不能用来替代领域。 + +## 曾考虑的替代方案 + +- **一套不区分维度的标签。** 不予采纳,因为类型与领域回答的是不同问题;两者混在一起时,存在一个维度的标签并不表示另一个维度也经过了考虑。 +- **一套固定、封闭的领域集合。** 不予采纳,因为仓库领域会持续演变。封闭集合会以牺牲语义准确性为代价来维持拼写不变。 +- **一个宽泛的 `core` 领域,或从包结构派生的标签。** 不予采纳,因为 `session`、`llm` 和 `agent` 等领域在跨越包边界时仍各自具有意义,而偶然涉及的文件路径并不是评审人或自动化流程所需的范围信息。 +- **为浏览器和桌面端分别设置领域。** 不予采纳,因为浏览器交付和 Electron 打包共同呈现同一个图形客户端领域;拆开二者将按交付形态而非工作的语义进行分类。 +- **以宽泛的实现领域替代语义领域。** 不予采纳,因为持久化的定时作业不是后台任务,附件不只是其来源接口或文件系统实现,产物也不只是声明它的工具或预览接口。 +- **同一项契约同时使用总括领域与细分领域。** 不予采纳,因为重复标签只会虚增范围,不会增加信息。一项 PR 确实修改不同契约时,多个领域标签仍然合理。 +- **每项 PR 恰好一个领域。** 不予采纳,因为一项内聚的变更可以合理地跨越多个领域;省略次要领域会隐藏受影响的契约。 + +## 后果 + +- 评审人和自动化流程获得一个稳定的意图信号,以及完整的语义范围。 +- `gui` 查询会同时覆盖浏览器与桌面端交付,`ui` 查询则只涵盖共享的跨接口契约。 +- `schedule`、`attachment` 与 `artifact` 查询直接对应各自领域,无需通过实现依赖近似归类。 +- 选择标签仍然需要判断:路径和标题前缀可以提示领域,但不能替代阅读变更内容。 +- 变更分类体系会产生维护工作。新增、重命名、拆分或移除领域时,需要更新本决策记录,并回填开放和已合并的 PR,使历史查询保持原有含义。 diff --git a/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.i18n.yaml deleted file mode 100644 index 12abdeaada..0000000000 --- a/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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-04-fold-stdio-ui-helper.md: b9c4c6cfb7643890a7cf4dcdeb9014d7c7158818 -2026-07-04-fold-stdio-ui-helper.zh.md: d71fc878c603757f7da20aab3e7e219e368e7745 diff --git a/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md b/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md deleted file mode 100644 index b9c4c6cfb7..0000000000 --- a/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md +++ /dev/null @@ -1,30 +0,0 @@ -# Agent Note: Fold the stdio UI helper into the stdio app - -Status: implemented - -English | [中文](2026-07-04-fold-stdio-ui-helper.zh.md) - -The later [redundant-agent removal](2026-07-20-remove-stdio-and-echo-agents.md) supersedes this package-placement decision and removes the folded package, app, and line-oriented surface entirely. - -## Problem - -The readline UI was a whole package (`@deepseek-ai/dsh-ui-stdio` under `packages/support/`) whose only runtime importer was the app package `@deepseek-ai/dsh-stdio-demo`. The examples reach the readline UI by loading the app, never by composing the helper themselves; every other repo reference was mechanical or descriptive surface that existed BECAUSE the package boundary existed — manifest and tsconfig entries, generated module-graph rows, dependency-graph and README rows, and doc comments naming the package. The ui group README recorded the support placement rationale ("exists chiefly for the examples and the coverage gate — `ui/` is reserved for surfaces shipped as product"), which left a standing tension: a shipped product app depending on a support package documented as NOT product surface. - -The boundary bought package metadata, workspace and tsconfig references, module-graph rows, README entries, and publint surface for a helper that is not independently swappable: the stdio app's front-door cluster always includes the readline UI, and nothing else can meaningfully consume it. - -## Decision - -At the time, the helper moved into `@deepseek-ai/dsh-stdio` as the terminal-channel plugin. `createStdioChat`, its `StdioRuntime` test seam, and its unit tests moved with it, keeping EOF handling, rendering, disposal, and piped-vs-TTY behavior under the per-file coverage gate without hijacking process globals. The module kept the named `name`/`inject`/`Config`/`apply` export shape consumed by the app mount, while the then-current Echo and REPL Loader smokes proved the composed tree and the plugin-shape suite pinned explicit `unwrapExports` behavior. The superseding removal note above owns the current package and example state. - -The earlier support helper package was removed: its manifest, tsconfig references, module-graph rows, and README rows disappeared, while the remaining documentation described the in-package module. - -## Alternatives considered - -### Why not promote it to `ui/` instead? - -Promotion would have resolved the support-vs-product mismatch while keeping the boundary — the right call only if the readline UI were an independently swappable integration or had a second composer, and the consumer census said neither. The structured ACP bridge stays its own package because it is an automation protocol surface with its own contract and snapshot tiers; the readline helper is scaffolding for one app's front door. Re-extraction stays cheap pre-release: if a second product app wants the readline UI, split it back out then, with that consumer shaping the package contract. - -## Consequences - -- The stdio app owns its whole front door; a leaf `cordis.yml` still loads one app package and nothing changed shape for the demos. -- A future standalone terminal UI that wants the helper as a package reintroduces it with that second consumer, rather than the repo keeping a boundary for hypothetical reuse. diff --git a/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.zh.md b/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.zh.md deleted file mode 100644 index d71fc878c6..0000000000 --- a/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.zh.md +++ /dev/null @@ -1,30 +0,0 @@ -# Agent Note: 将 stdio UI 辅助模块折入 stdio 应用 - -Status: implemented - -[English](2026-07-04-fold-stdio-ui-helper.md) | 中文 - -后来的[冗余 agent(智能体)移除](2026-07-20-remove-stdio-and-echo-agents.md)取代了这项包放置决策,并完整移除合并后的包、应用和面向行的表面。 - -## 问题 - -readline UI 曾是一个完整的包(`packages/support/` 下的 `@deepseek-ai/dsh-ui-stdio`),其唯一的运行时导入方是应用包 `@deepseek-ai/dsh-stdio-demo`。示例通过加载应用来使用 readline UI,从不自行组合该辅助模块;仓库中所有其他引用都是因为包边界存在而存在的机械性或描述性表面:manifest(元数据清单)与 tsconfig 条目、生成的 module-graph 行、依赖图与 README 行,以及命名该包的文档注释。ui 组 README 记录了 support 放置的理由("主要为示例和覆盖率门禁而存在,`ui/` 保留给作为产品交付的界面"),这留下了一个持续的张力:一个已交付的产品应用依赖一个被明确标注为非产品表面的 support 包。 - -这条边界换来的是:包元数据、workspace 与 tsconfig 引用、module-graph 行、README 条目,以及 publint 表面——服务于一个并不可独立替换的辅助模块:stdio 应用的前门集群始终包含 readline UI,且没有其他消费方能有意义地使用它。 - -## 决策 - -当时,该辅助函数移入 `@deepseek-ai/dsh-stdio`,成为终端通道插件。`createStdioChat`、其 `StdioRuntime` 测试 seam 和单元测试随之一同迁移,使 EOF 处理、渲染、释放以及管道/TTY 行为继续受逐文件覆盖率门禁约束,而不会劫持进程全局量。该模块保留应用挂载所消费的具名 `name`/`inject`/`Config`/`apply` 导出形状;当时的 Echo 和 REPL Loader 冒烟证明组合树,插件形状套件则固定显式 `unwrapExports` 行为。上方取代本文的移除记录负责当前包和示例状态。 - -早期的支持辅助包已移除:其清单、tsconfig 引用、模块图行和 README 行均已消失,其余文档改为描述包内模块。 - -## 曾考虑的替代方案 - -### 为什么不将其提升到 `ui/` 而是折入? - -提升可以解决 support 与 product 之间的错位,同时保留边界——只有在 readline UI 是一个可独立替换的集成或有第二个组合方时才是正确选择,而消费方普查表明两者皆非。结构化的 ACP(Agent Client Protocol)桥接保留为独立包,因为它是具有自身契约和快照层级的自动化协议表面;readline 辅助模块只是一个应用前门的脚手架。在发布前重新拆分成本很低:如果将来有第二个产品应用需要 readline UI,届时再拆出来,由那个消费方来塑造包契约。 - -## 后果 - -- stdio 应用完整拥有自己的前门;叶子 `cordis.yml` 仍然只加载一个应用包,演示的形态没有变化。 -- 未来如果有独立的终端 UI 需要将该辅助模块作为包使用,届时由那个第二消费方驱动重新引入,而非仓库为假设性的复用保留一条边界。 diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml index 91e9b078ad..89b7165650 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.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 -2026-07-20-remove-stdio-and-echo-agents.md: 2aba8193710c96d3726b91062bfa43d039b4cabf -2026-07-20-remove-stdio-and-echo-agents.zh.md: 2c3916683f4743384a2ce4104319da26145837fe +2026-07-20-remove-stdio-and-echo-agents.md: d804f8e3c3de886fc48c87d6378c9f06d0e3c15a +2026-07-20-remove-stdio-and-echo-agents.zh.md: d42e6d527231e1703153c02c66d9286fa1e73c95 diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md index 2aba819371..d804f8e3c3 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md @@ -18,8 +18,8 @@ The stdio and Echo agents are removed without compatibility packages, modes, com The remaining application roles are explicit: -- [`@deepseek-ai/dsh-tui-demo`](../../../../packages/examples/tui-demo/README.md) owns terminal-interactive execution. `examples/tui-agent` owns the complete coding composition, Code Mode overlay, PTY coverage, and terminal snapshots. -- [`@deepseek-ai/dsh-cli-demo`](../../../../packages/examples/cli-demo/README.md) owns non-interactive execution. `examples/headless-agent` owns the real-model one-shot composition, replay snapshots, generic real-agent suites, and test-only keyless Loader fixtures. +- [`@deepseek-ai/dsh-tui-demo`](../../../../packages/examples/tui-demo/README.md) owns terminal-interactive execution. It rejects non-TTY streams before Loader boot; `examples/tui-agent` owns the complete coding composition, Code Mode overlay, PTY coverage, and terminal snapshots. +- [`@deepseek-ai/dsh-cli-demo`](../../../../packages/examples/cli-demo/README.md) owns non-interactive execution, including pipes. `examples/headless-agent` owns the real-model one-shot composition, replay snapshots, generic real-agent suites, and test-only keyless Loader fixtures. - [`@deepseek-ai/dsh-acp-demo`](../../../../packages/examples/acp-demo/README.md) and `@deepseek-ai/dsh-jsonrpc` own their framed protocol integrations. The SDK project model and create/config workflows replace the `stdio` run-interface option with `tui`; generated TUI projects compose `@deepseek-ai/dsh-tui` and create or resume one exact session. Repository-facing demo documentation requires a DeepSeek API key and leads with the real Headless or TUI agents. @@ -28,11 +28,14 @@ Keyless validation is test-owned. The Headless Loader smoke uses a fixture adapt ## Verification -TUI and Headless Loader coverage run the real app packages in source and built modes. TUI uses a pseudo-terminal; Headless proves its task/result and tool-call contracts. Generated graphs and repository searches reject stale package, command, leaf, and SDK-interface references. +TUI and Headless Loader coverage run the real app packages in source and built modes. PTY-driven subprocess coverage is reserved for the TUI lifecycle; other entry-point smokes use the one-shot pipe protocol. Headless proves its task/result and tool-call contracts. Generated graphs and repository searches reject stale package, command, leaf, SDK-interface, `createStdioChat`, and `StdioRuntime` references. + +The TUI PTY smoke includes the Code Mode overlay composition, while `examples/cordis-agent/tests/keyless-smoke.e2e.ts` provides a minimal PTY boot over the real Cordis-agent Loader tree. The built `dsh` bin rejects a piped TUI launch before Loader boot and points at its one-shot `-p` mode; `apps/cli/tests/built-bin.e2e.ts` pins that path, while `cli-demo`'s built-bin suite runs text, JSON, and structurally parsed `stream-json` output under plain Node, persists fresh sessions, and rejects invalid arguments and missing config without contaminating stdout. Time-context integration uses the real Headless composition for two ordered turns, while its package tests own finer elapsed-time behavior. ## Alternatives considered - **Keep the line agent only for pipes** — rejected because Headless has a bounded task contract, format-pure stdout, durable completion, and process exit status. +- **Keep, fold, or promote the readline helper as a package** — rejected because it had one app consumer and no independently swappable contract. Folding it into the stdio app removed an unjustified support-package boundary but still retained the redundant product; a future standalone line UI needs a real second consumer before reintroducing that package. - **Keep Echo as the keyless quick start** — rejected because the first product experience should exercise the real model and supported coding agent, not a scripted adapter with a bespoke tool. - **Keep Echo only as a CI demo command** — rejected because test-owned Headless fixtures cover the same Loader and built-artifact boundaries without preserving a mock product leaf. - **Remove every stdio or mock mechanism** — rejected because framed protocols, process I/O, and deterministic test adapters are independent infrastructure, not the removed agents. @@ -43,3 +46,4 @@ TUI and Headless Loader coverage run the real app packages in source and built m - The repository has no keyless user-facing agent demo; local agent demos require `DEEPSEEK_API_KEY`. - CI retains keyless real-entry coverage through test fixtures rather than a product command. - Existing stdio-agent configurations, Echo commands, and SDK `--interface=stdio` invocations fail instead of being translated. +- Piped multi-turn interaction in one process and the readline provider for non-TTY `ask_user_question` are intentionally gone; resume covers durable multi-turn work, and a non-TTY composition must supply its own interaction provider. diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md index 2c3916683f..d42e6d5272 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md @@ -18,8 +18,8 @@ DeepSeek Harness 在 TUI 和 Headless coding agent 之外,还提供了两个 保留的应用角色均有明确归属: -- [`@deepseek-ai/dsh-tui-demo`](../../../../packages/examples/tui-demo/README.md) 负责终端交互式执行。`examples/tui-agent` 拥有完整 coding 组装、Code Mode 覆盖层、PTY 覆盖和终端快照。 -- [`@deepseek-ai/dsh-cli-demo`](../../../../packages/examples/cli-demo/README.md) 负责非交互式执行。`examples/headless-agent` 拥有真实模型的单次任务组装、回放快照、通用真实 agent 测试套件,以及仅供测试使用的无密钥 Loader fixture。 +- [`@deepseek-ai/dsh-tui-demo`](../../../../packages/examples/tui-demo/README.md) 负责终端交互式执行。它会在 Loader 启动前拒绝非 TTY 流;`examples/tui-agent` 拥有完整 coding 组装、Code Mode 覆盖层、PTY 覆盖和终端快照。 +- [`@deepseek-ai/dsh-cli-demo`](../../../../packages/examples/cli-demo/README.md) 负责非交互式执行,包括管道方式。`examples/headless-agent` 拥有真实模型的单次任务组装、回放快照、通用真实 agent 测试套件,以及仅供测试使用的无密钥 Loader fixture。 - [`@deepseek-ai/dsh-acp-demo`](../../../../packages/examples/acp-demo/README.md) 和 `@deepseek-ai/dsh-jsonrpc` 负责各自的分帧协议集成。 SDK 工程模型与 create/config 工作流将 `stdio` 运行接口选项替换为 `tui`;生成的 TUI 工程组合 `@deepseek-ai/dsh-tui`,并创建或恢复一个确切会话。仓库中的演示文档要求 DeepSeek API key,并优先引导到真实的 Headless 或 TUI agent。 @@ -28,11 +28,14 @@ SDK 工程模型与 create/config 工作流将 `stdio` 运行接口选项替换 ## 验证 -TUI 与 Headless 的 Loader 覆盖以源码和构建产物两种模式运行真实 app 包。TUI 使用伪终端;Headless 验证任务/结果契约和工具调用契约。生成图谱与仓库搜索会拒绝陈旧的包、命令、叶节点和 SDK 接口引用。 +TUI 与 Headless 的 Loader 覆盖以源码和构建产物两种模式运行真实 app 包。由 PTY 驱动的子进程覆盖仅用于 TUI 生命周期;其他入口冒烟测试使用单次管道协议。Headless 验证任务/结果契约和工具调用契约。生成图谱与仓库搜索会拒绝陈旧的包、命令、叶节点、SDK 接口、`createStdioChat` 和 `StdioRuntime` 引用。 + +TUI PTY 冒烟测试包含 Code Mode 覆盖层组装,而 `examples/cordis-agent/tests/keyless-smoke.e2e.ts` 会基于真实 Cordis-agent Loader 目录树执行最小 PTY 启动。构建后的 `dsh` 可执行文件会在 Loader 启动前拒绝通过管道启动 TUI,并指向其单次 `-p` 模式;`apps/cli/tests/built-bin.e2e.ts` 固定了该执行路径,而 `cli-demo` 的 built-bin 套件在普通 Node 下运行文本、JSON 和经过结构化解析的 `stream-json` 输出,持久化新建会话,并在不污染 stdout 的情况下拒绝无效参数和缺失配置。时间上下文集成通过真实 Headless 组装执行两个有序轮次,而更细粒度的耗时行为由时间上下文的包级测试负责。 ## 曾考虑的替代方案 - **仅为 pipe 保留面向行 agent**:不予采纳,因为 Headless 已提供有界任务契约、格式纯净的 stdout、持久完成边界和进程退出状态。 +- **将 readline helper 作为包保留、折叠或提升**:不予采纳,因为它只有一个 app 消费方,并不存在可独立替换的契约。将它折叠进 stdio app 虽然移除了没有正当理由的支撑包边界,却仍保留了重复产品;将来要重新引入这个包,独立的面向行 UI 必须先有真正的第二个消费方。 - **保留 Echo 作为无密钥快速上手路径**:不予采纳,因为首次产品体验应使用真实模型和受支持的 coding agent,而不是带专用工具的脚本化适配器。 - **只为 CI 演示命令保留 Echo**:不予采纳,因为由测试持有的 Headless fixture 可以覆盖相同的 Loader 和构建产物边界,无需保留 mock 产品叶节点。 - **移除所有 stdio 或 mock 机制**:不予采纳,因为分帧协议、进程 I/O 和确定性测试适配器是独立基础设施,并不是被移除的 agent。 @@ -43,3 +46,4 @@ TUI 与 Headless 的 Loader 覆盖以源码和构建产物两种模式运行真 - 仓库没有面向用户的无密钥 agent 演示;本地 agent 演示需要 `DEEPSEEK_API_KEY`。 - CI 通过测试 fixture 保留针对真实入口的无密钥覆盖,而不是依赖产品命令。 - 既有 stdio agent 配置、Echo 命令和 SDK `--interface=stdio` 调用会直接失败,不会被转换。 +- 有意移除了单进程内基于管道的多轮交互,以及面向非 TTY `ask_user_question` 的 readline 提供方;恢复会话可以满足持久多轮工作,非 TTY 组装则必须自行提供交互提供方。 diff --git a/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.i18n.yaml deleted file mode 100644 index a760a91a8f..0000000000 --- a/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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-20-retire-readline-front-door.md: bf2ff2e1498668e4a45b9fcf310d92b2f3ea3099 -2026-07-20-retire-readline-front-door.zh.md: ee22437a9e7ab8fbde612ba14f296c3d230928f1 diff --git a/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.md b/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.md deleted file mode 100644 index bf2ff2e149..0000000000 --- a/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.md +++ /dev/null @@ -1,46 +0,0 @@ -# Agent Note: Retire the readline front door and the repl-agent example - -Status: implemented - -English | [中文](2026-07-20-retire-readline-front-door.zh.md) - -## Problem - -The repo shipped two interactive terminal front doors: the line-oriented readline channel (`@deepseek-ai/dsh-stdio`) and the full-screen [`@deepseek-ai/dsh-tui`](../feature/2026-07-17-dedicated-full-screen-tui-front-door.md). After the TUI landed, readline's interactive role was redundant — `demo:tui` superseded `demo:repl` as the coding-agent experience — while its remaining real role, pipes and automation, was already served better by the one-shot `@deepseek-ai/dsh-cli-demo` app (task in, DSH-native `text`/`json`/`stream-json` out, durable persistence, signal handling). - -The duplication was structural, not just cosmetic: `dsh-stdio-demo` carried a `TerminalMode` (`auto`/`readline`/`tui`) selection seam, ~1,000 lines of readline unit tests, a readline transcript grammar (`[tool call] …` lines) that the CI demo smoke and two built-bin e2es grepped, and an inverted example composition where the flagship `tui-agent` leaf was defined as an include-patch over the `repl-agent` leaf it superseded. - -## Decision - -Delete the readline front door and the repl-agent example; keep exactly three front-door archetypes: **interactive TUI** (TTY-only, fails loud on pipes), **one-shot CLI** (`-p`/positional task, pipes and automation), and **servers** (ACP / JSON-RPC). - -- `packages/ui/stdio` and `examples/repl-agent` are gone. `packages/examples/stdio-demo` is renamed `@deepseek-ai/dsh-tui-demo` (`packages/examples/tui-demo`) and always mounts `dsh-tui`; the `TerminalMode`/`resolveTerminalMode`/`ui.mode` seam is deleted. The bin refuses non-TTY streams **before booting the Loader** (a compose-time throw inside a Loader tree is logged per-entry, not rethrown, so a piped launch would otherwise settle into an idle UI-less process instead of exiting nonzero). -- `examples/tui-agent/cordis.yml` now owns the coding composition inline (the include-patch inversion is gone); its Code Mode overlay includes its own base. `examples/cordis-agent` moved to the TUI app. -- `examples/echo-agent` moved to the one-shot `dsh-cli-demo` app; `dsh-cli-demo` gained `-p/--prompt` as the flag form of the single task (mutually exclusive with the positional). -- The UI-independent with-key coding e2es (`full-loop`, `coding-task`, `resume`, `compaction`, `todo-write`, `code-mode` and their shared harness) moved verbatim from `examples/repl-agent/tests/` to `examples/tui-agent/tests/` — they assemble the stack programmatically and never touched a UI. -- The SDK wizard's `stdio` run interface became `tui` (`RunInterface = 'acp' | 'tui' | 'embed'`), contributing a `dsh-tui` entry instead of `dsh-stdio`; the generated `index.ts` guards TTY before `startSDK` for the same pre-boot fail-loud reason as the tui-demo bin. - -### Testing policy: PTY only for the TUI - -Pipes remain the default test medium. PTY-driven subprocess tests are sanctioned **only** where the subject is the TUI itself: `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` (which gained the Code Mode overlay boot scenario, replacing repl-agent's pipe smoke as the overlay's keyless composition proof) and the minimal PTY boot smoke in `examples/cordis-agent` (whose front door IS the TUI). Everything else moved to pipes over the one-shot bin: - -- `examples/echo-agent/tests/echo.e2e.ts` proves the Loader boot + mock-model tool round-trip through `stream-json` records instead of readline transcript lines. -- The CI demo-smoke gate (`scripts/run-gates.ts`, AGENTS.md) runs `demo:echo --output-format stream-json -p "echo ci smoke"` and parses the records structurally. -- The TUI's piped-launch refusal (nonzero exit + pointer at the one-shot CLI) is covered by `apps/cli/tests/built-bin.e2e.ts` (the `dsh` TTY guard under plain Node); the echo-round-trip-under-plain-Node and missing-config fail-loud proofs live in `cli-demo`'s built-bin suite. -- `packages/context/time-context/tests/time-context.e2e.ts` runs one one-shot turn; multi-turn elapsed rendering stays unit-covered in its spec. - -## Accepted losses - -- **Piped multi-turn in one process** — the readline channel could script several turns over stdin; the one-shot bin runs one task per process. Multi-turn continuity is covered by `RESUME_SESSION_ID`/resume e2es and the TUI's scripted PTY conversation. -- **Non-TTY `ask_user_question`** — the readline provider was the only non-TTY terminal implementation of `ctx.userInteraction`. A headless or ACP automation run whose model calls `ask_user_question` fails that tool call unless its composition supplies a provider; Web owns the shipped non-terminal provider. - -## Alternatives considered - -- **Keep `dsh-stdio` as a pipe/automation channel without the repl demo** — rejected: its automation role duplicated `dsh-cli-demo` with a weaker contract (unstructured transcript, EOF-exit heuristics vs. one durable turn ending and format-pure output). -- **Rewrite the piped smokes as PTY drivers** — rejected: PTY is the flakier, more complex medium and is reserved for the one surface pipes cannot prove (real TTY takeover/restore). - -## Consequences - -- One interactive front door (TUI), one automation front door (one-shot CLI), two servers; no mode-selection seam in the terminal app. -- ~1,000 lines of readline unit tests deleted with their behavior; the readline transcript grammar is gone from all gates. -- This supersedes the packaging half of [fold the stdio UI helper](2026-07-04-fold-stdio-ui-helper.md) (the folded package is now deleted) and amends the composition described in [the TUI front-door note](../feature/2026-07-17-dedicated-full-screen-tui-front-door.md) (no `auto` selection; `tui-agent` owns the coding composition). diff --git a/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.zh.md b/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.zh.md deleted file mode 100644 index ee22437a9e..0000000000 --- a/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.zh.md +++ /dev/null @@ -1,46 +0,0 @@ -# Agent Note: 退役 readline 前端与 repl-agent 示例 - -Status: implemented - -[English](2026-07-20-retire-readline-front-door.md) | 中文 - -## 问题 - -仓库同时提供两个交互式终端前端:面向行的 readline 通道(`@deepseek-ai/dsh-stdio`)和全屏的 [`@deepseek-ai/dsh-tui`](../feature/2026-07-17-dedicated-full-screen-tui-front-door.md)。TUI 落地之后,readline 的交互角色已经冗余——`demo:tui` 作为编码 agent 体验取代了 `demo:repl`——而它剩下的真实角色(管道与自动化)已由单次任务的 `@deepseek-ai/dsh-cli-demo` 应用以更好的方式承担(任务输入、DSH 原生 `text`/`json`/`stream-json` 输出、持久化、信号处理)。 - -这种重复是结构性的,不只是表面问题:`dsh-stdio-demo` 携带一个 `TerminalMode`(`auto`/`readline`/`tui`)选择接缝、约 1,000 行 readline 单元测试、一套被 CI 演示冒烟测试和两个 built-bin e2e 用 grep 匹配的 readline 文本记录语法(`[tool call] …` 行),以及一个倒置的示例组合:旗舰 `tui-agent` 叶节点被定义为对它所取代的 `repl-agent` 叶节点的 include patch。 - -## 决定 - -删除 readline 前端和 repl-agent 示例;只保留三类前端原型:**交互式 TUI**(仅 TTY,管道下快速失败)、**单次任务 CLI**(`-p`/位置参数任务,服务管道与自动化)以及**服务器**(ACP / JSON-RPC)。 - -- `packages/ui/stdio` 与 `examples/repl-agent` 已删除。`packages/examples/stdio-demo` 更名为 `@deepseek-ai/dsh-tui-demo`(`packages/examples/tui-demo`)并始终挂载 `dsh-tui`;`TerminalMode`/`resolveTerminalMode`/`ui.mode` 接缝随之删除。bin 在**启动 loader 之前**就拒绝非 TTY 流(Loader 树内组合期抛出的异常按条目记录日志而不会重新抛出,管道启动否则会沉降为一个空闲的无 UI 进程而不是以非零码退出)。 -- `examples/tui-agent/cordis.yml` 现在内联拥有编码组合(include patch 倒置消失);其 Code Mode 覆盖层 include 自己的基础配置。`examples/cordis-agent` 迁移到 TUI 应用。 -- `examples/echo-agent` 迁移到单次任务的 `dsh-cli-demo` 应用;`dsh-cli-demo` 新增 `-p/--prompt` 作为单个任务的旗标形式(与位置参数互斥)。 -- 与 UI 无关的带密钥编码 e2e(`full-loop`、`coding-task`、`resume`、`compaction`、`todo-write`、`code-mode` 及其共享 harness)原样从 `examples/repl-agent/tests/` 移入 `examples/tui-agent/tests/`——它们以编程方式组装整个栈,从不接触任何 UI。 -- SDK 向导的 `stdio` 运行接口改为 `tui`(`RunInterface = 'acp' | 'tui' | 'embed'`),贡献 `dsh-tui` 配置项而不是 `dsh-stdio`;生成的 `index.ts` 在 `startSDK` 之前检查 TTY,理由与 tui-demo bin 的启动前快速失败相同。 - -### 测试策略:PTY 仅用于 TUI - -管道仍是默认测试介质。PTY 驱动的子进程测试**仅**在被测对象就是 TUI 本身时获准使用:`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts`(新增 Code Mode 覆盖层启动场景,取代 repl-agent 的管道冒烟测试成为该覆盖层的无密钥组合证明)和 `examples/cordis-agent` 中最小的 PTY 启动冒烟测试(其前端就是 TUI)。其余全部改为通过单次任务 bin 走管道: - -- `examples/echo-agent/tests/echo.e2e.ts` 通过 `stream-json` 记录证明 Loader 启动 + mock 模型的工具往返,而不是匹配 readline 文本记录行。 -- CI 演示冒烟门禁(`scripts/run-gates.ts`、AGENTS.md)运行 `demo:echo --output-format stream-json -p "echo ci smoke"` 并结构化解析记录。 -- TUI 对管道启动的拒绝(非零退出 + 指向单次任务 CLI 的提示)由 `apps/cli/tests/built-bin.e2e.ts`(纯 Node 下的 `dsh` TTY 守卫)覆盖;纯 Node 下的 echo 往返证明与缺失配置的快速失败证明位于 `cli-demo` 的 built-bin 套件。 -- `packages/context/time-context/tests/time-context.e2e.ts` 运行一个单次任务轮次;多轮 elapsed 渲染仍由其单元测试覆盖。 - -## 接受的损失 - -- **单进程内的管道多轮对话**——readline 通道可以通过 stdin 脚本化多个轮次;单次任务 bin 每个进程只运行一个任务。多轮连续性由 `RESUME_SESSION_ID`/resume e2e 和 TUI 的脚本化 PTY 对话覆盖。 -- **非 TTY 的 `ask_user_question`**——readline 提供方是 `ctx.userInteraction` 唯一的非 TTY 终端实现。模型调用 `ask_user_question` 的 headless 或 ACP 自动化运行会让该工具调用失败,除非其组合提供相应的 provider;Web 拥有已交付的非终端 provider。 - -## 曾考虑的替代方案 - -- **保留 `dsh-stdio` 作为纯管道/自动化通道而只删 repl 演示**——不予采纳:它的自动化角色以更弱的契约重复了 `dsh-cli-demo`(非结构化文本记录、EOF 退出的启发式判断,对比后者的一次持久轮次结束和格式纯净输出)。 -- **把管道冒烟测试改写为 PTY 驱动**——不予采纳:PTY 是更易波动、更复杂的介质,仅保留给管道无法证明的那一个表面(真实 TTY 的接管/恢复)。 - -## 后果 - -- 一个交互式前端(TUI)、一个自动化前端(单次任务 CLI)、两个服务器;终端应用不再有模式选择接缝。 -- 约 1,000 行 readline 单元测试随其行为一起删除;readline 文本记录语法从所有门禁中消失。 -- 本决定取代 [fold the stdio UI helper](2026-07-04-fold-stdio-ui-helper.md) 的打包部分(被折叠的包现已删除),并修订 [TUI 前端 Agent Note](../feature/2026-07-17-dedicated-full-screen-tui-front-door.md) 描述的组合(不再有 `auto` 选择;`tui-agent` 拥有编码组合)。 diff --git a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.i18n.yaml index d0c69e6c43..dbd47ad90e 100644 --- a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.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 -2026-07-22-plan-specific-collaboration-state.md: d6b606d2235b5dbcb7e1882dd34e8965799c1199 -2026-07-22-plan-specific-collaboration-state.zh.md: c0dc22f6ec296a293681b78cebe7cc05f49f774b +2026-07-22-plan-specific-collaboration-state.md: fb26d15238f0eb1b63fdccc7e48a6c49a44236cf +2026-07-22-plan-specific-collaboration-state.zh.md: 93186eebb263458bc99e7f7562d065fbf9e5d4bf diff --git a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md index d6b606d223..fb26d15238 100644 --- a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md +++ b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md @@ -1,4 +1,4 @@ -# Agent Note: Collapse named session modes into plan mode +# Agent Note: Plan-specific collaboration state Status: implemented @@ -10,6 +10,8 @@ The first plan-mode implementation introduced a generic named-mode registry even The word “mode” also spans unrelated domains. Sandbox mode is an enforcing policy owned by `ctx.sandboxPolicy` and logged as `sandbox/mode`; plan mode is a collaboration stance that contributes guidance and a reviewed exit. Treating both as instances of one named-mode abstraction would obscure their independent ownership. A transport's generic vocabulary is not evidence that the harness needs a generic mode domain. +Plan mode also needs a durable stance, a reviewable plan artifact, an explicit human boundary, and request reconstruction across resume and fork. Those requirements belong to the plan feature even after the generic registry and interactive ACP projections are removed. + ## Decision Plan mode owns a plan-specific product package: `@deepseek-ai/dsh-plan-mode` at `packages/plan/plan-mode/`. The durable fact is `plan/mode: { active: boolean }`, folded by `foldPlanMode(events)` with `false` as the empty-log value. `ctx.planMode.get(agent)` returns `{ active, pending? }`, and `set(agent, active)` records the boundary-applied selection. The existing prompt-submit, continuation, retry, append-failure, and disposal fences remain unchanged in meaning. @@ -20,6 +22,18 @@ Human-facing compositions own plan selection and review. This note originally ke Sandbox mode and approval policy remain separate enforcement axes. Plan mode neither reads nor writes them, and the simplification introduces no shared base type, registry, or preset abstraction across those concepts. +### Boundary and model contract + +`plan/mode` is log-only and non-surface, so resume, fork, and compaction recover the state without a live mirror. A spawned agent begins inactive because there is no creation-time plan option. Pending user selections flush before the affected request assembly on prompt submission, ordinary continuation, or a request-recovery retry; a failed durable append leaves the intent pending for a later boundary. + +The active state contributes the deployment's section at prompt order 50. Inactive state contributes no section, while `exit_plan_mode` remains registered in both states, so a transition changes the logged request header but not native tool schemas or the Code Mode SDK. A user-driven transition appends one plugin-sourced notice only when the last request header described the opposite state; a pre-first-request or net-zero selection adds none, and an approved tool exit relies on its tool result instead of a second notice. + +### Reviewed exit + +`exit_plan_mode` requires a calling agent in active plan mode and a non-empty markdown plan beginning with a heading. The user-interaction question carries that exact plan as detail and offers `Approve` or `Keep planning` plus free-text feedback. Only one `Approve` selection with no custom text consents; every other answer stays in plan mode and returns corrective feedback to the model. An approved exit becomes a silent pending selection, leaving plan guidance active for the rest of the current tool batch and removing it before the next request. + +The tool renders the submitted plan as a generic card titled by its first heading. An absent or failed user-interaction provider, a failed review, or plugin disposal while review is pending fails closed and leaves manual `/plan off` as the human escape path. + ## Deleted surface - The arbitrary definition map, mode-name regular expression, reserved-name rules, and per-definition command loop. @@ -31,16 +45,27 @@ Sandbox mode and approval policy remain separate enforcement axes. Plan mode nei **Keep a private generic registry and expose only plan today.** Rejected because the unused name/config machinery would still be maintained and tested without a second production consumer. A future collaboration state can establish the right shared seam from two concrete cases. -**Fold sandbox mode into the same service.** Rejected because collaboration guidance and execution confinement have different owners, lifecycle semantics, and consumers. Their shared English noun is not a domain relationship. +**Fold sandbox or approval policy into plan state.** Rejected because collaboration guidance, execution confinement, and permission decisions have different owners, lifecycle semantics, and consumers. A mode-owned sandbox cap also makes a user's explicit sandbox selection appear to succeed while silently doing nothing. **Let one presentation transport own plan state.** Rejected because TUI, Web, resume, fork, prompt assembly, and the exit tool need the same logged fact independently of any one transport. Presentation adapters own only their projections. +**Split a capability-seam trio or put the state in the agent loop.** Rejected because plan mode has no swappable backend, while existing session, prompt, tool, command, and lifecycle seams already provide every required hook. + +**Put flips in surface messages or store plans in files.** Rejected because the stance is a log-only fact and the tool argument already records the reviewable plan. Surface duplication spends model context, while a plan directory creates a second durable home. + +**Filter tools by a per-plan name allowlist or a global policy stack.** Rejected because mutability is a property of each tool, including future and MCP tools, rather than a list that every plan deployment must maintain. Effects metadata can establish a shared policy only when a concrete consumer exists; until then plan mode is guidance, not a security boundary. + +**Review through the approval seam or prose.** Rejected because a plan review is not a permission decision, needs the exact artifact and corrective free text, and must have a logged tool call as its structured transition. The user-interaction seam supplies that contract. + ## Verification - Package tests retain boundary ordering, retry, append-failure, HMR disposal, prompt assembly, stable native and Code Mode schemas, review outcomes, and invariant coverage through the boolean service. - Command tests cover bare `/plan`, `/plan `, active `/plan off`, pending-entry cancellation, inactive idempotence, absence of `/mode` and `/review`, and effect-scoped removal. - The keyless TUI scenarios enter through `/plan `, leave through `/plan off`, and prove that each committed `plan/mode` precedes the request header it changes, the entry message is logged under plan guidance, and the post-exit request omits that guidance. +- The complete `exit_plan_mode` review arc is package-tested but has no assembled-application snapshot after the interactive ACP scenarios were retired; current keyless TUI scenarios cover command entry and direct exit only. ## Consequences The implementation has one vocabulary for one shipped feature. Adding another collaboration stance is an explicit design decision instead of a config entry, and automation clients do not acquire human mode controls through ACP. The migration intentionally rejects old `mode/set` logs and old `modes.plan.section` configuration under the repository's pre-release format policy. + +Plan state remains reconstructable and tool schemas remain stable, but an idle pending selection is lost if the process exits before the next boundary. Entering or leaving plan mode changes the prompt from order 50 onward, and a model that ignores the guidance can still mutate unless the deployment independently configures sandbox, approval, or filesystem policy. diff --git a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.zh.md b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.zh.md index c0dc22f6ec..93186eebb2 100644 --- a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 将具名会话模式收敛为 plan mode +# Agent Note: plan 专用协作状态 Status: implemented @@ -10,6 +10,8 @@ Status: implemented 「mode」一词还横跨互不相关的领域。沙箱模式是由 `ctx.sandboxPolicy` 拥有、以 `sandbox/mode` 记录日志的强制执行策略;plan mode 则是一种协作方式,会贡献引导内容和经评审的退出路径。若把两者都视为同一个具名模式抽象的实例,就会掩盖二者各自独立的归属关系。传输协议的通用词汇并不能证明 harness 需要通用模式领域。 +Plan mode 还需要持久协作状态、可评审的计划产物、显式人工决策边界,以及跨恢复与 fork 的请求重建。即使移除通用注册表和 ACP 交互投影,这些要求仍归 plan 功能所有。 + ## 决策 Plan mode 拥有一个 plan 专用产品包:位于 `packages/plan/plan-mode/` 的 `@deepseek-ai/dsh-plan-mode`。持久化事实为 `plan/mode: { active: boolean }`,由 `foldPlanMode(events)` 折叠,空日志值为 `false`。`ctx.planMode.get(agent)` 返回 `{ active, pending? }`,`set(agent, active)` 则记录在边界生效的选择。现有的提示词提交、continuation、重试、追加失败和 dispose(资源释放)栅栏在语义上保持不变。 @@ -20,6 +22,18 @@ Plan mode 拥有一个 plan 专用产品包:位于 `packages/plan/plan-mode/` 沙箱模式与审批策略仍是彼此独立的强制约束轴。Plan mode 既不读取也不写入二者;此次简化也没有为这些概念引入共享基类型、注册表或预设抽象。 +### 边界与模型契约 + +`plan/mode` 仅记录到日志且不进入表层,因此恢复、fork 和压缩都能恢复该状态,无需实时镜像。spawn 出的 agent 初始处于未激活状态,因为创建时没有 plan 选项。待生效的用户选择会在提示词提交、普通 continuation 或请求恢复重试时,于受影响的请求组装前写入日志;持久追加失败会让意图保持待定,留到后续边界处理。 + +激活状态在提示词顺序 50 处贡献部署提供的区段。未激活状态不贡献区段,但 `exit_plan_mode` 在两种状态下都保持注册,因此状态转换会改变已记录的请求头,却不改变原生工具 schema 或 Code Mode SDK。用户发起的转换只会在上一条请求头描述相反状态时追加一条来源为插件的通知;第一次请求前的选择或最终状态未变化的选择不会追加通知,经批准的工具退出则依赖其工具结果,不再追加第二条通知。 + +### 经评审的退出 + +`exit_plan_mode` 要求调用方 agent 处于激活的 plan mode,并提交一份非空、以标题开头的 markdown 计划。用户交互问题将这份原样计划作为详情,并提供 `Approve`、`Keep planning` 和自由文本反馈。仅当唯一选择为 `Approve` 且没有自定义文本时才视为同意;其他所有回答都会留在 plan mode,并向模型返回纠正性反馈。经批准的退出会成为一项静默的待生效选择,使 plan 引导在当前工具批次的剩余部分继续有效,并在下一次请求前移除。 + +工具将提交的计划渲染为 generic 卡片,标题取自第一个标题。用户交互提供方缺失或失败、评审失败,或评审待定期间插件被 dispose,都会失败关闭,并保留手动 `/plan off` 作为人类退出路径。 + ## 删除的接口 - 任意定义映射、模式名正则表达式、保留名称规则以及逐定义命令循环。 @@ -31,16 +45,27 @@ Plan mode 拥有一个 plan 专用产品包:位于 `packages/plan/plan-mode/` **保留私有的通用注册表,目前只暴露 plan。** 不予采纳,因为没有第二个生产消费方时,仍需维护和测试未使用的名称与配置机制。未来若出现另一种协作状态,可以从两个具体案例出发建立合适的共享 seam。 -**将沙箱模式折叠进同一服务。** 不予采纳,因为协作引导与执行约束有不同的归属方、生命周期语义和消费方。二者的英文名称都含「mode」,不代表存在领域关系。 +**将沙箱或审批策略折叠进 plan 状态。** 不予采纳,因为协作引导、执行约束和权限决策有不同的归属方、生命周期语义和消费方。由 mode 拥有的沙箱上限还会让用户显式选择沙箱看似成功,实际却被静默忽略。 **让一种呈现传输拥有 plan 状态。** 不予采纳,因为 TUI、Web、恢复、fork、提示词组装和退出工具都需要独立于任何单一传输使用同一项已记录事实。呈现适配器只拥有各自的投影。 +**拆成能力 seam 三包,或把状态放进 agent loop。** 不予采纳,因为 plan mode 没有可替换后端,而现有的会话、提示词、工具、命令和生命周期 seam 已经提供所需的全部钩子。 + +**将状态切换写入表层消息,或把计划存入文件。** 不予采纳,因为协作状态是仅日志事实,工具参数已经记录了可评审的计划。重复写入表层会消耗模型上下文,而计划目录会形成第二个持久归属。 + +**按 plan 专用名称允许列表或全局策略栈筛选工具。** 不予采纳,因为可变性是每个工具自身的属性,包括未来工具和 MCP 工具,而不应由每个 plan 部署维护一份列表。只有出现具体消费方后,effects 元数据才能建立共享策略;在此之前,plan mode 是引导机制,不是安全边界。 + +**通过审批 seam 或普通文本完成评审。** 不予采纳,因为计划评审不是权限决策,需要精确的计划产物和纠正性自由文本,而且必须以已记录的工具调用作为结构化转换。用户交互 seam 提供了这项契约。 + ## 验证 - 包测试通过布尔服务继续覆盖边界顺序、重试、追加失败、HMR(热模块替换)资源释放、提示词组装、稳定的原生 schema 与 Code Mode schema、评审结果和不变式。 - 命令测试覆盖不带参数的 `/plan`、`/plan `、激活状态下的 `/plan off`、取消待生效的进入选择、未激活状态下的幂等性、不存在 `/mode` 和 `/review`,以及随 effect 作用域移除。 - 无密钥 TUI 场景通过 `/plan ` 进入、通过 `/plan off` 退出,并证明每个已提交的 `plan/mode` 都先于其所改变的请求头,进入消息在 plan 引导下记录到日志,且退出后的请求不含该引导。 +- 完整的 `exit_plan_mode` 评审流程有包测试,但交互式 ACP 场景退役后没有组装应用快照;当前无密钥 TUI 场景只覆盖命令进入和直接退出。 ## 后果 该实现只用一套词汇描述一项已交付功能。若要添加另一种协作方式,必须显式作出设计决策,而不能只增加配置项;自动化客户端不会通过 ACP 获得面向人类的模式控制。根据仓库的预发布格式策略,本次迁移有意拒绝旧的 `mode/set` 日志与 `modes.plan.section` 配置。 + +Plan 状态仍可重建,工具 schema 仍保持稳定,但如果进程在下一边界前退出,空闲状态下待生效的选择会丢失。进入或离开 plan mode 会改变提示词顺序 50 处及其后的内容;如果模型忽略引导,仍可能执行修改,除非部署另行配置沙箱、审批或文件系统策略。 diff --git a/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.i18n.yaml index 3e4286afec..fbe06d209f 100644 --- a/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.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 -2026-07-22-tui-titles-from-session-title-service.md: 735c940dbb8a84104ab4320d5c535b41690953d5 -2026-07-22-tui-titles-from-session-title-service.zh.md: 8e7e3ef070cc6518476fe0f53355cb0705e74c6a +2026-07-22-tui-titles-from-session-title-service.md: 04355b9c426af423dec347997f3b8ac62483eb7f +2026-07-22-tui-titles-from-session-title-service.zh.md: 5fc783c5ca08baba12a60f2aa6b5e286307aa9f0 diff --git a/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.md b/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.md index 735c940dbb..04355b9c42 100644 --- a/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.md +++ b/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.md @@ -6,11 +6,11 @@ English | [中文](2026-07-22-tui-titles-from-session-title-service.zh.md) ## Problem -Two model-title implementations coexisted after the tui-staging line merged onto master. The TUI carried its own `autoTitle` feature: a fire-and-forget `ctx.llm.stream` call after the first user message that set the terminal window title via OSC 0, with a one-shot latch, its own prompt, its own 40-character cap, and its own resume re-derivation ([auto-title Agent Note](../feature/2026-07-21-tui-auto-pane-title.md), [default-on Agent Note](../feature/2026-07-21-tui-auto-title-default-on.md)). Master had meanwhile landed [log-backed session titles](../feature/2026-07-21-log-backed-session-titles.md): a `sessionTitle` capability whose accepted revisions are durable `session/title` events, with a deterministic fallback and optional model providers. The TUI already consumed `session/title` for its header subtitle and window title, so a session could be titled twice by different strategies, and the TUI's process-local title was invisible to resume listings, forks, and Web consumers. +A per-session title makes terminal panes and tabs distinguishable, but a TUI-local model call would create a second title pipeline beside [log-backed session titles](../feature/2026-07-21-log-backed-session-titles.md). The local path needs its own prompt, cap, one-shot latch, resume derivation, cancellation, and failure fallback, while its process-local result remains invisible to session listings, forks, Web consumers, and replay. If both paths run, one session can also be titled twice by different strategies. ## Decision -The TUI-local generation is removed; the session-title service is the one title source. `TuiConfig.autoTitle`, the latch, the abort controller, the title prompt, and `titleLine` are gone from `dsh-tui`. The terminal rename stays: the TUI folds the latest logged title on mount (`foldSessionTitle`), renders it as the banner subtitle, and sets the terminal window title to `` on every accepted `session/title` event — including resumed sessions, whose titles now replay from the log instead of being re-generated. +The session-title service is the one title source. The TUI contains no `autoTitle` config, title-model request, latch, abort controller, prompt, or output cap. It folds the latest logged title on mount (`foldSessionTitle`), renders it as the banner subtitle, and calls `runtime.terminal.setTitle` with `` on every accepted `session/title` event. The same terminal-safe OSC 0 path handles the configured fallback title, resumed sessions, and live revisions without renaming tmux windows or adding another terminal-control surface. Model-made titles are a composition choice: `examples/tui-agent/cordis.yml` (and the scripted PTY fixture) mount `@deepseek-ai/dsh-session-title-first-message-llm`, which inherits the main request's route and replaces the spine's deterministic fallback with a short model summary. Deployments without the provider keep the fallback title from `dsh-agent-spine-demo`'s bundled `SessionTitleService`. @@ -20,6 +20,16 @@ Model-made titles are a composition choice: `examples/tui-agent/cordis.yml` (and **Port auto-title's prompt and cap into the service as a third provider.** The first-message-llm provider already exists with the same cadence, a reviewed prompt contract, durable request records, and supersession fencing; a second near-identical provider would be pure duplication. +**Use only a truncated first prompt or only a model title.** A deterministic fallback provides an immediate, free title, while an optional model provider improves quality without delaying the main turn. Forcing either strategy removes that deployment choice. + +**Make model titles a TUI default or block the first turn for them.** The cost and route belong to composition, and auxiliary title latency must stay off the interaction critical path. The TUI consumes accepted state instead of owning generation policy. + +**Rename a tmux window or use a separate terminal escape.** Rejected because the existing terminal adapter's OSC 0 path labels the pane or tab without acquiring tmux ownership or adding a second control API. + +## Verification + +TUI tests pin restored and live `session/title` consumption, terminal-safe title rendering, the configured fallback, and the absence of a TUI-owned model path. The keyless PTY smoke boots the real composition, accepts a logged provider title, and observes the resulting terminal title. The [log-backed title decision](../feature/2026-07-21-log-backed-session-titles.md) owns provider, persistence, resume, fork, cancellation, and stale-completion coverage. + ## Consequences -One title pipeline: durable, replayable, visible to every consumer, and fenced against stale completions by the service. The TUI sheds ~90 lines and its `llm`-streaming path. The cost is that a title now requires the provider plugin in the composition for model quality — a leaf choice, not a TUI default — and the terminal title changes shape from the bare model summary to the suffixed ` — <product>` form the log-backed path always used. The superseded auto-title Agent Notes carry pointers here. +One title pipeline is durable, replayable, visible to every consumer, and fenced against stale completions by the service. The TUI has no `llm`-streaming title path. Model quality requires a provider plugin in the composition, while deployments without one keep the deterministic fallback; the terminal title consistently uses the suffixed `<title> — <product>` shape. diff --git a/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.zh.md b/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.zh.md index 8e7e3ef070..5fc783c5ca 100644 --- a/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.zh.md @@ -6,11 +6,11 @@ Status: implemented ## 问题 -tui-staging 分支合入 master 后,两套模型标题实现并存。TUI 自带 `autoTitle` 特性:在首条用户消息后发起一次 fire-and-forget 的 `ctx.llm.stream` 调用,通过 OSC 0 设置终端窗口标题,带有一次性闩锁、自己的提示词、自己的 40 字符截断和自己的恢复重推导([auto-title Agent Note](../feature/2026-07-21-tui-auto-pane-title.md)、[default-on Agent Note](../feature/2026-07-21-tui-auto-title-default-on.md))。而 master 已落地[日志承载的会话标题](../feature/2026-07-21-log-backed-session-titles.md):一个 `sessionTitle` 能力,其被接受的修订是持久的 `session/title` 事件,带确定性回退和可选的模型 provider。TUI 已经消费 `session/title` 作为横幅副标题和窗口标题,于是一个会话可能被两种策略各标题一次,且 TUI 的进程本地标题对恢复列表、fork 和 Web 消费方不可见。 +每会话标题让终端窗格和标签页易于区分,但 TUI 本地模型调用会在[日志承载的会话标题](../feature/2026-07-21-log-backed-session-titles.md)旁形成第二条标题管线。本地路径需要自己的提示词、截断上限、一次性闩锁、恢复推导、取消和失败回退,而其进程本地结果仍对会话列表、fork、Web 消费方和回放不可见。若两条路径同时运行,同一会话还可能被不同策略命名两次。 ## 决策 -移除 TUI 本地生成;session-title 服务是唯一的标题来源。`TuiConfig.autoTitle`、闩锁、abort controller、标题提示词和 `titleLine` 全部从 `dsh-tui` 删除。终端重命名保留:TUI 在挂载时折叠最新的已记录标题(`foldSessionTitle`),将其渲染为横幅副标题,并在每个被接受的 `session/title` 事件上把终端窗口标题设为 `<会话标题> — <配置标题>` —— 包括恢复的会话,其标题现在从日志回放而不是重新生成。 +session-title 服务是唯一的标题来源。TUI 不包含 `autoTitle` 配置、标题模型请求、闩锁、abort controller、提示词或输出上限。TUI 在挂载时折叠最新的已记录标题(`foldSessionTitle`),将其渲染为横幅副标题,并在每个被接受的 `session/title` 事件上调用 `runtime.terminal.setTitle`,传入 `<session title> — <configured title>`。同一条终端安全的 OSC 0 路径会处理配置的回退标题、恢复的会话和实时修订,既不重命名 tmux 窗口,也不增加另一套终端控制接口。 模型生成的标题是组合选择:`examples/tui-agent/cordis.yml`(以及脚本化 PTY fixture)挂载 `@deepseek-ai/dsh-session-title-first-message-llm`,它继承主请求的确切路由,用简短的模型摘要替换 spine 的确定性回退。未挂载该 provider 的部署保留 `dsh-agent-spine-demo` 内置 `SessionTitleService` 的回退标题。 @@ -20,6 +20,16 @@ tui-staging 分支合入 master 后,两套模型标题实现并存。TUI 自 **把 auto-title 的提示词和截断移植为服务的第三个 provider。** first-message-llm provider 已经存在,节奏相同,且有经过评审的提示词契约、持久的请求记录和替换围栏;再造一个近乎相同的 provider 纯属重复。 +**只使用截断后的首条提示词,或只使用模型标题。** 确定性回退可以立即且免费地提供标题,而可选模型 provider 可以提升质量,不会延迟主轮次。强制采用任一种策略都会移除这项部署选择。 + +**让模型标题成为 TUI 默认行为,或为此阻塞第一个轮次。** 成本与路由归组合所有,辅助标题的延迟不得进入交互关键路径。TUI 只消费已接受的状态,不拥有生成策略。 + +**重命名 tmux 窗口,或使用另一种终端转义序列。** 不予采纳,因为现有终端适配器的 OSC 0 路径可以标记窗格或标签页,无需取得 tmux 归属,也无需增加第二套控制 API。 + +## 验证 + +TUI 测试锁定恢复后和实时的 `session/title` 消费、终端安全的标题渲染、配置的回退标题,以及不存在 TUI 自有模型路径。无密钥 PTY 冒烟测试启动真实组合,接收已记录的 provider 标题,并观察由此产生的终端标题。[日志承载标题决策](../feature/2026-07-21-log-backed-session-titles.md)拥有 provider、持久化、恢复、fork、取消和陈旧完成结果的覆盖。 + ## 影响 -标题管线归一:持久、可回放、对所有消费者可见,并由服务对过期完成设防。TUI 削减约 90 行及其 `llm` 流式路径。代价是模型质量的标题现在需要在组合中挂载 provider 插件 —— 这是叶配置选择,不是 TUI 默认值 —— 且终端标题形状从裸模型摘要变为日志路径一贯使用的 `<标题> — <产品>` 后缀形式。被取代的 auto-title Agent Note 携带指向本文的指针。 +唯一的标题管线持久、可回放、对所有消费方可见,并由服务防止陈旧完成结果生效。TUI 不再有 `llm` 流式标题路径。若要提升模型标题质量,组合中必须挂载 provider 插件;未挂载的部署保留确定性回退。终端标题始终采用 `<title> — <product>` 后缀形式。 diff --git a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml index 2901a3b813..fe203d8f0f 100644 --- a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-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 -2026-07-23-acp-automation-only-protocol.md: 2a92f306065b348764f35e1e63f0d7750a636372 -2026-07-23-acp-automation-only-protocol.zh.md: 5889d668310e3bd63f3934a39d4e5250f83f063d +2026-07-23-acp-automation-only-protocol.md: 0fe2fc27a963d21e8a24c1682359ab3bc9e7af48 +2026-07-23-acp-automation-only-protocol.zh.md: 0a471f0bf1b12e835660cdce2d2a2acd761e1b89 diff --git a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md index 2a92f30606..0fe2fc27a9 100644 --- a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md +++ b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md @@ -18,20 +18,26 @@ The snapshot suite complicates removal. Most ACP scenarios exercise the assemble The bridge emits only committed `assistant/message` text. Reasoning, raw chunks, tool activity, todos, plans, titles, retry markers, terminal metadata, diffs, locations, and resource links remain in the durable session log or in UI-specific transports. It does not provide session load/list/delete, commands, modes, configuration selectors, model switching, plan review, or human elicitation. -One-shot `session/request_permission` remains. It is a machine policy channel for bridge-owned agents, not a human approval UI: the client chooses allow once, reject once, or cancel, and the bridge never turns that response into a durable grant. [`dsh-subagent-acp`](../../../../packages/subagent/subagent-acp/README.md) uses this channel programmatically. +One-shot `session/request_permission` remains. It is a machine policy channel for bridge-owned agents, not a human approval UI: the answerer accepts only an exact agent object in the bridge's live session map, delegates foreign or call-less requests, and maps failed RPCs to the fail-closed unavailable outcome. The client chooses allow once, reject once, or cancel, and the bridge never turns that response into a durable grant. Asking policy stays in the approval seam and its producers; [`dsh-subagent-acp`](../../../../packages/subagent/subagent-acp/README.md) uses this channel programmatically. The app composition contains the agent spine, persistence, checkpoint policy, and ACP transport. It does not mount command, session-query, session-reference, plan-mode, permission-picker, or user-interaction services for ACP. SDK scaffolding likewise treats `ask_user_question` as TUI-only. +The transport programs interface-level agent, session, and approval services rather than the concrete agent loop. Tool execution stays inside the harness; ACP never delegates shell execution to an editor. stdout carries framed JSON-RPC only, so the app mounts no stdout logger and the bridge does not monkey-patch process output. + Disconnect and plugin disposal share one memoized quiescence boundary. Both successful and failed transport closure settle pending prompts as cancelled, dispose every bridge-owned agent, and await loop and session cleanup. A create that loses the close race disposes its unpublished handle. ## Snapshot boundary The ACP snapshot suite still boots the assembled ACP example and retains scenarios that pin backend behavior. Only scenarios driven through deleted UI methods leave the suite; semantic-checkpoint recovery runs through the headless `stream-json` example because ACP no longer loads sessions. +Protocol and lifecycle tests pin stop-reason and prompt codecs, version negotiation, fresh-session creation, text and resource-link flattening, rejection of empty or unsupported prompts, exact-agent permission ownership, multi-session isolation, prompt settlement, per-session cancellation, failed transport closure, ACP-only reload cleanup, and teardown quiescence. Built and real-stdio smokes reject stray stdout. The `session/new` branch that loses a real stdio close race remains coverage-exempt because the in-memory transport cannot reproduce that ordering; it disposes the unpublished handle, while the surrounding disposal tests pin the no-orphan invariant. + ## Alternatives considered **Keep ACP as an editor UI until Web reaches parity.** Rejected because it leaves two interactive contracts to evolve and keeps editor conventions in the automation boundary. +**Keep the earlier editor bridge behind disciplined seams.** Rejected even though that bridge correctly used interface services, tool-owned render intents, approval and user-interaction answerers, harness-owned execution, and a stdout-pure composition. Its terminal cards were capability-gated, display-only Zed `_meta` projections with a text fallback rather than ACP `terminal/create`, so shell execution never left the harness. The projection derived each display terminal id from the stable per-call id to prevent collisions and recovered exit code or signal from the rendered status markers because the pure result presenter received content blocks rather than a structured exit; marker round-trip tests and an explicit no-capability `console` fallback test pinned both contracts. Those boundaries were coherent but could not make editor cards, session navigation, configuration pickers, and human elicitation belong in an automation protocol. + **Replace ACP with a private subagent RPC.** Rejected because ACP already supplies a typed, interoperable process protocol and is used by the out-of-process subagent backend. **Remove machine permission requests with the other interaction features.** Rejected because an automated parent must answer a child agent's one-shot policy decision; this is control flow between agents, not presentation. diff --git a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.zh.md b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.zh.md index 5889d66831..0a471f0bf1 100644 --- a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.zh.md @@ -18,20 +18,26 @@ ACP 仍有一个有用的职责:另一个 agent(智能体)或自动化控 桥接层只发出已提交的 `assistant/message` 文本。推理、原始分片、工具活动、待办事项、计划、标题、重试标记、终端元数据、diff、位置和资源链接仍保留在持久会话日志或 UI 专用传输层中。它不提供会话加载、列出与删除、命令、模式、配置选择器、模型切换、plan 评审或面向人类的询问。 -保留一次性 `session/request_permission`。它是为桥接层拥有的 agent 提供的机器策略通道,而不是面向人类的审批 UI:客户端可选择允许一次、拒绝一次或取消,桥接层绝不会将该响应转换为持久授权。[`dsh-subagent-acp`](../../../../packages/subagent/subagent-acp/README.md) 会以程序化方式使用该通道。 +保留一次性 `session/request_permission`。它是为桥接层拥有的 agent 提供的机器策略通道,而不是面向人类的审批 UI:应答者只接受桥接层当前会话映射中的精确 agent 对象;外部请求或缺少调用标识的请求会继续委派;RPC 失败则映射为失败关闭的 `unavailable` 结果。客户端可选择允许一次、拒绝一次或取消,桥接层绝不会将该响应转换为持久授权。询问策略仍归审批 seam 及其生产者所有;[`dsh-subagent-acp`](../../../../packages/subagent/subagent-acp/README.md) 会以程序化方式使用该通道。 应用组装包含 agent 主干、持久化、检查点策略和 ACP 传输层。它不会为 ACP 挂载命令、会话查询、会话引用、plan mode、权限选择器或用户交互服务。SDK 脚手架同样将 `ask_user_question` 视为 TUI 专属功能。 +传输层调用 agent、会话和审批的接口服务,而不依赖具体的 agent loop。工具执行仍留在 harness 内;ACP 绝不会把 shell 执行委派给编辑器。stdout 只承载分帧 JSON-RPC,因此 app 不挂载 stdout logger,桥接层也不会 monkey-patch 进程输出。 + 断开连接与插件 dispose(资源释放)共享同一个经记忆化处理的静止边界。传输关闭无论成功还是失败,都会将待处理提示词以已取消状态结算,dispose 每个由桥接层拥有的 agent,并等待循环和会话清理完成。创建流程如果在与关闭的竞态中落败,就会 dispose 其尚未发布的 handle。 ## 快照边界 ACP 快照套件仍会启动组装后的 ACP 示例,并保留用于锁定后端行为的场景。从该套件移出的只有通过已删除的 UI 方法驱动的场景;由于 ACP 不再加载会话,语义检查点恢复通过 headless `stream-json` 示例执行。 +协议与生命周期测试会锁定停止原因编解码器和提示词编解码器、版本协商、新会话创建、文本与资源链接展平、拒绝空提示词或不受支持的提示词、精确 agent 权限归属、多会话隔离、提示词结算、按会话取消、传输关闭失败、ACP 专属重载清理,以及拆卸完全停稳。构建产物冒烟测试与真实 stdio 冒烟测试会拒绝混入 stdout 的额外输出。`session/new` 中在真实 stdio 关闭竞态中落败的分支仍属于覆盖豁免,因为内存传输层无法复现这一顺序;该分支会 dispose 尚未发布的 handle,而周边 dispose 测试会锁定无遗留资源不变式。 + ## 考虑过的替代方案 **在 Web 达到同等能力前,继续将 ACP 作为编辑器 UI。** 不予采用,因为这会留下两套需要演进的交互契约,并使编辑器约定继续存在于自动化边界中。 +**通过严格的 seam 保留早期编辑器桥接层。** 不予采用,尽管该桥接层正确使用了接口服务、工具自有的 render intent、审批与用户交互应答者、harness 自有执行,以及保持 stdout 纯净的组装。其终端卡片是经过能力门控、仅用于展示的 Zed `_meta` 投影,并提供文本回退,而非使用 ACP `terminal/create`,因此 shell 执行从未离开 harness。该投影从稳定的逐调用 id 派生每个展示用终端 id,以避免冲突;由于纯结果展示器接收的是内容块,而不是结构化退出信息,它会从渲染后的状态标记中恢复退出码或信号。标记往返测试和显式的无能力 `console` 回退测试锁定了这两项契约。这些边界保持一致,却无法让编辑器卡片、会话导航、配置选择器和面向人类的询问成为自动化协议应有的职责。 + **用私有 subagent RPC 替换 ACP。** 不予采用,因为 ACP 已经提供类型化、可互操作的进程协议,并由跨进程 subagent 后端使用。 **随其他交互功能一起移除机器权限请求。** 不予采用,因为自动化父 agent 必须回答子 agent 的一次性策略决策;这是 agent 之间的控制流,而不是展示层。 diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml new file mode 100644 index 0000000000..ad6c575d1e --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.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 +2026-07-25-client-settings-locale-theme.md: 87077b3fd3f0bd8a3375a71aebf947cbd9961799 +2026-07-25-client-settings-locale-theme.zh.md: a64a4afdf6565a527a25136694aa79305eeabb3c diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md new file mode 100644 index 0000000000..87077b3fd3 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md @@ -0,0 +1,130 @@ +# Agent Note: Client Settings, Locale, and Theme layering + +Status: proposed + +English | [中文](2026-07-25-client-settings-locale-theme.zh.md) + +## Problem + +The browser client's existing Settings is written directly inside the Sidebar, and language and theme are applied by component-local state mutating the DOM directly. As a result Settings cannot be extended by independent plugins, preference state has no stable cross-plugin service contract, and the theme registry carries both state and presentation responsibilities. + +## Proposal + +**Collaboration doctrine (how every later module joins Settings): feature owners self-register.** The Settings shell is a pure composition surface: it only declares slots and renders the chrome structure — zero copy, no locale dependency, and neither importing nor enumerating any feature; for a feature to appear in Settings, its own plugin registers into the corresponding slot — locale registers the Language row, ui-theme registers the Appearance row, ui-models registers the Models top-level panel. No separate `ui-settings-*` package is created for "a feature's settings page": the settings surface belongs to the feature package itself (shipping the Theme feature means Theme's settings choices ship with ui-theme). Content that belongs to no single feature (the trigger/title/close chrome copy, the General directory with its skeleton rows, the `settings` dictionary) is owned by `ui-settings-general` — the owner of the ownerless copy, not a feature satellite package. + +The Sidebar declares the `sidebar.settings` single slot; `ui-settings` occupies it and declares four slots: `settings.trigger` / `settings.header` / `settings.close` (chrome content seats, single) and `settings.section` (top-level pages, list). Accessible names all resolve from slot content: the trigger's accessible name is its text content, the dialog points at the header content node via aria-labelledby, and close is a visually hidden text seat. Each section is contributed by a feature plugin; the shell only reads entry metadata from the slot ledger to build the navigation, rendering the current section via `only`. General is registered by `ui-settings-general` (order 0) and declares the `settings.general.item` list slot, into which the feature plugins' preference rows slot by order. + +The Settings entry is the Settings row in the sidebar Foot; clicking it directly opens a 1080×700 centered overlay (black 24% mask); the close button, a mask click, and ESC all close it. There is no intermediate menu form of any kind. + +`@deepseek-ai/dsh-client-locale` provides `ctx.locale`; `ui-theme` provides `ctx.theme`. Both services read through a getter, write through a setter, and publish immutable snapshots via typed Cordis change events; each service persists its own preference (storing only the id, with bad values falling back to the default). + +Each feature row's apply layer subscribes to its own change event (locale to `locale/change`, ui-theme to `theme/change`) and projects the snapshot into the slot store declared when that row registered. React components only read `useStore` and write through the injected setter callbacks, never reading ctx or the services. + +The theme preference has three states — `light`, `dark`, `system` — defaulting to `system` (when no persisted preference exists or the value is bad). Resolving system belongs to the theme domain: ThemeService holds the `prefers-color-scheme` matchMedia listener (environment sensing, not DOM presentation) and re-emits the snapshot when the preference is system and the system color scheme changes; the snapshot carries both `preference` and the resolved `active` definition. + +The theme service never touches the DOM. `ui-layout` reads the Theme getter initially and then subscribes to `theme/change`; the presenter owned by Layout updates `body[data-ds-dark-theme]` and the theme tokens according to `active`. The presenter has no notion of system — it consumes only resolved results. + +### First-phase registration surfaces + +| Registration surface | Owning plugin | First-phase content | +|---|---|---| +| chrome content (trigger/header/close) | `ui-settings-general` | Settings entry-row icon and copy, panel title, close hidden text | +| General section (order 0) | `ui-settings-general` | Permission and Tool Call visual skeletons (no write operations) plus the `settings.general.item` slot declaration | +| Language row (item order 0) | `locale` | Selector dropdown; 中文/English genuinely switch | +| Appearance row (item order 10) | `ui-theme` | Light/Dark/System three cubes genuinely switch (the selected state reflects preference) | +| Models section (order 10) | `ui-models` | Navigation item only, with an empty content area; later model-management features land in that package | +| Plugin | none | Not built this phase, and the navigation does not show the item (once a later plugin feature package registers the section it appears automatically) | + +The first phase localizes only the copy inside the Settings overlay; dictionaries stay close to their owners — the chrome plus the General skeletons live in `ui-settings-general`'s `settings` namespace, and feature-row copy lives in each feature package (`settings.locale`, `settings.theme`, `settings.models`). + +### Slot topology + +```text +root +└─ sidebar + └─ sidebar.settings single/root + └─ ui-settings(壳,零文案) + ├─ settings.trigger single/root ui-settings-general 注册 + ├─ settings.header single/root ui-settings-general 注册 + ├─ settings.close single/root ui-settings-general 注册 + └─ settings.section list/root + ├─ general (order 0) ui-settings-general 注册 + │ └─ settings.general.item list/root + │ ├─ language (0) locale 注册 + │ └─ appearance (10) ui-theme 注册 + └─ models (order 10) ui-models 注册 +``` + +Section and item contributions both use declaration-aware deferral (ui-slots' `deferRegistration()`: ledger-judged presence, `refresh()` for localized labels, one-call disposal) and do not depend on the client manifest's apply order. The SlotMap types split homes: trigger/header/close/section have their canonical home in the ui-settings contract (the consumers, general and models, both depend on the shell — no cycle); `settings.general.item`'s canonical home is the locale package — it is the lowest common dependency of all item registrants (a settings row always carries copy), while the declarer general's contract is unreachable from locale/ui-theme (it would form a cycle); ui-theme consumes it through a re-export seam. + +### Future work: promote slot declarations to first-class injectable waits + +`deferRegistration()` is behaviorally isomorphic to `ctx.inject` — one waits on a ledger declaration, the other on service presence, with matching disappear/reappear lifecycle semantics; the difference is that the fiber form's disposer lifetime naturally equals the declaration's lifetime, so the stale-disposer presence-judging machinery disappears entirely. Direction (a separate PR): SlotsService bridges each slot into a `slot:<name>` service (value = the slot spec) at declaration commit / cascade removal, registrants migrate from `deferRegistration()` to a nested `ctx.inject(['slot:<name>'], cb)`, then `deferRegistration()` is deleted and packages/client/AGENTS.md checklist item 4 is rewritten. Boundaries to pin down: the nested fiber's harmless wait must not be named by the boot fail-loud scan (needs a test); the `slot:` namespace and the silent-wait-on-typo stance; provide keys are flat names (`slot:a.b` is one key, not a property path on `ctx.slots`). This phase keeps the `deferRegistration()` function form. + +### Service contracts + +```ts +export type ThemePreference = 'light' | 'dark' | 'system' + +export interface ThemeDefinition { + id: string + colorScheme: 'light' | 'dark' + tokens: Record<string, string> +} + +export interface ThemeSnapshot { + preference: ThemePreference + active: ThemeDefinition // system 已解析为具体 light/dark 定义 + themes: readonly ThemeDefinition[] + revision: number +} + +export interface LocaleDefinition { + id: 'zh' | 'en' + label: string +} + +export interface LocaleSnapshot { + active: 'zh' | 'en' + locales: readonly LocaleDefinition[] + revision: number +} + +export interface Events { + /** @param snapshot - Current locale registry snapshot. @mode emit */ + 'locale/change'(snapshot: LocaleSnapshot): void + /** @param snapshot - Current theme registry snapshot. @mode emit */ + 'theme/change'(snapshot: ThemeSnapshot): void +} +``` + +Locale ships with 中文 and English built in; `setLocale`/`setTheme` are the only write entry points, and an unknown id fails. + +## Alternatives considered + +**Having the app shell subscribe to preferences centrally and re-render the root slot tree.** A language or theme change only needs to update the actual consumers; a whole-tree refresh amplifies the blast radius and wires business preferences into the shell. + +**The theme service mutating the DOM directly.** The registry service would then depend on the presentation environment, with unclear lifecycle and global-style ownership; Layout already owns the page-root presentation boundary. + +**Resolving system in the Layout presenter.** The presenter would need its own matchMedia subscription and would pick the concrete definition out of the themes list, forcing the presentation layer to understand preference semantics; resolving on the service side gives every consumer the same resolved snapshot. + +**Settings importing and enumerating the sections.** Adding a page would require modifying the shell plugin, breaking the composition model where each feature occupies a slot from its own plugin. + +**A per-feature `ui-settings-*` satellite package for each section.** It divorces the settings surface from the feature itself: changing Theme behavior touches two packages, the package count grows linearly with settings items, and the satellite packages depending back on the locale/theme services form an intermediate layer that exists purely for the package split. Under feature-owner self-registration that layer does not exist: preference rows ship with their feature packages, and `ui-settings-general` takes in only the ownerless copy (the chrome and the General skeletons), carrying no feature's settings surface. + +**Injecting the Locale/Theme snapshots into React directly.** Inject results are cached by entry identity, so volatile values go stale; hand-rolling a React hook per service also bypasses the slot store's unified binding. + +## Acceptance criteria + +- The Settings shell depends only on the slot ledger, never on any feature implementation; General's item list likewise depends only on the ledger. +- Adding a settings item = the feature package registering it itself (a section or a general item), with zero shell changes. +- Locale and Theme writes go only through the setters; ongoing synchronization goes only through the change events. +- Each feature row's store initializes from the getter and is thereafter updated by its own change event with local re-renders. +- Layout applies the theme snapshot on its own and the theme service never accesses the DOM; no system branch appears in the presenter. +- 中文/English and Light/Dark/System switch and are restored after a refresh; with the preference on system, a system color-scheme change takes effect immediately. +- Models has only a navigation item and an empty content area; the Permission and Tool Call skeletons perform no writes. +- The overlay closes via the close button, a mask click, and ESC. + +## Risks + +The apply order of slot declarations and contributions is not fixed, so every section/item registrant must keep declaration-aware registration and judge presence by the ledger, not by a local disposer. Service events may fire before a row's first render, so both a feature row store's init and the inject attach must align to the current snapshot from the getter. The duplicated merge copies of `settings.general.item` (locale, ui-theme) must stay verbatim-identical to the ui-settings canonical home — any drift means changing all three together. Layout must clean up the global attributes it set on unmount, and ThemeService must remove its matchMedia listener on dispose, so nothing lingers after HMR. diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md new file mode 100644 index 0000000000..a64a4afdf6 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md @@ -0,0 +1,130 @@ +# Agent Note: Client Settings、Locale 与 Theme 分层 + +Status: proposed + +[English](2026-07-25-client-settings-locale-theme.md) | 中文 + +## Problem + +浏览器端已有的 Settings 直接写在 Sidebar 内,语言和主题也由组件本地状态直接改 DOM。这使 Settings 无法由独立插件扩展,偏好状态没有稳定的跨插件服务契约,主题 registry 同时承担状态与呈现职责。 + +## Proposal + +**协作导向(后续所有模块接入 Settings 的方式):功能属主自注册。** Settings 壳是纯组合面:只声明坑位、渲染 chrome 结构,零文案、不依赖 locale、不 import 也不枚举任何功能;一个功能要出现在 Settings 里,由它自己的插件向对应坑位注册——locale 注册 Language 行,ui-theme 注册 Appearance 行,ui-models 注册 Models 一级面板。不为「某功能的设置页」单开 `ui-settings-*` 包:设置面属于功能包本身(做 Theme 功能,Theme 的设置选择就随 ui-theme 一起交付)。不属于任何单一功能的内容(trigger/标题/close 的 chrome 文案、General 目录与骨架行、`settings` 字典)由 `ui-settings-general` 拥有——它是「无主文案」的属主,不是功能卫星包。 + +Sidebar 声明 `sidebar.settings` 单坑位,`ui-settings` 占用它并声明四个坑:`settings.trigger` / `settings.header` / `settings.close`(chrome 内容座,single)与 `settings.section`(一级页面,list)。无障碍名全部解析自坑内容:trigger 的可达名即其文本内容,dialog 经 aria-labelledby 指向 header 内容节点,close 是视觉隐藏文本座。每个 section 由功能插件贡献;壳只从 slot ledger 读取 entry metadata 生成导航,通过 `only` 渲染当前 section。General 由 `ui-settings-general` 注册(order 0)并声明 `settings.general.item` list 坑位,功能插件的偏好行按 order 排入。 + +Settings 入口是 sidebar Foot 的 Settings 行,点击直接打开 1080×700 居中浮层(黑 24% 遮罩);close 按钮、点击遮罩、ESC 均关闭。无任何中间菜单形态。 + +`@deepseek-ai/dsh-client-locale` 提供 `ctx.locale`,`ui-theme` 提供 `ctx.theme`。两个 service 都以 getter 读取、setter 写入并用 typed Cordis change event 发布 immutable snapshot;service 自己持久化偏好(只存 id,坏值回退默认)。 + +功能行的 apply 层各自订阅自家 change event(locale 订 `locale/change`,ui-theme 订 `theme/change`),把 snapshot 投影到该行注册时声明的 slot store。React 组件只读 `useStore`、写注入的 setter callback,不读取 ctx 或 service。 + +Theme 偏好三态:`light`、`dark`、`system`,默认 `system`(无持久化偏好或坏值时)。system 的解析属主题领域:ThemeService 持有 `prefers-color-scheme` matchMedia 监听(环境感知,非 DOM 呈现),偏好为 system 且系统配色变化时重发 snapshot;snapshot 同时携带 `preference` 与解析后的 `active` 定义。 + +Theme service 不操作 DOM。`ui-layout` 初始读取 Theme getter,随后订阅 `theme/change`,由 Layout 持有的 presenter 按 `active` 更新 `body[data-ds-dark-theme]` 和主题 token;presenter 不感知 system,只消费已解析结果。 + +### 首期注册面 + +| 注册面 | 属主插件 | 首期内容 | +|---|---|---| +| chrome 内容(trigger/header/close)| `ui-settings-general` | 设置入口行图标+文案、面板标题、close 隐藏文本 | +| General section(order 0)| `ui-settings-general` | Permission、Tool Call 视觉骨架(无写操作)+ `settings.general.item` 坑位声明 | +| Language 行(item order 0)| `locale` | Selector 下拉,中文/English 真实可切 | +| Appearance 行(item order 10)| `ui-theme` | Light/Dark/System 三 cube 真实可切(选中态看 preference) | +| Models section(order 10)| `ui-models` | 仅导航项,内容区为空;后续模型管理功能落在该包 | +| Plugin | 无 | 首期不做,导航不出现该项(后续插件功能包注册 section 即自动出现) | + +首期只翻译 Settings 浮层内文案;字典就近——chrome + General 骨架归 `ui-settings-general` 的 `settings` namespace,功能行文案归各功能包(`settings.locale`、`settings.theme`、`settings.models`)。 + +### Slot topology + +```text +root +└─ sidebar + └─ sidebar.settings single/root + └─ ui-settings(壳,零文案) + ├─ settings.trigger single/root ui-settings-general 注册 + ├─ settings.header single/root ui-settings-general 注册 + ├─ settings.close single/root ui-settings-general 注册 + └─ settings.section list/root + ├─ general (order 0) ui-settings-general 注册 + │ └─ settings.general.item list/root + │ ├─ language (0) locale 注册 + │ └─ appearance (10) ui-theme 注册 + └─ models (order 10) ui-models 注册 +``` + +section/item contribution 均使用 declaration-aware deferral(ui-slots 的 `deferRegistration()`:ledger 判在位、`refresh()` 换本地化 label、一键 dispose),不依赖 client manifest 的 apply 顺序。SlotMap 类型分家:trigger/header/close/section 正家在 ui-settings contract(消费者 general/models 均依赖壳,无环);`settings.general.item` 正家在 locale 包——它是全部 item 注册方的最低公共依赖(设置行必带文案),而声明方 general 的 contract 对 locale/ui-theme 不可达(会成环);ui-theme 经 re-export seam 消费。 + +### Future work:坑位声明升格为可 inject 的一等等待物 + +`deferRegistration()` 与 `ctx.inject` 行为同构——一个等 ledger 声明、一个等服务在场,消失/重现的生命周期语义一致;差别只在 fiber 版的 disposer 生命周期天然等于声明生命周期,stale-disposer 判在位机器可整体消失。方向(另开 PR):SlotsService 在声明落账/级联拆除处把每个坑位桥接成 `slot:<name>` 服务(value 为坑位 spec),注册方从 `deferRegistration()` 迁为嵌套 `ctx.inject(['slot:<name>'], cb)`,随后删除 `deferRegistration()` 并改写 packages/client/AGENTS.md checklist 第 4 条。待钉死的边界:嵌套 fiber 的无害等待不被 boot fail-loud 扫描点名(需测试);`slot:` 名字空间与 typo 静默等待的口径;provide 键是平面名(`slot:a.b` 是一个键,不是 `ctx.slots` 的属性路径)。本期维持 `deferRegistration()` 函数形式。 + +### Service contracts + +```ts +export type ThemePreference = 'light' | 'dark' | 'system' + +export interface ThemeDefinition { + id: string + colorScheme: 'light' | 'dark' + tokens: Record<string, string> +} + +export interface ThemeSnapshot { + preference: ThemePreference + active: ThemeDefinition // system 已解析为具体 light/dark 定义 + themes: readonly ThemeDefinition[] + revision: number +} + +export interface LocaleDefinition { + id: 'zh' | 'en' + label: string +} + +export interface LocaleSnapshot { + active: 'zh' | 'en' + locales: readonly LocaleDefinition[] + revision: number +} + +export interface Events { + /** @param snapshot - Current locale registry snapshot. @mode emit */ + 'locale/change'(snapshot: LocaleSnapshot): void + /** @param snapshot - Current theme registry snapshot. @mode emit */ + 'theme/change'(snapshot: ThemeSnapshot): void +} +``` + +Locale 内置中文和 English;`setLocale`/`setTheme` 是唯一写入口,未知 id 失败。 + +## Alternatives considered + +**由 app shell 统一订阅偏好并重渲染 root slot tree。** 语言和主题变化只需要更新实际消费者;全树刷新放大影响面,也把业务偏好接入 shell。 + +**Theme service 直接修改 DOM。** registry service 因此依赖呈现环境,生命周期与全局样式所有权不清;Layout 已经拥有页面根呈现边界。 + +**system 由 Layout presenter 解析。** presenter 需自带 matchMedia 订阅并在 themes 列表里挑选具体定义,呈现层被迫理解偏好语义;解析放服务侧则所有消费者拿到一致的已解析 snapshot。 + +**Settings import 并枚举各 section。** 新增页面必须修改壳插件,破坏「每个功能由自己的插件占坑」的组合模型。 + +**按功能为每个 section 单开 `ui-settings-*` 卫星包。** 设置面与功能本体分家:改 Theme 行为要动两个包,包数随设置项线性膨胀,且卫星包反向依赖 locale/theme 服务,形成纯粹为拆包而生的中间层。功能属主自注册下不存在这层:preference 行随功能包交付;`ui-settings-general` 只收无主文案(chrome 与 General 骨架),不承载任何功能的设置面。 + +**把 Locale/Theme snapshot 直接注入 React。** inject 结果按 entry identity 缓存,易变值会陈旧;为每个 service 自造 React hook 也绕开 slot store 的统一绑定。 + +## Acceptance criteria + +- Settings 壳只依赖 slot ledger,不依赖任一功能实现;General 的 item 列表同样只依赖 ledger。 +- 新增一个设置项 = 功能包自己注册(section 或 general item),零壳改动。 +- Locale 与 Theme 的写入只走 setter,持续同步只走 change event。 +- 功能行 store 初始化走 getter,后续由自家 change event 更新并局部重渲染。 +- Layout 独立应用 Theme snapshot,Theme service 不访问 DOM;presenter 不出现 system 分支。 +- 中文/English 与 Light/Dark/System 能切换并刷新后恢复;偏好为 system 时系统配色变化即时生效。 +- Models 只有导航项与空内容区;Permission、Tool Call 骨架无写操作。 +- 浮层经 close 按钮、遮罩点击、ESC 均可关闭。 + +## Risks + +slot 声明与 contribution 的 apply 顺序不固定,所有 section/item 注册方必须保留 declaration-aware registration,并以 ledger(而非本地 disposer)判定在位。service event 可能早于行首次渲染,功能行 store 的 init 与 inject attach 都必须从 getter 对齐当前 snapshot。`settings.general.item` 的重复合并副本(locale、ui-theme)与 ui-settings 正家必须逐字一致,漂移即三处一起改。Layout 卸载时必须清理自己设置的全局属性,ThemeService dispose 时必须移除 matchMedia 监听,避免 HMR 后残留。 diff --git a/.agents/notes/implemented/feature/2026-07-20-tui-startup-slogans.i18n.yaml b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.i18n.yaml similarity index 64% rename from .agents/notes/implemented/feature/2026-07-20-tui-startup-slogans.i18n.yaml rename to .agents/notes/proposed/feature/2026-07-06-recallable-compaction.i18n.yaml index 3ed957d231..7dcdab8078 100644 --- a/.agents/notes/implemented/feature/2026-07-20-tui-startup-slogans.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.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 -2026-07-20-tui-startup-slogans.md: a2a22baafddd08145cec0d03b65ee56b2f8114b1 -2026-07-20-tui-startup-slogans.zh.md: 58fa5790f315845f27b810d62658bd79428b519b +2026-07-06-recallable-compaction.md: ed5491e642ea7ac99fd9f4ba071a61e655f968d3 +2026-07-06-recallable-compaction.zh.md: 4060df2c2550f9ea3287adfb51d097c1a60baf71 diff --git a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.md b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.md index 3f54030f9d..ed5491e642 100644 --- a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.md +++ b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.md @@ -2,6 +2,8 @@ Status: proposed +English | [中文](2026-07-06-recallable-compaction.zh.md) + ## Problem Compaction is a one-way door. The summary the model sees carries no reference to what it shadows — the `shadowedRange` provenance lives only on the log-only `compact/summary` event — and no tool lets the model read a shadowed span back. Whatever the summarizer drops is gone from the model's reachable world, even though the append-only log holds every byte. Repeated compaction compounds this: the head checkpoint is rewritten every pass, so the request prefix takes a full prompt-cache miss each time, and earlier summaries are re-summarized generation after generation. diff --git a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.zh.md b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.zh.md new file mode 100644 index 0000000000..4060df2c25 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.zh.md @@ -0,0 +1,110 @@ +# Agent Note: 可回溯压缩(compaction):索引检查点、状态检查点与会话内历史回溯 + +Status: proposed + +[English](2026-07-06-recallable-compaction.md) | 中文 + +## 问题 + +压缩是一扇单向门。模型看到的摘要没有指向被其遮蔽内容的引用,因为 `shadowedRange` 来源只存在于仅写入日志、模型不可见的 `compact/summary` 事件上,也没有工具能让模型重新读取被遮蔽的区段。即使仅追加日志仍保存每一个字节,摘要器丢弃的内容也会离开模型可触达的世界。重复压缩会进一步放大问题:每一轮都会重写头部检查点,因此请求前缀每次都会完全失去提示词缓存命中,而更早的摘要也会一代又一代地被重新摘要。 + +根本原因是一个产物承担了两个互相冲突的角色。**索引** 需要冻结、按时间排序且成本低廉;模型的**工作记忆** 则需要全局视图、重新确定优先级并且可变。单一摘要无法同时胜任两者。 + +主流编码 harness 都没有让模型在循环内回溯,而且调研过的实现均未让压缩感知前缀缓存。事件溯源会话具备持久原文、可按 seq 寻址和精确回放的特征,是支持这两项功能的天然底座。 + +## 提案 + +把检查点拆为两类,并让被遮蔽的历史重新可达。 + +### 冻结的索引检查点 + +新近变为陈旧的历史按确定性策略拆分为分片:向 `chunkTokens` 累积;使用 `toolPairingBalancedBefore`/`toolPairingBalancedAfter` 对齐边缘;优先选择轮次边界;在平衡允许的范围内,把最终边界放在尽量接近保留边界的位置,使尾随切片缩小到大约一个轮次。每个分片通过一次 `compactRegion` 调用压缩为一个**索引存根**(`stubTokens`,约 100–200 个 token): + +- 用两三行说明发生了什么; +- 用一行关键词记录低频字面锚点,例如确切的错误字符串、值和配置键,并按类别分组; +- 由代码组装页脚:`[checkpoint c<summarySeq>: shadows conversation span #<start>–#<end>; originals retrievable via history_read]`。指针根据来源组装,绝不由模型编写。 + +已经提交的存根永不重写,也绝不再次进入之后的压缩区域。存根调用采用分层输入:固定前导内容与逐字节相同的本轮开始状态检查点(该阶段所有调用共享的前缀);随后是先前所有已提交存根的关键词行,使新条目索引其分片的独特内容,而不是重复整个目录;再加最近一两个已提交存根以维持时间连续性;最后是切片本身。同一轮中的同级存根不作为输入,因为并发阶段禁止这种依赖,而与轮次对齐的边界已经维持局部连续性。状态检查点只作为背景,绝不能成为存根需要总结的材料。完全由回溯内容构成的切片只通过代码生成存根,即只写一行指针,不调用 LLM(大语言模型)。存根调用失败时采用相同降级方式:其切片获得一个仅包含代码指针的存根,本轮继续执行,使状态重写成为一轮中唯一的强制 LLM 依赖。 + +### 状态检查点 + +系统维护一份可变的工作记忆文档(最多一份;第一次压缩前为零份),位于所有存根之后、保留尾部之前。每一轮根据先前状态与本轮变为陈旧的内容重写它,成本为 O(previous + new);过程遵守摘要提示词中已有的「合并而不重复陈述」规则,并覆盖决策、当前状态、约束和后续步骤。它带有自己的页脚,其大小上限与当前摘要处于同一量级。 + +膨胀保护会约束整轮操作:如果压缩后大小没有严格小于压缩前大小,就不提交任何内容,并继续当前轮次;本次尝试延后至积累更多陈旧历史后再进行。保护逻辑在两侧比较同一项度量:优先使用请求路径上提供方报告的用量;如果不可用,则两侧都回退为字符估算器。 + +### 一轮的执行过程 + +- 分片切片由表面位置范围表示。一轮分两个阶段运行:所有摘要调用先并发执行并在表面之外缓冲;随后严格从左到右提交区域,先提交各分片,最后提交尾随切片,使状态检查点通过连续的单节点替换落在所有存根之后。墙钟时间维持在接近一次摘要调用的水平。 +- 被取代的状态检查点会作为普通历史折入下一轮的第一个分片,不需要墓碑或新原语。其存根省略该状态,`history_read` 会把它渲染为 `[prior state checkpoint]`,并让页脚随渲染文本一同传递,使每个尾随切片都可通过两跳链路触达。 +- 范围选择会感知冻结边界:可压缩区段从最后一个已提交索引检查点之后开始;只有在不存在索引检查点时,才从表面头部开始。旧会话现有的头部检查点会被视为状态类检查点:其文本作为合并基线,其节点则像其他被取代状态一样折入历史。 +- 摘要阶段发生崩溃时不会提交任何内容;提交中途崩溃会留下一个从左到右的已提交前缀,恢复后的一轮从日志中最新的状态类 `compact/summary` 事件读取合并基线,并无条件提交剩余区域。恢复 `[stubs…][state][tail]` 的优先级高于缩减大小。 + +### 回溯工具 + +新增包(package)`@deepseek-ai/dsh-tool-recall`,它只是 `dsh-session` 与 `dsh-compact` 词汇之上的消费方,注册两个面向模型的工具: + +- `history_read(checkpoint, offset?)`:把日志中任意检查点(包括已被取代的检查点)遮蔽的区段渲染为 `User:`/`Assistant:`/`Tool result:` transcript(文本记录),并按配置预算分页,提供续传游标。 +- `history_search(query, checkpoint?, limit?)`:对每个被遮蔽区段进行不区分大小写的字面量扫描;返回带检查点 id 的片段与覆盖元数据(`scanned`/`matched`/`truncated`)。零匹配提示会说明扫描按字面量执行,并建议对可能的检查点直接使用 `history_read`。 + +两个工具都读取 `exec.agent.session.events`(沿用 tool-todo 访问模式;拒绝非 agent(智能体)调用方),只渲染表面类型的消息事件,并返回普通 `tool/result`:回溯字节会进入上下文尾部并记录到日志,因此无需特殊处理即可满足可重建性。系统不增加新存储或伴随索引:会话日志是归档,`compact/summary` 来源是索引元数据,而这些工具是两者之上的读取路径。工具 schema 与该包唯一的系统提示词章节都是静态字符串;检查点 id 只会通过页脚抵达模型。transcript 渲染器从 `compact-basic` 移入 `dsh-session`,供摘要器与工具共享。 + +### 缓存与成本 + +一轮后的请求前缀为 `[system][stubs…][state][tail]`。冻结存根在各轮之间逐字节稳定,因此缓存缺失从替换先前状态检查点的 token 才开始,规模保持 O(new chunks + state + tail),而当前实现会从位置零开始缺失。回溯输出落在尾部,不会改变前缀。每轮摘要输入大约是当前实现的两倍,另加一个 m·S 背景项;该成本受到 `chunkTokens` 下限(状态上限的小倍数)以及经过校验的 `stubTokens`/`chunkTokens` 比例上限约束。共享前缀输入布局依次为前导内容、逐字节相同的本轮开始状态、位于尾部的切片内容,使同级调用可以按缓存费率重复读取。 + +### 打包方式 + +该设计以新的后端 `dsh-compact-recallable` 交付,挂在现有 `ctx.compact` seam 上,并在已交付的示例配置中默认启用。`compact-basic` 保留为参考实现和该 seam 的设计对照,与成对 LLM 适配器的模式一致。seam JSDoc 中「最多一个自动生成的检查点,始终位于头部」这一条会放宽,改为说明两个后端各自的行为。 + +### 与进行中工作的关系 + +- **工具结果裁剪**(进行中的裁剪服务):其替换节点携带 `sourceEventSeqs`;同一注册表折叠会把经过裁剪的结果列为可回溯。它属于后续范围,两项工作互不阻塞。 +- **提供方 token 用量核算**(正在把压缩压力迁移至提供方报告用量的工作):为保护逻辑提供核算基础;本实现堆叠在它之后。 +- **「查询会话」backlog(待翻清单)条目**:它是跨会话的泛化方案;本 Agent Note(agent 决策记录)把范围限定在实时会话内,并选择工具名称与渲染方式,使该工作能够扩展本设计而不产生冲突。 +- **训练**:何时回溯属于学习到的行为。确定性页脚与关键词锚点为训练提供稳定目标,而回溯使用情况在会话日志中完全可见,可供轨迹导出;基准测试与 RL 设计由后训练侧推进。 + +### 后续事项 + +以下项目在评审期间已经明确,但会延后至观察结果证明需要时再实现: + +- 保护逻辑降级阶梯(通过代码汇总最早的存根前缀,保留页脚,已汇总 id 仍可作为回溯目标;随后在冻结边界后生成一份摘要):触发条件是观察到保护逻辑活锁或存根区域压力。 +- 存根输出回声检测(句子级 n-gram,豁免短字面量;先重试,再剥离):触发条件是观察到职责分工泄漏。 +- 定期使用分片原文刷新状态:触发条件是交接探针观察到漂移。 +- `stateFallbackThreshold`(存根数量低于阈值时使用完整细节的状态提示词):触发条件是短会话回归。 +- 延迟注册回溯工具:触发条件是在从不进行压缩的会话中测得上下文开销。 +- 在 pre-step 分摊存根起草工作:一旦已经陈旧但尚未压缩的内容积累超过 `chunkTokens`,就在下一个 pre-step 起草该分片的存根(一个仅写入日志的草稿事件,在分片周围的上下文仍然存活时写入),让压缩轮提交草稿,而不是集中执行摘要。这是后台压缩的确定性、精确回放等价形式(Claude Code 会话记忆采用这种模式;OpenClaw 证明同步语义完全相同)。触发条件是观察到一轮延迟,或近实时起草带来的存根质量收益得到验证。 +- 拆分摘要模型;由模型选择分片边界;跨会话回溯;语义搜索回退:每项都必须由各自证据支持。 +- 更丰富的 `history_search` 查询形式:正则表达式,以及对日志 JSON 工具结果执行的结构化查询(sql/jq 风格,或由 agent 针对索引存储编写查询)。触发条件是观察到搜索漏检;首版先交付字面量匹配,使回溯路径保持为日志的纯函数。 + +## 考虑过的替代方案 + +- **分阶段交付**(先在当前后端之上单独交付回溯工具;观察到回溯使用后,再决定是否拆分检查点):不予采纳。未经训练的模型会低频使用任何新工具,因此该条件测量的是训练缺失,而不是设计价值;训练侧需要完整机制来构建环境;预发布阶段修改持久化格式的成本最低;缓存经济性则属于第一方已经掌握的知识,不是等待遥测验证的假设。实现仍以堆叠 PR 方式落地,并先交付回溯工具,但这只是构建顺序,不是决策门槛。 +- **只保留冻结的全尺寸摘要,不设状态检查点**:不予采纳,因为永久前缀会无界增长、自我加速并最终发生颠簸,而且没有任何内容可以重新确定优先级。 +- **只保留纯存根,不设状态检查点**:不予采纳,因为这假定模型知道自己缺少什么,在面对未知的未知时会失败。 +- **由 LLM 老化/整合冻结分片**:不作为常规机制,因为摘要的摘要会丢失信息,并使冻结前缀频繁变化;其保留下来的形式是由代码汇总,且延后实现。 +- **把完整前缀作为分片摘要器输入**:不予采纳,因为成本为 O(N²);状态文档以 O(state) 提供相同背景。 +- **一次摘要调用输出全部结果**:不予采纳,因为摘要路径没有结构化输出约束;解析一份自由文本响应并将其拆开,正是保守失败设计要避免的脆弱 seam。 +- **由模型选择分片边界**:延后实现,因为相对于未经证明的收益,解析与校验成本过高;分片策略位于配置之后。 +- **由模型编写指针**:不予采纳,因为指针必须精确,应由确定性代码组装。 +- **FTS/向量索引伴随存储**:在会话内不予采纳,因为实时日志已在内存中且大小有界,在预算内进行字面量扫描已经足够;只有跨会话范围才能证明索引的价值。 +- **回溯路径中的语义搜索回退/次级模型提取**:不予采纳,因为其中的 LLM 或嵌入调用会破坏无密钥回放的确定性;回溯必须保持为日志的纯函数。 +- **使用原始事件而不是渲染后的 transcript**:不予采纳,因为这会泄漏仅日志可见的词汇与分片噪声;模型应读取模型曾经看到的内容。 +- **什么都不做(用 resume/fork 恢复)**:不予采纳,因为这会把恢复变成人工操作。 + +## 验收标准 + +- 长会话自动压缩后,每一轮完成时都会得到 `[stubs…][state][tail]`;先前存根在各轮之间保持逐字节相同;已提交存根绝不落入后续区域;被取代的状态检查点无需墓碑即可折入历史,渲染时带有标签,并可通过两跳链路触达和搜索。 +- 每个检查点的表面文本都以确定性页脚结束;页脚通过回放逐字节往返;状态检查点的来源记录其更宽的输入范围。 +- 在全部摘要就绪且保护逻辑通过同类核算前,不提交任何内容;保护失败不会提交任何内容,也不会让轮次失败;提交中途被终止后,下一次 pre-step 会恢复处理,从日志读取合并基线,并无条件提交状态区域以完成本轮;旧版头部检查点会被视为状态类。 +- `history_read` 在预算内渲染任意已记录检查点的区段,并提供可用游标;`history_search` 覆盖每个被遮蔽区段,返回带检查点 id 的片段与覆盖元数据,测试尤其要能找到只存在于被已取代状态检查点遮蔽区段中的内容,这是锁定尾随切片可达性的回归用例;两个工具都会拒绝非 agent 调用方,并对从未存在的 id 或遗留 `compact/start` 返回类型化错误;回溯内容作为普通 `tool/result` 出现;请求重建不变量会在同时包含压缩与回溯的会话上通过;一项无密钥快照场景端到端覆盖先压缩再回溯;工具 schema 与提示词章节在各轮之间逐字节相同。 +- 在长时间跨度 bench 套件中:任务成功率在预算相同的条件下不低于 `compact-basic`;交接保真探针(在一轮后重新陈述 K 项已知决策和约束)的得分不降低;每次运行都通过 dsh bench 报告流水线汇报回溯使用频率和命中有效性,并同时报告存根目录注意力度量与缓存命中遥测。 +- seam JSDoc、压缩能力 seam Agent Note、`architecture.md`,以及生成的工具、配置、持久化与模块图目录都在同一改动中更新;全部预算位于配置中;新源码目录具备逐文件 100% 覆盖率与 HMR(热模块替换)释放测试。 + +## 风险 + +- **回溯属于学习到的行为**:未经训练的模型会低频使用它,bench 报告会持续追踪这项差距,直至训练弥合问题。在此之前,状态检查点会让质量下限保持在当前摘要水平。 +- **未知的未知仍然存在**:如果某项细节既未出现在摘要中,也未出现在关键词中,就不会触发回溯。回溯把「即使已经怀疑也无法触达」变成「怀疑时可以触达」。 +- **存根目录会占用注意力**:每次请求中包含数十张稳定的索引卡,可能稀释模型关注点;验收标准中的 bench 度量会将其与 `compact-basic` 对比。 +- **成本**:每轮摘要输入大约是当前实现的两倍;短会话的成本和质量接近当前水平,而设计收益随会话长度增长。 +- **状态漂移与职责分工泄漏** 可以通过交接探针和存根评审观察;对应措施已列为后续事项。 +- **两个后端** 会增加维护接口;seam 契约和共享回溯消费方会约束该成本,bench 对比则用于逐步决定默认实现。 diff --git a/.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.i18n.yaml b/.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.i18n.yaml new file mode 100644 index 0000000000..d392c8cf22 --- /dev/null +++ b/.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.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 +2026-07-13-human-review-skill-maintenance.md: 76391bd110b7a86b194a9340ffcf7cc4602d1d2e +2026-07-13-human-review-skill-maintenance.zh.md: 67d68d07cc7f467a310b64d833a28da6f04bcc8e diff --git a/.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.md b/.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.md index 6d4de215c9..76391bd110 100644 --- a/.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.md +++ b/.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.md @@ -2,6 +2,8 @@ Status: proposed +English | [中文](2026-07-13-human-review-skill-maintenance.zh.md) + ## Problem The `dsh-code-review` skill records failure modes that require reviewer judgment, but one-off audits are expensive to repeat and easy to scope inconsistently. Treating every comment as a lesson produces checklist bloat; treating merge, thread resolution, or an author's “fixed” reply as proof of adoption promotes feedback that the final code may not implement. The maintenance process needs enough evidence and independent review to fail closed without requiring a webhook service, durable event state, or automatic repository promotion before the workflow has proven useful. diff --git a/.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.zh.md b/.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.zh.md new file mode 100644 index 0000000000..67d68d07cc --- /dev/null +++ b/.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.zh.md @@ -0,0 +1,85 @@ +# Agent Note: dsh-code-review 的定期人工评审维护 + +Status: proposed + +[English](2026-07-13-human-review-skill-maintenance.md) | 中文 + +## 问题 + +`dsh-code-review` skill(技能)记录需要评审人判断的失败模式,但一次性审计既难以重复,作用域也容易不一致。把每条评论都当作教训会让检查清单不断膨胀;把合并、讨论串已解决或作者回复「已修复」视为采纳证据,则会把最终代码可能并未落实的反馈提升为规则。维护流程需要足够的证据和独立评审,以便在证据不足时按不采纳处理,同时无需在工作流证明有用之前引入 webhook 服务、持久事件状态或自动仓库推广。 + +## 提案 + +在仓库外定期维护。一项保存在 skill 维护者机器上、而不提交到本仓库的私有工具,会针对刷新至 `origin/master` 的干净完整历史 checkout 运行。预期的调度器每天运行,并使用两天的 UTC 重叠窗口;手动运行可以通过另一个 `--since` 时长或重复的 `--pr` 参数指定显式集合。扫描相对于当前 skill 是幂等的,不存储仓库游标。推广时唯一会变更的仓库文件是 [.agents/skills/dsh-code-review/SKILL.md](../../../skills/dsh-code-review/SKILL.md);draft PR(Pull Request)携带来源概述,因此评审人无需私有适配器日志,也能审计源反馈与采纳证据。 + +```mermaid +flowchart TD + A["Maintainer or scheduler runs the tool on origin/master"] --> B["List PRs merged in the overlap window"] + B --> C["Collect pre-merge User feedback and final PR evidence"] + C --> D["Two reviewers verify provenance and adoption"] + D --> E{"Both confirm human-authored and adopted?"} + E -- "No" --> F["Exclude or retain as unresolved"] + E -- "Yes" --> G["Two reviewers classify against the current skill"] + G --> H["Draft a complete candidate from agreed guidance"] + H --> I["Two reviewers inspect the same skill diff"] + I -- "Blocking finding" --> J["Bounded revision loop"] + J --> I + I -- "Both approve" --> K["Run documentation and lint checks"] + K --> L["Leave a reviewed local working-tree diff"] +``` + +### 采集契约 + +每个选定的 PR 都会在获取任何反馈前接受过滤:其 merge commit 必须是 `origin/master` 的祖先。merge commit 可达性是唯一的资格检查:直接 base 为功能分支的堆叠 PR,只要该 base 随后已经进入 master,就会被纳入,因为无论中间 stack 如何,评审人评论的代码此时已经在 master 上。工具还会解析落地 merge 的目标父级;无法重建的落地形态会记录到 `skipped-pulls.json` 并跳过。单个 PR 在预检、采集或证据收集时失败,只会被跳过,不会中止整次运行。如果时间窗口会超过 GitHub 搜索的 1,000 条结果上限,搜索阶段会明确失败,避免无提示地遗漏已合并 PR。采集阶段会完整读取内联评审评论、评审提交和 PR commit 的分页连接。不采集 PR 对话评论,因为强制推送后,GitHub 当前状态无法证明哪个仍然存在的 commit 位于评论之前,采纳契约因此必然会无条件排除这些评论。只有当 GitHub 报告 actor `type` 为 `User`,并且创建时间与最后编辑时间都严格早于 PR 合并时,工作流才接受已采集反馈;与合并同一时间戳的编辑视为合并后操作。评审提交使用 GraphQL 的 `lastEditedAt`,因为 REST 表示不含编辑时间。 + +### 采纳证据 + +每条反馈都携带稳定的来源 ID 和有界的变更证据。评审人的 `commit_id` 仍属于该 PR 时(强制推送场景无法确认即排除),工具会选择 committer 时间戳严格早于反馈的最新 PR commit 作为基线,而不是采用评审人点击的 commit,因为后者可能更旧。工具绝不会直接比较该基线与落地 merge:这种 diff 会混入不断前移的目标分支中的无关变更。相反,它会向采纳评审人提供两份 PR 专用 patch 快照。令 `B` 为反馈基线,`T` 为落地 merge 的目标父级,`M` 为落地 merge。反馈时快照是从 `merge-base(B, T)` 至 `B` 的树 diff;最终快照是从 `T` 至 `M` 的树 diff。因此,目标分支专有变更不会出现在任一 PR patch 中,而反馈后加入 PR 的变更只会出现在最终快照中。遭到强制推送的评审、早于全部现存 PR commit 的反馈,以及无法重建目标父级的落地形态,会在任何评审人看到之前确定性地归类为 `unclear`。合并状态、已解决讨论串、作者回复「已修复」或同文件编辑只是上下文,不是采纳证明;PR 作者自己的评论绝不会进入适配器,因为它们不可能构成对作者自身意见的采纳。 + +### 双评审人分类与起草 + +两个独立配置的评审适配器,会从来源(`human-authored`、`forwarded-automation` 或 `unclear`)和采纳情况(`adopted`、`rejected` 或 `unclear`)两个维度,对每个符合条件的条目进行分类。只有两个适配器都判定为 `human-authored` 加 `adopted` 的条目才会继续。采纳集合随后会针对当前 skill 接受第二次独立分类:候选项、已经覆盖、实现专用或并非反馈。单个条目即可符合要求,不要求重复出现。意见分歧会得到一次有界的重新评估;如果仍未解决,则继续保留在运行产物中。单个批次的适配器输出如果未通过 schema 或 id 校验,系统会在批次层按不采纳处理:其中的每条反馈都标记为 unclear 并路由到 `excluded`,而不是中止整次运行;有问题的原始输出会保存在该次运行的私有产物中,供调试使用。如果任一适配器在某项操作的任何非空批次中都没有返回有效结果,运行会以非零状态退出并发出失败记录,而不会报告「没有候选项」。 + +主适配器只根据结构化的共同指引起草,绝不接收原始评审文本。根据适配器作者的契约,它保持无工具且只读:返回完整的候选文件内容,由工具校验后写入唯一目标。两个适配器随后评审同一份完整 skill diff;阻塞性问题会进入有界修订循环,而且两者必须批准同一版修订。工具会在运行文档和 lint 检查前,以及报告成功前,再次拒绝暂存改动和目标 skill 之外的编辑,因此检查或并发进程无法通过添加其他路径混入。失败时,工具使用尽力而为的比较并交换恢复自身写入,避免覆盖维护者的并发编辑。成功时,它保存一份候选资料包,其中包含源 `origin/master` commit、源 skill blob ID、已评审 diff、完整候选文件、源反馈 ID 与 URL、落地证据范围、适配器判定和检查结果;它绝不提交、推送、打开或合并 PR。 + +### 评审适配器协议 + +每个私有可执行文件从 stdin 接收有字节上限、带版本的 JSON 请求,并在 stdout 返回有字节上限且符合 schema 的 JSON。两个评审命令解析为逐字节相同的可执行文件时,工具拒绝运行;这是机械性的最低标准,保证主适配器与次适配器由独立提供方或模型驱动,仍是部署运维方的责任。`access` 与 `tools` 字段是适配器作者承担的契约标记,不是 OS 沙箱:评审子进程在清理后的环境中 spawn,其 `cwd` 指向私有运行目录而非仓库根目录;反馈包装在带随机数的 `<untrusted-feedback nonce="…">` 块中,每个提示词都会要求模型把它视为数据;128 位随机数防止不受信任的正文伪造结束标签。每个子进程都采用有界、感知中止的进程树清理。适配器作者把每项操作实现为纯只读推理(inference);即使 `edit` 操作也只会在 JSON 中返回完整候选内容,由工具校验后写入唯一目标。每个生产 `git`/`gh`/检查命令同样在清理后的环境中 spawn,避免 pre-push 钩子的路由变量无提示地重定向维护工具。候选写入与失败回滚会针对最近一次写入内容使用尽力而为的比较并交换;回滚还会取消暂存目标,避免由适配器或检查暂存的候选项在失败运行后遗留并进入之后的 commit。 + +### 推广契约 + +推广辅助工具从刷新至 `origin/master` 的干净 checkout 开始;当前 skill blob 与资料包中记录的源 blob 不同时,它拒绝应用候选项。运维方随后重新运行维护分析,或手动把 diff 变基后重新评审候选项;辅助工具绝不会用陈旧的完整文件输出替换较新的 `SKILL.md`。应用仍然有效的候选项后,它会打开 draft PR,其正文列出源反馈 URL 或 ID、用作采纳证据的落地 commit 范围、来源运行、检查结果和任何运维方编辑。原始适配器提示词与响应保持私有,但仓库评审人会获得足够的来源信息,以判断每条提议规则是否确实来自已采纳的人类反馈。 + +### 机制所在位置 + +工具源码、适配器二进制文件、提供方凭据和预期的每日调度器保存在维护者机器上,不会提交到本仓库。本文规定协议,参考实现属于私有基础设施。该机制只服务于由单个运维方维护的一项 skill,因此,让机制编辑持续接受仓库评审的成本高于来源可追溯性的收益。如果该机制将来移交给第二位维护者,移交工作需要一篇后续 Agent Note(agent 决策记录)来修订本决策;任何接手者都应从运维文档 [docs/cookbook/maintaining-dsh-code-review.md](../../../../docs/cookbook/maintaining-dsh-code-review.md) 入手。 + +## 考虑过的替代方案 + +- **把工具放入本仓库。** 对单维护者作用域不予采纳:仓库维护开销(类型检查、lint、覆盖率与横切重构)会超过已提交来源信息的价值。未来移交时仍可重新考虑。 +- **记录每条反馈产生时的 PR head**:不予采纳,因为这需要持续运行的观察器、持久事件状态、重试和强制推送协调。定期维护会在可用时使用经过评审的 commit 证据,并在整 PR 证据范围过宽、无法确认时直接排除。 +- **持久化已处理 PR 游标**:不予采纳,因为带重叠的时间窗口扫描成本低廉,并且相对于当前 skill 天然幂等;游标状态反而带来恢复和漏事件问题。 +- **每次新评论都运行**:不予采纳,因为一轮评审会产生许多相关评论,并且缺少判断采纳情况所需的最终产物。 +- **把合并或讨论串解决视为采纳**:不予采纳,因为 PR 可能在反馈被拒绝、被取代或刻意不解决的情况下合并。 +- **自动创建或合并仓库改动**:不予采纳,因为工具首先需要通过有用的定期输出积累可信记录。维护者检查并通过普通仓库评审推广本地 diff。 +- **从已经修复的 bot 问题中学习**:不予采纳,因为来源契约限定为人类评审反馈。系统会在分析前按 actor 类型过滤,并通过来源评审排除转发自动化问题的人类账号。 +- **让同一个评审人既当作者又作最终裁决**:不予采纳,因为独立判定能在不受支持的概括进入 skill 之前暴露问题。 + +## 验收标准 + +从 `proposed/` 推广到 `implemented/`,需要在针对本仓库的真实端到端运行中观察到以下全部事实: + +- 私有工具从刷新至 `origin/master` 的干净 detached checkout 运行,并且要么报告「没有候选项」,要么只生成 `.agents/skills/dsh-code-review/SKILL.md` 的 working-tree diff。**2026-07-15 已观察:** 扫描 62 个已合并 PR,跳过 5 个(merge commit 不可达或采集超过 250 个 commit 的上限),考虑 426 条人类反馈,发现 0 个候选项。 +- 两个评审适配器独立配置(不同的提供方或模型),无需用户干预即可完成 analyze/adopt/review 流程。**2026-07-15 已观察:** 不同的主/次适配器约用 8 分钟完成采纳与分析;一次适配器 id 幻觉由批次级保守失败机制处理,没有中止整次运行。 +- 调度器在没有交互式终端的情况下触发工具,并通过持久通知通道把候选 diff(或「没有候选项」记录)送达运维方。 +- 一个受控采集场景会在反馈基线之后,向目标分支加入与反馈匹配的变更;评审证据排除该目标分支专有变更,同时保留后续由 PR 自身加入的变更。 +- 源 skill 改变后,推广辅助工具会拒绝候选项;仍然有效的候选项会按照上文定义的来源概述打开 draft PR。 +- 该工作流生成的至少一个候选 diff 会由运维方检查,并通过普通仓库 PR 评审推广到 `master`。该 PR 用以证明工作流能够把已采纳反馈转化为已交付的 skill 指引。 + +## 风险 + +- **根据 committer 时间戳推断因果关系。** 反馈 commit 基线通过比较 GitHub commit 时间戳与反馈创建时间戳选出;committer 时钟偏差与重写仍会留下误判采纳的残余窗口。与 GitHub 的 PR 事件流交叉比对可以进一步收紧,但需要采集定期工具作用域以外的事件。 +- **两个「非候选项」分类结果会直接路由到 `excluded`,不进入争议轮次。** 两个分类器都判断「不是候选项」,但对非候选原因意见不一时(例如 `covered` 与 `specific`),该条目会被排除而不是重新评估。两个分类器都同意它不会形成新的评审行为,所以争议轮次不会改变结果。 +- **超出字节哈希差异的双评审人独立性属于部署契约。** 两个命令解析为逐字节相同的可执行文件时,工具拒绝运行,但它无法验证两个不同包装层是否由不同提供方或模型驱动。运维方必须配置相互独立的主适配器与次适配器。 +- **候选写入与回滚使用尽力而为的比较并交换。** POSIX 上基于文件的比较并交换并不真正原子;窗口持续一个事件循环周期。该工具面向单用户定期维护,不考虑真正并发的编辑器。 +- **单维护者风险。** 由于机制位于单台机器,服务中断后,skill 维护会完全停止,直到运维方恢复服务,或通过一篇后续 Agent Note 把机制移交给新维护者。 diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.i18n.yaml index 45949d3fcf..b7b5632e38 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.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 -2026-06-20-drop-acp-terminal-meta.md: 79da387ac1a7a0e6767e3bf24baa6039e39ef90d -2026-06-20-drop-acp-terminal-meta.zh.md: d29fd54618611f56fd071a0ee4a63bc207895d89 +2026-06-20-drop-acp-terminal-meta.md: 84b9028392f967e72f7b5585d013d669735631de +2026-06-20-drop-acp-terminal-meta.zh.md: 6ac7ba46bce20bcf2593ef422057e0562f8a1557 diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.md b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.md index 79da387ac1..84b9028392 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.md +++ b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.md @@ -1,14 +1,14 @@ # Agent Note: Drop ACP terminal `_meta` rendering -Status: rejected — Zed is the current target client, and the terminal `_meta` convention is intentional Zed UX with a plain ACP fallback for other clients. +Status: rejected — removing only Zed terminal metadata was rejected while ACP remained an editor bridge; automation-only ACP later removed the whole editor projection. English | [中文](2026-06-20-drop-acp-terminal-meta.zh.md) ## Problem -The ACP bridge implements a Zed-specific terminal-card convention through `_meta.terminal_info`, `_meta.terminal_output`, and `_meta.terminal_exit`. The implemented [rich ACP bash rendering Agent Note](../../implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) deliberately avoided ACP's client-side `terminal/create` because bash execution belongs in the harness, but still adopted the reference agents' display-only `_meta` convention. That gives a nicer Zed card at the cost of bridge state, capability negotiation, terminal ids, special update mapping, text fallback tests, and exit-pill parsing in `dsh-tool-bash`. +The former ACP editor bridge implemented a Zed-specific terminal-card convention through `_meta.terminal_info`, `_meta.terminal_output`, and `_meta.terminal_exit`. The current [render-intent decision](../../implemented/architecture/2026-07-02-tool-render-intent-union.md) preserves the underlying rule that bash execution belongs in the harness and terminal cards are display-only. The later [automation-only ACP decision](../../implemented/simplification/2026-07-23-acp-automation-only-protocol.md) removes the `_meta` projection, bridge state, capability negotiation, terminal ids, special update mapping, text fallback tests, and exit-pill parsing from ACP. TUI and the Web host/client runtime retain the tagged presentation contract, while ACP no longer renders editor cards. -The fallback path already exists: render the tool call and completed output as normal ACP content blocks. Non-Zed clients rely on that path anyway, but the Zed terminal card is a current target-client feature rather than speculative decoration. +At proposal time, the fallback path already existed: render the tool call and completed output as normal ACP content blocks. Non-Zed clients relied on that path, but the Zed terminal card was a target-client feature rather than speculative decoration. ## Proposal @@ -22,10 +22,10 @@ This proposal is narrower than [collapsing tool-owned UI presentation](2026-06-2 - `TerminalRendering`, terminal ids, terminal cwd resolution, and `_meta.terminal_*` update mapping disappear from `@deepseek-ai/dsh-acp`. - `ToolTerminal` disappears from `@deepseek-ai/dsh-tools`, or is unused and deleted with the presentation cleanup. - Bash result presentation no longer parses exit status for terminal pills. -- The implemented [rich ACP bash rendering Agent Note](../../implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) stays in `implemented/` as shipped history and is cross-linked from this proposal if superseded. +- The [automation-only ACP decision](../../implemented/simplification/2026-07-23-acp-automation-only-protocol.md) later removes ACP terminal cards and absorbs their execution-ownership rationale. ## What we give up -Zed users lose the dedicated terminal card: no cwd header, terminal display, or exit pill. They still see the command and output as plain content. That is a reasonable simplification while the ACP bridge is still unreleased and the `_meta` keys are a convention rather than a standard. +Under this proposal, Zed users would lose the dedicated terminal card: no cwd header, terminal display, or exit pill. They would still see the command and output as plain content. That was a reasonable simplification to consider while the ACP bridge was unreleased and the `_meta` keys were a convention rather than a standard. <!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) --> diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.zh.md b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.zh.md index d29fd54618..6ac7ba46bc 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.zh.md @@ -1,14 +1,14 @@ # Agent Note: 移除 ACP(Agent Client Protocol)终端 `_meta` 渲染 -Status: rejected — Zed 是当前目标客户端,terminal `_meta` 约定是有意设计的 Zed UX,同时为其他客户端保留普通 ACP 回退。 +Status: rejected — 在 ACP 仍是编辑器桥接层时,仅移除 Zed 终端元数据的方案被否决;后续仅面向自动化的 ACP 则移除了整个编辑器投影。 [English](2026-06-20-drop-acp-terminal-meta.md) | 中文 ## 问题 -ACP 桥接层通过 `_meta.terminal_info`、`_meta.terminal_output` 和 `_meta.terminal_exit` 实现了一套 Zed 特有的终端卡片约定。已实现的[富 ACP bash 渲染 Agent Note(agent 决策记录)](../../implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md)刻意回避了 ACP 客户端侧的 `terminal/create`(因为 bash 执行属于 harness 职责),但仍采用了参考 agent(智能体)的纯展示 `_meta` 约定。这在 Zed 中带来了更好的卡片效果,代价是桥接状态、能力协商、终端 id、特殊的 update 映射、文本回退测试,以及 `dsh-tool-bash` 中的 exit-pill 解析。 +原 ACP 编辑器桥接层通过 `_meta.terminal_info`、`_meta.terminal_output` 和 `_meta.terminal_exit` 实现了一套 Zed 特有的终端卡片约定。当前的 [render-intent 决策](../../implemented/architecture/2026-07-02-tool-render-intent-union.md)保留了底层规则:bash 执行属于 harness,terminal 卡片只用于展示。后续的[仅面向自动化 ACP 决策](../../implemented/simplification/2026-07-23-acp-automation-only-protocol.md)从 ACP 中移除了 `_meta` 投影、桥接状态、能力协商、终端 id、特殊 update 映射、文本回退测试和 exit-pill 解析。TUI 与 Web 宿主/客户端运行时保留带标签的展示契约,而 ACP 不再渲染编辑器卡片。 -回退路径已经存在:将工具调用和完成输出渲染为普通 ACP 内容块。非 Zed 客户端本来就依赖这条路径,但 Zed 终端卡片是当前目标客户端的功能特性,而非推测性装饰。 +本提案提出时,回退路径已经存在:将工具调用和完成输出渲染为普通 ACP 内容块。当时,非 Zed 客户端依赖这条路径,但 Zed 终端卡片是目标客户端的功能特性,而非推测性装饰。 ## 提案 @@ -22,10 +22,10 @@ ACP 桥接层通过 `_meta.terminal_info`、`_meta.terminal_output` 和 `_meta.t - `TerminalRendering`、终端 id、终端 cwd 解析与 `_meta.terminal_*` update 映射从 `@deepseek-ai/dsh-acp` 中消失。 - `ToolTerminal` 从 `@deepseek-ai/dsh-tools` 中消失,或在展示清理中因未使用而删除。 - Bash 结果展示不再为终端 pill 解析退出状态。 -- 已实现的[富 ACP bash 渲染 Agent Note](../../implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) 作为已交付历史保留在 `implemented/` 中;如被本提案取代,则加上交叉链接。 +- [仅面向自动化 ACP 决策](../../implemented/simplification/2026-07-23-acp-automation-only-protocol.md)后来移除了 ACP 终端卡片,并吸收了其中有关执行归属的决策依据。 ## 放弃的内容 -Zed 用户将失去专用终端卡片:没有 cwd 头部、终端展示或 exit pill。他们仍能以纯内容形式看到命令和输出。在 ACP 桥接层尚未发布、`_meta` 键只是约定而非标准的阶段,这是合理的简化。 +如果采用本提案,Zed 用户会失去专用终端卡片:没有 cwd 头部、终端展示或 exit pill。但他们仍会以纯内容形式看到命令和输出。当时 ACP 桥接层尚未发布,且 `_meta` 键只是约定而非标准;在这种情况下,考虑这项简化是合理的。 <!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) --> diff --git a/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.i18n.yaml index da93063670..14c7033185 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.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 -2026-06-20-generic-tool-rendering.md: 6fc610546da04e7d1e16fc17ada87483a142aa3c -2026-06-20-generic-tool-rendering.zh.md: 11386b87d845129950a8473eb1cf4ea6ce697ac8 +2026-06-20-generic-tool-rendering.md: a9ceb7a0e016b57295e3226e98a7fce51e49c21f +2026-06-20-generic-tool-rendering.zh.md: 553c1caa23ed34eea5f113372d12a7381dc2488a diff --git a/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.md b/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.md index 6fc610546d..a9ceb7a0e0 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.md +++ b/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.md @@ -1,14 +1,16 @@ # Agent Note: Collapse tool-owned UI presentation -Status: rejected — tool-owned presentation should wait for more real tools before being generalized or deleted. Bash and ACP currently need the existing richer presentation path. +Status: rejected — TUI and the Web host/client runtime consume the tagged render-intent union, so tool-owned presentation remains current even though ACP no longer projects it. English | [中文](2026-06-20-generic-tool-rendering.zh.md) ## Problem -Tools can define `presentCall()` and `presentResult()` callbacks that return `ToolCallPresentation`, `ToolResultPresentation`, and optional `ToolTerminal` fields. The code itself flags the design as muddy: title, kind, raw input, content, terminal cwd, terminal output, exit code, and signal grew incrementally into a bag of optional fields. ACP then maintains pending call state to pair a result with the original args, creates replay-only presenters on `session/load`, and maps terminal subfields into Zed-specific `_meta`. `dsh-tool-bash` even parses exit status back out of rendered text because the pure replay-safe presenter no longer has the structured `BashRunResult`. +The optional-field bag and ACP editor mapping below were the proposal-time context for this rejection. The current contracts live in [the tagged render-intent union](../../implemented/architecture/2026-07-02-tool-render-intent-union.md) and [automation-only ACP](../../implemented/simplification/2026-07-23-acp-automation-only-protocol.md). -The real first-party use is bash presentation for ACP. That is too little evidence to freeze a cross-package UI presentation API. +Tools could define `presentCall()` and `presentResult()` callbacks that returned `ToolCallPresentation`, `ToolResultPresentation`, and optional `ToolTerminal` fields. The code itself flagged the design as muddy: title, kind, raw input, content, terminal cwd, terminal output, exit code, and signal had grown incrementally into a bag of optional fields. ACP then maintained pending call state to pair a result with the original args, created replay-only presenters on `session/load`, and mapped terminal subfields into Zed-specific `_meta`. `dsh-tool-bash` even parsed exit status back out of rendered text because the pure replay-safe presenter no longer had the structured `BashRunResult`. + +The real first-party use was bash presentation for ACP. That was too little evidence to freeze a cross-package UI presentation API. ## Proposal @@ -28,8 +30,8 @@ As a smaller alternative, replace the current optional-field bag with one explic ## What we give up -Bash loses its custom terminal-looking card and model-written description placement. The fallback remains reasonable: the command appears as tool input, and the output appears as text. Rich rendering should be designed when the product has enough UI/tool variety to justify a stable presentation contract. +Under this proposal, Bash would lose its custom terminal-looking card and model-written description placement. The fallback would remain reasonable: the command would appear as tool input, and the output as text. Rich rendering would be designed when the product had enough UI/tool variety to justify a stable presentation contract. ## Related -This is the broad version of [dropping ACP terminal metadata](2026-06-20-drop-acp-terminal-meta.md). If this Agent Note is accepted, that narrower Agent Note becomes unnecessary. +The later [tagged render-intent union](../../implemented/architecture/2026-07-02-tool-render-intent-union.md) implements the smaller alternative once multiple producer and consumer families provide enough evidence for the vocabulary. [Automation-only ACP](../../implemented/simplification/2026-07-23-acp-automation-only-protocol.md) removes ACP's editor projection without removing tool-owned presentation from TUI or the Web host/client runtime. diff --git a/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.zh.md b/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.zh.md index 11386b87d8..553c1caa23 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.zh.md @@ -1,14 +1,16 @@ # Agent Note: 收拢工具自有的 UI 展示逻辑 -Status: rejected — 工具拥有的呈现机制应等到出现更多真实工具后再进行泛化或删除。Bash 与 ACP(Agent Client Protocol)目前仍需要现有的丰富呈现路径。 +Status: rejected — 尽管 ACP(Agent Client Protocol)已不再投影这套契约,TUI 与 Web 宿主/客户端运行时仍消费带标签 render-intent 联合类型,因此工具自有的展示仍然有效。 [English](2026-06-20-generic-tool-rendering.md) | 中文 ## 问题 -工具可以定义 `presentCall()` 和 `presentResult()` 回调,返回 `ToolCallPresentation`、`ToolResultPresentation` 以及可选的 `ToolTerminal` 字段。代码本身就标记了这个设计的混乱:title、kind、raw input、content、terminal cwd、terminal output、exit code 和 signal 逐步增长为一堆可选字段。ACP 随后维护 pending call 状态以将 result 与原始 args 配对,在 `session/load` 时创建仅用于回放的 presenter,并将 terminal 子字段映射为 Zed 特有的 `_meta`。`dsh-tool-bash` 甚至从渲染后的文本中反向解析退出状态,因为纯回放安全的 presenter 已经拿不到结构化的 `BashRunResult`。 +下文所述的可选字段集合与 ACP 的编辑器映射,是本提案遭否决时的背景。当前契约分别由[带标签 render-intent 联合类型](../../implemented/architecture/2026-07-02-tool-render-intent-union.md)与[ACP 作为仅面向自动化的协议](../../implemented/simplification/2026-07-23-acp-automation-only-protocol.md)承载。 -真正的第一方用途是为 ACP 提供 bash 展示。这不足以作为冻结一个跨包(package)UI 展示 API 的依据。 +当时,工具可以定义 `presentCall()` 和 `presentResult()` 回调,返回 `ToolCallPresentation`、`ToolResultPresentation` 以及可选的 `ToolTerminal` 字段。代码本身就标记了这个设计的混乱:title、kind、raw input、content、terminal cwd、terminal output、exit code 和 signal 已经逐步增长为一堆可选字段。ACP 随后维护 pending call 状态以将 result 与原始 args 配对,在 `session/load` 时创建仅用于回放的 presenter,并将 terminal 子字段映射为 Zed 特有的 `_meta`。`dsh-tool-bash` 甚至从渲染后的文本中反向解析退出状态,因为纯回放安全的 presenter 已经拿不到结构化的 `BashRunResult`。 + +当时,真正的第一方用途是为 ACP 提供 bash 展示。这点证据不足以作为冻结一个跨包(package)UI 展示 API 的依据。 ## 提案 @@ -28,8 +30,8 @@ Status: rejected — 工具拥有的呈现机制应等到出现更多真实工 ## 放弃了什么 -Bash 失去其自定义的终端风格卡片和模型生成描述的放置位置。回退方案仍然合理:命令作为工具输入展示,输出作为文本展示。富展示应当在产品拥有足够的 UI/工具多样性、足以支撑一份稳定的展示契约时再行设计。 +如果采用本提案,Bash 会失去其自定义的终端风格卡片和模型生成描述的放置位置。届时,回退方案仍然合理:命令会作为工具输入展示,输出会作为文本展示。只有当产品拥有足够的 UI/工具多样性、足以支撑一份稳定的展示契约时,才会设计富展示。 ## 相关 -这是[移除 ACP terminal 元数据](2026-06-20-drop-acp-terminal-meta.md)的宽泛版本。如果本 Agent Note(agent 决策记录)被接受,那个更窄的 Agent Note 就不再必要。 +后续的[带标签 render-intent 联合类型](../../implemented/architecture/2026-07-02-tool-render-intent-union.md)在多类生产者与消费方为这套词汇提供充分依据后,实现了较小的替代方案。[ACP 作为仅面向自动化的协议](../../implemented/simplification/2026-07-23-acp-automation-only-protocol.md)移除了 ACP 的编辑器投影,但没有从 TUI 或 Web 宿主/客户端运行时中移除工具自有的展示。 diff --git a/.agents/skills/dsh-find-simplifications/SKILL.md b/.agents/skills/dsh-find-simplifications/SKILL.md index 43e2218085..12ff4c7e33 100644 --- a/.agents/skills/dsh-find-simplifications/SKILL.md +++ b/.agents/skills/dsh-find-simplifications/SKILL.md @@ -1,6 +1,6 @@ --- name: dsh-find-simplifications -description: 'Use when working in the deepseek-harness repo to find non-obvious simplification candidates and write proposed Agent Notes or inline TODO/FIXME/XXX notes for dead, duplicated, speculative, or over-built code surfaces; especially for requests like "find simplification Agent Notes", "look for unnecessary complexity", "audit for removal-style cleanups", or "fold worthwhile simplification ideas from another PR".' +description: 'Use when working in the deepseek-harness repo to find non-obvious simplification candidates, write proposed Agent Notes or inline TODO/FIXME/XXX notes, audit or coalesce superseded Agent Notes, or fold worthwhile simplification ideas from another PR; especially for dead, duplicated, speculative, over-built, or added-then-removed surfaces.' --- # Finding DeepSeek Harness Simplifications @@ -66,6 +66,22 @@ Reject or downgrade a candidate when: - The removal would force unrelated churn without actually making the contract smaller. - The idea is correct but tiny. Add a targeted TODO/FIXME/XXX instead, using the urgency semantics in [docs/development.md](../../../docs/development.md). +## Coalesce Superseded Agent Notes + +Audit the Agent Note tree when the user asks to reduce or coalesce it, or when the simplification being implemented makes an owning note obsolete. Do not expand every code-simplification survey into a repository-wide note audit. + +Follow the deletion rule in the [Agent Note contract](../../notes/README.md#when-to-write-one); do not duplicate or weaken it here. For each candidate chain: + +1. Identify the current owner from shipped code, configuration, generated catalogs, package docs, newer Agent Notes, and inbound links; dates and titles are discovery hints, not proof. +2. Classify the old note as fully or partially superseded. Any surviving behavior, current contract, durable format, compatibility obligation, or independently current rejected alternative makes it partial. Rationale that can be transferred to the current owner does not by itself make supersession partial. +3. For full supersession, move every unique rationale, alternative, consequence, shipped verification contract, and named coverage gap into the current owner. An inventory that only describes deleted implementation mechanics is not one of those decision facts. +4. Repair every inbound link, then delete the English note, Chinese counterpart, and consistency record together. +5. Search exact filenames, symbols, config keys, event names, and wire strings after the edit. Keep partial supersessions cross-linked and current. + +An added-then-removed feature is a common full-supersession case. Let the removal note own the history only when the feature is absent from production code, configuration, schemas, durable or wire formats, migration, and compatibility behavior; no current documentation presents it as available; and no test exercises it as supported behavior. Removal rationale and tests that enforce absence may remain. Preserve why the feature originally existed, why that motivation no longer justified it, alternatives to full removal, the capability given up, conditions for reintroduction, and evidence that removal is complete. Old tests and implementation mechanics that verified only the deleted behavior are not current verification contracts. + +Reject consolidation when the removal is only one transport, default, implementation, or presentation of a feature; when persisted data or compatibility handling survives; or when the removal note does not yet carry enough rationale to prevent accidental reintroduction. A current negative design decision may legitimately need its own note even though the removed implementation is gone. + ## Write The Agent Note Create one file per durable proposal under `.agents/notes/<lifecycle>/<class>/yyyy-mm-dd-topic.md`, following the lifecycle/classification contract in `.agents/notes/README.md`. Keep prose paragraphs on one physical line and use relative Markdown links. @@ -106,9 +122,11 @@ For docs-only Agent Note work, run at least `pnpm run doc-sync`, `pnpm run lint` When opening or updating a PR, summarize: -- How many Agent Notes and inline notes were added. +- How many Agent Notes and inline notes were added, consolidated, retained as partial supersessions, or deleted. - The main areas surveyed. - What was intentionally excluded. - Which checks passed. +For each consolidation group, name the old and current owners, state the evidence for full supersession, and explain why deletion is safe. If an added-then-removed scan finds no qualifying note, report that result and the representative partial cases retained. + Use a draft PR while the survey is still expanding; mark ready only when the candidate set, review responses, and validation are settled. diff --git a/.agents/skills/dsh-translate-docs/SKILL.md b/.agents/skills/dsh-translate-docs/SKILL.md index 8c28d4afda..c11079e0bc 100644 --- a/.agents/skills/dsh-translate-docs/SKILL.md +++ b/.agents/skills/dsh-translate-docs/SKILL.md @@ -17,7 +17,7 @@ When this skill fires and translations need to be written, do not translate your These are authoritative; read them at the source so this skill never drifts out of sync. -- **[docs/i18n/README.md](../../../docs/i18n/README.md)** — the pairing contract: the three-file pair (`foo.md`, `foo.zh.md`, `foo.i18n.yaml`), the consistency record's both-side blob hashes, the language-switcher lines, scope/exclusions, and the rollout manifest. +- **[docs/i18n/README.md](../../../docs/i18n/README.md)** — the pairing contract: the three-file pair (`foo.md`, `foo.zh.md`, `foo.i18n.yaml`), the consistency record's both-side blob hashes, the language-switcher lines, scope, and exclusions. - **[docs/i18n/translation-rules.md](../../../docs/i18n/translation-rules.md)** — how to translate: faithfulness, structure preservation, terminology discipline, typography (MUST/SHOULD levels). - **[docs/i18n/terminology.md](../../../docs/i18n/terminology.md)** — the terminology table, binding in both directions. Load it BEFORE translating, not when a term feels uncertain; the terms you don't notice are the ones that drift. - **[docs/i18n/translation-prompt.md](../../../docs/i18n/translation-prompt.md)** — the automated pipeline's calibrated machine-consumed template. Agents using this skill do not render it; the terminology table is the only repository file the automated renderer injects, while this skill and `translation-rules.md` remain binding for agent-authored translations. @@ -25,7 +25,7 @@ These are authoritative; read them at the source so this skill never drifts out ## Find the work -- `pnpm run verify-translation-pairing --list` prints every in-scope document as missing / out-of-sync / ok — the work list for a translation batch. +- `pnpm run verify-translation-pairing --list` prints every in-scope document as missing / out-of-sync / ok. Missing and out-of-sync rows are contract violations; the normal check rejects them. - In a PR that edits paired docs, the work list is the diff itself: every changed side of a pair needs its counterpart updated and the pair re-recorded in the same PR, and the gate goes red if you forget. ## Triage by change type @@ -56,7 +56,7 @@ Do not process every file the same way: 1. Switcher: `[English](foo.md) | 中文` immediately after the Chinese file's H1, `English | [中文](foo.zh.md)` after the English file's H1 — add both if this is a new pair. 2. Record consistency: `pnpm run verify-translation-pairing --write` recomputes and records both sides' full blob hashes in `foo.i18n.yaml`. The yaml diff in your PR is the reviewable statement "I confirmed these two say the same thing" — only run it after you actually have. -3. New batch landed? Add the `.md` paths to `required` in [scripts/translation-pairing.manifest.json](../../../scripts/translation-pairing.manifest.json) so the gate ratchets forward. +3. No manifest entry is needed for an ordinary document: every in-scope source requires a pair. Change [scripts/translation-pairing.manifest.json](../../../scripts/translation-pairing.manifest.json) only when the owning policy documents a genuine generated, instructional, or bilingual-by-construction exclusion. ## Verify the mechanical and human halves diff --git a/AGENTS.md b/AGENTS.md index 64259784fb..a8f811229a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,6 +50,7 @@ Package groups: [packages/README.md](packages/README.md). ```sh pnpm install # pnpm workspaces, node ^22.19 || >=24 +pnpm run clean # remove build outputs and safe residue from deleted packages pnpm run test # vitest unit tests pnpm run test:coverage # CI coverage gate: per-file 100% on packages/*/*/src pnpm run test:e2e # real-API tests; self-skip without DEEPSEEK_API_KEY @@ -110,7 +111,8 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible behavior change adds or updates a keyless snapshot through a real runnable example in the same PR; package tests, e2e-only assertions, and mock-only fixtures do not substitute for the assembled application transcript. Fixtures must replay on macOS/Linux; fix fixtures, not normalizers. - **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up. -- **Keep PRs coherent and merge with merge commits.** Split an independently meaningful feature or design decision into a separate or stacked PR when combining it obscures ownership, intent, or verification. Never squash/rebase or rewrite pushed branches; put a review fix on its introducing PR, then merge down the stack ([guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)). +- **Keep PRs coherent; use merge commits.** Split independent features or design decisions when combining them obscures ownership, intent, or verification. Never squash, rebase, or rewrite pushed branches; fix the introducing PR, then merge down its stack ([guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)). +- **Label PRs:** exactly one kind (`feature`/`bug-fix`/`doc`/`testing`/`cleanup`), all matching areas; the [taxonomy](.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md) remains extensible for recurring distinctions. - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). - Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.i18n.yaml b/apps/cli/README.i18n.yaml similarity index 67% rename from .agents/notes/implemented/feature/2026-07-21-tui-no-banner.i18n.yaml rename to apps/cli/README.i18n.yaml index e5a2eb9f94..abe51abc2f 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-21-tui-no-banner.md: a6e0956f289cfc810da766fd0cae94b97baf5280 -2026-07-21-tui-no-banner.zh.md: acc5614727cf67881832af1685be557d675696e7 +README.md: 42d2a9641cf5d497c9aae45d9f60fce4498addb9 +README.zh.md: 0a62f8bb72e2cf2dbe045d28b81768bf4df800de diff --git a/apps/cli/README.md b/apps/cli/README.md index 50571632c0..42d2a9641c 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -1,5 +1,7 @@ # `@deepseek-ai/dsh` +English | [中文](README.zh.md) + The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are product assemblies over `packages/*` libraries. Plain `dsh` boots the interactive TUI coding agent, `dsh -p "task"` runs one headless turn, and `dsh web` serves the browser UI. Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`) and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped `cordis.yml` value when a flag is absent) and validity, and rejects a bad value at boot. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md new file mode 100644 index 0000000000..0a62f8bb72 --- /dev/null +++ b/apps/cli/README.zh.md @@ -0,0 +1,29 @@ +# `@deepseek-ai/dsh` + +[English](README.md) | 中文 + +`dsh` 命令行入口遵循 `apps/` 组装层:`apps/*` 是位于 `packages/*` 库之上的产品组装。直接运行 `dsh` 会启动交互式 TUI 编码 agent(智能体),`dsh -p "task"` 运行一个无头轮次,`dsh web` 则提供浏览器 UI。 + +Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`),`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 `cordis.yml` 值)和有效性的唯一真源,并在启动时拒绝错误值。 + +TUI 界面: + +- 启动已交付的默认配置(`examples/tui-agent/cordis.yml`),或由 `--config <path>` 指定的树(演示/测试用于启动其他示例树的逃生口),并通过 [`dsh-app-boot`](../../packages/ui/app-boot/README.md) 完成启动; +- 使用 `dsh --resume <session-id>` 恢复已持久化会话。当 Node 宿主公开 `process.execve` 时,还会提供 TUI 的原地移交宿主:选择器预检并刷新当前会话后,宿主会释放应用,并以规范化的 `dsh --resume <id>` 替换进程;不支持进程替换的运行时保留屏幕上显示的命令回退。该标志通过 `RESUME_SESSION_ID_KEY` 在启动上下文中提供 id(不使用环境变量),已交付的配置通过 `!!js` 读取它;缺失或无法读取的 id 会明确报错,而不会创建新会话; +- 将 **调用目录** 视为 workspace:会话、相对路径和 workspace 指令都从 cwd 解析; +- 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它; +- 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`.env` 填补环境缺口(环境中已有的值 > 项目 `.env` > 个人 `.env`),`config.yaml` 则修补已启动的树。 + +Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root <path>` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 + +`DSH_TOOLS_MODE` 为整个 Web/无头进程选择工具呈现模式:可选值为 `native`(未设置时的 schema 默认值)、`code`(仅含 `run_code` 的 Code Mode 协议接口)或 `both`;任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seam:Loader 组合是静态的,因此该设置作用于整个进程;待 Web UI 负责逐会话工具模式选择后便会移除。TUI 界面会忽略该变量(其配置树固定了自身模式)。 + +## 安装(开发机) + +将从源码运行的启动器符号链接到 PATH 上;它通过自身真实路径解析 checkout,因此代码更改会在下次启动时生效,无需构建: + +```sh +ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh +``` + +`pnpm run dsh` 从仓库根目录运行同一入口并直接转发参数,例如 `pnpm run dsh -p "task"`。构建形式(`lib/bin.js`,通过 `pnpm run build`)会在普通 Node 下启动同一配置。 diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index bd0c45f9f8..d6b432d4c6 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -233,8 +233,8 @@ - id: ui-theme name: '@deepseek-ai/dsh-client-ui-theme' -- id: i18n - name: '@deepseek-ai/dsh-client-i18n' +- id: locale + name: '@deepseek-ai/dsh-client-locale' - id: ui-layout name: '@deepseek-ai/dsh-client-ui-layout' @@ -242,6 +242,15 @@ - id: ui-sidebar name: '@deepseek-ai/dsh-client-ui-sidebar' +- id: ui-settings + name: '@deepseek-ai/dsh-client-ui-settings' + +- id: ui-settings-general + name: '@deepseek-ai/dsh-client-ui-settings-general' + +- id: ui-models + name: '@deepseek-ai/dsh-client-ui-models' + - id: ui-conversation name: '@deepseek-ai/dsh-client-ui-conversation' diff --git a/apps/cli/package.json b/apps/cli/package.json index 647a464c3c..937db88382 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -23,12 +23,15 @@ "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-hmr": "workspace:^", - "@deepseek-ai/dsh-client-i18n": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", + "@deepseek-ai/dsh-client-ui-models": "workspace:^", "@deepseek-ai/dsh-client-ui-question": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings-general": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^", diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 4db4861b93..05947889b2 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -42,7 +42,16 @@ "path": "../../packages/client/ui-theme" }, { - "path": "../../packages/client/i18n" + "path": "../../packages/client/ui-settings" + }, + { + "path": "../../packages/client/ui-settings-general" + }, + { + "path": "../../packages/client/ui-models" + }, + { + "path": "../../packages/client/locale" }, { "path": "../../packages/client/ui-layout" diff --git a/apps/web/tests/code-mode-fixture.snapshot.ts b/apps/web/tests/code-mode-fixture.snapshot.ts index e862474348..46549179be 100644 --- a/apps/web/tests/code-mode-fixture.snapshot.ts +++ b/apps/web/tests/code-mode-fixture.snapshot.ts @@ -17,10 +17,21 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true }, - { id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', url: '/plugins/i18n.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] }, { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { + id: '@deepseek-ai/dsh-client-ui-workspace', + dir: 'ui-workspace', + url: '/plugins/ui-workspace.js', + rev: 'fx', + inject: [ + '@deepseek-ai/dsh-client-runtime', + '@deepseek-ai/dsh-client-ui-conversation', + '@deepseek-ai/dsh-client-ui-sidebar', + ], + }, { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, ] @@ -94,9 +105,14 @@ function visibleText(element: Element): string { /** Open the fixture history session (the alpha log carrying the run_code turn) and scroll to its tail. */ async function openFixtureSession(): Promise<void> { const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) - const group = within(tree).getByText('4 sessions').closest('[role="treeitem"]') + const group = within(tree).getByText('4 sessions').closest<HTMLElement>('[role="treeitem"]') if (group === null) throw new Error('fixture Workspace group missing') - fireEvent.click(group) + if (group.getAttribute('aria-expanded') === 'false') { + fireEvent.click(within(group).getByText('fixture')) + await waitFor(() => { + expect(within(tree).getByText('4 sessions').closest('[role="treeitem"]')?.getAttribute('aria-expanded')).toBe('true') + }) + } const session = await within(tree).findByText('Fixture 历史会话') fireEvent.click(session) await waitFor(() => { diff --git a/apps/web/tests/session-title.snapshot.ts b/apps/web/tests/session-title.snapshot.ts index c1616bb724..313573ea94 100644 --- a/apps/web/tests/session-title.snapshot.ts +++ b/apps/web/tests/session-title.snapshot.ts @@ -10,9 +10,12 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true }, - { id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', url: '/plugins/i18n.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] }, { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { id: '@deepseek-ai/dsh-client-ui-settings', dir: 'ui-settings', url: '/plugins/ui-settings.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-sidebar'] }, + { id: '@deepseek-ai/dsh-client-ui-settings-general', dir: 'ui-settings-general', url: '/plugins/ui-settings-general.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings', '@deepseek-ai/dsh-client-locale'] }, + { id: '@deepseek-ai/dsh-client-ui-models', dir: 'ui-models', url: '/plugins/ui-models.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] }, { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, { id: '@deepseek-ai/dsh-client-ui-workspace', dir: 'ui-workspace', url: '/plugins/ui-workspace.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation', '@deepseek-ai/dsh-client-ui-sidebar'] }, { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, @@ -94,10 +97,10 @@ it('projects initial and revised durable titles through the built nine-plugin fi }) const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) - const projectCount = await within(tree).findByText('4 sessions') - const projectRow = projectCount.closest<HTMLElement>('[role="treeitem"]') - if (projectRow === null) throw new Error('fixture project row missing') - fireEvent.click(projectRow) + // The fixture Intent selects the workspace, so the current-group effect + // already expanded it; clicking the header would now collapse (the twist + // stays live since intent stopped forcing expansion). + await within(tree).findByText('4 sessions') const initialLabel = 'Fixture 历史会话' const initialRowLabel = await screen.findByText(initialLabel) diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index 8c5ed4b8c2..063ed71839 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -147,7 +147,7 @@ async function detailsTrack(page: Page): Promise<number> { // Readiness gate: `dsh web` serves ALL nine manifest plugins; until every UI // plugin's client bundle exists and exports apply, the loader fail-louds and // the frame never appears. -const UI_PLUGIN_DIRS = ['connection', 'runtime', 'ui-theme', 'i18n', 'ui-layout', 'ui-sidebar', 'ui-conversation', 'ui-question', 'ui-trajectory'] +const UI_PLUGIN_DIRS = ['connection', 'runtime', 'ui-theme', 'locale', 'ui-layout', 'ui-sidebar', 'ui-settings', 'ui-settings-general', 'ui-models', 'ui-conversation', 'ui-question', 'ui-trajectory'] const ROUND_DONE_MARKER = 'WEB_ROUND_DONE' const notReady = UI_PLUGIN_DIRS.filter((dir) => { const bundle = join(REPO_ROOT, 'packages/client', dir, 'lib/client.js') diff --git a/apps/web/tests/workspace-flow.snapshot.ts b/apps/web/tests/workspace-flow.snapshot.ts index 78ac843a64..b0ff27d522 100644 --- a/apps/web/tests/workspace-flow.snapshot.ts +++ b/apps/web/tests/workspace-flow.snapshot.ts @@ -10,9 +10,12 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true }, - { id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', url: '/plugins/i18n.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] }, { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { id: '@deepseek-ai/dsh-client-ui-settings', dir: 'ui-settings', url: '/plugins/ui-settings.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-sidebar'] }, + { id: '@deepseek-ai/dsh-client-ui-settings-general', dir: 'ui-settings-general', url: '/plugins/ui-settings-general.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings', '@deepseek-ai/dsh-client-locale'] }, + { id: '@deepseek-ai/dsh-client-ui-models', dir: 'ui-models', url: '/plugins/ui-models.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] }, { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, { id: '@deepseek-ai/dsh-client-ui-workspace', @@ -116,10 +119,12 @@ function workspaceChip(): HTMLElement { return chip } -/** Wait for the runtime-owned controlled input to echo a browser edit. */ -async function setComposerText(composer: HTMLElement, value: string): Promise<void> { +/** Edit the runtime-owned controlled input and assert the same-tick echo: + * a deferred echo makes React roll the textarea back mid-IME-composition, + * committing partial keystrokes (e.g. Pinyin "nihao" leaking as "nnini h…"). */ +function setComposerText(composer: HTMLElement, value: string): void { fireEvent.change(composer, { target: { value } }) - await waitFor(() => { expect((composer as HTMLTextAreaElement).value).toBe(value) }) + expect((composer as HTMLTextAreaElement).value).toBe(value) } it('starts a writable page-local draft without inventing a sidebar Workspace', async () => { @@ -127,7 +132,7 @@ it('starts a writable page-local draft without inventing a sidebar Workspace', a const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) const tree = screen.getByRole('tree', { name: 'Sessions' }) - await setComposerText(composer, 'keep this local') + setComposerText(composer, 'keep this local') expect({ headline: visibleText(screen.getByText("Let's start building")), @@ -188,7 +193,7 @@ it('drops the page-local draft on refresh while retaining real Workspaces and Se const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) const tree = screen.getByRole('tree', { name: 'Sessions' }) - await setComposerText(composer, 'discard this page-local draft') + setComposerText(composer, 'discard this page-local draft') const beforeGroup = within(tree).getByText('4 sessions').closest('[role="treeitem"]') if (beforeGroup === null) throw new Error('fixture Workspace projection missing before refresh') @@ -232,7 +237,7 @@ it('keeps a published Session with only cwd membership evidence in Ungrouped', a boot('?fixture&fixtureAttach=fail') const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) - await setComposerText(composer, 'keep this cwd-only session') + setComposerText(composer, 'keep this cwd-only session') fireEvent.click(screen.getByRole('button', { name: 'Send message' })) const tree = screen.getByRole('tree', { name: 'Sessions' }) @@ -267,7 +272,7 @@ it('materializes the automatic Workspace and Session on the first successful sen boot('?fixture=empty') const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) - await setComposerText(composer, 'build a lighthouse') + setComposerText(composer, 'build a lighthouse') fireEvent.click(screen.getByRole('button', { name: 'Send message' })) const tree = screen.getByRole('tree', { name: 'Sessions' }) @@ -296,7 +301,7 @@ it('keeps the published Workspace, Session, and unsent prompt after rejection', boot('?fixture=empty&fixturePrompt=reject') const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) - await setComposerText(composer, 'do not lose this') + setComposerText(composer, 'do not lose this') fireEvent.click(screen.getByRole('button', { name: 'Send message' })) const alert = await screen.findByRole('alert', {}, { timeout: 10_000 }) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index cf0b45c1ff..8f05f75bd1 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -847,7 +847,7 @@ export interface PlanModeConfig { } ``` -Source: [`packages/plan/plan-mode/src/index.ts:58`](../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:57`](../packages/plan/plan-mode/src/index.ts) ## `@deepseek-ai/dsh-pty-local` @@ -2045,12 +2045,15 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) - `@deepseek-ai/dsh-client-connection` — requires `httpServer` · `apiProxy` ([`packages/client/connection/src/index.ts`](../packages/client/connection/src/index.ts)) -- `@deepseek-ai/dsh-client-i18n` ([`packages/client/i18n/src/index.ts`](../packages/client/i18n/src/index.ts)) +- `@deepseek-ai/dsh-client-locale` ([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) - `@deepseek-ai/dsh-client-modules` — requires `httpServer` · `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) - `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) - `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-models` ([`packages/client/ui-models/src/index.ts`](../packages/client/ui-models/src/index.ts)) - `@deepseek-ai/dsh-client-ui-question` — requires `tools` · `userInteraction` ([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-settings` ([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-settings-general` ([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts)) - `@deepseek-ai/dsh-client-ui-theme` ([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts)) - `@deepseek-ai/dsh-client-ui-trajectory` ([`packages/client/ui-trajectory/src/index.ts`](../packages/client/ui-trajectory/src/index.ts)) diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-banner-sweep.i18n.yaml b/docs/cookbook/maintaining-dsh-code-review.i18n.yaml similarity index 66% rename from .agents/notes/implemented/feature/2026-07-21-tui-banner-sweep.i18n.yaml rename to docs/cookbook/maintaining-dsh-code-review.i18n.yaml index a06145f092..b983ffd591 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-banner-sweep.i18n.yaml +++ b/docs/cookbook/maintaining-dsh-code-review.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 -2026-07-21-tui-banner-sweep.md: c146424d53e75a72b63e346f87a5bbd206d67350 -2026-07-21-tui-banner-sweep.zh.md: 01cc153e88f067b7b8d2eb6317648f3892fe8a5a +maintaining-dsh-code-review.md: 2b5d0d926ae922f2650daac33cf35991cb71c5e5 +maintaining-dsh-code-review.zh.md: c0e8b64fde3a67174878b4b0665712c9ba2e67c0 diff --git a/docs/cookbook/maintaining-dsh-code-review.md b/docs/cookbook/maintaining-dsh-code-review.md index 8af449b749..2b5d0d926a 100644 --- a/docs/cookbook/maintaining-dsh-code-review.md +++ b/docs/cookbook/maintaining-dsh-code-review.md @@ -1,5 +1,7 @@ # Maintaining the dsh-code-review skill +English | [中文](maintaining-dsh-code-review.zh.md) + The [`dsh-code-review`](../../.agents/skills/dsh-code-review/SKILL.md) skill is kept current by a single designated operator running a private periodic maintenance tool. This cookbook is the entry point for that operator — and for anyone taking over the role — and for repo contributors who want to understand why skill updates arrive as small periodic PRs rather than one-off audits. The workflow itself is specified in the [human-review skill-maintenance Agent Note](../../.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.md). ## What the maintainer receives diff --git a/docs/cookbook/maintaining-dsh-code-review.zh.md b/docs/cookbook/maintaining-dsh-code-review.zh.md new file mode 100644 index 0000000000..c0e8b64fde --- /dev/null +++ b/docs/cookbook/maintaining-dsh-code-review.zh.md @@ -0,0 +1,64 @@ +# 维护 dsh-code-review skill + +[English](maintaining-dsh-code-review.md) | 中文 + +[`dsh-code-review`](../../.agents/skills/dsh-code-review/SKILL.md) skill(技能)由一名指定操作员通过私有的周期维护工具持续更新。本实操手册(cookbook)既是该操作员和接任者的入口,也帮助仓库贡献者理解为何 skill 更新会以小型周期 PR(Pull Request)的形式出现,而不是一次性审计。工作流本身由[人工评审 skill 维护 Agent Note(agent 决策记录)](../../.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.md)规定。 + +## 维护者会收到什么 + +每天运行私有工具,并使用 2 个 UTC 日的重叠窗口;在拟议的调度器完成验收运行之前,操作员按相同频率手动调用包装脚本。每周手动恢复运行使用 7 日窗口。工作流会: + +1. 选择指定窗口内合并、且合并 commit 可从 `origin/master` 到达的 PR(每天运行默认选择 2 个 UTC 日,每周运行选择 7 日)。合并 commit 无法到达的 PR(例如父分支被 squash 的堆叠分支),或超出 250 个 commit 获取上限的 PR,会记录到 `skipped-pulls.json` 并跳过,不会中止本次运行。 +2. 收集合并前带 commit 锚点的人工评审反馈(行内评论和评审提交),然后比较反馈时与最终落地的 PR patch。它不获取 PR 会话评论,因为 GitHub 当前状态无法为这些评论提供可抵抗 force-push 的反馈时基线;它也不会把只存在于目标分支的变更作为采纳证据。 +3. 两个独立配置的评审适配器先对来源和采纳情况分类,再根据当前 skill 对双方一致认定已采纳的条目分类。 +4. 主适配器起草完整修订版 `SKILL.md`;两个适配器评审同一份 diff;只要仍有阻塞发现,循环就会继续,直到双方批准。 +5. 工具声明成功前,会针对候选版本运行 `pnpm run doc-sync` 和 `pnpm run lint`。 + +每次运行都把产物保存在操作员的机器上。保存的 diff、候选 `SKILL.md` 和提升 manifest(元数据清单)按时间戳命名,存放在 `~/dsh-code-review-outputs/` 下。manifest 记录源 master commit 与 skill blob、源反馈 ID 和 URL、已落地证据范围、适配器裁决和门禁结果;每个适配器的原始 I/O 留在私有临时目录中,该目录路径会写入通知和 `~/Library/Logs/dsh-code-review-maintainer/` 下的每日日志。维护 worktree 在每次运行后都会恢复为干净状态,避免操作员直接在维护副本中编辑。 + +## 操作员如何处理候选 diff + +某次运行产出候选版本时,macOS 会发出一条带 `dsh-code-review-promote <timestamp>` 提示的通知。 + +1. **根据 diff 本身作出判断。** 不要因为「评审者已经批准」就直接接受:维护者契约规定最终判断由操作员作出。检查清单是否膨胀、是否有历史叙述、是否根据单次事件作出无依据的外推,以及是否与现有 skill 或权威文档重复。 + + ```sh + ls ~/dsh-code-review-outputs/ # every candidate ever produced + less ~/dsh-code-review-outputs/2026-07-16T02-00-00Z.diff + less ~/dsh-code-review-outputs/2026-07-16T02-00-00Z.SKILL.md + less ~/dsh-code-review-outputs/2026-07-16T02-00-00Z.manifest.json + ``` + +2. **与运行产物交叉核验。** 提升 manifest 会把每条拟议规则映射到源反馈和已落地证据;每个适配器的详细 I/O、共识和采纳证据位于本次运行的私有临时目录中(路径见日志)。至少抽查一个候选项:链接的人工评论是否确实支持新增规则?链接的 PR 是否确实采纳了它? + +3. **从三种处理方式中选择一种:** + - **丢弃。** 删除保存的候选版本。下一次运行会依据届时的当前 skill,重新考虑同一份反馈。 + + ```sh + rm ~/dsh-code-review-outputs/2026-07-16T02-00-00Z.{diff,SKILL.md,manifest.json} + ``` + - **暂存成批。** 如果更新很小,可以把候选版本留待与后续版本合并。源 skill 检查仍然适用;如果 `master` 先发生变化,请重新运行分析,或手动 rebase 并重新评审 diff。 + - **提升。** 在仓库的干净 `master` checkout 中运行提升辅助工具。它会刷新 `master`、验证当前 skill 与记录的源 blob 一致、应用保存的 diff,并创建一份 draft PR,其正文包含 manifest 的来源摘要。如果 skill 已发生漂移,它会停止而不是覆盖更新后的指导;操作员仍需在 GitHub 上评审 PR,并选择合并或关闭。 + + ```sh + cd ~/path/to/deepseek-harness # clean master + dsh-code-review-promote 2026-07-16T02-00-00Z + ``` + +4. **不要逐字提交适配器输出。** 提升过程中可以进行小幅编辑,例如收紧措辞、移除只有结合源 PR 上下文才有意义的示例、把规则并入现有规则。这些编辑是预期行为,也保留了工作流所依赖的「评审者判断」。合并前应在该分支上修订这些改动。 + +## 运行未产出候选版本时 + +只要每个非空分类阶段都至少产生一个有效的适配器结果,这就是常见情况。工具会在每日日志中记录「无候选版本」,不发送通知(避免提醒疲劳),然后继续。某天没有 skill 更新,说明工作流运行正常,而不是停滞。 + +## 中断与交接 + +该机制运行在一台机器上。操作员应随时处理以下中断: + +- **错过每日运行。** 2 日重叠窗口会自动覆盖一次漏跑;更长的间隔可通过设置 `DSH_CODE_REVIEW_SINCE=<Nd>` 手动运行包装脚本来恢复。重叠窗口具有幂等性:当前 skill 已包含的指导会被归类为 `covered`,不会再次成为候选项。 +- **适配器提供方中断。** 当两个评审命令解析为逐字节相同的可执行文件时,工具会拒绝运行。某个批次的适配器响应未通过 schema 或 ID 校验时,该批次会整体 fail-closed(其中每个条目都标记为不明确),运行则继续;原始输出会保留以便调试。如果任一适配器在某项操作的所有非空批次中都未产生有效结果,本次运行就会失败、写入失败记录并通知操作员;它绝不会把提供方完全中断折叠成「无候选版本」。 +- **交接给另一名维护者。** 新建一篇取代当前记录的后续 Agent Note:要么把机制移入仓库,要么记录新操作员的私有设置。不要暗中转交工具;Agent Note 的风险章节已把「单维护者关键人风险」列为交接必须记录决策的原因。 + +## 操作员的私有设置位于何处 + +工具源代码、评审适配器、提供方凭据和调度器属于操作员的私有基础设施,按设计位于本仓库之外(参见 Agent Note 的「机制位于何处」章节)。本实操手册和 Agent Note 描述的是**工作流保证什么**;这些保证**如何**实现则属于私有基础设施问题。如果你是新操作员,应以 Agent Note 的 `## Proposal` 各节作为实现依据。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 918382c5fe..ccb8e8f990 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -827,7 +827,7 @@ set(agent: Agent, active: boolean): void Types: [Agent](../core-data-structures/core.md) -Source: [`packages/plan/plan-mode/src/index.ts:142`](../../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:141`](../../packages/plan/plan-mode/src/index.ts) ## `ctx.pty` — `PtyService` @@ -2019,15 +2019,6 @@ get(id: WorkspaceId): Workspace | undefined */ list(): Workspace[] -/** - * Move one accounted, cwd-validated session to the front of its workspace. - * Ungrouped sessions and candidates filtered by the header check are - * no-ops. The owning workspace's relative position never changes. - * @param sessionId - Session whose activity was observed. - * @returns resolution after the possible record write. - */ -async touchSession(sessionId: SessionId): Promise<void> - /** * Resolve by canonical directory path without creating or mutating a * workspace. A missing path rejects during `realpath`; an existing unowned @@ -2038,9 +2029,7 @@ async touchSession(sessionId: SessionId): Promise<void> async resolveByPath(path: string): Promise<Workspace | undefined> ``` -Types: [SessionId](../core-data-structures/core.md) - -Source: [`packages/workspace/workspace/src/index.ts:75`](../../packages/workspace/workspace/src/index.ts) +Source: [`packages/workspace/workspace/src/index.ts:78`](../../packages/workspace/workspace/src/index.ts) ## Inherited `ctx` members (cordis core + loader/hmr/timer) diff --git a/.agents/notes/implemented/feature/2026-07-14-time-context-plugin.i18n.yaml b/docs/cordis-tutorial/01-first-plugin.i18n.yaml similarity index 65% rename from .agents/notes/implemented/feature/2026-07-14-time-context-plugin.i18n.yaml rename to docs/cordis-tutorial/01-first-plugin.i18n.yaml index 28ecd2a765..6ab341e8f5 100644 --- a/.agents/notes/implemented/feature/2026-07-14-time-context-plugin.i18n.yaml +++ b/docs/cordis-tutorial/01-first-plugin.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 -2026-07-14-time-context-plugin.md: 189f75fc12fe12e9dec56fc71ea901ec2eaa8b19 -2026-07-14-time-context-plugin.zh.md: 12671cb891531627fffabb7bd91a1532bc3de6b9 +01-first-plugin.md: b730b7ad7dc9ebd2e5dc8af4f7a83bd58ac7d4d8 +01-first-plugin.zh.md: 1e6901c048f5268ddead0faca85eaf5eef9c7533 diff --git a/docs/cordis-tutorial/01-first-plugin.md b/docs/cordis-tutorial/01-first-plugin.md index 084f9964e1..b730b7ad7d 100644 --- a/docs/cordis-tutorial/01-first-plugin.md +++ b/docs/cordis-tutorial/01-first-plugin.md @@ -1,5 +1,7 @@ # 1. Your first plugin +English | [中文](01-first-plugin.zh.md) + In the loader configuration used here, a Cordis plugin module named-exports an `apply` function. When Cordis loads it, it calls `apply` with a **context** — the `ctx` object through which the plugin registers everything it contributes. ## Write the plugin diff --git a/docs/cordis-tutorial/01-first-plugin.zh.md b/docs/cordis-tutorial/01-first-plugin.zh.md new file mode 100644 index 0000000000..1e6901c048 --- /dev/null +++ b/docs/cordis-tutorial/01-first-plugin.zh.md @@ -0,0 +1,95 @@ +# 1. 编写第一个插件 + +[English](01-first-plugin.md) | 中文 + +在本教程使用的 loader 配置中,Cordis 插件模块通过命名导出提供 `apply` 函数。Cordis 加载模块时,会用一个 **上下文** 调用 `apply`;该上下文就是 `ctx` 对象,插件通过它注册自己贡献的所有内容。 + +## 编写插件 + +在 `tmp/cordis-tutorial` 目录中(参见[环境设置](index.md#setup))创建 `hello.ts`: + +```ts +import type { Context } from 'cordis' + +export const name = 'hello' + +export function apply(ctx: Context) { + console.log('hello from my first plugin') +} +``` + +`name` 导出项是可选的显示元数据;它用于在诊断信息中标识插件。 + +## 组合应用 + +本教程的启动器通过配置组装应用。创建 `cordis.yml`: + +```yaml +- name: './hello.ts' +``` + +该文件是一组 Cordis 配置项的列表。`name` 是模块指定符,可以是相对路径或 NPM 包(package)名;loader 会挂载每个配置项。各项会并发启动,因此它们在列表中的位置不保证插件的加载先后;顺序由服务依赖(`inject`,参见[第 3 章](03-services.md))决定,而非文件中的位置。 + +## 运行 + +```sh +node --import tsx ../../vendor/cordis/bin.js +``` + +预期输出: + +``` +hello from my first plugin +``` + +当没有任何内容继续运行时,进程会自行退出。具体过程如下: + +1. 启动器创建根 `Context`,并挂载 **Loader** 插件。 +2. Loader 读取 `cordis.yml`,解析 `./hello.ts`,然后将其作为子插件挂载。 +3. Cordis 调用你的 `apply(ctx)`。 + +你的文件中没有框架启动代码:插件描述自己的贡献,`cordis.yml` 则组合应用。例如,[TUI agent(智能体)](../../examples/tui-agent/cordis.yml) 就是一个更长的插件组合。 + +## 其他两种插件形态 + +函数是最常见的形态,但 Cordis 接受三种形态: + +```ts +import { Service, type Context } from 'cordis' + +// 1. Function plugin (what you just wrote). +export function apply(ctx: Context) {} + +// 2. Object plugin: an object with an `apply` method. +export const objectPlugin = { + name: 'object-plugin', + apply(ctx: Context) {}, +} + +// 3. Class plugin: a Service subclass (covered in chapter 3). +export class MyService extends Service { + constructor(ctx: Context) { + super(ctx, 'myTutorialService') + } +} +``` + +在你需要公开服务之前,请一直使用函数形态;[第 3 章](03-services.md)介绍了何时应当使用类形态。 + +## 尝试制造错误 + +让 `apply` 抛出异常: + +```ts ignore-check +export function apply(ctx: Context) { + throw new Error('apply exploded') +} +``` + +再次运行:进程会因该错误而终止。插件加载失败必须明确报错,不会仅跳过该配置项。 + +还需要尽早了解一个例外:如果某个配置项的模块无法被 **解析**,例如路径或包名拼写错误,Cordis 会通过 logger 服务报告错误,而不会使进程崩溃。在启动阶段,这条报告可能在 console 导出器开始观察之前丢失。如果新增配置项似乎没有任何效果,请先检查拼写。 + +下一章:[生命周期与 effect](02-lifecycle-and-effects.md):插件卸载时会发生什么。 + +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) diff --git a/docs/cordis-tutorial/02-lifecycle-and-effects.i18n.yaml b/docs/cordis-tutorial/02-lifecycle-and-effects.i18n.yaml new file mode 100644 index 0000000000..6d9e4bb6fd --- /dev/null +++ b/docs/cordis-tutorial/02-lifecycle-and-effects.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 +02-lifecycle-and-effects.md: f1b39e06e9d25c51ab2d76503025e2b6ffe90c73 +02-lifecycle-and-effects.zh.md: a6021ed7475a0045d480810747244274eb5b4198 diff --git a/docs/cordis-tutorial/02-lifecycle-and-effects.md b/docs/cordis-tutorial/02-lifecycle-and-effects.md index 68a0f12ec8..f1b39e06e9 100644 --- a/docs/cordis-tutorial/02-lifecycle-and-effects.md +++ b/docs/cordis-tutorial/02-lifecycle-and-effects.md @@ -1,5 +1,7 @@ # 2. Lifecycle and effects +English | [中文](02-lifecycle-and-effects.zh.md) + A Cordis plugin can be unloaded by a config edit, hot reload, explicit disposal, or loss of a required service. Registrations made through Cordis APIs are effects and are undone when their owning plugin unloads; resources managed outside those APIs must be wrapped in `ctx.effect()`. ## Effects diff --git a/docs/cordis-tutorial/02-lifecycle-and-effects.zh.md b/docs/cordis-tutorial/02-lifecycle-and-effects.zh.md new file mode 100644 index 0000000000..a6021ed747 --- /dev/null +++ b/docs/cordis-tutorial/02-lifecycle-and-effects.zh.md @@ -0,0 +1,98 @@ +# 2. 生命周期与 effect + +[English](02-lifecycle-and-effects.md) | 中文 + +Cordis 插件可能因配置编辑、热重载、显式资源释放或所需服务消失而卸载。通过 Cordis API 建立的注册属于 effect,会在所属插件卸载时撤销;在这些 API 之外管理的资源必须包装在 `ctx.effect()` 中。 + +## Effect + +对于 Cordis 尚未管理的资源,例如定时器、连接或 watcher,应将其包装在 `ctx.effect()` 中并返回 disposer(dispose(资源释放)函数): + +创建 `lifecycle.ts`,将它放在 `tmp/cordis-tutorial` 中: + +```ts +import type { Context } from 'cordis' + +export const name = 'lifecycle-demo' + +function heartbeat(ctx: Context) { + console.log('heartbeat plugin loading') + ctx.effect(() => { + const timer = setInterval(() => console.log('tick'), 200) + return () => { + clearInterval(timer) + console.log('heartbeat cleaned up') + } + }) +} + +export function apply(ctx: Context) { + // Mount a child plugin and keep its fiber to dispose it later. + const fiber = ctx.plugin(heartbeat) + // The demo timer is itself an effect: if THIS plugin is unloaded first, + // the pending callback is cancelled instead of firing on a dead app. + ctx.effect(() => { + const timer = setTimeout(async () => { + await fiber.dispose() + console.log('disposed') + process.exit(0) + }, 700) + return () => clearTimeout(timer) + }) +} +``` + +让 `cordis.yml` 指向该文件: + +```yaml +- name: './lifecycle.ts' +``` + +运行(`node --import tsx ../../vendor/cordis/bin.js`)后会得到: + +``` +heartbeat plugin loading +tick +tick +tick +heartbeat cleaned up +disposed +``` + +请留意三点: + +- `ctx.plugin(heartbeat)` 会把一个**来自代码**的函数挂载为插件,这与 YAML loader 为每个配置项执行的操作相同。函数插件不需要 `apply` 方法:Cordis 会直接调用该函数,其名称只用于诊断。只有对象形态才要求 `apply` 方法,例如 `ctx.plugin({ apply(ctx) { /* ... */ } })`。调用会返回一个 **fiber**,即一个已加载插件实例的运行时句柄。 +- effect 主体在加载期间运行;它返回的 disposer 在卸载期间运行。对于生命周期与插件一致的资源,你绝不需要自行调用 disposer。 +- `fiber.dispose()` 会等该插件的所有清理工作(包括异步 disposer)完成后才结束,并递归卸载它挂载的所有子插件。 + +## Fiber 状态机 + +每个已加载插件实例都拥有一个 fiber,并依次经过以下状态: + +``` +PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED + ↘ FAILED +``` + +- **PENDING**:已经声明,但所需服务(第 3 章)尚不可用。 +- **LOADING / ACTIVE**:`apply` 正在运行/已经完成。 +- **FAILED**:`apply` 或配置校验抛出异常。 +- **UNLOADING / DISPOSED**:disposer 正在运行/一切均已拆除。 + +你会在[第 6 章](06-composition-and-hmr.md)再次遇到 PENDING,它通常就是「为什么我的插件没有输出」的答案。 + +## 已经属于 effect 的操作 + +你很少需要亲自编写 `ctx.effect()`,因为内置注册 API 本身已经是 effect: + +- `ctx.on(event, listener)`:监听器会在卸载时移除([第 4 章](04-events.md))。 +- `ctx.plugin(child)`:子插件会随父插件一同 dispose。 +- 服务注册属于 effect。`ctx.tools.register(...)` 等 harness 注册表也会把返回的 disposer 附着到调用插件上,因此会自动回卷([第 7 章](07-into-the-harness.md))。 + +对于 Cordis 不管理的资源,应在 `ctx.effect()` 内获取它,并返回用于释放资源的 disposer。此后 Cordis 会在卸载期间调用该释放逻辑,热重载时也不例外。 + +有一项顺序注意事项:disposer 会按注册顺序的逆序启动,但多个**异步** disposer 会并发运行。如果拆除步骤必须按顺序执行,请把它们放在同一个 disposer 中,并在其中依次等待每步完成。 + +下一章:[服务](03-services.md):插件如何共享功能。 + +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) diff --git a/docs/cordis-tutorial/03-services.i18n.yaml b/docs/cordis-tutorial/03-services.i18n.yaml new file mode 100644 index 0000000000..d42d5eb250 --- /dev/null +++ b/docs/cordis-tutorial/03-services.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 +03-services.md: 5848132c6ad18338fa893954d45fc20005db6199 +03-services.zh.md: 3c77d0451df9062f1a344e7474e6be141b709197 diff --git a/docs/cordis-tutorial/03-services.md b/docs/cordis-tutorial/03-services.md index 9f62003e99..5848132c6a 100644 --- a/docs/cordis-tutorial/03-services.md +++ b/docs/cordis-tutorial/03-services.md @@ -1,5 +1,7 @@ # 3. Services +English | [中文](03-services.zh.md) + A **service** is a named capability one plugin provides and other plugins consume through `ctx`. In the harness, `ctx.tools`, `ctx.llm`, and `ctx.agents` are services. A consumer names the capability, such as `'tools'`, rather than importing its provider, so configuration can select a provider without changing the consumer. ## Provide a service diff --git a/docs/cordis-tutorial/03-services.zh.md b/docs/cordis-tutorial/03-services.zh.md new file mode 100644 index 0000000000..3c77d0451d --- /dev/null +++ b/docs/cordis-tutorial/03-services.zh.md @@ -0,0 +1,98 @@ +# 3. 服务 + +[English](03-services.md) | 中文 + +**服务**是一个插件提供、其他插件通过 `ctx` 消费的命名功能。在 harness 中,`ctx.tools`、`ctx.llm` 和 `ctx.agents` 都是服务。消费方只命名 `'tools'` 之类的功能,而不导入其提供方,因此配置可以选择提供方,无需修改消费方。 + +## 提供服务 + +创建 `greeter.ts`,将它放在 `tmp/cordis-tutorial` 中: + +```ts +import { Service, type Context } from 'cordis' + +declare module 'cordis' { + interface Context { + greeter: GreeterService + } +} + +export class GreeterService extends Service { + constructor(ctx: Context) { + super(ctx, 'greeter') + } + + greet(who: string) { + return `Hello, ${who}!` + } +} + +export const name = 'greeter' + +export function apply(ctx: Context) { + ctx.plugin(GreeterService) +} +``` + +两部分协同工作: + +- **运行时**:`super(ctx, 'greeter')` 以名称 `greeter` 注册该实例。此后,任何插件都可以通过 `ctx.greeter` 访问它。注册属于 effect,卸载提供方时会移除该服务。 +- **编译时**:`declare module 'cordis'` 块使用 TypeScript 声明合并,把 `greeter` 加入 `Context` 接口,使 `ctx.greeter` 在各处都能通过类型检查。它不会生成代码;没有该声明时,服务在运行时仍能工作,但消费方会失去类型安全。 + +`Service` 子类本身就是插件(第 1 章介绍的类形态),因此 `ctx.plugin(GreeterService)` 会像挂载其他插件一样挂载它。 + +## 使用 `inject` 消费服务 + +创建 `consumer.ts`: + +```ts +import type { Context } from 'cordis' + +export const name = 'consumer' +export const inject = ['greeter'] + +export function apply(ctx: Context) { + console.log(ctx.greeter.greet('world')) +} +``` + +`inject` 列出该插件需要的服务。Cordis 会让插件保持 PENDING,直到列出的每项服务都存在,因此在 `apply` 内可以保证 `ctx.greeter` 已经就绪。`cordis.yml` 中的加载顺序无关紧要:决定插件何时启动的是依赖关系,而不是文件顺序。 + +组合并运行: + +```yaml +- name: './greeter.ts' +- name: './consumer.ts' +``` + +``` +Hello, world! +``` + +交换 `cordis.yml` 中两行的顺序后重新运行,输出仍然相同。尝试彻底移除 `./greeter.ts`:消费方会保持 PENDING,不输出任何内容,既不崩溃,也不会只运行一部分。处于 PENDING 的 fiber 也不会让 Node 的事件循环保持活跃,因此如果组合中没有其他运行项,进程会静默地以状态码 0 退出。[第 6 章](06-composition-and-hmr.md)介绍如何诊断这种状态。 + +## 加载后仍会跟踪依赖关系 + +`inject` 并非一次性的启动检查。如果应用运行期间所需服务消失,例如提供方被卸载或热替换,每个依赖插件也会随之卸载,并在服务恢复后再次加载。结合 effect([第 2 章](02-lifecycle-and-effects.md)),这能防止运行中的消费方保留对不可用服务的引用:依赖消失时,它自己的注册也会回卷。 + +这也是配置中可以替换服务的原因:卸载 `dsh-bash-local` 配置项,挂载另一个 `bash` 提供方,所有注入 `'bash'` 的插件都会干净地重启并使用新实现。 + +## 可选依赖 + +`inject` 用于硬性依赖。如果某项功能缺失时插件仍可运行,请跳过 `inject`,并在使用处探测: + +```ts ignore-check +export function apply(ctx: Context) { + // undefined when no provider is loaded; the plugin still runs. + const greeter = ctx.get('greeter') + console.log(greeter?.greet('maybe') ?? 'no greeter available') +} +``` + +## 命名 + +每个应用中的服务名称共用一个扁平命名空间。请为自有服务添加有辨识度的前缀或命名空间(harness 已占用 `tools` 和 `llm` 等普通名称);生成的[服务目录](../cordis-catalog/services.md)列出 harness 注册的每个名称。 + +下一章:[事件](04-events.md):无需共享服务即可通信。 + +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) diff --git a/docs/cordis-tutorial/04-events.i18n.yaml b/docs/cordis-tutorial/04-events.i18n.yaml new file mode 100644 index 0000000000..cd1fc962a7 --- /dev/null +++ b/docs/cordis-tutorial/04-events.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 +04-events.md: 18f39dc1b693e5fb7e1793ec4b7dcac9cf24db95 +04-events.zh.md: f55a61ff2f43ea42968893d07eb92ea0613b921a diff --git a/docs/cordis-tutorial/04-events.md b/docs/cordis-tutorial/04-events.md index 4c14ba5313..18f39dc1b6 100644 --- a/docs/cordis-tutorial/04-events.md +++ b/docs/cordis-tutorial/04-events.md @@ -1,5 +1,7 @@ # 4. Events +English | [中文](04-events.zh.md) + Services support direct calls; **events** let a plugin announce something without knowing which plugins listen. The harness uses events for interactions such as tool results, model requests, and approval decisions. ## Declare, emit, listen diff --git a/docs/cordis-tutorial/04-events.zh.md b/docs/cordis-tutorial/04-events.zh.md new file mode 100644 index 0000000000..f55a61ff2f --- /dev/null +++ b/docs/cordis-tutorial/04-events.zh.md @@ -0,0 +1,144 @@ +# 4. 事件 + +[English](04-events.md) | 中文 + +服务支持直接调用;**事件**让插件无需知道有哪些插件正在监听,就能发出通知。harness 使用事件处理工具结果、模型请求和审批决定等交互。 + +## 声明、发出与监听 + +创建 `stats.ts`,将它放在 `tmp/cordis-tutorial` 中。它是一项负责计数并在每次变化时发出通知的服务: + +```ts +import { Service, type Context } from 'cordis' + +declare module 'cordis' { + interface Context { + stats: StatsService + } + interface Events { + 'stats/report'(name: string, count: number): void + } +} + +export class StatsService extends Service { + private counts = new Map<string, number>() + + constructor(ctx: Context) { + super(ctx, 'stats') + } + + bump(name: string) { + const next = (this.counts.get(name) ?? 0) + 1 + this.counts.set(name, next) + this.ctx.emit('stats/report', name, next) + } +} + +export const name = 'stats' + +export function apply(ctx: Context) { + ctx.plugin(StatsService) +} +``` + +`interface Events` 合并与第 3 章的 `interface Context` 合并在事件系统中相互对应:它声明事件名称及其监听器签名,因此 `ctx.emit` 和 `ctx.on` 都具有完整类型。`namespace/action` 命名约定让扁平的事件命名空间保持易读。 + +创建 `reporter.ts`: + +```ts ignore-check +import type { Context } from 'cordis' +import type {} from './stats.ts' + +export const name = 'reporter' +export const inject = ['stats'] + +export function apply(ctx: Context) { + ctx.on('stats/report', (name, count) => { + console.log(`[stats] ${name} -> ${count}`) + }) + ctx.stats.bump('tool_call') + ctx.stats.bump('tool_call') + ctx.stats.bump('prompt') +} +``` + +`import type {} from './stats.ts'` 行不会在运行时导入任何内容;它的作用是让 TypeScript 看到声明合并。组合并运行: + +```yaml +- name: './stats.ts' +- name: './reporter.ts' +``` + +``` +[stats] tool_call -> 1 +[stats] tool_call -> 2 +[stats] prompt -> 1 +``` + +因为 `ctx.on()` 属于 effect,监听器会随插件一同消失,绝不需要手动维护 `removeListener`。 + +## 分发模式 + +`emit` 是 5 种分发模式之一。事件采用哪种模式是其契约的一部分,决定了监听器能否返回值、能否并发运行,以及能否彼此短路: + +| 模式 | 调用 | 语义 | +|---|---|---| +| emit | `ctx.emit(name, ...args)` | 同步广播;不会等待或收集返回的 promise 与值。 | +| parallel | `await ctx.parallel(name, ...args)` | 所有监听器并发运行,并一同等待。 | +| serial | `await ctx.serial(name, ...args)` | 监听器按顺序运行并等待;第一个非 `null`/`false`/`undefined` 返回值胜出,并停止后续监听器。 | +| bail | `ctx.bail(name, ...args)` | serial 的同步版本。 | +| waterfall(瀑布式事件) | `ctx.waterfall(name, ...args, next)` | 环绕中间件,见下文。 | + +每个 harness 事件都会在生成的[事件目录](../cordis-catalog/events.md)中记录其模式。 + +## Waterfall:转换或短路 + +waterfall 是实现拦截的模式。每个监听器都会收到参数和一个 `next()` continuation;它可以转换 `next()` 的返回值,也可以不调用 `next()` 就直接返回,从而短路链条的其余部分。Cordis 文档把后一种行为称为否决。创建 `waterfall-demo.ts`: + +```ts +import type { Context } from 'cordis' + +declare module 'cordis' { + interface Events { + 'demo/transform'(input: string, next: () => Promise<string>): Promise<string> + } +} + +export const name = 'waterfall-demo' + +export function apply(ctx: Context) { + // Listener 1: wrap the downstream result. + ctx.on('demo/transform', async (input, next) => { + const downstream = await next() + return downstream.toUpperCase() + }) + + // Listener 2: short-circuit when it owns the decision. + ctx.on('demo/transform', async (input, next) => { + if (input.includes('blocked')) return '** blocked **' + return next() + }) + + void (async () => { + console.log(await ctx.waterfall('demo/transform', 'hello', async () => 'hello')) + console.log(await ctx.waterfall('demo/transform', 'blocked words', async () => 'blocked words')) + })() +} +``` + +让 `cordis.yml` 只指向该文件并运行: + +``` +HELLO +** BLOCKED ** +``` + +按顺序看第二行如何产生:监听器 1 先运行并调用 `next()`,从而调用监听器 2;监听器 2 看到 `blocked` 后直接返回而不调用 `next()`,因此最内层默认逻辑(传给 `ctx.waterfall` 的函数)从未运行;返回途中,监听器 1 再把替换消息转换为大写。 + +由此得到一项纪律:**只负责观察或标注的 waterfall 监听器必须调用 `next()`**;不调用就直接返回代表有意短路。如果日志监听器忘记调用 `next()`,会悄无声息地吞掉所有下游的默认行为。这一点极其重要,已成为本仓库的常设规则([waterfall 语义](../cordis-primer.md#cordis-waterfall-semantics))。 + +harness 使用 waterfall 处理协作插件可以包装或回答的决策:[`agent/request`](../cordis-catalog/events.md#agentrequest--waterfall) 允许插件替换模型调用配置,[`approval/request`](../cordis-catalog/events.md#approvalrequest--waterfall) 允许策略代替用户作答。 + +下一章:[配置](05-config.md):来自 `cordis.yml` 的插件选项。 + +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) diff --git a/docs/cordis-tutorial/05-config.i18n.yaml b/docs/cordis-tutorial/05-config.i18n.yaml new file mode 100644 index 0000000000..deb6f119c2 --- /dev/null +++ b/docs/cordis-tutorial/05-config.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 +05-config.md: fc19add239636fa9e7071d9c77e48595caec1f08 +05-config.zh.md: 52a75e40672c9a08d285677dd14dcd404b925e5a diff --git a/docs/cordis-tutorial/05-config.md b/docs/cordis-tutorial/05-config.md index 09aaf8971d..fc19add239 100644 --- a/docs/cordis-tutorial/05-config.md +++ b/docs/cordis-tutorial/05-config.md @@ -1,5 +1,7 @@ # 5. Configuration +English | [中文](05-config.zh.md) + Each `cordis.yml` entry can carry a `config` block, and the plugin declares a schema that validates it before `apply` runs. Bad config fails the load with a precise error — the plugin never starts half-configured. ## A configurable plugin diff --git a/docs/cordis-tutorial/05-config.zh.md b/docs/cordis-tutorial/05-config.zh.md new file mode 100644 index 0000000000..52a75e4067 --- /dev/null +++ b/docs/cordis-tutorial/05-config.zh.md @@ -0,0 +1,84 @@ +# 5. 配置 + +[English](05-config.md) | 中文 + +每个 `cordis.yml` 配置项都可以携带 `config` 块,插件则声明一个 schema,在运行 `apply` 前验证该块。错误配置会导致加载失败,并给出准确的错误:插件绝不会在配置不完整时启动。 + +## 可配置插件 + +创建 `config-demo.ts`,并将其放在 `tmp/cordis-tutorial` 中: + +```ts +import type { Context } from 'cordis' +import Schema from 'schemastery' + +export const name = 'config-demo' + +export interface Config { + greeting: string + targets: string[] +} + +export const Config: Schema<Config> = Schema.object({ + greeting: Schema.string().default('Hello'), + targets: Schema.array(String).default(['world']), +}) + +export function apply(ctx: Context, config: Config) { + for (const target of config.targets) { + console.log(`${config.greeting}, ${target}!`) + } +} +``` + +导出的 `Config` 既是 TypeScript 接口,也是同名的运行时 schema:消费方获得类型,Cordis 获得验证器。本仓库使用 [Schemastery](https://github.com/shigma/schemastery) 定义 schema;Cordis 本身接受任意 [Standard Schema](https://standardschema.dev/) 验证器,因此将普通对象导出为 `Config` 无法工作。 + +对其进行配置: + +```yaml +- name: './config-demo.ts' + config: + targets: ['alpha', 'beta'] +``` + +运行: + +``` +Hello, alpha! +Hello, beta! +``` + +未提供 `greeting`,因此 schema 默认值会将其补齐:`apply` 始终会收到完整且经过验证的配置。 + +## 明确报错 + +现在向它传入无效内容: + +```yaml +- name: './config-demo.ts' + config: + targets: 'not-an-array' +``` + +``` +ValidationError: invalid config: + - $.targets expected array but got not-an-array (at targets) +``` + +插件的 fiber 进入 FAILED 状态,本教程的启动器打印错误后以状态码 1 退出。如果某个插件的 schema 有效配置命名了不可用的资源或提供方,该插件也应当在能解析该引用时立即拒绝。 + +## 计算得到的配置值 + +本仓库使用的 loader 支持 `!!js` 标签,用于必须在加载时计算的配置值,例如从环境中读取 API key: + +```yaml +- name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY +``` + +`!!js` **仅在 `config` 内有效**。配置项元数据(`name`、`id`、`disabled`、`inject` 等)是静态的;`disabled: !!js ...` 会生成一个真值表达式对象,始终禁用该配置项。详见 [loader 配置](../cordis-primer.md#loader-configuration)。 + +下一章:[组合与 HMR](06-composition-and-hmr.md):将 `cordis.yml` 视为应用。 + +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) diff --git a/docs/cordis-tutorial/06-composition-and-hmr.i18n.yaml b/docs/cordis-tutorial/06-composition-and-hmr.i18n.yaml new file mode 100644 index 0000000000..01f9345de3 --- /dev/null +++ b/docs/cordis-tutorial/06-composition-and-hmr.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 +06-composition-and-hmr.md: 66d6a9d93fe39baa881940ba32388979e2678505 +06-composition-and-hmr.zh.md: ebe63fc26607ae6d9344c4795a7975496ed901b5 diff --git a/docs/cordis-tutorial/06-composition-and-hmr.md b/docs/cordis-tutorial/06-composition-and-hmr.md index bb236cc169..66d6a9d93f 100644 --- a/docs/cordis-tutorial/06-composition-and-hmr.md +++ b/docs/cordis-tutorial/06-composition-and-hmr.md @@ -1,5 +1,7 @@ # 6. Composition and HMR +English | [中文](06-composition-and-hmr.zh.md) + Every capability built so far is a plugin, and `cordis.yml` selects the application's plugin tree. This chapter changes that composition, hot-reloads a plugin, and diagnoses a plugin that never loads. ## Entries are more than a name diff --git a/docs/cordis-tutorial/06-composition-and-hmr.zh.md b/docs/cordis-tutorial/06-composition-and-hmr.zh.md new file mode 100644 index 0000000000..ebe63fc266 --- /dev/null +++ b/docs/cordis-tutorial/06-composition-and-hmr.zh.md @@ -0,0 +1,113 @@ +# 6. 组合与 HMR(热模块替换) + +[English](06-composition-and-hmr.md) | 中文 + +到目前为止构建的每项功能都是插件,`cordis.yml` 则选择应用的插件树。本章会改变这种组合、热重载一个插件,并诊断始终无法加载的插件。 + +## 配置项不只有名称 + +配置项除了 `name` 和 `config`,还接受其他元数据: + +```yaml +- id: greeter # stable identity for this entry + name: './greeter.ts' +- id: consumer + name: './consumer.ts' + disabled: true # keep the entry, skip mounting it +``` + +`id` 为配置项提供稳定标识,使 loader 能区分修改现有配置项与先删除再添加。`disabled: true` 会卸载插件而不删除其配置项;改回原值后,插件以及所有因依赖其服务而处于 PENDING 的插件都会再次加载。 + +组可以嵌套一份配置项子列表,并将其作为一个单元加载和卸载;`isolate` 则为一个组提供某项服务名称的独立实例,因此两个组可以各自看到配置不同的 `bash`,互不影响。这些概念值得在用到之前先了解;[Cordis 入门](../cordis-primer.md)和[服务隔离示例](../user/develop/framework/service.md#service-isolation)介绍了详细内容。 + +## 热模块替换 + +卸载会释放 effect([第 2 章](02-lifecycle-and-effects.md)),加载则遵循依赖关系([第 3 章](03-services.md)),因此 HMR 可以先卸载、再加载,以替换正在运行的插件。`@cordisjs/plugin-hmr` 插件会监视文件,并在保存时执行这一过程。 + +在 `tmp/cordis-tutorial` 中编写 `cordis.yml`: + +```yaml +- id: logger + name: '@cordisjs/plugin-logger-console' +- id: timer + name: '@cordisjs/plugin-timer' +- id: hmr + name: '@cordisjs/plugin-hmr' + config: + root: ['.'] +- id: hello + name: './hello.ts' +``` + +列表中增加了两个支持插件:HMR 通过 Cordis logger 服务记录日志,因此没有 console exporter 时看不到其消息;它还会 `inject` `timer` 服务来实现去抖,如果没有 `@cordisjs/plugin-timer`,它就会永远停在 PENDING,而且不发出任何提示。下一节就讨论这种静默状态。 + +HMR 通过 Loader 的原生辅助工具读取 Node 的 loader 内部结构。请在 tsx 下运行 Cordis: + +```sh +node --import tsx ../../vendor/cordis/bin.js +``` + +现在编辑 `hello.ts`,修改日志消息并保存: + +``` +hello from my first plugin +2026-07-22 15:44:36 [I] hmr watching [ '.' ] +2026-07-22 15:44:39 [I] hmr reload plugin at hello.ts +hello from my EDITED plugin +``` + +旧实例先卸载(其所有 effect 都会回卷),新代码随后加载,`apply` 再次运行。按 Ctrl-C 停止进程。编辑 `cordis.yml` 本身也会触发更新:loader 按 `id` 比较配置项,只挂载、卸载或重新配置发生变化的部分。这就是上述配置项显式携带 `id` 的原因:不带该字段的配置项在每次读取时都会获得一个新生成的 id,所以只要配置文件发生任何编辑,即使自身文本未变,它也会被视为先删除再添加并重新挂载。 + +## 诊断始终无法加载的插件 + +依赖驱动加载也有另一面:如果插件的 `inject` 指定了无人提供的服务,它就会一直等待,不输出任何内容。这不是错误,因为 PENDING 是合法状态,提供方可能稍后才挂载。 + +你可以直接查看这些状态。每个上下文都能枚举插件注册表;创建 `diagnose.ts`: + +```ts +import { FiberState, type Context } from 'cordis' + +export const name = 'diagnose' + +export function apply(ctx: Context) { + setTimeout(() => { + for (const runtime of ctx.registry.values()) { + for (const fiber of runtime.fibers) { + if (fiber.state === FiberState.PENDING) { + console.log(`${fiber.name} is PENDING — a required service is missing`) + } + } + } + }, 500) +} +``` + +再创建一个依赖无法满足的插件 `needs-timer.ts`: + +```ts +import type { Context } from 'cordis' + +export const name = 'needs-timer' +export const inject = ['timer'] + +export function apply(ctx: Context) { + console.log('needs-timer loaded') +} +``` + +```yaml +- name: './needs-timer.ts' +- name: './diagnose.ts' +``` + +运行它(直接执行 `node --import tsx ../../vendor/cordis/bin.js`,按 Ctrl-C 停止): + +``` +needs-timer is PENDING — a required service is missing +``` + +`inject: ['timer']` 没有提供方。向列表添加 `- name: '@cordisjs/plugin-timer'` 后,插件就会加载。如果插件既不执行任何操作,也不报告任何内容,请检查其 fiber 状态。不加 PENDING 过滤条件进行迭代时,还会看到 loader 自身的插件(Loader、Include)处于 ACTIVE,因为配置文件本身也是通过插件挂载的。 + +下一章:[进入 harness](07-into-the-harness.md):把相同模式用于真实的 harness 服务。 + +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) diff --git a/docs/cordis-tutorial/07-into-the-harness.i18n.yaml b/docs/cordis-tutorial/07-into-the-harness.i18n.yaml new file mode 100644 index 0000000000..c85bcad755 --- /dev/null +++ b/docs/cordis-tutorial/07-into-the-harness.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 +07-into-the-harness.md: 6ec42c50fe5059955734fe7bc46117538dafaffc +07-into-the-harness.zh.md: 32b21b008837e2972a53db9d893788dc6a7de9a9 diff --git a/docs/cordis-tutorial/07-into-the-harness.md b/docs/cordis-tutorial/07-into-the-harness.md index a86c538d48..6ec42c50fe 100644 --- a/docs/cordis-tutorial/07-into-the-harness.md +++ b/docs/cordis-tutorial/07-into-the-harness.md @@ -1,5 +1,7 @@ # 7. Into the harness +English | [中文](07-into-the-harness.zh.md) + This chapter registers a model-callable tool with the harness's `tools` service, executes it through the harness tool pipeline, and observes the result event. It remains keyless and does not call a model. ## A tool plugin diff --git a/docs/cordis-tutorial/07-into-the-harness.zh.md b/docs/cordis-tutorial/07-into-the-harness.zh.md new file mode 100644 index 0000000000..32b21b0088 --- /dev/null +++ b/docs/cordis-tutorial/07-into-the-harness.zh.md @@ -0,0 +1,107 @@ +# 7. 进入 harness + +[English](07-into-the-harness.md) | 中文 + +本章会向 harness 的 `tools` 服务注册一个可由模型调用的工具,通过 harness 工具流水线执行它,并观察结果事件。整个示例无需密钥,也不会调用模型。 + +## 工具插件 + +创建 `greet-tool.ts`,将它放在 `tmp/cordis-tutorial` 中: + +```ts +import type { Context } from 'cordis' +import { defineTool } from '@deepseek-ai/dsh-tools' +import { CallId } from '@deepseek-ai/dsh-llm' + +export const name = 'greet-tool' +export const inject = ['tools'] + +export function apply(ctx: Context) { + ctx.tools.register(defineTool({ + name: 'greet', + description: 'Greet the named person.', + parameters: { + name: { type: 'string', required: true, description: 'Who to greet' }, + }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, + async execute(args) { + return `Hello, ${args.name}!` + }, + })) + + // Drive one call through the real execution pipeline, standing in for + // the model. CallId brands the correlation id a provider would issue. + void (async () => { + const result = await ctx.tools.execute({ + callId: CallId('demo-1'), + name: 'greet', + arguments: { name: 'Cordis' }, + signal: new AbortController().signal, + }) + console.log('tool replied:', JSON.stringify(result.content)) + })() +} +``` + +这里的每个模式都来自前几章:`inject: ['tools']`([第 3 章](03-services.md))会让插件等待工具注册表就绪;`ctx.tools.register(...)` 会把注册 disposer 附着到插件([第 2 章](02-lifecycle-and-effects.md)),因此卸载时会注销工具。`defineTool` 将 `parameters` 规约转换为向模型展示的 JSON Schema,推导 `args` 的类型,并在 `execute` 运行前校验模型提供的参数。工具返回由 `output.schema` 声明的规范值;`output.render` 则另行生成原生且持久的结果内容。 + +## 观察插件 + +创建 `tool-logger.ts`。这是一个独立插件,通过 harness 的 `tools/result` 事件观察应用中的每次工具调用: + +```ts +import type { Context } from 'cordis' +import type {} from '@deepseek-ai/dsh-tools' + +export const name = 'tool-logger' +export const inject = ['tools'] + +export function apply(ctx: Context) { + ctx.on('tools/result', (exec, result) => { + const text = result.content + .map(block => (block.type === 'text' ? block.text : '')) + .join('') + console.log(`[tool-logger] ${exec.name} -> ${text}`) + }) +} +``` + +`import type {} from '@deepseek-ai/dsh-tools'` 行会引入该包的声明合并,使 `'tools/result'` 及其 payload 具有类型。这与第 4 章导入 `stats.ts` 的做法相同,只是扩展到了包级别。 + +## 组合并运行 + +```yaml +- name: '@deepseek-ai/dsh-system-prompt' +- name: '@deepseek-ai/dsh-tools' +- name: './tool-logger.ts' +- name: './greet-tool.ts' +``` + +`@deepseek-ai/dsh-tools` 会注入 `systemPrompt` 服务,因为工具需要向系统提示词贡献 schema,所以组合中也要列出该服务的提供方。缺少提供方时,工具插件会像[第 6 章](06-composition-and-hmr.md)所述那样保持 PENDING。 + +```sh +node --import tsx ../../vendor/cordis/bin.js +``` + +``` +[tool-logger] greet -> Hello, Cordis! +tool replied: [{"type":"text","text":"Hello, Cordis!"}] +``` + +logger 会先触发:`tools/result` 在结果物化过程中发出,早于 `execute` 的 promise 向调用方返回结果。两个插件都不知道另一个插件存在,它们由注册表服务和事件连接。 + +## 从这里走向完整 agent(智能体) + +真实 agent 就是这套组合再加上更多插件:LLM(大语言模型)适配器、agent loop(智能体循环)、持久化和前端。对照 [examples/headless-agent/cordis.yml](../../examples/headless-agent/cordis.yml),你现在已经可以读懂其中每个配置项。将 `greet-tool.ts` 加入该文件的副本即可。 + +后续可以阅读: + +- [构建工具](../user/develop/basic/tool.md):深入了解 `defineTool`,包括呈现和更丰富的 schema。 +- [三层功能设计](../user/develop/practice/index.md):harness 如何组织可替换功能。 +- 生成的[服务](../cordis-catalog/services.md)与[事件](../cordis-catalog/events.md)目录:可以注入和监听的所有内容。 +- [架构](../architecture.md):这些插件所处的系统地图。 + +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) diff --git a/docs/cordis-tutorial/index.i18n.yaml b/docs/cordis-tutorial/index.i18n.yaml new file mode 100644 index 0000000000..275c700851 --- /dev/null +++ b/docs/cordis-tutorial/index.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 +index.md: af622ad4e35829c6283c40f1b0019d7959dac973 +index.zh.md: 35bad552ecce9c0496b0ed88b041a8109c81945b diff --git a/docs/cordis-tutorial/index.md b/docs/cordis-tutorial/index.md index 9cf3966117..af622ad4e3 100644 --- a/docs/cordis-tutorial/index.md +++ b/docs/cordis-tutorial/index.md @@ -1,5 +1,7 @@ # Cordis tutorial +English | [中文](index.zh.md) + Cordis is the plugin framework underneath the DeepSeek Harness SDK: a small runtime where every capability — tools, LLM adapters, file access, the agent loop itself — is a plugin mounted into a shared context. This tutorial teaches Cordis hands-on: each chapter is a runnable example you build in a scratch directory inside this repository, ending with a plugin wired into real harness services. The audience is agent developers. You do not need deep TypeScript experience; the [TypeScript notes](#typescript-notes) below explain the syntax that may be unfamiliar, and every chapter shows the exact commands and expected output. @@ -41,6 +43,8 @@ That one-file launcher (see [vendor/cordis/bin.js](../../vendor/cordis/bin.js)) 6. [Composition and HMR](06-composition-and-hmr.md) — the config file as a plugin tree, hot reload, and diagnosing a plugin that never loads. 7. [Into the harness](07-into-the-harness.md) — register a model-callable tool against real harness services. +<a id="typescript-notes"></a> + ## TypeScript notes The examples use three TypeScript features beyond ordinary modern JavaScript: diff --git a/docs/cordis-tutorial/index.zh.md b/docs/cordis-tutorial/index.zh.md new file mode 100644 index 0000000000..35bad552ec --- /dev/null +++ b/docs/cordis-tutorial/index.zh.md @@ -0,0 +1,58 @@ +# Cordis 教程 + +[English](index.md) | 中文 + +Cordis 是 DeepSeek Harness SDK 底层的插件框架:它是一个小型运行时,其中的每项能力,包括工具、LLM(大语言模型)适配器、文件访问乃至 agent loop(智能体循环)本身,都是挂载到共享上下文中的插件。本教程通过动手实践讲解 Cordis:每一章都是一个可以运行的示例,你将在本仓库内的临时目录中逐步构建它,最后把一个插件接入真实的 harness 服务。 + +本教程面向 agent 开发者。你不需要深入掌握 TypeScript;下文的 [TypeScript 说明](#typescript-notes)会解释可能陌生的语法,并且每一章都会给出确切命令和预期输出。 + +如果你想阅读精简的概念参考,而不是逐步实践,请参阅 [Cordis 入门](../cordis-primer.md)。详尽的 API 参考见生成的[事件](../cordis-catalog/events.md)与[服务](../cordis-catalog/services.md)目录,以及 [Cordis 核心 API](../cordis-catalog/core/context.md)页面。 + +## 准备工作 + +你需要克隆本仓库并安装依赖,具体前置条件见[快速入门](../user/guide/quickstart.md)。本教程不需要 API 密钥;所有示例均可在无密钥环境中运行。 + +```sh +git clone https://github.com/deepseek-harness/deepseek-harness.git +cd deepseek-harness +pnpm install +``` + +创建各章使用的临时目录。`tmp/` 已被 git 忽略,因此你在其中写入的任何内容都不会进入版本控制: + +```sh +mkdir -p tmp/cordis-tutorial +cd tmp/cordis-tutorial +``` + +每一章都从该目录运行同一条命令: + +```sh +node --import tsx ../../vendor/cordis/bin.js +``` + +这个单文件启动器(见 [vendor/cordis/bin.js](../../vendor/cordis/bin.js))会创建根 `Context`、挂载 Loader 插件,并让它从当前目录加载 `./cordis.yml`。其余所有内容,包括有哪些插件以及如何配置它们,都来自你稍后将编写的 YAML 文件。`--import tsx` 标志让 Node 无需构建步骤即可运行配置所指向的 TypeScript 文件。 + +## 章节 + +1. [你的第一个插件](01-first-plugin.md):插件是函数,由 loader 挂载。 +2. [生命周期与 effect](02-lifecycle-and-effects.md):由 Cordis 管理的注册会在所属插件卸载时撤销。 +3. [服务](03-services.md):在 `ctx` 上公开一项能力,并通过 `inject` 依赖它。 +4. [事件](04-events.md):类型化事件、广播分发和 waterfall(瀑布式事件)的短路行为。 +5. [配置](05-config.md):读取 `cordis.yml` 中经过校验的配置,并在输入错误时快速失败。 +6. [组合与 HMR(热模块替换)](06-composition-and-hmr.md):把配置文件作为插件树,使用热重载,并诊断始终无法加载的插件。 +7. [进入 harness](07-into-the-harness.md):基于真实的 harness 服务注册一个可由模型调用的工具。 + +<a id="typescript-notes"></a> + +## TypeScript 说明 + +这些示例使用了普通现代 JavaScript 之外的三项 TypeScript 功能: + +- **类型注解** 描述值,但不会改变运行时行为:`ctx: Context` 表示 `ctx` 具备 Cordis 上下文 API,`who: string` 接受文本,而 `string[]` 表示字符串数组。 +- **`import type { Context } from 'cordis'`** 只导入类型信息。它在运行时会消失,因此仅为类型注解使用 `Context` 的插件文件不会增加运行时依赖。 +- **声明合并**(`declare module 'cordis' { ... }`)会为 Cordis 已经声明的接口添加你的条目,例如新 `ctx.greeter` 属性的类型或事件名称。它不会生成任何运行时接线;插件必须另行提供服务或发出事件。第 3 章会完整展示该模式。 + +第 5 章还会使用 `interface` 描述配置对象的字段,并使用 `Schema<Config>` 这类泛型表示 schema 所校验的对象形状。你可以直接照写这些声明;周围的正文会解释每项声明连接了什么。 + +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) diff --git a/docs/core-data-structures/commands.i18n.yaml b/docs/core-data-structures/commands.i18n.yaml new file mode 100644 index 0000000000..ba55abec39 --- /dev/null +++ b/docs/core-data-structures/commands.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 +commands.md: 056c775f4c2e1586447db11821e5c7d56be01881 +commands.zh.md: 1a51305df356d8becf8c5517704dc375cdb8b585 diff --git a/docs/core-data-structures/commands.md b/docs/core-data-structures/commands.md index c36942cf18..056c775f4c 100644 --- a/docs/core-data-structures/commands.md +++ b/docs/core-data-structures/commands.md @@ -1,5 +1,7 @@ # Human Commands +English | [中文](commands.zh.md) + The human-command seam of [`dsh-commands`](../../packages/ui/commands). Interactive adapters use it to discover and directly execute plugin-owned commands for an exact agent without creating a model message. The [command Agent Note](../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md) owns dispatch and lifecycle rationale; the [package README](../../packages/ui/commands/README.md) owns composition and limitations. Source: [`packages/ui/commands/src/index.ts`](../../packages/ui/commands/src/index.ts) diff --git a/docs/core-data-structures/commands.zh.md b/docs/core-data-structures/commands.zh.md new file mode 100644 index 0000000000..1a51305df3 --- /dev/null +++ b/docs/core-data-structures/commands.zh.md @@ -0,0 +1,86 @@ +# 用户命令 + +[English](commands.md) | 中文 + +[`dsh-commands`](../../packages/ui/commands) 的用户命令 seam。交互式适配器用它发现插件拥有的命令,并针对确切的 agent(智能体)直接执行这些命令,而不创建模型消息。[命令 Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md) 负责分发与生命周期的决策依据;[包(package)README](../../packages/ui/commands/README.md) 负责组合方式与限制。 + +来源:[`packages/ui/commands/src/index.ts`](../../packages/ui/commands/src/index.ts) + +## 输入元数据 + +该 seam 公开一个可选的非结构化输入提示。命令的可用性由插件组合决定:每个消费注册表的适配器都会看到全部生效定义。 + +```ts type-equiv +/** Immutable metadata for a command's optional unstructured input. */ +interface CommandInputDescriptor { + /** Placeholder shown before the user supplies free-form input. */ + readonly hint: string +} +``` + +## 定义 + +`CommandDefinition` 是由插件编写的注册定义。注册表会验证并冻结一份与原始注册对象脱离的生效定义。 + +```ts type-equiv +/** Plugin-owned command registration. */ +interface CommandDefinition { + /** Lowercase command name without the leading slash. */ + readonly name: string + /** Human-readable summary used in discovery UI. */ + readonly description: string + /** Optional free-form input hint advertised to capable clients. */ + readonly input?: CommandInputDescriptor + /** Execute against the receiving agent without sending the command to the model. */ + readonly handler: (invocation: CommandInvocation) => CommandResult | Promise<CommandResult> +} +``` + +## 调用与结果 + +适配器拥有取消操作,并传入确切的目标 agent。`rawInput` 紧接在解析后的名称之后,并保留适配器传入的分隔符与后缀。结果会直接呈现给 UI,而不是工具结果或会话事件。 + +```ts type-equiv +/** Invocation passed to one registered command handler. */ +interface CommandInvocation { + /** Exact agent whose human-facing surface received the command. */ + readonly agent: Agent + /** Exact text following the registered command name, including separator whitespace. */ + readonly rawInput: string + /** Cancellation signal owned by the dispatching UI request. */ + readonly signal: AbortSignal +} +``` + +```ts type-equiv +/** Expected command outcome rendered directly by the dispatching UI. */ +type CommandResult = + | { readonly kind: 'success'; readonly text?: string } + | { readonly kind: 'error'; readonly text: string } +``` + +## 发现与解析视图 + +作用域解析后,适配器会获得不含处理器的不可变描述符。`parseCommand()` 在注册表解析前返回 `ParsedCommand`;语法有效的输入仍可能指向不可用的命令。 + +```ts type-equiv +/** Handler-free immutable command view returned to UI adapters. */ +interface CommandDescriptor { + /** Lowercase command name without the leading slash. */ + readonly name: string + /** Human-readable summary used in discovery UI. */ + readonly description: string + /** Optional free-form input hint advertised to capable clients. */ + readonly input?: CommandInputDescriptor +} +``` + +```ts type-equiv +/** Syntactically valid slash command before registry resolution. */ +interface ParsedCommand { + /** Lowercase command name without the leading slash. */ + readonly name: string + /** Exact text following the command name. */ + readonly rawInput: string +} +``` diff --git a/docs/core-data-structures/goal.i18n.yaml b/docs/core-data-structures/goal.i18n.yaml new file mode 100644 index 0000000000..47dc0c1b1c --- /dev/null +++ b/docs/core-data-structures/goal.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 +goal.md: 2e8d296eeda6e5f69c0f92829e347b7f55f41fa9 +goal.zh.md: a9c946e7cd37cf948c7ac0f3e4d0ea35ac80d614 diff --git a/docs/core-data-structures/goal.md b/docs/core-data-structures/goal.md index c0351f2f77..2e8d296eed 100644 --- a/docs/core-data-structures/goal.md +++ b/docs/core-data-structures/goal.md @@ -1,5 +1,7 @@ # Same-session goals +English | [中文](goal.zh.md) + Types shared by the event-sourced goal domain and its policy consumers. The [goal-domain Agent Note](../../.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md) owns the persistence and activation decisions; this page records the literal shapes from [`packages/goal/goal/src/types.ts`](../../packages/goal/goal/src/types.ts). ## Identity and lifecycle diff --git a/docs/core-data-structures/goal.zh.md b/docs/core-data-structures/goal.zh.md new file mode 100644 index 0000000000..a9c946e7cd --- /dev/null +++ b/docs/core-data-structures/goal.zh.md @@ -0,0 +1,145 @@ +# 同会话目标 + +[English](goal.md) | 中文 + +事件溯源目标领域及其策略消费方共享的类型。[目标领域 Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md)负责记录持久化与激活决策;本页记录 [`packages/goal/goal/src/types.ts`](../../packages/goal/goal/src/types.ts) 中的字面形态。 + +## 标识与生命周期 + +`GoalId` 是[品牌化 id](core.md#branded-ids)。调用方通过 `GoalRef` 修改一个确切修订版本;每次获准的持久变更都会递增修订号。 + +```ts type-equiv +/** Compare-and-set identity for one exact goal revision. */ +interface GoalRef { + /** Stable goal identity. */ + readonly id: GoalId + /** Positive revision; every durable mutation increments it. */ + readonly revision: number +} +``` + +持久阶段回答目标发生了什么。进程本地激活状态则另行回答续跑消费方能否开始另一个 Round。 + +```ts type-equiv +/** Durable continuation phase. Activation is process-local and separate. */ +type GoalPhase = + | 'active' + | 'paused' + | 'blocked' + | 'complete' +``` + +阻塞是唯一表示「因问题而停止」的持久状态。由策略负责的阻塞原因会携带一个用于路由、稳定且采用 lower-kebab-case 的代码,以及一段供人和模型阅读的自由文本说明。 + +```ts type-equiv +/** Machine-routable and human-readable explanation for a blocked goal. */ +interface GoalBlockReason { + /** Stable lower-kebab-case classification chosen by the blocking policy. */ + readonly code: string + /** Non-empty explanation shown to humans and models. */ + readonly message: string +} +``` + +```ts type-equiv +/** Full durable state written by every non-clear goal mutation. */ +interface GoalSnapshot extends GoalRef { + /** Human-requested completion objective. */ + readonly objective: string + /** Durable lifecycle phase. */ + readonly phase: GoalPhase + /** Present exactly while `phase` is `blocked`. */ + readonly blockedReason?: GoalBlockReason + /** Total admitted goal-round cap. */ + readonly maxGoalRounds: number +} +``` + +```ts type-equiv +/** Current goal projection, including values derived from the session log. */ +interface GoalView extends GoalSnapshot { + /** Highest admitted round number for this goal. */ + readonly roundsStarted: number + /** Epoch milliseconds of the create mutation. */ + readonly createdAt: number + /** Epoch milliseconds of the latest mutation. */ + readonly updatedAt: number + /** Process-local continuation eligibility; never persisted. */ + readonly activation: GoalActivation +} +``` + +## 持久变更 + +每次变更都是 Round 编号为 0、来源为目标的 `user/message`,其元数据要么是完整快照,要么是清除墓碑。版本、元数据、目标来源和逐字渲染内容共同构成一项回放不变量。 + +```ts type-equiv +/** Full-snapshot goal mutation retained in a model-visible context event. */ +interface GoalSnapshotChangeMeta { + readonly kind: 'goal/change' + readonly version: 1 + readonly operation: Exclude<GoalOperation, 'clear'> + readonly goal: GoalSnapshot + readonly roundsStarted: number + readonly createdAt: number + readonly updatedAt: number +} +``` + +```ts type-equiv +/** Tombstone retained when the current goal is cleared. */ +interface GoalClearChangeMeta { + readonly kind: 'goal/change' + readonly version: 1 + readonly operation: 'clear' + readonly cleared: GoalRef + readonly clearedAt: number +} +``` + +目标状态变更使用 Round `0`。续跑消费方会为每个获准的用户消息轮次标注正数且连续的 Round 编号和当前修订号;回放会拒绝编号缺口、陈旧修订号、已停止阶段和超出上限。 + +```ts type-equiv +/** Message attribution for durable goal state and continuation rounds. */ +interface GoalMessageSource { + readonly kind: 'goal' + readonly goalId: GoalId + readonly revision: number + /** Zero for state changes; positive for admitted continuation rounds. */ + readonly round: number +} +``` + +## 请求与通知 + +创建操作会区分调用方省略的值与部署选择,`create()` 会在内部解析后者。编辑是局部替换,其运行时校验器要求至少提供一个字段。每条变更通知都会携带获准的操作和确切修订号;清除操作不带 `goal`。 + +```ts type-equiv +/** Input whose omitted round cap is resolved by the service configuration. */ +interface CreateGoalRequest { + readonly objective: string + readonly maxGoalRounds?: number +} +``` + +```ts type-equiv +/** Fields changed by an edit; at least one must be present. */ +interface EditGoalRequest { + readonly objective?: string + readonly maxGoalRounds?: number +} +``` + +```ts type-equiv +/** Live notification after one goal mutation has been accepted for logging. */ +interface GoalChanged { + readonly operation: GoalOperation + readonly ref: GoalRef + /** Absent for a clear tombstone. */ + readonly goal?: GoalView +} +``` + +## 服务行为 + +[`GoalService`](../../packages/goal/goal/src/index.ts) 解析创建默认值、执行严格回放折叠、校验确切的活跃 agent 身份、以比较并设置方式执行变更、叠加延迟注入,并发出 `goal/changed` 通知;监听器故障会被隔离。包 [README](../../packages/goal/goal/README.md) 负责记录可调用契约和面向模型的契约。 diff --git a/docs/core-data-structures/lsp.i18n.yaml b/docs/core-data-structures/lsp.i18n.yaml new file mode 100644 index 0000000000..5ab99680eb --- /dev/null +++ b/docs/core-data-structures/lsp.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 +lsp.md: 62b133cbfdf521e067c56355664d7514a613397f +lsp.zh.md: d7000970ec9114bcdad40a39d2712d48b9865529 diff --git a/docs/core-data-structures/lsp.md b/docs/core-data-structures/lsp.md index eb370f6e38..62b133cbfd 100644 --- a/docs/core-data-structures/lsp.md +++ b/docs/core-data-structures/lsp.md @@ -1,5 +1,7 @@ # LSP navigation +English | [中文](lsp.zh.md) + The LSP seam — a [capability seam](../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md) exposing semantic code navigation on one `ctx.lsp` service, split across packages: interface ([dsh-lsp](../../packages/lsp/lsp), `ctx.lsp` + the provider registry), a generic implementation ([dsh-lsp-local](../../packages/lsp/lsp-local), a configured stdio language-server host), and consumer ([dsh-tool-lsp](../../packages/lsp/tool-lsp), the `lsp` tool schema). LSP is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A provider swap does not change how the model asks for navigation. Source: [`packages/lsp/lsp/src/types.ts`](../../packages/lsp/lsp/src/types.ts) diff --git a/docs/core-data-structures/lsp.zh.md b/docs/core-data-structures/lsp.zh.md new file mode 100644 index 0000000000..d7000970ec --- /dev/null +++ b/docs/core-data-structures/lsp.zh.md @@ -0,0 +1,165 @@ +# LSP 导航 + +[English](lsp.md) | 中文 + +LSP seam 是一个[能力 seam](../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md):它在单一 `ctx.lsp` 服务上公开语义代码导航,并拆分到多个包(package):接口([dsh-lsp](../../packages/lsp/lsp),`ctx.lsp` + 提供方注册表)、通用实现([dsh-lsp-local](../../packages/lsp/lsp-local),经过配置的 stdio 语言服务器宿主)和消费方([dsh-tool-lsp](../../packages/lsp/tool-lsp),即 `lsp` 工具 schema)。LSP 是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.md) 中。更换提供方不会改变模型请求导航的方式。 + +源文件:[`packages/lsp/lsp/src/types.ts`](../../packages/lsp/lsp/src/types.ts) + +## 操作与坐标 + +seam 与模型恰好公开 4 项语义查询;该联合是闭合的,因此新增一项查询会通过编译强制要求同步修改 seam、提供方和工具。位置与范围采用从零开始的 UTF-16 坐标,与协议一致;面向模型的工具采用从 1 开始的光标约定,并在输入和输出时进行转换。 + +```ts type-equiv +/** + * The four semantic queries the seam and model expose. A closed union: adding an operation is a + * compile-enforced change across the seam, providers, and the tool. Symbols and call hierarchy are + * deliberately deferred (they need different schemas). + */ +type LspOperation = 'goToDefinition' | 'findReferences' | 'goToImplementation' | 'hover' +``` + +```ts type-equiv +/** A zero-based UTF-16 cursor coordinate, matching the LSP wire convention. */ +interface LspPosition { + /** Zero-based line. */ + readonly line: number + /** Zero-based UTF-16 code-unit offset within the line. */ + readonly character: number +} +``` + +```ts type-equiv +/** A zero-based UTF-16 half-open range `[start, end)`. */ +interface LspRange { + readonly start: LspPosition + readonly end: LspPosition +} +``` + +## 请求 + +每个字段都是必填项:`workspaceRoot` 由调用方提供,`languageId` 来自提供方注册而非请求,超时与结果上限由消费方决定。因此没有字段需要由实现提供默认值,也不存在 `resolve()` 步骤。提供方收到调用方请求和派生的 `languageId`;后者只用于同步瞬态文档,从不参与选择。 + +```ts type-equiv +/** + * A caller's normalized query. Every field is required: `workspaceRoot` is caller-supplied, + * `languageId` comes from the provider registration (not here), and consumers own timeouts and + * result limits — so no field needs implementation defaulting and there is no `resolve()` step. + */ +interface LspQueryRequest { + /** Which semantic query to run. */ + readonly operation: LspOperation + /** The source file to query (relative to `workspaceRoot` or absolute; the provider canonicalizes). */ + readonly filePath: string + /** The zero-based UTF-16 cursor position to query at. */ + readonly position: LspPosition + /** The workspace root the provider resolves against and indexes; required, never defaulted. */ + readonly workspaceRoot: string +} +``` + +```ts type-equiv +/** + * A request as a provider receives it: the caller's {@link LspQueryRequest} plus the `languageId` + * the seam derived from the provider's extension mapping. The language id only synchronizes the + * transient document; it does not participate in selection. + */ +interface LspProviderQuery extends LspQueryRequest { + /** The LSP language id for `filePath`, from this provider's extension mapping. */ + readonly languageId: string +} +``` + +## 结果 + +这是一个闭合的可辨识联合:导航操作规范化为 `locations`,`hover` 规范化为内容或 `null`。消费方使用 `switch` 对 `kind` 做穷尽处理,因此新增分支会使编译失败,直到完成处理。`findReferences` 始终包含声明;提供方在内部强制保证这一点,因此调用方没有对应 flag。`locations` 变体携带 `resolvedWorkspaceRoot`,即提供方对请求中 `workspaceRoot` 的规范形式,也是其 `file:` URI 所相对的根目录;调用方在相对化显示路径时应使用它,而不是可能经过符号链接的请求根目录。 + +```ts type-equiv +/** One resolved location: a document URI and the range within it. */ +interface LspLocation { + /** The target document URI (`file:` or otherwise), verbatim from the server. */ + readonly uri: string + /** The range within the target document. */ + readonly range: LspRange +} +``` + +```ts type-equiv +/** Normalized hover content, or `null` for no hover at the position. */ +interface LspHover { + /** The normalized hover text (markdown or plaintext, provider-joined). */ + readonly contents: string + /** The range the hover applies to, when the server supplied one. */ + readonly range?: LspRange +} +``` + +```ts type-equiv +/** + * The closed result union. Navigation operations (`goToDefinition`, `findReferences`, + * `goToImplementation`) normalize to `locations`; `hover` normalizes to content or `null`. + * Consumers `switch` on `kind` to exhaustiveness so a new arm breaks compilation until handled. + * + * The `locations` variant carries `resolvedWorkspaceRoot`: the provider's canonical form of the + * request's `workspaceRoot`, and the root its `file:` location URIs are relative to. A caller that + * relativizes display paths MUST use this, not the request's (possibly symlinked) `workspaceRoot`; + * otherwise a symlinked workspace misclassifies in-workspace results as external. + */ +type LspQueryResult = + | { readonly kind: 'locations'; readonly locations: readonly LspLocation[]; readonly resolvedWorkspaceRoot: string } + | { readonly kind: 'hover'; readonly hover: LspHover | null } +``` + +## 提供方与服务 + +每个提供方拥有一个稳定的品牌化 `id`,以及一份互斥的、小写且以点开头的扩展名映射。`registerProvider` 会原子保留 id 和每个扩展名:注册无效或冲突时不发布任何内容;其 disposer 会释放所有保留项。每次查询独立选择提供方,且选择与顺序无关;没有匹配项时抛出 `LspError` `LSP_UNAVAILABLE`。该 seam 不公开协议类型、进程或文档控制,也不提供通用 JSON-RPC 逃生口。 + +```ts type-equiv +/** + * A language-server backend registered on `ctx.lsp`. Each provider owns a stable {@link + * LspProviderId} and an extension-to-language-id map (lowercase, leading-dot keys). + * `findReferences` always includes declarations — the provider enforces this internally; callers + * get no flag. + */ +interface LspProvider { + /** Stable provider identity, reserved atomically with the extension mappings. */ + readonly id: LspProviderId + /** Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). */ + readonly extensionToLanguage: Readonly<Record<string, string>> + /** + * Run one query. The seam has already selected this provider and derived `languageId`. + * @param request - the resolved provider query (caller request + derived language id). + * @param signal - optional cancellation; the provider stops its own work when it aborts. + * @returns the normalized, closed-union result. + */ + query(request: LspProviderQuery, signal?: AbortSignal): Promise<LspQueryResult> +} +``` + +```ts type-equiv +/** + * The LSP capability seam (`ctx.lsp`). Owns provider registration/selection and normalized query + * execution; exposes exactly the four operations and no protocol escape hatch. + */ +interface LspService { + /** + * Register a provider, atomically reserving its id and every normalized extension. Any conflict + * or invalid input publishes nothing and throws `LspError`; the returned disposer releases all + * reservations. Disposed with the calling fiber. + * @param provider - the backend to register. + * @returns a synchronous disposer releasing the id and all extension reservations. + */ + registerProvider(provider: LspProvider): () => void + /** + * Select a provider by the file's extension and run one query. Selection is per-query and + * order-independent; no match throws `LspError` `LSP_UNAVAILABLE`. + * @param request - the normalized query. + * @param signal - optional cancellation forwarded to the selected provider. + * @returns the normalized, closed-union result. + */ + query(request: LspQueryRequest, signal?: AbortSignal): Promise<LspQueryResult> +} +``` + +`LspProviderId` 是该 seam 的品牌化 id(来自 [dsh-brand](../../packages/util/brand) 的 `Branded<'LspProviderId'>`);`LspError` 扩展 `HarnessError`,提供 `LSP_INVALID_PROVIDER`、`LSP_CONFLICT`、`LSP_UNAVAILABLE`、`LSP_DISPOSED`、`LSP_UNSUPPORTED_OPERATION` 和 `LSP_MALFORMED_RESPONSE` 等稳定错误码,调用方应按错误码路由,而不是解析 `message`。 diff --git a/docs/core-data-structures/pty.i18n.yaml b/docs/core-data-structures/pty.i18n.yaml new file mode 100644 index 0000000000..6fa7d71d13 --- /dev/null +++ b/docs/core-data-structures/pty.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 +pty.md: 97e1e662d1128ab0555e34f8284cf69d7d9d0d1a +pty.zh.md: b17bc0d2c7bdb2a980df36824bd360ea975967f5 diff --git a/docs/core-data-structures/pty.md b/docs/core-data-structures/pty.md index b205ec64c9..97e1e662d1 100644 --- a/docs/core-data-structures/pty.md +++ b/docs/core-data-structures/pty.md @@ -1,5 +1,7 @@ # Persistent PTY Sessions +English | [中文](pty.zh.md) + Types shared by PTY backends, `ctx.pty`, and the model-facing consumer. The [persistent PTY Agent Note](../../.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md) owns the rationale; this page records the cross-package vocabulary from [`packages/pty/pty/src/types.ts`](../../packages/pty/pty/src/types.ts). ## Identity and readiness diff --git a/docs/core-data-structures/pty.zh.md b/docs/core-data-structures/pty.zh.md new file mode 100644 index 0000000000..b17bc0d2c7 --- /dev/null +++ b/docs/core-data-structures/pty.zh.md @@ -0,0 +1,91 @@ +# 持久 PTY 会话 + +[English](pty.md) | 中文 + +PTY 后端、`ctx.pty` 与面向模型的消费方共享的类型。[持久 PTY Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md) 负责记录决策依据;本页记录来自 [`packages/pty/pty/src/types.ts`](../../packages/pty/pty/src/types.ts) 的跨包(package)词汇。 + +## 标识与就绪 + +`PtySessionId` 是由服务铸造的品牌化 id。可选名称是拥有者本地的显示元数据;授权比较的是确切的所属 `Agent`,而不是名称或猜测的 id。 + +`PtyWaitReason` 说明一次发送为何返回。它与 `PtySessionStatus` 无关:一次发送可能因静默或超时而返回,但顶层 shell 仍然存活;`session_exit` 表示该 shell 已退出,而不是某个任意的前台子进程已退出。 + +```ts type-equiv +/** Why one interactive send returned control to its caller. */ +type PtyWaitReason = 'stdin_read' | 'inferred_idle' | 'timeout' | 'session_exit' +``` + +```ts type-equiv +/** Top-level PTY process status, independent of a send's wait reason. */ +type PtySessionStatus = + | { kind: 'running' } + | { kind: 'exited'; exitCode: number | null; signal: NodeJS.Signals | null } +``` + +## 后端与活跃会话 + +后端负责某个已注册类型的启动方式和就绪检测。`PtyService` 只在初始化成功后才发布返回的会话,随后负责 id 授权与清理。无法清理部分启动资源的后端会以 `PtyBackendCleanupError` 拒绝,从而让资源释放流程保留该清理失败,同时不替换调用方的取消原因。后端会话拥有终端状态,并负责使已捕获资源完全停稳。 + +```ts type-equiv +/** Replaceable provider for one PTY session type. */ +interface PtyBackend { + /** Stable type selected by {@link PtySpawnRequest.type}. */ + readonly type: string + /** Create an unpublished session or reject after cleaning partial resources; cleanup failure uses {@link PtyBackendCleanupError}. */ + spawn(spec: PtyBackendSpawnSpec): Promise<PtyBackendSession> +} +``` + +```ts type-equiv +/** Backend-owned live session retained by {@link PtyService}. */ +interface PtyBackendSession { + /** Initial bounded terminal output returned from `terminal_open`. */ + readonly motd: string + /** Top-level process id when one exists. */ + readonly pid?: number + /** Start one exclusive send operation. */ + startSend(request: PtySendRequest): PtySendOperation + /** Read one bounded page from retained scrollback. */ + read(request: PtyReadRequest): PtyReadResult + /** Signal the verified foreground process group. */ + signal(signal: PtySignal): Promise<PtySignalResult> + /** Observe top-level process status. */ + status(): PtySessionStatus + /** Idempotently close the captured owned process tree and await quiescence. */ + close(reason: string): Promise<void> +} +``` + +## 发送与保留输出 + +一个活跃会话同时只接受一个活动发送。该操作向通用后台任务公开一个消费式输出游标,并向前台调用方公开一个最终结果。`PtyReadResult` 则为有界的会话 scrollback 单独分页。 + +```ts type-equiv +/** Live backend-owned send; exactly one may be active per PTY session. */ +interface PtySendOperation { + /** Resolves after readiness, timeout, cancellation, or top-level process exit. */ + done: Promise<PtySendResult> + /** Consume output produced since the prior call. */ + readOutput(): PtySendRead + /** Request `SIGINT`; returns false after the operation settled. */ + cancel(): boolean +} +``` + +```ts type-equiv +/** Settled result for one foreground or background send. */ +interface PtySendResult { + /** Bounded rendered terminal delta remaining at settlement. */ + viewport: string + /** Why the wait returned; this does not imply arbitrary child-process exit. */ + waitReason: PtyWaitReason + /** Top-level session status observed at settlement. */ + sessionStatus: PtySessionStatus + /** Whether output was dropped from the operation or retained scrollback. */ + truncated: boolean +} +``` + +## 归属与持久性 + +`PtyService` 会将一项等待完成的清理附加到确切的拥有者作用域,拒绝其他拥有者的操作,并让会话在后端或工具插件重载期间保持存活。PTY 状态与原始字节仍局限在进程内。模型输入与有界的返回输出通过现有 `tool/call`、`tool/result` 和任务结果路径持久保存,而不是重复记录 PTY 会话事件。 diff --git a/docs/core-data-structures/session-reference.i18n.yaml b/docs/core-data-structures/session-reference.i18n.yaml new file mode 100644 index 0000000000..2fd211e4e2 --- /dev/null +++ b/docs/core-data-structures/session-reference.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 +session-reference.md: 4898cdd641427a023dde63bfc9759300964c7fac +session-reference.zh.md: 8e36d70241f2565a587bd3c1ee270d99dba47d71 diff --git a/docs/core-data-structures/session-reference.md b/docs/core-data-structures/session-reference.md index d9a73c08c6..4898cdd641 100644 --- a/docs/core-data-structures/session-reference.md +++ b/docs/core-data-structures/session-reference.md @@ -1,5 +1,7 @@ # Session References +English | [中文](session-reference.zh.md) + Structured cross-session reference requests and prepared message contexts. The [package contract](../../packages/context/session-reference) owns canonical URIs, current-surface projection, tag-safe JSON and byte retention, stable errors, and the untrusted model prompt. Host adapters use these types instead of passing their UI mention syntax into the agent core. Source: [`packages/context/session-reference/src/types.ts`](../../packages/context/session-reference/src/types.ts) diff --git a/docs/core-data-structures/session-reference.zh.md b/docs/core-data-structures/session-reference.zh.md new file mode 100644 index 0000000000..8e36d70241 --- /dev/null +++ b/docs/core-data-structures/session-reference.zh.md @@ -0,0 +1,67 @@ +# 会话引用 + +[English](session-reference.md) | 中文 + +结构化的跨会话引用请求与预备消息上下文。[包(package)契约](../../packages/context/session-reference) 负责规范 URI、当前表层投影、标签安全的 JSON 与字节保留、稳定错误和不可信的模型提示词。宿主适配器使用这些类型,而不会把各自 UI 的提及语法传入 agent(智能体)核心。 + +来源:[`packages/context/session-reference/src/types.ts`](../../packages/context/session-reference/src/types.ts) + +## 输入与候选项 + +`SessionReferenceInput` 是与宿主无关的选择。id 具有权威性;label 是随快照携带的显示元数据。 + +```ts type-equiv +/** One source session selected by a host. */ +interface SessionReferenceInput { + /** Opaque source session identity. */ + sessionId: SessionId + /** Optional user-facing mention label. */ + label?: string +} +``` + +`SessionReferenceCandidate` 是面向宿主的发现输出。存在最新会话标题时,它的 label 使用该标题;筛选仍只搜索 session id 和 cwd,绝不搜索 transcript(文本记录)。 + +```ts type-equiv +/** One host-facing candidate from exact session metadata. */ +interface SessionReferenceCandidate { + /** Opaque source session identity. */ + sessionId: SessionId + /** Latest log-backed title, falling back to the opaque session id. */ + label: string + /** Source session working directory, when recorded. */ + cwd?: string + /** Source session creation time in Unix epoch milliseconds. */ + createdAt: number +} +``` + +## 预备消息 + +预备过程保留可读的当前消息内容,并最多返回一个聚合上下文。宿主会把 `contexts` 绑定到该次确切的 `followup()` 或 `steer()` 调用。 + +```ts type-equiv +/** Message payload and the zero-or-one durable snapshot contexts bound to it. */ +interface PreparedReferencedMessage { + /** Readable message content after host mention tokens are removed. */ + content: ContentBlock[] + /** Empty without references; otherwise one aggregated untrusted context. */ + contexts: HookContext[] +} +``` + +## 错误 + +`SessionReferenceError.code` 区分无效配置或输入、自引用、数量限制、源读取失败、预算失败和取消。宿主协议会把这些 code 映射到各自的错误信封,无需检查提示词字节。 + +```ts type-equiv +/** Stable failure codes exposed to host adapters. */ +type SessionReferenceErrorCode = + | 'SESSION_REFERENCE_INVALID_CONFIG' + | 'SESSION_REFERENCE_INVALID_REFERENCE' + | 'SESSION_REFERENCE_SELF_REFERENCE' + | 'SESSION_REFERENCE_TOO_MANY' + | 'SESSION_REFERENCE_READ_FAILED' + | 'SESSION_REFERENCE_BUDGET_EXCEEDED' + | 'SESSION_REFERENCE_CANCELLED' +``` diff --git a/docs/core-data-structures/session-title.i18n.yaml b/docs/core-data-structures/session-title.i18n.yaml new file mode 100644 index 0000000000..f31d9016ac --- /dev/null +++ b/docs/core-data-structures/session-title.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 +session-title.md: 6575bda5fdecf2be15ed7c3288efb0efa759ac8a +session-title.zh.md: 66ec567ac6cef32acef9c50ae3e67155097a76b6 diff --git a/docs/core-data-structures/session-title.md b/docs/core-data-structures/session-title.md index 39e2b00ae5..6575bda5fd 100644 --- a/docs/core-data-structures/session-title.md +++ b/docs/core-data-structures/session-title.md @@ -1,5 +1,7 @@ # Session Titles +English | [中文](session-title.zh.md) + Durable latest-wins title state and the optional asynchronous provider vocabulary owned by [`@deepseek-ai/dsh-session-title`](../../packages/session-title/session-title). The shared LLM helper owns the exact auxiliary request record. Package READMEs own timing, fallback, failure, and fork behavior; the generated [persistence catalog](../persistence-catalog.md) owns the complete event declarations. Sources: [`packages/session-title/session-title/src/index.ts`](../../packages/session-title/session-title/src/index.ts), [`packages/session-title/session-title-llm/src/index.ts`](../../packages/session-title/session-title-llm/src/index.ts) diff --git a/docs/core-data-structures/session-title.zh.md b/docs/core-data-structures/session-title.zh.md new file mode 100644 index 0000000000..66ec567ac6 --- /dev/null +++ b/docs/core-data-structures/session-title.zh.md @@ -0,0 +1,142 @@ +# 会话标题 + +[English](session-title.md) | 中文 + +[`@deepseek-ai/dsh-session-title`](../../packages/session-title/session-title) 所拥有的持久化后写覆盖标题状态与可选异步提供方词汇。共享 LLM(大语言模型)辅助组件负责精确的辅助请求记录。各包(package)README 负责时序、回退、失败与 fork 行为;生成的[持久化日志事件目录](../persistence-catalog.md)负责完整的事件声明。 + +源码:[`packages/session-title/session-title/src/index.ts`](../../packages/session-title/session-title/src/index.ts)、[`packages/session-title/session-title-llm/src/index.ts`](../../packages/session-title/session-title-llm/src/index.ts) + +## 持久标题状态 + +提供方生成修订时会记录 `SessionTitleProviderId`。`SessionTitleEventData` 携带精确的人类消息来源信息,`SessionTitleSnapshot` 则加入 `foldSessionTitle()` 选出的持久事件信封事实。 + +```ts type-equiv +/** Identifies one session-title provider registration. */ +type SessionTitleProviderId = Branded<'SessionTitleProviderId'> +``` + +```ts type-equiv +/** Exact auxiliary model route that produced a title. */ +interface SessionTitleModelProvenance { + /** Registered LLM provider route. */ + readonly provider: string + /** Provider model id. */ + readonly model: string +} +``` + +```ts type-equiv +/** Durable ownership record for an accepted session title. */ +type SessionTitleSource = + | { readonly kind: 'fallback' } + | { + readonly kind: 'provider' + readonly provider: SessionTitleProviderId + readonly model?: SessionTitleModelProvenance + } +``` + +```ts type-equiv +/** Payload of the log-only `session/title` event. */ +interface SessionTitleEventData { + /** Normalized non-empty title text. */ + readonly title: string + /** Exact human `user/message` seqs used to derive this title. */ + readonly messageSeqs: number[] + /** Built-in fallback or registered-provider provenance. */ + readonly source: SessionTitleSource +} +``` + +```ts type-equiv +/** Latest folded title plus the title event's durable envelope facts. */ +interface SessionTitleSnapshot extends SessionTitleEventData { + /** Seq of the latest `session/title` event. */ + readonly eventSeq: number + /** Timestamp of the latest `session/title` event. */ + readonly updatedAt: number +} +``` + +## 辅助请求记录 + +共享 LLM 辅助组件会在调用模型前,记录每一项已经过验证且可分发的标题请求。即使后续生成失败,载荷仍会复现模型可见的系统输入与消息输入、路由、输出上限、提供方归属和源消息归因。 + +```ts type-equiv +/** Exact model-visible request recorded before one auxiliary title dispatch. */ +interface SessionTitleLlmRequestEventData { + /** Registered title-provider identity responsible for the request. */ + readonly titleProvider: SessionTitleProviderId + /** Exact human `user/message` seqs represented in `messages`. */ + readonly messageSeqs: number[] + /** Exact auxiliary LLM route. */ + readonly route: SessionTitleModelProvenance + /** Exact auxiliary system prompt. */ + readonly system: string + /** Exact auxiliary message list. */ + readonly messages: Message[] + /** Exact auxiliary output-token cap. */ + readonly maxTokens: number +} +``` + +## 提供方输入与输出 + +服务会对截至某一修订的合格消息创建快照。提供方返回的 seq 仅可来自该请求;由服务负责的接受过程会验证顺序、规范化标题、强制执行字节上限并追加来源信息。 + +```ts type-equiv +/** One eligible human text message exposed to title providers. */ +interface SessionTitleUserMessage { + /** Source `user/message` event seq. */ + readonly seq: number + /** Exact concatenated text-block content. */ + readonly text: string +} +``` + +```ts type-equiv +/** Automatic generation cadence owned by a registered provider. */ +type SessionTitleAutomaticMode = 'first-message' | 'all-user-messages' +``` + +```ts type-equiv +/** Immutable input supplied to one title-provider call. */ +interface SessionTitleProviderRequest { + /** Live session being titled. */ + readonly session: Session + /** All eligible human messages through this generation revision. */ + readonly messages: readonly SessionTitleUserMessage[] + /** Exact current logged main-request route, when one has been recorded. */ + readonly route?: SessionTitleModelProvenance + /** Cancellation for supersession, disposal, timeout composition, or the explicit caller. */ + readonly signal: AbortSignal +} +``` + +```ts type-equiv +/** Provider output before service-owned normalization and durable acceptance. */ +interface SessionTitleProviderResult { + /** Proposed title text. */ + readonly title: string + /** Exact seqs from `request.messages` used by this result. */ + readonly messageSeqs: readonly number[] + /** Auxiliary LLM route, when generation used a model. */ + readonly model?: SessionTitleModelProvenance +} +``` + +```ts type-equiv +/** One optional asynchronous title implementation registered with the service. */ +interface SessionTitleProvider { + /** Stable provider identity recorded in title provenance. */ + readonly id: SessionTitleProviderId + /** When new human prompts start automatic generation. */ + readonly automatic: SessionTitleAutomaticMode + /** + * Produce one title revision. + * @param request - message snapshot, current route, session, and cancellation. + * @returns proposed title plus exact input seqs and optional model provenance. + */ + generate(request: SessionTitleProviderRequest): Promise<SessionTitleProviderResult> +} +``` diff --git a/docs/core-data-structures/spill.i18n.yaml b/docs/core-data-structures/spill.i18n.yaml new file mode 100644 index 0000000000..4cd4cc5e1e --- /dev/null +++ b/docs/core-data-structures/spill.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 +spill.md: a798d8143b2849dc0cf49d04e7019ce796cdee45 +spill.zh.md: 1af6939d1d8fd37958cae4f9cf2cbf706b17acd0 diff --git a/docs/core-data-structures/spill.md b/docs/core-data-structures/spill.md index a964064912..a798d8143b 100644 --- a/docs/core-data-structures/spill.md +++ b/docs/core-data-structures/spill.md @@ -1,5 +1,7 @@ # Spill Storage +English | [中文](spill.zh.md) + The spill storage seam — a [capability seam](../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md) that persists a tool's oversized text and returns a model-facing locator plus retrieval guidance, split across packages: interface ([dsh-spill](../../packages/spill/spill), `ctx.spillStore`), implementation ([dsh-spill-local](../../packages/spill/spill-local), private session-scoped files on the host filesystem), and consumer ([dsh-spill-policy](../../packages/spill/spill-policy), the `tools/post-execute` policy). Spill is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). Preview mechanics stay in [dsh-retention](../../packages/util/retention); this seam only saves the final text the policy hands it. Source: [`packages/spill/spill/src/types.ts`](../../packages/spill/spill/src/types.ts) diff --git a/docs/core-data-structures/spill.zh.md b/docs/core-data-structures/spill.zh.md new file mode 100644 index 0000000000..1af6939d1d --- /dev/null +++ b/docs/core-data-structures/spill.zh.md @@ -0,0 +1,85 @@ +# 落盘存储 + +[English](spill.md) | 中文 + +落盘存储 seam 是一项[能力 seam](../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md),它持久保存工具的超大文本,并返回面向模型的定位符与检索指引;该能力拆分到三个包(package):接口([dsh-spill](../../packages/spill/spill),`ctx.spillStore`)、实现([dsh-spill-local](../../packages/spill/spill-local),宿主文件系统中会话作用域的私有文件)和消费方([dsh-spill-policy](../../packages/spill/spill-policy),`tools/post-execute` 策略)。落盘是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇记录在此处,而不在 [core.md](core.md) 中。预览机制仍归 [dsh-retention](../../packages/util/retention) 所有;该 seam 只保存策略交给它的最终文本。 + +源码:[`packages/spill/spill/src/types.ts`](../../packages/spill/spill/src/types.ts) + +## 保存请求 + +`saveText` 是整个 seam:原样持久保存 `content`,并返回不透明的定位符、后端提供的检索提示和准确字节数。请求携带保存时的存储命名空间(`owner`)、内容来源(`source`,用于命名和检查的描述性来源信息,而非访问控制)以及后端可用作命名提示的 `suggestedName`(它不是路径)。 + +```ts type-equiv +/** One request to persist text to a spill artifact. */ +interface SaveTextSpill { + owner: SpillOwner + source: SpillSource + /** + * A caller-suggested base name (e.g. `web_fetch.txt`). The backend sanitizes + * it to a single safe path segment before use — it is a hint, never a path. + */ + suggestedName: string + /** The full text to persist (UTF-8). */ + content: string +} +``` + +```ts type-equiv +/** + * Save-time storage namespace for a spilled artifact. The session id lets a + * backend group storage under the producing session, but the returned + * {@link SpillLocator} is the model-facing handle. Forked sessions inherit + * locators already present in the seeded log; those artifacts are not copied or + * re-owned, and spills produced after the fork use the child session id. + */ +interface SpillOwner { + sessionId: SessionId +} +``` + +`SpillOwner.sessionId` 是保存时的存储命名空间。fork 后的会话会从种子日志继承已有的落盘定位符;这些产物不会被复制或重新取得所有权,fork 后产生的落盘则使用子会话 id。保留期清理可以连同其他旧会话产物一起使旧定位符失效;落盘 seam 不定义逐会话的清理策略。 + +```ts type-equiv +/** + * Provenance of one spilled artifact — recorded by the backend for a readable + * filename and inspection. Not interpreted for access control; purely + * descriptive. + */ +interface SpillSource { + /** The tool whose result was spilled (e.g. `web_fetch`). */ + toolName: string + /** The model-issued call id the result belongs to. */ + callId: CallId + /** A short human label for the artifact (e.g. `result`). */ + label: string +} +``` + +## 结果 + +```ts type-equiv +/** A saved spill artifact: its locator, byte length, and backend-specific retrieval guidance. */ +interface SpillRef { + locator: SpillLocator + bytes: number + retrievalHint: string +} +``` + +`SpillLocator` 是后端返回的[品牌化](core.md#branded-ids)面向模型句柄。本地后端将它渲染为文件系统路径;远程或数据库后端可以渲染 URI、键或命令 token。消费方将它视为不透明值,并使用 `retrievalHint` 渲染,而不是假定 `read` 始终是正确的检索机制。 + +```ts type-equiv +/** + * Opaque model-facing handle for one spilled artifact. A local backend may use a + * filesystem path; a remote or database backend may use a URI or key. Consumers + * render it with {@link SpillRef.retrievalHint}, but do not parse it. + */ +type SpillLocator = Branded<'SpillLocator'> +``` + +## 服务 + +`SpillStore`(`ctx.spillStore`,定义于 [`packages/spill/spill/src/index.ts`](../../packages/spill/spill/src/index.ts))是只有一个方法的抽象服务:`saveText(input) → Promise<SpillRef>`。它持久保存完整的 `content`,并在实际存储失败(权限、ENOSPC、后端不可用)时拒绝。该 seam 只负责存储:不负责保留策略、工具结果替换或检索/搜索 API。 + +本地后端([dsh-spill-local](../../packages/spill/spill-local))写入 `<root>/session-<hash>/<random>-<safeName>`:根目录是已配置或延迟创建的私有(0700)目录,会话子目录采用 `sha256(sessionId)`,并通过排他的仅所有者可访问写入(`open(path, 'wx', 0o600)`)防止预先植入的符号链接重定向写入。其 `locator` 是本地路径,`retrievalHint` 则告知模型在该路径上使用 `read` 或 `grep`。策略消费方([dsh-spill-policy](../../packages/spill/spill-policy))会把超过 `maxInlineBytes` 的纯文本最终结果替换为保留库生成的首尾预览和落盘引用;该过程尽力而为:保存失败时保留原始内联结果,而不会把成功的调用变成 `isError`。 diff --git a/docs/core-data-structures/tasks.i18n.yaml b/docs/core-data-structures/tasks.i18n.yaml new file mode 100644 index 0000000000..f9d14f2163 --- /dev/null +++ b/docs/core-data-structures/tasks.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 +tasks.md: d1f5a6d7b369e6113132f60e493cf87757e20599 +tasks.zh.md: 1562d9401f0f55ac6d6260902b8b1c71d9664d48 diff --git a/docs/core-data-structures/tasks.md b/docs/core-data-structures/tasks.md index 2c7555b84d..d1f5a6d7b3 100644 --- a/docs/core-data-structures/tasks.md +++ b/docs/core-data-structures/tasks.md @@ -1,5 +1,7 @@ # Background Task Runtime +English | [中文](tasks.zh.md) + Types shared by long-running producers, `ctx.tasks`, and task control surfaces. The [runtime Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) owns the design; this page records the literal shapes from [`packages/tasks/tasks/src/types.ts`](../../packages/tasks/tasks/src/types.ts). ## Ids and status diff --git a/docs/core-data-structures/tasks.zh.md b/docs/core-data-structures/tasks.zh.md new file mode 100644 index 0000000000..1562d9401f --- /dev/null +++ b/docs/core-data-structures/tasks.zh.md @@ -0,0 +1,154 @@ +# 后台任务运行时 + +[English](tasks.md) | 中文 + +长时间运行的生产方、`ctx.tasks` 与任务控制接口共用的类型。[运行时 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)负责设计;本页记录 [`packages/tasks/tasks/src/types.ts`](../../packages/tasks/tasks/src/types.ts) 中的字面形状。 + +## ID 与状态 + +`TaskId` 是按 `<kind>-N` 生成的[品牌化 id](core.md#branded-ids)。访问控制依赖拥有者授权,而非 id 的保密性。`TaskKind` 派生自可合并扩展的 map;注册表将各个 kind 视为不透明的 id 命名空间。 + +```ts type-equiv +/** + * Producer-defined task kinds. Plugins extend this map by declaration merging; + * the registry treats every value as an opaque id namespace. + */ +interface TaskKindMap { + bash: 'bash' + subagent: 'subagent' +} +``` + +`TaskStatus` 为 `'running' | 'stopping' | 'completed' | 'killed' | 'failed'`;生产方特有的事实归入 `TaskSnapshot.detail`。 + +## 生产方契约 + +`TaskStart` 声明身份和启动器。运行时会在调用 `run()` 前完成预检,随后提交注册,不再执行可能失败的步骤。生产方拥有执行资源;运行时拥有身份、访问权限和生命周期状态。 + +```ts type-equiv +/** + * Producer declaration passed to {@link TaskService.start}. The runtime + * preflights access and cleanup before invoking {@link run}; the producer owns + * execution resources while the runtime owns identity and lifecycle state. + */ +interface TaskStart { + /** Producer kind — also the id prefix (`bash`, `subagent`, …). */ + kind: TaskKind + /** One-line model-facing label (the command; the delegation description). */ + label: string + /** + * Optional UTF-8 byte cap for each complete model-facing completion notice or + * output read, including control-surface status metadata. + */ + outputLimitBytes?: number + /** + * Owning live agent. Access is fenced by its session id, and agent disposal + * cancels and awaits the task. The instance must be the one currently + * registered under its agent id. Omitting the owner creates an unowned task, + * open to any caller until service disposal. + */ + owner?: Agent + /** + * Start the work after preflight and synchronously return its hooks. Called + * once; a throw leaves nothing registered, and the producer must clean up any + * partially started resources. + */ + run(): TaskHooks +} +``` + +`TaskHooks.done` 是完全停稳边界。可选的 `readOutput` 用来区分会消费输出的流式任务和仅有最终输出的任务。 + +```ts type-equiv +/** Hooks through which the runtime controls and observes producer work. */ +interface TaskHooks { + /** + * Request termination. Must be synchronous, idempotent, and eventually settle + * {@link done}; throws propagate. The optional reason is forwarded verbatim. + */ + cancel(reason?: string): void + /** + * Resolves after the producer releases its resources, not merely when work + * finishes. Must not reject; the runtime converts a rejection to `failed`. + * If teardown cancellation throws, the runtime may force-fail only the + * registry record without claiming that the work stopped. + */ + done: Promise<TaskOutcome> + /** + * Consume output produced since the previous call. The producer formats + * truncation and spill notices. Absence marks a final-output-only task; each + * task has one consuming cursor. + */ + readOutput?(): string +} +``` + +```ts type-equiv +/** Terminal result supplied by a producer through {@link TaskHooks.done}. */ +interface TaskOutcome { + /** How the task ended: finished (`completed`), cancelled (`killed`), or broke (`failed`). */ + status: 'completed' | 'killed' | 'failed' + /** Kind-specific detail rendered into status lines ('exit code: 3', 'max-tokens'). */ + detail?: string + /** Final output for tasks without `readOutput`; stream tasks leave it unset. */ + output?: string +} +``` + +## 消费方视图 + +快照是每次新建的只读投影。`ownerSession` 携带用于授权的共享 `SessionId`;完成监听器则会另行收到用于生命周期清理的确切拥有者对象。另一个接口已经交付终止状态或承诺交付时,`reported` 会抑制完成通知。 + +```ts type-equiv +/** + * A read-only projection of one task, safe to hand to listeners and tools — + * a fresh object per call, never live registry state. + */ +interface TaskSnapshot { + /** The registry-issued id (`<kind>-N`). */ + id: TaskId + /** The producer kind the task was registered with. */ + kind: TaskKind + /** The producer-supplied one-line label. */ + label: string + /** Producer-owned cap for complete model-facing notices and output reads. */ + outputLimitBytes?: number + /** + * Owner session id used for authorization and correlation; absent for + * unowned tasks. Completion listeners receive the exact {@link Agent} + * separately through {@link TaskDoneListener}. + */ + ownerSession?: SessionId + /** Current lifecycle state. */ + status: TaskStatus + /** Kind-specific status detail, present once the producer supplied one (usually terminal). */ + detail?: string + /** Epoch ms when the task was registered. */ + startedAt: number + /** Epoch ms when the task settled; absent while `running`/`stopping`. */ + finishedAt?: number + /** + * True when a kill, read, or wait has reported or committed to report the + * terminal state. Completion surfaces suppress redundant notices when set. + */ + reported: boolean +} +``` + +```ts type-equiv +/** Output and post-read state returned by {@link TaskService.read}. */ +interface TaskRead { + /** + * Stream kinds: the consuming delta since the previous read. Final-output + * kinds: empty while live, the terminal {@link TaskOutcome.output} (or + * empty) once settled — idempotent, never consumed. + */ + text: string + /** The task's state at read time. */ + snapshot: TaskSnapshot +} +``` + +## 服务行为 + +[`TaskService`](../../packages/tasks/tasks/src/index.ts) 提供原子 `start`、限定调用方作用域的 `get` 和 `list`、`read`、`kill`、有界 `wait`、故障隔离的 `onTaskDone` 监听器,以及 `attachSurface` 可用性防线。授权会比较拥有者会话;拥有者清理会选择确切的已注册 `Agent` 实例。包(package)契约见 [`dsh-tasks`](../../packages/tasks/tasks/README.md),面向模型的接口见 [`dsh-tool-tasks`](../../packages/tasks/tool-tasks/README.md)。 diff --git a/docs/core-data-structures/token-meter.i18n.yaml b/docs/core-data-structures/token-meter.i18n.yaml new file mode 100644 index 0000000000..ed05739725 --- /dev/null +++ b/docs/core-data-structures/token-meter.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 +token-meter.md: 05784e294485a11acf0e4c8972e4083b1786c943 +token-meter.zh.md: c0dc55274acf21186f7baa00c377f9135792f888 diff --git a/docs/core-data-structures/token-meter.md b/docs/core-data-structures/token-meter.md index 880ec79d7d..05784e2944 100644 --- a/docs/core-data-structures/token-meter.md +++ b/docs/core-data-structures/token-meter.md @@ -1,5 +1,7 @@ # Token Meter +English | [中文](token-meter.zh.md) + `@deepseek-ai/dsh-token-meter` exposes one detached replay snapshot for request pressure and positional surface pricing. `logRevision` is the number of durable events consumed for every field in the measurement. Source: [`packages/llm/token-meter/src/types.ts`](../../packages/llm/token-meter/src/types.ts) diff --git a/docs/core-data-structures/token-meter.zh.md b/docs/core-data-structures/token-meter.zh.md new file mode 100644 index 0000000000..c0dc55274a --- /dev/null +++ b/docs/core-data-structures/token-meter.zh.md @@ -0,0 +1,43 @@ +# Token 计量 + +[English](token-meter.md) | 中文 + +`@deepseek-ai/dsh-token-meter` 公开一个独立的回放快照,用于表示请求压力与按位置计算的 surface 定价。`logRevision` 表示生成该计量中每个字段时所消费的持久事件数量。 + +来源:[`packages/llm/token-meter/src/types.ts`](../../packages/llm/token-meter/src/types.ts) + +## `TokenMeasurement` + +```ts type-equiv +/** Detached immutable request-pressure and surface snapshot at one consumed log revision. */ +interface TokenMeasurement { + /** Number of durable events consumed; equal to the next unread event seq. */ + readonly logRevision: number + /** Provider or heuristic anchor used for this measurement. */ + readonly baseline: TokenMeasurementBaseline + /** Signed repricing of current surface content relative to the baseline anchor. */ + readonly surfaceDeltaTokens: number + /** Non-negative current request-and-response pressure. */ + readonly totalTokens: number + /** Total heuristic tokens across the current surface. */ + readonly surfaceTokens: number + /** Current surface nodes in positional head-to-tail order. */ + readonly nodes: readonly TokenSurfaceNode[] +} +``` + +`baseline.kind === 'usage'` 表示最近一次成功的提供方调用具有相同的规范请求信封,且当前总量不低于该调用的完整启发式锚点。`estimated` 表示不存在可复用的保守 usage 锚点,因此服务使用固定启发式规则对完整信封和 surface 定价。后续成功请求会替换早先的锚点;有符号的 `surfaceDeltaTokens` 会保留相对于匹配锚点的增长与缩减。`totalTokens` 仍表示请求与响应压力,`surfaceTokens` 则是仅针对 surface 的启发式总量,等于所有节点价格之和。 + +## `TokenSurfaceNode` + +```ts type-equiv +/** One token-priced node in the current ordered session surface. */ +interface TokenSurfaceNode { + /** Durable sequence number of the surface event. */ + readonly seq: number + /** Heuristic tokens for the exact message projected by this node. */ + readonly tokens: number +} +``` + +surface 顺序具有权威性;替换节点的持久 seq 可能高于位置排在其后的节点。该快照不可变,不会随底层回放折叠推进而增长。 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 9d2573305e..db97881ae8 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.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 -development.md: f706d54764bbf79d1f13ccb4c412e7b5717b1edb -development.zh.md: 15f5ae0c514ac412c302a99cb0a662acce510c44 +development.md: c46d84740e6f0a1f67158f39f9ea421cb57165d4 +development.zh.md: 9e13ab258e1db5406f84ece61959a995110578ae diff --git a/docs/development.md b/docs/development.md index f706d54764..c46d84740e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -166,7 +166,7 @@ The [core data structures](core-data-structures/core.md) docs paste source-equiv { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" } ``` -`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `"projection": "public-api"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate also enforces a 1:1 correspondence by document, symbol, and projection, so a block can't go silently unchecked and a stale entry can't linger. `doc-typecheck` skips both fence kinds (they aren't standalone-compilable) and excludes them from its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a block, update the manifest in the same change. +`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `"projection": "public-api"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change. ## Architecture context diff --git a/docs/development.zh.md b/docs/development.zh.md index 15f5ae0c51..9e13ab258e 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -166,7 +166,7 @@ pnpm run demo:acp { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" } ``` -`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `"projection": "public-api"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁还按文档、符号和投影强制 1:1 对应,因此不会有块被静默漏检,也不会有陈旧条目滞留。`doc-typecheck` 跳过两种围栏(它们不能独立编译),并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个块时,请在同一个变更里更新 manifest。 +`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `"projection": "public-api"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。 ## 架构上下文 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 880cde9fd8..8bf4ee19b5 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -36,7 +36,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:52`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:89`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace`](../packages/workspace/workspace), [`workspace-context`](../packages/context/workspace-context) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:111`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:113`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | @@ -64,6 +64,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `internal/dispatch` | - | [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `hmr`, `modules`, `webserver` | | `internal/status` | - | [`agent`](../packages/core/agent) | +| `locale/change` | `locale` (`emit`) | `locale`, `ui-models`, `ui-settings-general` | | `slots/changed` | `runtime` (`emit`) | - | +| `theme/change` | `ui-theme` (`emit`) | `ui-layout`, `ui-theme` | Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program. diff --git a/docs/i18n/README.i18n.yaml b/docs/i18n/README.i18n.yaml index 602699a178..904fe9a701 100644 --- a/docs/i18n/README.i18n.yaml +++ b/docs/i18n/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: 77d7b3210216c7c12d7d06b1ed16396d02ef1d16 -README.zh.md: de15fc3b5f30c1280ce6b38c1afd2475be7f9671 +README.md: 504e042eee5382d92f1b3f007c1d39695ff2ddde +README.zh.md: e39bb2b0ca3e4fc4b831ded50ad91f4f1bf2285a diff --git a/docs/i18n/README.md b/docs/i18n/README.md index 77d7b32102..504e042eee 100644 --- a/docs/i18n/README.md +++ b/docs/i18n/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -This repo's documentation is read by people and agents both inside and outside the company, so the README, Agent Notes, and docs tree are maintained in English and Simplified Chinese. This page defines the pairing contract, the enforcement gate, and the rollout policy; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. The committed agent workflow lives in [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md). +This repo's documentation is read by people and agents both inside and outside the company, so every document in scope is maintained in English and Simplified Chinese. This page defines the pairing contract, enforcement gate, scope, and exclusions; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. The committed agent workflow lives in [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md). ## The pairing contract @@ -23,29 +23,30 @@ This repo's documentation is read by people and agents both inside and outside t `pnpm run verify-translation-pairing` (part of `doc-sync`, which contributors run locally for documentation changes and CI runs exhaustively) enforces the contract mechanically: -1. Every file listed as `required` in [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) has a complete pair. -2. Every pair that exists at all — required or not — is complete and consistent: all three files present, each side's current blob hash equals the recorded one (editing either side without re-confirming the pair goes red), both sides carry the language switcher, and the structural signatures match in order — heading depths, verbatim code blocks (info string and content), table row and column counts, list kinds, ordered-list starts, item counts, and every link target apart from the switcher. +1. Every document in scope has a complete pair. README discovery is case-insensitive on the basename, so `missions/readme.md` is in scope alongside the other documentation roots. +2. Every pair artifact that exists at all is complete and consistent: all three files present, each side's current blob hash equals the recorded one (editing either side without re-confirming the pair goes red), both sides carry the language switcher, and the structural signatures match in order — heading depths, verbatim code blocks (info string and content), table row and column counts, list kinds, ordered-list starts, item counts, and every link target apart from the switcher. 3. Files listed as `excluded` have no `.zh.md` and no `.i18n.yaml` at all. -4. Every date-named document (`yyyy-mm-dd-*.md`) dated on or after the manifest's `requiredSince` cutoff has a complete pair — new date-named Agent Notes merge bilingual from birth. -`pnpm run verify-translation-pairing --list` prints the current pairing state of every document in scope — missing, out-of-sync, or ok — and is the work list for translation batches. It never fails; it reports. +Source-oriented code gates consume an exact `.zh.md` fence sequence as a derivative of its unsuffixed sibling instead of compiling or manifesting the same code twice. The sequence must match in length, order, fence kind, and byte-exact body; otherwise both copies remain independently checked and the pairing gate reports the structural mismatch. + +`pnpm run verify-translation-pairing --list` prints the current pairing state of every document in scope — missing, out-of-sync, or ok. It never fails; `missing` and `out-of-sync` rows identify violations that the normal check rejects. The practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart and re-records the pair** (run the [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill, then `--write`), exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI. The gate's limit, stated plainly: **a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.** It checks hashes and shape; it cannot judge whether the two sides actually say the same thing, or whether the wording is accurate, well-termed, and natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review. -## Scope, exclusions, and rollout +## Scope and exclusions -**Scope**: the root `README.md`, everything under `.agents/notes/**`, `docs/**`, and `python/**`. Package READMEs (`packages/**`) join the scope in a later batch. +**Scope**: every non-vendor README, plus every document under `.agents/notes/**`, `docs/**`, and `python/**`. README matching is case-insensitive on the basename and covers future directories without another manifest edit. Dependency and ignored build-output trees are discovery exclusions, not source documentation. **Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them): - `docs/cordis-catalog/`, `docs/tool-catalog/`, `docs/config-catalog.md`, `docs/persistence-catalog.md`, `docs/module-graph.md`, `docs/agent-lifecycle.md`, `docs/capability-seams.md`, `docs/event-producer-consumer.md`, `docs/graph-atlas.md`, and `docs/tool-execution-pipeline.md` — generated files; their generators emit English only today, so a hand-written translation would go stale on every regeneration. The planned follow-up is to teach the generators to emit Chinese alongside English, at which point these leave the exclusion list. -- `docs/AGENTS.md` and `.agents/notes/**/AGENTS.md` — agent instructions, maintained in English only like the root `AGENTS.md`. +- `docs/AGENTS.md`, `.agents/notes/**/AGENTS.md`, and their `CLAUDE.md` instruction symlinks — agent instructions, maintained in English only like the root `AGENTS.md`. - `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction. - [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior. -**Rollout**: a date-named document (`yyyy-mm-dd-*.md`, i.e. an Agent Note) dated on or after the manifest's `requiredSince` cutoff must merge with its pair. Earlier dates are backlog, including files created on the cutoff's eve. An Agent Note filename records its first-proposed date, so backdating past the cutoff is a review-visible violation. The manifest's `required` list is the current enforcement frontier, not the goal of full coverage. Translation batches add paths to `required`, ratcheting the gate forward. Unlisted documents remain visible in `--list`, while every existing pair is governed by the full contract. Because later edits must update both sides, expand `required` only as fast as translation review can support. +**Universal requirement**: every current or future document in scope must merge as a complete bilingual pair. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) contains only explicit exclusions; there is no per-file rollout list, date cutoff, or README-specific policy class. ## Division of labor diff --git a/docs/i18n/README.zh.md b/docs/i18n/README.zh.md index de15fc3b5f..e39bb2b0ca 100644 --- a/docs/i18n/README.zh.md +++ b/docs/i18n/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此 README、Agent Note(agent 决策记录)与 docs 目录树以英文和简体中文双语维护。本页定义配对契约、强制门禁与推进策略;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。仓库内置的 agent 工作流见 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md)。 +本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此范围内的每篇文档都以英文和简体中文维护。本页定义配对契约、强制门禁、范围与排除规则;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。仓库内置的 agent 工作流见 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md)。 ## 配对契约 @@ -23,29 +23,30 @@ `pnpm run verify-translation-pairing`(`doc-sync`(文档同步门禁)的一环,贡献者会针对文档变更在本地运行,CI 则会完整运行)机械地强制执行这份契约: -1. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 中 `required` 列出的每个文件都有完整配对。 -2. 任何已存在的配对(无论是否 required)都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、双方都带语言切换行、结构签名按序一致:标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。 +1. 范围内的每篇文档都有完整配对。发现 README 时,basename 不区分大小写,因此 `missions/readme.md` 与其他文档根一样属于范围。 +2. 任何已存在的配对产物都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、双方都带语言切换行、结构签名按序一致:标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。 3. 列为 `excluded` 的文件完全没有 `.zh.md`,也没有 `.i18n.yaml`。 -4. 凡文件名符合 `yyyy-mm-dd-*.md` 且日期不早于 manifest(元数据清单)中 `requiredSince` 分界日期的文档,都必须有完整配对;新建的日期命名 Agent Note 从创建起便须配齐中英文。 -`pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前配对状态(missing、out-of-sync 或 ok),是翻译批次的工作清单。它从不失败;它只报告。 +面向源码的代码门禁会把精确的 `.zh.md` 围栏序列视为其无后缀兄弟文件的派生内容,而不会再次编译相同代码或在 manifest 中重复登记。该序列必须在长度、顺序、围栏类型和按字节精确的正文上一致;否则两份副本仍会独立受检,配对门禁也会报告结构不匹配。 + +`pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前配对状态(missing、out-of-sync 或 ok)。它从不失败;其中 missing 与 out-of-sync 行指出普通检查会拒绝的违规。 这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 更新另一侧并重新记录配对**(运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill(技能),再 `--write`),与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。 把门禁的边界说白:**门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。** 它检查记录的 hash 与结构签名;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然;这部分契约由评审者把关,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。 -## 范围、排除与推进 +## 范围与排除 -**范围**:根 `README.md`,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部内容。包(package)README(`packages/**`)在后续批次加入范围。 +**范围**:除 vendor 源码外的全部 README,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部文档。匹配 README 时只看文件名且不区分大小写,因此今后新增的目录无需再修改 manifest。依赖目录和被忽略的构建产物目录只在发现阶段排除,并非源文档。 **排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`): - `docs/cordis-catalog/`、`docs/tool-catalog/`、`docs/config-catalog.md`、`docs/persistence-catalog.md`、`docs/module-graph.md`、`docs/agent-lifecycle.md`、`docs/capability-seams.md`、`docs/event-producer-consumer.md`、`docs/graph-atlas.md` 与 `docs/tool-execution-pipeline.md`:生成文件;生成器目前只输出英文,手写译文在每次重新生成时必然陈旧。计划中的后续工作是让生成器同时输出中文,届时这些文件移出排除清单。 -- `docs/AGENTS.md` 与 `.agents/notes/**/AGENTS.md`:agent 指令,与根 `AGENTS.md` 一样只以英文维护。 +- `docs/AGENTS.md`、`.agents/notes/**/AGENTS.md` 以及指向它们的 `CLAUDE.md` 指令符号链接:agent 指令,与根 `AGENTS.md` 一样只以英文维护。 - `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。 - [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。 -**推进**:以日期命名的文档(`yyyy-mm-dd-*.md`,即 Agent Note),只要标注日期等于或晚于 manifest 的 `requiredSince` 分界日期,合并时就必须配齐双语文件。更早日期的文件属于 backlog(待翻清单),包括分界前夜创建的文件。Agent Note 文件名记录首次提出日期,因此倒填日期绕过分界属于评审可见的违规。manifest 中的 `required` 列表是当前执行红线,并非全量覆盖这一最终目标。翻译批次将路径加入 `required`,使门禁只向前收紧。未列入的文档仍可通过 `--list` 查看,而任何已存在的配对都受完整契约约束。后续修改必须同步更新两侧,因此 `required` 的扩展速度不能超过翻译评审的承载能力。 +**统一要求**:当前及今后纳入范围的每篇文档,合并时都必须构成完整的双语配对。[scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 只包含显式排除项;不存在逐文件推进清单、日期分界或 README 专用政策类别。 ## 分工 diff --git a/docs/i18n/style-samples.md b/docs/i18n/style-samples.md index b20b2a4c90..87ef939730 100644 --- a/docs/i18n/style-samples.md +++ b/docs/i18n/style-samples.md @@ -68,17 +68,17 @@ 对比双语文件的 git 时间戳(无记录方案)——不予采纳:仅调整格式的改动会触发误报,无关修改后再提交译文又会造成漏检。只有基于内容本身的标识(每侧文件的 blob hash 与伴随记录比对),才能承载门禁所声称的语义。 -## ⑦ 推进策略(长段拆分示范) +## ⑦ 统一要求(长段拆分示范) -> **Rollout**: date-named Agent Notes don't wait for a batch — one dated on or after the manifest's `requiredSince` cutoff must merge with its pair, so each new date-named Agent Note is bilingual from birth. For the back-catalog, the `required` list in the manifest is the enforcement frontier, not the goal. […] Pairing a document is a commitment: every later edit to either side must carry the counterpart along, so grow the frontier at the pace translation review is actually resourced, not ahead of it. +> **Universal requirement**: every in-scope document merges as a complete bilingual pair. The manifest contains only explicit exclusions: it has no per-file rollout list, date cutoff, or README-specific policy class. […] Pairing is a continuing obligation: every later edit to either side updates the counterpart and consistency record in the same change. -**推进**:日期命名的 Agent Note 无需等待批量翻译。只要文件名中的日期不早于 manifest(元数据清单)的 `requiredSince` 分界日期,合入时就必须配齐中英文,因此此类 Agent Note 从创建起就要求双语齐备。对于存量文档,manifest 中的 `required` 列表只是当前的执行红线,并非最终目标。(……)一旦文档完成配对,后续修改任一侧都必须同步更新另一侧。因此,应根据实际可投入的翻译评审能力逐步扩展执行红线,不能超前。 +**统一要求**:每篇纳入范围的文档合入时都必须构成完整的双语配对。manifest(元数据清单)只包含显式排除项:其中没有逐文件推进清单、日期分界或 README 专用政策类别。(……)配对是一项持续义务:后续修改任一侧时,都必须在同一变更中同步更新对侧文件和一致性记录。 ## 从样例提炼的要点 - 语体是规范制度文:完整主谓、确定语气;不口语化,也不学术腔。 - 给句子补显式执行主体:英文的被动句和抽象主语,中文写成「系统/门禁/工具/评审人」做主语。 -- 用中文工程惯用语替换直译:false positive/negative→误报/漏检、enforcement frontier→执行红线、ratchet→只向前收紧不倒退放宽、reviewable act→评审凭证。 +- 用中文工程惯用语替换直译:false positive/negative→误报/漏检、ratchet→只向前收紧不倒退放宽、reviewable act→评审凭证。 - 隐喻本地化而非移植:bilingual from birth→从创建起就要求双语齐备;grandfathered→历史存量遗留。 - 类别名词说中文并在首现括注英文:实操手册(cookbook)、事故复盘(postmortem);指目录或路径时保留代码体英文。 - 长段按语义单元拆段,一段一件事;名词短语展开为动词句。 diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index ae28258b9b..3a40629372 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -36,7 +36,6 @@ | Agent Note | Agent Note | Agent Note(agent 决策记录) | 智能体注记、智能体笔记 | 本仓库中由 agent 撰写的提案与决策记录 | | agent harness | agent harness | agent harness(智能体框架) | | agent 组合词(agent harness/workflow/loop/skill 等)整体保留英文;未括注过 agent 时首现按对应组合词或 agent 行处理 | | agent loop | agent loop | agent loop(智能体循环) | | | -| backlog | backlog | backlog(待翻清单) | | 仅在双语翻译语境里括注`待翻清单` | | blob hash | blob hash | | | `git hash-object` 的结果 | | Cordis | Cordis | | | | | dispose | dispose | dispose(资源释放) | | | @@ -103,7 +102,6 @@ | deploy root | 部署根目录 | | | | | durability | 持久性 | | | | | feature requirement | 功能依赖 | | | 功能或功能选项通过 `requires` 声明的关系 | -| enforcement frontier | 执行红线 | | 强制边界 | i18n 配对机制用语:manifest `required` 清单所划的门禁生效范围;与金标样例(style-samples ⑦)一致 | | ergonomics | 易用性 / 开发体验 | | 人体工学 | API 或面向模型的接口用「易用性」;工具链或开发者工作流用「开发体验」 | | event | 事件 | | | | | event log | 事件日志 | | | | diff --git a/docs/module-graph.md b/docs/module-graph.md index abadead03f..0ccb4ffcc0 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -138,13 +138,16 @@ flowchart TD subgraph group_client["packages/client"] pkg_client_connection["client-connection"] pkg_client_hmr["client-hmr"] - pkg_client_i18n["client-i18n"] + pkg_client_locale["client-locale"] pkg_client_modules["client-modules"] pkg_client_runtime["client-runtime"] pkg_client_ui_conversation["client-ui-conversation"] pkg_client_ui_layout["client-ui-layout"] + pkg_client_ui_models["client-ui-models"] pkg_client_ui_primitives["client-ui-primitives"] pkg_client_ui_question["client-ui-question"] + pkg_client_ui_settings["client-ui-settings"] + pkg_client_ui_settings_general["client-ui-settings-general"] pkg_client_ui_sidebar["client-ui-sidebar"] pkg_client_ui_slots["client-ui-slots"] pkg_client_ui_theme["client-ui-theme"] @@ -228,13 +231,11 @@ flowchart TD pkg_acp_snapshot --> pkg_invariants pkg_llm_mock_server --> pkg_invariants pkg_loader_smoke --> pkg_invariants - pkg_client_i18n --> pkg_invariants pkg_client_modules --> pkg_invariants pkg_client_runtime --> pkg_invariants pkg_client_ui_primitives --> pkg_invariants pkg_client_ui_question --> pkg_invariants pkg_client_ui_slots --> pkg_invariants - pkg_client_ui_theme --> pkg_invariants pkg_client_ui_trajectory --> pkg_invariants pkg_client_web --> pkg_invariants pkg_client_web_react --> pkg_invariants @@ -250,13 +251,21 @@ flowchart TD pkg_client_hmr --> pkg_client_modules pkg_client_hmr --> pkg_host_webserver pkg_client_hmr --> pkg_invariants + pkg_client_locale --> pkg_client_runtime + pkg_client_locale --> pkg_client_ui_primitives + pkg_client_locale --> pkg_client_ui_slots + pkg_client_locale --> pkg_invariants pkg_client_ui_conversation --> pkg_client_runtime pkg_client_ui_conversation --> pkg_client_ui_primitives pkg_client_ui_conversation --> pkg_client_ui_slots pkg_client_ui_conversation --> pkg_invariants - pkg_client_ui_layout --> pkg_client_runtime - pkg_client_ui_layout --> pkg_client_ui_slots - pkg_client_ui_layout --> pkg_invariants + pkg_client_ui_models --> pkg_client_runtime + pkg_client_ui_models --> pkg_client_ui_slots + pkg_client_ui_models --> pkg_invariants + pkg_client_ui_settings --> pkg_client_runtime + pkg_client_ui_settings --> pkg_client_ui_primitives + pkg_client_ui_settings --> pkg_client_ui_slots + pkg_client_ui_settings --> pkg_invariants pkg_client_ui_sidebar --> pkg_client_runtime pkg_client_ui_sidebar --> pkg_client_ui_primitives pkg_client_ui_sidebar --> pkg_client_ui_slots @@ -291,6 +300,17 @@ flowchart TD pkg_system_prompt --> pkg_scope pkg_web --> pkg_invariants pkg_web --> pkg_llm + pkg_client_ui_settings_general --> pkg_client_locale + pkg_client_ui_settings_general --> pkg_client_runtime + pkg_client_ui_settings_general --> pkg_client_ui_primitives + pkg_client_ui_settings_general --> pkg_client_ui_settings + pkg_client_ui_settings_general --> pkg_client_ui_slots + pkg_client_ui_settings_general --> pkg_invariants + pkg_client_ui_theme --> pkg_client_locale + pkg_client_ui_theme --> pkg_client_runtime + pkg_client_ui_theme --> pkg_client_ui_primitives + pkg_client_ui_theme --> pkg_client_ui_slots + pkg_client_ui_theme --> pkg_invariants pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm @@ -343,6 +363,10 @@ flowchart TD pkg_app_boot --> pkg_invariants pkg_app_boot --> pkg_paths pkg_app_boot --> pkg_system_prompt + pkg_client_ui_layout --> pkg_client_runtime + pkg_client_ui_layout --> pkg_client_ui_slots + pkg_client_ui_layout --> pkg_client_ui_theme + pkg_client_ui_layout --> pkg_invariants pkg_code_runtime_worker --> pkg_code_runtime pkg_code_runtime_worker --> pkg_invariants pkg_code_runtime_worker --> pkg_session @@ -810,13 +834,11 @@ flowchart TD | [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | [`invariants`](../packages/support/invariants) | | [`llm-mock-server`](../packages/support/llm-mock-server) | `support` | [`invariants`](../packages/support/invariants) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`invariants`](../packages/support/invariants) | -| [`client-i18n`](../packages/client/i18n) | `client` | [`invariants`](../packages/support/invariants) | | [`client-modules`](../packages/client/modules) | `client` | [`invariants`](../packages/support/invariants) | | [`client-runtime`](../packages/client/runtime) | `client` | [`invariants`](../packages/support/invariants) | | [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | [`invariants`](../packages/support/invariants) | | [`client-ui-question`](../packages/client/ui-question) | `client` | [`invariants`](../packages/support/invariants) | | [`client-ui-slots`](../packages/client/ui-slots) | `client` | [`invariants`](../packages/support/invariants) | -| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`invariants`](../packages/support/invariants) | | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`invariants`](../packages/support/invariants) | | [`client-web`](../packages/client/web) | `client` | [`invariants`](../packages/support/invariants) | | [`client-web-react`](../packages/client/web-react) | `client` | [`invariants`](../packages/support/invariants) | @@ -828,8 +850,10 @@ flowchart TD | [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`client-connection`](../packages/client/connection) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | +| [`client-locale`](../packages/client/locale) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | @@ -842,6 +866,8 @@ flowchart TD | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -859,6 +885,7 @@ flowchart TD | [`session-title`](../packages/session-title/session-title) | `session-title` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`app-boot`](../packages/ui/app-boot) | `ui` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) | +| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`timeout`](../packages/util/timeout) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index e7790fa76f..c039792ee8 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -313,7 +313,7 @@ Source: [`packages/ui/permission/src/index.ts:36`](../packages/ui/permission/src 'plan/mode': { active: boolean } ``` -Source: [`packages/plan/plan-mode/src/index.ts:41`](../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:40`](../packages/plan/plan-mode/src/index.ts) ### `prompt/*` diff --git a/docs/web-styling.i18n.yaml b/docs/web-styling.i18n.yaml new file mode 100644 index 0000000000..5509012e3e --- /dev/null +++ b/docs/web-styling.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 +web-styling.md: af05faca30fc968828f5a850f59d9d48ae382b05 +web-styling.zh.md: d0838cd8a6ee4290cdddff16b979950bec396314 diff --git a/docs/web-styling.md b/docs/web-styling.md index 0b6aeb55d5..af05faca30 100644 --- a/docs/web-styling.md +++ b/docs/web-styling.md @@ -1,107 +1,109 @@ -# Web GUI 样式规范 +# Web GUI Style Guide -> **【token 体系已换代——§1 表格仅历史参考】** 本文的 `--bg-*`/`--text-*`/`--accent` token 族与其宿主包 `packages/client/web-ui` 已随插件化重构退役。现行 token 唯一来源=`packages/client/ui-theme/src/styles/` 的 `--dsw-*` 体系(static 色阶+alias 语义层,暗色=`body[data-ds-dark-theme]` 覆写),sheet 即权威、组件对账以它为准。**仍然有效**:工程约束(CSS Modules + clsx、无组件库、无 tailwind、组件禁 hardcode 色值)、字号成对写行高、间距 4 倍数、代码字体栈末位不放 monospace。 +English | [中文](web-styling.zh.md) -> 状态:原「活文档」(随 `packages/client/web-ui` 演进)。视觉基线源自对 deepseekchat 前端仓的实测调研。框架决策与工程约束由 [web-styling-system RFC](../.agents/notes/implemented/process/2026-07-19-web-styling-system.md) 拍板,本文不重复论证。 +> **[The token system has been replaced—the table in § 1 is retained only for historical reference]** The `--bg-*`/`--text-*`/`--accent` token families documented here and their host package, `packages/client/web-ui`, were retired during the plugin refactor. The sole current token source is `packages/client/ui-theme/src/styles/`, which defines the `--dsw-*` system (a static color scale plus a semantic alias layer, with dark-mode overrides under `body[data-ds-dark-theme]`). The sheet is authoritative and component audits use it as the baseline. **The following rules remain in force**: CSS Modules + clsx, no component library, no Tailwind, no hard-coded color values in components, pair every font size with a line height, use spacing in multiples of 4, and do not put monospace last in the code font stack. -## 1. 设计 token 表(权威定义) +> Status: formerly a “living document” that evolved with `packages/client/web-ui`. The visual baseline came from empirical study of the deepseekchat frontend repository. The [web-styling-system RFC](../.agents/notes/implemented/process/2026-07-19-web-styling-system.md) owns the framework decisions and engineering constraints; this document does not repeat their rationale. -所有 token 住 `packages/client/web-ui/src/style/global.css`:`:root` 亮色实值,`[data-theme='dark']` 块覆盖同名变量(未补全前列为占位)。组件 CSS 只引 token,不出现字面量色值。 +## 1. Design token table (authoritative definitions) -### 1.1 颜色(两层:注释里是 base 色板出处,变量名即语义别名) +All tokens live in `packages/client/web-ui/src/style/global.css`: `:root` contains the light-theme values, and the `[data-theme='dark']` block overrides the same variables (columns that were not complete are marked as placeholders). Component CSS references tokens only and contains no literal color values. -| token | 亮色实值 | 暗色(占位) | 用途 | +### 1.1 Colors (two layers: comments identify the base-palette source, while variable names are semantic aliases) + +| token | Light value | Dark value (placeholder) | Purpose | | --- | --- | --- | --- | -| `--bg-base` | `#ffffff` | `#151517` | 页面底 | -| `--bg-layer` | `#ffffff` | `#232324` | 浮层/面板 | -| `--bg-sidebar` | `#f9fafb` | `#1b1b1c` | 侧边栏底 | -| `--text-primary` | `#0f1115` | `#f9fafb` | 正文 | -| `--text-secondary` | `#61666b` | `#cfd3d6` | 次要文字 | -| `--text-tertiary` | `#81858c` | `#adb2b8` | 辅助/说明 | -| `--border-l1` | `rgba(0,0,0,.04)` | `rgba(255,255,255,.06)` | 弱分隔(侧边栏右缘) | -| `--border-l2` | `rgba(0,0,0,.1)` | `rgba(255,255,255,.12)` | 常规边框 | -| `--hover-bg` | `rgba(38,49,72,.06)` | `rgba(255,255,255,.08)` | hover 态底 | -| `--active-bg` | `rgba(38,49,72,.1)` | `rgba(255,255,255,.14)` | 按压/激活态底 | -| `--accent` | `#3964fe` | `#5686fe` | 品牌蓝(deepseek-500;暗提亮一档) | -| `--accent-soft` | `#edf3fe` | `#28313f` | 淡品牌底(强调块) | -| `--accent-item` | `#e4edfd` | `#35363a` | 侧边栏选中条目底 | -| `--bubble-bg` | `#edf3fe` | `#2c2c2e` | 用户消息气泡底 | -| `--ok` / `--error` / `--warn` | `#22c55e` / `#ec1313` / `#f59e0b` | 同值 | 语义状态色 | -| `--text-on-solid` | `#ffffff` | 同值 | 实色底(accent/error 徽标等)上的文字 | -| `--ok-soft` / `--error-soft` | `#e6faed` / `#fee2e2` | `#233c2c` / `#570c0c` | 语义状态软底(徽章);green-100/red-100,暗为 900 档 | -| `--color-frame-mux` / `--color-frame-host` | `#8250df` / `#0969da` | 同值 | RPC 调试面板方向色(自有,非基线) | -| `--frame-mux-soft` / `--frame-host-soft` | `rgba(130,80,223,.1)` / `rgba(9,105,218,.1)` | 同色 `.24` | 方向色软底(徽章) | -| `--scroll-color` / `--scroll-color-hover` | `rgba(0,0,0,.08)` / `.15` | `rgba(255,255,255,.15)` / `.24` | 滚动条(`.scrollable` 专用) | +| `--bg-base` | `#ffffff` | `#151517` | Page background | +| `--bg-layer` | `#ffffff` | `#232324` | Floating layer/panel | +| `--bg-sidebar` | `#f9fafb` | `#1b1b1c` | Sidebar background | +| `--text-primary` | `#0f1115` | `#f9fafb` | Body text | +| `--text-secondary` | `#61666b` | `#cfd3d6` | Secondary text | +| `--text-tertiary` | `#81858c` | `#adb2b8` | Supporting/descriptive text | +| `--border-l1` | `rgba(0,0,0,.04)` | `rgba(255,255,255,.06)` | Subtle separator (sidebar right edge) | +| `--border-l2` | `rgba(0,0,0,.1)` | `rgba(255,255,255,.12)` | Standard border | +| `--hover-bg` | `rgba(38,49,72,.06)` | `rgba(255,255,255,.08)` | Hover-state background | +| `--active-bg` | `rgba(38,49,72,.1)` | `rgba(255,255,255,.14)` | Pressed/active-state background | +| `--accent` | `#3964fe` | `#5686fe` | Brand blue (deepseek-500; one step lighter in dark mode) | +| `--accent-soft` | `#edf3fe` | `#28313f` | Soft brand background (emphasis blocks) | +| `--accent-item` | `#e4edfd` | `#35363a` | Selected sidebar-item background | +| `--bubble-bg` | `#edf3fe` | `#2c2c2e` | User-message bubble background | +| `--ok` / `--error` / `--warn` | `#22c55e` / `#ec1313` / `#f59e0b` | Same values | Semantic status colors | +| `--text-on-solid` | `#ffffff` | Same value | Text on solid backgrounds (accent/error badges, etc.) | +| `--ok-soft` / `--error-soft` | `#e6faed` / `#fee2e2` | `#233c2c` / `#570c0c` | Soft semantic status backgrounds (badges); green-100/red-100, with the 900 shades in dark mode | +| `--color-frame-mux` / `--color-frame-host` | `#8250df` / `#0969da` | Same values | RPC debugger direction colors (project-specific, not part of the baseline) | +| `--frame-mux-soft` / `--frame-host-soft` | `rgba(130,80,223,.1)` / `rgba(9,105,218,.1)` | Same colors at `.24` | Soft direction-color backgrounds (badges) | +| `--scroll-color` / `--scroll-color-hover` | `rgba(0,0,0,.08)` / `.15` | `rgba(255,255,255,.15)` / `.24` | Scrollbar colors (for `.scrollable` only) | -### 1.2 非颜色 +### 1.2 Non-color tokens -| token | 值 | 说明 | +| token | Value | Description | | --- | --- | --- | -| `--font-ui` | `Inter, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif` | 正文栈 | -| `--font-mono` | `Menlo, Monaco, Consolas, 'JetBrains Mono', 'Courier New', sans-serif` | 代码栈;**末位不放 monospace**(防 Windows 中文回退宋体) | -| `--fw-strong` | `600` | 粗体统一权重 | -| `--ease` | `cubic-bezier(.4,0,.2,1)` | 唯一缓动曲线 | -| `--dur` / `--dur-fast` / `--dur-slow` | `.2s` / `.1s` / `.3s` | 过渡三档 | -| `--radius-s` / `--radius-m` / `--radius-l` / `--radius-bubble` / `--radius-xl` | `8px` / `12px` / `16px` / `22px` / `24px` | 圆角语义档:小控件 / 列表条目与面板内块 / 浮层 / 气泡 / 输入卡片(基线 inputWrapper 同值);胶囊直接写 `999px` | -| `--shadow-panel` | `0 0 1px rgba(0,0,0,.2), 0 0 4px rgba(0,0,0,.02), 0 12px 32px rgba(0,0,0,.08)` | 浮层阴影(基线 lv3) | -| `--shadow-float` | `0 0 1px rgba(0,0,0,.24), 0 4px 12px rgba(0,0,0,.06), 0 16px 48px rgba(0,0,0,.16)` | 强浮动面板(lv3 加强档,如 RPC 调试浮层) | -| `--shadow-card` | `0 4px 10px rgba(0,0,0,.02), 0 2px 4px rgba(0,0,0,.04)`;暗色 `none` | 输入卡片微阴影(基线:亮色同底靠边框+微影区分,暗色靠提亮底、阴影关闭) | +| `--font-ui` | `Inter, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif` | Body-text stack | +| `--font-mono` | `Menlo, Monaco, Consolas, 'JetBrains Mono', 'Courier New', sans-serif` | Code stack; **do not put monospace last** (prevents SimSun fallback for Chinese on Windows) | +| `--fw-strong` | `600` | Unified bold weight | +| `--ease` | `cubic-bezier(.4,0,.2,1)` | Sole easing curve | +| `--dur` / `--dur-fast` / `--dur-slow` | `.2s` / `.1s` / `.3s` | Three transition durations | +| `--radius-s` / `--radius-m` / `--radius-l` / `--radius-bubble` / `--radius-xl` | `8px` / `12px` / `16px` / `22px` / `24px` | Semantic radius steps: small controls / list items and blocks inside panels / floating layers / bubbles / input cards (same as the baseline inputWrapper); use `999px` directly for pills | +| `--shadow-panel` | `0 0 1px rgba(0,0,0,.2), 0 0 4px rgba(0,0,0,.02), 0 12px 32px rgba(0,0,0,.08)` | Floating-layer shadow (baseline lv3) | +| `--shadow-float` | `0 0 1px rgba(0,0,0,.24), 0 4px 12px rgba(0,0,0,.06), 0 16px 48px rgba(0,0,0,.16)` | Strong floating panel (enhanced lv3, such as the RPC debugger overlay) | +| `--shadow-card` | `0 4px 10px rgba(0,0,0,.02), 0 2px 4px rgba(0,0,0,.04)`; dark value `none` | Subtle input-card shadow (baseline: borders plus a subtle shadow distinguish same-color light surfaces; a lighter background distinguishes dark surfaces, with the shadow disabled) | -字号与间距**不 token 化**(基线仓同款决策):字号在组件里写 px 且**成对写行高**,常用对 16/24(气泡)、14/22(UI 默认)、12/18(辅助);间距用 4 的倍数。 +Font sizes and spacing are **not tokenized** (matching the baseline repository's decision): components specify font sizes in px and **always pair them with line heights**. Common pairs are 16/24 (bubbles), 14/22 (UI default), and 12/18 (supporting text); spacing uses multiples of 4. -## 2. 视觉基线(源自 deepseekchat) +## 2. Visual baseline (from deepseekchat) -- 侧边栏:宽 `260px + 1px` 右边框(`--border-l1`);底色 `--bg-sidebar`。 -- 侧边栏条目:高 `40px`、圆角 `--radius-m`、字号 14px;hover 底 `--hover-bg` 或 sidebar 专属灰、**选中底 `--accent-item` 且不改文字色**。 -- 侧边栏分组标题:12px / weight 500 / `--text-tertiary` / sticky 顶部(底色同侧边栏遮滚动内容)。 -- 会话列:`max-width: 840px` 居中,<1024px 降 712px。 -- 消息流:**仅用户侧有气泡**——`--bubble-bg` 底、圆角 `--radius-bubble`、padding `10px 16px`、字号 16px/24px、`max-width: calc(100% - 88px)`;**助手侧纯文档流无底色**。 -- 消息操作条:默认 `opacity: 0`,父块 hover/focus-within 淡入(`--dur` + `--ease`)。 -- 输入卡片:与会话列同宽(840px,<1024px 降 712px)居中悬浮(距底留白带);圆角 `--radius-xl`、边框 `--border-l2`、底 `--bg-base`、阴影 `--shadow-card`;内部上下两段=textarea(16px/24px,min 2 行 max 14 行=336px,镜像 div 自增高)+ 操作行(右下嵌 34px 主圆钮);focus 无边框/阴影变化(基线同款)。 -- 输入主按钮(拍板 2026-07-20 三连,视觉参照 Codex App):32px 实心正圆图标钮(内联 SVG)——空闲=`--accent` 底白↑箭头「发送」,运行中原地变 `--accent-soft` 底 accent ■「停止」(同色系不告警、不用红)。**运行中锁输入**(拍板 3,取代早先 hover 菜单方案):textarea disabled(灰、草稿内容保留可见)、无任何排队/插话菜单,停止是唯一动作;turn 结束解禁并 refocus。键盘 Enter=发送、Ctrl/Meta+Enter=换行(运行中键盘路径随锁失效)。 -- 滚动条:近隐形、hover 加深、`scrollbar-gutter: stable` 不占布局(统一走 `.scrollable`,见 §3-9)。 -- RPC 四象限方向符(官方视觉词汇,空间隐喻:上=去 server、下=来自 server;单线=unary、双线=SSE): +- Sidebar: width `260px + 1px` right border (`--border-l1`); background `--bg-sidebar`. +- Sidebar items: height `40px`, radius `--radius-m`, font size 14px; hover background `--hover-bg` or a sidebar-specific gray; **selected items use `--accent-item` without changing text color**. +- Sidebar group headings: 12px / weight 500 / `--text-tertiary` / sticky at the top (using the sidebar background to cover scrolling content). +- Conversation column: centered at `max-width: 840px`, reduced to 712px below 1024px. +- Message stream: **only user messages have bubbles**: background `--bubble-bg`, radius `--radius-bubble`, padding `10px 16px`, font size 16px/24px, and `max-width: calc(100% - 88px)`; **assistant messages are a plain document flow without a background**. +- Message action bar: `opacity: 0` by default; fades in when its parent is hovered or contains focus (`--dur` + `--ease`). +- Input card: floats centered at the same width as the conversation column (840px, reduced to 712px below 1024px) with bottom spacing; radius `--radius-xl`, border `--border-l2`, background `--bg-base`, shadow `--shadow-card`; two internal vertical sections = textarea (16px/24px, minimum 2 lines, maximum 14 lines = 336px, auto-growing through a mirror div) + action row (a 34px primary round button nested at bottom right); focus does not change the border or shadow (matching the baseline). +- Primary input button (the three decisions made on 2026-07-20, visually based on the Codex App): a 32px solid circular icon button (inline SVG). Idle = `--accent` background with a white ↑ “Send” arrow; while running it changes in place to an accent ■ “Stop” icon on `--accent-soft` (the same color family, not a warning, and not red). **Input is locked while running** (decision 3, replacing the earlier hover-menu design): the textarea is disabled (gray, with draft content still visible), there is no queue/interjection menu, and Stop is the only action. When the turn ends, input unlocks and regains focus. Enter sends; Ctrl/Meta+Enter inserts a newline (the keyboard path is disabled with the locked input while running). +- Scrollbars: nearly invisible, darkening on hover, with `scrollbar-gutter: stable` so they do not consume layout space (always use `.scrollable`; see § 3.9). +- Four-quadrant RPC direction symbols (the official visual vocabulary, using the spatial metaphor that up goes to the server, down comes from the server; single line = unary, double line = SSE): -| 符号 | 象限 | 徽章配色 | +| Symbol | Quadrant | Badge colors | | --- | --- | --- | -| `↑` | client-request(unary 出站) | `--accent` / `--accent-soft` | -| `↓` | server-response(unary 回包) | ok `--ok`/`--ok-soft`,error `--error`/`--error-soft` | -| `⇟` | server-request(SSE 帧推送) | mux `--color-frame-mux`/`--frame-mux-soft`,host `--color-frame-host`/`--frame-host-soft` | -| `⇞` | client-response(SSE 侧回应) | `--accent`/`--accent-soft` 降透明度 | +| `↑` | client-request (unary outbound) | `--accent` / `--accent-soft` | +| `↓` | server-response (unary response) | ok `--ok`/`--ok-soft`, error `--error`/`--error-soft` | +| `⇟` | server-request (SSE frame push) | mux `--color-frame-mux`/`--frame-mux-soft`, host `--color-frame-host`/`--frame-host-soft` | +| `⇞` | client-response (SSE-side response) | `--accent`/`--accent-soft` at reduced opacity | -## 3. 样式编码规范(review 对照打勾) +## 3. Style implementation rules (review checklist) -1. 颜色/圆角/动效/字体栈只引 §1 token;组件 CSS 出现字面量色值即打回(渐变遮罩等特效除外,须注释说明)。 -2. 组件 CSS 禁止出现 `[data-theme]` 选择器;暗色差异只在 global.css token 表做。确需按主题换非 token 值(渐变端点等),组件定义局部 CSS 变量、主题块只覆写变量(变量桥)。 -3. 类名 camelCase;状态类用单形容词(`.active` `.show`),由 clsx 挂载:`clsx(styles.x, cond && styles.active, className)`。 -4. 对外组件必须透传 `className` 并合入根元素。 -5. 禁用 `composes`;复用靠 token 与组件抽取。 -6. `:global` 仅用于穿透第三方/跨包类名;禁止用它定义新全局类。 -7. 交互过渡一律 `var(--dur*) var(--ease)`,只过渡 opacity / transform / 背景色 / 阴影;纯 hover 展示型元素包 `@media (hover: hover)`。 -8. hover/active 底色优先用透明度制 token(叠任意海拔底色都成立),不新造实色灰。 -9. 滚动容器统一挂 global.css 的 `.scrollable` 工具类;组件内禁写 `::-webkit-scrollbar`。 -10. 媒体查询写在组件 css 尾部、贴着被覆盖规则;断点当前仅 1024px 一档(会话列降档),加第二档需先记入本文档。 -11. 动态样式 JS 侧只写 CSS 变量(`style={{'--x': v}}`),规则留在 CSS;禁止在 TSX 里拼接样式对象做主题/状态分支。 -12. 文字灰阶只用 `--text-primary/secondary/tertiary` 三级,不新造灰色。 +1. Colors, radii, motion, and font stacks reference only the § 1 tokens. Reject literal color values in component CSS (except for special effects such as gradient masks, which require an explanatory comment). +2. Component CSS must not contain `[data-theme]` selectors; dark-mode differences belong only in the global.css token table. If a theme must change a non-token value such as a gradient endpoint, define a local CSS variable in the component and have the theme block override only that variable (a variable bridge). +3. Use camelCase class names; use a single adjective for state classes (`.active` `.show`) and attach them with clsx: `clsx(styles.x, cond && styles.active, className)`. +4. Public components must accept `className` and merge it into the root element. +5. Do not use `composes`; share through tokens and extracted components. +6. Use `:global` only to pierce third-party or cross-package class names; do not use it to define new global classes. +7. All interaction transitions use `var(--dur*) var(--ease)` and transition only opacity / transform / background color / shadow. Wrap hover-only reveal elements in `@media (hover: hover)`. +8. Prefer opacity-based tokens for hover/active backgrounds because they compose over any elevation background; do not add new solid grays. +9. Apply the `.scrollable` utility class from global.css to every scroll container; do not write `::-webkit-scrollbar` inside components. +10. Put media queries at the end of the component CSS, next to the rules they override. The only current breakpoint is 1024px (where the conversation column steps down); record a second breakpoint in this document before adding it. +11. Dynamic styles in JS set only CSS variables (`style={{'--x': v}}`), while rules remain in CSS; do not assemble style objects in TSX to branch by theme or state. +12. Use only the three `--text-primary/secondary/tertiary` levels for gray text; do not add another gray. -## 4. 文件组织 +## 4. File organization -- `src/style/global.css` 固定分区顺序:① token 表(`:root` + `[data-theme='dark']`)② 全局基础(box-sizing、body、button reset)③ 全局工具类(`.scrollable` 等,总数保持个位数)。 -- `*.module.css` 与组件同目录同名;一个组件一个 module 文件。 -- 类型声明用现有 `css-modules.d.ts` 通配;组件数超 20 再评估引入 tcm 生成精确 `.css.d.ts`。 -- PostCSS 特性白名单:当前**零插件**(平铺 CSS + 原生嵌套按需);引入 nested/custom-media 需先记入本文档。 +- `src/style/global.css` always uses this section order: ① token table (`:root` + `[data-theme='dark']`), ② global foundations (box-sizing, body, button reset), ③ global utility classes (`.scrollable`, etc.; keep the total in single digits). +- Place each `*.module.css` beside the component with the same name; use one module file per component. +- Use the existing `css-modules.d.ts` wildcard declaration. Reassess introducing tcm to generate exact `.css.d.ts` files only after the component count exceeds 20. +- PostCSS feature allowlist: currently **no plugins** (flat CSS plus native nesting when needed). Record nested/custom-media in this document before introducing either. -## 5. 演进规则与偏离记录 +## 5. Evolution rules and deviation log -- **加新 token**:先进 §1 表(含暗色占位列)再在组件使用;review 见到未入表的 `--` 新变量即打回(组件局部变量桥除外)。 -- **偏离基线**:与 §2 任一常数不一致的实现,须在下方偏离表记一行(日期/项/理由)。 -- **暗色表补全验收**:`[data-theme='dark']` 覆盖 §1 全部占位列后,用 RPC 面板 + 侧边栏 + 会话流三个界面人工/截图核对一遍,无组件级主题选择器即达标。 +- **Adding a token**: add it to the § 1 table first (including the dark-placeholder column), then use it in the component. Reject any new `--` variable that has not been added to the table (except for component-local variable bridges). +- **Deviating from the baseline**: if an implementation differs from any constant in § 2, add one row to the deviation table below (date / item / rationale). +- **Dark-table completion acceptance**: after `[data-theme='dark']` overrides every placeholder column in § 1, compare the RPC panel, sidebar, and conversation stream manually or with screenshots. Acceptance requires all three to match and no component-level theme selector to remain. -| 日期 | 偏离项 | 理由 | +| Date | Deviation | Rationale | | --- | --- | --- | -| (空) | | | +| (none) | | | -## 6. 相关文档 +## 6. Related documentation -- [web-styling-system RFC](../.agents/notes/implemented/process/2026-07-19-web-styling-system.md)(框架五条与工程约束的裁决记录) -- 客户端消费架构与分层协议:[Web 客户端架构 RFC](../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)、[GUI 分层与 RPC 协议 RFC](../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) +- [web-styling-system RFC](../.agents/notes/implemented/process/2026-07-19-web-styling-system.md) (decision record for the five framework rules and engineering constraints) +- Client consumption architecture and layered protocols: [Web client architecture RFC](../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md), [GUI layering and RPC protocol RFC](../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) diff --git a/docs/web-styling.zh.md b/docs/web-styling.zh.md new file mode 100644 index 0000000000..d0838cd8a6 --- /dev/null +++ b/docs/web-styling.zh.md @@ -0,0 +1,109 @@ +# Web GUI 样式规范 + +[English](web-styling.md) | 中文 + +> **【token 体系已换代——§1 表格仅历史参考】** 本文的 `--bg-*`/`--text-*`/`--accent` token 族与其宿主包 `packages/client/web-ui` 已随插件化重构退役。现行 token 唯一来源=`packages/client/ui-theme/src/styles/` 的 `--dsw-*` 体系(static 色阶+alias 语义层,暗色=`body[data-ds-dark-theme]` 覆写),sheet 即权威、组件对账以它为准。**仍然有效**:工程约束(CSS Modules + clsx、无组件库、无 tailwind、组件禁 hardcode 色值)、字号成对写行高、间距 4 倍数、代码字体栈末位不放 monospace。 + +> 状态:原「活文档」(随 `packages/client/web-ui` 演进)。视觉基线源自对 deepseekchat 前端仓的实测调研。框架决策与工程约束由 [web-styling-system RFC](../.agents/notes/implemented/process/2026-07-19-web-styling-system.md) 拍板,本文不重复论证。 + +## 1. 设计 token 表(权威定义) + +所有 token 住 `packages/client/web-ui/src/style/global.css`:`:root` 亮色实值,`[data-theme='dark']` 块覆盖同名变量(未补全前列为占位)。组件 CSS 只引 token,不出现字面量色值。 + +### 1.1 颜色(两层:注释里是 base 色板出处,变量名即语义别名) + +| token | 亮色实值 | 暗色(占位) | 用途 | +| --- | --- | --- | --- | +| `--bg-base` | `#ffffff` | `#151517` | 页面底 | +| `--bg-layer` | `#ffffff` | `#232324` | 浮层/面板 | +| `--bg-sidebar` | `#f9fafb` | `#1b1b1c` | 侧边栏底 | +| `--text-primary` | `#0f1115` | `#f9fafb` | 正文 | +| `--text-secondary` | `#61666b` | `#cfd3d6` | 次要文字 | +| `--text-tertiary` | `#81858c` | `#adb2b8` | 辅助/说明 | +| `--border-l1` | `rgba(0,0,0,.04)` | `rgba(255,255,255,.06)` | 弱分隔(侧边栏右缘) | +| `--border-l2` | `rgba(0,0,0,.1)` | `rgba(255,255,255,.12)` | 常规边框 | +| `--hover-bg` | `rgba(38,49,72,.06)` | `rgba(255,255,255,.08)` | hover 态底 | +| `--active-bg` | `rgba(38,49,72,.1)` | `rgba(255,255,255,.14)` | 按压/激活态底 | +| `--accent` | `#3964fe` | `#5686fe` | 品牌蓝(deepseek-500;暗提亮一档) | +| `--accent-soft` | `#edf3fe` | `#28313f` | 淡品牌底(强调块) | +| `--accent-item` | `#e4edfd` | `#35363a` | 侧边栏选中条目底 | +| `--bubble-bg` | `#edf3fe` | `#2c2c2e` | 用户消息气泡底 | +| `--ok` / `--error` / `--warn` | `#22c55e` / `#ec1313` / `#f59e0b` | 同值 | 语义状态色 | +| `--text-on-solid` | `#ffffff` | 同值 | 实色底(accent/error 徽标等)上的文字 | +| `--ok-soft` / `--error-soft` | `#e6faed` / `#fee2e2` | `#233c2c` / `#570c0c` | 语义状态软底(徽章);green-100/red-100,暗为 900 档 | +| `--color-frame-mux` / `--color-frame-host` | `#8250df` / `#0969da` | 同值 | RPC 调试面板方向色(自有,非基线) | +| `--frame-mux-soft` / `--frame-host-soft` | `rgba(130,80,223,.1)` / `rgba(9,105,218,.1)` | 同色 `.24` | 方向色软底(徽章) | +| `--scroll-color` / `--scroll-color-hover` | `rgba(0,0,0,.08)` / `.15` | `rgba(255,255,255,.15)` / `.24` | 滚动条(`.scrollable` 专用) | + +### 1.2 非颜色 + +| token | 值 | 说明 | +| --- | --- | --- | +| `--font-ui` | `Inter, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif` | 正文栈 | +| `--font-mono` | `Menlo, Monaco, Consolas, 'JetBrains Mono', 'Courier New', sans-serif` | 代码栈;**末位不放 monospace**(防 Windows 中文回退宋体) | +| `--fw-strong` | `600` | 粗体统一权重 | +| `--ease` | `cubic-bezier(.4,0,.2,1)` | 唯一缓动曲线 | +| `--dur` / `--dur-fast` / `--dur-slow` | `.2s` / `.1s` / `.3s` | 过渡三档 | +| `--radius-s` / `--radius-m` / `--radius-l` / `--radius-bubble` / `--radius-xl` | `8px` / `12px` / `16px` / `22px` / `24px` | 圆角语义档:小控件 / 列表条目与面板内块 / 浮层 / 气泡 / 输入卡片(基线 inputWrapper 同值);胶囊直接写 `999px` | +| `--shadow-panel` | `0 0 1px rgba(0,0,0,.2), 0 0 4px rgba(0,0,0,.02), 0 12px 32px rgba(0,0,0,.08)` | 浮层阴影(基线 lv3) | +| `--shadow-float` | `0 0 1px rgba(0,0,0,.24), 0 4px 12px rgba(0,0,0,.06), 0 16px 48px rgba(0,0,0,.16)` | 强浮动面板(lv3 加强档,如 RPC 调试浮层) | +| `--shadow-card` | `0 4px 10px rgba(0,0,0,.02), 0 2px 4px rgba(0,0,0,.04)`;暗色 `none` | 输入卡片微阴影(基线:亮色同底靠边框+微影区分,暗色靠提亮底、阴影关闭) | + +字号与间距**不 token 化**(基线仓同款决策):字号在组件里写 px 且**成对写行高**,常用对 16/24(气泡)、14/22(UI 默认)、12/18(辅助);间距用 4 的倍数。 + +## 2. 视觉基线(源自 deepseekchat) + +- 侧边栏:宽 `260px + 1px` 右边框(`--border-l1`);底色 `--bg-sidebar`。 +- 侧边栏条目:高 `40px`、圆角 `--radius-m`、字号 14px;hover 底 `--hover-bg` 或 sidebar 专属灰、**选中底 `--accent-item` 且不改文字色**。 +- 侧边栏分组标题:12px / weight 500 / `--text-tertiary` / sticky 顶部(底色同侧边栏遮滚动内容)。 +- 会话列:`max-width: 840px` 居中,<1024px 降 712px。 +- 消息流:**仅用户侧有气泡**——`--bubble-bg` 底、圆角 `--radius-bubble`、padding `10px 16px`、字号 16px/24px、`max-width: calc(100% - 88px)`;**助手侧纯文档流无底色**。 +- 消息操作条:默认 `opacity: 0`,父块 hover/focus-within 淡入(`--dur` + `--ease`)。 +- 输入卡片:与会话列同宽(840px,<1024px 降 712px)居中悬浮(距底留白带);圆角 `--radius-xl`、边框 `--border-l2`、底 `--bg-base`、阴影 `--shadow-card`;内部上下两段=textarea(16px/24px,min 2 行 max 14 行=336px,镜像 div 自增高)+ 操作行(右下嵌 34px 主圆钮);focus 无边框/阴影变化(基线同款)。 +- 输入主按钮(拍板 2026-07-20 三连,视觉参照 Codex App):32px 实心正圆图标钮(内联 SVG)——空闲=`--accent` 底白↑箭头「发送」,运行中原地变 `--accent-soft` 底 accent ■「停止」(同色系不告警、不用红)。**运行中锁输入**(拍板 3,取代早先 hover 菜单方案):textarea disabled(灰、草稿内容保留可见)、无任何排队/插话菜单,停止是唯一动作;turn 结束解禁并 refocus。键盘 Enter=发送、Ctrl/Meta+Enter=换行(运行中键盘路径随锁失效)。 +- 滚动条:近隐形、hover 加深、`scrollbar-gutter: stable` 不占布局(统一走 `.scrollable`,见 §3-9)。 +- RPC 四象限方向符(官方视觉词汇,空间隐喻:上=去 server、下=来自 server;单线=unary、双线=SSE): + +| 符号 | 象限 | 徽章配色 | +| --- | --- | --- | +| `↑` | client-request(unary 出站) | `--accent` / `--accent-soft` | +| `↓` | server-response(unary 回包) | ok `--ok`/`--ok-soft`,error `--error`/`--error-soft` | +| `⇟` | server-request(SSE 帧推送) | mux `--color-frame-mux`/`--frame-mux-soft`,host `--color-frame-host`/`--frame-host-soft` | +| `⇞` | client-response(SSE 侧回应) | `--accent`/`--accent-soft` 降透明度 | + +## 3. 样式编码规范(review 对照打勾) + +1. 颜色/圆角/动效/字体栈只引 §1 token;组件 CSS 出现字面量色值即打回(渐变遮罩等特效除外,须注释说明)。 +2. 组件 CSS 禁止出现 `[data-theme]` 选择器;暗色差异只在 global.css token 表做。确需按主题换非 token 值(渐变端点等),组件定义局部 CSS 变量、主题块只覆写变量(变量桥)。 +3. 类名 camelCase;状态类用单形容词(`.active` `.show`),由 clsx 挂载:`clsx(styles.x, cond && styles.active, className)`。 +4. 对外组件必须透传 `className` 并合入根元素。 +5. 禁用 `composes`;复用靠 token 与组件抽取。 +6. `:global` 仅用于穿透第三方/跨包类名;禁止用它定义新全局类。 +7. 交互过渡一律 `var(--dur*) var(--ease)`,只过渡 opacity / transform / 背景色 / 阴影;纯 hover 展示型元素包 `@media (hover: hover)`。 +8. hover/active 底色优先用透明度制 token(叠任意海拔底色都成立),不新造实色灰。 +9. 滚动容器统一挂 global.css 的 `.scrollable` 工具类;组件内禁写 `::-webkit-scrollbar`。 +10. 媒体查询写在组件 css 尾部、贴着被覆盖规则;断点当前仅 1024px 一档(会话列降档),加第二档需先记入本文档。 +11. 动态样式 JS 侧只写 CSS 变量(`style={{'--x': v}}`),规则留在 CSS;禁止在 TSX 里拼接样式对象做主题/状态分支。 +12. 文字灰阶只用 `--text-primary/secondary/tertiary` 三级,不新造灰色。 + +## 4. 文件组织 + +- `src/style/global.css` 固定分区顺序:① token 表(`:root` + `[data-theme='dark']`)② 全局基础(box-sizing、body、button reset)③ 全局工具类(`.scrollable` 等,总数保持个位数)。 +- `*.module.css` 与组件同目录同名;一个组件一个 module 文件。 +- 类型声明用现有 `css-modules.d.ts` 通配;组件数超 20 再评估引入 tcm 生成精确 `.css.d.ts`。 +- PostCSS 特性白名单:当前**零插件**(平铺 CSS + 原生嵌套按需);引入 nested/custom-media 需先记入本文档。 + +## 5. 演进规则与偏离记录 + +- **加新 token**:先进 §1 表(含暗色占位列)再在组件使用;review 见到未入表的 `--` 新变量即打回(组件局部变量桥除外)。 +- **偏离基线**:与 §2 任一常数不一致的实现,须在下方偏离表记一行(日期/项/理由)。 +- **暗色表补全验收**:`[data-theme='dark']` 覆盖 §1 全部占位列后,用 RPC 面板 + 侧边栏 + 会话流三个界面人工/截图核对一遍,无组件级主题选择器即达标。 + +| 日期 | 偏离项 | 理由 | +| --- | --- | --- | +| (空) | | | + +## 6. 相关文档 + +- [web-styling-system RFC](../.agents/notes/implemented/process/2026-07-19-web-styling-system.md)(框架五条与工程约束的裁决记录) +- 客户端消费架构与分层协议:[Web 客户端架构 RFC](../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)、[GUI 分层与 RPC 协议 RFC](../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) diff --git a/examples/README.i18n.yaml b/examples/README.i18n.yaml new file mode 100644 index 0000000000..a96133c3c8 --- /dev/null +++ b/examples/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 8fd261e624771dc568582b6d22e9985072a06715 +README.zh.md: 2ff0d0bef382435588fce01c23aa7b74e1a149b5 diff --git a/examples/README.md b/examples/README.md index f1f3967634..8fd261e624 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,5 +1,7 @@ # Examples +English | [中文](README.zh.md) + Runnable demos (not workspaces) that showcase how the harness is wired. Each example is a **thin leaf**: a `cordis.yml` that picks swappable backends, loads one app package, and may add optional product tools. The composition and boot glue live in [`@deepseek-ai/dsh-tui-demo`](../packages/examples/tui-demo), [`@deepseek-ai/dsh-cli-demo`](../packages/examples/cli-demo), [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo), and their shared [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) bundle. There is no `start.ts`; the terminal `demo:*` scripts boot through the [`dsh`](../apps/cli/README.md) CLI (which mounts the `tui-demo` bundle), and the headless/ACP scripts invoke the `cli-demo`/`acp-demo` bins. ## headless-agent diff --git a/examples/README.zh.md b/examples/README.zh.md new file mode 100644 index 0000000000..2ff0d0bef3 --- /dev/null +++ b/examples/README.zh.md @@ -0,0 +1,35 @@ +# 示例 + +[English](README.md) | 中文 + +展示 harness 如何接线的可运行演示(不是 workspace)。每个示例都是一个 **轻量叶节点**:一份选择可替换后端、加载一个应用包(package)并可添加可选产品工具的 `cordis.yml`。组合和启动粘合代码位于 [`@deepseek-ai/dsh-tui-demo`](../packages/examples/tui-demo)、[`@deepseek-ai/dsh-cli-demo`](../packages/examples/cli-demo)、[`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo) 及它们共享的 [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) 组合包中。没有 `start.ts`;终端 `demo:*` 脚本通过 [`dsh`](../apps/cli/README.md) CLI(命令行界面)启动(该 CLI 挂载 `tui-demo` 组合包),无头/ACP(Agent Client Protocol)脚本则调用 `cli-demo`/`acp-demo` bin。 + +## headless-agent + +非交互式 agent(智能体)演示:接受一个位置任务,在 `@deepseek-ai/dsh-cli-demo` 应用上运行一个完整模型/工具轮次,持久化新会话,打印 `text`、`json` 或 `stream-json`,然后退出。 + +运行:`pnpm run demo:headless "task"`(需要 `DEEPSEEK_API_KEY`)。输出契约、安全边界和快照套件详见 [headless-agent/README.md](headless-agent/README.md)。 + +## tui-agent + +交互式编码 agent:DeepSeek V4、文件系统与 bash 工具、subagent、工作流、`todo_write`、压缩(compaction)和全屏 TUI。这里也是 TUI PTY 与快照场景的归属地。 + +运行:`pnpm run demo:tui`(需要 `DEEPSEEK_API_KEY`)。使用 `pnpm run demo:code-mode` 运行其 Code Mode 覆盖。控制与组合详见 [tui-agent/README.md](tui-agent/README.md)。 + +## jsonrpc-agent + +通过 Python SDK 驱动的无人值守编码 agent:JSON-RPC stdio、仅前台 `bash`、`read`/`write`/`edit`、一个前台 `subagent`、`todo_write`、JSONL 持久化和压缩。它不包含终端 UI、stdout 日志、批准、skill 和后台任务控制。详见 [jsonrpc-agent/README.md](jsonrpc-agent/README.md)。 + +## cordis-agent + +**自指** 演示:编码主干加 [`@deepseek-ai/dsh-tool-cordis`](../packages/cordis/tool-cordis),其三个工具(`cordis_inspect`/`cordis_mount`/`cordis_unmount`)使 agent 可以检查自身所在的实时 cordis 运行时,将模型编写的插件挂载到其中(事件监听器、一个专为自身创建的全新工具,或一个供另一挂载项注入的服务),并再次释放它们。所有动态挂载都归入同一 `cordis-dynamic` fiber 子树。`ctx.fs`/`ctx.web` 服务仅作为提供方随行,是这些插件构建所依赖的能力。 + +运行:`pnpm run demo:cordis`(需要 `DEEPSEEK_API_KEY`)。分阶段演示脚本详见 [cordis-agent/README.md](cordis-agent/README.md),设计与沙箱注意事项详见[工具集 Agent Note](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 + +## acp-agent + +作为 **Agent Client Protocol (ACP)** 自动化服务器通过 JSON-RPC stdio 公开的 agent,由 [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo) 提供。程序化客户端可以创建新会话、发送文本提示词、消费已提交的 assistant 文本、回答一次性权限请求并取消工作。它拥有 ACP 无密钥快照套件。 + +运行:`pnpm run demo:acp`(需要 `DEEPSEEK_API_KEY`);`pnpm run demo:code-mode acp` 通过 `code-mode.cordis.yml` 覆盖以 Code Mode 启动同一服务器。协议与快照测试契约详见 [acp-agent/README.md](acp-agent/README.md)。 + +默认 `cordis.yml` 组合 [`@deepseek-ai/dsh-sandbox-local`](../packages/sandbox/sandbox-local)、[`@deepseek-ai/dsh-bash-sandbox`](../packages/bash/bash-sandbox) 和 [`@deepseek-ai/dsh-user-approval`](../packages/ui/user-approval)。`workspace-write` 将 bash 和文件系统变更限制在每个会话 workspace 中;范围更广的重试会通过 ACP 成为一次性机器权限请求。 diff --git a/examples/acp-agent/README.i18n.yaml b/examples/acp-agent/README.i18n.yaml new file mode 100644 index 0000000000..22842fcd04 --- /dev/null +++ b/examples/acp-agent/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 4b3d86b00613cc7c37a8898ef3b39d40a167e66b +README.zh.md: 5bcd85f2b4ae34a11b980bf196d3401f764004d8 diff --git a/examples/acp-agent/README.md b/examples/acp-agent/README.md index 892a32cdc7..4b3d86b006 100644 --- a/examples/acp-agent/README.md +++ b/examples/acp-agent/README.md @@ -1,5 +1,7 @@ # acp-agent example +English | [中文](README.zh.md) + Automation-oriented [Agent Client Protocol](https://agentclientprotocol.com) server over JSON-RPC stdio. It is intended for parent agents, subagent providers, and other programmatic clients, not as the product UI. ```sh diff --git a/examples/acp-agent/README.zh.md b/examples/acp-agent/README.zh.md new file mode 100644 index 0000000000..5bcd85f2b4 --- /dev/null +++ b/examples/acp-agent/README.zh.md @@ -0,0 +1,30 @@ +# acp-agent 示例 + +[English](README.md) | 中文 + +通过 JSON-RPC stdio 提供的自动化导向 [Agent Client Protocol](https://agentclientprotocol.com) 服务器。它面向父 agent(智能体)、subagent 提供方和其他程序化客户端,而非产品 UI。 + +```sh +pnpm run demo:acp # needs DEEPSEEK_API_KEY (repo-root .env or env) +pnpm run demo:code-mode acp # same protocol with the Code Mode tool transport +``` + +该叶节点加载 ACP 应用、DeepSeek 适配器、受沙箱限制的 bash 与文件系统栈、一次性批准策略、压缩(compaction)、subagent、工作流、钩子、派生会话查询索引和重复守卫。应用为每次 `session/new` 创建一个新 agent,将会话持久化到 JSONL,并保持 stdout 只含协议内容。[`session-query.cordis.yml`](session-query.cordis.yml) 为其专用快照显式选用 workspace 授权的查询工具和通用超时/溢出策略;[`fs.cordis.yml`](fs.cordis.yml) 为文件系统场景添加溢出存储,[`code-mode.cordis.yml`](code-mode.cordis.yml) 则添加 `run_code` 及其生成的 TypeScript SDK。 + +## 协议通道 + +Stdout 只携带以换行分隔的 ACP JSON-RPC。`@deepseek-ai/dsh-acp-demo` 不安装 stdout logger;叶节点的附加项必须使用 stderr 输出诊断信息。 + +自动化契约(支持的方法、基线提示词内容、已提交文本输出,以及有意缺少的 UI 界面)位于 [`@deepseek-ai/dsh-acp`](../../packages/acp/acp/README.md)。 + +## 会话 workspace 与权限 + +每次 `session/new` 都提供一个绝对 `cwd`。受沙箱限制的 bash 与文件系统变更会根据该会话 cwd 解析 `workspace-write`,因此并发会话可以使用不同的项目根目录;平台临时根目录仍是共享可写暂存空间(参见[沙箱契约](../../packages/sandbox/sandbox/README.md))。`DSH_PERMISSION_MODE` 在部署和测试中选择 `workspace-write` 或 `danger-full-access`。 + +在 `workspace-write` 下,模型请求扩大沙箱权限的重试会触发 `session/request_permission`,选项为 `allow_once` 和 `reject_once`。客户端以程序方式决策;解除对话框或答案不可用时会失败闭合。选定结果仅适用于该次重试,并通过常规工具结果/审计路径记录。服务器绝不公开权限选择器,也不持久化客户端策略。 + +## 快照测试 + +此示例拥有 ACP 快照套件。它会启动真实自动化服务器,通过 `dsh-llm-replay` 回放已提交的模型流,并比较规范化后的协议输出与重新持久化的会话日志。录制使用真实模型;刷新会复用已提交的回放输入。覆盖场景包括抛出/挂起行为,可选 `workspace/` fixture(测试前置数据)则为外部状态检查预置环境。 + +大多数场景锁定后端行为,而非 ACP 专用行为;[仅面向自动化的 ACP 决策](../../.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary)说明了为何该覆盖仍与传输层耦合。 diff --git a/examples/cordis-agent/README.i18n.yaml b/examples/cordis-agent/README.i18n.yaml new file mode 100644 index 0000000000..7f4f2ae9dd --- /dev/null +++ b/examples/cordis-agent/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 1309fe9b2935d3224f097ceb2e80501c8075a933 +README.zh.md: 2e3e7d7206d0d676ae7d9c9b3a2c2f8be26aafe7 diff --git a/examples/cordis-agent/README.md b/examples/cordis-agent/README.md index a0bb2188da..1309fe9b29 100644 --- a/examples/cordis-agent/README.md +++ b/examples/cordis-agent/README.md @@ -1,5 +1,7 @@ # cordis-agent +English | [中文](README.zh.md) + The self-referential harness demo: the DeepSeek V4 coding spine on the full-screen TUI plus [`@deepseek-ai/dsh-tool-cordis`](../../packages/cordis/tool-cordis/README.md), which hands the model three tools over the **live cordis runtime it is running inside** — inspect it, mount new plugins into it, and dispose them again. The `ctx.fs` and `ctx.web` services are mounted (provider-only, no model-facing file/web tools) so the plugins the agent writes have real capabilities to build on; Node built-ins are trapped in the sandbox and redirect to those services. The design (sandbox semantics, mount lifecycle, cross-mount composition, caveats) lives in [the toolset Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). ## Run it diff --git a/examples/cordis-agent/README.zh.md b/examples/cordis-agent/README.zh.md new file mode 100644 index 0000000000..2e3e7d7206 --- /dev/null +++ b/examples/cordis-agent/README.zh.md @@ -0,0 +1,35 @@ +# cordis-agent + +[English](README.md) | 中文 + +自指 harness 演示:在全屏 TUI 上运行 DeepSeek V4 编码主干,并加载 [`@deepseek-ai/dsh-tool-cordis`](../../packages/cordis/tool-cordis/README.md)。后者通过 agent(智能体)所在的 **实时 cordis 运行时** 向模型提供三个工具:检查运行时、将新插件挂载到其中,以及再次释放它们。`ctx.fs` 和 `ctx.web` 服务也会挂载(仅作为提供方,不包含面向模型的文件/Web 工具),使 agent 编写的插件可以构建于真实能力之上;Node 内置模块在沙箱中被截获并重定向到这些服务。设计(沙箱语义、挂载生命周期、跨挂载组合、注意事项)详见[工具集 Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 + +## 运行 + +```sh +# repo root .env (gitignored) or exported env: +# DEEPSEEK_API_KEY=sk-… +# DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API +pnpm run demo:cordis +``` + +预期演示分阶段进行:先验证监听器链接,再让 agent 扩展自身: + +``` +> Mount a plugin that listens to the 'agent/status' event and logs every status change, then run `echo hi` with bash. + [tool call] cordis_mount({"code": "return { name: 'status-logger', apply(ctx) { ctx.on('agent/status', (agent, status) => console.log('status →', status)) } }"}) + [tool result] mounted dyn-1 (plugin "status-logger", state: active) + [tool call] bash({"command": "echo hi"}) +[cordis:dyn-1] status → … ← the mounted listener firing, live +> Now give yourself a reverse_text tool and use it on "harness". + [tool call] cordis_mount({"code": "return { name: 'reverse-text', inject: ['tools'], apply(ctx) { ctx.tools.register(harness.defineTool({ name: 'reverse_text', … })) } }"}) + [tool call] reverse_text({"text": "harness"}) ← a tool the agent built for itself, one step earlier +> Unmount both. + [tool call] cordis_unmount({"id": "dyn-1"}) +``` + +请求 `cordis_inspect` 并使用 `what: "api"` 或 `what: "events"`,即可查看为 agent 生成、供其编写插件时参考的服务/事件资料。还可尝试两个协作挂载(一个中调用 `ctx.provide`,另一个中使用 `inject`),观察 cordis 如何暂停并恢复消费方。 + +## 端到端测试 + +`tests/keyless-smoke.e2e.ts` 使用虚拟密钥通过 Loader 启动真实 `cordis.yml`,并断言横幅、包名解析和 EOF 后干净退出。`tests/cordis-tools.e2e.ts` 是带密钥的冒烟测试:真实模型挂载状态监听器,测试验证其带标记的 console 行;然后创建并使用 `reverse_text` 工具,再通过 provide/inject 组合两个挂载。[`packages/cordis/tool-cordis`](../../packages/cordis/tool-cordis) 在每文件 100% 覆盖率门禁下承载单元覆盖。 diff --git a/examples/headless-agent/README.i18n.yaml b/examples/headless-agent/README.i18n.yaml new file mode 100644 index 0000000000..7c4dbaf463 --- /dev/null +++ b/examples/headless-agent/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 445804a2611e5e8093eadf345ad10a2a7984c012 +README.zh.md: 68ec718afe0b2aca276be2689cbae74167ee1c7b diff --git a/examples/headless-agent/README.md b/examples/headless-agent/README.md index a1e2455ed3..445804a261 100644 --- a/examples/headless-agent/README.md +++ b/examples/headless-agent/README.md @@ -1,5 +1,7 @@ # headless-agent +English | [中文](README.zh.md) + Headless one-shot agent wiring: DeepSeek V4 + local bash and filesystem tools + subagent delegation + workflows and fresh-agent Ralph iteration + `todo_write` + JSONL persistence, with [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo) as the app front door. ## Run it diff --git a/examples/headless-agent/README.zh.md b/examples/headless-agent/README.zh.md new file mode 100644 index 0000000000..68ec718afe --- /dev/null +++ b/examples/headless-agent/README.zh.md @@ -0,0 +1,26 @@ +# headless-agent + +[English](README.md) | 中文 + +无头单次 agent(智能体)接线:DeepSeek V4 + 本地 bash 与文件系统工具 + subagent 委托 + 工作流与新 agent Ralph 迭代 + `todo_write` + JSONL 持久化,并以 [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo) 作为应用入口。 + +## 运行 + +```sh +# repo root .env (gitignored) or exported env: +# DEEPSEEK_API_KEY=sk-… +# DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API +pnpm run demo:headless "fix the failing test in this workspace" +pnpm run demo:headless --output-format json -- "summarize the implementation" +pnpm run demo:headless --output-format stream-json -- "run the focused tests" +``` + +必须提供且只能提供一个非空位置任务;含空格的任务需要加引号。没有 `-p` 标志。`text` 打印最后一条包含文本的 assistant 消息,`json` 打印一条 DSH 原生结果记录,`stream-json` 则在该记录之前发出顶层会话的规范任务轮次事件。子会话只通过父工具事件和结果对外显示。 + +每次调用都会创建并持久化新会话,在一个轮次中运行所有模型和工具步骤,然后刷新、释放并退出。这是非交互式自动化:没有提示符、批准、恢复、第二轮次或 stdin 上下文。已配置工具可以修改启动 workspace、运行命令、spawn 子 agent,并消耗提供方 token。 + +## 高级与快照接线 + +[`advanced.cordis.yml`](advanced.cordis.yml) 在已交付叶节点上添加 Code Mode 和 Cordis 工具。[`advanced.cordis.snapshot.yml`](advanced.cordis.snapshot.yml) 只将实时 LLM(大语言模型)替换为回放。[`tests/`](tests/) 下的测试拥有无密钥真实 Loader 冒烟测试、密钥门控的外部状态验证冒烟测试,以及带父子会话 fixture(测试前置数据)的 `stream-json` 回放快照。 + +包级 [CLI 契约](../../packages/examples/cli-demo/README.md)记录输出记录、退出状态、取消、持久化以及模型/token 影响。 diff --git a/examples/jsonrpc-agent/README.i18n.yaml b/examples/jsonrpc-agent/README.i18n.yaml new file mode 100644 index 0000000000..59c18e0131 --- /dev/null +++ b/examples/jsonrpc-agent/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 6ee4e9d824315bde76b7a534679f018df9a6d3e8 +README.zh.md: dc9b6233e7074e7a9b13bf10bcd2f310b0ad7bf3 diff --git a/examples/jsonrpc-agent/README.md b/examples/jsonrpc-agent/README.md index 83c6e98fa5..6ee4e9d824 100644 --- a/examples/jsonrpc-agent/README.md +++ b/examples/jsonrpc-agent/README.md @@ -1,5 +1,7 @@ # jsonrpc-agent +English | [中文](README.zh.md) + The unattended coding-agent composition for the Python SDK's bundled JSON-RPC runtime. It intentionally loads no terminal UI, console logger, approval surface, or user-interaction tool because stdout belongs to the SDK protocol and turns are driven by the SDK. The model-facing tools are: diff --git a/examples/jsonrpc-agent/README.zh.md b/examples/jsonrpc-agent/README.zh.md new file mode 100644 index 0000000000..dc9b6233e7 --- /dev/null +++ b/examples/jsonrpc-agent/README.zh.md @@ -0,0 +1,27 @@ +# jsonrpc-agent + +[English](README.md) | 中文 + +面向 Python SDK 内置 JSON-RPC 运行时的无人值守编码 agent(智能体)组合。它有意不加载终端 UI、console logger、批准界面或用户交互工具,因为 stdout 属于 SDK 协议,轮次由 SDK 驱动。 + +面向模型的工具为: + +- `bash`,仅前台 +- `read`、`write` 和 `edit` +- `subagent`,使用一个前台进程内 spawn 提供方 +- `todo_write` + +周边运行时还加载 JSONL 会话持久化和自动上下文压缩(compaction)。`maxTokensAsSuccess` 将受 token 上限限制的模型轮次保留为已接受的评估结果,同时保留其 `max-tokens` 原因。 + +## 运行时环境 + +| 变量 | 用途 | +|---|---| +| `DEEPSEEK_API_KEY` | 传给 OpenAI 兼容宿主端点的凭据 | +| `DEEPSEEK_BASE_URL` | `dsh-llm-deepseek` 使用的宿主端点 | +| `DSH_CWD` | bash 和文件系统工具使用的 agent workspace | +| `DSH_MAX_TOKENS_AS_SUCCESS` | `true`(默认)接受受 token 上限限制的结果;`false` 将其报告为错误 | +| `DSH_SESSION_ROOT` | JSONL 轨迹目录 | +| `DSH_SYSTEM_PROMPT` | 由部署提供的编码人格 | + +通过 Python SDK 的 `cordis` 选项或 `DSH_CORDIS_CONFIG` 传入配置路径。内置可执行文件已携带此文件命名的每个插件;目标机器无需 Node.js。 diff --git a/examples/tui-agent/README.i18n.yaml b/examples/tui-agent/README.i18n.yaml new file mode 100644 index 0000000000..631848bb77 --- /dev/null +++ b/examples/tui-agent/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: fdf3972f98f8ff9690b71469ae415dc18d339f10 +README.zh.md: 71f7ae949d034757a20adfae2cbe566011edc584 diff --git a/examples/tui-agent/README.md b/examples/tui-agent/README.md index 5196de053b..fdf3972f98 100644 --- a/examples/tui-agent/README.md +++ b/examples/tui-agent/README.md @@ -1,5 +1,7 @@ # tui-agent +English | [中文](README.zh.md) + The full-screen interactive coding agent: DeepSeek V4, local bash and filesystem tools, compaction, subagents, workflows and fresh-agent Ralph iteration, plan mode (`/plan` enters and `exit_plan_mode` reviews the exit), timeout/spill policy, and JSONL persistence through [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo), loaded from `cordis.yml`. The sibling [`headless-agent`](../headless-agent/README.md) runs the same capability class as a one-shot pipe-friendly task, and [`acp-agent`](../acp-agent/README.md) serves it over JSON-RPC. ## Run it diff --git a/examples/tui-agent/README.zh.md b/examples/tui-agent/README.zh.md new file mode 100644 index 0000000000..71f7ae949d --- /dev/null +++ b/examples/tui-agent/README.zh.md @@ -0,0 +1,80 @@ +# tui-agent + +[English](README.md) | 中文 + +全屏交互式编码 agent(智能体):DeepSeek V4、本地 bash 与文件系统工具、压缩(compaction)、subagent、工作流与新 agent Ralph 迭代、plan mode(`/plan` 进入,`exit_plan_mode` 评审退出)、超时/溢出策略,以及通过 [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo) 提供的 JSONL 持久化;该应用从 `cordis.yml` 加载。同级 [`headless-agent`](../headless-agent/README.md) 以适合单次管道的任务形式运行同一能力类,[`acp-agent`](../acp-agent/README.md) 则通过 JSON-RPC 提供该能力。 + +## 运行 + +```sh +# repo root .env (gitignored) or exported env: +# DEEPSEEK_API_KEY=sk-… +# DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API +pnpm run demo:tui +``` + +演示脚本和可安装的 `dsh` CLI([`apps/cli`](../../apps/cli/README.md))都会作为已交付的默认配置启动此示例的 `cordis.yml`;`dsh` 还会应用 `~/.dsh` 中的个人覆盖,并将调用目录作为 workspace。 + +输入一项编码任务。agent 使用 `read`/`write`/`edit` 文件系统工具处理常规文件操作,使用 `bash`(加上面向后台任务的通用 `task_output`/`task_list`/`task_kill`)执行 shell 命令、搜索和测试。每次操作都在新的 `bash -c` 中运行(系统提示词要求模型传递 `workdir`,而不是使用 `cd`)。fs 工具和 bash 都会根据会话 workspace 解析相对路径。agent 还可以通过 `subagent`/`subagent_fork` 委托。 + +`todo_write` 任务跟踪器是选用的,不在已交付配置中:请将 `@deepseek-ai/dsh-tool-todo` 添加到 `cordis.yml`(或在 `~/.dsh` 下使用个人配置覆盖)以公开该工具。加载后,模型会把整表计划记录到会话日志,TUI 则渲染它。 + +TUI 渲染 Markdown 历史、推理、工具所有的终端/diff/通用卡片、token 总量,以及加载 `todo_write` 时的最新计划。较长的工具正文保留首尾预览;Ctrl+O 展开或折叠所有卡片。Enter 用于提交,或在 agent 运行时进行 steering(中途引导);Ctrl+R 切换推理,Escape 取消,`/help` 列出命令。`/plan` 为下一步骤选择 plan mode;`/plan <message>` 还会将消息提交到该步骤,`/plan off` 则在没有模型输入的情况下选择默认 mode。`/status` 会展开当前会话的标识、活动计数、精确 token/缓存 bucket、上下文用量和时间戳,而不中断正在运行的轮次。`/model` 打开当前提供方目录的键盘选择器;使用 Up/Down 和 Enter,或使用 `/model <model>` 和 `/model <provider>/<model>` 直接选择。`ask_user_question` 会打开一个位于左下方的宽键盘面板,包含批次进度和编号选项。 + +### 恢复早先的会话 + +每次运行默认都会启动新会话(其事件日志落在 `./.sessions/` 下)。如需 **继续** 先前对话,请将其 id 传给已安装的 `dsh` CLI:此时 `main` agent 会重新水化持久日志,而不会从头开始,因此模型会将早先轮次视为历史: + +```sh +dsh --resume <prior-session-id> +``` + +`/resume` 打开可搜索键盘选择器,显示标题、活动、上一轮结果、模型路由、持久 goal 阶段和实时/已持久化状态。已安装的 `dsh` 宿主会刷新并释放当前应用,然后以 `dsh --resume <id>` 替换进程。TUI 仍会在退出时打印该命令,并在自定义宿主无法移交时显示它。`dsh --resume <id>` 在启动上下文中提供 id,`cordis.yml` 会读取它(`resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`);没有标志时,agent 会开始新会话。缺失或无法读取的 id 不会启动 agent,而会发出 `agent-loop/config-start-failed`:TUI 打印失败并以非零状态退出。选择器没有跨进程会话锁,因此拥有并发宿主的部署必须自行协调会话所有权。 + +## Code Mode + +[`code-mode.cordis.yml`](code-mode.cordis.yml) 在同一树上覆盖 worker 线程运行时和 `tools: { mode: code }`。模型会收到一个 `run_code` 传输工具,加上一份为可见工具生成的 TypeScript SDK;只有程序输出会返回模型上下文。使用 `mode: both` 可在 `run_code` 旁同时公开原生调用。执行契约详见 [Code Mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)。 + +```sh +pnpm run demo:code-mode # this overlay under the TUI (default UI) +pnpm run demo:code-mode acp # the acp-agent example's same-shaped overlay +``` + +尝试一项横跨多个工具调用的任务,例如: + +> 统计 docs/ 下每个 `*.md` 文件的行数,并将最大的三个写入 summary.txt。 + +然后观察 transcript(文本记录):一次 `run_code` 调用、一个循环调用工具的程序,以及模型筛选后的结果,而不是五次原始工具输出往返。 + +## 每个叶节点配置项所演示的内容 + +此示例是轻量叶节点 `cordis.yml`:它选择可替换后端、加载一个应用包(package),并添加有意放在共享主干外的产品工具。主干(会话、系统提示词、工具、agent、不变式、`agent-loop`)和入口集群(JSONL 持久化、pi-tui 通道、预创建的 `main` agent)位于 [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo) 应用及其加载的 [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) 组合包中;叶节点负责接线后端与面向模型的可选工具: + +| 配置项 | 演示内容 | +|---|---| +| `hmr` (`@cordisjs/plugin-hmr`) | 开发/演示的编辑-重载循环:它是 **叶节点** 配置项(不内置到应用),因为它依赖 Loader 的内部模块访问 | +| `llm-deepseek` | 通过配置提供真实 `LlmAdapter`(`!!js process.env.…` 密钥);将一行替换为 `@deepseek-ai/dsh-llm-pi-ai` 即可使用库后端对照实现 | +| `bash` (`dsh-bash-local`) | 执行器实现:bash seam 的可替换一半。面向模型的 `bash` schema(`tool-bash`)和通用 `task_*` 控制(`tool-tasks`)由 `dsh-agent-spine-demo` 提供,因此叶节点只选择执行器 | +| `tui-agent` (`@deepseek-ai/dsh-tui-demo`) | 应用组合包:agent-spine 演示 + JSONL 持久化 + pi-tui 通道 + 预创建的 `main` agent | +| `subagent`, `subagent-spawn`, `subagent-fork` | subagent 提供方注册表加两个进程内后端:新子 agent,以及用父 agent 已完成轮次前缀播种的子 agent | +| `tool-subagent`, `tool-subagent-fork` | 两次面向模型的 `dsh-tool-subagent` 加载,每次绑定不同提供方,并以不同工具名(`subagent`、`subagent_fork`)公开 | +| `workflow-workerthread`, `tool-workflow` | worker 线程工作流引擎及其面向模型的 `workflow` 工具,子调用通过 spawn 后端路由 | +| `plan-mode` | 插件拥有的 `/plan [message]` 进入命令和 `/plan off` 退出命令、plan-mode 提示词策略、工具限制,以及经评审的 `exit_plan_mode` 转换 | +| `fs-local`, `fs-policy`, `tool-fs` | 文件系统栈:本地 `ctx.fs` 提供方、先读后写/编辑策略门禁(位于 `fs/*` 事件门禁),以及面向模型的 `read`/`write`/`edit` 工具。相对路径根据会话 workspace 解析 | + +## 端到端测试(`pnpm run test:e2e`) + +与 UI 无关的带密钥套件通过 `tests/harness.ts` 以程序方式组装完整栈(无 PTY、无 Loader): + +- `tests/full-loop.e2e.ts`:canary 测试:真实模型通过真实 bash 工具运行 `echo e2e-ok`;断言 `tool/call`/`tool/result` 会话事件和最终答案。 +- `tests/coding-task.e2e.ts`:类 swebench 冒烟测试:临时目录包含 `add.js`(其中 `a - b` 写在本应是 `a + b` 的位置)和失败的 `add.test.js`;agent 必须修复错误并验证。测试会自行重新运行 `node add.test.js` 并检查文件,不信任 agent 的声称。 +- `tests/resume.e2e.ts`:跨进程持久连续性:第一次运行告诉真实模型一个密码并将轮次持久化到临时 JSONL 根目录,然后释放整个上下文;第二次运行在同一根目录上创建新上下文,恢复会话 id 并要求模型回忆密码。只有重新水化的日志能够提供该回忆。 +- `tests/compaction.e2e.ts`:压缩冒烟测试:一项真实多步 bash 任务在故意设得很小的上下文窗口中运行,使自动压缩监听器在会话中途触发。测试验证外部状态:真实日志中出现 `compact/start…end` 对,表层缩减(替换节点遮蔽旧节点),且 agent 在压缩后仍给出正确最终答案。 +- `tests/todo-write.e2e.ts`:加载选用 `todo_write` 工具,由真实模型驱动,测试验证产生的 `todo/write` 会话事件。 +- `tests/code-mode.e2e.ts`:带密钥 Code Mode 证明:使用真实模型和双工具任务,断言线上工具列表精确为 `[run_code]`,`tool/code-dispatch` 事件位于父调用下,且筛选后的答案已返回。 + +这些测试在没有 `DEEPSEEK_API_KEY` 时自行跳过。无密钥 `tests/tui-keyless-smoke.e2e.ts` 通过 PTY 启动真实 Loader 树(唯一获准的 PTY 界面):基础启动 + `/plan` + `/exit`,一次带问题对话框和工具往返的脚本 LLM 对话,Code Mode 覆盖欢迎行,以及恢复失败退出路径。 + +## 快照测试 + +`tests/snapshots/<scenario>/session.jsonl` 提供已录制的用户提示词和模型分片;同级子日志驱动 subagent 和工作流。无密钥套件通过真实循环和工具实现执行这些脚本,然后比较可读的预期终端单元格/样式输出。使用 `pnpm run test:snapshot:refresh` 刷新仅展示变更;已录制模型旅程改变时,使用 DeepSeek 密钥运行 `pnpm run test:snapshot:record`。已实现的 [TUI 快照 Agent Note](../../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) 拥有场景矩阵,以及已录制旅程、瞬时包快照与 PTY 覆盖之间的分工。 diff --git a/missions/readme.md b/missions/readme.md deleted file mode 100644 index 66167719ad..0000000000 --- a/missions/readme.md +++ /dev/null @@ -1,36 +0,0 @@ -# Workspace GUI 收尾备忘 - -## 产品改动 - -- 用户要求“去掉功能”时,先拆开视觉入口、可访问性语义和响应行为分别确认。本次 composer 加号保留原样和 `Add attachment` 标签,只在组合层停止传入 Workspace 回调;不要删除按钮、改样式或把它禁用。 -- 临时交互不应上浮到 React 呈现层。Session/Workspace Intent、首次消息保留和 materialize 重试归 runtime 对象与 service;组件只接收标准 action、hooks 和纯呈现状态。 -- RFC、测试名称和 PR 描述只写最终产品语义,不保留 `reconcilePublishedDraft`、`pendingCwd` 等已经撤销的中间方案。 - -## Snapshot 与测试定位 - -- `apps/web/tests/**/*.snapshot.ts` 验证 built application,需用 `DSH_EXAMPLE_MODE=lib`,并确认相关 `lib/` 已由当前源码构建;普通 source-mode Vitest 通过不能替代它。 -- 对 runtime 管理的受控输入执行 `fireEvent.change` 后,必须 `waitFor` 输入值回显再点击发送,否则发送可能读取旧的空 prompt。 -- 页面中 Workspace 与 Session 可以同名,禁止用无作用域的 `findByText` 定位。先用 `within` 锁定 Sessions tree、计数或对应 group,再找目标行。 -- 新 push 后先看 assembled snapshot 是否真正跑过;本地 focused snapshot 通过后仍以 `gh pr checks` 的 artifact job 为准。 - -## Coverage 收口 - -- 测试筛选和 coverage 筛选是两件事。用 owning tests 配合逐个 `--coverage.include='<source-file>'`,先拿到真实未覆盖行和分支,不要直接反复跑全仓 coverage。 -- 多个 coverage 进程并发时必须给不同的 `--coverage.reportsDirectory`,否则报告目录互相覆盖。各 worker 完成后再跑一次合并后的精确 coverage,确认共享 worktree 的改动组合起来仍为 100%。 -- 全仓 coverage 若先被无关测试超时打断,不能把它当作目标文件的结论;先用精确 include 修本分支缺口,再让 CI exhaustive coverage 验证整体。 -- Coverage 测试仍要描述行为,不写“为了覆盖某分支”的注释。不可达分支才使用已有规范允许的 `v8 ignore`,可达分支补真实行为测试。 - -## 并发与提交 - -- Coverage 适合按不相交写区并发:例如 Sidebar tests、Workspace picker tests、connection/storage tests。派工时明确“只改 tests、不改 src、不 commit、不得回滚他人改动”。 -- 不直接信任各 worker 的单独结果;主会话审查 diff、运行合并后的 focused coverage、清理生成报告,再统一 commit。 -- 推送前按 `dsh-pre-push-checks` 选择最小充分验证,不重复已经通过的检查;正常 push 让 pre-push typecheck 运行,并核对本地 HEAD 与远端 ref 一致。 -- 生成的 `.coverage/` 只属于本地诊断。环境拒绝 `rm -rf` 时,依次使用 `find .coverage -type f -delete` 和 `find .coverage -depth -type d -empty -delete`;不要让报告进入 commit。 - -## GitHub 与 CI - -- GitHub 操作统一走 `gh`,并从 git 配置注入代理:`proxy="$(git config --get http.https://github.com.proxy)"; https_proxy="$proxy" http_proxy="$proxy" GH_PAGER=cat ~/.local/bin/gh ...`。不要改用网页。 -- 每次 push 都会产生一轮新 checks;旧轮次的失败不能代表当前 HEAD。先确认 run 对应当前提交,再拉失败日志。 -- `gh run watch` 只监视一个 workflow。最终必须用 `gh pr checks` 汇总 CI、e2e、sandbox 和 Windows 等独立 workflow;偶发平台失败先等当前 HEAD 重跑结果,不预先修改无关代码。 -- PR base 和 description 在最终 push 后再次用 `gh pr edit --base ... --body-file ...` 同步。PR 描述应包含最终产品动线、架构边界和实际运行过的验证,不写仍待执行的承诺。 -- Review thread 用 GraphQL/`gh api` 检查 `isResolved` 和已有回复,避免对已经解决的旧实现评论重复修复。 diff --git a/native/README.i18n.yaml b/native/README.i18n.yaml new file mode 100644 index 0000000000..4013fc656d --- /dev/null +++ b/native/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 84808b2ee9dafa4f9f980c35a81ebe12480a4f5d +README.zh.md: f73d4176454d9a577bf674a6bfe3f15cce3402b4 diff --git a/native/README.md b/native/README.md index 983e67f740..84808b2ee9 100644 --- a/native/README.md +++ b/native/README.md @@ -1,5 +1,7 @@ # native/ +English | [中文](README.zh.md) + Source of record for `node-addon-landlock-run`, the Landlock self-restrict-then-exec launcher the harness consumes from npm (`packages/sandbox/sandbox-local`, `packages/bash/bash-sandbox`). Launcher development happens HERE, next to the consumers; the standalone repository is the release mirror that packs and publishes the npm package family. ## Release mirror diff --git a/native/README.zh.md b/native/README.zh.md new file mode 100644 index 0000000000..f73d417645 --- /dev/null +++ b/native/README.zh.md @@ -0,0 +1,22 @@ +# native/ + +[English](README.md) | 中文 + +`node-addon-landlock-run` 的记录真源:这是 harness 从 npm 消费的 Landlock「先限制自身、再执行」启动器(`packages/sandbox/sandbox-local`、`packages/bash/bash-sandbox`)。启动器在此处开发,与消费方相邻;独立仓库是打包并发布 npm 包系列的发布镜像。 + +## 发布镜像 + +| 目录 | 镜像仓库 | 上次导出的发布版 | Commit | +|---|---|---|---| +| `landlock-run/` | https://github.com/deepseek-harness/node-addon-landlock-run | `v0.0.1` | `614f7fd7dc11e6eaceefba9e7ff1fbe28b51ba22` | + +该子树是一个自包含的 pnpm workspace,拥有自己的 `AGENTS.md`、文档、门禁和锁文件;它不属于 harness workspace(`pnpm-workspace.yaml` 不包含它),因此 harness 的安装、构建和 CI 门禁绝不会触及它。镜像的 `.github/` 不进入该子树;[.github/workflows/landlock-run.yml](../.github/workflows/landlock-run.yml)(手动触发)在此处运行子树的 CI 任务,对这些任务的更改会在下次导出时镜像到镜像仓库的 `ci.yml`。 + +## 导出流程(发布新版本) + +1. 先通过常规 harness PR 将启动器更改落地于此;触发 `Landlock Run` 工作流,并确保其所有任务通过。 +2. 在镜像 checkout 中替换 `.github/` 以外的所有内容:`git -C <mirror> rm -rq -- . ':!.github'`,然后执行 `git -C <harness> archive HEAD:native/landlock-run | tar -x -C <mirror>`,最后执行 `git -C <mirror> add -A` 并提交。 +3. 在镜像中按照其发布清单(`docs/release.md`)操作:`pnpm release:commit <version>` → 合并 → 标记 `vX.Y.Z` → 两阶段 `Release` 工作流(先以 `publish=false` 预演,再从标签以 `publish=true` 发布)。 +4. 使用已发布的标签/commit 更新上方 manifest(元数据清单)表,并在同一更改中提升 harness 消费方的依赖范围。 + +镜像不得分叉:如果更改直接提交到镜像中(例如发布期间的热修复),必须在下次导出前将其移植回此处。 diff --git a/native/landlock-run/README.i18n.yaml b/native/landlock-run/README.i18n.yaml new file mode 100644 index 0000000000..7212b6a317 --- /dev/null +++ b/native/landlock-run/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 284d5df764cf5a5205973696211aee2366d3b76e +README.zh.md: 7163314abac0362afccee6fcc4506a84701cc27a diff --git a/native/landlock-run/README.md b/native/landlock-run/README.md index 2bb92843e6..284d5df764 100644 --- a/native/landlock-run/README.md +++ b/native/landlock-run/README.md @@ -1,5 +1,7 @@ # node-addon-landlock-run +English | [中文](README.zh.md) + A [Landlock](https://landlock.io/) self-restrict-then-exec launcher for confining subprocesses on Linux, distributed as prebuilt per-platform npm packages plus a thin JS entry package that resolves the binary and speaks its CLI contract. Built for agent harnesses and other hosts that need to run untrusted commands under a filesystem allow-list without confining themselves. The first tool is **`landlock-run`** — a self-restrict-then-exec [Landlock](https://landlock.io/) launcher (~300 lines of C11 over the raw kernel UAPI, statically linked against musl). It installs a Landlock ruleset on itself and `exec`s the wrapped command; the ruleset is inherited across `execve`, so the command and every process it spawns run confined while the invoking process stays unrestricted. Fail-closed: if the kernel cannot enforce, it exits without running the command. diff --git a/native/landlock-run/README.zh.md b/native/landlock-run/README.zh.md new file mode 100644 index 0000000000..7163314aba --- /dev/null +++ b/native/landlock-run/README.zh.md @@ -0,0 +1,60 @@ +# node-addon-landlock-run + +[English](README.md) | 中文 + +一个 [Landlock](https://landlock.io/)「先限制自身、再执行」启动器,用于在 Linux 上限制子进程。它以每平台预构建 npm 包加一个轻量 JS 入口包的形式发布;入口包负责解析二进制文件并遵循其 CLI(命令行界面)契约。该启动器面向需要在文件系统允许清单下运行不可信命令、但不能限制自身的 agent harness 和其他宿主。 + +第一个工具是 **`landlock-run`**:一个「先限制自身、再执行」的 [Landlock](https://landlock.io/) 启动器(基于原始内核 UAPI 编写,约 300 行 C11,并与 musl 静态链接)。它在自身上安装 Landlock 规则集,再 `exec` 被包装的命令;该规则集会跨 `execve` 继承,因此命令及其产生的每个进程都在限制下运行,调用进程仍不受限制。它采用失败闭合:如果内核无法强制执行,则不运行命令并直接退出。 + +## 安装 + +```sh +npm install node-addon-landlock-run +``` + +已发布包由一个入口包和可选平台包组成: + +```text +node-addon-landlock-run +node-addon-landlock-run-linux-x64 +node-addon-landlock-run-linux-arm64 +``` + +npm 的 `os`/`cpu` 字段使安装器只拉取匹配的平台包。系统有意不提供安装时构建回退:在没有对应平台包的宿主上,解析后的路径绝不存在,探测会报告 `unusable`,消费方以失败闭合方式处理。 + +## 用法 + +```js +import { grantArgs, launcherPath, probe } from 'node-addon-landlock-run'; + +const launcher = launcherPath(); +if (probe(launcher) !== 'unusable') { + const argv = [launcher, ...grantArgs({ readOnly: ['/'], readWrite: ['/tmp/work'] }), '--', 'bash', '-c', command]; + // spawn argv with your process runner of choice +} +``` + +公开 API 有意保持简小: + +- `launcherPath()`:当前宿主启动器的绝对路径(有意不检查是否存在;探测结果才是可用性信号)。 +- `probe(launcher?, { timeoutMs? })`:功能性强制执行探测,返回 `'full' | 'partial' | 'unusable'`。 +- `grantArgs({ readOnly?, readWrite? })`:启动器的授权 argv;未授予的一切都被拒绝。 +- `LAUNCHER_BIN`、`LAUNCHER_FAILURE_EXIT` (125):契约常量。 + +完整的二进制契约(argv 语法、退出码、报告行)锁定在 [docs/cli-contract.md](docs/cli-contract.md) 中。 + +## 支持范围 + +支持 linux-x64 和 linux-arm64,且内核已启用 Landlock(5.13+;ABI 级别决定强制执行为 `full` 还是 `partial`,详见 [docs/support-matrix.md](docs/support-matrix.md))。其他平台有意不提供对应包:消费方会在这些平台上运行其他限制后端。 + +## 开发 + +```sh +corepack enable +pnpm install +pnpm build:ts # entry packages → lib/ +pnpm build:native # this Linux architecture's binaries (apt-get install musl-tools) +pnpm test +``` + +二进制文件被 git 忽略,并且按架构原生构建:本地只构建当前机器的版本,CI 的每架构 runner 则是记录中的构建者。发布流程详见 [docs/release.md](docs/release.md)。 diff --git a/native/landlock-run/packages/entry/README.i18n.yaml b/native/landlock-run/packages/entry/README.i18n.yaml new file mode 100644 index 0000000000..2985be0419 --- /dev/null +++ b/native/landlock-run/packages/entry/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: e402cdfe71c4eb81b977a21955fe3fff6bf55fd3 +README.zh.md: 03dd18969d8e5c94be31605201b74b126ae5c06d diff --git a/native/landlock-run/packages/entry/README.md b/native/landlock-run/packages/entry/README.md index 789b1ddf6b..e402cdfe71 100644 --- a/native/landlock-run/packages/entry/README.md +++ b/native/landlock-run/packages/entry/README.md @@ -1,5 +1,7 @@ # node-addon-landlock-run +English | [中文](README.zh.md) + Landlock self-restrict-then-exec launcher for confining subprocesses on Linux: this entry package resolves the per-platform prebuilt binary, runs its functional enforcement probe, and builds its grant argv — consumers never spell launcher flags or parse launcher output themselves. ```js diff --git a/native/landlock-run/packages/entry/README.zh.md b/native/landlock-run/packages/entry/README.zh.md new file mode 100644 index 0000000000..03dd18969d --- /dev/null +++ b/native/landlock-run/packages/entry/README.zh.md @@ -0,0 +1,18 @@ +# node-addon-landlock-run + +[English](README.md) | 中文 + +用于在 Linux 上限制子进程的 Landlock「先限制自身、再执行」启动器:此入口包解析每平台预构建二进制文件,运行功能性强制执行探测,并构建其授权 argv。消费方无需自行拼写启动器标志或解析启动器输出。 + +```js +import { grantArgs, launcherPath, probe } from 'node-addon-landlock-run'; + +const launcher = launcherPath(); +if (probe(launcher) !== 'unusable') { + const argv = [launcher, ...grantArgs({ readOnly: ['/'], readWrite: ['/tmp/work'] }), '--', 'bash', '-c', command]; +} +``` + +启动器在自身上安装 Landlock 规则集,再 `exec` 被包装的命令;该规则集会跨 `execve` 继承,因此整个进程树都在限制下运行。未授予的一切都被拒绝;启动器失败时以 `125` 退出且不运行命令:始终失败闭合,绝不失败开放。二进制契约锁定在仓库的 `docs/cli-contract.md` 中;C 源码作为 `src/main.c` 随该 tarball 分发,便于审计。 + +平台包(由 `os`/`cpu` 选择的可选依赖,内部不含 JavaScript):`node-addon-landlock-run-linux-x64`、`node-addon-landlock-run-linux-arm64`。在缺少对应包的宿主上,`launcherPath()` 返回确定且不存在的路径,`probe()` 报告 `'unusable'`;系统有意不提供安装时编译回退。 diff --git a/native/landlock-run/packages/linux-arm64/README.i18n.yaml b/native/landlock-run/packages/linux-arm64/README.i18n.yaml new file mode 100644 index 0000000000..b6b14f1eef --- /dev/null +++ b/native/landlock-run/packages/linux-arm64/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: e5117988cf0bae2227edaa041700c2f75753899c +README.zh.md: 93fee68207a9f03a54f214c69904d44729ed71e5 diff --git a/native/landlock-run/packages/linux-arm64/README.md b/native/landlock-run/packages/linux-arm64/README.md index 1921c8f4b5..e5117988cf 100644 --- a/native/landlock-run/packages/linux-arm64/README.md +++ b/native/landlock-run/packages/linux-arm64/README.md @@ -1,5 +1,7 @@ # node-addon-landlock-run-linux-arm64 +English | [中文](README.zh.md) + Prebuilt `bin/landlock-run` Landlock launcher for linux-arm64 — a static musl binary compiled natively (no cross toolchain) from the C source shipped in [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run). npm's `os`/`cpu` fields select this package at install time; the entry package resolves it to a file path — it ships no JavaScript and is never imported. The binary is git-ignored and rides the npm tarball via the `files` list; the `prepack` gate refuses to pack when it is missing or has the wrong ELF architecture, and the release pipeline byte-pins the packed binary against the CI build it came from. Static musl linking means one binary for glibc and musl distros alike — hence no libc suffix in the name. diff --git a/native/landlock-run/packages/linux-arm64/README.zh.md b/native/landlock-run/packages/linux-arm64/README.zh.md new file mode 100644 index 0000000000..93fee68207 --- /dev/null +++ b/native/landlock-run/packages/linux-arm64/README.zh.md @@ -0,0 +1,9 @@ +# node-addon-landlock-run-linux-arm64 + +[English](README.md) | 中文 + +面向 linux-arm64 的预构建 `bin/landlock-run` Landlock 启动器:一个从 [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) 中随包发布的 C 源码原生编译而成的静态 musl 二进制文件(不使用交叉工具链)。npm 的 `os`/`cpu` 字段在安装时选择此包;入口包将其解析为文件路径。该包不包含 JavaScript,也绝不会被导入。 + +该二进制文件被 git 忽略,并通过 `files` 列表进入 npm tarball;如果文件缺失或 ELF 架构错误,`prepack` 门禁会拒绝打包,发布流水线则会按字节将打包二进制文件锁定到其来源 CI 构建。静态 musl 链接使同一个二进制文件同时适用于 glibc 和 musl 发行版,因此名称中没有 libc 后缀。 + +同级包:`node-addon-landlock-run-linux-x64`。 diff --git a/native/landlock-run/packages/linux-x64/README.i18n.yaml b/native/landlock-run/packages/linux-x64/README.i18n.yaml new file mode 100644 index 0000000000..af3f916fb4 --- /dev/null +++ b/native/landlock-run/packages/linux-x64/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 68b5dfc9b6f437a387c3792ee047a1f11630aca0 +README.zh.md: b1fa2e3f16c20c4d7e287c17ab0ba946e6cadbea diff --git a/native/landlock-run/packages/linux-x64/README.md b/native/landlock-run/packages/linux-x64/README.md index ce741eb34c..68b5dfc9b6 100644 --- a/native/landlock-run/packages/linux-x64/README.md +++ b/native/landlock-run/packages/linux-x64/README.md @@ -1,5 +1,7 @@ # node-addon-landlock-run-linux-x64 +English | [中文](README.zh.md) + Prebuilt `bin/landlock-run` Landlock launcher for linux-x64 — a static musl binary compiled natively (no cross toolchain) from the C source shipped in [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run). npm's `os`/`cpu` fields select this package at install time; the entry package resolves it to a file path — it ships no JavaScript and is never imported. The binary is git-ignored and rides the npm tarball via the `files` list; the `prepack` gate refuses to pack when it is missing or has the wrong ELF architecture, and the release pipeline byte-pins the packed binary against the CI build it came from. Static musl linking means one binary for glibc and musl distros alike — hence no libc suffix in the name. diff --git a/native/landlock-run/packages/linux-x64/README.zh.md b/native/landlock-run/packages/linux-x64/README.zh.md new file mode 100644 index 0000000000..b1fa2e3f16 --- /dev/null +++ b/native/landlock-run/packages/linux-x64/README.zh.md @@ -0,0 +1,9 @@ +# node-addon-landlock-run-linux-x64 + +[English](README.md) | 中文 + +面向 linux-x64 的预构建 `bin/landlock-run` Landlock 启动器:一个从 [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) 中随包发布的 C 源码原生编译而成的静态 musl 二进制文件(不使用交叉工具链)。npm 的 `os`/`cpu` 字段在安装时选择此包;入口包将其解析为文件路径。该包不包含 JavaScript,也绝不会被导入。 + +该二进制文件被 git 忽略,并通过 `files` 列表进入 npm tarball;如果文件缺失或 ELF 架构错误,`prepack` 门禁会拒绝打包,发布流水线则会按字节将打包二进制文件锁定到其来源 CI 构建。静态 musl 链接使同一个二进制文件同时适用于 glibc 和 musl 发行版,因此名称中没有 libc 后缀。 + +同级包:`node-addon-landlock-run-linux-arm64`。 diff --git a/package.json b/package.json index 897e81171e..3797b24efa 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "scripts": { "build": "tsc -b && tsdown", "build:web": "pnpm --filter @deepseek-ai/dsh-frontend run build", - "clean:build": "rm -rf .typecheck packages/*/*/lib vendor/*/lib *.tsbuildinfo", + "clean": "tsx scripts/clean.ts", "typecheck": "tsc -b", "lint": "eslint .", "lint:fix": "eslint . --fix", diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml new file mode 100644 index 0000000000..9b2c44af22 --- /dev/null +++ b/packages/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: d7427c3f9892f56185cc1175245f14a6ccea0d25 +README.zh.md: 6894aa7333f6ba4bc5723871fb77c18b5fb518a1 diff --git a/packages/README.md b/packages/README.md index cffc4a0554..d7427c3f98 100644 --- a/packages/README.md +++ b/packages/README.md @@ -1,5 +1,7 @@ # Packages +English | [中文](README.zh.md) + Packages use the `@deepseek-ai/dsh-*` scope. Each is a Cordis `Service` subclass or function plugin; contributions use `ctx.effect()`, `ctx.on()`, or `ctx.waterfall()`. Authoring rules: [package](AGENTS.md) and [root](../AGENTS.md#conventions). ## Hierarchy diff --git a/packages/README.zh.md b/packages/README.zh.md new file mode 100644 index 0000000000..6894aa7333 --- /dev/null +++ b/packages/README.zh.md @@ -0,0 +1,56 @@ +# 包 + +[English](README.md) | 中文 + +所有包都使用 `@deepseek-ai/dsh-*` scope。每个包都是 Cordis `Service` 子类或函数插件;所有贡献通过 `ctx.effect()`、`ctx.on()` 或 `ctx.waterfall()` 注册。编写规则见[包](AGENTS.md)与[根规则](../AGENTS.md#conventions)。 + +## 层级结构 + +包位于 `packages/<group>/<pkg>/`;组是容器,包名仍为 `@deepseek-ai/dsh-<pkg>`。**每个组 README 是规范的包/ctx 键映射。** + +| 组 | 职责 | 发布预期 | +|---|---|---| +| [`core/`](core/README.md) | 产品 API 主干:会话、提示词、工具、agent(智能体)服务与具体循环 | 产品:稳定表面 | +| [`goal/`](goal/README.md) | 持久化的同会话 goal 状态与生命周期 | 产品:稳定表面 | +| [`llm/`](llm/README.md) | LLM(大语言模型)能力系列:抽象服务 + 提供方适配器 | 产品:稳定表面 | +| [`bash/`](bash/README.md) | Bash 能力系列:执行器 seam、本地实现、面向模型的工具 | 产品:稳定表面 | +| [`pty/`](pty/README.md) | 持久 PTY 能力系列:按所有者隔离的会话、本地实现和面向模型的工具 | 产品:稳定表面 | +| [`code-runtime/`](code-runtime/README.md) | 代码执行能力系列:面向模型所写程序的运行时 seam + worker 线程后端 | 产品:稳定表面 | +| [`sandbox/`](sandbox/README.md) | 进程限制 seam;bwrap/Landlock/Seatbelt 后端 | 产品:稳定表面 | +| [`fs/`](fs/README.md) | 文件系统能力系列:seam、本地实现、面向模型的文件工具、bash 后端发现工具 | 产品:稳定表面 | +| [`lsp/`](lsp/README.md) | LSP 能力系列:seam、通用 stdio 提供方和 `lsp` 工具 | 产品:稳定表面 | +| [`skill/`](skill/README.md) | Skill(技能)能力系列:提供方注册表、本地提供方和面向模型的目录/加载器 | 产品:稳定表面 | +| [`compact/`](compact/README.md) | 压缩(compaction)能力系列:抽象 seam + 基础后端(工具延后) | 产品:稳定表面 | +| [`context/`](context/README.md) | 模型可见请求上下文,包括 workspace 指令和时间上下文 | 产品:稳定表面 | +| [`subagent/`](subagent/README.md) | Subagent 能力系列:提供方注册表 seam 和面向模型的委托工具 | 产品:稳定表面 | +| [`tasks/`](tasks/README.md) | 通用后台任务运行时和面向模型的 `task_*` 控制工具 | 产品:稳定表面 | +| [`workflow/`](workflow/README.md) | 工作流能力系列:脚本引擎 seam、worker 线程引擎、面向模型的 `workflow` 与新 agent `ralph` 工具 | 产品:稳定表面 | +| [`web/`](web/README.md) | Web 能力系列:seam、搜索/获取提供方实现和面向模型的 Web 工具 | 产品:稳定表面 | +| [`spill/`](spill/README.md) | 溢出能力系列:存储 seam、本地实现、工具结果溢出策略 | 产品:稳定表面 | +| [`todo/`](todo/README.md) | Todo/规划系列:面向模型的 `todo_write` 工具 | 产品:稳定表面 | +| [`plan/`](plan/README.md) | Plan 协作状态,提供直接进入命令与经评审的退出 | 产品:稳定表面 | +| [`timeout/`](timeout/README.md) | 工具调用超时策略:`tools/execute` 截止时间强制执行器 | 产品:稳定表面 | +| [`guard/`](guard/README.md) | 循环卫生守卫:建议性重复调用提醒 | 产品:稳定表面 | +| [`cordis/`](cordis/README.md) | 自指运行时工具集:检查实时运行时的插件与服务,挂载/卸载模型所写插件([设计](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | 产品:稳定表面 | +| [`hooks/`](hooks/README.md) | 钩子桥接 + 共享 Claude Code/Codex 协议格式库 | 产品:稳定表面 | +| [`session-persistence/`](session-persistence/README.md) | 持久化能力系列:seam + JSONL/SQLite 后端 | 产品:稳定表面 | +| [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定表面 | +| [`session-title/`](session-title/README.md) | 日志支撑的会话标题:回退服务、共享 LLM 策略和选用提供方 | 产品:稳定表面 | +| [`storage/`](storage/README.md) | 非会话存储中枢 + 后端 + 领域形式 | 产品:稳定表面 | +| [`workspace/`](workspace/README.md) | Workspace 实体 | 产品:稳定表面 | +| [`sdk/`](sdk/README.md) | 项目 SDK 工具 | 产品:稳定表面 | +| [`acp/`](acp/README.md) | 仅面向自动化的 Agent Client Protocol 服务器 | 产品:稳定表面 | +| [`ui/`](ui/README.md) | 人类/客户端集成:TUI 与 JSON-RPC、批准/交互 seam、用户问答工具 | 产品:稳定表面 | +| [`examples/`](examples/README.md) | 演示组合包(agent-spine + TUI/CLI/ACP/JSON-RPC bin),由叶节点加载 | 支持:示例基础设施 | +| [`support/`](support/README.md) | 支持基础设施(testkit、不变式、回放、Loader 冒烟测试) | 支持:兼容性预期较低 | +| [`util/`](util/README.md) | 组间共享的低层零依赖工具(`Branded<B>`、Harness home/路径辅助函数、超时、保留策略) | 支持:小型、稳定、无 harness 依赖 | + +组用于区分产品 API 与支持基础设施。新包加入现有组;新组则更新其 README 和此表。 + +## 依赖 + +依赖图由工具生成:[docs/module-graph.md](../docs/module-graph.md)(`pnpm run gen-module-graph`,CI 中有新鲜度门禁)。 + +**扩展插件依赖接口,绝不依赖具体循环。** `dsh-agent-loop` 可替换;UI、钩子和工具插件使用 `dsh-agent`。包括 `dsh-agent-spine-demo` 在内的组合包可以依赖主干插件。能力拆分为接口/实现/消费方包;详见[能力 seam](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)。 + +包 README 覆盖用途、API、扩展点和[模型体验](../docs/cookbook/adding-a-package.md#4-write-the-package-readme);列入模型无关[省略允许清单](../scripts/verify-package-readme-model-experience.ts)的包除外。它们还要包含 `## Known Limitations and Deferred Work`,或使用其[允许清单](../scripts/verify-package-readme-limitations.ts)。 diff --git a/packages/acp/README.i18n.yaml b/packages/acp/README.i18n.yaml new file mode 100644 index 0000000000..cf2cf1791a --- /dev/null +++ b/packages/acp/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 326615210e5cfc39004fc5ab7462623089ac4126 +README.zh.md: 9999ecdd019501c3f501a6c69fab5e0ccfaf555c diff --git a/packages/acp/README.md b/packages/acp/README.md index 480b15726d..326615210e 100644 --- a/packages/acp/README.md +++ b/packages/acp/README.md @@ -1,5 +1,7 @@ # acp/ — Agent Client Protocol automation +English | [中文](README.zh.md) + The ACP group exposes harness agents to programmatic clients. It is an interoperability transport, not a presentation or human-interaction layer. | Package | Role | diff --git a/packages/acp/README.zh.md b/packages/acp/README.zh.md new file mode 100644 index 0000000000..9999ecdd01 --- /dev/null +++ b/packages/acp/README.zh.md @@ -0,0 +1,11 @@ +# acp/:Agent Client Protocol 自动化 + +[English](README.md) | 中文 + +ACP(Agent Client Protocol)组将 harness 中的 agent(智能体)公开给程序化客户端。它是互操作传输层,而非展示层或人机交互层。 + +| 包 | 职责 | +|---|---| +| [`acp/`](acp/README.md) | 仅面向自动化的 ACP 服务器:新文本会话、已提交的 assistant 输出、机器权限策略、取消和由连接拥有的清理。 | + +与之匹配的进程外 subagent 客户端仍位于 [`subagent/subagent-acp`](../subagent/subagent-acp/README.md),因为它实现 subagent 提供方接口;任意 ACP 客户端都可以驱动同一服务器契约。 diff --git a/packages/acp/acp/README.i18n.yaml b/packages/acp/acp/README.i18n.yaml new file mode 100644 index 0000000000..5583a2b9a5 --- /dev/null +++ b/packages/acp/acp/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 1b188b994d17ce56e8d5df019ddef755338fcc88 +README.zh.md: f8abe9e45a5efffa436513f7d4a931c69c624b84 diff --git a/packages/acp/acp/README.md b/packages/acp/acp/README.md index 20b1ecbefe..1b188b994d 100644 --- a/packages/acp/acp/README.md +++ b/packages/acp/acp/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-acp +English | [中文](README.zh.md) + Automation-only [Agent Client Protocol](https://agentclientprotocol.com) server over JSON-RPC stdio. Programmatic clients create fresh harness agents, send text prompts, collect committed assistant text, resolve one-shot permission requests by policy, and cancel work. The primary in-repository client is [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md). This package is a transport adapter, not a UI integration or a capability seam. It does not expose editor navigation, transcript replay, commands, modes, configuration pickers, elicitation, reasoning, plans, titles, or tool presentation. Interactive rendering and human questions belong to the web and TUI modules. diff --git a/packages/acp/acp/README.zh.md b/packages/acp/acp/README.zh.md new file mode 100644 index 0000000000..f8abe9e45a --- /dev/null +++ b/packages/acp/acp/README.zh.md @@ -0,0 +1,79 @@ +# @deepseek-ai/dsh-acp + +[English](README.md) | 中文 + +通过 JSON-RPC stdio 提供的仅面向自动化的 [Agent Client Protocol](https://agentclientprotocol.com) 服务器。程序化客户端可以创建新 harness agent(智能体)、发送文本提示词、收集已提交的 assistant 文本、通过策略解决一次性权限请求并取消工作。仓库中的主要客户端是 [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md)。 + +此包(package)是传输适配器,而非 UI 集成或能力 seam。它不公开编辑器导航、transcript(文本记录)回放、命令、mode、配置选择器、信息征集、推理、计划、标题或工具展示。交互渲染与人类问题属于 Web 和 TUI 模块。 + +## 插件 + +`apply(ctx, config)` 在 stdin/stdout 上打开 `AgentSideConnection` 并驱动 `ctx.agents`。Stdout 专用于协议帧。 + +| 配置 | 默认值 | 含义 | +|---|---|---| +| `provider` | 无 | 每个已创建 agent 的初始提供方路由。 | +| `model` | 无 | 每个已创建 agent 的初始模型。 | + +两个字段都是可选的,以便由另一个 agent/request 监听器提供目标。可运行 ACP 组合同时要求两者。 + +## 协议契约 + +| 方法 | 行为 | +|---|---| +| `initialize` | 协商受支持的版本,并仅公布基线提示词(无图像、音频或嵌入上下文能力)。不公布会话、编辑器、终端、文件系统或 MCP 能力。 | +| `authenticate` | 空操作,因为服务器不公布身份验证方法。 | +| `session/new` | 使用绝对主 `cwd` 创建新 agent;接受空的 `additionalDirectories` 和 `mcpServers`,拒绝非空值。 | +| `session/prompt` | 连接文本块,将基线资源链接渲染为带方括号的文本引用,拒绝空输入或超出基线的输入,每个会话只允许一个正在处理的请求,并从该请求拥有的持久 `turn/end` 结算。 | +| `session/cancel` | 仅取消被定址的 agent,并将其待处理提示词结算为 `cancelled`;未知 id 为空操作。 | +| `session/update` | 为每个非空文本块发出一个 `agent_message_chunk`;这些文本块来自已提交的 `assistant/message`。省略原始增量和非消息事件。 | +| `session/request_permission` | 为携带工具调用 id 的桥接层所有批准请求提供一次性允许/拒绝选项。客户端可以自动回答。 | + +一个连接可以拥有多个会话。桥接层使用带品牌的 session id 为记录建键,并在路由事件或权限请求前检查精确的 agent 标识。每个会话都有独立的提示词槽位、workspace、取消路径和 disposer。 + +已提交消息输出有意以逐 token 延迟换取干净的自动化结果。未提交的提供方分片和重试尝试无法泄漏部分文本;推理与工具活动仍保留在会话日志中,以便其他界面观测。 + +## 生命周期 + +客户端断开与 Cordis 释放共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,结算待处理提示词,然后并行释放所有已拥有的 agent handle,并等待它们的循环/会话清理完成。因此,仅 ACP 的插件重载不会遗留 agent。 + +## 运行 + +`pnpm --dir /path/to/deepseek-harness run demo:acp` 启动仓库的自动化服务器组合。父 harness 可以通过 [`@deepseek-ai/dsh-subagent-acp`](../../subagent/subagent-acp/README.md) spawn 它;其他 ACP 客户端只需上述核心方法。 + +## 模型体验 + +### 提示词文本 + +#### 模型所见内容 + +`session/prompt` 文本块会原样连接为一条用户消息;基线资源链接会在该消息中表示为带方括号的 `[resource_link name=… uri=…]` 引用,模型可以使用自身工具打开它。协议元数据、客户端能力、权限选择和 session id 绝不进入模型请求。 + +#### Token 影响 + +提示词 token 取决于数据,并保留在该会话的历史中直到压缩。并发 ACP 会话保留独立上下文。 + +#### KV Cache 影响 + +仅追加;新用户消息位于可复用请求前缀之后,不会使先前缓存条目失效。 + +### 权限决策 + +#### 模型所见内容 + +没有直接内容。拥有该决策的工具通过常规工具结果路径记录允许、拒绝、取消或不可用结果。 + +#### Token 影响 + +只有拥有该决策的工具结果会贡献 token。 + +#### KV Cache 影响 + +通过所属工具结果仅追加。 + +## 已知限制与延后工作 + +- **仅新会话**:不支持加载、列出、恢复、删除和 fork。 +- **仅基线提示词和一个 workspace**:图像、音频、嵌入资源、非空附加目录和 MCP 服务器都会被拒绝;资源链接会被展平为文本引用,而不是已获取内容。 +- **仅已提交答案**:实时进度、推理、工具活动、计划、标题和用量不上线。 +- **连接拥有的生命期**:一个连接会释放其所有会话;尚未实现每会话关闭。 diff --git a/packages/bash/README.i18n.yaml b/packages/bash/README.i18n.yaml new file mode 100644 index 0000000000..6f9db27161 --- /dev/null +++ b/packages/bash/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 08b36270800cdd79c82d6781bbfb2e12e2dc2060 +README.zh.md: a98506a6cdf41e5b298b40e1b8e1faf0c4c917d2 diff --git a/packages/bash/README.md b/packages/bash/README.md index 2e2bb5692a..08b3627080 100644 --- a/packages/bash/README.md +++ b/packages/bash/README.md @@ -1,5 +1,7 @@ # bash/ — bash capability family +English | [中文](README.zh.md) + The canonical three-package capability seam (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract executor interface, concrete implementations, and the model-facing tool that consumes it. All **product** packages. | Package | Role | ctx key | diff --git a/packages/bash/README.zh.md b/packages/bash/README.zh.md new file mode 100644 index 0000000000..a98506a6cd --- /dev/null +++ b/packages/bash/README.zh.md @@ -0,0 +1,14 @@ +# bash/:bash 能力家族 + +[English](README.md) | 中文 + +规范的三包能力 seam(见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):抽象执行器接口、具体实现,以及消费该接口的面向模型工具。这些全是**产品** 包。 + +| 包 | 职责 | ctx key | +|---|---|---| +| `bash/` | 抽象 bash 执行器 seam(接口 + 词汇;沙箱结果事实携带 [`sandbox/`](../sandbox/README.md) seam 的模式/强制执行词汇) | `ctx.bash` | +| `bash-local/` | 本地子进程 `BashExecutor` 实现 | (注册 `ctx.bash`) | +| `bash-sandbox/` | 消费沙箱的 `BashExecutor`(通过 `ctx.sandbox` 包装每个命令 argv,标记拒绝/强制执行事实;扩展 `bash-local` 的机制) | (注册 `ctx.bash`) | +| `tool-bash/` | 面向模型的 `bash` schema;后台进程注册到通用 [`tasks/`](../tasks/README.md) 运行时 | (注册到 `ctx.tools`) | + +接口位于 `bash/bash/`。以 `bash-sandbox` 替换 `bash-local`,同时不改动接口或工具,正是这种拆分存在的意义:叶级 `cordis.yml` 选择一个执行器配置项;受限实现还需选择一个 `ctx.sandbox` 提供方配置项(见 [acp-agent 示例的默认组合](../../examples/acp-agent/))。 diff --git a/packages/bash/bash-local/README.i18n.yaml b/packages/bash/bash-local/README.i18n.yaml new file mode 100644 index 0000000000..c7e9587289 --- /dev/null +++ b/packages/bash/bash-local/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 1668f33e8acf6d749d4d3753478c12d48a19ac3c +README.zh.md: 0e0a4ad41b532e39f6f2470aa981a08b6d6230c1 diff --git a/packages/bash/bash-local/README.md b/packages/bash/bash-local/README.md index 5d99161fc0..1668f33e8a 100644 --- a/packages/bash/bash-local/README.md +++ b/packages/bash/bash-local/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-bash-local +English | [中文](README.zh.md) + Local-subprocess implementation of the `@deepseek-ai/dsh-bash` executor seam: `LocalBashExecutor` spawns `bash -c <command>` per call in its own process group, collects bounded output with size-limited full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group. The package root exports the default and named `LocalBashExecutor` plugin plus its `Config`; subprocess plumbing stays internal to the implementation package. diff --git a/packages/bash/bash-local/README.zh.md b/packages/bash/bash-local/README.zh.md new file mode 100644 index 0000000000..0e0a4ad41b --- /dev/null +++ b/packages/bash/bash-local/README.zh.md @@ -0,0 +1,49 @@ +# @deepseek-ai/dsh-bash-local + +[English](README.md) | 中文 + +`@deepseek-ai/dsh-bash` 执行器 seam 的本地子进程实现:`LocalBashExecutor` 每次调用都会在独立进程组中 spawn `bash -c <command>`,收集有界输出,并用限制大小的完整流 spill 文件保留超量内容,随后针对整个进程组从 SIGTERM 逐步升级为 SIGKILL。 + +包根目录导出默认与具名的 `LocalBashExecutor` 插件及其 `Config`;子进程管道细节保留在该实现包内部。 + +## 配置 + +```yaml +- id: bash + name: '@deepseek-ai/dsh-bash-local' + config: + cwd: /path/to/workspace # default: process.cwd() + timeoutMs: 120000 # default foreground timeout + maxTimeoutMs: 600000 # cap for per-call overrides + maxOutputBytes: 64000 # per-stream in-memory cap; overflow spills to disk + maxSpillBytes: 67108864 # per-stream full-output spill cap + graceMs: 3000 # kill escalation and post-exit pipe-drain grace +``` + +## 行为(以及设计来源) + +设计时调研了 Claude Code、OpenCode、Codex 和 pi 的 bash 工具,主要取舍如下: + +- **每次调用都 spawn,不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`(行为确定,不读取 rc 文件)。调研的四种工具均会每次调用单独 spawn。`XXX(stateful-shell)` 位于 `src/run.ts`,记录了两种已验证的有状态设计(Claude Code 仅持久化 cwd;Codex 使用 PTY exec 会话),供真实工作流程需要时采用。 +- **使用逐步升级终止整个进程组**:子进程使用 `detached` spawn(拥有独立进程组);终止时先向该组发送 SIGTERM,经过 `graceMs` 宽限期后再发送 SIGKILL(默认 3 秒,沿用 OpenCode 的升级策略;管道与子 shell 会随父进程一起结束)。主 shell 退出后,继承的 stdout/stderr 管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地阻止命令结束。系统会容忍 ESRCH;脱离该组重新挂载的 daemon 仍可能存活,这与调研工具的局限相同。 +- **保留尾部的截断 + 有界 spill 文件**:输出超过 `maxOutputBytes` 后,内存中保留尾部(错误/结果通常聚集在末尾,沿用 pi/OpenCode 的理由),同时将完整流追加到临时文件,并在可用时报告该路径。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台任务仍使用 `maxOutputBytes`。某个流大于 `maxSpillBytes` 时,会丢弃已不完整的 spill,仅返回带截断标记的尾部。如果最终关闭 spill 时报告延迟写回失败,执行器同样不会公布路径,以免声称存在不完整的文件。 +- **适合模型的环境变量 + 凭证清理**:以 `process.env` 为基础,移除形似凭证的变量(`*KEY*`/`*SECRET*`/`*TOKEN*`)和所有环境中的 `DSH_*` 名称,再设置 `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat`(Codex 硬编码的集合),防止分页器与 ANSI 颜色破坏结果。spec 的普通 `env` 在清理后合并,但会拒绝 `DSH_*`;受管 `dshEnv` 会拒绝普通名称并最后合并,防止遗留嵌套 harness 身份。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 +- **后台进程**:`start()` 会立即返回实时 `BashProcess` 句柄,不应用超时(Claude Code 在转为后台时会解除超时);句柄的 `readOutput()` 使用全流字节偏移量进行增量读取;dispose 会终止每个运行中的进程并等待其退出。所有具有任务形态的事项(id、所有权、轮询、通知)都属于通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md),工具层会在其中注册该句柄;本执行器不会接触会话或注册表。 + +## 模型体验 + +通过 `dsh-tool-bash` 间接影响;该工具会渲染此执行器有界的 stdout/stderr 尾部、后台进程增量、spill 文件路径与基础设施失败。 + +#### KV Cache 影响 + +不会直接失效;请求前缀变更由具名消费方负责。 + +## 已知限制与暂缓事项 + +- **自身不受约束**:此执行器始终以 harness 进程的权限运行命令;需要限制的部署可以组合 [`dsh-bash-sandbox`](../bash-sandbox/README.md),每次调用的 allow/deny/ask 策略则属于 `tools/pre-execute`。 +- **没有持久 shell 或 PTY**:每次调用都启动新的非登录 `bash -c`;仅持久化 cwd 与交互式终端会话均继续暂缓,直到真实工作流程需要它们。 +- **仅支持 POSIX**:`bash` 二进制、独立进程组、进程组终止以及 SIGTERM→SIGKILL 升级都已硬编码;不支持 Windows。 +- **凭证清理依赖名称启发式规则**:只匹配 `*KEY*`/`*SECRET*`/`*TOKEN*`;名称不同的 secret(例如 `*PASSWORD*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。 +- **不会删除已完成的 spill 文件**:有界的完整输出恢复文件(以及每个进程的私有 spill 目录)会在 OS tmpdir 下累积,直到外部机制进行清理;超大的不完整 spill 会被丢弃并立即尝试删除,但清理失败可能留下一个有界文件。 + +原始进程处理位于 `src/run.ts`;`src/index.ts` 负责服务接线。 diff --git a/packages/bash/bash-sandbox/README.i18n.yaml b/packages/bash/bash-sandbox/README.i18n.yaml new file mode 100644 index 0000000000..86168916fe --- /dev/null +++ b/packages/bash/bash-sandbox/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: ca77a9c626784b29145712535d69de4afbd3a697 +README.zh.md: c1a65ead539ef3930d70d27f2b176a5346daded3 diff --git a/packages/bash/bash-sandbox/README.md b/packages/bash/bash-sandbox/README.md index 93e0c9e6f2..ca77a9c626 100644 --- a/packages/bash/bash-sandbox/README.md +++ b/packages/bash/bash-sandbox/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-bash-sandbox +English | [中文](README.zh.md) + Sandbox-consuming implementation of the [`@deepseek-ai/dsh-bash`](../bash/) executor seam. Load it **instead of** `@deepseek-ai/dsh-bash-local`, together with a [`ctx.sandbox`](../../sandbox/sandbox/) provider (e.g. [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/)) and a [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) (which owns the default mode + workspace root, shared with the sandboxed filesystem) — no alternate tool plugin is needed; `dsh-tool-bash` detects the executor's `sandboxMode` capability and adds the escalation fields. The package root exports the default and named `SandboxBashExecutor` plugin plus its `Config`; quoting and result-classification helpers stay internal. diff --git a/packages/bash/bash-sandbox/README.zh.md b/packages/bash/bash-sandbox/README.zh.md new file mode 100644 index 0000000000..c1a65ead53 --- /dev/null +++ b/packages/bash/bash-sandbox/README.zh.md @@ -0,0 +1,90 @@ +# @deepseek-ai/dsh-bash-sandbox + +[English](README.md) | 中文 + +消费 [`@deepseek-ai/dsh-bash`](../bash/) 执行器 seam 的沙箱实现。加载它时,应**用它替代** `@deepseek-ai/dsh-bash-local`,并同时加载 [`ctx.sandbox`](../../sandbox/sandbox/) 提供方(例如 [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/))及 [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/);后者拥有默认模式 + 工作区根目录,并与受沙箱约束的文件系统共享这些设置。无需使用替代工具插件;`dsh-tool-bash` 会检测执行器的 `sandboxMode` 能力并添加升权字段。 + +包根目录导出默认与具名的 `SandboxBashExecutor` 插件及其 `Config`;引号处理与结果分类 helper 保留在内部。 + +每条命令的限制方式都是:把本执行器即将 spawn 的精确 `['bash', '-c', command]` argv 交给提供方,再 spawn 其返回的(已包装)argv。由哪种平台 runner 执行限制,以及是否有 runner 可用(必须快速失败并返回结构化 `SANDBOX_UNAVAILABLE` 错误,绝不能静默无约束运行),属于提供方职责;本包只拥有 bash 侧。 + +| 模式 | 文件影响 | +|---|---| +| `read-only`(默认) | 任何位置都不可写(在 `/dev` 中只有 `/dev/null` 节点可写,因此 `>/dev/null` 仍可正常工作) | +| `workspace-write` | 只能写入 `workspaceRoot` + `/tmp`(在 bwrap 下为临时目录,在 Landlock 下为宿主 `/tmp`,在 Seatbelt 下为 `/private/tmp` 加每用户临时目录) | +| `danger-full-access` | 不作限制;绝不咨询提供方。前台结果携带 `sandbox: { mode, denied: false }`;后台进程句柄不携带沙箱事实。 | + +语义: + +- **拒绝是结果事实。** 如果一次失败运行的 stderr 包含所选后端自身的拒绝方言,即提供方在每次包装时加上的特征(bwrap 下的 EROFS 文本、Landlock 下的 EACCES、Seatbelt 下的 EPERM),则结果报告 `BashRunResult.sandbox.denied: true`(从已收集的 stderr 尾部进行保守分类)。每次受限制运行还会携带执行时模式(`result.sandbox.mode`)与提供方强制执行完整性(`result.sandbox.enforcement`:`full`,或在较旧 Landlock ABI 上为 `partial`)。 +- **Runner 失败是沙箱失败,绝不是命令失败。** 前台执行会抛出 `SANDBOX_UNAVAILABLE`;已结算的后台进程会标记 `process.sandbox.runnerFailed`,bash 产生方通过通用 `task_output` 渲染它。spawn 失败也会经过结算,因此受限制的后台句柄会保留自身的模式/强制执行事实,并释放每进程计数。 +- **部署回退,每次调用策略。** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) 为每次工具调用解析完整的 `SandboxExecutionPolicy`:调用会话提供自身的模式覆盖与不可变 cwd 根目录,部署配置则为无 agent 调用提供回退。已批准的升权只更改该策略的模式,会话根目录仍然附着其上。`resolve()` 把策略带入 spec,因此来自不同项目的重叠命令会在各自的根目录与模式下运行、分类和报告。能力事实 `ctx.bash.sandboxMode` 报告已配置的默认值,因此工具层只在装载该执行器时才公布升权。模型只能通过结果事实了解沙箱:静态 bash 工具描述会解释拒绝标记,系统提示词中不会声明当前模式。 +- **只限制文件影响。** 设计上不限制网络与进程可见性:模式词汇不会声称覆盖后端未强制执行的范围。 +- 进程机制(spawn、进程组终止、输出收集/spill、后台句柄、凭证清理)继承自 [`dsh-bash-local`](../bash-local/);runner 选择位于 [`dsh-sandbox-local`](../../sandbox/sandbox-local/)。 + +seam 上仅拒绝:拒绝是一项已报告事实,本执行器绝不自行协商权限。批准问题位于工具层(`dsh-tool-bash`),由它驱动本包遵守的覆盖。 + +```yaml +- id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' +- id: sandbox-policy + name: '@deepseek-ai/dsh-sandbox-policy' + config: + mode: read-only + workspaceRoot: !!js process.cwd() # fallback for calls without a session cwd +- id: bash + name: '@deepseek-ai/dsh-bash-sandbox' +``` + +无密钥消费方集成证明是 `tests/bwrap.e2e.ts`、`tests/landlock.e2e.ts` 和 `tests/seatbelt.e2e.ts`(通过 `ctx.bash` 驱动真实提供方 + 真实 runner,在真实世界验证,并在相应 runner 缺失时各自自行跳过)。agent-spine e2e 还会在一个 Cordis 上下文中驱动两个并发会话,并证明每个真实 bash 工具调用只能写入自身项目。可运行 demo 见 [acp-agent 示例的默认组合](../../../examples/acp-agent/)。 + +## 模型体验 + +### 间接的 Bash 工具 schema + +#### 模型看到的内容 + +基线是生成的 [`dsh-tool-bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash)。通过公布一个执行限制的 `sandboxMode`,此后端会为 `bash` 增加 `sandbox_permissions`,其 enum 为 `workspace-write` | `danger-full-access`,并增加 `justification`。后端不添加提示词文本,会话的有效模式仍不会声明。 + +#### Token 影响 + +在 `bash` 可见的请求上增加少量固定 schema;模式切换不增加上下文 token。 + +#### KV Cache 影响 + +执行器持续公布相同沙箱能力时,前缀保持稳定。更改这些能力会改变 `bash` schema,可能使从该定义起的复用失效;每会话模式切换不会导致失效。 + +### 间接的 Bash 工具结果 + +#### 模型看到的内容 + +在普通有界输出之后,被拒绝的调用会精确追加 `[sandbox: file access denied under <mode> mode]`。当升权可用时,接下来精确追加 `[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]`。已结算的后台 runner 失败则追加 `[sandbox: the sandbox runner itself failed under <mode> mode — the command did not run; this is a sandbox problem, not a command failure]`。 + +#### Token 影响 + +除普通输出外,正常允许的运行不会增加 token。拒绝或失败会增加上述有条件标记,并保留到压缩。 + +#### KV Cache 影响 + +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 配置项失效。 + +### 间接的 Bash 工具错误 + +#### 模型看到的内容 + +如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误;它由 `dsh-sandbox` 持有](../../sandbox/sandbox/README.md#confinement-error-indirectly)。如果 runner 在执行时失败,此后端会提供第一行 stderr 作为详细信息。 + +#### Token 影响 + +该次调用可见的是有条件错误文本,并保留在历史记录中直到压缩。 + +#### KV Cache 影响 + +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 配置项失效。 + +## 已知限制与暂缓事项 + +- **限制只覆盖文件影响**:网络访问与进程可见性不变,因此这些模式不是通用安全沙箱。 +- **拒绝从失败命令的 stderr 推断**:后端特征使该推断可跨平台使用,但匹配的应用错误可能被分类为拒绝,也可能遗漏未出现在保留尾部中的拒绝。 +- **后台 runner 失败没有即时错误通道**:它记录在已结算进程上,并在调用方使用 `task_output` 读取通用任务时呈现。 +- **`danger-full-access` 有意绕过 `ctx.sandbox`**:它是显式无约束模式,不是更宽的沙箱 profile。 diff --git a/packages/bash/bash/README.i18n.yaml b/packages/bash/bash/README.i18n.yaml new file mode 100644 index 0000000000..f32efb60a0 --- /dev/null +++ b/packages/bash/bash/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: b4ee66a1fa2696254a1f2f411b7db5d3190f8370 +README.zh.md: 151d4bd7ab257234584b9008c96e6356d7e39351 diff --git a/packages/bash/bash/README.md b/packages/bash/bash/README.md index 73fbb4fb3e..b4ee66a1fa 100644 --- a/packages/bash/bash/README.md +++ b/packages/bash/bash/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-bash +English | [中文](README.zh.md) + The **bash executor seam**: an abstract `BashExecutor` service (`ctx.bash`) defining WHAT a bash backend does — run foreground commands and start background processes — without saying HOW. Task ids, ownership, collection, cancellation, and notices belong to the generic `ctx.tasks` runtime. This package is the interface quarter of the bash capability, split so each concern can evolve (and be swapped) independently: diff --git a/packages/bash/bash/README.zh.md b/packages/bash/bash/README.zh.md new file mode 100644 index 0000000000..151d4bd7ab --- /dev/null +++ b/packages/bash/bash/README.zh.md @@ -0,0 +1,49 @@ +# @deepseek-ai/dsh-bash + +[English](README.md) | 中文 + +**bash 执行器 seam**:抽象 `BashExecutor` 服务(`ctx.bash`)定义 bash 后端做什么,即运行前台命令与启动后台进程,但不规定如何实现。task id、所有权、收集、取消与通知属于通用 `ctx.tasks` 运行时。 + +本包是 bash 能力中负责接口的四分之一,各项职责因此可以独立演进(和替换): + +| 包 | 职责 | +|---|---| +| `@deepseek-ai/dsh-bash`(本包) | 接口:抽象服务 + 词汇类型 | +| `@deepseek-ai/dsh-bash-local` | 实现:本地子进程 | +| `@deepseek-ai/dsh-bash-sandbox` | 实现:沿用 `dsh-bash-local` 的机制,但通过 [`ctx.sandbox`](../../sandbox/sandbox/) 限制每次 spawn,并将拒绝报告为结果事实 | +| `@deepseek-ai/dsh-tool-bash` | 基于 `ctx.bash`、面向模型的工具 schema | + +该拆分与 LLM seam(`LlmService`/`LlmAdapter`)及 agent 工具调研结果一致:pi 将执行隐藏在 `BashOperations` 接口之后(本地 shell/SSH/VM 后端),Codex 则隐藏在 exec-server 协议之后。`dsh-bash-sandbox` 正是这种替换的实际应用:沙箱执行器位于同一接口之后;消费方检测其 `sandboxMode` 能力并添加升权字段,无需导入实现。容器化或远程执行器也可以同样接入。 + +## 服务 API(`ctx.bash`) + +| 成员 | 语义 | +|---|---| +| `run(spec)` | 前台执行。命令完成时 resolve。**只会因基础设施失败而 reject**(工作目录不可用、shell 缺失、信号已在调用前中止);非零退出、超时终止和中止终止都会 resolve 为描述性 `BashRunResult`。 | +| `start(spec)` | 后台执行。立即返回不含任务语义的 `BashProcess` 句柄;**不应用超时**。调用方可以将其适配到 `ctx.tasks`。 | +| `sandboxMode` | 工具层的能力事实:沙箱执行器用于限制执行的默认模式(基类中为 `undefined`,即「此执行器不使用沙箱」)。`dsh-tool-bash` 会在注册时读取它,仅当组合确实支持升权字段时才公布这些字段。 | +| `BashProcess.readOutput()` | **增量** 读取输出:连续读取绝不会重复交付。因缓冲区边界丢失数据的读取会标记 `lossy`,并指向完整流 spill 文件。 | +| `BashProcess.kill()` | 终止进程组。如果进程已结束,返回 `false`。 | + +实现会继承 `BashExecutor` 并实现抽象方法。dispose 必须终止每个运行中的进程并等待其退出,详见 HMR 安全测试。 + +## 词汇 + +`BashExecRequest`(command、workdir?、timeoutMs?、stdoutMaxBytes?、signal?、stdin?、env?、dshEnv?、sandboxPolicy?)在执行前解析为 `BashExecSpec`(command、workdir、timeoutMs、stdoutMaxBytes、signal?、stdin?、env?、dshEnv?、sandboxPolicy)。`stdoutMaxBytes` 是受信任前台运行的捕获预算,用于必须解析完整有界 stdout 的消费方;面向模型的 bash 工具不公开该字段。`sandboxPolicy` 在请求上可选,在已解析 spec 上必填但可为 null:它携带完整的每次调用模式与工作区根目录。沙箱工具路径通过 `ctx.sandboxPolicy` 从调用会话解析它;沙箱执行器的直接调用方回退到部署策略,非沙箱执行器则携带该字段但不作限制。 + +每会话沙箱模式覆盖词汇(`'sandbox/mode'` 事件、`effectiveSandboxMode(events)` fold 以及 `setSandboxMode(session, mode)` 写入路径)不位于此处。它是所有强制执行家族共享的策略状态,属于 [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/)。`run()` 返回 `BashRunResult`;`start()` 返回 `BashProcess`,其增量读取与终止方法由 `dsh-tool-bash` 适配为通用任务注册。沙箱执行器会在前台结果与已结算进程句柄上标记 `BashSandboxInfo`。详见 `src/types.ts` 与 [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md)。 + +`stdin` 与普通 `env` 由同进程插件(hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay;导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的单一真源。模型 bash 使用 `ctx.bashEnv` 收集的当前快照。实现会移除继承的受管 key,拒绝普通 `env` 中的这些名称,再合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态。面向模型的工具不公开任何一个字段。这三者在已解析 spec 上仍然可选;缺失表示没有输入/overlay。详见 [bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 + +## 模型体验 + +通过 `dsh-tool-bash` 间接影响;该工具会将执行器输出与沙箱事实转为指引和保留的工具结果 token。 + +#### KV Cache 影响 + +不会直接失效;请求前缀变更由具名消费方负责。 + +## 已知限制与暂缓事项 + +- **没有交互式输入词汇**:`stdin` 只会在 spawn 时写入一次并关闭;seam 不提供向运行中任务继续输入的通道,也没有 PTY 会话概念。 +- **前台超时始终由执行器拥有**:seam 上的调用方拥有 deadline 模式已由 [工具调用超时策略 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md) 明确暂缓。 diff --git a/packages/bash/tool-bash/README.i18n.yaml b/packages/bash/tool-bash/README.i18n.yaml new file mode 100644 index 0000000000..a529b56b56 --- /dev/null +++ b/packages/bash/tool-bash/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 965ae25a5e29a4f767adfcb73e4a77f1060e4b46 +README.zh.md: 60be5c5ca5624719f5ca651a78b6ba56f3f3df06 diff --git a/packages/bash/tool-bash/README.md b/packages/bash/tool-bash/README.md index e58145ee67..965ae25a5e 100644 --- a/packages/bash/tool-bash/README.md +++ b/packages/bash/tool-bash/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-tool-bash +English | [中文](README.zh.md) + The model-facing `bash` tool registered over the `ctx.bash` executor seam. Foreground execution stays behind that seam; a background process handle is registered with the generic `ctx.tasks` runtime and controlled through `task_output`, `task_list`, and `task_kill` from `@deepseek-ai/dsh-tool-tasks`. Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`); the plugin stays pending until `ctx.bash` exists (`inject: ['tools', 'bash', 'systemPrompt']`). diff --git a/packages/bash/tool-bash/README.zh.md b/packages/bash/tool-bash/README.zh.md new file mode 100644 index 0000000000..60be5c5ca5 --- /dev/null +++ b/packages/bash/tool-bash/README.zh.md @@ -0,0 +1,158 @@ +# @deepseek-ai/dsh-tool-bash + +[English](README.md) | 中文 + +模型侧 `bash` 工具,注册在 `ctx.bash` 执行器 seam 上。前台执行始终位于该 seam 之后;后台进程句柄会注册到通用 `ctx.tasks` 运行时,并通过 `task_output`、`task_list` 和 `task_kill` 控制;这些工具由 `@deepseek-ai/dsh-tool-tasks` 提供。 + +需要加载执行器实现(例如 `@deepseek-ai/dsh-bash-local`);在 `ctx.bash` 可用之前,插件会保持等待状态(`inject: ['tools', 'bash', 'systemPrompt']`)。 + +包(package)根只公开 Cordis 插件契约(`name`、`inject`、`Config`、`apply`);结果渲染和后台进程适配仍是实现细节,由同包测试覆盖。 + +插件还会提供 `tool:bash` 提示词段落(顺序 105):检查每个结果中的 `[exit code: N]` 标记,发现失败时先调查原因再继续。 + +## 工具 + +### `bash` + +| 参数 | 类型 | 说明 | +|---|---|---| +| `command` | string(必填) | 通过 `bash -c` 运行。调用之间不保留状态;请使用 `workdir`,不要使用 `cd`。 | +| `description` | string(必填) | 用一行主动语态概述命令(5~10 个词),仅用于 UI/日志显示,不影响执行。 | +| `timeoutMs` | number | 以毫秒为单位覆盖超时时间。执行器会应用其配置的默认值和上限。 | +| `workdir` | string | 本次调用的工作目录。默认为调用方 agent(智能体)会话 cwd 的文件系统标识(`session.header.cwd`),使每个会话都在自己的工作区中运行;相对 `workdir` 也以同一标识为基准解析。 | +| `run_in_background` | boolean | 立即返回 task id;不应用超时。 | +| `sandbox_permissions` | string enum | 仅当已挂载的执行器启用沙箱时才会公开(`ctx.bash.sandboxMode` 报告一个具有限制作用的默认值):被拒命令所需的更宽模式,取自封闭的目标词汇 `workspace-write`/`danger-full-access`(绝不能缩减为执行器默认值;有效模式按会话确定,执行时会基于它检查是否严格拓宽,未拓宽的请求直接失败,不会向任何人发起提示)。 | +| `justification` | string | 必须与 `sandbox_permissions` 一同提供(缺少任一项都会产生验证错误):用一句话向用户解释此命令为何需要这项更宽权限。 | + +执行前,`command`、`workdir` 和 `timeoutMs` 会通过 `ctx.bash.resolve()` 依据执行器配置默认值完成解析,因此执行器 seam(`BashExecSpec`)收到显式的 `workdir`/`timeoutMs` 值。工具层会根据调用方 agent 的 `session.header.cwd` 应用工作目录默认值,然后才调用 `resolve()`:由于 N 个会话共享一个执行器,逐会话 cwd 必须来自 `exec.agent`;只有无法取得会话 cwd 时,执行器才回退到自身配置/`process.cwd()`。存在沙箱策略时,工具会复用已经规范化的 `workspaceRoot` 作为工作目录基准,防止限制逻辑与进程启动过程对同一个会话路径拼写产生不同解析结果。 + +### 托管 shell 环境 + +每次模型发起的前台或后台 bash 调用都会收到新收集的一组可信 `DSH_*` 环境变量。`DSH_HOME` 是由 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析出的 Harness home 绝对路径(依次采用 `dshHome` 配置、环境中的 `$DSH_HOME`、`~/.dsh`),`DSH_SHELL=1` 则标识受托管的子进程。Agent 调用还会收到 `DSH_SESSION_ID=agent.session.header.id`;当活跃的持久化 seam 找到 JSONL 产物时,也会收到 `DSH_SESSION_JSONL=<absolute target path>`。JSONL 路径只是位置提示:首次 flush 前它可能尚不存在,也可能不包含当前缓冲的轮次,并且它不是授权凭据。 + +`ctx.bashEnv` 持有收集过程。其他插件可以注册具有 effect 作用域的贡献方,提供稳定名称、已声明的键/说明以及 `resolve(execution: ToolExecution)`;重复持有或运行时返回未声明的键会快速失败,而 `list()` 无需执行提供方即可列举声明。Harness 内置项保留 `DSH_HOME`、`DSH_SHELL` 和 `DSH_SESSION_ID`;tool-bash 的持久化转换器持有 `DSH_SESSION_JSONL`,其值来自后端无关的 `sessionPersistence.locate()` seam。 + +```ts +import type { Context } from 'cordis' +import type {} from '@deepseek-ai/dsh-tool-bash' + +export const inject = ['bashEnv'] + +export function apply(ctx: Context): void { + ctx.bashEnv.register({ + name: 'deployment-region', + variables: { DSH_DEPLOYMENT_REGION: { description: 'Current deployment region.' } }, + resolve: execution => execution.agent === undefined ? {} : { DSH_DEPLOYMENT_REGION: 'cn-north' }, + }) +} +``` + +overlay 根据当前 `ToolExecution` 计算,并通过专用的 `BashExecRequest.dshEnv` 通道传递。本地执行器会先删除继承的所有 `DSH_*`,再合并该快照,因此嵌套 harness 和并发的父/子 agent 不会泄漏陈旧身份。它绝不会修改 `process.env`。工具说明只教授通用 `$DSH_*` 约定,不会点名持久化专用变量,也不会添加永久的系统提示词段落。 + +结果文本依次包含 stdout、可选的 `[stderr]` 段落和适用的沙箱拒绝、超时、信号、退出代码及截断标记。超时与最终退出状态分别报告;非零退出仍是由模型解释的结果,不会成为 `isError`。截断结果会链接安全的完整 spill 文件,或报告文件不可用。只有 spawn 错误和中止等基础设施故障才会产生 `isError`。 + +已完成前台进程的规范成功值为 `{ kind: 'foreground', ...BashRunResult }`,已发布任务则为 `{ kind: 'background', taskId }`。Native renderer 保留上述文本,包括精确的 `started background task <id>`;程序化消费方使用带类型字段,无需解析这些字符串。执行器的流上限仍是 `BashRunResult` 的采集限制,并携带其 spill 路径。 + +当 `run_in_background` 为 true 时,此插件会在 spawn 前预检 `ctx.tasks.start()`,把调用方 agent 注册为持有者,并将返回的 `BashProcess` 句柄适配为通用的取消/完成/增量输出钩子。任务运行时持有 id、跨会话隔离、完成通知、等待和 dispose(资源释放)清理;此插件只把 bash 退出/沙箱事实映射为任务输出和结果详情。`enableRunInBackground: false` 会移除该参数,并在执行时拒绝强制后台调用。 + +## UI 展示 + +工具持有自己的 `presentCall`/`presentResult` 渲染意图。前台调用是终端卡片,包含命令、说明、cwd、原始输出和解析后的退出状态。后台启动只返回 task id,因此使用通用执行卡片;通用 `task_*` 工具持有各自的卡片。这些 presenter 是纯函数,可安全回放。 + +## 工具仅使用具名参数构建请求 + +`BashExecRequest` seam 携带可选的 `stdoutMaxBytes`、`stdin`、普通 `env` 和托管 `dshEnv`,供可信进程内插件及此工具的环境注册表使用。模型侧工具不公开 `stdoutMaxBytes`、`stdin` 或 `env`:它使用具名的命令/工作目录/超时/信号/沙箱字段,加上从注册表收集的 `dshEnv` 来构建请求。额外模型键会被忽略,无法替换托管值。Shell 语法可以提供等价的命令级行为,而本地执行器会清除环境中的凭据和陈旧 `DSH_*` 值。参见 [stdin/env Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)。 + +## 权限与升权 + +除非启用沙箱的执行器([`dsh-bash-sandbox`](../bash-sandbox/))限制命令,否则命令以执行器的完整权限运行。仅拒绝型沙箱会把拒绝作为结果事实报告,并在此渲染为拒绝标记;逐调用的允许/拒绝/询问策略由 `tools/pre-execute` waterfall(瀑布式事件)负责(参见 docs/architecture.md)。 + +需要升权的 bash 调用会在执行前解析 `ctx.approval`。`allowed-once` 只对该次调用应用请求模式;审批被拒、取消、不可用或缺少审批上下文时,命令完全不会执行,并返回不同的错误。发生真实拒绝后,模型可以在同一轮次中使用满足需要的最窄模式和理由重试同一命令一次;审批提示本身就是征求同意的步骤。升权绝不能预先推测,禁用或拒绝审批即为最终结果。其理由由 [沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) 持有。 + +## 逐会话模式切换 + +对于启用沙箱的执行器,每次调用依次按单次升权、会话覆盖、执行器默认值解析模式。未启用沙箱以及没有 agent 的调用不携带会话覆盖。提示词和切换通知均不公布当前常驻模式;拒绝结果会在边界相关时报告有效模式。参见 [`dsh-bash` 整合](../bash/README.md)和[沙箱切换契约](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。 + +## 模型体验 + +### 系统提示词 + +#### 模型看到的内容 + +此插件注册作用域内的每个请求都包含下方 bash 指引。启用沙箱的执行器不会添加模式声明或切换通知。作用域工具限制可以隐藏 schema,但不会移除这个独立注册的段落。 + +##### Bash 指引 + +```markdown +Check the [exit code: N] marker on every bash result; investigate failures before moving on. +``` + +#### Token 影响 + +插件活跃期间,每个请求都会产生少量固定输入开销,不受沙箱模式或模式切换影响。 + +#### KV Cache 影响 + +只要注册作用域和提示词文本不变,前缀即可稳定复用。插件激活或 dispose 可能从此提示词段落开始使复用失效;沙箱模式切换不会。 + +### 工具 schema + +#### 模型看到的内容 + +模型会看到生成的 [`bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash)。仅当此生产方启用 `run_in_background` 时,该字段才会出现;仅当已挂载执行器声明支持沙箱时,`sandbox_permissions` 和 `justification` 才会出现。Agent 作用域的工具限制可以移除该 agent 的定义。 + +#### Token 影响 + +工具可见的每个请求都会产生固定 schema 开销;沙箱支持会增加升权字段及其条件说明段落。 + +#### KV Cache 影响 + +只要可见性、后台支持和执行器沙箱功能保持不变,前缀即可稳定复用。限制、配置或执行器发生变化时,可能从首个变化的工具定义开始使复用失效。 + +### 前台结果 + +#### 模型看到的内容 + +renderer 先输出依数据而定的 stdout 尾部,再输出可选的 `[stderr]` 和 stderr 尾部。没有输出时,它会精确输出 `(no output)`。条件行精确为 `[output truncated; full output: <path-or-(unavailable)>]`、`[sandbox: file access denied under <mode> mode]`、`[timed out after <timeoutMs>ms]`、`[killed by signal: <signal>]` 和 `[exit code: <exitCode>]`;沙箱升权与 runner 故障行原文列于 [`dsh-bash-sandbox`](../bash-sandbox/README.md)。 + +#### Token 影响 + +调用前结果 token 为零。每条流的输出有界,每个已输出行则会保留在历史中,直至压缩(compaction)。 + +#### KV Cache 影响 + +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 + +### 后台任务上下文与结果 + +#### 模型看到的内容 + +启动会精确返回 `started background task <taskId>`。此生产方会向通用任务运行时提供增量进程输出、可选的 `[some output was dropped from memory; full output: <paths-or-(unavailable)>]`、沙箱事实,以及 `exit code: <exitCode>` 或 `signal: <signal>` 等终止详情。[`dsh-tool-tasks`](../../tasks/tool-tasks/README.md) 持有模型可见的状态行、完成通知、列表和取消响应。 + +#### Token 影响 + +启动确认很短并会保留;收集到的输出依数据而定,并受执行器流缓冲区限制。消费式读取不会重复先前输出。 + +#### KV Cache 影响 + +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 + +### 工具错误 + +#### 模型看到的内容 + +验证和策略失败统一为 `Error: <message>`。此包的稳定消息包括 `invalid command: expected a non-empty string`、`invalid description: expected a non-empty string`、`invalid timeoutMs: expected a positive number, got <value>`、`invalid escalation: sandbox_permissions requires a justification`、`invalid escalation: justification is only valid together with sandbox_permissions`、`invalid justification: expected a non-empty sentence`、`background execution is disabled for this bash tool`、`background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`、`sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`、`sandbox escalation to "<mode>" is not strictly wider than this call's current "<mode>" mode`、审批不可用/拒绝/取消变体,以及 `command aborted`。 + +#### Token 影响 + +只有失败调用会增加这些保留 token;升权被拒时命令不会运行,因此不会添加命令输出。 + +#### KV Cache 影响 + +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 + +## 已知限制与延期工作 + +- **回放退出状态 pill 从结果文本解析**:如果输出最后一行恰好精确为 `[exit code: N]` / `[killed by signal: …]`,会话回放将显示错误的 pill;这是仅影响展示的已知残留问题。 +- **`bash` 工具不采用 `timeout-policy` 预算**:根据[工具调用 timeout-policy Agent Note](../../../.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md),它保留由执行器持有的 `BASH_TIMEOUT` 路径。 +- **后台进程没有执行器超时**:工作不再需要时,调用方必须使用 `task_kill`,或依赖持有者/服务的 dispose。 diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml new file mode 100644 index 0000000000..0dd8860d65 --- /dev/null +++ b/packages/client/connection/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 80228a180faba0c556ff720e999b29b5bb1635b6 +README.zh.md: f4b857886bfafa891ceb1bd6b79b27e1fb725819 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 569670c274..80228a180f 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-client-connection +English | [中文](README.zh.md) + Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. ## Keyless fixture diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md new file mode 100644 index 0000000000..f4b857886b --- /dev/null +++ b/packages/client/connection/README.zh.md @@ -0,0 +1,22 @@ +# @deepseek-ai/dsh-client-connection + +[English](README.md) | 中文 + +协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 + +## 无密钥 fixture + +任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session;`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace;`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId,均具有足够的确定性,组装后的 Web 测试可以据此协调列表与帧的到达。 + +## 模型体验 + +无。协议消费层只在浏览器与主机之间搬运已经组合好的消息;这里没有任何内容进入模型请求。 + +#### KV Cache 影响 + +无;该包既不组装也不发送提供方请求。 + +## 已知限制与暂缓事项 + +- **history 的隐式恢复存在争议**:在未附加的会话上打开 history,会在主机侧拉起 agent;纯持久化读取的替代方案记录在 rt-core 协调账本中,P-I 不作改变。该包的消费方会在首次打开时感受到这段延迟。 +- **计划移除 `ToolEventView`/`ToolCallView`/`ToolResultView` 的重新导出**:当 toolview 迁移删除主机 `viewFor` 行时,它们会一并移除(呈现属于客户端);在此之前,fixture 保留一份局部 `viewFor` 镜像。 diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index d0f55c2fdf..f5dc3ed199 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -684,6 +684,59 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { emitHost({ type: 'host/workspace-changed', workspace: { ...created } }) return ok(request, { workspace: { ...created }, created: true }) }, + rename: (request) => { + const { workspaceId, title } = request.payload + const workspace = workspaces.find(w => w.workspaceId === workspaceId) + if (workspace === undefined) { + return err(request, { + code: 'workspace-not-found', + message: `no workspace ${workspaceId}`, + details: { workspaceId }, + }) + } + const trimmed = title.trim() + if (trimmed !== workspace.title) { + if (workspaces.some(w => w.workspaceId !== workspaceId && w.title === trimmed)) { + return err(request, { + code: 'workspace-name-conflict', + message: `workspace name '${trimmed}' is already in use`, + details: { name: trimmed }, + }) + } + workspace.title = trimmed + workspace.updatedAt = new Date().toISOString() + emitHost({ type: 'host/workspace-changed', workspace: { ...workspace } }) + } + return ok(request, { workspace: { ...workspace } }) + }, + insertSessionBefore: (request) => { + const { workspaceId, sessionId, beforeSessionId } = request.payload + const workspace = workspaces.find(w => w.workspaceId === workspaceId) + if (workspace === undefined) { + return err(request, { + code: 'workspace-not-found', + message: `no workspace ${workspaceId}`, + details: { workspaceId }, + }) + } + if (!workspace.sessionIds.includes(sessionId) + || (beforeSessionId !== undefined && !workspace.sessionIds.includes(beforeSessionId))) { + return err(request, { + code: 'workspace-move-invalid', + message: `session or anchor is not accounted by workspace ${workspaceId}`, + details: { workspaceId, sessionId, ...beforeSessionId === undefined ? {} : { beforeSessionId } }, + }) + } + const without = workspace.sessionIds.filter(id => id !== sessionId) + const at = beforeSessionId === undefined ? without.length : without.indexOf(beforeSessionId) + const sessionIds = [...without.slice(0, at), sessionId, ...without.slice(at)] + if (!sessionIds.every((id, index) => id === workspace.sessionIds[index])) { + workspace.sessionIds = sessionIds + workspace.updatedAt = new Date().toISOString() + emitHost({ type: 'host/workspace-changed', workspace: { ...workspace } }) + } + return ok(request, { workspace: { ...workspace } }) + }, }, events: { async *mux(_request, signal) { @@ -800,6 +853,8 @@ export class FixtureApiClient extends AbstractApiClient { case 'host.describe': return this.api.host.describe(request) case 'workspace.list': return this.api.workspace.list(request) case 'workspace.create': return this.api.workspace.create(request) + case 'workspace.rename': return this.api.workspace.rename(request) + case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request) } } diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index faca82d5c3..eecacc9581 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -77,6 +77,12 @@ export class FakeApiClient implements IApiClient { workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' }, created: true, }))), + rename: (payload: unknown) => this.record('workspace.rename', payload, Promise.resolve(ok({ + workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' }, + }))), + insertSessionBefore: (payload: unknown) => this.record('workspace.insertSessionBefore', payload, Promise.resolve(ok({ + workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' }, + }))), } /** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */ diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 16fa4b4ed6..0baabaf617 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -311,6 +311,60 @@ describe('createFixtureApi', () => { expect(rootPath.result.value.workspace.title).toBe('/') }) + it('workspace.rename covers not-found, conflict, no-op, and the changed frame', async () => { + const api = createFixtureApi() + const abort = new AbortController() + const seen: HostFrame[] = [] + const consuming = (async () => { + for await (const envelope of api.events.host(req({}), abort.signal)) { + seen.push(envelope.payload) + if (seen.length >= 2) abort.abort() + } + })() + await new Promise(resolve => setTimeout(resolve, 10)) + const wsid = 'fx-ws-fixture' as WorkspaceId + const missing = await api.workspace.rename(req({ workspaceId: 'fx-ws-void' as WorkspaceId, title: 'x' })) + expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'fx-ws-void' } } }) + + await api.workspace.create(req({ name: 'occupied' })) + const conflict = await api.workspace.rename(req({ workspaceId: wsid, title: ' occupied ' })) + expect(conflict.result).toMatchObject({ ok: false, error: { code: 'workspace-name-conflict', details: { name: 'occupied' } } }) + + const noop = await api.workspace.rename(req({ workspaceId: wsid, title: ' fixture ' })) + if (!noop.result.ok) throw new Error('no-op rename failed') + expect(noop.result.value.workspace.title).toBe('fixture') + + const renamed = await api.workspace.rename(req({ workspaceId: wsid, title: 'renamed' })) + if (!renamed.result.ok) throw new Error('rename failed') + expect(renamed.result.value.workspace.title).toBe('renamed') + await consuming + // Only the create and the effective rename emit frames; the no-op stays silent. + expect(seen.map(f => f.type)).toEqual(['host/workspace-changed', 'host/workspace-changed']) + }) + + it('workspace.insertSessionBefore moves, appends, no-ops, and rejects invalid ids', async () => { + const api = createFixtureApi() + const wsid = 'fx-ws-fixture' as WorkspaceId + const missing = await api.workspace.insertSessionBefore(req({ workspaceId: 'fx-ws-void' as WorkspaceId, sessionId: sid('fx-alpha') })) + expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found' } }) + const ghost = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-ghost') })) + expect(ghost.result).toMatchObject({ ok: false, error: { code: 'workspace-move-invalid', details: { sessionId: 'fx-ghost' } } }) + const badAnchor = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-alpha'), beforeSessionId: sid('fx-ghost') })) + expect(badAnchor.result).toMatchObject({ ok: false, error: { code: 'workspace-move-invalid', details: { beforeSessionId: 'fx-ghost' } } }) + + const moved = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-gamma'), beforeSessionId: sid('fx-beta') })) + if (!moved.result.ok) throw new Error('move failed') + expect(moved.result.value.workspace.sessionIds).toEqual(['fx-alpha', 'fx-gamma', 'fx-beta']) + const appended = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-alpha') })) + if (!appended.result.ok) throw new Error('append failed') + expect(appended.result.value.workspace.sessionIds).toEqual(['fx-gamma', 'fx-beta', 'fx-alpha']) + const before = appended.result.value.workspace.updatedAt + const noop = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-alpha') })) + if (!noop.result.ok) throw new Error('no-op move failed') + expect(noop.result.value.workspace.sessionIds).toEqual(['fx-gamma', 'fx-beta', 'fx-alpha']) + expect(noop.result.value.workspace.updatedAt).toBe(before) + }) + it('session.create({workspaceId}) lands on the account and unknown ids error', async () => { const api = createFixtureApi() const abort = new AbortController() @@ -558,6 +612,15 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { const workspace = await client.workspace.create({ name: 'via-client' }) if (!workspace.result.ok) throw new Error('workspace create failed') expect(workspace.result.value.workspace.title).toBe('via-client') + const wsid = workspace.result.value.workspace.workspaceId + const renamed = await client.workspace.rename({ workspaceId: wsid, title: 'via-client-2' }) + if (!renamed.result.ok) throw new Error('workspace rename failed') + expect(renamed.result.value.workspace.title).toBe('via-client-2') + const attached = await client.sessions.create({ workspaceId: wsid }) + if (!attached.result.ok) throw new Error('attached create failed') + const moved = await client.workspace.insertSessionBefore({ workspaceId: wsid, sessionId: attached.result.value.sessionId }) + if (!moved.result.ok) throw new Error('workspace move failed') + expect(moved.result.value.workspace.sessionIds).toEqual([attached.result.value.sessionId]) }) it('maps empty, prompt-reject, and workspace-first query scenarios', async () => { diff --git a/packages/client/hmr/README.i18n.yaml b/packages/client/hmr/README.i18n.yaml new file mode 100644 index 0000000000..ce05fdae22 --- /dev/null +++ b/packages/client/hmr/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 2b2f63c25cbf3a46babef78a4dfb52f859156887 +README.zh.md: 6d94ca4a5e91f390e58575aa4ddf64fc18a509de diff --git a/packages/client/hmr/README.md b/packages/client/hmr/README.md index fc262bb086..2b2f63c25c 100644 --- a/packages/client/hmr/README.md +++ b/packages/client/hmr/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-client-hmr +English | [中文](README.zh.md) + Hot reload for fetch-arrival client plugins. A static-arrival entry composed only into `--dev` graphs (`dsh web --dev`); production graphs omit the row, so the shell-bundled code stays inert. The browser half subscribes to the system SSE channel (`GET /plugins/events`) and reloads one plugin per `rebuilt` frame, serialized through a queue (the bundle handoff slot is single). The sequence per frame — `prefetch` (fetch the new bundle before touching anything), `invalidate`, `registry.delete` (before the fiber: a bare fiber dispose trips the vendored Loader's self-dispose branch, which would mark the entry disabled), drain the old fiber, delete `entry.fiber`, remove owned `<style data-plugin>` tags, `entry.refresh()` re-imports and remounts, `fiber.await()` rethrows startup failures loud. Dependents reload through cordis itself: a fiber's activation epoch strings its service providers' uids, so replacing a provider's fiber cascades every dependent with zero client-side graph analysis. The node half detects rebuilds with one interval that stat-polls each graph bundle from a synchronous baseline, immediately re-hashes after adding a row, retains missing rows as dirty, and broadcasts only real rev changes; any tsdown watch process producing the bundle therefore triggers HMR with no builder→host channel. diff --git a/packages/client/hmr/README.zh.md b/packages/client/hmr/README.zh.md new file mode 100644 index 0000000000..6d94ca4a5e --- /dev/null +++ b/packages/client/hmr/README.zh.md @@ -0,0 +1,21 @@ +# @deepseek-ai/dsh-client-hmr + +[English](README.md) | 中文 + +为通过 fetch 到达的客户端插件提供热重载。该静态到达配置项只组合进 `--dev` 图(`dsh web --dev`);生产图省略此行,因此外壳打包的代码保持不活动。 + +浏览器侧订阅系统 SSE 通道(`GET /plugins/events`),每个 `rebuilt` 帧重载一个插件,并通过队列串行执行(组合包交接 slot 只能容纳一个)。每帧的顺序是:`prefetch`(在触碰任何内容前抓取新组合包)、`invalidate`、`registry.delete`(在 fiber 之前执行:只释放 fiber 会触发 vendored Loader 的 self-dispose 分支,把配置项标为禁用)、排空旧 fiber、删除 `entry.fiber`、移除自身拥有的 `<style data-plugin>` 标签、通过 `entry.refresh()` 重新导入并挂载、以 `fiber.await()` 将启动失败高声重新抛出。依赖方由 cordis 自身重载:fiber 的激活 epoch 会串联其服务提供方的 uid,因此替换提供方 fiber 会级联所有依赖方,无需客户端图分析。node 侧使用一个 interval 检测重建:从同步基线开始 stat-poll 每个图组合包;新增一行后立即重新计算 hash;缺失行保持 dirty;只广播真实 rev 变更。因此,任何生成组合包的 tsdown watch 进程都能触发 HMR,无需 builder→host 通道。 + +## 模型体验 + +无。重载驱动器属于浏览器侧机制;这里没有任何内容进入模型请求。 + +#### KV Cache 影响 + +无;该包既不组装也不发送提供方请求。 + +## 已知限制与暂缓事项 + +- **重载有意保持粗粒度**:会创建全新的 fiber 和组件;重载插件中的 React 状态会丢失,数据层(connection/runtime fiber、Session 对象)不受影响。react-refresh 级状态保留与「重新执行组合包会重新运行 factory」冲突,因此有意排除。 +- **失败时不回滚**:失败的重载会使配置项处于 FAILED 状态,并在 loader 状态投影中高声报告;自动恢复先前组合包会等到实际需要出现后再实现。 +- **重建帧不会刷新图 rev**:陈旧 rev 无害(组合包端点以 no-cache 提供内容);rev 刷新会随重新连接握手机制落地。 diff --git a/packages/client/i18n/README.md b/packages/client/i18n/README.md deleted file mode 100644 index db6be0fe2a..0000000000 --- a/packages/client/i18n/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# @deepseek-ai/dsh-client-i18n - -i18n plugin: I18nService (ns×locale dictionaries, bind(ns)→t with a stable function identity, locale store). Contract: api-contracts v3 §8. - -## Model Experience - -None, as the i18n registry serves browser UI copy; nothing here reaches a model request. - -#### KV Cache effect - -None; this package neither assembles nor sends a provider request. - -## Known Limitations and Deferred Work - -- **zh/en ship as empty structures** — the existing UI copy is inline Chinese; extraction into dictionaries is deferred repo-wide work, so `bind(ns)` consumers today mostly receive key-echo fallbacks. -- **Locale switching re-renders the whole tree** — accepted as a low-frequency operation; no per-namespace subscription granularity. diff --git a/packages/client/i18n/src/client/index.ts b/packages/client/i18n/src/client/index.ts deleted file mode 100644 index 9dea9c4bd4..0000000000 --- a/packages/client/i18n/src/client/index.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Browser-side locale registry. Bound translation functions retain stable - * identity for injected consumers. - */ -import type { Context } from 'cordis' -// Snapshot stores are framework-neutral; React consumers bind hooks at their -// rendering boundary. -import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' -import { en } from '../locales/en.ts' -import { zh } from '../locales/zh.ts' - -/** Translate a key with optional params. */ -export type Translate = (key: string, params?: Record<string, unknown>) => string - -/** Locale dictionary: flat key to template string ({name} placeholders). */ -export type LocaleDict = Record<string, string> - -declare module 'cordis' { - interface Context { - i18n: I18nService - } -} - -/** Fallback locale consulted after the active locale misses. */ -export const FALLBACK_LOCALE = 'zh' - -/** Shared namespace for shell-level texts. */ -export const COMMON_NS = 'common' - -/** - * Dictionary registry plus locale switch. Lookup chain per key: active locale - * -> zh fallback -> the key itself (missing text stays visible, fail loud in - * the UI rather than blank). - */ -export class I18nService { - private dicts = new Map<string, Map<string, LocaleDict>>() - private bound = new Map<string, Translate>() - private localeStore = createSnapshotStore<string>(FALLBACK_LOCALE) - - /** - * Register a dictionary for a namespace and locale. Duplicate (ns, locale) - * throws (single occupant; a namespace's texts have one owner). - * @param ns - namespace. - * @param locale - locale tag (zh/en to start). - * @param dict - dictionary. - * @returns disposer (idempotent). - */ - register(ns: string, locale: string, dict: LocaleDict): () => void { - let locales = this.dicts.get(ns) - if (!locales) { - locales = new Map() - this.dicts.set(ns, locales) - } - if (locales.has(locale)) throw new Error(`i18n namespace "${ns}" already has locale "${locale}"`) - locales.set(locale, dict) - return () => { - const owner = this.dicts.get(ns) - if (owner?.get(locale) === dict) owner.delete(locale) - } - } - - /** - * Bind a namespace to a translate function. The returned reference is - * stable per namespace (repeat binds return the same function), so it can - * ride inject surfaces without breaking memoization. - * @param ns - namespace. - * @returns the translate function (reads the locale store at call time). - */ - bind(ns: string): Translate { - let t = this.bound.get(ns) - if (!t) { - t = (key, params) => this.translate(ns, key, params) - this.bound.set(ns, t) - return t - } - return t - } - - /** Active locale store (switching re-renders the tree; low frequency). */ - get locale(): SnapshotStore<string> { - return this.localeStore - } - - private translate(ns: string, key: string, params?: Record<string, unknown>): string { - const locales = this.dicts.get(ns) - const template = locales?.get(this.localeStore.getSnapshot())?.[key] - ?? locales?.get(FALLBACK_LOCALE)?.[key] - ?? key - if (!params) return template - return template.replace(/\{(\w+)\}/g, (match, name: string) => - name in params ? String(params[name]) : match) - } -} - -/** Required services (none; the loader passes the export surface as an object plugin). */ -export const inject: string[] = [] - -/** - * Client plugin body: provide the i18n service with base dictionaries. - * @param ctx - client cordis context. - */ -export function apply(ctx: Context): void { - const i18n = new I18nService() - i18n.register(COMMON_NS, 'zh', zh) - i18n.register(COMMON_NS, 'en', en) - ctx.provide('i18n', i18n) -} diff --git a/packages/client/i18n/tests/i18n.spec.ts b/packages/client/i18n/tests/i18n.spec.ts deleted file mode 100644 index 12fef2c716..0000000000 --- a/packages/client/i18n/tests/i18n.spec.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { I18nService } from '@deepseek-ai/dsh-client-i18n/client' - -describe('I18nService', () => { - it('translates from the active locale with zh fallback then key passthrough', () => { - const i18n = new I18nService() - i18n.register('ns', 'zh', { hello: '你好', onlyZh: '仅中文' }) - i18n.register('ns', 'en', { hello: 'Hello' }) - const t = i18n.bind('ns') - expect(i18n.locale.getSnapshot()).toBe('zh') - expect(t('hello')).toBe('你好') - i18n.locale.set('en') - expect(t('hello')).toBe('Hello') - expect(t('onlyZh')).toBe('仅中文') - expect(t('missing.key')).toBe('missing.key') - }) - - it('interpolates {name} params and leaves unknown placeholders intact', () => { - const i18n = new I18nService() - i18n.register('ns', 'zh', { greet: '你好,{name}!第 {n} 次', partial: '{known} 与 {unknown}' }) - const t = i18n.bind('ns') - expect(t('greet', { name: '世界', n: 2 })).toBe('你好,世界!第 2 次') - expect(t('partial', { known: 'A' })).toBe('A 与 {unknown}') - expect(t('greet')).toBe('你好,{name}!第 {n} 次') - }) - - it('bind returns a stable reference per namespace', () => { - const i18n = new I18nService() - expect(i18n.bind('a')).toBe(i18n.bind('a')) - expect(i18n.bind('a')).not.toBe(i18n.bind('b')) - }) - - it('duplicate (ns, locale) throws; disposer unregisters and is idempotent', () => { - const i18n = new I18nService() - const dispose = i18n.register('ns', 'zh', { k: 'v1' }) - expect(() => i18n.register('ns', 'zh', { k: 'v2' })).toThrow('already has locale') - dispose() - dispose() - const t = i18n.bind('ns') - expect(t('k')).toBe('k') - i18n.register('ns', 'zh', { k: 'v2' }) - expect(t('k')).toBe('v2') - }) - - it('locale store is subscribable (snapshot store contract)', () => { - const i18n = new I18nService() - let notified = 0 - i18n.locale.subscribe(() => { notified += 1 }) - i18n.locale.set('en') - expect(i18n.locale.getSnapshot()).toBe('en') - expect(notified).toBe(1) - }) -}) diff --git a/packages/client/i18n/tests/invariant.spec.ts b/packages/client/i18n/tests/invariant.spec.ts deleted file mode 100644 index b992e50d52..0000000000 --- a/packages/client/i18n/tests/invariant.spec.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import { apply as nodeApply } from '@deepseek-ai/dsh-client-i18n' -import { apply as clientApply, COMMON_NS, I18nService, inject } from '@deepseek-ai/dsh-client-i18n/client' -import * as I18nInvariant from '@deepseek-ai/dsh-client-i18n/invariant' -import InvariantService from '@deepseek-ai/dsh-invariants' - -describe('invariant companion', () => { - it('registers under the package name with an empty installer', async () => { - const ctx = new Context() - await ctx.plugin(InvariantService, { enabled: true }) - await expect(ctx.plugin(I18nInvariant).await()).resolves.toBeDefined() - }) - - it('node-half apply is a no-op host placeholder', () => { - nodeApply() - expect(true).toBe(true) // reaching here without throw is the contract - }) - - it('client apply provides ctx.i18n seeded with the zh/en common namespace', async () => { - expect(inject).toEqual([]) - const ctx = new Context() - await ctx.plugin({ inject, apply: clientApply }).await() - const i18n = ctx.get('i18n') - expect(i18n).toBeInstanceOf(I18nService) - // Seeded dictionaries occupy the (ns, locale) seats even while empty. - expect(() => (i18n as I18nService).register(COMMON_NS, 'zh', {})).toThrow('already has locale') - expect(() => (i18n as I18nService).register(COMMON_NS, 'en', {})).toThrow('already has locale') - }) -}) diff --git a/packages/client/i18n/tsdown.config.ts b/packages/client/i18n/tsdown.config.ts deleted file mode 100644 index 1c0802be49..0000000000 --- a/packages/client/i18n/tsdown.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { clientBundle } from '../tsdown.client.ts' - -export default clientBundle('@deepseek-ai/dsh-client-i18n', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/locale/README.i18n.yaml b/packages/client/locale/README.i18n.yaml new file mode 100644 index 0000000000..699c9561cc --- /dev/null +++ b/packages/client/locale/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 9015af2b44a33771b06863ace139fe97695df616 +README.zh.md: 6b129bcabbef5b5a00c5073ebc9142a0e406ddba diff --git a/packages/client/locale/README.md b/packages/client/locale/README.md new file mode 100644 index 0000000000..9015af2b44 --- /dev/null +++ b/packages/client/locale/README.md @@ -0,0 +1,18 @@ +# @deepseek-ai/dsh-client-locale + +English | [中文](README.zh.md) + +Locale plugin: LocaleService — the browser locale preference (`zh`/`en`, persisted under `dsh.locale`, getter/setter with `locale/change` snapshots) plus the ns×locale dictionary registry (`bind(ns)`→t with a stable function identity; lookup chain active → zh → key). + +## Model Experience + +None, as the locale registry serves browser UI copy; nothing here reaches a model request. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **Only the Settings surface is translated** — other pages keep inline copy; repo-wide extraction into dictionaries is deferred. +- **Locale switching re-renders subscribed consumers only** — sections not wired to `locale/change` keep their rendered text until remount. diff --git a/packages/client/locale/README.zh.md b/packages/client/locale/README.zh.md new file mode 100644 index 0000000000..6b129bcabb --- /dev/null +++ b/packages/client/locale/README.zh.md @@ -0,0 +1,18 @@ +# @deepseek-ai/dsh-client-locale + +[English](README.md) | 中文 + +locale 插件:LocaleService 包含浏览器 locale 偏好(`zh`/`en`,以 `dsh.locale` 为键持久化;提供 getter/setter,并生成 `locale/change` 快照),以及 ns×locale 字典注册表(`bind(ns)`→t 的函数标识稳定;查找链为 active → zh → key)。 + +## 模型体验 + +无。locale 注册表为浏览器 UI 文案提供服务;这里没有任何内容进入模型请求。 + +#### KV Cache 影响 + +无;该包既不组装也不发送提供方请求。 + +## 已知限制与暂缓事项 + +- **只有设置界面完成翻译**:其他页面仍保留内联文案;将全仓文案提取到字典的工作暂缓。 +- **切换 locale 只重新渲染已订阅的消费方**:未接入 `locale/change` 的分区会保留已渲染文本,直到重新挂载。 diff --git a/packages/client/i18n/package.json b/packages/client/locale/package.json similarity index 52% rename from packages/client/i18n/package.json rename to packages/client/locale/package.json index 3e617174bf..4d75496dc8 100644 --- a/packages/client/i18n/package.json +++ b/packages/client/locale/package.json @@ -1,6 +1,6 @@ { - "name": "@deepseek-ai/dsh-client-i18n", - "description": "i18n plugin: I18nService (ns x locale dictionaries, bind(ns) -> t, locale store); zh/en skeleton", + "name": "@deepseek-ai/dsh-client-locale", + "description": "Locale plugin: LocaleService (zh/en preference with getter/setter/change event + persistence; ns x locale dictionaries, bind(ns) -> t); registers the Language settings row", "version": "0.0.1", "private": true, "type": "module", @@ -23,21 +23,29 @@ "./package.json": "./package.json" }, "dshClient": { - "inject": [], + "inject": [ + "@deepseek-ai/dsh-client-runtime" + ], "platform": "web", "immediately": true }, "license": "BSD-3-Clause", - "dependencies": { - "@deepseek-ai/dsh-client-runtime": "workspace:^" - }, "peerDependencies": { + "@deepseek-ai/dsh-client-runtime": "^0.0.1", + "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", + "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@types/react": "~18.3.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" }, "files": [ "lib/index.js", @@ -46,5 +54,9 @@ "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" - ] + ], + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + } } diff --git a/packages/client/locale/src/client/LanguageRow.module.css b/packages/client/locale/src/client/LanguageRow.module.css new file mode 100644 index 0000000000..f17a67d279 --- /dev/null +++ b/packages/client/locale/src/client/LanguageRow.module.css @@ -0,0 +1,47 @@ +/* Language row (figma 'Setting-Cell': gap 8, pad 16/0, hairline separator; + * the section column removes the separator on its last child). */ + +.row { + display: flex; + align-items: center; + gap: 8px; + padding: 16px 0; + border-bottom: 1px solid var(--dsw-alias-border-l2); +} + +.rowText { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; + padding-right: 48px; +} + +.title { + font-size: 14px; + font-weight: 400; + line-height: 22px; + color: var(--dsw-alias-label-primary); +} + +/* Selector pill (figma 'Selector': h36 r18, fill #F5F6F7, pad 0/14, gap 12). */ +.selector { + display: inline-flex; + align-items: center; + gap: 12px; + height: 36px; + padding: 0 14px; + border: none; + border-radius: 18px; + background: var(--dsw-alias-bg-module-platform); + font: inherit; + font-size: 14px; + line-height: 22px; + color: var(--dsw-alias-label-primary); + cursor: pointer; +} + +.chevron { + flex: none; +} diff --git a/packages/client/locale/src/client/LanguageRow.tsx b/packages/client/locale/src/client/LanguageRow.tsx new file mode 100644 index 0000000000..a824bc6752 --- /dev/null +++ b/packages/client/locale/src/client/LanguageRow.tsx @@ -0,0 +1,68 @@ +/** + * Language preference row registered into the General section item slot + * (figma 501:30011 'Setting-Cell'): title + selector pill opening the locale + * menu. Registered by this package — the locale feature owns its own + * settings surface. + */ +import { useState } from 'react' +import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' +import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives' +import type {} from './settings-contract.ts' +import type { createLanguageRowStore } from './settings-store.ts' +import css from './LanguageRow.module.css' + +/** Injected business face: namespace-bound translate + the preference write. */ +export interface LanguageRowInjected { + /** Translate a `settings.locale` dictionary key to the active-locale text. */ + t: (key: string) => string + /** Switch the active locale (a registered locale id). */ + setLocale: (id: string) => void +} + +/** Full component props: runtime share + store share + injected face. */ +export type LanguageRowComponentProps = + PropsRuntime<'settings.general.item'> & PropsStore<ReturnType<typeof createLanguageRowStore>> & LanguageRowInjected + +/** + * Render the Language row. + * @param props - composed slot props. + * @returns the row element tree. + */ +export function LanguageRow({ t, setLocale, useStore }: LanguageRowComponentProps) { + const active = useStore(s => s.active) + const options = useStore(s => s.options) + const [open, setOpen] = useState(false) + const activeLabel = options.find(o => o.id === active)?.label ?? active + + return ( + <div className={css.row}> + <div className={css.rowText}> + <div className={css.title}>{t('language.title')}</div> + </div> + <Menu + open={open} + onClose={() => { setOpen(false) }} + items={options.map(o => ({ id: o.id, label: o.label }))} + selectedId={active} + onSelect={(id) => { + setLocale(id) + setOpen(false) + }} + align="end" + portal + anchor={( + <button + type="button" + className={css.selector} + aria-haspopup="menu" + aria-expanded={open} + onClick={() => { setOpen(v => !v) }} + > + {activeLabel} + <IconChevronDownOutline14 className={css.chevron} /> + </button> + )} + /> + </div> + ) +} diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts new file mode 100644 index 0000000000..cb6bb6f861 --- /dev/null +++ b/packages/client/locale/src/client/index.ts @@ -0,0 +1,248 @@ +/** + * Browser-side locale registry. Bound translation functions retain stable + * identity for injected consumers. The plugin also registers the Language + * preference row into the settings General section — the locale feature owns + * its own settings surface. + */ +import type { Context } from 'cordis' +import { deferRegistration, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots' +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import { en } from '../locales/en.ts' +import { zh } from '../locales/zh.ts' +import type { LanguageRowInjected } from './LanguageRow.tsx' +import { LanguageRow } from './LanguageRow.tsx' +import { createLanguageRowStore } from './settings-store.ts' + +export type { LanguageRowComponentProps, LanguageRowInjected } from './LanguageRow.tsx' +export type { LanguageOptionRow, LanguageRowState } from './settings-store.ts' +export type { SettingsGeneralItemOwnerProps } from './settings-contract.ts' + +/** Translate a key with optional params. */ +export type Translate = (key: string, params?: Record<string, unknown>) => string + +/** Locale dictionary: flat key to template string ({name} placeholders). */ +export type LocaleDict = Record<string, string> + +/** Locale identifier: the two shipped locales. */ +export type LocaleId = 'zh' | 'en' + +/** One selectable locale: id plus its self-described display name. */ +export interface LocaleDefinition { + /** Locale id (persisted; the setLocale argument). */ + id: LocaleId + /** Display name in its own language (中文 / English). */ + label: string +} + +/** Immutable locale state published on every change. */ +export interface LocaleSnapshot { + /** Active locale id. */ + active: LocaleId + /** Selectable locales in display order. */ + locales: readonly LocaleDefinition[] + /** Monotonic change counter (registry or active changes). */ + revision: number +} + +declare module 'cordis' { + interface Context { + locale: LocaleService + } + interface Events { + /** + * Locale state changed (active locale switched or registry updated). + * @param snapshot - Current immutable locale snapshot. + * @mode emit + */ + 'locale/change'(snapshot: LocaleSnapshot): void + } +} + +/** Fallback locale consulted after the active locale misses (also the default). */ +export const FALLBACK_LOCALE: LocaleId = 'zh' + +/** Shared namespace for shell-level texts. */ +export const COMMON_NS = 'common' + +/** Namespace owning this feature's settings-row copy. */ +export const SETTINGS_NS = 'settings.locale' + +/** localStorage key holding the persisted locale id. */ +export const STORAGE_KEY = 'dsh.locale' + +/** The two shipped locales. */ +const LOCALES: readonly LocaleDefinition[] = Object.freeze([ + { id: 'zh', label: '中文' }, + { id: 'en', label: 'English' }, +]) + +/** + * Dictionary registry plus locale preference. Lookup chain per key: active + * locale -> zh fallback -> the key itself (missing text stays visible, fail + * loud in the UI rather than blank). Reads go through {@link getLocale}; + * writes only through {@link setLocale}; continuous sync only through the + * `locale/change` event. + */ +export class LocaleService { + private dicts = new Map<string, Map<string, LocaleDict>>() + private bound = new Map<string, Translate>() + private snapshot: LocaleSnapshot + private readonly ctx: Context + + /** + * @param ctx - owning context (change events are emitted on it). + */ + constructor(ctx: Context) { + this.ctx = ctx + this.snapshot = Object.freeze({ active: restorePreference(), locales: LOCALES, revision: 0 }) + } + + /** + * Read the current immutable locale snapshot. + * @returns the current snapshot (stable reference until the next change). + */ + getLocale(): LocaleSnapshot { + return this.snapshot + } + + /** + * Switch the active locale — the only preference write entry. Persists the + * id and emits `locale/change`. + * @param id - a registered locale id; unknown ids throw. + */ + setLocale(id: string): void { + const match = this.snapshot.locales.find(l => l.id === id) + if (match === undefined) throw new Error(`locale "${id}" is not registered`) + if (this.snapshot.active === match.id) return + this.snapshot = Object.freeze({ + active: match.id, + locales: this.snapshot.locales, + revision: this.snapshot.revision + 1, + }) + persistPreference(match.id) + this.ctx.emit('locale/change', this.snapshot) + } + + /** + * Register a dictionary for a namespace and locale. Duplicate (ns, locale) + * throws (single occupant; a namespace's texts have one owner). + * @param ns - namespace. + * @param locale - locale tag (zh/en to start). + * @param dict - dictionary. + * @returns disposer (idempotent). + */ + register(ns: string, locale: string, dict: LocaleDict): () => void { + let locales = this.dicts.get(ns) + if (!locales) { + locales = new Map() + this.dicts.set(ns, locales) + } + if (locales.has(locale)) throw new Error(`locale namespace "${ns}" already has locale "${locale}"`) + locales.set(locale, dict) + return () => { + const owner = this.dicts.get(ns) + if (owner?.get(locale) === dict) owner.delete(locale) + } + } + + /** + * Bind a namespace to a translate function. The returned reference is + * stable per namespace (repeat binds return the same function), so it can + * ride inject surfaces without breaking memoization. + * @param ns - namespace. + * @returns the translate function (reads the active locale at call time). + */ + bind(ns: string): Translate { + let t = this.bound.get(ns) + if (!t) { + t = (key, params) => this.translate(ns, key, params) + this.bound.set(ns, t) + return t + } + return t + } + + private translate(ns: string, key: string, params?: Record<string, unknown>): string { + const locales = this.dicts.get(ns) + const template = locales?.get(this.snapshot.active)?.[key] + ?? locales?.get(FALLBACK_LOCALE)?.[key] + ?? key + if (!params) return template + return template.replace(/\{(\w+)\}/g, (match, name: string) => + name in params ? String(params[name]) : match) + } +} + +/** Read the persisted locale id; unknown or unreadable values fall back to zh. */ +function restorePreference(): LocaleId { + // Non-browser runs (node e2e booting the client tree) have no localStorage. + if (typeof localStorage === 'undefined') return FALLBACK_LOCALE + try { + const stored = localStorage.getItem(STORAGE_KEY) + if (stored === 'zh' || stored === 'en') return stored + } catch { + // Storage access can throw (privacy mode); the default below covers it. + } + return FALLBACK_LOCALE +} + +/** Persist the locale id; storage failures are non-fatal (preference resets next boot). */ +function persistPreference(id: LocaleId): void { + if (typeof localStorage === 'undefined') return + try { + localStorage.setItem(STORAGE_KEY, id) + } catch { + // Storage access can throw (privacy mode / quota); the preference simply + // does not survive the session. + } +} + +/** Required services: the slot registry (the feature registers its own settings row). */ +export const inject = ['slots'] + +/** + * Client plugin body: provide the locale service with base dictionaries and + * register the feature-owned Language preference row into the General + * section's item slot (a feature owns its settings surface). + * @param ctx - client cordis context. + */ +export function apply(ctx: ClientContext): void { + const locale = new LocaleService(ctx) + locale.register(COMMON_NS, 'zh', zh) + locale.register(COMMON_NS, 'en', en) + locale.register(SETTINGS_NS, 'zh', { 'language.title': '语言' }) + locale.register(SETTINGS_NS, 'en', { 'language.title': 'Language' }) + ctx.provide('locale', locale) + + const store = createLanguageRowStore() + let bound: BoundActions<typeof store> | undefined + const sync = (snapshot: LocaleSnapshot): void => { + bound?.sync( + snapshot.active, + snapshot.locales.map(l => ({ id: l.id, label: l.label })), + snapshot.revision, + ) + } + ctx.on('locale/change', sync) + const injected = (actions: BoundActions<typeof store>): LanguageRowInjected => { + bound = actions + // Re-sync from the getter so no event is lost between registration and + // first render (the store's revision guard drops stale duplicates). + sync(locale.getLocale()) + return { + t: locale.bind(SETTINGS_NS), + setLocale: (id) => { locale.setLocale(id) }, + } + } + ctx.effect(() => { + const deferred = deferRegistration(ctx.slots, 'settings.general.item', LanguageRow, () => + ctx.slots.register({ + name: 'settings.general.item', + id: 'language', + order: 0, + store, + inject: injected, + }, LanguageRow)) + return () => { deferred.dispose() } + }, 'locale: language settings row registration') +} diff --git a/packages/client/locale/src/client/settings-contract.ts b/packages/client/locale/src/client/settings-contract.ts new file mode 100644 index 0000000000..e032707645 --- /dev/null +++ b/packages/client/locale/src/client/settings-contract.ts @@ -0,0 +1,26 @@ +/** + * The `settings.general.item` slot type — one preference row inside the + * settings General section, contributed by the feature plugin that owns the + * preference (locale → Language, ui-theme → Appearance). Options: `id` (row + * key), `order` (row position). Rows draw their own internals (row layout, + * separators via CSS); the section column only stacks them. + * + * TYPE HOME RATIONALE: the slot is declared at runtime by + * ui-settings-general's General entry, but its type lives here — this + * package is the common dependency of every item registrant (any settings + * row carries copy, so every registrant already depends on locale), whereas + * the declarer's own contract is unreachable for locale/ui-theme without a + * reference cycle. + */ +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SlotMap { + /** One preference row inside the settings General section (see module JSDoc). */ + 'settings.general.item': { kind: 'list'; scope: 'root'; owner: SettingsGeneralItemOwnerProps } + } +} + +/** Owner share of a General preference row (the section supplies nothing). */ +export interface SettingsGeneralItemOwnerProps { + /** Marker field: item owner props are intentionally empty. */ + children?: never +} diff --git a/packages/client/locale/src/client/settings-store.ts b/packages/client/locale/src/client/settings-store.ts new file mode 100644 index 0000000000..485fd409f0 --- /dev/null +++ b/packages/client/locale/src/client/settings-store.ts @@ -0,0 +1,47 @@ +/** + * Language row slot store: a mirror of the locale service snapshot. The + * plugin's apply-world change listener is the only writer; the row component + * reads via props.useStore. + */ +import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client' + +/** One selectable locale row (id + self-described label). */ +export interface LanguageOptionRow { + /** Locale id (the setLocale argument). */ + id: string + /** Display name in its own language (中文 / English). */ + label: string +} + +/** Store state mirrored from the locale snapshot. */ +export interface LanguageRowState { + /** Active locale id. */ + active: string + /** Selectable locales in display order. */ + options: LanguageOptionRow[] + /** Service revision; -1 until first sync so revision 0 lands as a change. */ + revision: number +} + +/** Declared action shape giving the exported factory a stable return type. */ +type LanguageRowActions = { + sync: (draft: LanguageRowState, active: string, options: LanguageOptionRow[], revision: number) => void +} + +/** + * Declares the Language row state and write surface. + * @returns the store handle. + */ +export function createLanguageRowStore(): EngineStoreHandle<LanguageRowState, LanguageRowActions> { + return defineStore({ + init: (): LanguageRowState => ({ active: '', options: [], revision: -1 }), + actions: { + sync: (d, active: string, options: LanguageOptionRow[], revision: number) => { + if (revision <= d.revision) return + d.active = active + d.options = options + d.revision = revision + }, + }, + }) +} diff --git a/packages/client/locale/src/css-modules.d.ts b/packages/client/locale/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/locale/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record<string, string> + export default classes +} + +declare module '*.css' diff --git a/packages/client/i18n/src/index.ts b/packages/client/locale/src/index.ts similarity index 61% rename from packages/client/i18n/src/index.ts rename to packages/client/locale/src/index.ts index e759f1edc1..c220373932 100644 --- a/packages/client/i18n/src/index.ts +++ b/packages/client/locale/src/index.ts @@ -1,4 +1,4 @@ /** Host loader entry for the browser implementation exported from `./client`. */ -/** Host plugin body — no host-side behavior for the i18n plugin. */ +/** Host plugin body — no host-side behavior for the locale plugin. */ export function apply(): void {} diff --git a/packages/client/i18n/src/invariant.ts b/packages/client/locale/src/invariant.ts similarity index 86% rename from packages/client/i18n/src/invariant.ts rename to packages/client/locale/src/invariant.ts index b2c196e93b..96c94018f0 100644 --- a/packages/client/i18n/src/invariant.ts +++ b/packages/client/locale/src/invariant.ts @@ -1,16 +1,16 @@ /** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-i18n`. - * @module @deepseek-ai/dsh-client-i18n/invariant + * Package-owned invariant companion for `@deepseek-ai/dsh-client-locale`. + * @module @deepseek-ai/dsh-client-locale/invariant */ /* jscpd:ignore-start */ import type { Context } from 'cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' -const PACKAGE_NAME = '@deepseek-ai/dsh-client-i18n' +const PACKAGE_NAME = '@deepseek-ai/dsh-client-locale' /** Cordis companion plugin name. */ -export const name = 'client-i18n-invariant' +export const name = 'client-locale-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] diff --git a/packages/client/i18n/src/locales/en.ts b/packages/client/locale/src/locales/en.ts similarity index 100% rename from packages/client/i18n/src/locales/en.ts rename to packages/client/locale/src/locales/en.ts diff --git a/packages/client/i18n/src/locales/zh.ts b/packages/client/locale/src/locales/zh.ts similarity index 100% rename from packages/client/i18n/src/locales/zh.ts rename to packages/client/locale/src/locales/zh.ts diff --git a/packages/client/locale/tests/apply.spec.ts b/packages/client/locale/tests/apply.spec.ts new file mode 100644 index 0000000000..25dbdfe239 --- /dev/null +++ b/packages/client/locale/tests/apply.spec.ts @@ -0,0 +1,116 @@ +/** locale apply wiring: service + dictionaries provision, declaration-aware + * Language row registration, snapshot projection into the row store, and + * recovery after an HMR collapse of the declaring entry. */ +import { Context } from 'cordis' +import { describe, expect, it } from 'vitest' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-locale/client' +import type { LanguageRowInjected, LocaleService } from '@deepseek-ai/dsh-client-locale/client' +import { LanguageRow } from '../src/client/LanguageRow.tsx' +import type { createLanguageRowStore } from '../src/client/settings-store.ts' + +const SLOT = 'settings.general.item' + +async function bench() { + const ctx = new Context() + await ctx.plugin(SlotsService).await() + return { ctx, slots: ctx.get('slots') as SlotsService } +} + +/** Stand in for the settings shell: declare the General item slot from root. */ +function declareItems(slots: SlotsService): () => void { + return slots.register( + { name: 'root', children: { [SLOT]: { kind: 'list', scope: 'root' } } } as never, + () => null, + ) +} + +/** Mirror the framework's inject choreography: bake a real instance from the + * declared handle and hand its actions to the entry's inject factory. */ +function faceOf(slots: SlotsService) { + const entry = slots.entries(SLOT).find(e => e.component === LanguageRow)! + const handle = entry.store as ReturnType<typeof createLanguageRowStore> + const instance = handle.create() + const face = (entry.inject as unknown as (a: typeof instance.actions) => LanguageRowInjected)(instance.actions) + return { entry, instance, face } +} + +describe('locale apply', () => { + it('declares the slot service', () => { + expect(inject).toEqual(['slots']) + }) + + it('provides the service with base + settings dictionaries and registers the row (declaration before or after apply)', async () => { + const before = await bench() + declareItems(before.slots) + await before.ctx.plugin({ inject: [...inject], apply }).await() + const locale = before.ctx.get('locale') as LocaleService + // Base dictionaries are registered: the (ns, locale) seats are occupied. + expect(() => locale.register('common', 'zh', {})).toThrow('already has locale') + expect(() => locale.register('common', 'en', {})).toThrow('already has locale') + expect(locale.bind(SETTINGS_NS)('language.title')).toBe('语言') + const entry = before.slots.entries(SLOT).find(e => e.component === LanguageRow)! + expect(entry.options).toMatchObject({ id: 'language', order: 0 }) + + const after = await bench() + const fiber = after.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(after.slots.entries(SLOT)).toHaveLength(0) + declareItems(after.slots) + await Promise.resolve() + expect(after.slots.entries(SLOT).some(e => e.component === LanguageRow)).toBe(true) + }) + + it('projects service snapshots into the row store and routes face writes back', async () => { + const b = await bench() + declareItems(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const locale = b.ctx.get('locale') as LocaleService + // An event ahead of any inject hits the unbound-actions arm. + locale.setLocale('en') + + const { instance, face } = faceOf(b.slots) + // The inject-time re-sync sealed the init window: the mirror is current. + expect(instance.getSnapshot().active).toBe('en') + expect(instance.getSnapshot().options.map(o => o.id)).toEqual(['zh', 'en']) + expect(face.t('language.title')).toBe('Language') + + face.setLocale('zh') + expect(locale.getLocale().active).toBe('zh') + expect(instance.getSnapshot().active).toBe('zh') + expect(face.t('language.title')).toBe('语言') + }) + + it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => { + const b = await bench() + const host = declareItems(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + expect(b.slots.entries(SLOT)).toHaveLength(1) + + // Collapse: the declarer dies, the cascade removes our entry while the + // apply closure still holds its (now stale) disposer. + host() + expect(b.slots.entries(SLOT)).toHaveLength(0) + + declareItems(b.slots) + await Promise.resolve() + expect(b.slots.entries(SLOT).some(e => e.component === LanguageRow)).toBe(true) + }) + + it('teardown removes the row; teardown without a declaration is quiet', async () => { + const b = await bench() + declareItems(b.slots) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(b.slots.entries(SLOT)).toHaveLength(1) + await fiber.dispose() + expect(b.slots.entries(SLOT)).toHaveLength(0) + + // Never-declared bench: the effect disposer's dispose arm stays undefined. + const quiet = await bench() + const f2 = quiet.ctx.plugin({ inject: [...inject], apply }) + await f2.await() + await f2.dispose() + expect(quiet.slots.entries(SLOT)).toHaveLength(0) + }) +}) diff --git a/packages/client/locale/tests/invariant.spec.ts b/packages/client/locale/tests/invariant.spec.ts new file mode 100644 index 0000000000..fa62ca79f6 --- /dev/null +++ b/packages/client/locale/tests/invariant.spec.ts @@ -0,0 +1,34 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { apply as nodeApply } from '@deepseek-ai/dsh-client-locale' +import { apply as clientApply, COMMON_NS, LocaleService, inject } from '@deepseek-ai/dsh-client-locale/client' +import * as LocaleInvariant from '@deepseek-ai/dsh-client-locale/invariant' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import InvariantService from '@deepseek-ai/dsh-invariants' + +describe('invariant companion', () => { + it('registers under the package name with an empty installer', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + await expect(ctx.plugin(LocaleInvariant).await()).resolves.toBeDefined() + }) + + it('node-half apply is a no-op host placeholder', () => { + nodeApply() + expect(true).toBe(true) // reaching here without throw is the contract + }) + + it('client apply provides ctx.locale seeded with the zh/en common namespace', async () => { + // The feature registers its own Language settings row, hence the slots edge. + expect(inject).toEqual(['slots']) + const ctx = new Context() + new SlotsService(ctx) + await ctx.plugin({ inject, apply: clientApply }).await() + const locale = ctx.get('locale') + expect(locale).toBeInstanceOf(LocaleService) + // Seeded dictionaries occupy the (ns, locale) seats even while empty. + expect(() => (locale as LocaleService).register(COMMON_NS, 'zh', {})).toThrow('already has locale') + expect(() => (locale as LocaleService).register(COMMON_NS, 'en', {})).toThrow('already has locale') + }) +}) diff --git a/packages/client/locale/tests/language-row.spec.tsx b/packages/client/locale/tests/language-row.spec.tsx new file mode 100644 index 0000000000..af33038970 --- /dev/null +++ b/packages/client/locale/tests/language-row.spec.tsx @@ -0,0 +1,82 @@ +// @vitest-environment jsdom +/** LanguageRow behavior: selector pill shows the active locale, the menu + * opens/closes, and selection drives setLocale. */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { createSnapshotStore, type SessionListState, type WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import { LanguageRow } from '../src/client/LanguageRow.tsx' +import type { LanguageRowComponentProps } from '../src/client/LanguageRow.tsx' +import { createLanguageRowStore } from '../src/client/settings-store.ts' + +afterEach(cleanup) + +const OPTIONS = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }] + +/** Empty global standard-kit hooks (the row reads neither). */ +function emptySessions() { + const store = createSnapshotStore<SessionListState>( + { ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready' }) + return bindSnapshotSelector(store) +} +function emptyWorkspaces() { + const store = createSnapshotStore<WorkspaceListState>({ + items: [], intent: undefined, state: 'idle', phase: 'ready', error: null, + baselinesReady: true, recentWorkspaceId: undefined, + }) + return bindSnapshotSelector(store) +} + +function mount(active = 'en') { + // Real store instance — the sanctioned zero-machinery path for tests. + const store = createLanguageRowStore().create() + store.actions.sync(active, OPTIONS, 0) + const setLocale = vi.fn() + const props: LanguageRowComponentProps = { + useSessions: emptySessions(), + useWorkspaces: emptyWorkspaces(), + useStore: bindSnapshotSelector(store), + actions: store.actions, + t: (key: string) => key === 'language.title' ? 'Language' : key, + setLocale, + } + render(<LanguageRow {...props} />) + return { store, setLocale } +} + +describe('LanguageRow', () => { + it('shows the title and the active locale label on the selector pill', () => { + mount('en') + expect(screen.getByText('Language')).toBeDefined() + const trigger = screen.getByRole('button', { name: /English/ }) + expect(trigger.getAttribute('aria-expanded')).toBe('false') + }) + + it('opens the menu, selects a locale, and closes', () => { + const b = mount('en') + const trigger = screen.getByRole('button', { name: /English/ }) + fireEvent.click(trigger) + expect(trigger.getAttribute('aria-expanded')).toBe('true') + fireEvent.click(screen.getByRole('menuitem', { name: '中文' })) + expect(b.setLocale).toHaveBeenCalledWith('zh') + expect(trigger.getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByRole('menuitem', { name: '中文' })).toBeNull() + }) + + it('closes on outside pointerdown without selecting', () => { + const b = mount('en') + fireEvent.click(screen.getByRole('button', { name: /English/ })) + expect(screen.getByRole('menuitem', { name: '中文' })).toBeDefined() + fireEvent.pointerDown(document.body) + expect(screen.queryByRole('menuitem', { name: '中文' })).toBeNull() + expect(b.setLocale).not.toHaveBeenCalled() + }) + + it('follows store changes; an unknown active id falls back to the id itself', () => { + const b = mount('en') + act(() => { b.store.actions.sync('zh', OPTIONS, 1) }) + expect(screen.getByRole('button', { name: /中文/ })).toBeDefined() + act(() => { b.store.actions.sync('fr', OPTIONS, 2) }) + expect(screen.getByRole('button', { name: /fr/ })).toBeDefined() + }) +}) diff --git a/packages/client/locale/tests/locale.spec.ts b/packages/client/locale/tests/locale.spec.ts new file mode 100644 index 0000000000..3f9efaed19 --- /dev/null +++ b/packages/client/locale/tests/locale.spec.ts @@ -0,0 +1,102 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client' +import { LocaleService, STORAGE_KEY } from '@deepseek-ai/dsh-client-locale/client' + +const make = (): { ctx: Context; svc: LocaleService; events: LocaleSnapshot[] } => { + const ctx = new Context() + const events: LocaleSnapshot[] = [] + ctx.on('locale/change', (snapshot) => { events.push(snapshot) }) + return { ctx, svc: new LocaleService(ctx), events } +} + +describe('LocaleService', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('translates through the active-locale -> zh -> key chain', () => { + const { svc } = make() + svc.register('ns', 'zh', { hello: '你好', onlyZh: '仅中文' }) + svc.register('ns', 'en', { hello: 'Hello' }) + const t = svc.bind('ns') + expect(svc.getLocale().active).toBe('zh') + expect(t('hello')).toBe('你好') + svc.setLocale('en') + expect(t('hello')).toBe('Hello') + expect(t('onlyZh')).toBe('仅中文') + expect(t('missing.key')).toBe('missing.key') + }) + + it('interpolates {name} params and leaves unknown placeholders intact', () => { + const { svc } = make() + svc.register('ns', 'zh', { greet: '你好,{name}!第 {n} 次', partial: '{known} 与 {unknown}' }) + const t = svc.bind('ns') + expect(t('greet', { name: '世界', n: 2 })).toBe('你好,世界!第 2 次') + expect(t('partial', { known: 'A' })).toBe('A 与 {unknown}') + }) + + it('bind returns a stable per-namespace function identity', () => { + const { svc } = make() + expect(svc.bind('a')).toBe(svc.bind('a')) + expect(svc.bind('a')).not.toBe(svc.bind('b')) + }) + + it('rejects duplicate (ns, locale) and disposer only removes its own dict', () => { + const { svc } = make() + const dispose = svc.register('ns', 'zh', { k: 'v1' }) + expect(() => svc.register('ns', 'zh', { k: 'v2' })).toThrow('already has locale') + dispose() + const t = svc.bind('ns') + expect(t('k')).toBe('k') + svc.register('ns', 'zh', { k: 'v2' }) + expect(t('k')).toBe('v2') + dispose() + expect(t('k')).toBe('v2') + }) + + it('setLocale persists, republishes an immutable snapshot, and no-ops on same value', () => { + const { svc, events } = make() + svc.setLocale('en') + expect(svc.getLocale().active).toBe('en') + expect(localStorage.getItem(STORAGE_KEY)).toBe('en') + expect(events).toHaveLength(1) + expect(events[0]).toBe(svc.getLocale()) + expect(events[0]!.revision).toBe(1) + svc.setLocale('en') + expect(events).toHaveLength(1) + }) + + it('throws on unknown locale ids', () => { + const { svc } = make() + expect(() => { svc.setLocale('fr') }).toThrow('not registered') + }) + + it('restores a persisted locale and falls back to zh on garbage', () => { + localStorage.setItem(STORAGE_KEY, 'en') + expect(make().svc.getLocale().active).toBe('en') + localStorage.setItem(STORAGE_KEY, 'fr') + expect(make().svc.getLocale().active).toBe('zh') + }) + + it('runs without localStorage (node boots): defaults on read, no-op on write', () => { + vi.stubGlobal('localStorage', undefined) + try { + const { svc } = make() + expect(svc.getLocale().active).toBe('zh') + svc.setLocale('en') + expect(svc.getLocale().active).toBe('en') + } finally { + vi.unstubAllGlobals() + } + }) + + it('exposes the two shipped locales with self-described labels', () => { + const { svc } = make() + expect(svc.getLocale().locales).toEqual([ + { id: 'zh', label: '中文' }, + { id: 'en', label: 'English' }, + ]) + }) +}) diff --git a/packages/client/locale/tests/settings-store.spec.ts b/packages/client/locale/tests/settings-store.spec.ts new file mode 100644 index 0000000000..90e9487a96 --- /dev/null +++ b/packages/client/locale/tests/settings-store.spec.ts @@ -0,0 +1,30 @@ +/** Language row store: snapshot-mirror action and the revision guard. */ +import { describe, expect, it } from 'vitest' +import { createLanguageRowStore } from '../src/client/settings-store.ts' + +const OPTIONS = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }] + +describe('createLanguageRowStore', () => { + it('init shape: empty mirror with revision at -1', () => { + const store = createLanguageRowStore().create() + expect(store.getSnapshot()).toEqual({ active: '', options: [], revision: -1 }) + }) + + it('sync mirrors the snapshot and advances the revision', () => { + const store = createLanguageRowStore().create() + store.actions.sync('zh', OPTIONS, 0) + expect(store.getSnapshot()).toEqual({ active: 'zh', options: OPTIONS, revision: 0 }) + store.actions.sync('en', OPTIONS, 1) + expect(store.getSnapshot().active).toBe('en') + expect(store.getSnapshot().revision).toBe(1) + }) + + it('revision guard drops stale and duplicate writes', () => { + const store = createLanguageRowStore().create() + store.actions.sync('en', OPTIONS, 5) + store.actions.sync('zh', OPTIONS, 4) + store.actions.sync('zh', OPTIONS, 5) + expect(store.getSnapshot().active).toBe('en') + expect(store.getSnapshot().revision).toBe(5) + }) +}) diff --git a/packages/client/i18n/tsconfig.json b/packages/client/locale/tsconfig.json similarity index 79% rename from packages/client/i18n/tsconfig.json rename to packages/client/locale/tsconfig.json index 63eb9779ca..8585ba74ca 100644 --- a/packages/client/i18n/tsconfig.json +++ b/packages/client/locale/tsconfig.json @@ -9,10 +9,16 @@ ], "references": [ { - "path": "../../../vendor/cordis" + "path": "../runtime" }, { - "path": "../runtime" + "path": "../ui-primitives" + }, + { + "path": "../ui-slots" + }, + { + "path": "../../../vendor/cordis" }, { "path": "../../support/invariants" diff --git a/packages/client/locale/tsdown.config.ts b/packages/client/locale/tsdown.config.ts new file mode 100644 index 0000000000..2141970e5d --- /dev/null +++ b/packages/client/locale/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-locale', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/modules/README.i18n.yaml b/packages/client/modules/README.i18n.yaml new file mode 100644 index 0000000000..9175950a50 --- /dev/null +++ b/packages/client/modules/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: efba9e2eb0b148677fc7ac18bfad6333fb6f80da +README.zh.md: 7d1aa8af08256c47c1ae65343e46c30e910128d0 diff --git a/packages/client/modules/README.md b/packages/client/modules/README.md index 234b8406e6..efba9e2eb0 100644 --- a/packages/client/modules/README.md +++ b/packages/client/modules/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-client-modules +English | [中文](README.zh.md) + Client module system: the browser peer of Node's internal ESM loader, built as a lazy CJS table. The web shell mounts the vendored cordis Loader for entry governance (fiber lifecycle, inject waiting, update/refresh) and injects this package's `ClientModuleLoader` as its `internal` seam — the vendored side's only consumption point is `EntryTree.import`, so replacing `internal` replaces exactly "how plugin code arrives" and nothing else. Lazy CJS model (web2): executing a plugin bundle only REGISTERS its factory (`window.__ModuleLoader__.load({id, factory})`); every module body side effect — CSS injection included — lives in the factory closure and runs at materialization (`factory(require)` → export surface, memoized in `loadCache`), not at script execution. A factory that requires another registered-but-unmaterialized module materializes it recursively, so load order needs no external sequencing; require cycles throw (factory-form CJS cannot deliver partial exports). `<id>/client` and the bare id name the same surface (a plugin bundle IS its package's client half). diff --git a/packages/client/modules/README.zh.md b/packages/client/modules/README.zh.md new file mode 100644 index 0000000000..7d1aa8af08 --- /dev/null +++ b/packages/client/modules/README.zh.md @@ -0,0 +1,22 @@ +# @deepseek-ai/dsh-client-modules + +[English](README.md) | 中文 + +客户端模块系统:Node 内部 ESM loader 的浏览器端对等实现,以惰性 CJS 表构建。web 外壳挂载 vendored cordis Loader 来治理配置项(fiber 生命周期、inject 等待、update/refresh),并把该包的 `ClientModuleLoader` 作为其 `internal` seam 注入;vendored 一侧唯一的消费点是 `EntryTree.import`,因此替换 `internal` 恰好只会替换「插件代码如何到达」,不会改变其他内容。 + +惰性 CJS 模型(web2):执行插件组合包只会注册其 factory(`window.__ModuleLoader__.load({id, factory})`);每个模块主体的副作用(包括 CSS 注入)都位于 factory 闭包中,在物化时运行(`factory(require)` → 导出表层,并在 `loadCache` 中记忆化),不会在脚本执行时运行。如果 factory 请求另一个已注册但尚未物化的模块,系统会递归物化它,因此加载顺序无需外部编排;require 循环会抛出异常(factory 形式的 CJS 无法交付部分导出)。`<id>/client` 与裸 id 指向同一表层(一个插件组合包就是其包的客户端侧)。 + +解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 表层;外壳自身的静态注册表(`registerStatic`,app-shell)→ 模块;已注册 factory → 物化;图行(`window.__DSH_BOOT__`)→ 抓取 + 执行 + 物化;其他情况一律抛出异常。这是构建时组合包纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含抓取分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段到达 hook(抓取 + 执行,只注册;并发调用共享一个进行中的 task);`invalidate` 会丢弃 factory 与物化记录,使下一次 prefetch/import 重新抓取(HMR hook)。 + +## 模型体验 + +无。模块 loader 属于浏览器侧内核机制;这里没有任何内容进入模型请求。 + +#### KV Cache 影响 + +无;该包既不组装也不发送提供方请求。 + +## 已知限制与暂缓事项 + +- **有意采用扁平模块图**:每个组合包是一个模块节点,其边只指向表叶;接口(loadCache/edges/invalidate)按通用模块图塑形,因此可以改变 externalization 粒度而不更改接口。 +- **自身不记录卸载账目**:样式移除与 fiber 拆卸顺序属于 HMR 驱动器(`@deepseek-ai/dsh-client-hmr`);loader 只逐记录清点自身拥有的样式标签 id。 diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml new file mode 100644 index 0000000000..6ad7ad4e1f --- /dev/null +++ b/packages/client/runtime/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 7776a5c2cf1d0990c9c339c6e5fc66401f935810 +README.zh.md: 8a0b7394c07878b8de958eae43d11203c92b5827 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 2768a8a58d..7776a5c2cf 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-client-runtime +English | [中文](README.zh.md) + Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state, and page-local Session Intent state; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, page-local Workspace Intent state, default-target derivation, and the cross-object New Session flow. The runtime fans the shared Host stream into both managers. Contract: api-contracts v3 §4. ## Workspace and Session lists diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md new file mode 100644 index 0000000000..8a0b7394c0 --- /dev/null +++ b/packages/client/runtime/README.zh.md @@ -0,0 +1,37 @@ +# @deepseek-ai/dsh-client-runtime + +[English](README.md) | 中文 + +客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象、列表/scope/history 状态和页面局部 Session Intent 状态;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、页面局部 Workspace Intent 状态、默认目标派生,以及跨对象 New Session 流程。运行时把共享 Host 流分发给两个 manager。契约:api-contracts v3 §4。 + +## Workspace 与 Session 列表 + +Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量帧会在其响应之上回放。第一次成功的基线建立 Host 顺序;后续刷新更新行和成员关系,但不改变已经显示的标识之间的相对顺序。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。 + +SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;web-react 创建 hook。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。 + +## Session 创建失败 + +`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId。失败时抛出 `SessionCreateError`:传输状态不确定后仍可取得 `requestedSessionId`;如果 Host 在附加失败前已经发布真实 Session,则会设置 `publishedSessionId`,此时 `workspace-attach-failed` 提供了证明。在 New Session 流程中,前端 Session 对象拥有其保留的提示词,并推动提示词完成附加与发送;部分发布的 Session 会保留同一对象和提示词,同时显示为 Ungrouped。 + +## Code Mode 子调用索引 + +`ConversationSnapshot.codeDispatches` 按父调用的 callId 和启动顺序,用原生调用块形状组织一个 `run_code` 调用的子调用:`tool/code-dispatch-start` 事件落成 `RunningToolCall` 形状(行组件从该形状推导运行中的转圈状态),其 `tool/code-dispatch` 完结事件原位替换为 `ToolResultNode` 形状,`callTime` 携带成对 start 事件的时间。start 落在回放窗口之外的完结事件则直接追加,`callTime: null`(耗时未知——绝不伪造零耗时)。live mux 帧与历史回放构建相同的索引;子调用永不进入 surface `nodes` 流;无关快照交换不会改变每个父调用对应的数组引用和映射引用,两者均保持 memo 稳定。 + +## Session 标题投影 + +`SessionManager` 独立于列表和 Session 实例到达情况,保留最近一次通过验证的 `session/title` 控制快照。seq 更新的事件会替换旧快照,标题时间戳计入列表新近程度;订阅基线会先丢弃 seq 超过其 `lastSeq` 的任何已保留标题,再接收可选的折叠标题。显式移除 Session 也会清除已保留标题。因此,面向客户端的 `SessionSummary.title` 只包含真实的持久标题;`displayTitle` 始终存在,并依次回退到 cwd basename 和 Session id。冷启动的持久会话会保持该回退值,直到打开或恢复会话,促使主机折叠并投影日志支持的标题。 + +## 模型体验 + +无。客户端运行时承载浏览器侧服务与 Session 对象层;这里没有任何内容进入模型请求。 + +#### KV Cache 影响 + +无;该包既不组装也不发送提供方请求。 + +## 已知限制与暂缓事项 + +- **`loader.unload` 是 stub(抛出 not-implemented)**:完整链路(fiber 释放 → 注册级联 → 样式移除)随 HMR 项目落地。 +- **scope 拆卸由阶段驱动,目前只能有一个占用者**:已 staged 的 Session 精确跟随 `list.current`(staging 就是打开信号:事件窗口打开 ⟺ Session 位于 stage);在 staged 状态下被移除的 Session,其 scope 会冻结保留,直到 stage 转向其他 Session,而非直到真实观察者数量降为零。解析(`cell()`/`binding()`/`scope()`)只是纯寻址,可安全用于渲染。并发 pane 落地时,staged 状态可以扩展为多 pane 列表。 +- **插件组合包从该包执行值导入时必须使用 `/client` 子路径**:裸包名不在 loader external 表中,会内联第二个模块实例;其私有 scope-tag Symbol 永远无法匹配(空状态 P0 事故复盘)。 diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 907fc961f6..51d07e70e6 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -162,7 +162,14 @@ export class SessionManager { * @param text - exact controlled-input value for the active frontend Session. */ updateIntent(text: string): void { - this.getIntent()?.updatePendingPrompt(text) + const session = this.getIntent() + if (session === undefined) return + session.updatePendingPrompt(text) + // The intent watch defers via markDirty, but the hero composer reads this + // prompt from the LIST snapshot as a controlled value: it must flush in + // the same tick as onChange (see Notifier.notifyNow) or React rolls the + // textarea back and IME composition breaks. + this.notifier.notifyNow() } private discardIntent(): void { diff --git a/packages/client/runtime/src/client/workspaces/manager.ts b/packages/client/runtime/src/client/workspaces/manager.ts index 6db4e54c79..c512694816 100644 --- a/packages/client/runtime/src/client/workspaces/manager.ts +++ b/packages/client/runtime/src/client/workspaces/manager.ts @@ -1,7 +1,7 @@ /** Workspace baseline, incremental-frame, and unary-action owner. */ import type { - HostFrame, IApiClient, RpcError, RpcRequest, RpcResult, WorkspaceView, + HostFrame, IApiClient, RpcError, RpcRequest, RpcResult, SessionId, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-connection/client' import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' import { mergeOrderedBaseline } from '../ordered-baseline.ts' @@ -143,6 +143,40 @@ export class WorkspaceManager { return result } + /** + * Rename a Workspace, then publish its returned snapshot without waiting + * for the changed frame. + * @param workspaceId - target workspace. + * @param title - new display title. + * @returns the wire result. + */ + async rename(workspaceId: WorkspaceId, title: string): Promise<RpcResult<{ workspace: WorkspaceView }>> { + const { result } = await this.api.workspace.rename({ workspaceId, title }) + if (result.ok) this.upsert(result.value.workspace) + return result + } + + /** + * Move a session within its Workspace's manual order, then publish the + * returned snapshot without waiting for the changed frame. + * @param workspaceId - owning workspace. + * @param sessionId - accounted session to move. + * @param beforeSessionId - accounted anchor to insert before; omitted appends. + * @returns the wire result. + */ + async insertSessionBefore( + workspaceId: WorkspaceId, + sessionId: SessionId, + beforeSessionId?: SessionId, + ): Promise<RpcResult<{ workspace: WorkspaceView }>> { + const { result } = await this.api.workspace.insertSessionBefore({ + workspaceId, sessionId, + ...beforeSessionId === undefined ? {} : { beforeSessionId }, + }) + if (result.ok) this.upsert(result.value.workspace) + return result + } + /** * Host-frame entry. Non-workspace frames are ignored so the runtime can * fan one host stream out to both object managers. @@ -189,6 +223,11 @@ export class WorkspaceManager { private upsert(view: WorkspaceView, identity?: Workspace): void { this.refreshFrames?.push(view) const index = this.items.findIndex(item => item.getSnapshot().view?.workspaceId === view.workspaceId) + // Mutation responses and changed frames race (two carriers, no ordering): + // reject a snapshot strictly older than the installed projection so a + // late unary response cannot roll back a newer frame. + const installed = index === -1 ? undefined : this.items[index]?.getSnapshot().view + if (installed !== undefined && Date.parse(view.updatedAt) < Date.parse(installed.updatedAt)) return if (identity !== undefined) { this.items = index === -1 ? [identity, ...this.items] diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index 854c53a75f..9768a3fac2 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -2,7 +2,7 @@ import type { Context } from 'cordis' import type { - IApiClient, RpcError, WorkspaceId, WorkspaceView, + IApiClient, RpcError, SessionId, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-connection/client' import type { SnapshotStore } from '../contract/store.ts' import { createSnapshotStore } from '../contract/store.ts' @@ -100,6 +100,35 @@ export class WorkspacesService { return result.value.workspace } + /** + * Rename a Workspace. + * @param workspaceId - target workspace. + * @param title - new display title (trimmed non-empty by the Host). + * @returns the renamed Workspace view. + */ + async rename(workspaceId: WorkspaceId, title: string): Promise<WorkspaceView> { + const result = await this.manager.rename(workspaceId, title) + if (!result.ok) throw new Error(`workspace rename failed: ${result.error.code}: ${result.error.message}`) + return result.value.workspace + } + + /** + * Move a session within its Workspace's manual order (DOM-insertBefore-like). + * @param workspaceId - owning workspace. + * @param sessionId - accounted session to move. + * @param beforeSessionId - accounted anchor to insert before; omitted appends. + * @returns the updated Workspace view. + */ + async insertSessionBefore( + workspaceId: WorkspaceId, + sessionId: SessionId, + beforeSessionId?: SessionId, + ): Promise<WorkspaceView> { + const result = await this.manager.insertSessionBefore(workspaceId, sessionId, beforeSessionId) + if (!result.ok) throw new Error(`workspace move failed: ${result.error.code}: ${result.error.message}`) + return result.value.workspace + } + /** * Refresh the workspace baseline, reusing an in-flight pull. * @returns completion of the current or newly started workspace baseline pull. diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index 45efcf9e36..a9fbda4907 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -92,9 +92,18 @@ export class FakeApiClient implements IApiClient { onWorkspaceCreate: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView; created: boolean }>> = () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws'), created: true })) + onWorkspaceRename: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView }>> = + () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') })) + + onWorkspaceInsertSessionBefore: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView }>> = + () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') })) + readonly workspace: IApiClient['workspace'] = { list: (payload: unknown) => this.record('workspace.list', payload, this.onWorkspaceList(payload)), create: (payload: unknown) => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)), + rename: (payload: unknown) => this.record('workspace.rename', payload, this.onWorkspaceRename(payload)), + insertSessionBefore: (payload: unknown) => + this.record('workspace.insertSessionBefore', payload, this.onWorkspaceInsertSessionBefore(payload)), } /** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */ diff --git a/packages/client/runtime/tests/session-intents.spec.ts b/packages/client/runtime/tests/session-intents.spec.ts index c09bfa4ee4..fca2c3e0b4 100644 --- a/packages/client/runtime/tests/session-intents.spec.ts +++ b/packages/client/runtime/tests/session-intents.spec.ts @@ -60,6 +60,34 @@ describe('frontend Session and Workspace intents', () => { expect(workspaces.list.getSnapshot().intent).toBeUndefined() }) + it('echoes updateIntent into the list snapshot in the same tick (controlled-input contract)', async () => { + const api = new FakeApiClient() + const { sessions, workspaces } = services(api) + await ready(api, workspaces, sessions, [workspace('target')]) + let notified = 0 + sessions.list.subscribe(() => { notified += 1 }) + // IME composition drives change events that a controlled textarea must see + // reflected before the handler returns; a microtask-deferred echo makes + // React roll the DOM back and the composition commits partial keystrokes. + sessions.updateIntent('你') + expect(sessions.list.getSnapshot().intent?.prompt).toBe('你') + expect(notified).toBeGreaterThan(0) + }) + + it('ignores updateIntent with no active Intent', async () => { + const api = new FakeApiClient() + const { sessions, workspaces } = services(api) + await ready(api, workspaces, sessions, [workspace('only', [sid('s-real')])], [ + { sessionId: sid('s-real'), updatedAt: 1, running: false }, + ]) + sessions.open(sid('s-real')) + expect(sessions.list.getSnapshot().intent).toBeUndefined() + let notified = 0 + sessions.list.subscribe(() => { notified += 1 }) + sessions.updateIntent('dropped') + expect(notified).toBe(0) + }) + it('materializes zero-state Workspace and Session intents and retains a rejected first prompt', async () => { const api = new FakeApiClient() const { sessions, workspaces } = services(api) diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 56bdd29320..9b93feae8b 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -33,7 +33,7 @@ export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools| * Documented TEMPORARY exemption, not a platform module (hence not in * platform.ts): the snapshot-store engine (createSnapshotStore/defineStore/ * shallowEqual) lives in runtime pending its promotion-time rehoming, and - * five importers (i18n, ui-layout, ui-conversation ×3) ride this single + * five importers (locale, ui-layout, ui-conversation ×3) ride this single * exemption. At runtime the lazy CJS table answers the require natively: * runtime is an immediately-tier row, its factory is registered before any * dependent bundle materializes. TODO(webload/store-rehome): remove with the diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml new file mode 100644 index 0000000000..8117d42ea4 --- /dev/null +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: b9ec555f158722ea1f41e01c4b3f7131d3fe3467 +README.zh.md: b1e3c1f4331148ebf1c58b4bcd4869270bb44311 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index ac0f33eba5..b9ec555f15 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-client-ui-conversation +English | [中文](README.zh.md) + Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, stats line, per-tool row slot with a bash sample registrant), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares). The no-session hero renders the frontend Session Intent from the Session list projection, including its frontend Workspace Intent when no real Workspace exists. It declares `conversation.empty.workspace`, where ui-workspace registers the same picker used by the sidebar. WorkspacesService starts the cross-object flow; each Workspace or Session object owns its own materialization. The Session keeps its identity across publication and retains any prompt that still needs connection or delivery; ConversationRoot reads that `pendingPrompt` from `useSession` and edits or retries it through the scoped ConversationService. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md new file mode 100644 index 0000000000..b1e3c1f433 --- /dev/null +++ b/packages/client/ui-conversation/README.zh.md @@ -0,0 +1,33 @@ +# @deepseek-ai/dsh-client-ui-conversation + +[English](README.md) | 中文 + +会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、统计行、逐工具行 slot 及一个 bash 示例注册方)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。 + +无会话主视觉区会渲染来自 Session 列表投影的前端 Session Intent;没有真实 Workspace 时,还会包含其前端 Workspace Intent。它声明 `conversation.empty.workspace`,ui-workspace 会在此注册侧边栏所用的同一选择器。WorkspacesService 启动跨对象流程;每个 Workspace 或 Session 对象拥有自身的物化。Session 在发布期间保持身份,并保留任何仍需连接或交付的提示词;ConversationRoot 读取该 `pendingPrompt`,其来源是 `useSession`,再通过 scope 内的 ConversationService 编辑或重试。 + +视图环本身就是 slot:会话注册声明 `'conversation.view'` 列表 slot(Session scope),并将其列在 `children` 表中;ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: <active id>`);视图标签页从环账本的注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包自身的环配置项;其他插件(ui-trajectory)通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView`/`ViewEntry`/`ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。 + +通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和 `Write · <path>` 或 `Edit · <path>` 摘要,同时保留共享的行到详情交互。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行),details 面板则会根据选中的子调用 id 解析出其完整记录的参数与完整输出。 + +工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。 + +逐 Session UI 状态(选择、普通编辑器草稿、活跃视图)位于已声明的聊天 store(`stores.ts` `createChatStore`)中:apply 构造一个 handle,并将其传给会话、聊天视图和详情注册,因此 Session slot 每个 Session 共享一个实例(选择由聊天视图写入、详情读取),框架拥有实例生命周期与草稿持久化。前端 Session Intent 来自 Session 列表投影;发布后,任何保留的提示词都来自该 Session 的会话快照。组件保持纯粹:框架标准工具包(Session scope 下的 `useSession`/`sessionId`,以及全局 `useSessions`/`useWorkspaces`)和 store 表层(`useStore`/`actions`)会从注册声明自动到达;inject factory 为运行时 Session 操作、发送/停止、标签页、详情和分页贡献普通数据与回调。 + +`src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props,包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。 + +## 模型体验 + +无。会话 UI 在浏览器中渲染会话历史与流;这里没有任何内容进入模型请求。 + +#### KV Cache 影响 + +无;该包既不组装也不发送提供方请求。 + +## 已知限制与暂缓事项 + +- **统计行没有耗时区段**:assistant `usage` 只携带 token 计数;耗时需要主机数据源。 +- **详情面板是最小形态**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。 +- **assistant footer 扩展(IconActions 行、逐消息分页)是预留 slot**:设计中已有图稿,尚未实现。 +- **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。 +- **审批卡片只是只读占位符**:问题请求通过编辑器链回答(ui-question),Web 侧审批回答属于 P-II 审批项目。 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index d7306a6810..95fde30b5a 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -24,7 +24,7 @@ }, "dshClient": { "inject": [ - "@deepseek-ai/dsh-client-i18n", + "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-layout" ], diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 21fe70499e..b040e847e0 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -99,7 +99,7 @@ async function bench() { ctx.provide('workspaces', workspacesFake) const layoutFake = { openDetails: vi.fn(), closeDetails: vi.fn() } ctx.provide('layout', layoutFake) - ctx.provide('i18n', { bind: () => (key: string) => key }) + ctx.provide('locale', { bind: () => (key: string) => key }) // The AppFrame role: the three conversation-package slots must be declared // by a live entry before apply can contribute into them (the stand-in diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index dcfe2d7de7..31b251843a 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -48,7 +48,7 @@ async function bench() { sendSession: vi.fn(), }) ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) - ctx.provide('i18n', { bind: () => (key: string) => key }) + ctx.provide('locale', { bind: () => (key: string) => key }) // Declared by ui-layout's root entry in production; a stand-in root // occupant declares them here so the contributions land (it consumes diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index 579ee3fa92..b636a227e2 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -93,7 +93,7 @@ async function bench(nodes: ToolResultNode[]) { sendSession: vi.fn(), }) ctx.provide('layout', layout) - ctx.provide('i18n', { bind: () => (key: string) => key }) + ctx.provide('locale', { bind: () => (key: string) => key }) slots.install(createSlotRenderer()) slots.register({ @@ -212,7 +212,7 @@ describe('registrant load-order seam', () => { sendSession: vi.fn(), }) ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) - ctx.provide('i18n', { bind: () => (key: string) => key }) + ctx.provide('locale', { bind: () => (key: string) => key }) slots.register({ name: 'root', children: { diff --git a/packages/client/ui-conversation/tsconfig.json b/packages/client/ui-conversation/tsconfig.json index 7755337093..902c1c7f9f 100644 --- a/packages/client/ui-conversation/tsconfig.json +++ b/packages/client/ui-conversation/tsconfig.json @@ -27,7 +27,7 @@ "path": "../ui-layout" }, { - "path": "../i18n" + "path": "../locale" }, { "path": "../../support/invariants" diff --git a/packages/client/ui-layout/README.i18n.yaml b/packages/client/ui-layout/README.i18n.yaml new file mode 100644 index 0000000000..737791ba66 --- /dev/null +++ b/packages/client/ui-layout/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 6aeda0e04bf690f5cc8a6d52eea95b1b2782a143 +README.zh.md: 9fbc2ed8392ae3ceba453517d9af5ef41f039d2d diff --git a/packages/client/ui-layout/README.md b/packages/client/ui-layout/README.md index 4a9f5e9bd5..6aeda0e04b 100644 --- a/packages/client/ui-layout/README.md +++ b/packages/client/ui-layout/README.md @@ -1,6 +1,8 @@ # @deepseek-ai/dsh-client-ui-layout -Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar is fixed-width (only details shrinks, then auto-closes); a closed sidebar retains a 56px control rail while details closes to zero width. +English | [中文](README.zh.md) + +Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar is fixed-width (only details shrinks, then auto-closes); a closed sidebar retains a 56px control rail while details closes to zero width. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto `document.body` (`data-ds-dark-theme` from the active color scheme plus the theme's alias tokens as inline variables). AppFrame reads the runtime Session projection: `baselinesReady` selects loading, a page-local `SessionListState.intent` selects the empty composer, and a connected Session renders through `SessionProvider`. The conversation and empty-state owner shares are empty; each registrant obtains business data from standard hooks and actions from its own inject face. The sidebar owner share contains only `collapsed` and `width`; navigation actions belong to sidebar's own injected service face. diff --git a/packages/client/ui-layout/README.zh.md b/packages/client/ui-layout/README.zh.md new file mode 100644 index 0000000000..9fbc2ed839 --- /dev/null +++ b/packages/client/ui-layout/README.zh.md @@ -0,0 +1,23 @@ +# @deepseek-ai/dsh-client-ui-layout + +[English](README.md) | 中文 + +外壳插件:三栏 AppFrame(拖动手柄与让步链)加 `ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot,并声明 `sidebar`、`conversation`、`details` 和 `conversation.empty`。侧边栏宽度固定(只会收缩详情栏,然后将其自动关闭);关闭的侧边栏仍保留 56px 控制轨道,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 `document.body`(依据当前配色方案设置 `data-ds-dark-theme`,并将主题的别名 token 设为内联变量)。 + +AppFrame 读取运行时 Session 投影:`baselinesReady` 选择加载状态,页面局部的 `SessionListState.intent` 选择空白编辑器,已连接 Session 则通过 `SessionProvider` 渲染。会话及空状态的 owner share 为空;每个注册方通过标准 hook 获取业务数据,并从自身的 inject 表层获取操作。侧边栏 owner share 只包含 `collapsed` 和 `width`;导航操作属于侧边栏自身注入的服务表层。 + +`/client` 导出表层包含插件主体(`apply`/`inject`)、`LayoutService` 和四个 owner-share 接口。AppFrame、面板 store 与让步求解器仍属于包内部;测试通过 `/src` 导入内部实现。 + +## 模型体验 + +无。布局外壳管理浏览器查看状态;这里没有任何内容进入模型请求。 + +#### KV Cache 影响 + +无;该包既不组装也不发送提供方请求。 + +## 已知限制与暂缓事项 + +- **详情栏打开/宽度状态是全局状态**:它不会随会话变化(P-I 已裁定);为逐会话键控升级预留了 slot。 +- **让步链自动关闭通过推导零宽度实现,不会改动持久化的打开标志**:窗口变宽时面板会自行恢复;消费方禁止把 `details.open` 当作实际渲染状态。 +- **挤压重排期间尚未实现滚动锚定**:与虚拟化列表项目一并暂缓。 diff --git a/packages/client/ui-layout/package.json b/packages/client/ui-layout/package.json index fd3e691375..b816533474 100644 --- a/packages/client/ui-layout/package.json +++ b/packages/client/ui-layout/package.json @@ -24,7 +24,8 @@ }, "dshClient": { "inject": [ - "@deepseek-ai/dsh-client-runtime" + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-theme" ], "platform": "web" }, @@ -36,13 +37,16 @@ "peerDependencies": { "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", + "@deepseek-ai/dsh-client-ui-theme": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7", "react": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-ui-theme": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "cordis": "^4.0.0-rc.7", diff --git a/packages/client/ui-layout/src/client/index.ts b/packages/client/ui-layout/src/client/index.ts index 6ecb6f6b1d..7474cd69c3 100644 --- a/packages/client/ui-layout/src/client/index.ts +++ b/packages/client/ui-layout/src/client/index.ts @@ -4,13 +4,16 @@ * four child slots (declaration = exclusive render authority), seats the * layout store (panel geometry), and wires the panel-action service face. * ctx.layout is the cross-plugin panel-action seam; navigation state lives - * with the runtime sessions service. + * with the runtime sessions service. A second effect seats the theme + * presenter, which projects ctx.theme snapshots onto document.body. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type {} from '@deepseek-ai/dsh-client-ui-theme/client' import type { PanelActions } from './service.ts' import { AppFrame } from './AppFrame.tsx' import { createLayoutStore } from './stores.ts' import { LayoutService } from './service.ts' +import { ThemePresenter } from './theme-presenter.ts' // Contract surface only (export-convergence rule: cross-package consumers // keep a symbol exported; test-only/package-internal symbols live off /src). @@ -62,7 +65,7 @@ export interface DetailsOwnerProps {} export interface EmptyOwnerProps { children?: never } /** Required services (cordis fiber inject — the loader passes the whole export surface as an object plugin). */ -export const inject = ['slots'] +export const inject = ['slots', 'theme'] /** * Client plugin body: provide ctx.layout, then one register() call — AppFrame @@ -98,4 +101,16 @@ export function apply(ctx: ClientContext): void { void disposeService() } }, 'ui-layout: service + root registration') + + // Theme presentation: pure DOM writes from resolved snapshots — initial + // state through the getter once, then event-driven only; no React path. + ctx.effect(() => { + const presenter = new ThemePresenter() + presenter.apply(ctx.theme.getTheme()) + const off = ctx.on('theme/change', (snapshot) => { presenter.apply(snapshot) }) + return () => { + off() + presenter.dispose() + } + }, 'ui-layout: theme presenter') } diff --git a/packages/client/ui-layout/src/client/theme-presenter.ts b/packages/client/ui-layout/src/client/theme-presenter.ts new file mode 100644 index 0000000000..958f2fd93e --- /dev/null +++ b/packages/client/ui-layout/src/client/theme-presenter.ts @@ -0,0 +1,43 @@ +/** + * Global theme DOM applier: projects the resolved ThemeSnapshot onto + * document.body — the `data-ds-dark-theme` palette switch plus the active + * theme's alias-token overrides as inline CSS variables. Pure DOM writes, no + * React involvement; the presenter only ever retracts what it wrote itself, + * so foreign body attributes and inline styles survive apply/dispose. + */ +import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client' + +/** Body attribute selecting the dark base palette in the token stylesheets. */ +export const DARK_ATTRIBUTE = 'data-ds-dark-theme' + +/** Applies theme snapshots to document.body; one instance per plugin fiber. */ +export class ThemePresenter { + /** Token names this presenter wrote in the last apply (its retraction set). */ + private appliedTokens: string[] = [] + + /** + * Project a snapshot onto the body: switch the palette attribute from + * `active.colorScheme` (never the id — `system` is resolved upstream) and + * replace the previously applied token variables with `active.tokens`. + * @param snapshot - resolved theme snapshot from ctx.theme. + */ + apply(snapshot: ThemeSnapshot): void { + const body = document.body + if (snapshot.active.colorScheme === 'dark') body.setAttribute(DARK_ATTRIBUTE, '') + else body.removeAttribute(DARK_ATTRIBUTE) + for (const name of this.appliedTokens) body.style.removeProperty(name) + this.appliedTokens = [] + for (const [name, value] of Object.entries(snapshot.active.tokens)) { + body.style.setProperty(name, value) + this.appliedTokens.push(name) + } + } + + /** Retract everything this presenter wrote: the palette attribute and all applied token variables. */ + dispose(): void { + const body = document.body + body.removeAttribute(DARK_ATTRIBUTE) + for (const name of this.appliedTokens) body.style.removeProperty(name) + this.appliedTokens = [] + } +} diff --git a/packages/client/ui-layout/tests/apply.spec.ts b/packages/client/ui-layout/tests/apply.spec.ts index bed0987f62..910b5a3132 100644 --- a/packages/client/ui-layout/tests/apply.spec.ts +++ b/packages/client/ui-layout/tests/apply.spec.ts @@ -9,6 +9,8 @@ import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' +import { apply as themeApply, inject as themeInject, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' import { apply, inject, LayoutService } from '@deepseek-ai/dsh-client-ui-layout/client' import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-layout' import * as invariant from '@deepseek-ai/dsh-client-ui-layout/invariant' @@ -16,13 +18,17 @@ import * as invariant from '@deepseek-ai/dsh-client-ui-layout/invariant' async function bench() { const ctx = new Context() const slotsFiber = ctx.plugin(SlotsService) + // Theme now injects ['slots', 'locale'] (it registers its Appearance + // settings row); seat a real locale service so the theme fiber activates. + ctx.provide('locale', new LocaleService(ctx)) + await ctx.plugin({ inject: themeInject, apply: themeApply }).await() await slotsFiber.await() return { ctx, slots: ctx.get('slots') as SlotsService } } describe('ui-layout client apply', () => { it('declares its service dependencies', () => { - expect(inject).toEqual(['slots']) + expect(inject).toEqual(['slots', 'theme']) }) it('provides ctx.layout and registers AppFrame into root with the four child declarations', async () => { @@ -53,6 +59,23 @@ describe('ui-layout client apply', () => { expect(actions.toggleSidebar).toHaveBeenCalledOnce() }) + it('theme presenter applies the initial snapshot, follows theme/change, and unwinds on dispose', async () => { + const { ctx } = await bench() + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + // Initial getter application: jsdom has no matchMedia, system resolves light. + expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false) + const theme = ctx.get('theme') as ThemeService + theme.setTheme('dark') + expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(true) + await fiber.dispose() + expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false) + // Listener is off: further theme changes no longer reach the body. + theme.setTheme('light') + theme.setTheme('dark') + expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false) + }) + it('teardown unwinds the service, the root registration, and the child declarations', async () => { const { ctx, slots } = await bench() const fiber = ctx.plugin({ inject: [...inject], apply }) diff --git a/packages/client/ui-layout/tests/theme-presenter.spec.ts b/packages/client/ui-layout/tests/theme-presenter.spec.ts new file mode 100644 index 0000000000..ced83a379e --- /dev/null +++ b/packages/client/ui-layout/tests/theme-presenter.spec.ts @@ -0,0 +1,56 @@ +// @vitest-environment jsdom +// ThemePresenter behavior account: the palette attribute follows +// active.colorScheme only, token variables replace the previous apply's set, +// and dispose retracts everything the presenter wrote. + +import { beforeEach, describe, expect, it } from 'vitest' +import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client' +import { DARK_ATTRIBUTE, ThemePresenter } from '@deepseek-ai/dsh-client-ui-layout/src/client/theme-presenter.ts' + +function snapshot(colorScheme: 'light' | 'dark', tokens: Record<string, string> = {}): ThemeSnapshot { + // The presenter must key off colorScheme, not the id — keep them distinct. + const active = { id: `${colorScheme}-test`, colorScheme, tokens } + return { preference: colorScheme, active, themes: [active], revision: 1 } +} + +beforeEach(() => { + document.body.removeAttribute(DARK_ATTRIBUTE) + document.body.removeAttribute('style') +}) + +describe('ThemePresenter', () => { + it('light scheme leaves the dark attribute absent', () => { + const presenter = new ThemePresenter() + presenter.apply(snapshot('light')) + expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false) + }) + + it('dark scheme sets the attribute; switching back to light removes it', () => { + const presenter = new ThemePresenter() + presenter.apply(snapshot('dark')) + expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(true) + presenter.apply(snapshot('light')) + expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false) + }) + + it('applies tokens as inline variables and clears the previous set on theme change', () => { + const presenter = new ThemePresenter() + presenter.apply(snapshot('dark', { '--dsw-alias-bg': '#111', '--dsw-alias-fg': '#eee' })) + expect(document.body.style.getPropertyValue('--dsw-alias-bg')).toBe('#111') + expect(document.body.style.getPropertyValue('--dsw-alias-fg')).toBe('#eee') + presenter.apply(snapshot('light', { '--dsw-alias-bg': '#fff' })) + expect(document.body.style.getPropertyValue('--dsw-alias-bg')).toBe('#fff') + // The old theme's extra variable is gone, not merged. + expect(document.body.style.getPropertyValue('--dsw-alias-fg')).toBe('') + }) + + it('dispose removes the attribute and every applied variable, sparing foreign inline styles', () => { + document.body.style.setProperty('--foreign', 'kept') + const presenter = new ThemePresenter() + presenter.apply(snapshot('dark', { '--dsw-alias-bg': '#111' })) + presenter.dispose() + expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false) + expect(document.body.style.getPropertyValue('--dsw-alias-bg')).toBe('') + expect(document.body.style.getPropertyValue('--foreign')).toBe('kept') + }) +}) diff --git a/packages/client/ui-layout/tsconfig.json b/packages/client/ui-layout/tsconfig.json index c82482294b..731eec8012 100644 --- a/packages/client/ui-layout/tsconfig.json +++ b/packages/client/ui-layout/tsconfig.json @@ -11,9 +11,15 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../locale" + }, { "path": "../ui-slots" }, + { + "path": "../ui-theme" + }, { "path": "../ui-primitives" }, diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml new file mode 100644 index 0000000000..4ecb711730 --- /dev/null +++ b/packages/client/ui-models/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 13f51d5338affd65d0705cec6a3b4ef78a534f0f +README.zh.md: 466505beb27c729246afe04e6378235b91d072cf diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md new file mode 100644 index 0000000000..13f51d5338 --- /dev/null +++ b/packages/client/ui-models/README.md @@ -0,0 +1,17 @@ +# @deepseek-ai/dsh-client-ui-models + +English | [中文](README.zh.md) + +Models settings section plugin: registers the `models` nav entry into `settings.section` with an intentionally empty content column — model management lands in a later phase. + +## Model Experience + +None, as the section renders an empty browser UI column; nothing here reaches a model request. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **Content column is empty by design** — provider list, editing form, and activation flow are deferred until the model-management service exists. diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md new file mode 100644 index 0000000000..466505beb2 --- /dev/null +++ b/packages/client/ui-models/README.zh.md @@ -0,0 +1,17 @@ +# @deepseek-ai/dsh-client-ui-models + +[English](README.md) | 中文 + +模型设置分区插件:注册 `models` 导航项,使其进入 `settings.section`;内容栏有意留空,模型管理将在后续阶段实现。 + +## 模型体验 + +无。该分区渲染空白的浏览器 UI 内容栏;这里没有任何内容进入模型请求。 + +#### KV Cache 影响 + +无;该包既不组装也不发送提供方请求。 + +## 已知限制与暂缓事项 + +- **内容栏按设计留空**:提供方列表、编辑表单和激活流程均暂缓,待模型管理服务就绪后实现。 diff --git a/packages/client/ui-models/package.json b/packages/client/ui-models/package.json new file mode 100644 index 0000000000..df8cba7c8b --- /dev/null +++ b/packages/client/ui-models/package.json @@ -0,0 +1,63 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-models", + "description": "Models feature plugin: registers its Settings section (nav entry, empty content column; model management lands later)", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dshClient": { + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-client-locale" + ], + "platform": "web" + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-client-runtime": "^0.0.1", + "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@types/react": "~18.3.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ] +} diff --git a/packages/client/ui-models/src/client/ModelsSection.tsx b/packages/client/ui-models/src/client/ModelsSection.tsx new file mode 100644 index 0000000000..ee33b916cb --- /dev/null +++ b/packages/client/ui-models/src/client/ModelsSection.tsx @@ -0,0 +1,13 @@ +/** + * Models settings section: an intentionally empty content column — the nav + * entry exists so the section slot composition is visible; model management + * lands in a later phase. + */ + +/** + * Render the (empty) Models section content column. + * @returns null — no content this phase. + */ +export function ModelsSection() { + return null +} diff --git a/packages/client/ui-models/src/client/index.ts b/packages/client/ui-models/src/client/index.ts new file mode 100644 index 0000000000..5abcb65bcf --- /dev/null +++ b/packages/client/ui-models/src/client/index.ts @@ -0,0 +1,51 @@ +/** + * Models settings section plugin, browser half. Registers the `models` nav + * entry into the shell-declared `settings.section` list slot; the content + * column is intentionally empty until model management lands. Export + * discipline: packages/client/AGENTS.md. + */ +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots' +// Type-only: pulls the shell's SlotMap merge (the 'settings.section' entry). +import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +// Type-only: pulls the locale plugin's Context merge (ctx.locale). +import type {} from '@deepseek-ai/dsh-client-locale/client' +import { ModelsSection } from './ModelsSection.tsx' + +/** + * Required services (cordis fiber inject). The target slot is declared by + * ui-settings' apply, whose activation order relative to this one is NOT + * constrained; registration goes through declaration-aware deferral. + */ +export const inject = ['slots', 'locale'] + +/** + * Register the Models section once the `settings.section` declaration is on + * the ledger. + * @param ctx - client root context. + */ +export function apply(ctx: ClientContext): void { + ctx.effect(() => { + const disposers = [ + ctx.locale.register('settings.models', 'zh', { nav: '模型' }), + ctx.locale.register('settings.models', 'en', { nav: 'Models' }), + ] + return () => { for (const dispose of disposers) dispose() } + }, 'ui-models: nav copy dictionaries') + ctx.effect(() => { + const deferred = deferRegistration(ctx.slots, 'settings.section', ModelsSection, () => + ctx.slots.register({ + name: 'settings.section', + id: 'models', + order: 10, + label: ctx.locale.bind('settings.models')('nav'), + }, ModelsSection)) + // Nav labels are registrant-localized: refresh on locale change so the + // ledger carries fresh text (the version bump re-renders the shell). + const offLocale = ctx.on('locale/change', () => { deferred.refresh() }) + return () => { + offLocale() + deferred.dispose() + } + }, 'ui-models: settings section registration') +} diff --git a/packages/client/ui-models/src/css-modules.d.ts b/packages/client/ui-models/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-models/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record<string, string> + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-models/src/index.ts b/packages/client/ui-models/src/index.ts new file mode 100644 index 0000000000..da3060fe59 --- /dev/null +++ b/packages/client/ui-models/src/index.ts @@ -0,0 +1,4 @@ +/** Host loader entry for the browser implementation exported from `./client`. */ + +/** Host plugin body — no host-side behavior for the models settings plugin. */ +export function apply(): void {} diff --git a/packages/client/ui-models/src/invariant.ts b/packages/client/ui-models/src/invariant.ts new file mode 100644 index 0000000000..c7c4996748 --- /dev/null +++ b/packages/client/ui-models/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-models`. + * @module @deepseek-ai/dsh-client-ui-models/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-models' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-models-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: a nav-entry-only section plugin rendering a fixed + * empty content column — it emits no cordis events and owns no cross-plugin + * mutable relation. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/ui-models/tests/apply.spec.ts b/packages/client/ui-models/tests/apply.spec.ts new file mode 100644 index 0000000000..1842000675 --- /dev/null +++ b/packages/client/ui-models/tests/apply.spec.ts @@ -0,0 +1,95 @@ +/** Models section registration: declaration-aware deferral, locale re-registration, and HMR recovery. */ +import { Context } from 'cordis' +import { describe, expect, it } from 'vitest' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' +import { apply, inject } from '@deepseek-ai/dsh-client-ui-models/client' +import { ModelsSection } from '../src/client/ModelsSection.tsx' + +async function bench() { + const ctx = new Context() + await ctx.plugin(SlotsService).await() + const locale = new LocaleService(ctx) + ctx.provide('locale', locale) + return { ctx, slots: ctx.get('slots') as SlotsService, locale } +} + +function declare(slots: SlotsService): () => void { + return slots.register( + { name: 'root', children: { 'settings.section': { kind: 'list', scope: 'root' } } } as never, + () => null, + ) +} + +describe('ui-models apply', () => { + it('declares the services it uses', () => { + expect(inject).toEqual(['slots', 'locale']) + }) + + it('registers the models nav entry for declarations before or after apply', async () => { + const before = await bench() + declare(before.slots) + await before.ctx.plugin({ inject: [...inject], apply }).await() + const entry = before.slots.entries('settings.section')[0]! + expect(entry.component).toBe(ModelsSection) + expect(entry.options).toEqual({ id: 'models', order: 10, label: '模型' }) + + const after = await bench() + await after.ctx.plugin({ inject: [...inject], apply }).await() + expect(after.slots.entries('settings.section')).toHaveLength(0) + declare(after.slots) + await Promise.resolve() + expect(after.slots.entries('settings.section')[0]!.component).toBe(ModelsSection) + // The self-inflicted ledger notifications hit the duplicate guard. + expect(after.slots.entries('settings.section')).toHaveLength(1) + }) + + it('re-registers with fresh label text on locale change', async () => { + const b = await bench() + declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + b.locale.setLocale('en') + expect(b.slots.entries('settings.section')[0]!.options.label).toBe('Models') + b.locale.setLocale('zh') + expect(b.slots.entries('settings.section')[0]!.options.label).toBe('模型') + }) + + it('locale change while the slot is undeclared stays a no-op', async () => { + const b = await bench() + await b.ctx.plugin({ inject: [...inject], apply }).await() + b.locale.setLocale('en') + expect(b.slots.entries('settings.section')).toHaveLength(0) + b.locale.setLocale('zh') + }) + + it('re-registers after an HMR collapse re-declares the slot (stale disposer must not block)', async () => { + const b = await bench() + const redeclare = declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + expect(b.slots.entries('settings.section')).toHaveLength(1) + // Declarer unload: the cascade removes our entry while our local + // disposer variable goes stale. + redeclare() + expect(b.slots.entries('settings.section')).toHaveLength(0) + declare(b.slots) + await Promise.resolve() + expect(b.slots.entries('settings.section')[0]!.component).toBe(ModelsSection) + // The locale path also recovers through the same ledger re-check. + b.locale.setLocale('en') + expect(b.slots.entries('settings.section')[0]!.options.label).toBe('Models') + b.locale.setLocale('zh') + }) + + it('registers the zh/en nav dictionaries and disposes everything with the fiber', async () => { + const b = await bench() + declare(b.slots) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(b.locale.bind('settings.models')('nav')).toBe('模型') + await fiber.dispose() + expect(b.slots.entries('settings.section')).toHaveLength(0) + // The (ns, locale) seats are free again — the dictionary disposers ran. + expect(() => b.locale.register('settings.models', 'zh', {})).not.toThrow() + expect(() => b.locale.register('settings.models', 'en', {})).not.toThrow() + }) +}) diff --git a/packages/client/ui-models/tests/invariant.spec.ts b/packages/client/ui-models/tests/invariant.spec.ts new file mode 100644 index 0000000000..05fb52ee1b --- /dev/null +++ b/packages/client/ui-models/tests/invariant.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import * as ModelsInvariant from '@deepseek-ai/dsh-client-ui-models/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' +import { ModelsSection } from '../src/client/ModelsSection.tsx' + +describe('invariant companion', () => { + it('registers under the package name with an empty installer', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + await expect(ctx.plugin(ModelsInvariant).await()).resolves.toBeDefined() + }) + + it('node-half apply is a no-op host placeholder', async () => { + const { apply } = await import('@deepseek-ai/dsh-client-ui-models') + apply() + expect(true).toBe(true) // reaching here without throw is the contract + }) + + it('the section content column is intentionally empty this phase', () => { + expect(ModelsSection()).toBeNull() + }) +}) diff --git a/packages/client/ui-models/tsconfig.json b/packages/client/ui-models/tsconfig.json new file mode 100644 index 0000000000..dde94c20af --- /dev/null +++ b/packages/client/ui-models/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../ui-slots" + }, + { + "path": "../runtime" + }, + { + "path": "../ui-settings" + }, + { + "path": "../locale" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/client/ui-models/tsdown.config.ts b/packages/client/ui-models/tsdown.config.ts new file mode 100644 index 0000000000..fc044b07d6 --- /dev/null +++ b/packages/client/ui-models/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-models', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml new file mode 100644 index 0000000000..6b4e776cfe --- /dev/null +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 4e2a22e77dc1611728477ea0a9d8c50dfc9f7f5d +README.zh.md: 36253971281fd346f9b0ec4648c4b8824ed918a7 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 5b158c453a..4e2a22e77d 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-client-ui-primitives +English | [中文](README.zh.md) + Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, markdown family (MessageText/MarkdownText/JsonBlock). Contract: api-contracts v3 §8. ## Markdown rendering diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md new file mode 100644 index 0000000000..3625397128 --- /dev/null +++ b/packages/client/ui-primitives/README.zh.md @@ -0,0 +1,23 @@ +# @deepseek-ai/dsh-client-ui-primitives + +[English](README.md) | 中文 + +纯 React 原子组件(零 cordis):StateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input,以及 markdown 家族(MessageText/MarkdownText/JsonBlock)。契约:api-contracts v3 §8。 + +## Markdown 渲染 + +`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。 + +## 模型体验 + +无。该包在浏览器中渲染纯 React 原子组件;这里没有任何内容进入模型请求。 + +#### KV Cache 影响 + +无;该包既不组装也不发送提供方请求。 + +## 已知限制与暂缓事项 + +- **字形级图标是重新绘制的近似版本**:鱼形标志(以及 ui-conversation 持有的闪光图标)来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。 +- **Pill 与 Input 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。 +- **StateDot 的 `Active` 变体是设计中的隐藏占位符**:尚未实现;已交付的四种状态(done/warning/ongoing/error)构成完整的 P-I 表层。 diff --git a/packages/client/ui-primitives/src/HoverCard.module.css b/packages/client/ui-primitives/src/HoverCard.module.css new file mode 100644 index 0000000000..8d8a52100e --- /dev/null +++ b/packages/client/ui-primitives/src/HoverCard.module.css @@ -0,0 +1,22 @@ +/* Block, not inline-flex: consumers wrap full-width list rows and an + * inline wrapper would shrink them; the card still measures this rect. */ +.root { + position: relative; + display: block; +} + +/* Preview card (figma session hover card): 244 wide, r12, pad 12/16, the + * menu card's elevation. Surface is #2C2C2E in both themes (figma value, + * light/dark identical), so a component-level variable, not a theme token. */ +.card { + --dsw-hovercard-bg: #2C2C2E; + position: fixed; + z-index: 100; + box-sizing: border-box; + width: 244px; + padding: 12px 16px; + border-radius: 12px; + background: var(--dsw-hovercard-bg); + box-shadow: var(--dsw-shadow-lv3); + pointer-events: none; +} diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx new file mode 100644 index 0000000000..1720a0b79c --- /dev/null +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -0,0 +1,112 @@ +// HoverCard: delayed hover-preview card portaled to document.body. +// Same portal mechanics as Menu: the wrapper span supplies the anchor rect, +// the card is fixed-positioned at its right edge and repositions on +// scroll/resize while open. Display-only — the card ignores pointer events +// and closes the instant the pointer leaves the anchor (no close delay). + +import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import type { ReactNode } from 'react' +import { createPortal } from 'react-dom' +import css from './HoverCard.module.css' + +/** + * Render an anchor with a hover-triggered preview card. + * @param props.anchor - the hover target (rendered in place inside a wrapper span). + * @param props.content - card content (display-only, no pointer interaction). + * @param props.openDelayMs - hover dwell before the card shows (default 500). + * @param props.disabled - suppress opening; turning true closes an open card. + * @returns anchor wrapper with the conditional portaled card. + */ +export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false }: { + anchor: ReactNode + content: ReactNode + openDelayMs?: number + disabled?: boolean +}) { + const rootRef = useRef<HTMLSpanElement>(null) + const cardRef = useRef<HTMLDivElement>(null) + const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null) + const [open, setOpen] = useState(false) + const [pos, setPos] = useState<{ left: number; top: number } | null>(null) + + const clearTimer = () => { + if (timerRef.current !== null) { + clearTimeout(timerRef.current) + timerRef.current = null + } + } + + // Owner disabling mid-hover (menu opened, drag started) closes immediately. + useEffect(() => { + if (!disabled) return + clearTimer() + setOpen(false) + }, [disabled]) + + useEffect(() => clearTimer, []) + + // Fixed-position from the anchor rect before paint; track the anchor while + // open (capture-phase scroll catches nested panes), as in Menu portal mode. + useLayoutEffect(() => { + if (!open) { setPos(null); return } + const place = () => { + const wrapper = rootRef.current + /* v8 ignore next -- the ref is attached before the layout effect runs and the listeners die with it. */ + if (wrapper === null) return + const r = wrapper.getBoundingClientRect() + const h = cardRef.current?.offsetHeight ?? 0 + const top = r.top + h > window.innerHeight - 8 ? window.innerHeight - h - 8 : r.top + setPos({ left: r.right + 8, top }) + } + place() + window.addEventListener('scroll', place, true) + window.addEventListener('resize', place) + return () => { + window.removeEventListener('scroll', place, true) + window.removeEventListener('resize', place) + } + }, [open]) + + // The first placement ran before the card mounted (height read 0): once the + // card's real height is measurable, correct the bottom-edge clamp. The + // correction converges — a clamped top satisfies the guard, so it runs once. + useLayoutEffect(() => { + if (!open || pos === null) return + /* v8 ignore next -- the card is mounted whenever pos is set, so the ref is attached here. */ + const h = cardRef.current?.offsetHeight ?? 0 + if (pos.top + h > window.innerHeight - 8) { + setPos({ left: pos.left, top: window.innerHeight - h - 8 }) + } + }, [open, pos]) + + const card = open && pos !== null && ( + <div ref={cardRef} className={css.card} style={pos}> + {content} + </div> + ) + + return ( + <span + ref={rootRef} + className={css.root} + onPointerEnter={() => { + if (disabled) return + clearTimer() + timerRef.current = setTimeout(() => { setOpen(true) }, openDelayMs) + }} + onPointerLeave={() => { + clearTimer() + setOpen(false) + }} + // Any press inside the anchor (row click, menu trigger) dismisses the + // card immediately, without waiting for the owner to flip `disabled`. + onPointerDownCapture={() => { + clearTimer() + setOpen(false) + }} + > + {anchor} + {card !== false && createPortal(card, document.body)} + </span> + ) +} diff --git a/packages/client/ui-primitives/src/Menu.module.css b/packages/client/ui-primitives/src/Menu.module.css index b55abe094d..6825731275 100644 --- a/packages/client/ui-primitives/src/Menu.module.css +++ b/packages/client/ui-primitives/src/Menu.module.css @@ -30,11 +30,13 @@ /* Portal mode: fixed in the viewport, coordinates supplied inline from the * anchor rect (side/align resolved in JS, the in-place offset rules above - * don't apply). */ + * don't apply). Portaled lists must layer above modal overlays (z 1000) — + * an anchor inside a dialog still expects its menu on top. */ .portal { position: fixed; top: auto; left: auto; + z-index: 1100; } /* Open above the anchor (empty-state workspace chip: figma 122:9481). */ @@ -109,6 +111,27 @@ background: transparent; } +/* Destructive row: error text/icon, danger hover fill. */ +.danger { + color: var(--dsw-alias-state-error-primary); +} + +.danger .itemIcon { + color: var(--dsw-alias-state-error-primary); +} + +.danger:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover-danger); +} + +/* Heading row: non-interactive small grey text, padding aligned with items. */ +.label { + padding: 8px 10px; + font-size: 12px; + line-height: 16px; + color: var(--dsw-alias-label-tertiary); +} + /* Separator cell (figma 122:9481): py 4 / px 2 around the hairline. */ .separator { height: 1px; diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index de015ee534..9abb6a3bb2 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -4,6 +4,7 @@ // the anchor rect, for anchors inside overflow-clipping containers (sidebar). // The owner controls `open`; outside-click closing uses one document listener // active only while open. Submenus open on hover/focus inside the same root. +// Entries also cover non-interactive `label` headings and `danger` rows. import { useEffect, useLayoutEffect, useRef, useState } from 'react' import type { CSSProperties, ReactNode } from 'react' @@ -19,6 +20,8 @@ export interface MenuItem { disabled?: boolean /** Leading icon (figma .Menu_cell gap 8). */ icon?: ReactNode + /** Destructive row: error-colored text/icon and danger hover fill. */ + danger?: boolean /** Nested card opened to the right on hover/focus. */ submenu?: readonly MenuItem[] } @@ -29,13 +32,24 @@ export interface MenuSeparator { id: string } -/** One primary-menu entry: a row or a separator. */ -export type MenuEntry = MenuItem | MenuSeparator +/** Non-interactive heading row above a group of items. */ +export interface MenuLabel { + type: 'label' + id: string + text: string +} + +/** One primary-menu entry: a row, a separator, or a heading label. */ +export type MenuEntry = MenuItem | MenuSeparator | MenuLabel function isSeparator(entry: MenuEntry): entry is MenuSeparator { return 'type' in entry && entry.type === 'separator' } +function isLabel(entry: MenuEntry): entry is MenuLabel { + return 'type' in entry && entry.type === 'label' +} + /** * Render an anchored dropdown menu. * @param props.open - whether the list is showing (owner-controlled). @@ -50,6 +64,8 @@ function isSeparator(entry: MenuEntry): entry is MenuSeparator { * from the anchor rect (repositions on scroll/resize while open). Use when an * ancestor's overflow clipping would crop the in-place list; default false * keeps the pure-CSS in-place behavior. + * @param props.closeOnPointerLeave - close the list when the pointer leaves + * it (default false keeps it open until outside click/Escape/selection). * @param props.getAnchorRect - portal mode only: supply the anchor rect * directly (e.g. from a host-owned trigger button) instead of measuring the * Menu's own wrapper span. Required when the wrapper isn't itself laid out at @@ -58,7 +74,7 @@ function isSeparator(entry: MenuEntry): entry is MenuSeparator { * scroll/resize; return null to skip placement for that frame. * @returns anchor wrapper with the conditional list. */ -export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, getAnchorRect, className }: { +export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, getAnchorRect, className }: { open: boolean anchor: ReactNode items: readonly MenuEntry[] @@ -68,6 +84,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align align?: 'start' | 'end' side?: 'bottom' | 'top' portal?: boolean + closeOnPointerLeave?: boolean getAnchorRect?: () => DOMRect | null className?: string }) { @@ -135,11 +152,19 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align className={clsx(css.list, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)} style={fixedPos ?? undefined} role="menu" + onPointerLeave={closeOnPointerLeave ? () => { onClose() } : undefined} + // React portals bubble synthetic events through the REACT tree: without + // this stop, an item click re-fires the anchor row's own onClick + // (open/toggle) after onSelect. + onClick={(e) => { e.stopPropagation() }} > {items.map(entry => { if (isSeparator(entry)) { return <div key={entry.id} className={css.separator} role="separator" /> } + if (isLabel(entry)) { + return <div key={entry.id} className={css.label} role="presentation">{entry.text}</div> + } const hasSub = entry.submenu !== undefined && entry.submenu.length > 0 const subOpen = hasSub && openSubmenuId === entry.id return ( @@ -152,7 +177,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align <button type="button" role="menuitem" - className={clsx(css.item, entry.id === selectedId && css.selected)} + className={clsx(css.item, entry.id === selectedId && css.selected, entry.danger === true && css.danger)} disabled={entry.disabled} aria-haspopup={hasSub ? 'menu' : undefined} aria-expanded={hasSub ? subOpen : undefined} diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 80bb3848dd..4c2083bae1 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -583,3 +583,90 @@ export const IconTreeCorner8x10 = ({ size = 10, className }: IconProps) => ( <path d="M0 0L-0.5 0L-0.5 7L0 7L0.5 7L0.5 0L0 0ZM3 10L3 10.5L8 10.5L8 10L8 9.5L3 9.5L3 10ZM0 7L-0.5 7C-0.5 8.933 1.067 10.5 3 10.5L3 10L3 9.5C1.61929 9.5 0.5 8.38071 0.5 7L0 7Z" fill="currentColor"/> </svg> ) + +/** ic_ds_light_outline_16 */ +export const IconLightOutline16 = ({ size = 16, className }: IconProps) => ( + <svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path + d="M11.3496 8C11.3496 6.14985 9.85015 4.65039 8 4.65039C6.14985 4.65039 4.65039 6.14985 4.65039 8C4.65039 9.85015 6.14985 11.3496 8 11.3496C9.85015 11.3496 11.3496 9.85015 11.3496 8ZM12.6504 8C12.6504 10.5681 10.5681 12.6504 8 12.6504C5.43188 12.6504 3.34961 10.5681 3.34961 8C3.34961 5.43188 5.43188 3.34961 8 3.34961C10.5681 3.34961 12.6504 5.43188 12.6504 8Z" + fill="currentColor" + /> + <path d="M8.65039 0.5V2.5H7.34961V0.5H8.65039Z" fill="currentColor" /> + <path d="M8.65039 13.5V15.5H7.34961V13.5H8.65039Z" fill="currentColor" /> + <path + d="M3.15808 2.24035L4.57229 3.65456L3.6525 4.57435L2.23829 3.16014L3.15808 2.24035Z" + fill="currentColor" + /> + <path + d="M12.3505 11.4327L13.7647 12.8469L12.8449 13.7667L11.4307 12.3525L12.3505 11.4327Z" + fill="currentColor" + /> + <path + d="M2.24537 12.8469L3.65958 11.4327L4.57937 12.3525L3.16516 13.7667L2.24537 12.8469Z" + fill="currentColor" + /> + <path + d="M11.4377 3.65455L12.852 2.24033L13.7718 3.16012L12.3575 4.57434L11.4377 3.65455Z" + fill="currentColor" + /> + <path d="M0.5 7.35461H2.5V8.6554H0.5L0.5 7.35461Z" fill="currentColor" /> + <path d="M13.5 7.35461H15.5V8.6554H13.5V7.35461Z" fill="currentColor" /> + </svg> +) + +/** ic_ds_dark_outline_16 */ +export const IconDarkOutline16 = ({ size = 16, className }: IconProps) => ( + <svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path + d="M13.2764 9.52324C12.5607 9.97754 11.7177 10.242 10.7812 10.242C8.11386 10.2419 5.95042 8.07997 5.9502 5.41289C5.9502 4.48128 6.21453 3.61071 6.67188 2.87285C4.30332 3.4658 2.54992 5.60845 2.5498 8.16093C2.5498 11.1712 4.99103 13.6102 8 13.6102C10.5383 13.6102 12.6709 11.8724 13.2764 9.52324ZM7.05078 5.41289C7.051 7.47224 8.72116 9.1423 10.7812 9.14238C11.9248 9.14238 12.887 8.63397 13.5781 7.8084C13.7266 7.63106 13.9701 7.56547 14.1875 7.64433C14.4049 7.72329 14.5497 7.9297 14.5498 8.16093C14.5498 11.7766 11.6161 14.7098 8 14.7098C4.38402 14.7098 1.4502 11.7792 1.4502 8.16093C1.45033 4.54322 4.3812 1.61015 8 1.61015C8.23027 1.61015 8.43585 1.75352 8.51562 1.96953C8.59536 2.18554 8.53241 2.42829 8.35742 2.57793C7.55573 3.26311 7.05078 4.27876 7.05078 5.41289Z" + fill="currentColor" + /> + </svg> +) + +/** ic_ds_followsystem_outline_16 */ +export const IconFollowsystemOutline16 = ({ size = 16, className }: IconProps) => ( + <svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M12.1665 13.5811V14.7803H3.66651V13.5811H12.1665Z" fill="currentColor" /> + <path + d="M13.4453 7.02379C13.4453 6.04702 13.4452 5.3616 13.3887 4.83434C13.3333 4.31828 13.2302 4.02378 13.0723 3.80309C12.9446 3.62475 12.7877 3.46883 12.6094 3.34117C12.3887 3.18328 12.0942 3.08007 11.5781 3.02477C11.0508 2.96829 10.3655 2.96715 9.38867 2.96715H6.61035C5.63359 2.96715 4.94816 2.96827 4.4209 3.02477C3.90486 3.0801 3.61034 3.18321 3.38965 3.34117C3.21143 3.46878 3.05534 3.62487 2.92774 3.80309C2.76977 4.02377 2.66667 4.3183 2.61133 4.83434C2.55483 5.3616 2.55371 6.04702 2.55371 7.02379C2.55371 8.0006 2.55485 8.68596 2.61133 9.21324C2.66663 9.72936 2.76983 10.0238 2.92774 10.2445C3.0554 10.4228 3.21131 10.5797 3.38965 10.7074C3.61034 10.8654 3.90484 10.9685 4.4209 11.0238C4.94816 11.0803 5.63359 11.0804 6.61035 11.0804H9.38867C10.3654 11.0804 11.0508 11.0803 11.5781 11.0238C12.0941 10.9685 12.3887 10.8652 12.6094 10.7074C12.7877 10.5797 12.9446 10.4229 13.0723 10.2445C13.2301 10.0238 13.3334 9.72927 13.3887 9.21324C13.4452 8.68596 13.4453 8.00058 13.4453 7.02379ZM14.6455 7.02379C14.6455 7.97428 14.646 8.73509 14.5811 9.34117C14.5149 9.95828 14.3756 10.4858 14.0479 10.9437C13.8436 11.229 13.5938 11.4788 13.3086 11.683C12.8507 12.0108 12.3232 12.15 11.7061 12.2162C11.1 12.2811 10.3391 12.2806 9.38867 12.2806H6.61035C5.66018 12.2806 4.89991 12.2811 4.29395 12.2162C3.67684 12.15 3.14935 12.0108 2.69141 11.683C2.40613 11.4788 2.15639 11.229 1.95215 10.9437C1.62436 10.4858 1.4841 9.95828 1.41797 9.34117C1.35305 8.73511 1.35449 7.97424 1.35449 7.02379C1.35449 6.07366 1.35308 5.31333 1.41797 4.70738C1.4841 4.09028 1.62436 3.56279 1.95215 3.10485C2.15638 2.81956 2.40613 2.56982 2.69141 2.36559C3.14935 2.03779 3.67684 1.89753 4.29395 1.83141C4.8999 1.76652 5.66022 1.76793 6.61035 1.76793H9.38867C10.3391 1.76793 11.1 1.76649 11.7061 1.83141C12.3232 1.89753 12.8507 2.03779 13.3086 2.36559C13.5939 2.56982 13.8436 2.81957 14.0479 3.10485C14.3756 3.56279 14.5149 4.09028 14.5811 4.70738C14.646 5.31335 14.6455 6.07362 14.6455 7.02379Z" + fill="currentColor" + /> + </svg> +) + +/** ic_ds_data_outline_16 */ +export const IconDataOutline16 = ({ size = 16, className }: IconProps) => ( + <svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M12.0997 8.54554C12.2905 8.54989 12.3541 8.58056 12.4535 8.74614L12.8849 9.46387C12.9851 9.63071 13.0464 9.66013 13.2388 9.66447H14.1138C14.3417 9.66448 14.3512 9.66937 14.4686 9.86507L14.892 10.5717C14.9942 10.7422 14.9948 10.8247 14.892 10.9961L14.4756 11.6906C14.3741 11.8677 14.3694 11.9379 14.4756 12.115L14.892 12.8096C14.9942 12.9801 14.9947 13.0625 14.892 13.234L14.4686 13.9406C14.3643 14.1028 14.3063 14.1354 14.1138 14.1412H13.2388C13.0465 14.1456 12.985 14.1752 12.8849 14.3418L12.4535 15.0595C12.353 15.2195 12.2895 15.2558 12.0997 15.2601H11.2237C10.9962 15.2601 10.9871 15.2548 10.8699 15.0595L10.4384 14.3418C10.3383 14.175 10.2767 14.1456 10.0846 14.1412H9.2096C9.01854 14.1355 8.95761 14.1006 8.85477 13.9406L8.43139 13.234C8.32562 13.0576 8.33148 12.9862 8.43139 12.8096L8.84771 12.115C8.95165 11.9416 8.94659 11.863 8.84771 11.6906L8.43139 10.9961C8.32767 10.8232 8.33411 10.7437 8.43139 10.5717L8.85477 9.86507C8.95447 9.69891 9.01875 9.67017 9.2096 9.66447H10.0846C10.2741 9.66441 10.3414 9.62547 10.4384 9.46387L10.8699 8.74614C10.987 8.55106 10.9963 8.54554 11.2237 8.54554H12.0997ZM11.6612 10.232C11.3326 10.7798 10.8155 11.0948 10.1743 11.106C10.4443 11.61 10.4425 12.1976 10.1743 12.6987C10.803 12.7096 11.3391 13.0359 11.6612 13.5727C11.9855 13.0323 12.5131 12.7098 13.148 12.6987C12.879 12.196 12.8789 11.6086 13.148 11.106C12.5076 11.0948 11.9894 10.7794 11.6612 10.232Z" + fill="currentColor" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M7.51205 0.790627C9.19055 0.790649 10.7401 1.0691 11.892 1.54364C12.4664 1.78029 12.9719 2.07885 13.3436 2.4408C13.7171 2.80467 13.9916 3.27253 13.9918 3.82384V7.90442C13.6067 7.69532 13.1907 7.53597 12.7529 7.43366V5.66454C12.4928 5.82898 12.2028 5.97601 11.892 6.10405C10.74 6.57865 9.19071 6.85706 7.51205 6.85706C5.8337 6.85703 4.285 6.57852 3.13309 6.10405C2.82215 5.97593 2.53164 5.8291 2.27121 5.66454V7.4135C2.27134 7.75678 2.6066 8.27106 3.62502 8.73405C4.58641 9.17097 5.95762 9.45591 7.50499 9.45681C7.24582 9.83133 7.03684 10.2434 6.88706 10.6826C5.44388 10.6162 4.12516 10.3216 3.11192 9.86104C2.81708 9.72698 2.53185 9.56866 2.27121 9.38928V11.2542C2.27158 11.5974 2.60697 12.1109 3.62502 12.5737C4.41933 12.9347 5.4937 13.1898 6.71569 13.2693C6.80349 13.7128 6.9513 14.1345 7.14814 14.5273C5.60324 14.4862 4.18593 14.1889 3.11192 13.7007C2.01039 13.1998 1.03366 12.3814 1.03333 11.2542V3.82384C1.03352 3.27273 1.30721 2.80461 1.68049 2.4408C2.05211 2.07893 2.55887 1.78026 3.13309 1.54364C4.28492 1.06926 5.83393 0.790683 7.51205 0.790627ZM7.51205 2.02851C5.95492 2.02857 4.57354 2.29079 3.60486 2.68979C3.11958 2.88977 2.76667 3.11253 2.5454 3.32788C2.32671 3.54101 2.2714 3.7089 2.27121 3.82384C2.27121 3.93882 2.32624 4.10625 2.5454 4.3198C2.76667 4.53527 3.11927 4.75781 3.60486 4.9579C4.5736 5.35699 5.95467 5.61914 7.51205 5.61918C9.06942 5.61918 10.4505 5.35695 11.4192 4.9579C11.9051 4.75773 12.2584 4.53536 12.4797 4.3198C12.6988 4.10627 12.7529 3.93882 12.7529 3.82384C12.7527 3.70889 12.6984 3.54104 12.4797 3.32788C12.2584 3.11239 11.9049 2.88989 11.4192 2.68979C10.4505 2.29079 9.06925 2.02853 7.51205 2.02851Z" + fill="currentColor" + /> + </svg> +) + +/** ic_ds_List_Pen_outline_16 */ +export const IconListPenOutline16 = ({ size = 16, className }: IconProps) => ( + <svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M10.8239 3.54733V4.78443H4.63437V3.54733H10.8239Z" fill="currentColor" /> + <path d="M10.8239 6.12629V7.36338H4.63437V6.12629H10.8239Z" fill="currentColor" /> + <path d="M9.073 8.70524V9.94234H4.63437V8.70524H9.073Z" fill="currentColor" /> + <path + d="M9.13321 0.573526C10.0076 0.573525 10.7179 0.572522 11.285 0.63397C11.8645 0.696791 12.3743 0.831648 12.8193 1.1548C13.0776 1.34246 13.3056 1.57047 13.4933 1.82875C13.8164 2.2737 13.9513 2.7836 14.0141 3.36303C14.0755 3.93015 14.0745 4.64049 14.0745 5.51485V6.1757L12.7327 7.5629V5.51485C12.7327 4.61092 12.732 3.9862 12.6803 3.5081C12.6298 3.0427 12.5379 2.79497 12.4083 2.61654C12.3033 2.47211 12.176 2.34472 12.0315 2.23977C11.8531 2.11016 11.6054 2.01823 11.14 1.96777C10.6618 1.91601 10.0372 1.91539 9.13321 1.91539H6.32658C5.42262 1.91539 4.79796 1.91604 4.31983 1.96777C3.85451 2.01819 3.60672 2.11029 3.42827 2.23977C3.28392 2.34465 3.15643 2.47223 3.0515 2.61654C2.9219 2.79496 2.82997 3.04274 2.7795 3.5081C2.72774 3.9862 2.72712 4.61092 2.72712 5.51485V10.023C2.72712 10.9273 2.72773 11.5525 2.7795 12.0307C2.82992 12.4959 2.92205 12.7429 3.0515 12.9213C3.15645 13.0657 3.28384 13.1931 3.42827 13.2981C3.60676 13.4277 3.85408 13.5206 4.31983 13.5711C4.79797 13.6228 5.42259 13.6234 6.32658 13.6234H6.87057L5.57707 14.9593C5.03527 14.9556 4.57031 14.9467 4.17476 14.9039C3.59508 14.841 3.08558 14.7063 2.64048 14.383C2.38215 14.1953 2.15422 13.9684 1.96653 13.7101C1.64319 13.2649 1.50851 12.7546 1.4457 12.1748C1.38432 11.6076 1.38525 10.8974 1.38525 10.023V5.51485C1.38525 4.64049 1.38426 3.93015 1.4457 3.36303C1.50853 2.78363 1.64341 2.27368 1.96653 1.82875C2.15417 1.57059 2.38228 1.34239 2.64048 1.1548C3.08544 0.831805 3.59533 0.696762 4.17476 0.63397C4.74193 0.572552 5.45218 0.573525 6.32658 0.573526H9.13321Z" + fill="currentColor" + /> + <path d="M14.2193 14.9553H10.0124L11.3744 13.6134H14.2193V14.9553Z" fill="currentColor" /> + <path + d="M8.24493 13.3711L7.49015 14.8806C7.40148 15.058 7.58961 15.2461 7.76695 15.1574L9.27651 14.4027L14.6147 9.09934L13.5832 8.06775L8.24493 13.3711Z" + fill="currentColor" + /> + </svg> +) diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index 9fd3d149fc..5eff2e40b2 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -9,7 +9,8 @@ export type { ButtonVariant } from './Button.tsx' export { Pill } from './Pill.tsx' export { Input } from './Input.tsx' export { Menu } from './Menu.tsx' -export type { MenuEntry, MenuItem, MenuSeparator } from './Menu.tsx' +export type { MenuEntry, MenuItem, MenuSeparator, MenuLabel } from './Menu.tsx' +export { HoverCard } from './HoverCard.tsx' export { Modal } from './Modal.tsx' export { ConnectionBanner } from './ConnectionBanner.tsx' export { FishLogo } from './FishLogo.tsx' diff --git a/packages/client/ui-primitives/tests/atoms.spec.tsx b/packages/client/ui-primitives/tests/atoms.spec.tsx index 440f4ba6ef..9e298d057a 100644 --- a/packages/client/ui-primitives/tests/atoms.spec.tsx +++ b/packages/client/ui-primitives/tests/atoms.spec.tsx @@ -136,6 +136,51 @@ describe('Menu', () => { expect(screen.getByRole('separator')).toBeDefined() }) + it('renders a non-interactive heading label and a danger row', () => { + const onSelect = vi.fn() + render( + <Menu + open + anchor={<span>trigger</span>} + items={[ + { type: 'label', id: 'h', text: 'Group by' }, + { id: 'del', label: 'Delete', danger: true }, + ]} + onSelect={onSelect} + onClose={() => {}} + />) + const heading = screen.getByText('Group by') + expect(heading.getAttribute('role')).toBe('presentation') + // The heading is not a menu item — only the danger row is interactive. + expect(screen.getAllByRole('menuitem')).toHaveLength(1) + const danger = screen.getByRole('menuitem', { name: 'Delete' }) + expect(danger.className).toMatch(/danger/) + fireEvent.click(danger) + expect(onSelect).toHaveBeenCalledWith('del') + }) + + it('closeOnPointerLeave closes when the pointer leaves the list; default stays open', () => { + const onClose = vi.fn() + const { rerender } = render( + <Menu open closeOnPointerLeave anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />) + fireEvent.pointerLeave(screen.getByRole('menu')) + expect(onClose).toHaveBeenCalledTimes(1) + rerender( + <Menu open anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />) + fireEvent.pointerLeave(screen.getByRole('menu')) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('a list click does not bubble to the anchor row (portal synthetic-event path)', () => { + const rowClick = vi.fn() + render( + <div onClick={rowClick}> + <Menu open anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={() => {}} /> + </div>) + fireEvent.click(screen.getByRole('menuitem', { name: 'Alpha' })) + expect(rowClick).not.toHaveBeenCalled() + }) + it('opens a submenu on hover and selects a nested item', () => { const onSelect = vi.fn() render( diff --git a/packages/client/ui-primitives/tests/hover-card.spec.tsx b/packages/client/ui-primitives/tests/hover-card.spec.tsx new file mode 100644 index 0000000000..c7a95f49fb --- /dev/null +++ b/packages/client/ui-primitives/tests/hover-card.spec.tsx @@ -0,0 +1,148 @@ +// @vitest-environment jsdom +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { HoverCard } from '@deepseek-ai/dsh-client-ui-primitives' + +afterEach(cleanup) +beforeEach(() => { vi.useFakeTimers() }) +afterEach(() => { vi.useRealTimers() }) + +/** Anchor wrapper rect: the card positions from this (jsdom rects are all-zero by default). */ +function stubAnchorRect(anchor: HTMLElement, rect: { top: number; right: number }): void { + const wrapper = anchor.parentElement as HTMLElement + wrapper.getBoundingClientRect = () => ({ + top: rect.top, right: rect.right, left: rect.right - 100, bottom: rect.top + 34, + width: 100, height: 34, x: rect.right - 100, y: rect.top, toJSON: () => ({}), + } as DOMRect) +} + +function mount(props: { openDelayMs?: number; disabled?: boolean } = {}) { + const view = render( + <HoverCard anchor={<span>row</span>} content={<div>card body</div>} {...props} />, + ) + const anchor = screen.getByText('row') + stubAnchorRect(anchor, { top: 40, right: 200 }) + return { view, anchor, wrapper: anchor.parentElement as HTMLElement } +} + +describe('HoverCard', () => { + it('opens after the dwell delay, positioned right of the anchor', () => { + const { wrapper } = mount() + fireEvent.pointerEnter(wrapper) + expect(screen.queryByText('card body')).toBeNull() + act(() => { vi.advanceTimersByTime(499) }) + expect(screen.queryByText('card body')).toBeNull() + act(() => { vi.advanceTimersByTime(1) }) + const card = screen.getByText('card body').parentElement as HTMLElement + expect(card.parentElement).toBe(document.body) + expect(card.style.left).toBe('208px') + expect(card.style.top).toBe('40px') + }) + + it('honors a custom openDelayMs', () => { + const { wrapper } = mount({ openDelayMs: 50 }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(50) }) + expect(screen.getByText('card body')).toBeTruthy() + }) + + it('pointerleave before the delay cancels the pending open', () => { + const { wrapper } = mount() + fireEvent.pointerEnter(wrapper) + fireEvent.pointerLeave(wrapper) + act(() => { vi.advanceTimersByTime(1000) }) + expect(screen.queryByText('card body')).toBeNull() + }) + + it('pointerleave closes an open card immediately; re-enter restarts the dwell', () => { + const { wrapper } = mount() + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByText('card body')).toBeTruthy() + fireEvent.pointerLeave(wrapper) + expect(screen.queryByText('card body')).toBeNull() + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByText('card body')).toBeTruthy() + }) + + it('a press inside the anchor dismisses the card without waiting for disabled', () => { + const { wrapper } = mount() + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByText('card body')).toBeTruthy() + fireEvent.pointerDown(screen.getByText('row')) + expect(screen.queryByText('card body')).toBeNull() + // The pending timer is also cleared: no reopen after the dwell. + act(() => { vi.advanceTimersByTime(1000) }) + expect(screen.queryByText('card body')).toBeNull() + }) + + it('disabled suppresses opening entirely', () => { + const { wrapper } = mount({ disabled: true }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(1000) }) + expect(screen.queryByText('card body')).toBeNull() + }) + + it('flipping disabled true closes an open card', () => { + const { view, wrapper } = mount() + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByText('card body')).toBeTruthy() + view.rerender(<HoverCard anchor={<span>row</span>} content={<div>card body</div>} disabled />) + expect(screen.queryByText('card body')).toBeNull() + }) + + it('corrects the bottom-edge clamp once the mounted card height is measurable', () => { + // First placement reads height 0 (card not yet mounted) and keeps the + // anchor top; the post-mount correction re-clamps with the real height. + window.innerHeight = 300 + const offsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight')! + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, get: () => 120 }) + try { + const { wrapper } = mount() + stubAnchorRect(screen.getByText('row'), { top: 280, right: 200 }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + const card = screen.getByText('card body').parentElement as HTMLElement + // 300 - 120 - 8 = 172, instead of the anchor top 280. + expect(card.style.top).toBe('172px') + } finally { + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', offsetHeight) + } + }) + + it('clamps inside placement itself when the card is already measured (resize path)', () => { + window.innerHeight = 300 + const { wrapper } = mount() + stubAnchorRect(screen.getByText('row'), { top: 280, right: 200 }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + const card = screen.getByText('card body').parentElement as HTMLElement + Object.defineProperty(card, 'offsetHeight', { value: 120 }) + act(() => { fireEvent.resize(window) }) + expect(card.style.top).toBe('172px') + }) + + it('repositions on capture-phase scroll while open and stops listening after close', () => { + const { wrapper } = mount() + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + stubAnchorRect(screen.getByText('row'), { top: 90, right: 300 }) + act(() => { fireEvent.scroll(document) }) + const card = screen.getByText('card body').parentElement as HTMLElement + expect(card.style.left).toBe('308px') + expect(card.style.top).toBe('90px') + fireEvent.pointerLeave(wrapper) + expect(screen.queryByText('card body')).toBeNull() + }) + + it('unmount clears a pending open timer', () => { + const { view, wrapper } = mount() + fireEvent.pointerEnter(wrapper) + view.unmount() + act(() => { vi.advanceTimersByTime(1000) }) + expect(screen.queryByText('card body')).toBeNull() + }) +}) diff --git a/packages/client/ui-primitives/tests/icons.spec.tsx b/packages/client/ui-primitives/tests/icons.spec.tsx index 74bf5a9678..281124b8d5 100644 --- a/packages/client/ui-primitives/tests/icons.spec.tsx +++ b/packages/client/ui-primitives/tests/icons.spec.tsx @@ -14,8 +14,8 @@ const icons = Object.fromEntries( const iconNames = Object.keys(icons) describe('ic_ds_ icon set', () => { - it('exports the full P-I set (43 deepsuite + 7 figma extracts)', () => { - expect(iconNames.length).toBe(50) + it('exports the full P-I set (43 deepsuite + 12 figma extracts)', () => { + expect(iconNames.length).toBe(55) }) it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', name => { diff --git a/packages/client/ui-question/README.i18n.yaml b/packages/client/ui-question/README.i18n.yaml new file mode 100644 index 0000000000..476bd3f0b6 --- /dev/null +++ b/packages/client/ui-question/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 28132d1d0643f5e8f658ab77de9017467da2d172 +README.zh.md: c70e77fc90eb5b226ceabd8a1e7cc7ca6c011c40 diff --git a/packages/client/ui-question/README.md b/packages/client/ui-question/README.md index a02c85d54c..28132d1d06 100644 --- a/packages/client/ui-question/README.md +++ b/packages/client/ui-question/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-client-ui-question +English | [中文](README.zh.md) + Web `ask_user_question` feature plugin. Its host half mounts `dsh-tool-ask-user` only when the Web feature is selected; its browser half registers the `question` entry in the conversation-owned `conversation.composer` keyed slot. The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges derived from label suffixes, and custom answers. Single-select choices advance immediately, and Enter submits once every question is answered or skipped; Enter during IME composition confirms the input candidate without advancing. It submits one structured answer batch for the whole request: “Skip this question” retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`. diff --git a/packages/client/ui-question/README.zh.md b/packages/client/ui-question/README.zh.md new file mode 100644 index 0000000000..c70e77fc90 --- /dev/null +++ b/packages/client/ui-question/README.zh.md @@ -0,0 +1,22 @@ +# @deepseek-ai/dsh-client-ui-question + +[English](README.md) | 中文 + +Web `ask_user_question` 功能插件。只有选择 Web 功能时,其主机侧才会挂载 `dsh-tool-ask-user`;浏览器侧会把 `question` 配置项注册到会话拥有的 `conversation.composer` 键控 slot 中。 + +组件每次渲染一个问题,提供进度导航、单选和多选选项、由标签后缀派生的推荐徽标,以及自定义答案。单选选项会立即前进;所有问题均已回答或跳过后,Enter 会提交;IME 输入法组合期间按 Enter 只会确认输入候选,不会前进。组件为整个请求提交一批结构化答案:「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 形状;关闭则以 `ASK_CANCELLED` 拒绝整个等待。 + +选择状态只存在于以请求 rpcId 为 key 的组件本地。使用相同 id 回放时,只要组件仍挂载,就会保留草稿;主机发出的 `question/resolved` 则会移除编辑器。主机仍具有最终决定权:HTTP 交付成功不会在本地移除待处理状态。 + +## 模型体验 + +通过 `dsh-tool-ask-user` 间接影响;该包拥有模型可见的工具 schema 和结构化结果。 + +#### KV Cache 影响 + +不会直接失效;模型可见的工具调用与结果由 `dsh-tool-ask-user` 拥有。 + +## 已知限制与暂缓事项 + +- **未提交的草稿不持久**:重新连接再同步或完整刷新页面时,会恢复主机拥有且 rpcId 相同的待处理请求,但编辑器卸载会重置本地选项和自定义文本草稿。 +- **每次只有一个请求拥有编辑器**:后续待处理请求仍留在会话快照中,并在较早请求解决后显示。 diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml new file mode 100644 index 0000000000..9377fc73b8 --- /dev/null +++ b/packages/client/ui-settings-general/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: c392d745021c0fc6a752cf71dd0506a435106c50 +README.zh.md: 83ab81e01eae435a74b50fa363a4de203c483002 diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md new file mode 100644 index 0000000000..c392d74502 --- /dev/null +++ b/packages/client/ui-settings-general/README.md @@ -0,0 +1,17 @@ +# @deepseek-ai/dsh-client-ui-settings-general + +English | [中文](README.zh.md) + +Settings ownerless-copy plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the General section (Permission/Tool Call skeleton rows + the `settings.general.item` slot declaration), and the `settings` dictionaries. Feature-owned rows (Language, Appearance) and sections (Models) stay with their feature packages. + +## Model Experience + +None, as the plugin renders browser settings UI; nothing here reaches a model request. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **Permission and Tool Call are display skeletons** — the backing host services and RPC methods do not exist yet; the controls are disabled and write nothing. When they gain real backing, each moves to its owning feature plugin per the self-registration doctrine. diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md new file mode 100644 index 0000000000..83ab81e01e --- /dev/null +++ b/packages/client/ui-settings-general/README.zh.md @@ -0,0 +1,17 @@ +# @deepseek-ai/dsh-client-ui-settings-general + +[English](README.md) | 中文 + +设置界面文案插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容,「通用」分区(「权限」/「工具调用」骨架行和 `settings.general.item` slot 声明),以及 `settings` 字典。归具体功能所有的行(「语言」、「外观」)和分区(「模型」)仍由各自的功能包提供。 + +## 模型体验 + +无。该插件渲染浏览器设置 UI;这里没有任何内容进入模型请求。 + +#### KV Cache 影响 + +无;该包既不组装也不发送提供方请求。 + +## 已知限制与暂缓事项 + +- **「权限」与「工具调用」只是展示骨架**:对应的宿主服务和 RPC 方法尚不存在;这些控件已禁用,不会写入任何内容。一旦获得实际支撑,按照自注册原则,每一项都会移至拥有它的功能插件。 diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json new file mode 100644 index 0000000000..798a7710f0 --- /dev/null +++ b/packages/client/ui-settings-general/package.json @@ -0,0 +1,67 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-settings-general", + "description": "Settings ownerless-copy plugin: the General section (skeleton rows + item slot), the shell trigger/header chrome content, and the settings dictionaries", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dshClient": { + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-client-locale" + ], + "platform": "web" + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-client-locale": "^0.0.1", + "@deepseek-ai/dsh-client-runtime": "^0.0.1", + "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", + "@deepseek-ai/dsh-client-ui-settings": "^0.0.1", + "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@types/react": "~18.3.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ] +} diff --git a/packages/client/ui-settings-general/src/client/GeneralSection.module.css b/packages/client/ui-settings-general/src/client/GeneralSection.module.css new file mode 100644 index 0000000000..5e053a49c5 --- /dev/null +++ b/packages/client/ui-settings-general/src/client/GeneralSection.module.css @@ -0,0 +1,111 @@ +/* General section rows (figma 501:29983 'Options'): stacked groups, 16px + * vertical padding each, hairline separator under all but the last child + * (feature-contributed rows carry their own row chrome and separators; the + * :last-child rule strips the trailing one wherever the column ends). */ + +.section { + display: flex; + flex-direction: column; + width: 100%; +} + +.section > :last-child { + border-bottom: none; +} + +/* Title + trailing control row (figma 'Setting-Cell': gap 8, pad 16/0). */ +.row { + display: flex; + align-items: center; + gap: 8px; + padding: 16px 0; + border-bottom: 1px solid var(--dsw-alias-border-l2); +} + +/* Title + full-width body group (figma 'Frame 2117131229': column, gap 8). */ +.group { + display: flex; + flex-direction: column; + gap: 8px; + padding: 16px 0; + border-bottom: 1px solid var(--dsw-alias-border-l2); +} + +/* Leading text column (figma 'Frame 2036083120': gap 4, pad-right 48). */ +.rowText { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; + padding-right: 48px; +} + +.title { + font-size: 14px; + font-weight: 400; + line-height: 22px; + color: var(--dsw-alias-label-primary); +} + +.desc { + font-size: 12px; + font-weight: 400; + line-height: 18px; + color: var(--dsw-alias-label-tertiary); +} + +/* Selector pill (figma 'Selector': h36 r18, fill #F5F6F7, pad 0/14, gap 12). */ +.selector { + display: inline-flex; + align-items: center; + gap: 12px; + height: 36px; + padding: 0 14px; + border: none; + border-radius: 18px; + background: var(--dsw-alias-bg-module-platform); + font: inherit; + font-size: 14px; + line-height: 22px; + color: var(--dsw-alias-label-primary); + cursor: pointer; +} + +.selector:disabled { + cursor: default; +} + +.chevron { + flex: none; +} + +/* Tool Call mode cubes share an 8px gap. */ +.cubeRow { + display: flex; + align-items: stretch; + gap: 8px; +} + +/* Tool Call mode cube (figma '.Selector Cube' 418w r16; horizontal inset = + * outer pad 4 + inner .Menu_cell pad 10, vertical = inner pad 8). */ +.modeCube { + box-sizing: border-box; + width: 418px; + display: flex; + flex-direction: column; + justify-content: center; + gap: 2px; + padding: 8px 14px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 16px; + background: transparent; + text-align: left; +} + +/* Selected cube: #F5F6F7 fill + #ADB2B8 border (static token — the bluish-400 + * step has no alias-layer name). */ +.selected { + background: var(--dsw-alias-bg-module-platform); + border-color: var(--dsw-static-neutral-bluish-400); +} diff --git a/packages/client/ui-settings-general/src/client/GeneralSection.tsx b/packages/client/ui-settings-general/src/client/GeneralSection.tsx new file mode 100644 index 0000000000..8c75e76022 --- /dev/null +++ b/packages/client/ui-settings-general/src/client/GeneralSection.tsx @@ -0,0 +1,61 @@ +/** + * The General section (figma 501:29983 'Options'): Permission and Tool Call + * skeleton rows, then the feature-contributed preference rows from the + * `settings.general.item` slot (locale → Language, ui-theme → Appearance). + * The section column stacks rows; each row draws its own internals and + * separator. + */ +import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import css from './GeneralSection.module.css' + +/** Injected face of the General section: the settings-namespace translate. */ +export interface GeneralSectionInjected { + /** Translate a `settings` dictionary key to the active-locale text. */ + t: (key: string) => string +} + +/** Full component props: section owner share + item render share + inject face. */ +export type GeneralSectionComponentProps = + PropsRuntime<'settings.section'> & PropsRenderSlots<'settings.general.item'> & GeneralSectionInjected + +/** + * Render the General section content column. + * @param props - composed slot props (contract/slots.ts). + * @returns the section element tree. + */ +export function GeneralSection({ t, renderSlot }: GeneralSectionComponentProps) { + return ( + <div className={css.section}> + {/* Permission (skeleton): disabled selector pill. */} + <div className={css.row}> + <div className={css.rowText}> + <div className={css.title}>{t('permission.title')}</div> + <div className={css.desc}>{t('permission.desc')}</div> + </div> + <button type="button" className={css.selector} disabled> + {t('permission.value')} + <IconChevronDownOutline14 className={css.chevron} /> + </button> + </div> + + {/* Tool Call (skeleton): schema cube pinned selected, code cube unselected. */} + <div className={css.group}> + <div className={css.title}>{t('toolcall.title')}</div> + <div className={css.cubeRow}> + <div className={`${css.modeCube} ${css.selected}`}> + <div className={css.title}>{t('toolcall.schema.title')}</div> + <div className={css.desc}>{t('toolcall.schema.desc')}</div> + </div> + <div className={css.modeCube}> + <div className={css.title}>{t('toolcall.code.title')}</div> + <div className={css.desc}>{t('toolcall.code.desc')}</div> + </div> + </div> + </div> + + {/* Feature-owned preference rows (Language, Appearance, …). */} + {renderSlot('settings.general.item', {})} + </div> + ) +} diff --git a/packages/client/ui-settings-general/src/client/chrome.module.css b/packages/client/ui-settings-general/src/client/chrome.module.css new file mode 100644 index 0000000000..3291b3cd66 --- /dev/null +++ b/packages/client/ui-settings-general/src/client/chrome.module.css @@ -0,0 +1,7 @@ +/* Trigger row label (the shell's button provides layout/colors; the label + * only guards against overflow during the sidebar collapse crossfade). */ + +.triggerLabel { + overflow: hidden; + white-space: nowrap; +} diff --git a/packages/client/ui-settings-general/src/client/chrome.tsx b/packages/client/ui-settings-general/src/client/chrome.tsx new file mode 100644 index 0000000000..dc7ec94a29 --- /dev/null +++ b/packages/client/ui-settings-general/src/client/chrome.tsx @@ -0,0 +1,56 @@ +/** + * Shell chrome content registered into the shell's trigger/header seats: the + * trigger row icon + label (figma sidebar foot) and the panel title text. + * The shell renders the surrounding chrome (button, nav heading row) and + * reads each entry's `label` option for aria text. + */ +import { IconSettingsOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import css from './chrome.module.css' + +/** Injected face of both chrome seats: the settings-namespace translate. */ +export interface ChromeInjected { + /** Translate a `settings` dictionary key to the active-locale text. */ + t: (key: string) => string +} + +/** Trigger content props: the sidebar column state + translate. */ +export type TriggerContentProps = PropsRuntime<'settings.trigger'> & ChromeInjected + +/** Header content props: translate only. */ +export type HeaderContentProps = PropsRuntime<'settings.header'> & ChromeInjected + +/** + * Render the trigger row content (icon; label only in the wide column). + * @param props - composed slot props. + * @returns the trigger content fragment. + */ +export function TriggerContent({ wide, t }: TriggerContentProps) { + return ( + <> + <IconSettingsOutline14 size={wide ? 14 : 18} /> + {wide && <span className={css.triggerLabel}>{t('trigger')}</span>} + </> + ) +} + +/** + * Render the panel title text. + * @param props - composed slot props. + * @returns the title text node. + */ +export function HeaderContent({ t }: HeaderContentProps) { + return <>{t('title')}</> +} + +/** Close-button label text props: translate only. */ +export type CloseLabelProps = PropsRuntime<'settings.close'> & ChromeInjected + +/** + * Render the close button's visually-hidden label text. + * @param props - composed slot props. + * @returns the label text node. + */ +export function CloseLabel({ t }: CloseLabelProps) { + return <>{t('close')}</> +} diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts new file mode 100644 index 0000000000..afb37d8b92 --- /dev/null +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -0,0 +1,87 @@ +/** + * Settings ownerless-copy plugin, browser half: registers everything on the + * Settings surface that belongs to no single feature — the trigger/header + * chrome content, the General section (skeleton rows + the + * `settings.general.item` slot declaration), and the `settings` + * dictionaries. Feature-owned rows and sections stay with their features. + * Export discipline: packages/client/AGENTS.md. + */ +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots' +// Type-only: pulls the shell's SlotMap merges (trigger/header/section/item). +import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +import type { ChromeInjected } from './chrome.tsx' +import { CloseLabel, HeaderContent, TriggerContent } from './chrome.tsx' +import type { GeneralSectionInjected } from './GeneralSection.tsx' +import { GeneralSection } from './GeneralSection.tsx' +import { en, zh } from './locales.ts' + +export type { + ChromeInjected, CloseLabelProps, HeaderContentProps, TriggerContentProps, +} from './chrome.tsx' +export type { + GeneralSectionComponentProps, GeneralSectionInjected, +} from './GeneralSection.tsx' + +/** Dictionary namespace owned by this plugin (shell chrome + General copy). */ +const NS = 'settings' + +/** + * Required services (cordis fiber inject). The target slots are declared by + * ui-settings' apply, whose activation order relative to this one is NOT + * constrained; registration goes through declaration-aware deferral. + */ +export const inject = ['slots', 'locale'] + +/** + * Register the `settings` dictionaries, the chrome content, and the General + * section, each once its slot declaration is on the ledger. + * @param ctx - client root context. + */ +export function apply(ctx: ClientContext): void { + ctx.effect(() => { + const disposers = [ + ctx.locale.register(NS, 'zh', zh), + ctx.locale.register(NS, 'en', en), + ] + return () => { for (const dispose of disposers) dispose() } + }, 'ui-settings-general: dictionaries') + + const t = ctx.locale.bind(NS) + const chromeInjected = (): ChromeInjected => ({ t }) + const generalInjected = (): GeneralSectionInjected => ({ t }) + + // All four seats refresh on locale change: re-registration bumps each + // slot's ledger version, which re-renders the outlets through their own + // subscriptions (outlet memoization would swallow a parent-only render). + ctx.effect(() => { + const trigger = deferRegistration(ctx.slots, 'settings.trigger', TriggerContent, () => + ctx.slots.register({ name: 'settings.trigger', inject: chromeInjected }, TriggerContent)) + const header = deferRegistration(ctx.slots, 'settings.header', HeaderContent, () => + ctx.slots.register({ name: 'settings.header', inject: chromeInjected }, HeaderContent)) + const close = deferRegistration(ctx.slots, 'settings.close', CloseLabel, () => + ctx.slots.register({ name: 'settings.close', inject: chromeInjected }, CloseLabel)) + const general = deferRegistration(ctx.slots, 'settings.section', GeneralSection, () => + ctx.slots.register({ + name: 'settings.section', + id: 'general', + order: 0, + label: t('general.nav'), + children: { 'settings.general.item': { kind: 'list', scope: 'root' } }, + inject: generalInjected, + }, GeneralSection)) + const offLocale = ctx.on('locale/change', () => { + trigger.refresh() + header.refresh() + close.refresh() + general.refresh() + }) + return () => { + offLocale() + trigger.dispose() + header.dispose() + close.dispose() + general.dispose() + } + }, 'ui-settings-general: chrome and section registrations') +} diff --git a/packages/client/ui-settings-general/src/client/locales.ts b/packages/client/ui-settings-general/src/client/locales.ts new file mode 100644 index 0000000000..6fc3295561 --- /dev/null +++ b/packages/client/ui-settings-general/src/client/locales.ts @@ -0,0 +1,40 @@ +/** + * `settings` namespace dictionaries: shell chrome plus the shell-owned + * General section (nav label, skeleton rows). Skeleton-row technical copy + * (Read only / Schema mode / Code mode and their descriptions) is shared + * verbatim across locales per the Figma design. Feature-owned rows + * (Language, Appearance) ship their copy in their own packages. + */ +import type { LocaleDict } from '@deepseek-ai/dsh-client-locale/client' + +const SHARED = { + 'permission.value': 'Read only', + 'toolcall.schema.title': 'Schema mode', + 'toolcall.schema.desc': 'Traditional function calling — invoke tools one at a time', + 'toolcall.code.title': 'Code mode', + 'toolcall.code.desc': 'Chain multiple tools with code — multi-step orchestration', +} satisfies LocaleDict + +/** Simplified Chinese dictionary. */ +export const zh: LocaleDict = { + ...SHARED, + 'trigger': '设置', + 'title': '设置', + 'close': '关闭', + 'general.nav': '通用设置', + 'permission.title': '权限', + 'permission.desc': '选择默认权限模式', + 'toolcall.title': '工具调用', +} + +/** English dictionary. */ +export const en: LocaleDict = { + ...SHARED, + 'trigger': 'Settings', + 'title': 'Settings', + 'close': 'Close', + 'general.nav': 'General', + 'permission.title': 'Permission', + 'permission.desc': 'Choose default permission mode', + 'toolcall.title': 'Tool Call', +} diff --git a/packages/client/ui-settings-general/src/css-modules.d.ts b/packages/client/ui-settings-general/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-settings-general/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record<string, string> + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-settings-general/src/index.ts b/packages/client/ui-settings-general/src/index.ts new file mode 100644 index 0000000000..94b9bdf674 --- /dev/null +++ b/packages/client/ui-settings-general/src/index.ts @@ -0,0 +1,4 @@ +/** Host loader entry for the browser implementation exported from `./client`. */ + +/** Host plugin body — no host-side behavior for the general settings plugin. */ +export function apply(): void {} diff --git a/packages/client/ui-settings-general/src/invariant.ts b/packages/client/ui-settings-general/src/invariant.ts new file mode 100644 index 0000000000..29f762834d --- /dev/null +++ b/packages/client/ui-settings-general/src/invariant.ts @@ -0,0 +1,32 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-settings-general`. + * @module @deepseek-ai/dsh-client-ui-settings-general/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-settings-general' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-settings-general-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: a copy-owning registrant contributing chrome content + * and the General section into shell-declared slots — it emits no cordis + * events and owns no cross-plugin mutable relation; slot conflicts already + * fail loud in the slot core at load time. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/ui-settings-general/tests/apply.spec.ts b/packages/client/ui-settings-general/tests/apply.spec.ts new file mode 100644 index 0000000000..d01be576b7 --- /dev/null +++ b/packages/client/ui-settings-general/tests/apply.spec.ts @@ -0,0 +1,150 @@ +/** Ownerless-copy registrations: the four seats, the dictionaries, locale refresh, and HMR recovery. */ +import { Context } from 'cordis' +import { describe, expect, it } from 'vitest' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' +import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client' +import type { GeneralSectionInjected } from '@deepseek-ai/dsh-client-ui-settings-general/client' +import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx' +import { GeneralSection } from '../src/client/GeneralSection.tsx' + +/** The four seats this plugin fills (slot name → expected component). */ +const SEATS = [ + ['settings.trigger', TriggerContent], + ['settings.header', HeaderContent], + ['settings.close', CloseLabel], + ['settings.section', GeneralSection], +] as const + +async function bench() { + const ctx = new Context() + await ctx.plugin(SlotsService).await() + const locale = new LocaleService(ctx) + ctx.provide('locale', locale) + return { ctx, slots: ctx.get('slots') as SlotsService, locale } +} + +/** Declare the shell's four child slots the way ui-settings' entry does. */ +function declare(slots: SlotsService): () => void { + return slots.register( + { + name: 'root', + children: { + 'settings.trigger': { kind: 'single', scope: 'root' }, + 'settings.header': { kind: 'single', scope: 'root' }, + 'settings.close': { kind: 'single', scope: 'root' }, + 'settings.section': { kind: 'list', scope: 'root' }, + }, + } as never, + () => null, + ) +} + +function generalEntry(slots: SlotsService) { + return slots.entries('settings.section').find(e => e.component === GeneralSection) +} + +describe('ui-settings-general apply', () => { + it('declares the services it uses', () => { + expect(inject).toEqual(['slots', 'locale']) + }) + + it('fills all four seats for declarations before or after apply', async () => { + const before = await bench() + declare(before.slots) + await before.ctx.plugin({ inject: [...inject], apply }).await() + for (const [name, component] of SEATS) { + expect(before.slots.entries(name)[0]!.component).toBe(component) + } + const entry = generalEntry(before.slots)! + expect(entry.options).toEqual({ id: 'general', order: 0, label: '通用设置' }) + expect(before.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) + const injected = (entry.inject as unknown as () => GeneralSectionInjected)() + expect(injected.t('permission.title')).toBe('权限') + // The chrome seats share one inject face: the settings-ns translate. + const chrome = (before.slots.entries('settings.trigger')[0]!.inject as unknown as () => GeneralSectionInjected)() + expect(chrome.t('trigger')).toBe('设置') + + const after = await bench() + await after.ctx.plugin({ inject: [...inject], apply }).await() + for (const [name] of SEATS) expect(after.slots.entries(name)).toHaveLength(0) + declare(after.slots) + await Promise.resolve() + for (const [name, component] of SEATS) { + expect(after.slots.entries(name)[0]!.component).toBe(component) + // The self-inflicted ledger notifications hit the duplicate guard. + expect(after.slots.entries(name)).toHaveLength(1) + } + }) + + it('registers the zh/en settings dictionaries and frees the seats on teardown', async () => { + const b = await bench() + declare(b.slots) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(b.locale.bind('settings')('title')).toBe('设置') + b.locale.setLocale('en') + expect(b.locale.bind('settings')('close')).toBe('Close') + b.locale.setLocale('zh') + await fiber.dispose() + // The (ns, locale) seats are free again — the dictionary disposers ran. + expect(() => b.locale.register('settings', 'zh', {})).not.toThrow() + expect(() => b.locale.register('settings', 'en', {})).not.toThrow() + }) + + it('refreshes all four seats on locale change with fresh General label text', async () => { + const b = await bench() + declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const zhVersions = SEATS.map(([name]) => b.slots.getVersion(name)) + b.locale.setLocale('en') + // Every seat re-registered (version moved) and the label re-resolved. + SEATS.forEach(([name], i) => { + expect(b.slots.getVersion(name)).toBeGreaterThan(zhVersions[i]!) + expect(b.slots.entries(name)).toHaveLength(1) + }) + expect(generalEntry(b.slots)!.options.label).toBe('General') + b.locale.setLocale('zh') + expect(generalEntry(b.slots)!.options.label).toBe('通用设置') + }) + + it('locale change while the slots are undeclared stays a no-op', async () => { + const b = await bench() + await b.ctx.plugin({ inject: [...inject], apply }).await() + b.locale.setLocale('en') + for (const [name] of SEATS) expect(b.slots.entries(name)).toHaveLength(0) + b.locale.setLocale('zh') + }) + + it('re-registers after an HMR collapse of the declaring chain (stale disposers must not block)', async () => { + const b = await bench() + const redeclare = declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + // Declarer unload: the cascade removes every seat entry and the item + // declaration while our local disposers go stale. + redeclare() + for (const [name] of SEATS) expect(b.slots.entries(name)).toHaveLength(0) + expect(b.slots.spec('settings.general.item')).toBeUndefined() + declare(b.slots) + await Promise.resolve() + for (const [name, component] of SEATS) { + expect(b.slots.entries(name)[0]!.component).toBe(component) + } + expect(b.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) + // The recovered registrations still ride the locale path. + b.locale.setLocale('en') + expect(generalEntry(b.slots)!.options.label).toBe('General') + b.locale.setLocale('zh') + }) + + it('removes every seat and the item declaration on teardown', async () => { + const b = await bench() + declare(b.slots) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(b.slots.spec('settings.general.item')).toBeDefined() + await fiber.dispose() + for (const [name] of SEATS) expect(b.slots.entries(name)).toHaveLength(0) + expect(b.slots.spec('settings.general.item')).toBeUndefined() + }) +}) diff --git a/packages/client/ui-settings-general/tests/components.spec.tsx b/packages/client/ui-settings-general/tests/components.spec.tsx new file mode 100644 index 0000000000..2a041c6cf4 --- /dev/null +++ b/packages/client/ui-settings-general/tests/components.spec.tsx @@ -0,0 +1,72 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, render, screen } from '@testing-library/react' +import type { GeneralSectionComponentProps } from '../src/client/GeneralSection.tsx' +import { GeneralSection } from '../src/client/GeneralSection.tsx' +import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx' +import { en } from '../src/client/locales.ts' + +afterEach(cleanup) + +const t = (key: string) => en[key] ?? key + +// Global standard kit stubs: none of these components consume the hooks. +const unusedHook = (() => { throw new Error('unused by settings-general components') }) as never +const kit = { useSessions: unusedHook, useWorkspaces: unusedHook } + +describe('chrome content', () => { + it('TriggerContent renders the icon with the label in the wide column', () => { + const { container } = render(<TriggerContent {...kit} wide t={t} />) + expect(container.querySelector('svg')).toBeTruthy() + expect(screen.getByText('Settings')).toBeTruthy() + }) + + it('TriggerContent drops the label in the rail state', () => { + const { container } = render(<TriggerContent {...kit} wide={false} t={t} />) + expect(container.querySelector('svg')).toBeTruthy() + expect(screen.queryByText('Settings')).toBeNull() + }) + + it('HeaderContent and CloseLabel render their translated text', () => { + render(<HeaderContent {...kit} t={t} />) + render(<CloseLabel {...kit} t={t} />) + expect(screen.getByText('Settings')).toBeTruthy() + expect(screen.getByText('Close')).toBeTruthy() + }) +}) + +describe('GeneralSection', () => { + function mount() { + const renderSlot = vi.fn( + ((key: string) => <div data-testid={`slot-${key}`} />) as GeneralSectionComponentProps['renderSlot'], + ) + const props: GeneralSectionComponentProps = { ...kit, t, renderSlot } + const view = render(<GeneralSection {...props} />) + return { view, renderSlot } + } + + it('renders the Permission skeleton row with the disabled selector', () => { + mount() + expect(screen.getByText('Permission')).toBeTruthy() + expect(screen.getByText('Choose default permission mode')).toBeTruthy() + const selector = screen.getByRole('button', { name: /Read only/ }) as HTMLButtonElement + expect(selector.disabled).toBe(true) + }) + + it('renders the Tool Call skeleton cubes with schema pinned selected', () => { + mount() + expect(screen.getByText('Tool Call')).toBeTruthy() + const schema = screen.getByText('Schema mode') + const code = screen.getByText('Code mode') + expect(schema.parentElement!.className).toContain('selected') + expect(code.parentElement!.className).not.toContain('selected') + expect(screen.getByText('Traditional function calling — invoke tools one at a time')).toBeTruthy() + expect(screen.getByText('Chain multiple tools with code — multi-step orchestration')).toBeTruthy() + }) + + it('renders the feature-contributed item slot after the skeleton rows', () => { + const { renderSlot } = mount() + expect(renderSlot).toHaveBeenCalledWith('settings.general.item', {}) + expect(screen.getByTestId('slot-settings.general.item')).toBeTruthy() + }) +}) diff --git a/packages/client/ui-settings-general/tests/invariant.spec.ts b/packages/client/ui-settings-general/tests/invariant.spec.ts new file mode 100644 index 0000000000..7b0527c0ff --- /dev/null +++ b/packages/client/ui-settings-general/tests/invariant.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import * as GeneralInvariant from '@deepseek-ai/dsh-client-ui-settings-general/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' + +describe('invariant companion', () => { + it('registers under the package name with an empty installer', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + await expect(ctx.plugin(GeneralInvariant).await()).resolves.toBeDefined() + }) + + it('node-half apply is a no-op host placeholder', async () => { + const { apply } = await import('@deepseek-ai/dsh-client-ui-settings-general') + apply() + expect(true).toBe(true) // reaching here without throw is the contract + }) +}) diff --git a/packages/client/ui-settings-general/tsconfig.json b/packages/client/ui-settings-general/tsconfig.json new file mode 100644 index 0000000000..5ef01ba51c --- /dev/null +++ b/packages/client/ui-settings-general/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../ui-slots" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../runtime" + }, + { + "path": "../ui-settings" + }, + { + "path": "../locale" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/client/ui-settings-general/tsdown.config.ts b/packages/client/ui-settings-general/tsdown.config.ts new file mode 100644 index 0000000000..bf67c4f10f --- /dev/null +++ b/packages/client/ui-settings-general/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-settings-general', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/ui-settings/README.i18n.yaml b/packages/client/ui-settings/README.i18n.yaml new file mode 100644 index 0000000000..91f8103288 --- /dev/null +++ b/packages/client/ui-settings/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: bb99f9b37927eec57650aa4025deb043b369c78e +README.zh.md: fce11e2cf44fe6c1debe850df644b0114dbde5e3 diff --git a/packages/client/ui-settings/README.md b/packages/client/ui-settings/README.md new file mode 100644 index 0000000000..bb99f9b379 --- /dev/null +++ b/packages/client/ui-settings/README.md @@ -0,0 +1,17 @@ +# @deepseek-ai/dsh-client-ui-settings + +English | [中文](README.zh.md) + +Settings shell plugin: a pure composition face. It occupies `sidebar.settings` with the trigger chrome and the modal settings panel, and declares the slots registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content) and `settings.section` (one page per feature). The shell ships no copy and reads no locale state — all text arrives from registrants (ui-settings-general owns chrome and General; features own their sections and rows), so the section ledger bump is its only re-render trigger. + +## Model Experience + +None, as the settings shell serves browser UI composition; nothing here reaches a model request. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **Panel is browser-preference scope only** — host-side settings surfaces (permission mode, tool-call mode) have no RPC backing yet; their skeletons live in ui-settings-general. diff --git a/packages/client/ui-settings/README.zh.md b/packages/client/ui-settings/README.zh.md new file mode 100644 index 0000000000..fce11e2cf4 --- /dev/null +++ b/packages/client/ui-settings/README.zh.md @@ -0,0 +1,17 @@ +# @deepseek-ai/dsh-client-ui-settings + +[English](README.md) | 中文 + +设置外壳插件:一个纯组合表层。它以触发控件和模态设置面板占用 `sidebar.settings`,并声明由注册方填充的 slot:`settings.trigger`/`settings.header`/`settings.close`(界面框架内容)和 `settings.section`(每项功能一页)。外壳不自带文案,也不读取 locale 状态:所有文本都来自注册方(ui-settings-general 拥有界面框架和「通用」分区;各功能拥有各自的分区和行),因此只有分区账本更新会触发它重新渲染。 + +## 模型体验 + +无。设置外壳为浏览器 UI 提供组合能力;这里没有任何内容进入模型请求。 + +#### KV Cache 影响 + +无;该包既不组装也不发送提供方请求。 + +## 已知限制与暂缓事项 + +- **面板仅涵盖浏览器偏好设置**:宿主侧设置表层(权限模式、工具调用模式)尚无 RPC 支撑;其骨架位于 ui-settings-general。 diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json new file mode 100644 index 0000000000..efadf3190f --- /dev/null +++ b/packages/client/ui-settings/package.json @@ -0,0 +1,66 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-settings", + "description": "Settings shell plugin: sidebar trigger + modal panel occupying sidebar.settings; declares the settings.section list slot", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dshClient": { + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-sidebar" + ], + "platform": "web" + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "BSD-3-Clause", + "dependencies": { + "clsx": "^2.0.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-client-runtime": "^0.0.1", + "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", + "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@types/react": "~18.3.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ] +} diff --git a/packages/client/ui-settings/src/client/SettingsRoot.module.css b/packages/client/ui-settings/src/client/SettingsRoot.module.css new file mode 100644 index 0000000000..e2b2c878df --- /dev/null +++ b/packages/client/ui-settings/src/client/SettingsRoot.module.css @@ -0,0 +1,202 @@ +/* Settings shell (figma 501:29904 mask context / 501:29947 panel): sidebar + foot trigger row + centered 1080x700 modal panel. The trigger reproduces + the former sidebar foot geometry (49px wide row / 36px rail circle); the + panel is a two-column layout — 188px nav rail + content column with a + 54px header and the 24px-padded options area. */ + +/* Trigger row (former sidebar foot, figma 133:7668): 49px hover pill. */ +.trigger { + flex: none; + display: flex; + align-items: center; + gap: 8px; + width: 100%; + height: 49px; + margin: 8px 0 0; + padding: 0 2px 0 6px; + border: none; + border-radius: 12px; + background: transparent; + cursor: pointer; + overflow: hidden; + color: var(--dsw-alias-label-primary); + font-family: inherit; + font-size: 14px; +} + +.trigger:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +/* Rail trigger: the same 36x36 circle box as the other rail controls. */ +.trigger.rail { + width: 36px; + height: 36px; + margin: 18px 0 10px; + justify-content: center; + gap: 0; + padding: 0; + border-radius: 50%; +} + +.triggerLabel { + overflow: hidden; + white-space: nowrap; +} + +/* Full-viewport layer (figma Mask 501:29946 #000@24%, no blur). */ +.overlay { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; +} + +.mask { + position: absolute; + inset: 0; + background: var(--dsw-alias-bg-mask-1); +} + +/* Panel (figma Settings 501:29947): 1080x700, r24, white, lv3 shadow + (figma effects match --dsw-shadow-lv3 exactly). */ +.panel { + position: relative; + z-index: 1; + display: flex; + width: 1080px; + height: 700px; + max-width: calc(100vw - 48px); + max-height: calc(100vh - 48px); + border-radius: 24px; + overflow: hidden; + background: var(--dsw-alias-bg-layer-1); + box-shadow: var(--dsw-shadow-lv3); +} + +/* Nav rail (figma .Setting-nav 501:29958): 188 wide, pad (12,22,12,0), + gap 18, no own fill — the panel white shows through. */ +.nav { + flex: none; + display: flex; + flex-direction: column; + gap: 18px; + width: 188px; + padding: 22px 12px 0; + box-sizing: border-box; +} + +/* Title row (figma 501:29959): 16/500 lh24, 12px side padding. */ +.navTitle { + padding: 0 12px; + font-size: 16px; + line-height: 24px; + font-weight: 500; + color: var(--dsw-alias-label-primary); +} + +/* Cell stack (figma 501:29961): gap 4. */ +.navList { + display: flex; + flex-direction: column; + gap: 4px; +} + +/* Nav cell (figma .Setting-nav-cell 501:29962): 164x40, r12, pad + (12,9,16,9), gap 8; label 14/400 lh22; selected fill #EBEEF2. */ +.navCell { + display: flex; + align-items: center; + gap: 8px; + height: 40px; + padding: 9px 16px 9px 12px; + box-sizing: border-box; + border: none; + border-radius: 12px; + background: transparent; + cursor: pointer; + font-family: inherit; + font-size: 14px; + line-height: 22px; + font-weight: 400; + color: var(--dsw-alias-label-primary); + text-align: left; +} + +.navCell:hover { + background: var(--dsw-specific-sidebar-nav-item-hover); +} + +.navCell.active { + background: var(--dsw-specific-sidebar-nav-item-active); +} + +.navIcon { + flex: none; +} + +.navLabel { + flex: 1; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +/* Content column (figma Content 501:29980): header + options. */ +.content { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +/* Header (figma .Header 501:29981): h54, pad (10,20,14,8), close right. */ +.header { + flex: none; + display: flex; + align-items: flex-start; + justify-content: flex-end; + height: 54px; + padding: 20px 14px 8px 10px; + box-sizing: border-box; +} + +/* Close button (figma .Icon_container 501:29982): 28x28, r28, 14px glyph. */ +.close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: none; + border-radius: 28px; + background: transparent; + cursor: pointer; + color: var(--dsw-alias-label-primary); +} + +.close:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +/* Options area (figma Options 501:29983): pad (24,0,24,8), scrolls. */ +.options { + flex: 1; + min-height: 0; + padding: 0 24px 8px; + overflow-y: auto; +} + +/* Visually-hidden text seat (close button accessible name from slot content). */ +.hiddenLabel { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} diff --git a/packages/client/ui-settings/src/client/SettingsRoot.tsx b/packages/client/ui-settings/src/client/SettingsRoot.tsx new file mode 100644 index 0000000000..04fa39a03c --- /dev/null +++ b/packages/client/ui-settings/src/client/SettingsRoot.tsx @@ -0,0 +1,124 @@ +/** + * Settings shell root: the sidebar-foot trigger row plus the centered modal + * panel (figma 501:29947, 1080x700) with the section nav rail. The shell is + * a pure composition face — every piece of text (trigger label, panel title, + * close label, sections) arrives from registrants through slots; accessible + * names resolve to that content (trigger: its own text; dialog: + * aria-labelledby the title node; close: visually-hidden slot text). Modal + * open state and the active section id are component-local viewing state. + */ +import { useCallback, useEffect, useId, useRef, useState } from 'react' +import clsx from 'clsx' +import { IconCloseOutline16, IconDataOutline16, IconSettingsOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import type { SettingsRootComponentProps } from './contract/slots.ts' +import css from './SettingsRoot.module.css' + +/** Nav glyph by section id; unknown ids fall back to the settings gear. */ +function navIcon(id: string) { + if (id === 'models') return <IconDataOutline16 className={css.navIcon} size={16} /> + return <IconSettingsOutline16 className={css.navIcon} size={16} /> +} + +type PanelProps = { + rows: ReturnType<SettingsRootComponentProps['sections']> + renderSlot: SettingsRootComponentProps['renderSlot'] + onClose: () => void +} + +/** + * The modal layer: full-viewport mask + centered panel. Close paths: the + * header button, a mask click, and document-level Escape (mounted only while + * open, so the listener lifetime is the panel's). + */ +function SettingsPanel({ rows, renderSlot, onClose }: PanelProps) { + // Local selection; entries can unmount underneath it, so the render-time + // projection falls back to the first row when the id is gone. + const [activeId, setActiveId] = useState<string | undefined>(undefined) + const active = rows.find((r) => r.id === activeId)?.id ?? rows[0]?.id + const titleId = useId() + + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + document.addEventListener('keydown', onKeyDown) + return () => { document.removeEventListener('keydown', onKeyDown) } + }, [onClose]) + + // Baseline focus management: entering the dialog lands on the close button. + const closeButton = useRef<HTMLButtonElement | null>(null) + useEffect(() => { closeButton.current?.focus() }, []) + + return ( + <div className={css.overlay} role="presentation"> + <div className={css.mask} aria-hidden="true" onClick={onClose} /> + <div className={css.panel} role="dialog" aria-modal="true" aria-labelledby={titleId}> + <nav className={css.nav}> + <div className={css.navTitle} id={titleId}>{renderSlot('settings.header', {})}</div> + <div className={css.navList}> + {rows.map((row) => ( + <button + key={row.id} + type="button" + className={clsx(css.navCell, row.id === active && css.active)} + aria-current={row.id === active ? 'true' : undefined} + onClick={() => { setActiveId(row.id) }} + > + {navIcon(row.id)} + <span className={css.navLabel}>{row.label}</span> + </button> + ))} + </div> + </nav> + <div className={css.content}> + <div className={css.header}> + <button ref={closeButton} type="button" className={css.close} onClick={onClose}> + <IconCloseOutline16 size={14} /> + <span className={css.hiddenLabel}>{renderSlot('settings.close', {})}</span> + </button> + </div> + <div className={css.options}> + {active !== undefined && renderSlot('settings.section', {}, { only: active })} + </div> + </div> + </div> + </div> + ) +} + +/** + * Render the settings trigger and panel. + * @param props - composed slot props (contract/slots.ts). + * @returns the settings shell element tree. + */ +export function SettingsRoot(props: SettingsRootComponentProps) { + const { wide, subscribeSections, sectionsVersion, sections, renderSlot } = props + const [open, setOpen] = useState(false) + const close = useCallback(() => { setOpen(false) }, []) + + // The ledger tick keeps the nav rows fresh: registrants re-register with + // freshly localized text on locale change, and the trigger/header/close + // seats re-render through their own outlets' subscriptions. + // State = ledger version: same-version notifications dedupe to no render. + const [, setSectionsRev] = useState(() => sectionsVersion()) + useEffect( + () => subscribeSections(() => { setSectionsRev(sectionsVersion()) }), + [subscribeSections, sectionsVersion], + ) + const rows = sections() + + return ( + <> + <button + type="button" + className={clsx(css.trigger, !wide && css.rail)} + aria-haspopup="dialog" + aria-expanded={open} + onClick={() => { setOpen(true) }} + > + {renderSlot('settings.trigger', { wide })} + </button> + {open && <SettingsPanel rows={rows} renderSlot={renderSlot} onClose={close} />} + </> + ) +} diff --git a/packages/client/ui-settings/src/client/contract/slots.ts b/packages/client/ui-settings/src/client/contract/slots.ts new file mode 100644 index 0000000000..1a263108bc --- /dev/null +++ b/packages/client/ui-settings/src/client/contract/slots.ts @@ -0,0 +1,97 @@ +/** + * Settings shell slot contract — the canonical home of every settings slot + * type. The shell is a pure composition face with zero copy of its own: it + * occupies the sidebar-owned `sidebar.settings` hole and declares the slots + * below; ALL text (trigger label, panel title, close aria, section content) + * arrives from registrants. A feature owns its settings surface — adding a + * setting never means editing the shell; copy that belongs to no single + * feature (chrome, the General section) is owned by ui-settings-general. + */ +import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +// Type-only: pulls ui-sidebar's SlotMap merge (the 'sidebar.settings' entry) +// into every program that sees this contract. +import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SlotMap { + /** + * The sidebar-foot trigger row content: icon + label, supplied as slot + * content (the accessible name comes from the content — rail state + * renders the label visually hidden). The shell renders the button + * chrome and owns open state. Absent contribution degrades to an + * icon-only button without an accessible name (broken-composition state; + * the shipped composition always registers the seat). + */ + 'settings.trigger': { kind: 'single'; scope: 'root'; owner: SettingsTriggerOwnerProps } + /** + * The panel title text seat. Content renders inside the nav heading row; + * the dialog's accessible name points at that node via aria-labelledby. + * Absent contribution leaves the heading empty. + */ + 'settings.header': { kind: 'single'; scope: 'root'; owner: SettingsHeaderOwnerProps } + /** + * The close button's visually-hidden label text (the button itself — + * icon, geometry, focus — is shell chrome). Absent contribution leaves + * the button without an accessible name (broken-composition state). + */ + 'settings.close': { kind: 'single'; scope: 'root'; owner: SettingsHeaderOwnerProps } + /** + * One settings page per list entry. Registrant options carry the nav + * identity: `id` (section key, drives `only` filtering), `order` (nav + * position), `label` (registrant-localized display text — the registrant + * re-registers with fresh text on locale change, so the shell never + * subscribes locale state; the ledger bump doubles as the shell's + * re-render trigger). Sections render inside the panel content column. + * (`settings.general.item`, declared by ui-settings-general's General + * entry, is typed in the locale package — the common dependency of every + * item registrant; the shell neither declares nor renders it.) + */ + 'settings.section': { kind: 'list'; scope: 'root'; owner: SettingsSectionOwnerProps } + } +} + +/** Owner share of the trigger content seat: the sidebar column state. */ +export interface SettingsTriggerOwnerProps { + /** Whether the sidebar renders wide content (false = 56px rail, icon only). */ + wide: boolean +} + +/** Owner share of the header title seat (the shell supplies nothing). */ +export interface SettingsHeaderOwnerProps { + /** Marker field: header owner props are intentionally empty. */ + children?: never +} + +/** + * Owner share of a settings section entry. The shell owns modal visibility + * and navigation; sections receive nothing but the render site (their data + * arrives through their own inject faces and stores). + */ +export interface SettingsSectionOwnerProps { + /** Marker field: section owner props are intentionally empty for now. */ + children?: never +} + +/** + * Registrant-private injected share of the settings shell (assembled in + * apply): ledger projections only — the shell reads no locale state. + */ +export type SettingsRootInjected = { + /** Read the settings.section ledger version (nav invalidation). */ + sectionsVersion: () => number + /** Subscribe to settings.section ledger changes. */ + subscribeSections: (listener: () => void) => () => void + /** Project the settings.section ledger into nav rows (id/order/label). */ + sections: () => readonly { id: string; order: number; label: string }[] +} + +/** + * Full component props of the settings shell root: the sidebar owner share + * (wide/rail state) plus the declared render shares and the injected face. + * No store is registered — modal open state and active section id are + * component-local viewing state. + */ +export type SettingsRootComponentProps = + PropsRuntime<'sidebar.settings'> + & PropsRenderSlots<'settings.trigger' | 'settings.header' | 'settings.close' | 'settings.section'> + & SettingsRootInjected diff --git a/packages/client/ui-settings/src/client/index.ts b/packages/client/ui-settings/src/client/index.ts new file mode 100644 index 0000000000..7cb3dfd6d4 --- /dev/null +++ b/packages/client/ui-settings/src/client/index.ts @@ -0,0 +1,61 @@ +/** + * Settings shell plugin, browser half. A pure composition face: occupies the + * sidebar-owned `sidebar.settings` hole with the trigger chrome + modal + * panel, declares the `settings.trigger` / `settings.header` / + * `settings.section` slots, and projects the section ledger into the panel + * navigation. The shell ships no copy and reads no locale state — all text + * arrives from registrants (ui-settings-general owns the chrome and General + * content; features own their rows and sections). Export discipline: + * packages/client/AGENTS.md. + */ +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots' +import type { SettingsRootInjected } from './contract/slots.ts' +import { SettingsRoot } from './SettingsRoot.tsx' + +export type { + SettingsHeaderOwnerProps, SettingsRootComponentProps, SettingsRootInjected, + SettingsSectionOwnerProps, SettingsTriggerOwnerProps, +} from './contract/slots.ts' + +/** + * Required services (cordis fiber inject). The target slot is declared by + * ui-sidebar's apply, whose activation order relative to this one is NOT + * constrained (dshClient.inject edges are informational); registration goes + * through declaration-aware deferral. + */ +export const inject = ['slots'] + +/** + * Register the settings shell into `sidebar.settings` once the declaration is + * on the ledger. + * @param ctx - client root context. + */ +export function apply(ctx: ClientContext): void { + const injected = (): SettingsRootInjected => ({ + sectionsVersion: () => ctx.slots.getVersion('settings.section'), + subscribeSections: listener => ctx.slots.subscribe('settings.section', listener), + sections: () => ctx.slots.entries('settings.section') + .map(e => ({ + /* v8 ignore next -- list-slot registration requires id (SlotCore rejects an entry without one) */ + id: e.options.id ?? '', + order: e.options.order ?? 0, + label: e.options.label ?? '', + })) + .sort((a, b) => a.order - b.order), + }) + ctx.effect(() => { + const deferred = deferRegistration(ctx.slots, 'sidebar.settings', SettingsRoot, () => + ctx.slots.register({ + name: 'sidebar.settings', + children: { + 'settings.trigger': { kind: 'single', scope: 'root' }, + 'settings.header': { kind: 'single', scope: 'root' }, + 'settings.close': { kind: 'single', scope: 'root' }, + 'settings.section': { kind: 'list', scope: 'root' }, + }, + inject: injected, + }, SettingsRoot)) + return () => { deferred.dispose() } + }, 'ui-settings: shell registration') +} diff --git a/packages/client/ui-settings/src/css-modules.d.ts b/packages/client/ui-settings/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-settings/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record<string, string> + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-settings/src/index.ts b/packages/client/ui-settings/src/index.ts new file mode 100644 index 0000000000..52fe4c43e6 --- /dev/null +++ b/packages/client/ui-settings/src/index.ts @@ -0,0 +1,4 @@ +/** Host loader entry for the browser implementation exported from `./client`. */ + +/** Host plugin body — no host-side behavior for the settings shell plugin. */ +export function apply(): void {} diff --git a/packages/client/ui-settings/src/invariant.ts b/packages/client/ui-settings/src/invariant.ts new file mode 100644 index 0000000000..53d7fb066a --- /dev/null +++ b/packages/client/ui-settings/src/invariant.ts @@ -0,0 +1,32 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-settings`. + * @module @deepseek-ai/dsh-client-ui-settings/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-settings' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-settings-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: a presentation shell projecting the settings.section + * ledger into navigation — it emits no cordis events and owns no cross-plugin + * mutable relation; slot declaration/registration conflicts already fail loud + * in the slot core at load time. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/ui-settings/tests/apply.spec.ts b/packages/client/ui-settings/tests/apply.spec.ts new file mode 100644 index 0000000000..d7fdfbd546 --- /dev/null +++ b/packages/client/ui-settings/tests/apply.spec.ts @@ -0,0 +1,111 @@ +/** Settings shell registration: declaration-aware deferral, the ledger projections, and HMR recovery. */ +import { Context } from 'cordis' +import { describe, expect, it, vi } from 'vitest' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings/client' +import type { SettingsRootInjected } from '@deepseek-ai/dsh-client-ui-settings/client' +import { SettingsRoot } from '../src/client/SettingsRoot.tsx' + +async function bench() { + const ctx = new Context() + await ctx.plugin(SlotsService).await() + return { ctx, slots: ctx.get('slots') as SlotsService } +} + +function declare(slots: SlotsService): () => void { + return slots.register( + { name: 'root', children: { 'sidebar.settings': { kind: 'single', scope: 'root' } } } as never, + () => null, + ) +} + +function injectedOf(slots: SlotsService): SettingsRootInjected { + const entry = slots.entries('sidebar.settings')[0]! + return (entry.inject as () => SettingsRootInjected)() +} + +/** The shell's four child declarations (chrome seats + the section list). */ +const CHILD_SPECS = { + 'settings.trigger': { kind: 'single', scope: 'root' }, + 'settings.header': { kind: 'single', scope: 'root' }, + 'settings.close': { kind: 'single', scope: 'root' }, + 'settings.section': { kind: 'list', scope: 'root' }, +} as const + +describe('ui-settings apply', () => { + it('declares only the slot registry (a pure composition face, no locale)', () => { + expect(inject).toEqual(['slots']) + }) + + it('registers the shell and declares the four child slots, before or after the declaration', async () => { + const before = await bench() + declare(before.slots) + await before.ctx.plugin({ inject: [...inject], apply }).await() + expect(before.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot) + for (const [name, spec] of Object.entries(CHILD_SPECS)) { + expect(before.slots.spec(name as never)).toEqual(spec) + } + + const after = await bench() + await after.ctx.plugin({ inject: [...inject], apply }).await() + expect(after.slots.entries('sidebar.settings')).toHaveLength(0) + declare(after.slots) + await Promise.resolve() + expect(after.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot) + // The self-inflicted ledger notifications hit the duplicate guard. + expect(after.slots.entries('sidebar.settings')).toHaveLength(1) + }) + + it('projects the section ledger into ordered nav rows with option defaults', async () => { + const b = await bench() + declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const injected = injectedOf(b.slots) + // The shell ships no sections of its own — registrants fill the ledger. + expect(injected.sections()).toEqual([]) + b.slots.register({ name: 'settings.section', id: 'z', order: 20, label: 'Z' } as never, () => null) + // No order and no label: both projection defaults apply. + b.slots.register({ name: 'settings.section', id: 'a' } as never, () => null) + expect(injected.sections()).toEqual([ + { id: 'a', order: 0, label: '' }, + { id: 'z', order: 20, label: 'Z' }, + ]) + expect(injected.sectionsVersion()).toBe(b.slots.getVersion('settings.section')) + const listener = vi.fn() + const off = injected.subscribeSections(listener) + b.slots.register({ name: 'settings.section', id: 'b', order: 1, label: 'B' } as never, () => null) + await Promise.resolve() + expect(listener).toHaveBeenCalled() + off() + }) + + it('re-registers after an HMR collapse re-declares the slot (stale disposer must not block)', async () => { + const b = await bench() + const redeclare = declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + expect(b.slots.entries('sidebar.settings')).toHaveLength(1) + // Declarer unload: the cascade removes our entry and every child + // declaration while our local disposer variable goes stale. + redeclare() + expect(b.slots.entries('sidebar.settings')).toHaveLength(0) + expect(b.slots.spec('settings.trigger')).toBeUndefined() + declare(b.slots) + await Promise.resolve() + expect(b.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot) + for (const [name, spec] of Object.entries(CHILD_SPECS)) { + expect(b.slots.spec(name as never)).toEqual(spec) + } + }) + + it('unregisters the shell and collapses all four child slots on teardown', async () => { + const b = await bench() + declare(b.slots) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + await fiber.dispose() + expect(b.slots.entries('sidebar.settings')).toHaveLength(0) + for (const name of Object.keys(CHILD_SPECS)) { + expect(b.slots.spec(name as never)).toBeUndefined() + } + }) +}) diff --git a/packages/client/ui-settings/tests/invariant.spec.ts b/packages/client/ui-settings/tests/invariant.spec.ts new file mode 100644 index 0000000000..c3474d5bdd --- /dev/null +++ b/packages/client/ui-settings/tests/invariant.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import * as SettingsInvariant from '@deepseek-ai/dsh-client-ui-settings/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' + +describe('invariant companion', () => { + it('registers under the package name with an empty installer', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + await expect(ctx.plugin(SettingsInvariant).await()).resolves.toBeDefined() + }) + + it('node-half apply is a no-op host placeholder', async () => { + const { apply } = await import('@deepseek-ai/dsh-client-ui-settings') + apply() + expect(true).toBe(true) // reaching here without throw is the contract + }) +}) diff --git a/packages/client/ui-settings/tests/settings-root.spec.tsx b/packages/client/ui-settings/tests/settings-root.spec.tsx new file mode 100644 index 0000000000..9584d500e5 --- /dev/null +++ b/packages/client/ui-settings/tests/settings-root.spec.tsx @@ -0,0 +1,180 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import type { SettingsRootComponentProps } from '../src/client/contract/slots.ts' +import { SettingsRoot } from '../src/client/SettingsRoot.tsx' + +afterEach(cleanup) + +type Row = { id: string; order: number; label: string } + +/** Slot-content stand-ins: the shell renders whatever the seats contribute. */ +const SEAT_CONTENT: Record<string, string> = { + 'settings.trigger': 'Settings', + 'settings.header': 'Settings Title', + 'settings.close': 'Close', +} + +function mount({ + wide = true, + rows = [ + { id: 'general', order: 0, label: 'General' }, + { id: 'models', order: 10, label: 'Models' }, + ], +}: { wide?: boolean; rows?: Row[] } = {}) { + // Mutable row store standing in for the ledger; bump() plays a change. + let current = rows + let version = 0 + const listeners = new Set<() => void>() + const renderSlot = vi.fn( + ((key: string, _owner: unknown, opts?: { only?: string }) => { + if (key === 'settings.section') return <div data-testid={`section-${opts?.only ?? 'all'}`} /> + return SEAT_CONTENT[key] + }) as SettingsRootComponentProps['renderSlot'], + ) + // Global standard kit stubs: the shell consumes neither hook. + const unusedHook = (() => { throw new Error('unused by SettingsRoot') }) as never + const props: SettingsRootComponentProps = { + useSessions: unusedHook, + useWorkspaces: unusedHook, + wide, + sectionsVersion: () => version, + subscribeSections: (listener) => { + listeners.add(listener) + return () => { listeners.delete(listener) } + }, + sections: () => current, + renderSlot, + } + const view = render(<SettingsRoot {...props} />) + const bump = (next: Row[]) => { + act(() => { + current = next + version += 1 + for (const fn of [...listeners]) fn() + }) + } + return { view, renderSlot, bump, listeners } +} + +function openPanel() { + fireEvent.click(screen.getByRole('button', { name: 'Settings' })) +} + +describe('SettingsRoot trigger', () => { + it('renders the trigger seat content as the accessible name (no aria-label of its own)', () => { + const { renderSlot } = mount() + const trigger = screen.getByRole('button', { name: 'Settings' }) + expect(trigger.hasAttribute('aria-label')).toBe(false) + expect(renderSlot).toHaveBeenCalledWith('settings.trigger', { wide: true }) + expect(trigger.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(trigger) + expect(screen.getByRole('dialog')).toBeTruthy() + expect(screen.getByRole('button', { name: 'Settings', expanded: true })).toBeTruthy() + }) + + it('hands the rail state to the trigger seat', () => { + const { renderSlot } = mount({ wide: false }) + expect(renderSlot).toHaveBeenCalledWith('settings.trigger', { wide: false }) + }) +}) + +describe('SettingsPanel chrome seats', () => { + it('names the dialog via aria-labelledby pointing at the header seat node', () => { + mount() + openPanel() + const dialog = screen.getByRole('dialog') + const titleId = dialog.getAttribute('aria-labelledby')! + expect(titleId).toBeTruthy() + const title = document.getElementById(titleId)! + expect(title.textContent).toBe('Settings Title') + expect(screen.getByRole('dialog', { name: 'Settings Title' })).toBeTruthy() + }) + + it('names the close button through the visually-hidden close seat text', () => { + mount() + openPanel() + const close = screen.getByRole('button', { name: 'Close' }) + expect(close.hasAttribute('aria-label')).toBe(false) + expect(close.textContent).toContain('Close') + }) +}) + +describe('SettingsPanel close paths', () => { + it('closes via the header button', () => { + mount() + openPanel() + fireEvent.click(screen.getByRole('button', { name: 'Close' })) + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('closes via a mask click', () => { + mount() + openPanel() + const dialog = screen.getByRole('dialog') + fireEvent.click(dialog.parentElement!.firstElementChild!) + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('closes via document-level Escape and unhooks the listener with the panel', () => { + mount() + openPanel() + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.queryByRole('dialog')).toBeNull() + // Ignored while closed (listener removed with the panel) and non-Escape + // keys are ignored while open. + fireEvent.keyDown(document, { key: 'Escape' }) + openPanel() + fireEvent.keyDown(document, { key: 'Enter' }) + expect(screen.getByRole('dialog')).toBeTruthy() + }) + + it('lands focus on the close button when the dialog opens', () => { + mount() + openPanel() + expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Close' })) + }) +}) + +describe('SettingsPanel navigation', () => { + it('projects rows, marks the first active, and renders only that section', () => { + mount() + openPanel() + expect(screen.getByRole('button', { name: 'General' }).getAttribute('aria-current')).toBe('true') + expect(screen.getByRole('button', { name: 'Models' }).getAttribute('aria-current')).toBeNull() + expect(screen.getByTestId('section-general')).toBeTruthy() + }) + + it('switches the rendered section on nav click', () => { + mount() + openPanel() + fireEvent.click(screen.getByRole('button', { name: 'Models' })) + expect(screen.getByRole('button', { name: 'Models' }).getAttribute('aria-current')).toBe('true') + expect(screen.getByTestId('section-models')).toBeTruthy() + expect(screen.queryByTestId('section-general')).toBeNull() + }) + + it('falls back to the first row when the active entry unregisters', () => { + const { bump } = mount() + openPanel() + fireEvent.click(screen.getByRole('button', { name: 'Models' })) + bump([{ id: 'general', order: 0, label: 'General' }]) + expect(screen.queryByRole('button', { name: 'Models' })).toBeNull() + expect(screen.getByTestId('section-general')).toBeTruthy() + }) + + it('renders an empty content column when the ledger is empty', () => { + const { renderSlot } = mount({ rows: [] }) + openPanel() + expect(screen.getByRole('dialog')).toBeTruthy() + const sectionCalls = renderSlot.mock.calls.filter(c => c[0] === 'settings.section') + expect(sectionCalls).toHaveLength(0) + }) + + it('drops the ledger subscription on unmount', () => { + const { view, listeners } = mount() + expect(listeners.size).toBe(1) + view.unmount() + expect(listeners.size).toBe(0) + }) +}) diff --git a/packages/client/ui-settings/tsconfig.json b/packages/client/ui-settings/tsconfig.json new file mode 100644 index 0000000000..d94f0fab96 --- /dev/null +++ b/packages/client/ui-settings/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../ui-slots" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../runtime" + }, + { + "path": "../ui-sidebar" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/client/ui-settings/tsdown.config.ts b/packages/client/ui-settings/tsdown.config.ts new file mode 100644 index 0000000000..ba06fdc7c9 --- /dev/null +++ b/packages/client/ui-settings/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-settings', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml new file mode 100644 index 0000000000..b9072c3787 --- /dev/null +++ b/packages/client/ui-sidebar/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 93a1f15a5802f94a0ebe930dda1dbd4fbc7343c9 +README.zh.md: 1ef636dbd00c894c8312ab1fbfa9a96af45956f0 diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index d8c2f61ee0..93a1f15a58 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -1,10 +1,14 @@ # @deepseek-ai/dsh-client-ui-sidebar +English | [中文](README.zh.md) + Sidebar plugin: real Host Workspaces in stable Host order, each containing its `sessionIds` in Workspace order with `parentId` nesting; Sessions outside every Workspace appear in a trailing `Ungrouped` section. Search, state dots, and collapse into the layout-owned 56px rail are presentation-local. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). New Session starts the runtime's page-local frontend Session Intent; a real Workspace's "+" starts one targeted to that Workspace. The Workspace header "+" opens ui-workspace's shared picker, whose selection also targets a frontend Session. A Workspace Intent does not appear in the sidebar. -`SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared `sidebar.workspace` child slot, and injected `startSession`, `open`, and sidebar-toggle callbacks. There is no plugin store: `deriveGroups` consumes object-layer snapshots and component-local expansion/search state. +`SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared `sidebar.workspace` and `sidebar.settings` child slots, and injected `startSession`, `open`, and sidebar-toggle callbacks. There is no plugin store: `deriveGroups` consumes object-layer snapshots and component-local expansion/search state. + +The foot is the `sidebar.settings` seat: the sidebar renders only the bottom-pinned layout slot and shares its column state (`wide`); ui-settings registers the trigger row and settings panel there. The `/client` export surface is the plugin body (`apply`/`inject`) plus the contract types only — SidebarRoot, the row components, and the tree derivation are internal (the slot registration closes over them; tests import src paths directly). diff --git a/packages/client/ui-sidebar/README.zh.md b/packages/client/ui-sidebar/README.zh.md new file mode 100644 index 0000000000..1ef636dbd0 --- /dev/null +++ b/packages/client/ui-sidebar/README.zh.md @@ -0,0 +1,27 @@ +# @deepseek-ai/dsh-client-ui-sidebar + +[English](README.md) | 中文 + +侧边栏插件:真实 Host Workspace 按稳定的 Host 顺序排列;每个 Workspace 按自身顺序包含其 `sessionIds`,并以 `parentId` 嵌套;不属于任何 Workspace 的 Session 显示在末尾的 `Ungrouped` 分区。搜索、状态点以及折叠到布局拥有的 56px 轨道,都只属于呈现层。契约:[slot 系统标准](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)。 + +New Session 会启动运行时的页面局部前端 Session Intent;真实 Workspace 的「+」会启动一项以该 Workspace 为目标的 Intent。Workspace 标题栏的「+」打开 ui-workspace 的共享选择器,选择结果同样以一个前端 Session 为目标。Workspace Intent 不会出现在侧边栏中。 + +`SidebarRootComponentProps` 组合布局 owner share、全局 `useSessions` 和 `useWorkspaces` hook、已声明的 `sidebar.workspace` 与 `sidebar.settings` 子 slot,以及注入的 `startSession`、`open` 和侧边栏切换回调。这里没有插件 store:`deriveGroups` 消费对象层快照与组件局部的展开/搜索状态。 + +页脚承载 `sidebar.settings`:侧边栏只渲染固定在底部的布局 slot,并共享其栏状态(`wide`);ui-settings 在此注册触发行和设置面板。 + +`/client` 导出表层只包含插件主体(`apply`/`inject`)及契约类型:SidebarRoot、行组件和树派生均属于内部实现(slot 注册通过闭包引用它们;测试直接导入 src 路径)。 + +## 模型体验 + +无。侧边栏渲染浏览器会话列表;这里没有任何内容进入模型请求。 + +#### KV Cache 影响 + +无;该包既不组装也不发送提供方请求。 + +## 已知限制与暂缓事项 + +- **状态点只有两种实时数据状态(running/none)**:done/error/amber 数据源随 P-II 审批与通知到来;四色原语已经接线。 +- **分组选单只提供按 Workspace 分组**:Update/Status 分组策略只有图稿而没有规范,暂缓实现。 +- **「New task completed」未读标记是本地查看状态**:完成时间 > 上次查看时间这一事实永远不会到达主机。 diff --git a/packages/client/ui-sidebar/src/client/Rows.tsx b/packages/client/ui-sidebar/src/client/Rows.tsx deleted file mode 100644 index 6535a9a08a..0000000000 --- a/packages/client/ui-sidebar/src/client/Rows.tsx +++ /dev/null @@ -1,143 +0,0 @@ -/** - * Sidebar tree row components (figma Cell set 14:3080): pure presentational — - * all data and callbacks arrive via props. Hover swaps (folder->chevron, - * time->ellipsis, action buttons) are CSS-only. - */ -import clsx from 'clsx' -import { - IconFolderClose16, IconFolderOpen16, IconPlusOutline16, - IconTriangleRightFill14, StateDot, -} from '@deepseek-ai/dsh-client-ui-primitives' -import type { GroupNode, SessionNode } from './tree.ts' -import { formatRelativeTime } from './tree.ts' -import css from './Rows.module.css' - -/** Indent step per tree level: one 16px slot (figma session cell). */ -const INDENT_STEP = 16 - -/** - * Project (workspace) header row: 54px, folder + title + session count; - * hover reveals the chevron and create button. `containsCurrent` arrives on - * the node (derivation fact, no renderer scan). - * @param props.group - derived group node. - * @param props.onToggle - expand/collapse the group. - * @param props.onCreate - start a frontend Session inside this Workspace. - * @returns the row element. - */ -export function ProjectRowItem({ group, onToggle, onCreate }: { - group: GroupNode - onToggle: () => void - onCreate: () => void -}) { - const row = group - const active = group.expanded && group.containsCurrent - const count = `${row.sessionCount} ${row.sessionCount === 1 ? 'session' : 'sessions'}` - return ( - <div className={css.projectRow} role="treeitem" aria-expanded={row.expanded} onClick={onToggle}> - <span className={clsx(css.slot, css.folder, active && css.folderActive)}> - {row.expanded ? <IconFolderOpen16 /> : <IconFolderClose16 />} - </span> - <span className={clsx(css.slot, css.chevron)}> - <IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} /> - </span> - <span className={css.projectText}> - <span className={css.title}>{row.label}</span> - <span className={css.meta}>{count}</span> - </span> - <span className={css.rowActions}> - <button - type="button" - className={css.iconButton} - aria-label={`New session in ${row.label}`} - onClick={(e) => { e.stopPropagation(); onCreate() }} - > - <IconPlusOutline16 /> - </button> - </span> - </div> - ) -} - -/** - * The selected "New session" row for a frontend Session Intent targeted to a - * real Workspace. The row disappears when the Intent is replaced or connects. - * @returns the placeholder row element. - */ -export function IntentRowItem() { - return ( - <div className={clsx(css.sessionRow, css.selected)} role="treeitem" aria-selected style={{ paddingLeft: 8 }}> - <span className={css.slot} /> - <span className={css.slot} /> - <span className={css.title}>New session</span> - </div> - ) -} - -/** - * One session subtree: the node's own 34px row (indent by depth, expand - * twist when it has children, running dot, relative time) plus its visible - * children, recursively — the component tree mirrors the derived tree. - * @param props.node - derived session node. - * @param props.depth - 0 = directly under the group header. - * @param props.currentId - selected session id (row highlight). - * @param props.now - epoch ms for relative-time formatting. - * @param props.onOpen - open a session by id. - * @param props.onToggle - unfold/fold a subtree by id. - * @returns the node's row followed by its children. - */ -export function SessionNodeItem({ node, depth, currentId, now, onOpen, onToggle }: { - node: SessionNode - depth: number - currentId: string | undefined - now: number - onOpen: (id: SessionNode['id']) => void - onToggle: (id: SessionNode['id']) => void -}) { - const row = node - const selected = node.id === currentId - // Rail (figma session cell: pad 8, twist slot 16, status slot 16, gap 4 to - // the title): both slots are always reserved so titles align whether or not - // the twist/dot is lit. Extra depth rides the left padding. - const ownRow = ( - <div - className={clsx(css.sessionRow, selected && css.selected)} - role="treeitem" - aria-selected={selected} - {...(row.hasChildren ? { 'aria-expanded': row.expanded } : {})} - style={{ paddingLeft: 8 + depth * INDENT_STEP }} - onClick={() => { onOpen(node.id) }} - > - {row.hasChildren - ? ( - <button - type="button" - className={css.twist} - aria-label={row.expanded ? 'Collapse' : 'Expand'} - onClick={(e) => { e.stopPropagation(); onToggle(node.id) }} - > - <IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} /> - </button> - ) - : <span className={css.slot} />} - <span className={css.slot}>{row.running && <StateDot state="ongoing" />}</span> - <span className={css.title}>{row.title}</span> - <span className={css.time}>{formatRelativeTime(row.updatedAt, now)}</span> - </div> - ) - return ( - <> - {ownRow} - {node.children.map(child => ( - <SessionNodeItem - key={child.id} - node={child} - depth={depth + 1} - currentId={currentId} - now={now} - onOpen={onOpen} - onToggle={onToggle} - /> - ))} - </> - ) -} diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index 591aef2330..fe73435df8 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -48,8 +48,7 @@ refresh straight into the collapsed state renders statically. */ .railIn .iconButton, .railIn .newSession, -.railIn .searchButton, -.railIn .foot { +.railIn .footArea { animation: rail-in 150ms var(--ds-ease-in-out) 100ms backwards; } @@ -184,133 +183,9 @@ max-width: 0; } -/* Section header: 36px, "WorkSpace" label + group-by / new-workspace buttons; - the right-anchored new-workspace button is the row's rail survivor. */ -.sectionHeader { - flex: none; - display: flex; - align-items: center; - justify-content: flex-end; - gap: 4px; - height: 36px; - padding-left: 12px; - margin-bottom: 4px; - box-sizing: border-box; - border-radius: 12px; - overflow: hidden; - color: var(--dsw-alias-label-tertiary); -} - -.collapsed .sectionHeader { - height: 36px; - padding-left: 0; - margin-bottom: 12px; -} - -.sectionLabel { - flex: 1; - min-width: 0; - overflow: hidden; - white-space: nowrap; - line-height: 20px; -} - -/* Search input: 38px capsule (figma 133:7649); collapsed it renders as the - rail's search control. Upstream binds a dedicated design-system variable (light - #F1F3F5 / dark #1B1B1C) matching no shipped alias — a component token - pinned to the static scale mirrors it (ruled compliant: indirect via - custom property, upstream-variable equivalent). */ -.search { - --dsh-search-input-fill: var(--dsw-static-neutral-bluish-75); - flex: none; - display: flex; - align-items: center; - gap: 8px; - height: 38px; - margin: 0 2px 12px; /* bottom: former listArea gap 4 + own 8 (spec padB12 to the first cell) */ - padding: 0 14px; - box-sizing: border-box; - border: 1px solid var(--dsw-alias-border-l2); - border-radius: 24px; - background: var(--dsh-search-input-fill); - color: var(--dsw-alias-label-caption); - overflow: hidden; -} - -:global(body[data-ds-dark-theme]) .search { - --dsh-search-input-fill: var(--dsw-static-neutral-bluish-900); -} - -.collapsed .search { - height: 36px; - padding: 0; - margin: 0 0 12px; - gap: 0; - border-color: transparent; - background: transparent; -} - -/* The capsule's leading icon, upgraded to the rail's search control. While - expanded it is decorative: pointer-events off so clicks reach the label - (native input focus); collapsed it becomes the hit target. */ -.searchButton { - flex: none; - display: inline-flex; - align-items: center; - justify-content: center; - border: none; - border-radius: 50%; - padding: 0; - background: transparent; - pointer-events: none; - color: inherit; -} - -.collapsed .searchButton { - width: 36px; - height: 36px; - pointer-events: auto; - cursor: pointer; - color: var(--dsw-alias-label-primary); -} - -.collapsed .searchButton:hover { - background: var(--dsw-alias-interactive-bg-hover); -} - -.searchInput { - flex: 1; - min-width: 0; - border: none; - outline: none; - background: transparent; - font-size: 14px; - line-height: 20px; - color: var(--dsw-alias-label-primary); -} - -.searchInput::placeholder { - color: var(--dsw-alias-label-tertiary); -} - -.clearButton { - flex: none; - display: inline-flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - border: none; - border-radius: 50%; - padding: 0; - background: transparent; - cursor: pointer; - color: var(--dsw-alias-label-secondary); -} - -/* Tree seat: always mounted so the foot never moves; the tree content inside - is wide-only and clips while the column squeezes. */ -.listArea { +/* Region seat: always mounted so the foot never moves; the browser inside + handles its own wide/rail content. */ +.regionArea { flex: 1; min-height: 0; display: flex; @@ -318,99 +193,11 @@ overflow: hidden; } -/* Relative for the bottom fade overlay. */ -.treeBody { - flex: 1; - min-height: 0; - display: flex; - flex-direction: column; - position: relative; -} - -/* Bottom fade (figma 133:7666): 72px overlay pinned to the visible bottom, - transparent -> sidebar fill so it tracks the theme. */ -.fade { - position: absolute; - left: 0; - right: 0; - bottom: 0; - height: 72px; - background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill)); - pointer-events: none; -} - -/* Tree list: the only scrolling region. Block, not a flex column: as flex - items the 54/34 rows would shrink under content overflow (scrollHeight - collapses onto clientHeight and wheel scrolling dies); block children keep - their design heights and the 4px rhythm rides margins instead of gap. */ -.list { - flex: 1; - min-height: 0; - overflow-y: auto; - padding-bottom: 12px; -} - -/* One workspace section: header row + expanded session run. Rows inside - keep the former flat-list 4px gap as sibling margins; the inter-group - breathing room (figma 133:7661 batch separator, 20px after an expanded - run) rides the NEXT section's top margin so the last group adds none. */ -.groupSection > * + * { - margin-top: 4px; -} - -.groupSection + .groupSection { - margin-top: 4px; -} - -.groupSection:has([aria-expanded='true']) + .groupSection { - margin-top: 20px; -} - -.empty { - padding: 16px 12px; - color: var(--dsw-alias-label-tertiary); - font-size: 13px; -} - -/* Foot: settings entry (figma 133:7668, 49 hug): the former 18/10 vertical - margins fold into the row so the hover pill spans the full 49px. */ -.foot { +/* Foot seat: a pure layout socket pinned under the region; the ui-settings + trigger row inside owns its own geometry (49px wide row / 36px rail + circle) and hover chrome. */ +.footArea { flex: none; - display: flex; - align-items: center; - gap: 8px; - height: 49px; - margin: 8px 0 0; /* + 49px row + root padBottom 6 keeps the old 57px band */ - padding: 0 2px 0 6px; - border-radius: 12px; - cursor: pointer; - overflow: hidden; - color: var(--dsw-alias-label-primary); -} - -.foot:hover { - background: var(--dsw-alias-interactive-bg-hover); -} - -/* Rail settings: the same 36x36 circle box as the other rail controls. */ -.collapsed .foot { - width: 36px; - height: 36px; - margin: 18px 0 10px; - justify-content: center; - gap: 0; - padding: 0; - border-radius: 50%; -} - -.footLabel { - max-width: 120px; - overflow: hidden; - white-space: nowrap; -} - -.collapsed .footLabel { - max-width: 0; } @media (prefers-reduced-motion: reduce) { @@ -418,8 +205,7 @@ .fading > *, .railIn .iconButton, .railIn .newSession, - .railIn .searchButton, - .railIn .foot { + .railIn .footArea { transition: none; animation: none; } diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index 931eb1ba17..f5f810cbbe 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -1,170 +1,39 @@ /** - * Collapse is a slide plus crossfade: content freezes at its expanded - * width (inline style) and fades out in place while the sliding column - * (AppFrame grid tracks) clips it — nothing reflows mid-slide. At settle - * the wide-only content (brand, labels, input, tree) unmounts, dropping - * the sessions subscription, and the control rows snap to the 56px rail - * (one icon each, same top-down order) fading in as the slide ends. Rail - * search expands and focuses the search box. + * Sidebar shell: column geometry only. Collapse is a slide plus crossfade: + * content freezes at its expanded width (inline style) and fades out in place + * while the sliding column (AppFrame grid tracks) clips it — nothing reflows + * mid-slide. At settle the wide-only content unmounts and the control rows + * snap to the 56px rail (one icon each, same top-down order) fading in as the + * slide ends. The workspace/session browsing region between the New Session + * button and the foot is the `sidebar.workspaces` registrant's, and the foot + * is the `sidebar.settings` registrant's; the shell hands them the wide flag + * (plus an expand request callback for the browser). */ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import clsx from 'clsx' import { BrandWordmark, FishLogo, - IconCloseFill14, IconNewChatOutline16, IconPanelLeftOutline16, IconPersonalizationOutline16, - IconProjectAddOutline16, IconSearchOutline16, IconSettingsOutline14, - Menu, Tooltip, + IconNewChatOutline16, IconPanelLeftOutline16, + Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client' import type { SidebarRootComponentProps } from './contract/slots.ts' -import { deriveGroups, UNGROUPED_KEY } from './tree.ts' -import { IntentRowItem, ProjectRowItem, SessionNodeItem } from './Rows.tsx' import css from './SidebarRoot.module.css' /** Wide-content unmount delay; matches the 150ms wide-content fade-out. */ const COLLAPSE_SETTLE_MS = 150 -/** Column slide length (--ds-transition-duration-slow): rail-search focus waits it out — focus() forces a synchronous layout and would jank the slide. */ -const EXPAND_SLIDE_MS = 300 - -const GROUP_BY_ITEMS = [ - { id: 'workspace', label: 'Workspace' }, - // Only workspace grouping is implemented. - { id: 'update', label: 'Update', disabled: true }, - { id: 'status', label: 'Status', disabled: true }, -] - -/** Immutable membership toggle for the local expansion arrays. */ -function toggled(list: readonly string[], key: string): string[] { - return list.includes(key) ? list.filter((k) => k !== key) : [...list, key] -} - -/** Group-by strategy menu; own open state so it resets with the wide chrome. */ -function GroupByMenu() { - const [open, setOpen] = useState(false) - return ( - <Menu - open={open} - onClose={() => { setOpen(false) }} - items={GROUP_BY_ITEMS} - selectedId="workspace" - onSelect={() => { setOpen(false) }} - align="end" - anchor={( - <button - type="button" - className={clsx(css.iconButton, css.wide)} - aria-label="Group by" - onClick={() => { setOpen((v) => !v) }} - > - <IconPersonalizationOutline16 /> - </button> - )} - /> - ) -} - -type SessionTreeProps = Pick< - SidebarRootComponentProps, - 'useSessions' | 'startSession' | 'open' -> & { - workspaces: readonly WorkspaceView[] - /** Live search filter owned by the root (the query outlives the tree). */ - query: string -} - -/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */ -function SessionTree({ useSessions, startSession, open, workspaces, query }: SessionTreeProps) { - const list = useSessions((s) => s) - const current = list.current - const [expandedProjects, setExpandedProjects] = useState<string[]>([]) - const [expandedSessions, setExpandedSessions] = useState<string[]>([]) - // Re-expand when publication moves the selected intent into a real Workspace. - const intent = list.intent - const intentWorkspaceId = intent?.target.kind === 'workspace' - ? intent.target.workspaceId - : undefined - const currentGroup = current === undefined - ? undefined - : intent?.sessionId === current - ? intentWorkspaceId - : (workspaces.find(w => w.sessionIds.includes(current))?.workspaceId as string | undefined) - ?? UNGROUPED_KEY - useEffect(() => { - if (current === undefined || currentGroup === undefined) return - setExpandedProjects((l) => (l.includes(currentGroup) ? l : [...l, currentGroup])) - }, [current, currentGroup]) - const groups = useMemo( - () => deriveGroups(list, workspaces, { expandedProjects, expandedSessions, query }), - [list, workspaces, expandedProjects, expandedSessions, query], - ) - const now = Date.now() - - return ( - <div className={clsx(css.treeBody, css.wide)}> - <div className={css.list} role="tree" aria-label="Sessions"> - {groups.length === 0 && ( - <div className={css.empty}>{query === '' ? 'No sessions yet' : 'No matches'}</div> - )} - {groups.map(group => ( - // Group section: header row + expanded session subtree. The - // inter-group breathing room (former flat-list batch separator) - // is the section's own margin (SidebarRoot.module.css). - <div key={group.key} className={css.groupSection}> - <ProjectRowItem - group={group} - onToggle={() => { setExpandedProjects((l) => toggled(l, group.key)) }} - onCreate={() => { - if (group.workspaceId !== undefined) startSession(group.workspaceId) - }} - /> - {group.intentHere && <IntentRowItem />} - {group.sessions.map(node => ( - <SessionNodeItem - key={node.id} - node={node} - depth={0} - currentId={current} - now={now} - onOpen={open} - onToggle={(id) => { setExpandedSessions((l) => toggled(l, id)) }} - /> - ))} - </div> - ))} - </div> - <span className={css.fade} /> - </div> - ) -} - /** - * Render the sidebar column. + * Render the sidebar column shell. * @param props - composed slot props (runtime share + injected callbacks, contract/slots.ts). * @returns the sidebar element tree. */ export function SidebarRoot({ collapsed, width, - useSessions, - useWorkspaces, startSession, - open, toggleSidebar, renderSlot, }: SidebarRootComponentProps) { - const workspaces = useWorkspaces(state => state.items) - // The query outlives the tree and the input (both wide-only) so collapsing - // does not silently drop an in-progress filter. - const [query, setQuery] = useState('') - const searchInput = useRef<HTMLInputElement | null>(null) - // Section-header + opens the workspace picker (same popover in wide and - // rail states; the hole sits beside the button and opens rightward). - const [wsPickerOpen, setWsPickerOpen] = useState(false) - // Placement anchor for the picker popover: the slot span renders elsewhere - // in the DOM, so the picker positions off this button's rect. - const wsPlusRef = useRef<HTMLButtonElement>(null) - // Wide content stays mounted while the collapse animates (fading via // .collapsed .wide), unmounts at settle, and remounts right away on expand. const [settled, setSettled] = useState(collapsed) @@ -186,19 +55,6 @@ export function SidebarRoot({ const everWide = useRef(!collapsed) if (!collapsed) everWide.current = true - // Rail search = expand + land in the search box: the flag arms before the - // expand toggle; once expanded the input is mounted and takes focus. - const [searchOnExpand, setSearchOnExpand] = useState(false) - useEffect(() => { - if (!collapsed && searchOnExpand) { - const timer = window.setTimeout(() => { - searchInput.current?.focus({ preventScroll: true }) - setSearchOnExpand(false) - }, EXPAND_SLIDE_MS) - return () => { window.clearTimeout(timer) } - } - }, [collapsed, searchOnExpand]) - return ( <div className={clsx(css.root, !wide && css.collapsed, !wide && everWide.current && css.railIn, collapsed && wide && css.fading)} @@ -238,85 +94,18 @@ export function SidebarRoot({ </button> </Tooltip> - <div className={css.sectionHeader}> - {wide && <span className={clsx(css.sectionLabel, css.wide)}>Workspaces</span>} - {wide && <GroupByMenu />} - <Tooltip label="New Workspace" disabled={wide}> - <button - ref={wsPlusRef} - type="button" - className={css.iconButton} - aria-label="Create workspace" - onClick={() => { setWsPickerOpen(v => !v) }} - > - <IconProjectAddOutline16 size={wide ? 16 : 18} /> - </button> - </Tooltip> - {/* Picker hole beside the + (same site in wide and rail states). */} - {renderSlot('sidebar.workspace', { - open: wsPickerOpen, - anchorRef: wsPlusRef, - onPick: (workspaceId) => { - setWsPickerOpen(false) - startSession(workspaceId) - }, - onClose: () => { setWsPickerOpen(false) }, + {/* The browsing region fills the column between the controls and the + foot in both states; its rail icon column rides the same slot. */} + <div className={css.regionArea}> + {renderSlot('sidebar.workspaces', { + wide, + expandSidebar: () => { if (collapsed) toggleSidebar() }, })} </div> - {/* Expanded: the row is a click-to-focus field (the leading icon is - decorative). Collapsed: the icon is the rail's search control. */} - <div className={css.search} onClick={() => { if (!collapsed) searchInput.current?.focus() }}> - <Tooltip label="Search" disabled={wide}> - <button - type="button" - className={css.searchButton} - aria-label="Search sessions" - tabIndex={collapsed ? 0 : -1} - onClick={() => { if (collapsed) { setSearchOnExpand(true); toggleSidebar() } }} - > - <IconSearchOutline16 size={wide ? 14 : 18} /> - </button> - </Tooltip> - {wide && ( - <input - ref={searchInput} - className={clsx(css.searchInput, css.wide)} - type="text" - placeholder="Search name, keywords..." - value={query} - onChange={(e) => { setQuery(e.target.value) }} - /> - )} - {wide && query !== '' && ( - <button - type="button" - className={clsx(css.clearButton, css.wide)} - aria-label="Clear search" - onClick={() => { setQuery('') }} - > - <IconCloseFill14 /> - </button> - )} - </div> - - {/* Always-mounted seat: its flex slot pins the foot to the bottom in - both states while the tree itself is wide-only. */} - <div className={css.listArea}> - {wide && ( - <SessionTree - useSessions={useSessions} - workspaces={workspaces} - startSession={startSession} - open={open} - query={query} - /> - )} - </div> - - <div className={css.foot} role="button" tabIndex={0} aria-label="Settings"> - <IconSettingsOutline14 size={wide ? 14 : 18} /> - {wide && <span className={clsx(css.footLabel, css.wide)}>Settings</span>} + {/* Foot seat: ui-settings registers the trigger row + panel here. */} + <div className={css.footArea}> + {renderSlot('sidebar.settings', { wide })} </div> </div> ) diff --git a/packages/client/ui-sidebar/src/client/contract/slots.ts b/packages/client/ui-sidebar/src/client/contract/slots.ts index 0334ca88c9..5e26f6defd 100644 --- a/packages/client/ui-sidebar/src/client/contract/slots.ts +++ b/packages/client/ui-sidebar/src/client/contract/slots.ts @@ -1,69 +1,70 @@ /** * Sidebar slot contract: the registrant-side props composition for the - * layout-owned `sidebar` slot and the Workspace picker hole declared here. - * The runtime share combines layout-owned page state and actions with the - * global useSessions and useWorkspaces hooks; the injected share adds the - * runtime navigation actions and sidebar toggle. + * layout-owned `sidebar` slot, plus the holes this shell declares. The shell + * owns column geometry (fold state machine, brand row, New Session); + * everything between the section header and the list bottom is the + * `sidebar.workspaces` registrant's (ui-workspace), and the foot is the + * `sidebar.settings` registrant's (ui-settings). */ -import type { RefObject } from 'react' import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: pulls ui-layout's SlotMap merge (the 'sidebar' entry) into every // program that sees this contract, so PropsRuntime<'sidebar'> resolves. import type {} from '@deepseek-ai/dsh-client-ui-layout/client' -import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' +import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { /** - * The workspace picker hole in the sidebar section header (anchored at - * the + button). Declared by this package's 'sidebar' entry (declaring - * is claiming); ui-workspace registers the picker. + * The workspace/session browsing region: section header, search, the + * grouped/flat session list, and every workspace dialog. Declared by this + * package's 'sidebar' entry (declaring is claiming); ui-workspace + * registers the browser. */ - 'sidebar.workspace': { kind: 'single'; scope: 'root'; owner: SidebarWorkspaceOwnerProps } + 'sidebar.workspaces': { kind: 'single'; scope: 'root'; owner: SidebarSectionOwnerProps } + /** + * The settings seat at the sidebar foot. Declared by this package's + * 'sidebar' entry; ui-settings registers its trigger row + modal panel. + * The sidebar passes only its column state — it holds no settings state. + */ + 'sidebar.settings': { kind: 'single'; scope: 'root'; owner: SidebarSettingsOwnerProps } } } /** - * Owner share of the sidebar workspace hole: popover geometry plus the - * sidebar's pick semantics. The picked Host Workspace is already real; the - * callback starts a frontend Session Intent targeted to it. + * Owner share of the browser hole — the only facts crossing the shell/region + * seam. Business data and actions arrive through the region's own inject. */ -export interface SidebarWorkspaceOwnerProps { - /** Popover visibility (+ button toggle state, host-local). */ - open: boolean - /** - * The + button element — the popover's placement anchor. The picker's - * slot span renders elsewhere in the DOM, so without this the menu - * positions off the zero-size placement span (order-dependent). Optional - * only until the host passes it; absent falls back to in-place placement. - */ - anchorRef?: RefObject<HTMLElement> - /** Start a frontend Session in a selected or newly created real Workspace. */ - onPick: (workspaceId: WorkspaceId) => void - /** Close the popover (outside click / Escape / post-pick). */ - onClose: () => void +export interface SidebarSectionOwnerProps { + /** Shell fold-state output: wide renders the full browser, rail the icon column. */ + wide: boolean + /** Rail icons request expansion; the browser rides the wide flip for focus. */ + expandSidebar: () => void +} + +/** + * Owner share of the sidebar settings seat: the column display state the + * occupant's trigger row must render against (wide row vs rail icon). + */ +export interface SidebarSettingsOwnerProps { + /** Whether the sidebar renders wide content (false = 56px rail). */ + wide: boolean } /** * Registrant-private injected share (arrives via the register inject - * factory). Host Workspace and Session data use the global framework hooks; - * navigation and panel actions are plain callbacks, and viewing state remains - * component-local. A type alias supplies the implicit index signature required - * by the registry. + * factory). The shell keeps only its own controls: starting a Session from + * the New Session button and toggling the column. */ export type SidebarRootInjected = { /** Start or replace the current frontend Session Intent. */ startSession: (workspaceId?: WorkspaceId, prompt?: string) => void - /** Open a real Session. */ - open: (sessionId: SessionId) => void /** Toggle the sidebar column through the layout service. */ toggleSidebar: () => void } /** - * Full component props: layout owner state/actions plus global useSessions - * and useWorkspaces, the declared Workspace picker render share, and this - * package's injected callback. No store is registered. + * Full component props: layout owner state/actions plus the declared holes' + * render shares and this package's injected callbacks. No store is registered. */ export type SidebarRootComponentProps = - PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspace'> & SidebarRootInjected + PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspaces' | 'sidebar.settings'> & SidebarRootInjected diff --git a/packages/client/ui-sidebar/src/client/index.ts b/packages/client/ui-sidebar/src/client/index.ts index 0a1c8ebb12..abd78bee12 100644 --- a/packages/client/ui-sidebar/src/client/index.ts +++ b/packages/client/ui-sidebar/src/client/index.ts @@ -1,28 +1,31 @@ -/** Registers the sidebar UI into the layout-owned slot. */ +/** Registers the sidebar shell into the layout-owned slot. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import type { SidebarRootInjected } from './contract/slots.ts' import { SidebarRoot } from './SidebarRoot.tsx' -export type { SidebarRootComponentProps, SidebarRootInjected, SidebarWorkspaceOwnerProps } from './contract/slots.ts' +export type { SidebarRootComponentProps, SidebarRootInjected, SidebarSectionOwnerProps, SidebarSettingsOwnerProps } from './contract/slots.ts' /** Services required by the sidebar plugin. */ -export const inject = ['slots', 'layout', 'sessions', 'workspaces'] +export const inject = ['slots', 'layout', 'workspaces'] -/** Registers the sidebar component and its service callbacks. +/** Registers the sidebar shell and its service callbacks. * @param ctx - Client root context. */ export function apply(ctx: ClientContext): void { const injectProps = (): SidebarRootInjected => ({ startSession: (workspaceId, prompt) => { ctx.workspaces.startSession(workspaceId, prompt) }, - open: (sessionId) => { ctx.sessions.open(sessionId) }, toggleSidebar: () => { ctx.layout.toggleSidebar() }, }) ctx.effect( () => ctx.slots.register({ name: 'sidebar', - // SidebarRoot owns this picker site; ui-workspace registers the shared - // picker that selects a Host Workspace for a frontend Session Intent. - children: { 'sidebar.workspace': { kind: 'single', scope: 'root' } }, + // The shell owns geometry; ui-workspace registers the whole browsing + // region (header, search, session list, workspace dialogs), ui-settings + // registers the foot trigger + settings panel. + children: { + 'sidebar.workspaces': { kind: 'single', scope: 'root' }, + 'sidebar.settings': { kind: 'single', scope: 'root' }, + }, inject: injectProps, }, SidebarRoot), 'ui-sidebar: slot registration', diff --git a/packages/client/ui-sidebar/tests/apply.spec.tsx b/packages/client/ui-sidebar/tests/apply.spec.tsx index 5d285bd20c..681454691c 100644 --- a/packages/client/ui-sidebar/tests/apply.spec.tsx +++ b/packages/client/ui-sidebar/tests/apply.spec.tsx @@ -1,4 +1,4 @@ -/** Sidebar slot registration and its plain runtime/layout callbacks. */ +/** Sidebar shell slot registration and its plain runtime/layout callbacks. */ import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' @@ -9,10 +9,8 @@ async function bench(declare = true) { const ctx = new Context() await ctx.plugin(SlotsService).await() const layout = { toggleSidebar: vi.fn() } - const sessions = { open: vi.fn() } const workspaces = { startSession: vi.fn() } ctx.provide('layout', layout) - ctx.provide('sessions', sessions as never) ctx.provide('workspaces', workspaces as never) const slots = ctx.get('slots') as SlotsService if (declare) { @@ -21,25 +19,23 @@ async function bench(declare = true) { () => null, ) } - return { ctx, slots, layout, sessions, workspaces } + return { ctx, slots, layout, workspaces } } describe('ui-sidebar apply', () => { it('declares only the services it uses', () => { - expect(inject).toEqual(['slots', 'layout', 'sessions', 'workspaces']) + expect(inject).toEqual(['slots', 'layout', 'workspaces']) }) - it('registers the sidebar and declares its Workspace picker hole', async () => { + it('registers the shell and declares the browsing-region hole', async () => { const b = await bench() await b.ctx.plugin({ inject: [...inject], apply }).await() expect(b.slots.entries('sidebar')).toHaveLength(1) - expect(b.slots.spec('sidebar.workspace')).toEqual({ kind: 'single', scope: 'root' }) + expect(b.slots.spec('sidebar.workspaces')).toEqual({ kind: 'single', scope: 'root' }) const injected = (b.slots.entries('sidebar')[0]!.inject as () => SidebarRootInjected)() - expect(Object.keys(injected)).toEqual(['startSession', 'open', 'toggleSidebar']) + expect(Object.keys(injected)).toEqual(['startSession', 'toggleSidebar']) injected.startSession('workspace' as never, 'prompt') expect(b.workspaces.startSession).toHaveBeenCalledWith('workspace', 'prompt') - injected.open('session' as never) - expect(b.sessions.open).toHaveBeenCalledWith('session') injected.toggleSidebar() expect(b.layout.toggleSidebar).toHaveBeenCalledOnce() }) @@ -55,6 +51,6 @@ describe('ui-sidebar apply', () => { await fiber.await() await fiber.dispose() expect(b.slots.entries('sidebar')).toHaveLength(0) - expect(b.slots.spec('sidebar.workspace')).toBeUndefined() + expect(b.slots.spec('sidebar.workspaces')).toBeUndefined() }) }) diff --git a/packages/client/ui-sidebar/tests/rows.spec.tsx b/packages/client/ui-sidebar/tests/rows.spec.tsx deleted file mode 100644 index 468ce550d9..0000000000 --- a/packages/client/ui-sidebar/tests/rows.spec.tsx +++ /dev/null @@ -1,76 +0,0 @@ -// @vitest-environment jsdom -import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' -import { IntentRowItem, ProjectRowItem, SessionNodeItem } from '../src/client/Rows.tsx' -import type { GroupNode, SessionNode } from '../src/client/tree.ts' - -afterEach(cleanup) - -const sid = (id: string) => id as SessionId -const wid = (id: string) => id as WorkspaceId - -describe('sidebar rows', () => { - it('renders an active Workspace and keeps its create action separate from toggling', () => { - const onToggle = vi.fn() - const onCreate = vi.fn() - const group: GroupNode = { - key: 'project', workspaceId: wid('project'), cwd: '/projects/project', label: 'Project', - sessionCount: 1, expanded: true, containsCurrent: true, intentHere: false, sessions: [], - } - render(<ProjectRowItem group={group} onToggle={onToggle} onCreate={onCreate} />) - - expect(screen.getByText('1 session')).toBeTruthy() - expect(screen.getByRole('treeitem').getAttribute('aria-expanded')).toBe('true') - fireEvent.click(screen.getByRole('button', { name: 'New session in Project' })) - expect(onCreate).toHaveBeenCalledOnce() - expect(onToggle).not.toHaveBeenCalled() - fireEvent.click(screen.getByText('Project')) - expect(onToggle).toHaveBeenCalledOnce() - }) - - it('renders the frontend Intent placeholder as selected', () => { - render(<IntentRowItem />) - expect(screen.getByRole('treeitem').getAttribute('aria-selected')).toBe('true') - }) - - it('renders and operates selected, running, recursive Session nodes', () => { - const child: SessionNode = { - id: sid('child'), title: 'Child', children: [], hasChildren: false, - expanded: false, running: false, updatedAt: 0, - } - const parent: SessionNode = { - id: sid('parent'), title: 'Parent', children: [child], hasChildren: true, - expanded: true, running: true, updatedAt: 0, - } - const onOpen = vi.fn() - const onToggle = vi.fn() - const view = render( - <SessionNodeItem node={parent} depth={0} currentId={parent.id} now={0} onOpen={onOpen} onToggle={onToggle} />, - ) - - const parentRow = screen.getByText('Parent').closest('[role="treeitem"]')! - const childRow = screen.getByText('Child').closest('[role="treeitem"]')! - expect(parentRow.getAttribute('aria-selected')).toBe('true') - expect(parentRow.getAttribute('aria-expanded')).toBe('true') - expect(childRow.getAttribute('aria-selected')).toBe('false') - expect(childRow.hasAttribute('aria-expanded')).toBe(false) - - fireEvent.click(screen.getByRole('button', { name: 'Collapse' })) - expect(onToggle).toHaveBeenCalledWith(parent.id) - expect(onOpen).not.toHaveBeenCalled() - fireEvent.click(parentRow) - fireEvent.click(childRow) - expect(onOpen.mock.calls).toEqual([[parent.id], [child.id]]) - - view.rerender( - <SessionNodeItem - node={{ ...parent, children: [], expanded: false, running: false }} - depth={1} currentId={undefined} now={0} onOpen={onOpen} onToggle={onToggle} - />, - ) - expect(screen.getByRole('button', { name: 'Expand' })).toBeTruthy() - expect(screen.getByRole('treeitem').getAttribute('aria-selected')).toBe('false') - expect(screen.getByRole('treeitem').style.paddingLeft).toBe('24px') - }) -}) diff --git a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx index 26adafd793..b15d079a3d 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx @@ -1,79 +1,51 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import type { - SessionId, SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView, -} from '@deepseek-ai/dsh-client-runtime/client' -import type { SidebarRootComponentProps } from '../src/client/contract/slots.ts' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import type { SidebarRootComponentProps, SidebarSectionOwnerProps, SidebarSettingsOwnerProps } from '../src/client/contract/slots.ts' import { SidebarRoot } from '../src/client/SidebarRoot.tsx' afterEach(() => { cleanup() vi.useRealTimers() }) -const sid = (id: string) => id as SessionId -const wid = (id: string) => id as WorkspaceId -const hook = <T,>(snapshot: T) => <S,>(selector: (state: T) => S): S => selector(snapshot) -const workspace: WorkspaceView = { - workspaceId: wid('project'), path: '/projects/project', title: 'Project', sessionIds: [sid('s1')], - createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', -} -const sessions: SessionListState = { - ids: [sid('s1')], - byId: { [sid('s1')]: { id: sid('s1'), displayTitle: 'First session', running: false, updatedAt: 1 } }, - current: undefined, phase: 'ready', - intent: undefined, -} -const workspaces: WorkspaceListState = { - items: [workspace], state: 'idle', phase: 'ready', error: null, - intent: undefined, baselinesReady: true, recentWorkspaceId: workspace.workspaceId, -} -function mount(sessionState: SessionListState = sessions) { - const startSession = vi.fn() - const open = vi.fn() - let pickerOwner: unknown - const view = render( - <SidebarRoot - collapsed={false} width={300} - useSessions={hook(sessionState)} useWorkspaces={hook(workspaces)} - startSession={startSession} open={open} toggleSidebar={vi.fn()} - renderSlot={((_key: string, owner: unknown) => { pickerOwner = owner; return null }) as SidebarRootComponentProps['renderSlot']} - />, - ) - return { view, startSession, open, pickerOwner: () => pickerOwner } -} +// The shell never reads the global hooks itself, but they ride the standard +// props share; stub them as never-called functions. +const neverHook = (() => { throw new Error('shell must not read global hooks') }) as never -function mountSidebar({ - sessionState = sessions, - workspaceState = workspaces, - collapsed = false, - width = 300, -}: { - sessionState?: SessionListState - workspaceState?: WorkspaceListState - collapsed?: boolean - width?: number -} = {}) { +function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; width?: number } = {}) { const startSession = vi.fn() - const open = vi.fn() const toggleSidebar = vi.fn() - let pickerOwner: unknown - let current = { sessionState, workspaceState, collapsed, width } + let regionOwner: SidebarSectionOwnerProps | undefined + let settingsOwner: SidebarSettingsOwnerProps | undefined + let current = { collapsed, width } const root = () => ( <SidebarRoot collapsed={current.collapsed} width={current.width} - useSessions={hook(current.sessionState)} useWorkspaces={hook(current.workspaceState)} - startSession={startSession} open={open} toggleSidebar={toggleSidebar} - renderSlot={((_key: string, owner: unknown) => { pickerOwner = owner; return null }) as SidebarRootComponentProps['renderSlot']} + useSessions={neverHook} useWorkspaces={neverHook} + startSession={startSession} toggleSidebar={toggleSidebar} + renderSlot={((key: string, owner: SidebarSectionOwnerProps | SidebarSettingsOwnerProps) => { + if (key === 'sidebar.settings') { + settingsOwner = owner as SidebarSettingsOwnerProps + return <div data-testid="settings-seat" data-wide={owner.wide} /> + } + regionOwner = owner as SidebarSectionOwnerProps + return <div data-testid="region" data-wide={owner.wide} /> + }) as SidebarRootComponentProps['renderSlot']} /> ) const view = render(root()) return { startSession, - open, toggleSidebar, - pickerOwner: () => pickerOwner, + regionOwner: () => { + if (regionOwner === undefined) throw new Error('region owner not rendered') + return regionOwner + }, + settingsOwner: () => { + if (settingsOwner === undefined) throw new Error('settings owner not rendered') + return settingsOwner + }, rerender(next: Partial<typeof current>) { current = { ...current, ...next } view.rerender(root()) @@ -81,181 +53,42 @@ function mountSidebar({ } } -describe('SidebarRoot', () => { - it('renders real Workspaces from useWorkspaces and routes New Session', () => { - const b = mount() - expect(screen.getByText('Project')).toBeTruthy() +describe('SidebarRoot shell', () => { + it('routes New Session and the column toggle', () => { + const b = mountShell() fireEvent.click(screen.getByRole('button', { name: 'New session' })) expect(b.startSession).toHaveBeenCalledWith() - }) - - it('shows a frontend Session under its real Workspace and routes its row plus', () => { - const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: workspace.workspaceId }, prompt: '', phase: 'connecting' as const } - const b = mount({ - ...sessions, - current: intent.sessionId, - intent, - }) - expect(screen.getByText('New session')).toBeTruthy() - expect(screen.getByText('2 sessions')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: 'New session in Project' })) - expect(b.startSession).toHaveBeenCalledWith(workspace.workspaceId) - }) - - it('forwards Workspace picker selection and closes the picker', () => { - const b = mount() - fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) - const owner = b.pickerOwner() as { open: boolean; onPick(id: WorkspaceId): void } - expect(owner.open).toBe(true) - owner.onPick(workspace.workspaceId) - expect(b.startSession).toHaveBeenCalledWith(workspace.workspaceId) - }) - - it('opens a real Session through the owner action', () => { - const b = mount({ ...sessions, current: sid('intent'), intent: { - sessionId: sid('intent'), target: { kind: 'workspace', workspaceId: workspace.workspaceId }, prompt: '', phase: 'ready', - } }) - fireEvent.click(screen.getByText('Project')) - fireEvent.click(screen.getByText('First session')) - expect(b.open).toHaveBeenCalledWith(sid('s1')) - }) - - it('opens, selects, dismisses, and toggles the group-by menu', () => { - mount() - const button = screen.getByRole('button', { name: 'Group by' }) - - fireEvent.click(button) - fireEvent.click(screen.getByRole('menuitem', { name: 'Workspace' })) - expect(screen.queryByRole('menu')).toBeNull() - - fireEvent.click(button) - fireEvent.keyDown(document, { key: 'Escape' }) - expect(screen.queryByRole('menu')).toBeNull() - - fireEvent.click(button) - fireEvent.click(button) - expect(screen.queryByRole('menu')).toBeNull() - }) - - it('routes every Workspace picker close path', () => { - const b = mount() - fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) - const owner = b.pickerOwner() as { open: boolean; onClose(): void } - expect(owner.open).toBe(true) - act(() => { owner.onClose() }) - expect((b.pickerOwner() as { open: boolean }).open).toBe(false) - }) - - it('focuses, filters, and clears search while distinguishing both empty states', () => { - mount() - const input = screen.getByPlaceholderText('Search name, keywords...') - fireEvent.click(input.parentElement!) - expect(document.activeElement).toBe(input) - fireEvent.click(screen.getByRole('button', { name: 'Search sessions' })) - - fireEvent.change(input, { target: { value: 'missing' } }) - expect(screen.getByText('No matches')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: 'Clear search' })) - expect(screen.queryByText('No matches')).toBeNull() - - cleanup() - const emptySessions = listState() - const emptyWorkspaces: WorkspaceListState = { ...workspaces, items: [], recentWorkspaceId: undefined } - mountSidebar({ sessionState: emptySessions, workspaceState: emptyWorkspaces }) - expect(screen.getByText('No sessions yet')).toBeTruthy() - }) - - it('toggles Workspace and nested Session expansion in both directions', () => { - const parent = sid('parent') - const child = sid('child') - const nestedSessions: SessionListState = { - ...sessions, - ids: [parent, child], - byId: { - [parent]: { id: parent, displayTitle: 'Parent', running: false, updatedAt: 2 }, - [child]: { id: child, displayTitle: 'Child', running: false, updatedAt: 1, parentId: parent }, - }, - } - const nestedWorkspace: WorkspaceListState = { - ...workspaces, - items: [{ ...workspace, sessionIds: [parent, child] }], - } - mountSidebar({ sessionState: nestedSessions, workspaceState: nestedWorkspace }) - - fireEvent.click(screen.getByText('Project')) - fireEvent.click(screen.getByRole('button', { name: 'Expand' })) - expect(screen.getByText('Child')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: 'Collapse' })) - expect(screen.queryByText('Child')).toBeNull() - fireEvent.click(screen.getByText('Project')) - expect(screen.queryByText('Parent')).toBeNull() - }) - - it('does not start a Session from an Ungrouped row create action', () => { - const loose = sid('loose') - const looseSessions: SessionListState = { - ...listState(), - ids: [loose], - byId: { [loose]: { id: loose, displayTitle: 'Loose', running: false, updatedAt: 1 } }, - current: loose, - } - const b = mountSidebar({ - sessionState: looseSessions, - workspaceState: { ...workspaces, items: [], recentWorkspaceId: undefined }, - }) - fireEvent.click(screen.getByRole('button', { name: 'New session in Ungrouped' })) - expect(b.startSession).not.toHaveBeenCalled() - }) - - it('keeps an already expanded selected Workspace open and resolves later Workspace matches', () => { - const b = mountSidebar() - fireEvent.click(screen.getByText('Project')) - const other = { ...workspace, workspaceId: wid('other'), title: 'Other', sessionIds: [] } - b.rerender({ - sessionState: { ...sessions, current: sid('s1') }, - workspaceState: { ...workspaces, items: [other, workspace] }, - }) - expect(screen.getByText('First session')).toBeTruthy() - - b.rerender({ - sessionState: { - ...sessions, - current: sid('draft'), - intent: { sessionId: sid('draft'), target: { kind: 'workspace-intent' }, prompt: '', phase: 'ready' }, - }, - }) - expect(screen.getByText('Project')).toBeTruthy() - }) - - it('renders the static collapsed rail and expands rail search into focused input', () => { - vi.useFakeTimers() - const b = mountSidebar({ collapsed: true }) - expect(screen.getByRole('button', { name: 'Open sidebar' })).toBeTruthy() - expect(screen.queryByPlaceholderText('Search name, keywords...')).toBeNull() - fireEvent.click(screen.getByRole('button', { name: 'Open sidebar' })) - expect(b.toggleSidebar).toHaveBeenCalledOnce() - - fireEvent.click(screen.getByRole('button', { name: 'Search sessions' })) - expect(b.toggleSidebar).toHaveBeenCalledTimes(2) - b.rerender({ collapsed: false }) - const input = screen.getByPlaceholderText('Search name, keywords...') - act(() => { vi.advanceTimersByTime(300) }) - expect(document.activeElement).toBe(input) - }) - - it('keeps wide content during live collapse, then settles to the rail', () => { - vi.useFakeTimers() - const b = mountSidebar({ width: 320 }) fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' })) expect(b.toggleSidebar).toHaveBeenCalledOnce() - b.rerender({ collapsed: true, width: 56 }) - expect(screen.getByPlaceholderText('Search name, keywords...')).toBeTruthy() - act(() => { vi.advanceTimersByTime(150) }) - expect(screen.queryByPlaceholderText('Search name, keywords...')).toBeNull() + }) + + it('hands the region its wide flag and clamps expandSidebar to the collapsed state', () => { + const b = mountShell() + expect(b.regionOwner().wide).toBe(true) + // The settings seat rides the same wide flag (ui-settings renders the row). + expect(b.settingsOwner().wide).toBe(true) + // Expanded: the request is a no-op (no accidental collapse). + b.regionOwner().expandSidebar() + expect(b.toggleSidebar).not.toHaveBeenCalled() + }) + + it('keeps the region mounted through collapse and expands on its request', () => { + vi.useFakeTimers() + const b = mountShell() + b.rerender({ collapsed: true }) + // Wide content survives the crossfade window, then settles into the rail. + expect(b.regionOwner().wide).toBe(true) + vi.advanceTimersByTime(200) + b.rerender({}) + expect(b.regionOwner().wide).toBe(false) + expect(screen.getByTestId('region')).toBeTruthy() + b.regionOwner().expandSidebar() + expect(b.toggleSidebar).toHaveBeenCalledOnce() + }) + + it('renders statically collapsed on a cold start (no crossfade classes)', () => { + const b = mountShell({ collapsed: true }) + expect(b.regionOwner().wide).toBe(false) expect(screen.getByRole('button', { name: 'Open sidebar' })).toBeTruthy() }) }) - -function listState(): SessionListState { - return { ids: [], byId: {}, current: undefined, phase: 'ready', intent: undefined } -} diff --git a/packages/client/ui-slots/README.i18n.yaml b/packages/client/ui-slots/README.i18n.yaml new file mode 100644 index 0000000000..4fb34675c7 --- /dev/null +++ b/packages/client/ui-slots/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: ed6f052b3a47e08d693928b6763e32427b829467 +README.zh.md: 8f15352d09a33a862203507ac89841da91a43e59 diff --git a/packages/client/ui-slots/README.md b/packages/client/ui-slots/README.md index 4057691295..ed6f052b3a 100644 --- a/packages/client/ui-slots/README.md +++ b/packages/client/ui-slots/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-client-ui-slots +English | [中文](README.zh.md) + Slot registry pure core, slot terminal design: SlotMap declaration merging, the single `register` composition API on SlotCore, the four-share component-props type family, the store-seat type family, and the renderer install-seam contract. React types only at runtime — the package is React-free and cordis-free. One `register({ name, children?, store?, inject?, ...kind }, Component)` call contributes a component into a declared slot and, in the same breath, declares child slots (declaration = render authorization = runtime spec, one table), a store seat, and the registrant's business face. The component is checked at the call site against `ComposedProps` — the intersection of four shares, each derived from its single source of truth: diff --git a/packages/client/ui-slots/README.zh.md b/packages/client/ui-slots/README.zh.md new file mode 100644 index 0000000000..8f15352d09 --- /dev/null +++ b/packages/client/ui-slots/README.zh.md @@ -0,0 +1,35 @@ +# @deepseek-ai/dsh-client-ui-slots + +[English](README.md) | 中文 + +Slot 注册表纯核心、slot 终端设计:SlotMap 声明合并、SlotCore 上唯一的 `register` 组合 API、四 share 组件 props 类型家族、store seat 类型家族,以及 renderer 安装 seam 契约。React 类型仅在运行时使用,该包不依赖 React,也不依赖 cordis。 + +一次 `register({ name, children?, store?, inject?, ...kind }, Component)` 调用会向已声明 slot 贡献一个组件,同时声明子 slot(声明 = 渲染授权 = 运行时规范,三者共用一张表)、store seat 以及注册方的业务表层。组件会在调用点依据 `ComposedProps` 接受检查;该类型是四个 share 的交集,每个 share 都从各自的唯一真源派生: + +| share | 类型 | 来源 | +|---|---|---| +| runtime | `PropsRuntime<K>` | SlotMap 配置项:`owner`(父级 renderSlot 调用点)+ Session 标准工具包 + 全局 seat | +| child render | `PropsRenderSlots<S>` | register 调用的 `children` key 集合(静态缩窄的 `renderSlot`) | +| store | `PropsStore<H>` | 已声明 handle:`useStore` selector hook + 移除 draft 的 `actions` | +| business | `I` | 从 `inject` factory 返回值推断 | + +chain-kind slot 会反转键控路由:配置项自行提名,而不是由分发点选择 `entryKey`。每次注册都携带一个纯 `ChainSelect` selector(另有可选的升序 `priority`,相同值按注册顺序处理);第一个非 null 返回值选中其配置项,并成为组件的 `matched` prop;全部返回 null 时则使用 owner 的 `renderSlotChain` fallback(`ChainRenderOpts`)。 + +标准工具包接口(`SessionStandardProps`、`GlobalStandardProps`)在这里声明为空,由 runtime 包合并(与 SlotMap key 相同的 declare-merge 模式)。renderer 会把运行时 Session 和 Workspace observable source 绑定为 selector hook。Inject factory 参数从声明派生(`InjectParams`):Session slot 获得 `sessionId`;声明 store 时追加 baked `actions`;没有其他参数,数据访问位于 apply 闭包的 ctx 中。 + +store 家族(输入 `defineStore` 规范/输出 `StoreHandle<T, A>`)为 store seat 建模:`init` 推断状态 schema;`actions` 是完整的 draft-transform 写入集合;`BakedActions` 移除 draft 参数,成为组件和 inject factory 收到的回调。`defineStore` 值实现位于 runtime 包(引擎所属位置),并满足这里导出的 `DefineStore` 契约。引擎产物与 renderer host 契约携带裸快照 source(`getSnapshot`/`subscribe`),绝不携带 React hook;hook 绑定属于渲染机制这一侧的 seam,只有 props 契约 hook 类型(`SnapshotSelectorHook`)位于这里。 + +`SlotCore` 在构造时播种先验的 `'root'` slot,并强制执行加载时验证(注册未声明 slot、重复声明子项、在两个 scope 下使用同一个共享 handle、chain 注册缺少 `select`,这些情况都在 register 时抛出)。配置项的 disposer 会递归折叠其声明的子 slot:账本行、贡献和 store 挂载都沿同一生命周期轴消失。`renderer.ts` 携带安装 seam(`SlotRenderer`、`SlotRendererHost`)以及 `StaleAuthorizationError`/`SlotOwnershipError`;实现在 web-react 中,安装则在外壳启动中完成。 + +## 模型体验 + +无。slot 注册表属于浏览器侧 UI 接线;这里没有任何内容进入模型请求。 + +#### KV Cache 影响 + +无;该包既不组装也不发送提供方请求。 + +## 已知限制与暂缓事项 + +- **`isLive` 会线性扫描所有记录**:在 UI 插件的注册规模(数十项)下没有问题;如果账本变得频繁访问,再使用配置项→记录反向引用改进。 +- **`__renders` 幻象锚点在 `PropsRenderSlots` 上可见**:这是与类型链设计的 `__accepts` 相同且已接受的噪声;泛型方法签名在 key 联合之间比较宽松,因此必须依靠逆变标记强制执行「组件 key 集合 ⊆ children 声明」。 diff --git a/packages/client/ui-slots/src/deferred.ts b/packages/client/ui-slots/src/deferred.ts new file mode 100644 index 0000000000..af85c27f97 --- /dev/null +++ b/packages/client/ui-slots/src/deferred.ts @@ -0,0 +1,66 @@ +/** + * Declaration-aware registration deferral: the shared timing machinery for + * registering into a slot whose declaring entry activates in unconstrained + * order (dshClient.inject edges never sequence apply). Presence is judged on + * the LEDGER, not a local flag — after an HMR collapse re-declares the slot, + * the cascade has already removed the entry while the local disposer went + * stale, and a flag guard would block the re-registration. + */ + +/** Minimal registry face the deferral reads (SlotsService satisfies it). */ +export interface DeferralRegistry { + /** Declared spec lookup (undefined = not declared yet). */ + spec(name: string): unknown + /** Current entries of the slot (component identity is the presence judge). */ + entries(name: string): readonly { component: unknown }[] + /** Subscribe to the slot's ledger changes; returns the unsubscriber. */ + subscribe(name: string, listener: () => void): () => void +} + +/** Handle over one deferred registration. */ +export interface DeferredRegistration { + /** + * Drop the current registration (stale disposers are harmless no-ops) and + * immediately re-attempt — the refresh path for registrants whose options + * carry localized text. + */ + refresh(): void + /** Unsubscribe and unregister (idempotent through the slot core). */ + dispose(): void +} + +/** + * Register into `name` as soon as its declaration is on the ledger, and + * re-register whenever the declaration reappears after a collapse. + * @param registry - the slot registry face. + * @param name - target slot name. + * @param component - the component whose ledger presence marks "registered". + * @param register - performs the actual registration; returns its disposer. + * @returns the deferral handle (dispose in the owning effect's disposer). + */ +export function deferRegistration( + registry: DeferralRegistry, + name: string, + component: unknown, + register: () => () => void, +): DeferredRegistration { + let dispose: (() => void) | undefined + const tryRegister = (): void => { + if (registry.spec(name) === undefined) return + if (registry.entries(name).some(e => e.component === component)) return + dispose = register() + } + const unsubscribe = registry.subscribe(name, () => { tryRegister() }) + tryRegister() + return { + refresh() { + dispose?.() + dispose = undefined + tryRegister() + }, + dispose() { + unsubscribe() + dispose?.() + }, + } +} diff --git a/packages/client/ui-slots/src/index.ts b/packages/client/ui-slots/src/index.ts index 677bc4e0df..e3bc57797d 100644 --- a/packages/client/ui-slots/src/index.ts +++ b/packages/client/ui-slots/src/index.ts @@ -18,6 +18,7 @@ import type { BoundActions, HandleOf, PropsStore, SnapshotSelectorHook, StoreDec export * from './store.ts' export * from './renderer.ts' +export * from './deferred.ts' /** Slot contract table. Owners extend via declaration merging; entries are {@link SlotEntryDef}. */ export interface SlotMap {} diff --git a/packages/client/ui-theme/README.i18n.yaml b/packages/client/ui-theme/README.i18n.yaml new file mode 100644 index 0000000000..3f0e2659dc --- /dev/null +++ b/packages/client/ui-theme/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 5c9794d4f5faea42861f47423d4e8980cbf89216 +README.zh.md: 2e0f76133ee38ba2fdc385e7fbb2a4a03b733cfa diff --git a/packages/client/ui-theme/README.md b/packages/client/ui-theme/README.md index 2ce5d9b9e6..5c9794d4f5 100644 --- a/packages/client/ui-theme/README.md +++ b/packages/client/ui-theme/README.md @@ -1,10 +1,12 @@ # @deepseek-ai/dsh-client-ui-theme -Theme plugin: ThemeService over the --dsw-* token base stylesheets (static scale + alias semantic layers); apply(id) toggles the `body[data-ds-dark-theme]` attribute, so theme switches are pure CSS cascade. Contract: api-contracts v3 §8. +English | [中文](README.zh.md) + +Theme plugin: ThemeService over the --dsw-* token base stylesheets (static scale + alias semantic layers). The service owns the theme preference (`light`/`dark`/`system`, persisted under `dsh.theme`), resolves `system` through `prefers-color-scheme`, and publishes immutable `ThemeSnapshot`s on the `theme/change` event; it never touches the DOM — ui-layout's presenter applies the resolved snapshot (`body[data-ds-dark-theme]` + inline alias tokens). Contract: api-contracts v3 §8. ## Model Experience -None, as the theme service toggles browser CSS; nothing here reaches a model request. +None, as the theme service manages a browser preference; nothing here reaches a model request. #### KV Cache effect @@ -12,6 +14,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **No theme-switch control ships in P-I** — the service surface (register/apply/current) is complete but no UI owner mounts a toggle; switching happens programmatically. - **Third-party themes are a surface, not a product** — registering one means overriding same-named alias variables; no validation exists that an override set is complete. - **The token sheets are the sole color authority** — values absent from cssdesign (for example the design's #4176E6 tab blue) are deliberately not appended; the nearest semantic token wins (arbitrated 2026-07-22). diff --git a/packages/client/ui-theme/README.zh.md b/packages/client/ui-theme/README.zh.md new file mode 100644 index 0000000000..2e0f76133e --- /dev/null +++ b/packages/client/ui-theme/README.zh.md @@ -0,0 +1,18 @@ +# @deepseek-ai/dsh-client-ui-theme + +[English](README.md) | 中文 + +主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeService。该服务拥有主题偏好(`light`/`dark`/`system`,以 `dsh.theme` 为键持久化),将 `system` 通过 `prefers-color-scheme` 解析为实际主题,并发布不可变的 `ThemeSnapshot`,通过 `theme/change` 事件通知变化;它绝不接触 DOM:ui-layout 的呈现器会应用解析后的快照(依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为内联变量)。契约:api-contracts v3 §8。 + +## 模型体验 + +无。主题服务管理浏览器偏好;这里没有任何内容进入模型请求。 + +#### KV Cache 影响 + +无;该包既不组装也不发送提供方请求。 + +## 已知限制与暂缓事项 + +- **第三方主题是表层,不是产品**:注册主题意味着覆盖同名别名变量;目前不会验证一组覆盖是否完整。 +- **token 样式表是颜色的唯一权威**:不会追加 cssdesign 中缺失的值(例如设计中的 #4176E6 标签页蓝色);应使用最接近的语义 token(裁定于 2026-07-22)。 diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index 4e601914e3..e60289b52d 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-theme", - "description": "Theme plugin: ThemeService (apply = toggle body[data-ds-dark-theme]), --dsw-* token base stylesheets", + "description": "Theme plugin: ThemeService (light/dark/system preference, prefers-color-scheme resolution, theme/change snapshots; no DOM), --dsw-* token base stylesheets; registers the Appearance settings row", "version": "0.0.1", "private": true, "type": "module", @@ -24,18 +24,32 @@ "./package.json": "./package.json" }, "dshClient": { - "inject": [], + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-locale" + ], "platform": "web", "immediately": true }, "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-client-locale": "^0.0.1", + "@deepseek-ai/dsh-client-runtime": "^0.0.1", + "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", + "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@types/react": "~18.3.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" }, "files": [ "lib/index.js", @@ -44,5 +58,12 @@ "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" - ] + ], + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "dependencies": { + "clsx": "^2.0.0" + } } diff --git a/packages/client/ui-theme/src/client/AppearanceRow.module.css b/packages/client/ui-theme/src/client/AppearanceRow.module.css new file mode 100644 index 0000000000..9619caa823 --- /dev/null +++ b/packages/client/ui-theme/src/client/AppearanceRow.module.css @@ -0,0 +1,51 @@ +/* Appearance row (figma 'Frame 2117131228': title + cube row, column gap 8, + * pad 16/0, hairline separator; the section column strips it when last). */ + +.group { + display: flex; + flex-direction: column; + gap: 8px; + padding: 16px 0; + border-bottom: 1px solid var(--dsw-alias-border-l2); +} + +.title { + font-size: 14px; + font-weight: 400; + line-height: 22px; + color: var(--dsw-alias-label-primary); +} + +.cubeRow { + display: flex; + align-items: stretch; + gap: 8px; +} + +/* Appearance cube (figma '.Selector Cube' 276x82 r16, pad 20/32, centered + * icon-over-label column, gap 4). */ +.themeCube { + box-sizing: border-box; + width: 276px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + padding: 20px 32px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 16px; + background: transparent; + font: inherit; + font-size: 14px; + line-height: 22px; + color: var(--dsw-alias-label-primary); + cursor: pointer; +} + +/* Selected cube: #F5F6F7 fill + #ADB2B8 border (static token — the bluish-400 + * step has no alias-layer name). */ +.selected { + background: var(--dsw-alias-bg-module-platform); + border-color: var(--dsw-static-neutral-bluish-400); +} diff --git a/packages/client/ui-theme/src/client/AppearanceRow.tsx b/packages/client/ui-theme/src/client/AppearanceRow.tsx new file mode 100644 index 0000000000..b4aad1725d --- /dev/null +++ b/packages/client/ui-theme/src/client/AppearanceRow.tsx @@ -0,0 +1,63 @@ +/** + * Appearance preference row registered into the General section item slot + * (figma 501:30012 'Frame 2117131228'): title + three preference cubes. + * Registered by this package — the theme feature owns its own settings + * surface. Selection follows the persisted preference, never the resolved + * active theme. + */ +import clsx from 'clsx' +import { + IconDarkOutline16, IconFollowsystemOutline16, IconLightOutline16, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' +import type { ThemePreference } from './index.ts' +import type {} from './settings-contract.ts' +import type { createAppearanceRowStore } from './settings-store.ts' +import css from './AppearanceRow.module.css' + +/** Injected business face: namespace-bound translate + the preference write. */ +export interface AppearanceRowInjected { + /** Translate a `settings.theme` dictionary key to the active-locale text. */ + t: (key: string) => string + /** Switch the theme preference. */ + setTheme: (id: ThemePreference) => void +} + +/** Full component props: runtime share + store share + injected face. */ +export type AppearanceRowComponentProps = + PropsRuntime<'settings.general.item'> & PropsStore<ReturnType<typeof createAppearanceRowStore>> & AppearanceRowInjected + +/** Cube order and icons (figma 501:30015-30017: Light, Dark, System). */ +const CUBES: readonly { id: ThemePreference; labelKey: string; Icon: typeof IconLightOutline16 }[] = [ + { id: 'light', labelKey: 'appearance.light', Icon: IconLightOutline16 }, + { id: 'dark', labelKey: 'appearance.dark', Icon: IconDarkOutline16 }, + { id: 'system', labelKey: 'appearance.system', Icon: IconFollowsystemOutline16 }, +] + +/** + * Render the Appearance row. + * @param props - composed slot props. + * @returns the row element tree. + */ +export function AppearanceRow({ t, setTheme, useStore }: AppearanceRowComponentProps) { + const preference = useStore(s => s.preference) + return ( + <div className={css.group}> + <div className={css.title}>{t('appearance.title')}</div> + <div className={css.cubeRow}> + {CUBES.map(({ id, labelKey, Icon }) => ( + <button + key={id} + type="button" + className={clsx(css.themeCube, preference === id && css.selected)} + aria-pressed={preference === id} + onClick={() => { setTheme(id) }} + > + <Icon /> + {t(labelKey)} + </button> + ))} + </div> + </div> + ) +} diff --git a/packages/client/ui-theme/src/client/index.ts b/packages/client/ui-theme/src/client/index.ts index d9f5b70c3b..dd11c98f06 100644 --- a/packages/client/ui-theme/src/client/index.ts +++ b/packages/client/ui-theme/src/client/index.ts @@ -1,81 +1,276 @@ /** - * Browser theme registry over the `--dsw-*` token stylesheets. Theme changes - * update CSS variables and `body[data-ds-dark-theme]` without React renders. + * Browser theme registry over the `--dsw-*` token stylesheets. The service + * owns the theme preference (light/dark/system), resolves `system` through + * `prefers-color-scheme`, and publishes immutable snapshots; it never touches + * the DOM — ui-layout's presenter consumes the resolved snapshot. The plugin + * also registers the Appearance preference row into the settings General + * section — the theme feature owns its own settings surface. */ import type { Context } from 'cordis' +import { deferRegistration, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots' +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +// Type-only: pulls the locale plugin's Context merge (ctx.locale). +import type {} from '@deepseek-ai/dsh-client-locale/client' +import type { AppearanceRowInjected } from './AppearanceRow.tsx' +import { AppearanceRow } from './AppearanceRow.tsx' +import { createAppearanceRowStore } from './settings-store.ts' + +export type { AppearanceRowComponentProps, AppearanceRowInjected } from './AppearanceRow.tsx' +export type { AppearanceRowState } from './settings-store.ts' + +/** Namespace owning this feature's settings-row copy. */ +export const SETTINGS_NS = 'settings.theme' /** Theme token dictionary: --dsw-alias-* overrides keyed by variable name. */ export type ThemeTokens = Record<string, string> +/** Theme preference: a concrete theme id or follow-the-OS. */ +export type ThemePreference = 'light' | 'dark' | 'system' + +/** One selectable theme: id, dark/light semantics, and alias-token overrides. */ +export interface ThemeDefinition { + /** Theme id (the setTheme argument for concrete themes). */ + id: string + /** + * Which base palette this theme builds on. The presenter switches + * `body[data-ds-dark-theme]` from this field — never from the id. + */ + colorScheme: 'light' | 'dark' + /** Alias-layer overrides applied as inline CSS variables over the base palette. */ + tokens: ThemeTokens +} + +/** Immutable theme state published on every change. */ +export interface ThemeSnapshot { + /** The persisted preference (may be `system`). */ + preference: ThemePreference + /** The resolved active theme (`system` resolved via prefers-color-scheme). */ + active: ThemeDefinition + /** Registered themes in registration order. */ + themes: readonly ThemeDefinition[] + /** Monotonic change counter (registry or active changes). */ + revision: number +} + declare module 'cordis' { interface Context { theme: ThemeService } + interface Events { + /** + * Theme state changed (preference switched, registry updated, or the OS + * color scheme changed while the preference is `system`). + * @param snapshot - Current immutable theme snapshot. + * @mode emit + */ + 'theme/change'(snapshot: ThemeSnapshot): void + } } +/** localStorage key holding the persisted theme preference. */ +export const STORAGE_KEY = 'dsh.theme' + +/** Default preference when nothing (or garbage) is persisted. */ +export const DEFAULT_PREFERENCE: ThemePreference = 'system' + +const BUILTIN_THEMES: readonly ThemeDefinition[] = Object.freeze([ + Object.freeze({ id: 'light', colorScheme: 'light' as const, tokens: Object.freeze({}) }), + Object.freeze({ id: 'dark', colorScheme: 'dark' as const, tokens: Object.freeze({}) }), +]) + /** - * Theme registry and switcher. `light`/`dark` are built in (the base - * stylesheets carry both palettes; the dark palette activates via the - * body[data-ds-dark-theme] attribute). Third-party themes register alias-layer - * overrides applied as inline CSS variables on body, cascading over whichever - * base palette the attribute selects. + * Theme registry and preference owner. `light`/`dark` are built in (the base + * stylesheets carry both palettes); third-party themes register alias-layer + * overrides. Reads go through {@link getTheme}; writes only through + * {@link setTheme}; continuous sync only through the `theme/change` event. + * The service holds the `prefers-color-scheme` media query (environment + * sensing, not presentation) and re-emits when the OS scheme flips while the + * preference is `system`. */ export class ThemeService { - private themes = new Map<string, ThemeTokens>([['light', {}], ['dark', {}]]) - private appliedTokens: ThemeTokens = {} - private active = 'light' + private readonly ctx: Context + private themes: ThemeDefinition[] = [...BUILTIN_THEMES] + private preference: ThemePreference + private revision = 0 + private snapshot: ThemeSnapshot + private readonly media: MediaQueryList | undefined /** - * Register a theme. Duplicate id throws (single occupant per id; the - * built-in pair counts). - * @param id - theme id. - * @param tokens - alias-layer overrides (variable name to value). - * @returns disposer. Disposing the active theme reverts to `light` so the - * UI never keeps tokens of an unregistered theme. + * @param ctx - owning context (change events are emitted on it; the + * media-query listener is released through ctx.effect on dispose). */ - register(id: string, tokens: ThemeTokens): () => void { - if (this.themes.has(id)) throw new Error(`theme "${id}" is already registered`) - this.themes.set(id, tokens) - return () => { - if (!this.themes.delete(id)) return - if (this.active === id) this.apply('light') + constructor(ctx: Context) { + this.ctx = ctx + this.preference = restorePreference() + // Non-browser runs (node e2e booting the client tree) have no matchMedia. + this.media = typeof matchMedia === 'undefined' ? undefined : matchMedia('(prefers-color-scheme: dark)') + this.snapshot = this.buildSnapshot() + if (this.media !== undefined) { + const media = this.media + const onChange = (): void => { + if (this.preference !== 'system') return + this.publish() + } + ctx.effect(() => { + media.addEventListener('change', onChange) + return () => { media.removeEventListener('change', onChange) } + }, 'ui-theme: prefers-color-scheme listener') } } /** - * Activate a theme: toggle body[data-ds-dark-theme] (set only for `dark`) - * and swap the previous theme's inline token overrides for this one's. - * Unregistered id throws. - * @param id - registered theme id. + * Read the current immutable theme snapshot. + * @returns the current snapshot (stable reference until the next change). */ - apply(id: string): void { - const tokens = this.themes.get(id) - if (!tokens) throw new Error(`theme "${id}" is not registered`) - const body = document.body - for (const name of Object.keys(this.appliedTokens)) body.style.removeProperty(name) - if (id === 'dark') body.setAttribute('data-ds-dark-theme', '') - else body.removeAttribute('data-ds-dark-theme') - for (const [name, value] of Object.entries(tokens)) body.style.setProperty(name, value) - this.appliedTokens = tokens - this.active = id + getTheme(): ThemeSnapshot { + return this.snapshot } /** - * Report the active theme id (initially `light`). - * @returns the active theme id. + * Switch the theme preference — the only preference write entry. Persists + * the preference and emits `theme/change`. + * @param id - a registered theme id or `system`; unknown ids throw. */ - current(): string { - return this.active + setTheme(id: string): void { + if (id !== 'system' && !this.themes.some(t => t.id === id)) { + throw new Error(`theme "${id}" is not registered`) + } + if (this.preference === id) return + this.preference = id as ThemePreference + persistPreference(this.preference) + this.publish() + } + + /** + * Register a theme. Duplicate id throws (single occupant per id; the + * built-in pair counts; `system` is a preference, not a registrable id). + * @param definition - theme id, colorScheme, and alias-token overrides. + * @returns disposer. Disposing the theme backing the active preference + * resets the preference to the default so the UI never keeps tokens of an + * unregistered theme. + */ + register(definition: ThemeDefinition): () => void { + if (definition.id === 'system') throw new Error('"system" is a preference, not a registrable theme id') + if (this.themes.some(t => t.id === definition.id)) { + throw new Error(`theme "${definition.id}" is already registered`) + } + this.themes = [...this.themes, definition] + this.publish() + return () => { + if (!this.themes.some(t => t.id === definition.id)) return + this.themes = this.themes.filter(t => t.id !== definition.id) + if (this.preference === definition.id) { + this.preference = DEFAULT_PREFERENCE + persistPreference(this.preference) + } + this.publish() + } + } + + private buildSnapshot(): ThemeSnapshot { + const resolvedId = this.preference === 'system' + ? (this.media?.matches === true ? 'dark' : 'light') + : this.preference + // Both built-ins always exist; a registered preference id resolves or has + // been reset by its disposer, so the lookup cannot miss. + const active = this.themes.find(t => t.id === resolvedId) + /* v8 ignore next 2 -- needs a registry without light/dark, which register()/dispose() cannot produce */ + if (active === undefined) throw new Error(`theme registry lost "${resolvedId}"`) + return Object.freeze({ + preference: this.preference, + active, + themes: Object.freeze([...this.themes]), + revision: this.revision, + }) + } + + private publish(): void { + this.revision += 1 + this.snapshot = this.buildSnapshot() + this.ctx.emit('theme/change', this.snapshot) } } -/** Required services (none; the loader passes the export surface as an object plugin). */ -export const inject: string[] = [] +/** Read the persisted preference; unknown or unreadable values fall back to the default. */ +function restorePreference(): ThemePreference { + // Non-browser runs (node e2e booting the client tree) have no localStorage. + if (typeof localStorage === 'undefined') return DEFAULT_PREFERENCE + try { + const stored = localStorage.getItem(STORAGE_KEY) + if (stored === 'light' || stored === 'dark' || stored === 'system') return stored + } catch { + // Storage access can throw (privacy mode); the default below covers it. + } + return DEFAULT_PREFERENCE +} + +/** Persist the preference; storage failures are non-fatal (preference resets next boot). */ +function persistPreference(preference: ThemePreference): void { + if (typeof localStorage === 'undefined') return + try { + localStorage.setItem(STORAGE_KEY, preference) + } catch { + // Storage access can throw (privacy mode / quota); the preference simply + // does not survive the session. + } +} + +/** Required services: slots + locale (the feature registers its own settings row with localized copy). */ +export const inject = ['slots', 'locale'] /** - * Client plugin body: provide the theme service. + * Client plugin body: provide the theme service and register the + * feature-owned Appearance preference row into the General section's item + * slot (a feature owns its settings surface). * @param ctx - client cordis context. */ -export function apply(ctx: Context): void { - ctx.provide('theme', new ThemeService()) +export function apply(ctx: ClientContext): void { + const theme = new ThemeService(ctx) + ctx.provide('theme', theme) + + ctx.effect(() => { + const disposers = [ + ctx.locale.register(SETTINGS_NS, 'zh', { + 'appearance.title': '外观', + 'appearance.light': '浅色', + 'appearance.dark': '深色', + 'appearance.system': '跟随系统', + }), + ctx.locale.register(SETTINGS_NS, 'en', { + 'appearance.title': 'Appearance', + 'appearance.light': 'Light', + 'appearance.dark': 'Dark', + 'appearance.system': 'System', + }), + ] + return () => { for (const dispose of disposers) dispose() } + }, 'ui-theme: settings row dictionaries') + + const store = createAppearanceRowStore() + let bound: BoundActions<typeof store> | undefined + const sync = (snapshot: ThemeSnapshot): void => { + bound?.sync(snapshot.preference, snapshot.revision) + } + ctx.on('theme/change', sync) + const injected = (actions: BoundActions<typeof store>): AppearanceRowInjected => { + bound = actions + // Re-sync from the getter so no event is lost between registration and + // first render (the store's revision guard drops stale duplicates). + sync(theme.getTheme()) + return { + t: ctx.locale.bind(SETTINGS_NS), + setTheme: (id) => { theme.setTheme(id) }, + } + } + ctx.effect(() => { + const deferred = deferRegistration(ctx.slots, 'settings.general.item', AppearanceRow, () => + ctx.slots.register({ + name: 'settings.general.item', + id: 'appearance', + order: 10, + store, + inject: injected, + }, AppearanceRow)) + return () => { deferred.dispose() } + }, 'ui-theme: appearance settings row registration') } diff --git a/packages/client/ui-theme/src/client/settings-contract.ts b/packages/client/ui-theme/src/client/settings-contract.ts new file mode 100644 index 0000000000..60bfffebb5 --- /dev/null +++ b/packages/client/ui-theme/src/client/settings-contract.ts @@ -0,0 +1,9 @@ +/** + * Re-export seam for the `settings.general.item` slot type consumed by this + * package's Appearance row. The canonical home is the locale package (the + * common dependency of every item registrant); this file exists so row + * modules import the type from within their own package. + */ +export type { SettingsGeneralItemOwnerProps } from '@deepseek-ai/dsh-client-locale/client' +// Side-effect type import: pulls the SlotMap merge into this program. +import type {} from '@deepseek-ai/dsh-client-locale/client' diff --git a/packages/client/ui-theme/src/client/settings-store.ts b/packages/client/ui-theme/src/client/settings-store.ts new file mode 100644 index 0000000000..256b04a299 --- /dev/null +++ b/packages/client/ui-theme/src/client/settings-store.ts @@ -0,0 +1,37 @@ +/** + * Appearance row slot store: a mirror of the theme service snapshot. The + * plugin's apply-world change listener is the only writer; the row component + * reads via props.useStore. + */ +import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client' +import type { ThemePreference } from './index.ts' + +/** Store state mirrored from the theme snapshot. */ +export interface AppearanceRowState { + /** Persisted preference (selection state reads this, never the resolved active theme). */ + preference: ThemePreference + /** Service revision; -1 until first sync so revision 0 lands as a change. */ + revision: number +} + +/** Declared action shape giving the exported factory a stable return type. */ +type AppearanceRowActions = { + sync: (draft: AppearanceRowState, preference: ThemePreference, revision: number) => void +} + +/** + * Declares the Appearance row state and write surface. + * @returns the store handle. + */ +export function createAppearanceRowStore(): EngineStoreHandle<AppearanceRowState, AppearanceRowActions> { + return defineStore({ + init: (): AppearanceRowState => ({ preference: 'system', revision: -1 }), + actions: { + sync: (d, preference: ThemePreference, revision: number) => { + if (revision <= d.revision) return + d.preference = preference + d.revision = revision + }, + }, + }) +} diff --git a/packages/client/ui-theme/src/css-modules.d.ts b/packages/client/ui-theme/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-theme/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record<string, string> + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-theme/src/invariant.ts b/packages/client/ui-theme/src/invariant.ts index 292304cbaf..4ec3296cd6 100644 --- a/packages/client/ui-theme/src/invariant.ts +++ b/packages/client/ui-theme/src/invariant.ts @@ -15,9 +15,10 @@ export const name = 'client-ui-theme-invariant' export const inject = ['invariants'] /** - * No runtime invariant: a token-sheet registry whose apply() flips one body - * attribute — it emits no cordis events; registration/apply/current behavior - * is asserted directly by this package's behavior specs. + * No runtime invariant: the theme registry publishes immutable snapshots on + * its own `theme/change` event synchronously with the setter/registry + * mutation in the same service — snapshot/event agreement is asserted + * directly by this package's behavior specs. */ const install: InvariantInstaller = () => {} diff --git a/packages/client/ui-theme/tests/appearance-row.spec.tsx b/packages/client/ui-theme/tests/appearance-row.spec.tsx new file mode 100644 index 0000000000..4782b674a8 --- /dev/null +++ b/packages/client/ui-theme/tests/appearance-row.spec.tsx @@ -0,0 +1,75 @@ +// @vitest-environment jsdom +/** AppearanceRow behavior: three cubes, selection follows the persisted + * preference, clicks drive setTheme. */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { createSnapshotStore, type SessionListState, type WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import { AppearanceRow } from '../src/client/AppearanceRow.tsx' +import type { AppearanceRowComponentProps } from '../src/client/AppearanceRow.tsx' +import { createAppearanceRowStore } from '../src/client/settings-store.ts' +import type { ThemePreference } from '../src/client/index.ts' + +afterEach(cleanup) + +const COPY: Record<string, string> = { + 'appearance.title': 'Appearance', + 'appearance.light': 'Light', + 'appearance.dark': 'Dark', + 'appearance.system': 'System', +} + +/** Empty global standard-kit hooks (the row reads neither). */ +function emptySessions() { + const store = createSnapshotStore<SessionListState>( + { ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready' }) + return bindSnapshotSelector(store) +} +function emptyWorkspaces() { + const store = createSnapshotStore<WorkspaceListState>({ + items: [], intent: undefined, state: 'idle', phase: 'ready', error: null, + baselinesReady: true, recentWorkspaceId: undefined, + }) + return bindSnapshotSelector(store) +} + +function mount(preference: ThemePreference = 'system') { + // Real store instance — the sanctioned zero-machinery path for tests. + const store = createAppearanceRowStore().create() + store.actions.sync(preference, 0) + const setTheme = vi.fn() + const props: AppearanceRowComponentProps = { + useSessions: emptySessions(), + useWorkspaces: emptyWorkspaces(), + useStore: bindSnapshotSelector(store), + actions: store.actions, + t: (key: string) => COPY[key] ?? key, + setTheme, + } + render(<AppearanceRow {...props} />) + return { store, setTheme } +} + +const pressed = (name: RegExp): string | null => + screen.getByRole('button', { name }).getAttribute('aria-pressed') + +describe('AppearanceRow', () => { + it('renders the title and three cubes with the preference cube selected', () => { + mount('dark') + expect(screen.getByText('Appearance')).toBeDefined() + expect(pressed(/Dark/)).toBe('true') + expect(pressed(/Light/)).toBe('false') + expect(pressed(/System/)).toBe('false') + }) + + it('click drives setTheme; selection follows the store mirror, not the click echo', () => { + const b = mount('dark') + fireEvent.click(screen.getByRole('button', { name: /Light/ })) + expect(b.setTheme).toHaveBeenCalledWith('light') + // No store write yet: selection is unchanged. + expect(pressed(/Dark/)).toBe('true') + act(() => { b.store.actions.sync('light', 1) }) + expect(pressed(/Light/)).toBe('true') + expect(pressed(/Dark/)).toBe('false') + }) +}) diff --git a/packages/client/ui-theme/tests/apply.spec.ts b/packages/client/ui-theme/tests/apply.spec.ts new file mode 100644 index 0000000000..9852b93e66 --- /dev/null +++ b/packages/client/ui-theme/tests/apply.spec.ts @@ -0,0 +1,117 @@ +/** ui-theme apply wiring: service provision, settings dictionaries riding the + * locale service, declaration-aware Appearance row registration, snapshot + * projection into the row store, and HMR collapse recovery. */ +import { Context } from 'cordis' +import { describe, expect, it } from 'vitest' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' +import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-ui-theme/client' +import type { AppearanceRowInjected, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' +import { AppearanceRow } from '../src/client/AppearanceRow.tsx' +import type { createAppearanceRowStore } from '../src/client/settings-store.ts' + +const SLOT = 'settings.general.item' + +async function bench() { + const ctx = new Context() + await ctx.plugin(SlotsService).await() + const locale = new LocaleService(ctx) + ctx.provide('locale', locale) + return { ctx, slots: ctx.get('slots') as SlotsService, locale } +} + +/** Stand in for the settings shell: declare the General item slot from root. */ +function declareItems(slots: SlotsService): () => void { + return slots.register( + { name: 'root', children: { [SLOT]: { kind: 'list', scope: 'root' } } } as never, + () => null, + ) +} + +/** Mirror the framework's inject choreography: bake a real instance from the + * declared handle and hand its actions to the entry's inject factory. */ +function faceOf(slots: SlotsService) { + const entry = slots.entries(SLOT).find(e => e.component === AppearanceRow)! + const handle = entry.store as ReturnType<typeof createAppearanceRowStore> + const instance = handle.create() + const face = (entry.inject as unknown as (a: typeof instance.actions) => AppearanceRowInjected)(instance.actions) + return { entry, instance, face } +} + +describe('ui-theme apply', () => { + it('declares the slot and locale services', () => { + expect(inject).toEqual(['slots', 'locale']) + }) + + it('provides the service, registers localized copy, and registers the row (declaration before or after apply)', async () => { + const before = await bench() + declareItems(before.slots) + await before.ctx.plugin({ inject: [...inject], apply }).await() + expect(before.locale.bind(SETTINGS_NS)('appearance.title')).toBe('外观') + before.locale.setLocale('en') + expect(before.locale.bind(SETTINGS_NS)('appearance.title')).toBe('Appearance') + const entry = before.slots.entries(SLOT).find(e => e.component === AppearanceRow)! + expect(entry.options).toMatchObject({ id: 'appearance', order: 10 }) + + const after = await bench() + const fiber = after.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(after.slots.entries(SLOT)).toHaveLength(0) + declareItems(after.slots) + await Promise.resolve() + expect(after.slots.entries(SLOT).some(e => e.component === AppearanceRow)).toBe(true) + }) + + it('projects service snapshots into the row store and routes face writes back', async () => { + const b = await bench() + declareItems(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const theme = b.ctx.get('theme') as ThemeService + // An event ahead of any inject hits the unbound-actions arm. + theme.setTheme('dark') + + const { instance, face } = faceOf(b.slots) + // The inject-time re-sync sealed the init window: the mirror is current. + expect(instance.getSnapshot().preference).toBe('dark') + expect(face.t('appearance.dark')).toBe('深色') + + face.setTheme('system') + expect(theme.getTheme().preference).toBe('system') + expect(instance.getSnapshot().preference).toBe('system') + }) + + it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => { + const b = await bench() + const host = declareItems(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + expect(b.slots.entries(SLOT)).toHaveLength(1) + + // Collapse: the declarer dies, the cascade removes our entry while the + // apply closure still holds its (now stale) disposer. + host() + expect(b.slots.entries(SLOT)).toHaveLength(0) + + declareItems(b.slots) + await Promise.resolve() + expect(b.slots.entries(SLOT).some(e => e.component === AppearanceRow)).toBe(true) + }) + + it('teardown removes the row and the dictionaries; teardown without a declaration is quiet', async () => { + const b = await bench() + declareItems(b.slots) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(b.slots.entries(SLOT)).toHaveLength(1) + await fiber.dispose() + expect(b.slots.entries(SLOT)).toHaveLength(0) + // Dictionary disposal: translation falls back to the bare key. + expect(b.locale.bind(SETTINGS_NS)('appearance.title')).toBe('appearance.title') + + // Never-declared bench: the effect disposer's dispose arm stays undefined. + const quiet = await bench() + const f2 = quiet.ctx.plugin({ inject: [...inject], apply }) + await f2.await() + await f2.dispose() + expect(quiet.slots.entries(SLOT)).toHaveLength(0) + }) +}) diff --git a/packages/client/ui-theme/tests/invariant.spec.ts b/packages/client/ui-theme/tests/invariant.spec.ts index 6f4e867c4d..640599ea43 100644 --- a/packages/client/ui-theme/tests/invariant.spec.ts +++ b/packages/client/ui-theme/tests/invariant.spec.ts @@ -4,6 +4,8 @@ import { Context } from 'cordis' import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-theme' import { apply as clientApply, inject, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' import * as ThemeInvariant from '@deepseek-ai/dsh-client-ui-theme/invariant' +import { apply as localeApply } from '@deepseek-ai/dsh-client-locale/client' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import InvariantService from '@deepseek-ai/dsh-invariants' describe('invariant companion', () => { @@ -18,9 +20,13 @@ describe('invariant companion', () => { expect(true).toBe(true) // reaching here without throw is the contract }) - it('client apply provides ctx.theme with no service prerequisites', async () => { - expect(inject).toEqual([]) + it('client apply provides ctx.theme over the slots/locale edges', async () => { + // The feature registers its own Appearance settings row with localized + // copy, hence the slots + locale edges. + expect(inject).toEqual(['slots', 'locale']) const ctx = new Context() + new SlotsService(ctx) + await ctx.plugin({ inject: ['slots'], apply: localeApply }).await() await ctx.plugin({ inject, apply: clientApply }).await() expect(ctx.get('theme')).toBeInstanceOf(ThemeService) }) diff --git a/packages/client/ui-theme/tests/settings-store.spec.ts b/packages/client/ui-theme/tests/settings-store.spec.ts new file mode 100644 index 0000000000..540d0f5b3b --- /dev/null +++ b/packages/client/ui-theme/tests/settings-store.spec.ts @@ -0,0 +1,28 @@ +/** Appearance row store: snapshot-mirror action and the revision guard. */ +import { describe, expect, it } from 'vitest' +import { createAppearanceRowStore } from '../src/client/settings-store.ts' + +describe('createAppearanceRowStore', () => { + it('init shape: system preference with revision at -1', () => { + const store = createAppearanceRowStore().create() + expect(store.getSnapshot()).toEqual({ preference: 'system', revision: -1 }) + }) + + it('sync mirrors the preference and advances the revision', () => { + const store = createAppearanceRowStore().create() + store.actions.sync('dark', 0) + expect(store.getSnapshot()).toEqual({ preference: 'dark', revision: 0 }) + store.actions.sync('light', 2) + expect(store.getSnapshot().preference).toBe('light') + expect(store.getSnapshot().revision).toBe(2) + }) + + it('revision guard drops stale and duplicate writes', () => { + const store = createAppearanceRowStore().create() + store.actions.sync('dark', 3) + store.actions.sync('system', 2) + store.actions.sync('system', 3) + expect(store.getSnapshot().preference).toBe('dark') + expect(store.getSnapshot().revision).toBe(3) + }) +}) diff --git a/packages/client/ui-theme/tests/theme.spec.ts b/packages/client/ui-theme/tests/theme.spec.ts index 126957c5eb..c853c9fd67 100644 --- a/packages/client/ui-theme/tests/theme.spec.ts +++ b/packages/client/ui-theme/tests/theme.spec.ts @@ -1,61 +1,151 @@ // @vitest-environment jsdom -import { beforeEach, describe, expect, it } from 'vitest' -import { ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client' +import { STORAGE_KEY, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' + +const make = (): { ctx: Context; theme: ThemeService; events: ThemeSnapshot[] } => { + const ctx = new Context() + const events: ThemeSnapshot[] = [] + ctx.on('theme/change', (snapshot) => { events.push(snapshot) }) + return { ctx, theme: new ThemeService(ctx), events } +} describe('ThemeService', () => { beforeEach(() => { - document.body.removeAttribute('data-ds-dark-theme') - document.body.removeAttribute('style') + localStorage.clear() }) - it('starts on light; apply toggles the dark body attribute both ways', () => { - const theme = new ThemeService() - expect(theme.current()).toBe('light') - theme.apply('dark') - expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(true) - expect(theme.current()).toBe('dark') - theme.apply('light') + it('defaults to the system preference resolved against prefers-color-scheme', () => { + const { theme } = make() + const snapshot = theme.getTheme() + expect(snapshot.preference).toBe('system') + // jsdom matchMedia is absent; system resolves to light. + expect(snapshot.active.id).toBe('light') + expect(snapshot.active.colorScheme).toBe('light') + expect(snapshot.themes.map(t => t.id)).toEqual(['light', 'dark']) + }) + + it('setTheme switches, persists, republishes, and keeps DOM untouched', () => { + const { theme, events } = make() + theme.setTheme('dark') + expect(theme.getTheme().preference).toBe('dark') + expect(theme.getTheme().active.colorScheme).toBe('dark') + expect(localStorage.getItem(STORAGE_KEY)).toBe('dark') + expect(events).toHaveLength(1) + expect(events[0]).toBe(theme.getTheme()) + // The service never touches presentation state. expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false) - expect(theme.current()).toBe('light') + // Same-value set is a no-op (no extra event). + theme.setTheme('dark') + expect(events).toHaveLength(1) }) - it('throws on unregistered apply and duplicate register (built-ins included)', () => { - const theme = new ThemeService() - expect(() => { theme.apply('sepia') }).toThrow('not registered') - expect(() => theme.register('light', {})).toThrow('already registered') - theme.register('sepia', {}) - expect(() => theme.register('sepia', {})).toThrow('already registered') + it('restores a persisted preference and falls back on garbage', () => { + localStorage.setItem(STORAGE_KEY, 'dark') + expect(make().theme.getTheme().preference).toBe('dark') + localStorage.setItem(STORAGE_KEY, 'sepia') + expect(make().theme.getTheme().preference).toBe('system') }) - it('applies third-party token overrides as body inline vars and swaps them on switch', () => { - const theme = new ThemeService() - theme.register('sepia', { '--dsw-alias-bg-base': 'rgb(1, 2, 3)' }) - theme.apply('sepia') - expect(document.body.style.getPropertyValue('--dsw-alias-bg-base')).toBe('rgb(1, 2, 3)') - expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false) - theme.apply('dark') - expect(document.body.style.getPropertyValue('--dsw-alias-bg-base')).toBe('') - expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(true) + it('throws on unknown setTheme ids, duplicate registration, and the system id', () => { + const { theme } = make() + expect(() => { theme.setTheme('sepia') }).toThrow('not registered') + expect(() => theme.register({ id: 'light', colorScheme: 'light', tokens: {} })).toThrow('already registered') + expect(() => theme.register({ id: 'system', colorScheme: 'light', tokens: {} })).toThrow('preference') }) - it('disposing the active theme reverts to light; disposer is idempotent', () => { - const theme = new ThemeService() - const dispose = theme.register('sepia', { '--dsw-alias-bg-base': 'red' }) - theme.apply('sepia') + it('registered themes join the snapshot; disposing the active one resets to default', () => { + const { theme, events } = make() + const dispose = theme.register({ id: 'sepia', colorScheme: 'light', tokens: { '--dsw-alias-bg-base': 'red' } }) + expect(theme.getTheme().themes.map(t => t.id)).toEqual(['light', 'dark', 'sepia']) + theme.setTheme('sepia') + expect(theme.getTheme().active.tokens['--dsw-alias-bg-base']).toBe('red') dispose() - expect(theme.current()).toBe('light') - expect(document.body.style.getPropertyValue('--dsw-alias-bg-base')).toBe('') - expect(() => { theme.apply('sepia') }).toThrow('not registered') + expect(theme.getTheme().preference).toBe('system') + expect(theme.getTheme().themes.map(t => t.id)).toEqual(['light', 'dark']) + expect(localStorage.getItem(STORAGE_KEY)).toBe('system') + // register + set + dispose = three publishes; disposer is idempotent. + expect(events.length).toBe(3) dispose() - expect(theme.current()).toBe('light') + expect(events.length).toBe(3) }) - it('disposing an inactive theme leaves the active selection untouched', () => { - const theme = new ThemeService() - const dispose = theme.register('sepia', {}) - theme.apply('dark') + it('disposing an inactive theme keeps the active preference', () => { + const { theme } = make() + const dispose = theme.register({ id: 'sepia', colorScheme: 'light', tokens: {} }) + theme.setTheme('dark') dispose() - expect(theme.current()).toBe('dark') - expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(true) + expect(theme.getTheme().preference).toBe('dark') + }) + + it('revision increases monotonically across every publish', () => { + const { theme, events } = make() + theme.setTheme('dark') + theme.setTheme('light') + const dispose = theme.register({ id: 'sepia', colorScheme: 'dark', tokens: {} }) + dispose() + expect(events.map(e => e.revision)).toEqual([1, 2, 3, 4]) + }) + + it('runs without localStorage (node boots): defaults on read, no-op on write', () => { + vi.stubGlobal('localStorage', undefined) + try { + const { theme } = make() + expect(theme.getTheme().preference).toBe('system') + theme.setTheme('dark') + expect(theme.getTheme().preference).toBe('dark') + } finally { + vi.unstubAllGlobals() + } + }) + + describe('prefers-color-scheme resolution (stubbed matchMedia)', () => { + type Listener = () => void + const stubMedia = (initialMatches: boolean) => { + const listeners = new Set<Listener>() + const media = { + matches: initialMatches, + addEventListener: (_: 'change', fn: Listener) => { listeners.add(fn) }, + removeEventListener: (_: 'change', fn: Listener) => { listeners.delete(fn) }, + flip() { + this.matches = !this.matches + for (const fn of listeners) fn() + }, + listenerCount: () => listeners.size, + } + vi.stubGlobal('matchMedia', () => media) + return media + } + + afterEach(() => { vi.unstubAllGlobals() }) + + it('system resolves against the media query and follows OS flips', () => { + const media = stubMedia(true) + const { theme, events } = make() + expect(theme.getTheme().preference).toBe('system') + expect(theme.getTheme().active.id).toBe('dark') + media.flip() + expect(theme.getTheme().active.id).toBe('light') + expect(events).toHaveLength(1) + }) + + it('OS flips do not republish while a concrete preference is set', () => { + const media = stubMedia(false) + const { theme, events } = make() + theme.setTheme('light') + expect(events).toHaveLength(1) + media.flip() + expect(events).toHaveLength(1) + expect(theme.getTheme().active.id).toBe('light') + }) + + it('context dispose releases the media listener', async () => { + const media = stubMedia(false) + const { ctx } = make() + expect(media.listenerCount()).toBe(1) + await ctx.fiber.dispose() + expect(media.listenerCount()).toBe(0) + }) }) }) diff --git a/packages/client/ui-theme/tsconfig.json b/packages/client/ui-theme/tsconfig.json index 51f9171643..7d5cc6f235 100644 --- a/packages/client/ui-theme/tsconfig.json +++ b/packages/client/ui-theme/tsconfig.json @@ -8,6 +8,18 @@ "src" ], "references": [ + { + "path": "../locale" + }, + { + "path": "../runtime" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-slots" + }, { "path": "../../../vendor/cordis" }, diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml new file mode 100644 index 0000000000..b07fe7a1fd --- /dev/null +++ b/packages/client/ui-trajectory/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 9e7dee8d5572baad13d7598d1fbde7b042dd5ab4 +README.zh.md: da14d5265c16acb4e75d21bc445ef9702c83e697 diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index f99a5c8386..9e7dee8d55 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-client-ui-trajectory +English | [中文](README.zh.md) + Trajectory turn-list chrome (sticky Turn / Message·Step groups / step cells) plus Waterfall placeholder; the pure-consumer minimal plugin exemplar (registers two view tabs into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8. ## Model Experience diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md new file mode 100644 index 0000000000..da14d5265c --- /dev/null +++ b/packages/client/ui-trajectory/README.zh.md @@ -0,0 +1,17 @@ +# @deepseek-ai/dsh-client-ui-trajectory + +[English](README.md) | 中文 + +轨迹轮次列表 chrome(吸顶 Turn/Message·Step 分组/步骤单元格)及 Waterfall 占位符;这是纯消费方最小插件范例(向会话的 `'conversation.view'` slot 环注册两个视图标签页,不提供服务,也不声明 Context 合并)。契约:api-contracts v3 §8。 + +## 模型体验 + +无。轨迹视图在浏览器中渲染会话数据;这里没有任何内容进入模型请求。 + +#### KV Cache 影响 + +无;该包既不组装也不发送提供方请求。 + +## 已知限制与暂缓事项 + +- **进行中的 Time 保持空白**:`partial`/`runningCalls` 行在实时钟策略落地前渲染为 `—`;选中样式只在本地生效(未连接到聊天详情);锚点深链接仍暂缓实现。 diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml new file mode 100644 index 0000000000..d0f2d0a20a --- /dev/null +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: e0247b3e26f617f86e9c0094afa1cbc920f02d33 +README.zh.md: 92ef463faab4b1ccda85d7f3cec1678a338d4010 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index b2a024fc5e..e0247b3e26 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-client-ui-workspace +English | [中文](README.zh.md) + Shared Workspace picker plugin. `WorkspacePicker` is registered into the sidebar's `sidebar.workspace` slot and the page-local Session Intent hero's `conversation.empty.workspace` slot, so both surfaces use the same menu and creation modals. The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object; the existing-folder and create-new actions first create a real Workspace through the object layer, then select it. Create-new disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md new file mode 100644 index 0000000000..92ef463faa --- /dev/null +++ b/packages/client/ui-workspace/README.zh.md @@ -0,0 +1,22 @@ +# @deepseek-ai/dsh-client-ui-workspace + +[English](README.md) | 中文 + +共享 Workspace 选择器插件。`WorkspacePicker` 注册到侧边栏的 `sidebar.workspace` slot,以及页面局部 Session Intent 主视觉区的 `conversation.empty.workspace` slot,因此两个表层使用同一菜单和创建模态框。 + +该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象;使用现有文件夹和新建操作时,系统会先通过对象层创建真实 Workspace,再将其选中。新建操作会禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。 + +两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。 + +## 模型体验 + +无。选择器属于浏览器 chrome;这里没有任何内容进入模型请求。 + +#### KV Cache 影响 + +无;该包既不组装也不发送提供方请求。 + +## 已知限制与暂缓事项 + +- **没有 Workspace 重命名/删除控件**:选择器仅支持选择和创建。 +- **现有文件夹入口仅支持手动输入路径**:Host 创建失败会显示在模态框中。 diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index 0a6c82c0cf..c36486d2fd 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -35,6 +35,9 @@ "watch": "tsdown --watch" }, "license": "BSD-3-Clause", + "dependencies": { + "clsx": "^2.0.0" + }, "peerDependencies": { "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css new file mode 100644 index 0000000000..d6375cb698 --- /dev/null +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css @@ -0,0 +1,265 @@ +/* Workspace browsing region (fills the sidebar shell's hole): section + header, search capsule, and the scrolling session list. Wide/rail + variants ride the shell's fold state through the `wide` owner prop — + rail state renders only the two 36x36 icon controls. */ + +.root { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.iconButton { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: 50%; + padding: 0; + background: transparent; + cursor: pointer; + color: var(--dsw-alias-label-secondary); +} + +.iconButton:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +/* Section header: 36px, "Workspaces/Sessions" label + group-by / + new-workspace buttons; the right-anchored new-workspace button is the + row's rail survivor. */ +.sectionHeader { + flex: none; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 4px; + height: 36px; + padding-left: 12px; + margin-bottom: 4px; + box-sizing: border-box; + border-radius: 12px; + overflow: hidden; + color: var(--dsw-alias-label-tertiary); +} + +.sectionLabel { + flex: 1; + min-width: 0; + overflow: hidden; + white-space: nowrap; + line-height: 20px; +} + +/* Search input: 38px capsule (figma 133:7649); rail state renders it as the + region's search control. Upstream binds a dedicated design-system variable + (light #F1F3F5 / dark #1B1B1C) matching no shipped alias — a component + token pinned to the static scale mirrors it. */ +.search { + --dsh-search-input-fill: var(--dsw-static-neutral-bluish-75); + flex: none; + display: flex; + align-items: center; + gap: 8px; + height: 38px; + margin: 0 2px 12px; + padding: 0 14px; + box-sizing: border-box; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 24px; + background: var(--dsh-search-input-fill); + color: var(--dsw-alias-label-caption); + overflow: hidden; +} + +:global(body[data-ds-dark-theme]) .search { + --dsh-search-input-fill: var(--dsw-static-neutral-bluish-900); +} + +/* The capsule's leading icon: decorative while wide (pointer-events off so + clicks reach the input), the hit target in rail state. */ +.searchButton { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + border-radius: 50%; + padding: 0; + background: transparent; + pointer-events: none; + color: inherit; +} + +.searchInput { + flex: 1; + min-width: 0; + border: none; + outline: none; + background: transparent; + font-size: 14px; + line-height: 20px; + color: var(--dsw-alias-label-primary); +} + +.searchInput::placeholder { + color: var(--dsw-alias-label-tertiary); +} + +.clearButton { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: 50%; + padding: 0; + background: transparent; + cursor: pointer; + color: var(--dsw-alias-label-secondary); +} + +/* Rail variant (own .rail class from the wide owner prop — the region never + reads the shell's class names): the two icon controls stack as 36x36 + circles matching the shell's rail rhythm. */ +.rail .sectionHeader { + padding-left: 0; + margin-bottom: 12px; +} + +.rail .iconButton { + width: 36px; + height: 36px; + color: var(--dsw-alias-label-primary); +} + +.rail .search { + height: 36px; + padding: 0; + margin: 0 0 12px; + gap: 0; + border-color: transparent; + background: transparent; +} + +.rail .searchButton { + width: 36px; + height: 36px; + pointer-events: auto; + cursor: pointer; + color: var(--dsw-alias-label-primary); +} + +.rail .searchButton:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +/* List seat: always mounted so the shell foot never moves. */ +.listArea { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* Relative for the bottom fade overlay. */ +.treeBody { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + position: relative; +} + +/* Bottom fade (figma 133:7666): 72px overlay pinned to the visible bottom, + transparent -> sidebar fill so it tracks the theme. */ +.fade { + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 72px; + background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill)); + pointer-events: none; +} + +/* Wide-only content fades back in on expand remount (mirrors the shell). */ +.wide { + animation: wide-in 200ms var(--ds-ease-in-out); +} + +@keyframes wide-in { + from { opacity: 0; } +} + +/* List: the only scrolling region. Block, not a flex column: as flex items + the 54/34 rows would shrink under content overflow; block children keep + their design heights and the 4px rhythm rides margins instead of gap. */ +.list { + flex: 1; + min-height: 0; + overflow-y: auto; + padding-bottom: 12px; +} + +/* One workspace section: header row + expanded session run. Rows inside + keep the former flat-list 4px gap as sibling margins; the inter-group + breathing room (figma 133:7661 batch separator, 20px after an expanded + run) rides the NEXT section's top margin so the last group adds none. */ +.groupSection > * + * { + margin-top: 4px; +} + +.groupSection + .groupSection { + margin-top: 4px; +} + +.groupSection:has([aria-expanded='true']) + .groupSection { + margin-top: 20px; +} + +.empty { + padding: 16px 12px; + color: var(--dsw-alias-label-tertiary); + font-size: 13px; +} + +/* Rename dialog form (same figma dialog family as the create modals). */ +.renameInput { + box-sizing: border-box; + width: 100%; + height: 44px; + padding: 7px 14px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 22px; + outline: none; + background: transparent; + font-size: 14px; + font-weight: 400; + line-height: 22px; + color: var(--dsw-alias-label-primary); +} + +.renameInput:disabled { + color: var(--dsw-alias-label-dimmed); +} + +.renameError { + margin-top: 8px; + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-state-error-primary); +} + +@media (prefers-reduced-motion: reduce) { + .wide { + animation: none; + } +} diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx new file mode 100644 index 0000000000..d051df2fdb --- /dev/null +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -0,0 +1,433 @@ +/** + * The workspace/session browsing region filling the sidebar shell's + * `sidebar.workspaces` hole: section header (title + group-by + new + * workspace), search, the grouped tree or flat list, and the workspace + * dialogs. Wide state renders the full browser; rail state renders the two + * region icons (search / new workspace), each requesting shell expansion + * through the owner share. The picker menu and create dialogs live in + * WorkspacePicker (same package — direct composition, no slot between them). + */ +import { useEffect, useMemo, useRef, useState } from 'react' +import clsx from 'clsx' +import { + Button, IconCloseFill14, IconPersonalizationOutline16, + IconProjectAddOutline16, IconSearchOutline16, Menu, Modal, Tooltip, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client' +import type { WorkspaceBrowserProps } from './contract/slots.ts' +import type { SessionNode } from './tree.ts' +import { deriveFlat, deriveGroups, UNGROUPED_KEY } from './tree.ts' +import { IntentRowItem, ProjectRowItem, SessionNodeItem } from './rows/Rows.tsx' +import { WorkspaceCreateFlow } from './WorkspacePicker.tsx' +import css from './WorkspaceBrowser.module.css' + +/** Column slide length (--ds-transition-duration-slow): rail-search focus waits it out — focus() forces a synchronous layout and would jank the slide. */ +const EXPAND_SLIDE_MS = 300 + +const GROUP_BY_ITEMS = [ + { type: 'label' as const, id: 'group-by', text: 'Group by' }, + { id: 'workspace', label: 'WorkSpace' }, + { id: 'flat', label: 'In one list' }, +] + +/** Immutable membership toggle for the local expansion arrays. */ +function toggled(list: readonly string[], key: string): string[] { + return list.includes(key) ? list.filter((k) => k !== key) : [...list, key] +} + +/** Group-by strategy menu; own open state so it resets with the wide chrome. */ +function GroupByMenu({ groupBy, onPick }: { + groupBy: 'workspace' | 'flat' + onPick: (mode: 'workspace' | 'flat') => void +}) { + const [open, setOpen] = useState(false) + return ( + <Menu + open={open} + onClose={() => { setOpen(false) }} + items={GROUP_BY_ITEMS} + selectedId={groupBy} + onSelect={(id) => { + /* v8 ignore next -- narrowing guard: the heading label is not selectable, so the only arriving ids are the two modes. */ + if (id === 'workspace' || id === 'flat') onPick(id) + setOpen(false) + }} + align="end" + // Portal: the section header clips overflow, so an in-place list would + // be cut off at the header's bounds. + portal + anchor={( + <button + type="button" + className={clsx(css.iconButton, css.wide)} + aria-label="Group by" + onClick={() => { setOpen((v) => !v) }} + > + <IconPersonalizationOutline16 /> + </button> + )} + /> + ) +} + +/** In-flight root-row drag: source identity plus the current insert marker. */ +interface DragState { + workspaceId: WorkspaceId + sessionId: SessionNode['id'] + /** Row the marker sits on and which half (insert above/below it). */ + over: { id: SessionNode['id']; half: 'before' | 'after' } | null +} + +type SessionTreeProps = Pick< + WorkspaceBrowserProps, + 'useSessions' | 'startSession' | 'open' | 'insertSessionBefore' +> & { + workspaces: readonly WorkspaceView[] + /** Live search filter owned by the browser root (the query outlives the tree). */ + query: string + /** Open the browser-owned rename dialog for a real Workspace group. */ + onRenameRequest: (workspaceId: WorkspaceId, currentTitle: string) => void +} + +/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */ +function SessionTree({ useSessions, startSession, open, workspaces, query, onRenameRequest, insertSessionBefore }: SessionTreeProps) { + const list = useSessions((s) => s) + const current = list.current + const [expandedProjects, setExpandedProjects] = useState<string[]>([]) + const [expandedSessions, setExpandedSessions] = useState<string[]>([]) + // Transient drag viewing state (never store-bound; order truth stays Host-side). + const [drag, setDrag] = useState<DragState | null>(null) + // Re-expand when publication moves the selected intent into a real Workspace. + const intent = list.intent + const intentWorkspaceId = intent?.target.kind === 'workspace' + ? intent.target.workspaceId + : undefined + const currentGroup = current === undefined + ? undefined + : intent?.sessionId === current + ? intentWorkspaceId + : (workspaces.find(w => w.sessionIds.includes(current))?.workspaceId as string | undefined) + ?? UNGROUPED_KEY + useEffect(() => { + if (current === undefined || currentGroup === undefined) return + setExpandedProjects((l) => (l.includes(currentGroup) ? l : [...l, currentGroup])) + }, [current, currentGroup]) + const groups = useMemo( + () => deriveGroups(list, workspaces, { expandedProjects, expandedSessions, query }), + [list, workspaces, expandedProjects, expandedSessions, query], + ) + const now = Date.now() + + return ( + <div className={clsx(css.treeBody, css.wide)}> + <div className={css.list} role="tree" aria-label="Sessions"> + {groups.length === 0 && ( + <div className={css.empty}>{query === '' ? 'No sessions yet' : 'No matches'}</div> + )} + {groups.map(group => ( + // Group section: header row + expanded session subtree. The + // inter-group breathing room (former flat-list batch separator) + // is the section's own margin (WorkspaceBrowser.module.css). + <div key={group.key} className={css.groupSection}> + <ProjectRowItem + group={group} + onToggle={() => { setExpandedProjects((l) => toggled(l, group.key)) }} + onCreate={() => { + if (group.workspaceId !== undefined) startSession(group.workspaceId) + }} + onRename={group.workspaceId === undefined + ? undefined + : () => { + /* v8 ignore next -- narrowing guard: the closure is only created for real-workspace groups. */ + if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label) + }} + /> + {group.expanded && group.intentHere && <IntentRowItem />} + {group.sessions.map((node, index) => { + // Draggable: real-workspace group roots outside search. The drag + // never leaves its group — rows of other groups show no markers + // and reject drops (visual movement confined to this section). + const draggable = group.workspaceId !== undefined && query === '' + const sameGroupDrag = drag !== null && drag.workspaceId === group.workspaceId + const dragProps = !draggable || group.workspaceId === undefined ? undefined : { + start: () => { + setDrag({ workspaceId: group.workspaceId as WorkspaceId, sessionId: node.id, over: null }) + }, + active: sameGroupDrag, + marker: sameGroupDrag && drag.over?.id === node.id ? drag.over.half : null, + hover: (half: 'before' | 'after') => { + /* v8 ignore next -- narrowing guard: Rows gates hover on `active`, which is false while the drag state is null. */ + setDrag(d => (d === null ? d : { ...d, over: { id: node.id, half } })) + }, + drop: (half: 'before' | 'after') => { + /* v8 ignore next -- narrowing guard: Rows gates drop on `active`, which is false while the drag state is null. */ + if (drag === null) return + const roots = group.sessions + // Anchor = the row the insert line points at ('after' means + // the next root; end-of-list omits the anchor → append). + const anchor = half === 'before' ? node.id : roots[index + 1]?.id + setDrag(null) + if (anchor === drag.sessionId) return + // No-op when the drop lands back on the source position. + const sourceIndex = roots.findIndex(r => r.id === drag.sessionId) + const anchorIndex = anchor === undefined ? roots.length : roots.findIndex(r => r.id === anchor) + if (sourceIndex !== -1 && (anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1)) return + insertSessionBefore(drag.workspaceId, drag.sessionId, anchor).catch((reason: unknown) => { + console.warn('session reorder rejected:', reason) + }) + }, + end: () => { setDrag(null) }, + } + return ( + <SessionNodeItem + key={node.id} + node={node} + depth={0} + currentId={current} + now={now} + onOpen={open} + onToggle={(id) => { setExpandedSessions((l) => toggled(l, id)) }} + drag={dragProps} + /> + ) + })} + </div> + ))} + </div> + <span className={css.fade} /> + </div> + ) +} + +/** The flat "In one list" body: every session a top-level row, newest-first. */ +function FlatList({ useSessions, open, query }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'query'>) { + const list = useSessions((s) => s) + const rows = useMemo(() => deriveFlat(list, { query }), [list, query]) + const now = Date.now() + // The intent placeholder renders outside search only; it suppresses the + // empty state only while actually rendered (a query hides both). + const intentRow = query === '' && list.intent !== undefined + return ( + <div className={clsx(css.treeBody, css.wide)}> + <div className={css.list} role="tree" aria-label="Sessions"> + {rows.length === 0 && !intentRow && ( + <div className={css.empty}>{query === '' ? 'No sessions yet' : 'No matches'}</div> + )} + {intentRow && <IntentRowItem />} + {rows.map(node => ( + <SessionNodeItem + key={node.id} + node={node} + depth={0} + currentId={list.current} + now={now} + onOpen={open} + /* v8 ignore next -- required-prop filler: flat rows render no twist, so it never fires. */ + onToggle={() => {}} + flat + /> + ))} + </div> + <span className={css.fade} /> + </div> + ) +} + +/** + * Render the browsing region. + * @param props - composed slot props (shell owner share + store + injected actions). + * @returns the region element tree. + */ +export function WorkspaceBrowser({ + wide, + expandSidebar, + useSessions, + useWorkspaces, + useStore, + actions, + startSession, + open, + renameWorkspace, + insertSessionBefore, + createWorkspace, +}: WorkspaceBrowserProps) { + const workspaces = useWorkspaces(state => state.items) + const groupBy = useStore(s => s.groupBy) + // The query outlives the tree and the input (both wide-only) so collapsing + // does not silently drop an in-progress filter. + const [query, setQuery] = useState('') + const searchInput = useRef<HTMLInputElement | null>(null) + // Section-header + opens the picker menu (same popover in wide and rail + // states; the menu anchors on this button). + const [wsPickerOpen, setWsPickerOpen] = useState(false) + const wsPlusRef = useRef<HTMLButtonElement>(null) + + // Rail search = expand + land in the search box: the flag arms before the + // expand request; once the shell flips wide the input mounts and takes focus. + const [searchOnExpand, setSearchOnExpand] = useState(false) + useEffect(() => { + if (wide && searchOnExpand) { + const timer = window.setTimeout(() => { + searchInput.current?.focus({ preventScroll: true }) + setSearchOnExpand(false) + }, EXPAND_SLIDE_MS) + return () => { window.clearTimeout(timer) } + } + }, [wide, searchOnExpand]) + + // Rename dialog (browser-owned so it outlives row unmounts during collapse). + const [renameTarget, setRenameTarget] = useState<{ workspaceId: WorkspaceId; currentTitle: string } | null>(null) + const [renameDraft, setRenameDraft] = useState('') + const [renaming, setRenaming] = useState(false) + const [renameError, setRenameError] = useState<string | null>(null) + const renameTrimmed = renameDraft.trim() + const renameDuplicate = renameTarget !== null && renameTrimmed !== '' && renameTrimmed !== renameTarget.currentTitle + && workspaces.some(w => w.title === renameTrimmed) + const renameBlocked = renaming || renameTrimmed === '' + || renameTarget === null || renameTrimmed === renameTarget.currentTitle || renameDuplicate + const closeRename = () => { + if (renaming) return + setRenameTarget(null) + setRenameError(null) + } + const confirmRename = () => { + if (renameBlocked || renameTarget === null) return + setRenaming(true) + setRenameError(null) + renameWorkspace(renameTarget.workspaceId, renameTrimmed).then(() => { + setRenaming(false) + setRenameTarget(null) + }).catch((reason: unknown) => { + setRenaming(false) + setRenameError(reason instanceof Error ? reason.message : String(reason)) + }) + } + + return ( + <div className={clsx(css.root, !wide && css.rail)}> + <div className={css.sectionHeader}> + {wide && ( + <span className={clsx(css.sectionLabel, css.wide)}> + {groupBy === 'flat' ? 'Sessions' : 'Workspaces'} + </span> + )} + {wide && <GroupByMenu groupBy={groupBy} onPick={(mode) => { actions.setGroupBy(mode) }} />} + <Tooltip label="New Workspace" disabled={wide}> + <button + ref={wsPlusRef} + type="button" + className={css.iconButton} + aria-label="Create workspace" + onClick={() => { + if (!wide) expandSidebar() + setWsPickerOpen(v => !v) + }} + > + <IconProjectAddOutline16 size={wide ? 16 : 18} /> + </button> + </Tooltip> + {/* Picker menu + create dialogs (same package — direct composition). */} + <WorkspaceCreateFlow + open={wsPickerOpen} + anchorRef={wsPlusRef} + useWorkspaces={useWorkspaces} + createWorkspace={createWorkspace} + onPick={(workspaceId) => { + setWsPickerOpen(false) + startSession(workspaceId) + }} + onClose={() => { setWsPickerOpen(false) }} + /> + </div> + + {/* Expanded: the row is a click-to-focus field (the leading icon is + decorative). Rail: the icon is the region's search control. */} + <div className={css.search} onClick={() => { if (wide) searchInput.current?.focus() }}> + <Tooltip label="Search" disabled={wide}> + <button + type="button" + className={css.searchButton} + aria-label="Search sessions" + tabIndex={wide ? -1 : 0} + onClick={() => { if (!wide) { setSearchOnExpand(true); expandSidebar() } }} + > + <IconSearchOutline16 size={wide ? 14 : 18} /> + </button> + </Tooltip> + {wide && ( + <input + ref={searchInput} + className={clsx(css.searchInput, css.wide)} + type="text" + placeholder="Search name, keywords..." + value={query} + onChange={(e) => { setQuery(e.target.value) }} + /> + )} + {wide && query !== '' && ( + <button + type="button" + className={clsx(css.clearButton, css.wide)} + aria-label="Clear search" + onClick={() => { setQuery('') }} + > + <IconCloseFill14 /> + </button> + )} + </div> + + {/* Always-mounted seat keeps the region's flex slot while the list + itself is wide-only. */} + <div className={css.listArea}> + {wide && (groupBy === 'flat' + ? <FlatList useSessions={useSessions} open={open} query={query} /> + : ( + <SessionTree + useSessions={useSessions} + workspaces={workspaces} + startSession={startSession} + open={open} + query={query} + insertSessionBefore={insertSessionBefore} + onRenameRequest={(workspaceId, currentTitle) => { + setRenameTarget({ workspaceId, currentTitle }) + setRenameDraft(currentTitle) + setRenameError(null) + }} + /> + ))} + </div> + + <Modal + open={renameTarget !== null} + onClose={closeRename} + title="Rename workspace" + footer={( + <> + <Button variant="outline" disabled={renaming} onClick={closeRename}>Cancel</Button> + <Button variant="primary" disabled={renameBlocked} onClick={confirmRename}>Rename</Button> + </> + )} + > + <input + className={css.renameInput} + value={renameDraft} + aria-label="Workspace name" + autoFocus + disabled={renaming} + onChange={(e) => { setRenameDraft(e.target.value); setRenameError(null) }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + confirmRename() + } + }} + /> + {renameDuplicate && ( + <div className={css.renameError} role="alert">A workspace named “{renameTrimmed}” already exists.</div> + )} + {renameError !== null && <div className={css.renameError} role="alert">{renameError}</div>} + </Modal> + </div> + ) +} diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index 8f6f483580..2be39875bc 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -1,9 +1,15 @@ -/** Shared Workspace picker for the sidebar and New Session hero. */ +/** + * Workspace pick/create flow. WorkspaceCreateFlow is the reusable core + * (menu + path/create dialogs) consumed directly by WorkspaceBrowser (same + * package) and wrapped by WorkspacePicker for the conversation empty-state + * slot registration. + */ +import type { RefObject } from 'react' import { useCallback, useState } from 'react' import { Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' +import type { WorkspaceId, WorkspaceListState, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client' import type { WorkspacePickerProps } from './contract/slots.ts' import css from './WorkspacePicker.module.css' @@ -13,14 +19,35 @@ const CREATE_NEW = '::create-new' type ModalKind = 'path' | 'create' | null -export function WorkspacePicker({ +/** Core flow props: the owner supplies popover control and pick semantics. */ +export interface WorkspaceCreateFlowProps { + /** Popover visibility (anchor button toggle state, owner-local). */ + open: boolean + /** The anchor button element — the popover's placement anchor. */ + anchorRef?: RefObject<HTMLElement | null> | undefined + /** Selector hook over the workspace list (framework standard hook). */ + useWorkspaces: <S>(selector: (state: WorkspaceListState) => S) => S + /** Create or adopt a real Host Workspace. */ + createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView> + /** A real Workspace was picked or created. */ + onPick: (workspaceId: WorkspaceId) => void + /** Close the popover (outside click / Escape / post-pick). */ + onClose: () => void +} + +/** + * Render the pick menu plus the two create dialogs. + * @param props - owner-controlled flow props. + * @returns menu + dialog elements. + */ +export function WorkspaceCreateFlow({ open, anchorRef, useWorkspaces, + createWorkspace, onPick, onClose, - createWorkspace, -}: WorkspacePickerProps) { +}: WorkspaceCreateFlowProps) { const workspaceSnapshot = useWorkspaces(state => state) const workspaces = workspaceSnapshot.items const getAnchorRect = useCallback( @@ -194,3 +221,29 @@ export function WorkspacePicker({ </> ) } + +/** + * The conversation empty-state registration: adapts the owner share to the + * core flow (all state and semantics live in the flow / the owner). + * @param props - empty-state slot props (owner share + injected creation callback). + * @returns the flow element. + */ +export function WorkspacePicker({ + open, + anchorRef, + useWorkspaces, + onPick, + onClose, + createWorkspace, +}: WorkspacePickerProps) { + return ( + <WorkspaceCreateFlow + open={open} + anchorRef={anchorRef} + useWorkspaces={useWorkspaces} + createWorkspace={createWorkspace} + onPick={onPick} + onClose={onClose} + /> + ) +} diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index a3045e5070..121e5d60f1 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -1,30 +1,59 @@ /** - * Shared Workspace picker contract for the sidebar and page-local Session Intent hero - * slots. Each runtime share provides its owner's popover controls plus the - * global useWorkspaces hook; this package adds the injected Host Workspace - * creation callback. + * ui-workspace contracts. Two registrations share this package: + * + * - WorkspaceBrowser fills the sidebar shell's `sidebar.workspaces` hole — + * the whole browsing region (section header, search, grouped/flat session + * list, workspace dialogs). It registers this package's viewing store and + * consumes the shell's two-fact owner share (wide / expandSidebar). + * - WorkspacePicker fills the conversation empty-state hole (menu + + * create dialogs shared with the browser). */ -import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -// Type-only: pull both owner SlotMap merges into programs that resolve the -// picker runtime union below. +import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' +// Type-only: pull the owner SlotMap merges into programs that resolve the +// runtime shares below. import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client' +import type { createWorkspaceViewStore } from '../stores.ts' /** - * Registrant-private injected share. Pick semantics remain in each owner's - * onPick callback; this callback creates only the real Host Workspace. A type - * alias supplies the implicit index signature required by the registry. + * Browser-private injected share (arrives via the register inject factory). + * Data reads use the global framework hooks; these are the Host actions the + * browsing region drives. + */ +export type WorkspaceBrowserInjected = { + /** Start or replace the current frontend Session Intent. */ + startSession: (workspaceId?: WorkspaceId, prompt?: string) => void + /** Open a real Session. */ + open: (sessionId: SessionId) => void + /** Rename a Host Workspace (rejects on name conflict; resolves on durability). */ + renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise<void> + /** + * Reorder a session inside its Workspace account (DOM-insertBefore + * semantics: omitted anchor appends to the end). The view refreshes from + * the Host response/changed frame; failures leave the order unchanged. + */ + insertSessionBefore: (workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId) => Promise<void> + /** Explicitly create or adopt a real Workspace before targeting a Session. */ + createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView> +} + +/** Full browser props: shell owner share + viewing store + injected actions. */ +export type WorkspaceBrowserProps = + PropsRuntime<'sidebar.workspaces'> + & PropsStore<ReturnType<typeof createWorkspaceViewStore>> + & WorkspaceBrowserInjected + +/** + * Picker-private injected share. Pick semantics remain in the owner's onPick + * callback; this callback creates only the real Host Workspace. A type alias + * supplies the implicit index signature required by the registry. */ export type WorkspacePickerInjected = { /** Explicitly create or adopt a real Workspace before targeting a Session. */ createWorkspace(input: { name: string } | { path: string }): Promise<WorkspaceView> } -/** - * Full picker props: either owner's runtime share, including useWorkspaces, - * plus this package's injected creation callback. - */ +/** Full picker props: the empty-state owner share plus the creation callback. */ export type WorkspacePickerProps = - (PropsRuntime<'sidebar.workspace'> | PropsRuntime<'conversation.empty.workspace'>) - & WorkspacePickerInjected + PropsRuntime<'conversation.empty.workspace'> & WorkspacePickerInjected diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index aa54587650..50ccb3564c 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -1,54 +1,85 @@ /** - * Shared Workspace picker plugin, browser half. WorkspacePicker registers in - * the sidebar and page-local Session Intent hero slots, reads real Host Workspaces - * through the global useWorkspaces hook, and delegates selection semantics to - * each owner. Its injected share creates a Workspace without creating a - * Session. Export discipline: packages/client/AGENTS.md. + * Workspace plugin, browser half. Two registrations: WorkspaceBrowser fills + * the sidebar shell's `sidebar.workspaces` hole (the whole browsing region), + * and WorkspacePicker fills the conversation empty-state hole. Both read real + * Host Workspaces through the global useWorkspaces hook. Export discipline: + * packages/client/AGENTS.md. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' -import type { WorkspacePickerInjected } from './contract/slots.ts' +import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from './contract/slots.ts' +import { createWorkspaceViewStore } from './stores.ts' +import { WorkspaceBrowser } from './WorkspaceBrowser.tsx' import { WorkspacePicker } from './WorkspacePicker.tsx' -export type { WorkspacePickerInjected, WorkspacePickerProps } from './contract/slots.ts' +export type { + WorkspaceBrowserInjected, WorkspaceBrowserProps, WorkspacePickerInjected, WorkspacePickerProps, +} from './contract/slots.ts' /** - * Required services (cordis fiber inject). The target slot is declared by - * the ui-sidebar apply, whose activation order relative to this one is NOT - * constrained: dshClient.inject edges are informational (loading/prefetch - * metadata, never apply sequencing) and the sidebar provides no waitable - * service. apply therefore registers via declaration-aware deferral instead - * of assuming order. + * Required services (cordis fiber inject). The target slots are declared by + * the ui-sidebar / ui-conversation applies, whose activation order relative + * to this one is NOT constrained: dshClient.inject edges are informational + * (loading/prefetch metadata, never apply sequencing) and neither owner + * provides a waitable service. apply therefore registers via + * declaration-aware deferral instead of assuming order. */ -export const inject = ['slots', 'workspaces'] +export const inject = ['slots', 'sessions', 'workspaces'] /** - * Register WorkspacePicker in both owner slots once their declarations are on - * the ledger. The inject factory returns a plain Workspace creation callback; - * data reads use the framework's global useWorkspaces hook. + * Register the browser and picker once their slot declarations are on the + * ledger. Inject factories return plain callbacks; data reads use the + * framework's global hooks. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { - const injected = (): WorkspacePickerInjected => ({ + const browserInjected = (): WorkspaceBrowserInjected => ({ + startSession: (workspaceId, prompt) => { ctx.workspaces.startSession(workspaceId, prompt) }, + open: (sessionId) => { ctx.sessions.open(sessionId) }, + renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) }, + insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => { + await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId) + }, createWorkspace: input => ctx.workspaces.create(input), }) - // Declaration-aware registration: the sidebar's declaring apply may - // activate after this one (entry activation order is unconstrained), and a - // register into an undeclared slot throws. Register once the declaration - // is on the ledger; the subscription also re-registers after an HMR - // collapse re-declares the slot (the cascade disposed our entry with it). + const pickerInjected = (): WorkspacePickerInjected => ({ + createWorkspace: input => ctx.workspaces.create(input), + }) + // Declaration-aware registration: each owner's declaring apply may activate + // after this one (entry activation order is unconstrained), and a register + // into an undeclared slot throws. Register once the declaration is on the + // ledger; the subscription also re-registers after an HMR collapse + // re-declares the slot (the cascade disposed our entry with it). ctx.effect(() => { - const slotNames = ['sidebar.workspace', 'conversation.empty.workspace'] as const - const disposers = new Map<(typeof slotNames)[number], () => void>() - const tryRegister = (name: (typeof slotNames)[number]): void => { - if (ctx.slots.spec(name) === undefined) return - if (ctx.slots.entries(name).some(e => e.component === WorkspacePicker)) return - disposers.set(name, ctx.slots.register({ name, inject: injected }, WorkspacePicker)) + const registrations = [ + { + name: 'sidebar.workspaces' as const, + component: WorkspaceBrowser, + register: () => ctx.slots.register( + { name: 'sidebar.workspaces', store: createWorkspaceViewStore(), inject: browserInjected }, + WorkspaceBrowser, + ), + }, + { + name: 'conversation.empty.workspace' as const, + component: WorkspacePicker, + register: () => ctx.slots.register( + { name: 'conversation.empty.workspace', inject: pickerInjected }, + WorkspacePicker, + ), + }, + ] + const disposers = new Map<string, () => void>() + const tryRegister = (entry: (typeof registrations)[number]): void => { + if (ctx.slots.spec(entry.name) === undefined) return + if (ctx.slots.entries(entry.name).some(e => e.component === entry.component)) return + disposers.set(entry.name, entry.register()) } - const unsubscribers = slotNames.map(name => ctx.slots.subscribe(name, () => { tryRegister(name) })) - for (const name of slotNames) tryRegister(name) + const unsubscribers = registrations.map(entry => + ctx.slots.subscribe(entry.name, () => { tryRegister(entry) })) + for (const entry of registrations) tryRegister(entry) return () => { for (const unsubscribe of unsubscribers) unsubscribe() for (const dispose of disposers.values()) dispose() } - }, 'ui-workspace: picker registrations') + }, 'ui-workspace: browser + picker registrations') } diff --git a/packages/client/ui-sidebar/src/client/Rows.module.css b/packages/client/ui-workspace/src/client/rows/Rows.module.css similarity index 79% rename from packages/client/ui-sidebar/src/client/Rows.module.css rename to packages/client/ui-workspace/src/client/rows/Rows.module.css index f996f03348..7b19284b66 100644 --- a/packages/client/ui-sidebar/src/client/Rows.module.css +++ b/packages/client/ui-workspace/src/client/rows/Rows.module.css @@ -150,14 +150,63 @@ } .projectRow:hover .rowActions, -.sessionRow:hover .rowActions { +.sessionRow:hover .rowActions, +.projectRow.menuOpen .rowActions, +.sessionRow.menuOpen .rowActions { display: inline-flex; } -.sessionRow:hover .time { +.sessionRow:hover .time, +.sessionRow.menuOpen .time { display: none; } +/* An open row menu pins the hover affordances (figma: the row keeps its + hover fill while its dropdown is up). */ +.projectRow.menuOpen, +.sessionRow.menuOpen { + background: var(--dsw-alias-interactive-bg-hover); +} + +/* Drag reorder insert line (workspace-group roots): 2px accent above or + below the hovered row, drawn with box-shadow so no layout shift. */ +.sessionRow.dropBefore { + box-shadow: 0 -2px 0 0 var(--dsw-alias-state-business-primary); +} + +.sessionRow.dropAfter { + box-shadow: 0 2px 0 0 var(--dsw-alias-state-business-primary); +} + +/* Hover-card body (figma 169:16903): dark surface, fixed colors both themes. */ +.hoverContent { + display: flex; + flex-direction: column; + gap: 8px; +} + +.hoverTitle { + font-size: 14px; + line-height: 20px; + color: #FFFFFF; + overflow-wrap: break-word; +} + +.hoverTime { + font-size: 12px; + line-height: 16px; + color: #CFD3D6; +} + +.hoverStatus { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + line-height: 20px; + color: #ADB2B8; +} + .iconButton { flex: none; display: inline-flex; diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx new file mode 100644 index 0000000000..1239b87591 --- /dev/null +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -0,0 +1,284 @@ +/** + * Workspace browser tree row components (figma Cell set 14:3080): pure presentational — + * all data and callbacks arrive via props. Hover swaps (folder->chevron, + * time->ellipsis, action buttons) are CSS-only. Row ... menus are visual-only + * except workspace Rename; the session hover card is suppressed while a menu + * is open. + */ +import { useState } from 'react' +import clsx from 'clsx' +import { + HoverCard, IconBranchOutline16, IconEditOutline16, IconEllipsisOutline16, + IconFolderClose16, IconFolderOpen16, IconPlusOutline16, + IconTrashOutline16, IconTriangleRightFill14, Menu, StateDot, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { GroupNode, SessionNode } from '../tree.ts' +import { formatRelativeTime } from '../tree.ts' +import css from './Rows.module.css' + +/** Indent step per tree level: one 16px slot (figma session cell). */ +const INDENT_STEP = 16 + +const SESSION_MENU_ITEMS = [ + { id: 'rename', label: 'Rename', icon: <IconEditOutline16 /> }, + { id: 'fork', label: 'Fork session', icon: <IconBranchOutline16 /> }, + { id: 'delete', label: 'Delete session', icon: <IconTrashOutline16 />, danger: true }, +] + +const WORKSPACE_MENU_ITEMS = [ + { id: 'rename', label: 'Rename', icon: <IconEditOutline16 /> }, + { id: 'delete', label: 'Delete workspace', icon: <IconTrashOutline16 />, danger: true }, +] + +/** + * Project (workspace) header row: 54px, folder + title + session count; + * hover reveals the chevron and create button. `containsCurrent` arrives on + * the node (derivation fact, no renderer scan). + * @param props.group - derived group node. + * @param props.onToggle - expand/collapse the group. + * @param props.onCreate - start a frontend Session inside this Workspace. + * @returns the row element. + */ +export function ProjectRowItem({ group, onToggle, onCreate, onRename }: { + group: GroupNode + onToggle: () => void + onCreate: () => void + /** Open the rename dialog; absent for the ungrouped bucket (no menu shown). */ + onRename?: (() => void) | undefined +}) { + const row = group + const active = group.expanded && group.containsCurrent + const count = `${row.sessionCount} ${row.sessionCount === 1 ? 'session' : 'sessions'}` + const [menuOpen, setMenuOpen] = useState(false) + return ( + <div + className={clsx(css.projectRow, menuOpen && css.menuOpen)} + role="treeitem" + aria-expanded={row.expanded} + onClick={onToggle} + > + <span className={clsx(css.slot, css.folder, active && css.folderActive)}> + {row.expanded ? <IconFolderOpen16 /> : <IconFolderClose16 />} + </span> + <span className={clsx(css.slot, css.chevron)}> + <IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} /> + </span> + <span className={css.projectText}> + <span className={css.title}>{row.label}</span> + <span className={css.meta}>{count}</span> + </span> + <span className={css.rowActions}> + {onRename !== undefined && ( + <Menu + open={menuOpen} + onClose={() => { setMenuOpen(false) }} + items={WORKSPACE_MENU_ITEMS} + onSelect={(id) => { + setMenuOpen(false) + if (id === 'rename') onRename() + // Delete is visual-only for now. + }} + portal + closeOnPointerLeave + anchor={( + <button + type="button" + className={css.iconButton} + aria-label={`Workspace actions for ${row.label}`} + onClick={(e) => { e.stopPropagation(); setMenuOpen(v => !v) }} + > + <IconEllipsisOutline16 /> + </button> + )} + /> + )} + <button + type="button" + className={css.iconButton} + aria-label={`New session in ${row.label}`} + onClick={(e) => { e.stopPropagation(); onCreate() }} + > + <IconPlusOutline16 /> + </button> + </span> + </div> + ) +} + +/** + * The selected "New session" row for a frontend Session Intent targeted to a + * real Workspace. The row disappears when the Intent is replaced or connects. + * One status-slot indent in both grouped and flat lists (session rows carry + * no twist slot either, so titles align). + * @returns the placeholder row element. + */ +export function IntentRowItem() { + return ( + <div className={clsx(css.sessionRow, css.selected)} role="treeitem" aria-selected style={{ paddingLeft: 8 }}> + <span className={css.slot} /> + <span className={css.title}>New session</span> + </div> + ) +} + +/** + * One session subtree: the node's own 34px row (indent by depth, expand + * twist when it has children, running dot, relative time) plus its visible + * children, recursively — the component tree mirrors the derived tree. + * @param props.node - derived session node. + * @param props.depth - 0 = directly under the group header. + * @param props.currentId - selected session id (row highlight). + * @param props.now - epoch ms for relative-time formatting. + * @param props.onOpen - open a session by id. + * @param props.onToggle - unfold/fold a subtree by id. + * @returns the node's row followed by its children. + */ +/** Hover-card body: full title, relative time, and the status line (running/idle until wire status lands). */ +function SessionHoverContent({ node, now }: { node: SessionNode; now: number }) { + return ( + <div className={css.hoverContent}> + <div className={css.hoverTitle}>{node.title}</div> + <div className={css.hoverTime}>{`${formatRelativeTime(node.updatedAt, now)} ago`}</div> + <div className={css.hoverStatus}> + <StateDot state={node.running ? 'ongoing' : 'done'} /> + <span>{node.running ? 'Running' : 'Idle'}</span> + </div> + </div> + ) +} + +/** + * Root-row drag wiring supplied by the group owner (workspace groups only). + * `drop` reports the half of the row the pointer released on: 'before' + * inserts above this row, 'after' below it (the owner resolves the anchor). + */ +export interface RowDragProps { + /** Start dragging this row. */ + start: () => void + /** A drag from the same group is in flight (rows show insert markers). */ + active: boolean + /** Current marker on this row: insert line above, below, or none. */ + marker: 'before' | 'after' | null + /** Report the hovered half while a same-group drag passes over this row. */ + hover: (half: 'before' | 'after') => void + drop: (half: 'before' | 'after') => void + end: () => void +} + +/** Pointer-position half of a row (insert line above or below). */ +function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | 'after' { + const rect = e.currentTarget.getBoundingClientRect() + return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after' +} + +export function SessionNodeItem({ node, depth, currentId, now, onOpen, onToggle, drag, flat = false }: { + node: SessionNode + depth: number + currentId: string | undefined + now: number + onOpen: (id: SessionNode['id']) => void + onToggle: (id: SessionNode['id']) => void + /** Present only on draggable rows (workspace-group roots outside search). */ + drag?: RowDragProps | undefined + /** Flat-list variant: no twist slot (figma flat cell) — titles align on the status slot. */ + flat?: boolean +}) { + const row = node + const selected = node.id === currentId + const [menuOpen, setMenuOpen] = useState(false) + // Rail (figma session cell: pad 8, twist slot 16, status slot 16, gap 4 to + // the title): both slots are always reserved so titles align whether or not + // the twist/dot is lit. Extra depth rides the left padding. + const ownRow = ( + <div + className={clsx( + css.sessionRow, selected && css.selected, menuOpen && css.menuOpen, + drag?.marker === 'before' && css.dropBefore, drag?.marker === 'after' && css.dropAfter, + )} + role="treeitem" + aria-selected={selected} + {...(row.hasChildren ? { 'aria-expanded': row.expanded } : {})} + style={{ paddingLeft: 8 + depth * INDENT_STEP }} + onClick={() => { onOpen(node.id) }} + draggable={drag !== undefined} + onDragStart={drag === undefined + ? undefined + : (e) => { + e.dataTransfer.effectAllowed = 'move' + drag.start() + }} + onDragEnd={drag?.end} + onDragOver={drag === undefined + ? undefined + : (e) => { + if (!drag.active) return + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + drag.hover(rowHalf(e)) + }} + onDrop={drag === undefined + ? undefined + : (e) => { + if (!drag.active) return + e.preventDefault() + drag.drop(rowHalf(e)) + }} + > + {row.hasChildren && !flat + ? ( + <button + type="button" + className={css.twist} + aria-label={row.expanded ? 'Collapse' : 'Expand'} + onClick={(e) => { e.stopPropagation(); onToggle(node.id) }} + > + <IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} /> + </button> + ) + : null} + <span className={css.slot}>{row.running && <StateDot state="ongoing" />}</span> + <span className={css.title}>{row.title}</span> + <span className={css.time}>{formatRelativeTime(row.updatedAt, now)}</span> + <span className={css.rowActions}> + <Menu + open={menuOpen} + onClose={() => { setMenuOpen(false) }} + items={SESSION_MENU_ITEMS} + onSelect={() => { setMenuOpen(false) }} // Visual-only for now. + portal + closeOnPointerLeave + anchor={( + <button + type="button" + className={css.iconButton} + aria-label={`Session actions for ${row.title}`} + onClick={(e) => { e.stopPropagation(); setMenuOpen(v => !v) }} + > + <IconEllipsisOutline16 /> + </button> + )} + /> + </span> + </div> + ) + return ( + <> + <HoverCard + anchor={ownRow} + content={<SessionHoverContent node={node} now={now} />} + disabled={menuOpen || drag?.active === true} + /> + {node.children.map(child => ( + <SessionNodeItem + key={child.id} + node={child} + depth={depth + 1} + currentId={currentId} + now={now} + onOpen={onOpen} + onToggle={onToggle} + /> + ))} + </> + ) +} diff --git a/packages/client/ui-workspace/src/client/stores.ts b/packages/client/ui-workspace/src/client/stores.ts new file mode 100644 index 0000000000..ed89d80d9e --- /dev/null +++ b/packages/client/ui-workspace/src/client/stores.ts @@ -0,0 +1,36 @@ +/** + * The workspace browser's viewing store: the session-list grouping mode, + * persisted across reloads. Module level exports the factory only (a + * module-level handle would pin the store identity across plugin reloads); + * register() receives the factory and the browser derives its PropsStore + * share from the return type. + */ +import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client' + +/** Session-list grouping mode: workspace sections or one flat recency list. */ +export type WorkspaceGroupBy = 'workspace' | 'flat' + +/** Workspace browser viewing state (grouping mode only; transient UI facts stay component-local). */ +type WorkspaceViewState = { groupBy: WorkspaceGroupBy } + +/** + * Annotation twin of the actions literal below (the export needs a declared + * return type); drift fails assignability at the defineStore call. + */ +type WorkspaceViewActions = { + setGroupBy: (draft: WorkspaceViewState, mode: WorkspaceGroupBy) => void +} + +/** + * Create the workspace browser viewing store handle. + * @returns the store handle (spec + type + identity + factory in one). + */ +export function createWorkspaceViewStore(): EngineStoreHandle<WorkspaceViewState, WorkspaceViewActions> { + return defineStore({ + init: (): WorkspaceViewState => ({ groupBy: 'workspace' }), + persist: 'dsh.workspace.view', + actions: { + setGroupBy: (d, mode: WorkspaceGroupBy) => { d.groupBy = mode }, + }, + }) +} diff --git a/packages/client/ui-sidebar/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts similarity index 89% rename from packages/client/ui-sidebar/src/client/tree.ts rename to packages/client/ui-workspace/src/client/tree.ts index 64fda519e6..7de4d14e8d 100644 --- a/packages/client/ui-sidebar/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -1,5 +1,5 @@ /** - * Derives the sidebar tree from Host Workspace order and membership. + * Derives the workspace browser tree from Host Workspace order and membership. * Unassigned Sessions trail under Ungrouped; only Intents targeting real Workspaces render. */ import type { SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client' @@ -223,11 +223,12 @@ function buildSearch(g: Group, visible: ReadonlySet<SessionId>): SessionNode[] { } /** - * Derive the nested sidebar group structure. + * Derive the nested workspace browser group structure. * * Normal mode: every group shows; sessions populate under expanded groups, * descending only into expanded sessions. A frontend Session Intent targeting - * a real Workspace marks that group `intentHere` and forces it expanded. Search mode (non-blank query, + * a real Workspace marks that group `intentHere` (rendered only while the + * group is expanded; expansion stays viewer-owned). Search mode (non-blank query, * case-insensitive display-title substring): expansion state is ignored — * matched sessions and their ancestor chains are forced visible, groups * without a display-title or label hit are dropped, a label-only hit keeps @@ -263,7 +264,9 @@ export function deriveGroups( && g.workspaceId !== undefined && intentWorkspaceId === g.workspaceId const intentHere = q === '' && hasIntent if (q === '') { - const expanded = intentHere || expandedProjects.has(g.key) + // The intent never forces expansion — the viewer auto-expands the + // target group once (current-group effect); the toggle stays live. + const expanded = expandedProjects.has(g.key) groups.push({ key: g.key, workspaceId: g.workspaceId, @@ -294,6 +297,29 @@ export function deriveGroups( return groups } +/** + * Derive the flat session list ("In one list" mode): every session — fork + * children included — as a top-level row, strictly newest-first. No grouping, + * no parent/child adjacency; rows reuse SessionNode with children always + * empty so the renderer stays branch-free. Search mode filters by + * case-insensitive display-title substring. + * @param list - sessions list snapshot. + * @param view - the search query (expansion state does not apply). + * @returns flat rows in render order. + */ +export function deriveFlat(list: SessionListState, view: Pick<TreeView, 'query'>): SessionNode[] { + const q = view.query.trim().toLowerCase() + const rows: SessionSummary[] = [] + for (const id of list.ids) { + const s = list.byId[id] + if (s === undefined) continue + if (q !== '' && !s.displayTitle.toLowerCase().includes(q)) continue + rows.push(s) + } + rows.sort(byRecency) + return rows.map(s => sessionNode(s, [], false, false)) +} + /** * Compact relative time for session rows ("now", "5min", "3h", "2d", "4mo", "1y"). * @param updatedAt - epoch ms of the session's last activity. diff --git a/packages/client/ui-workspace/tests/apply.spec.ts b/packages/client/ui-workspace/tests/apply.spec.ts index 9352caa0b4..196d05d46b 100644 --- a/packages/client/ui-workspace/tests/apply.spec.ts +++ b/packages/client/ui-workspace/tests/apply.spec.ts @@ -2,7 +2,8 @@ import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client' -import type { WorkspacePickerInjected } from '@deepseek-ai/dsh-client-ui-workspace/client' +import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from '@deepseek-ai/dsh-client-ui-workspace/client' +import { WorkspaceBrowser } from '../src/client/WorkspaceBrowser.tsx' import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx' async function bench() { @@ -13,32 +14,33 @@ async function bench() { path: 'name' in input ? `/projects/${input.name}` : input.path, title: 'new', sessionIds: [], createdAt: '0', updatedAt: '0', })) - ctx.provide('workspaces', { create }) - return { ctx, slots: ctx.get('slots') as SlotsService, create } + const startSession = vi.fn() + const rename = vi.fn(async () => ({})) + const insertSessionBefore = vi.fn(async () => ({})) + const open = vi.fn() + ctx.provide('workspaces', { create, startSession, rename, insertSessionBefore } as never) + ctx.provide('sessions', { open } as never) + return { ctx, slots: ctx.get('slots') as SlotsService, create, startSession, rename, insertSessionBefore, open } } -function declare(slots: SlotsService, name: 'sidebar.workspace' | 'conversation.empty.workspace'): () => void { - return slots.register( - { name: 'root', children: { [name]: { kind: 'single', scope: 'root' } } } as never, - () => null, - ) -} +type HoleName = 'sidebar.workspaces' | 'conversation.empty.workspace' -function injectedOf(slots: SlotsService, name: 'sidebar.workspace' | 'conversation.empty.workspace'): WorkspacePickerInjected { - const entry = slots.entries(name)[0]! - return (entry.inject as () => WorkspacePickerInjected)() +/** Declare one or both holes with a single root registration ('root' is a single slot). */ +function declare(slots: SlotsService, ...names: HoleName[]): () => void { + const children = Object.fromEntries(names.map(name => [name, { kind: 'single', scope: 'root' }])) + return slots.register({ name: 'root', children } as never, () => null) } describe('ui-workspace apply', () => { - it('declares the independent Workspace service', () => { - expect(inject).toEqual(['slots', 'workspaces']) + it('declares the services it drives', () => { + expect(inject).toEqual(['slots', 'sessions', 'workspaces']) }) - it('registers the shared picker for declarations that arrive before or after apply', async () => { + it('registers browser and picker for declarations arriving before or after apply', async () => { const before = await bench() - declare(before.slots, 'sidebar.workspace') + declare(before.slots, 'sidebar.workspaces') await before.ctx.plugin({ inject: [...inject], apply }).await() - expect(before.slots.entries('sidebar.workspace')[0]!.component).toBe(WorkspacePicker) + expect(before.slots.entries('sidebar.workspaces')[0]!.component).toBe(WorkspaceBrowser) const after = await bench() await after.ctx.plugin({ inject: [...inject], apply }).await() @@ -47,23 +49,35 @@ describe('ui-workspace apply', () => { expect(after.slots.entries('conversation.empty.workspace')[0]!.component).toBe(WorkspacePicker) }) - it('routes name and path creation to WorkspacesService', async () => { + it('routes browser actions and picker creation to the services', async () => { const b = await bench() - declare(b.slots, 'sidebar.workspace') + declare(b.slots, 'sidebar.workspaces', 'conversation.empty.workspace') await b.ctx.plugin({ inject: [...inject], apply }).await() - const injected = injectedOf(b.slots, 'sidebar.workspace') - await injected.createWorkspace({ name: 'project' }) - await injected.createWorkspace({ path: '/tmp/project' }) - expect(b.create).toHaveBeenNthCalledWith(1, { name: 'project' }) - expect(b.create).toHaveBeenNthCalledWith(2, { path: '/tmp/project' }) + + const browser = (b.slots.entries('sidebar.workspaces')[0]!.inject as () => WorkspaceBrowserInjected)() + browser.startSession('ws', 'prompt') + expect(b.startSession).toHaveBeenCalledWith('ws', 'prompt') + browser.open('session') + expect(b.open).toHaveBeenCalledWith('session') + await browser.renameWorkspace('ws', 'renamed') + expect(b.rename).toHaveBeenCalledWith('ws', 'renamed') + await browser.insertSessionBefore('ws', 's1', 's2') + expect(b.insertSessionBefore).toHaveBeenCalledWith('ws', 's1', 's2') + await browser.createWorkspace({ name: 'project' }) + expect(b.create).toHaveBeenCalledWith({ name: 'project' }) + + const picker = (b.slots.entries('conversation.empty.workspace')[0]!.inject as () => WorkspacePickerInjected)() + await picker.createWorkspace({ path: '/tmp/project' }) + expect(b.create).toHaveBeenCalledWith({ path: '/tmp/project' }) }) - it('unregisters picker entries on teardown', async () => { + it('unregisters both entries on teardown', async () => { const b = await bench() - declare(b.slots, 'sidebar.workspace') + declare(b.slots, 'sidebar.workspaces', 'conversation.empty.workspace') const fiber = b.ctx.plugin({ inject: [...inject], apply }) await fiber.await() await fiber.dispose() - expect(b.slots.entries('sidebar.workspace')).toHaveLength(0) + expect(b.slots.entries('sidebar.workspaces')).toHaveLength(0) + expect(b.slots.entries('conversation.empty.workspace')).toHaveLength(0) }) }) diff --git a/packages/client/ui-workspace/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.spec.tsx new file mode 100644 index 0000000000..b90fb9a601 --- /dev/null +++ b/packages/client/ui-workspace/tests/rows.spec.tsx @@ -0,0 +1,251 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, createEvent, fireEvent, render, screen } from '@testing-library/react' +import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' +import type { RowDragProps } from '../src/client/rows/Rows.tsx' +import { IntentRowItem, ProjectRowItem, SessionNodeItem } from '../src/client/rows/Rows.tsx' +import type { GroupNode, SessionNode } from '../src/client/tree.ts' + +afterEach(cleanup) + +const sid = (id: string) => id as SessionId +const wid = (id: string) => id as WorkspaceId + +/** Half detection reads the row rect; jsdom rects are all-zero by default. */ +function stubRect(row: HTMLElement): void { + row.getBoundingClientRect = () => ({ + top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, + x: 0, y: 100, toJSON: () => ({}), + } as DOMRect) +} + +function dragProps(overrides: Partial<RowDragProps> = {}): RowDragProps { + return { + start: vi.fn(), active: false, marker: null, + hover: vi.fn(), drop: vi.fn(), end: vi.fn(), + ...overrides, + } +} + +const dataTransfer = { effectAllowed: '', dropEffect: '' } + +/** jsdom lacks DragEvent — the fireEvent fallback drops clientY, so pin it on the built event. */ +function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number): void { + const event = kind === 'dragOver' ? createEvent.dragOver(row) : createEvent.drop(row) + Object.defineProperty(event, 'clientY', { value: clientY }) + Object.defineProperty(event, 'dataTransfer', { value: { ...dataTransfer } }) + fireEvent(row, event) +} + +describe('workspace browser rows', () => { + it('renders an active Workspace and keeps its create action separate from toggling', () => { + const onToggle = vi.fn() + const onCreate = vi.fn() + const group: GroupNode = { + key: 'project', workspaceId: wid('project'), cwd: '/projects/project', label: 'Project', + sessionCount: 1, expanded: true, containsCurrent: true, intentHere: false, sessions: [], + } + render(<ProjectRowItem group={group} onToggle={onToggle} onCreate={onCreate} />) + + expect(screen.getByText('1 session')).toBeTruthy() + expect(screen.getByRole('treeitem').getAttribute('aria-expanded')).toBe('true') + fireEvent.click(screen.getByRole('button', { name: 'New session in Project' })) + expect(onCreate).toHaveBeenCalledOnce() + expect(onToggle).not.toHaveBeenCalled() + fireEvent.click(screen.getByText('Project')) + expect(onToggle).toHaveBeenCalledOnce() + }) + + it('renders the frontend Intent placeholder as selected', () => { + render(<IntentRowItem />) + expect(screen.getByRole('treeitem').getAttribute('aria-selected')).toBe('true') + }) + + it('renders and operates selected, running, recursive Session nodes', () => { + const child: SessionNode = { + id: sid('child'), title: 'Child', children: [], hasChildren: false, + expanded: false, running: false, updatedAt: 0, + } + const parent: SessionNode = { + id: sid('parent'), title: 'Parent', children: [child], hasChildren: true, + expanded: true, running: true, updatedAt: 0, + } + const onOpen = vi.fn() + const onToggle = vi.fn() + const view = render( + <SessionNodeItem node={parent} depth={0} currentId={parent.id} now={0} onOpen={onOpen} onToggle={onToggle} />, + ) + + const parentRow = screen.getByText('Parent').closest('[role="treeitem"]')! + const childRow = screen.getByText('Child').closest('[role="treeitem"]')! + expect(parentRow.getAttribute('aria-selected')).toBe('true') + expect(parentRow.getAttribute('aria-expanded')).toBe('true') + expect(childRow.getAttribute('aria-selected')).toBe('false') + expect(childRow.hasAttribute('aria-expanded')).toBe(false) + + fireEvent.click(screen.getByRole('button', { name: 'Collapse' })) + expect(onToggle).toHaveBeenCalledWith(parent.id) + expect(onOpen).not.toHaveBeenCalled() + fireEvent.click(parentRow) + fireEvent.click(childRow) + expect(onOpen.mock.calls).toEqual([[parent.id], [child.id]]) + + view.rerender( + <SessionNodeItem + node={{ ...parent, children: [], expanded: false, running: false }} + depth={1} currentId={undefined} now={0} onOpen={onOpen} onToggle={onToggle} + />, + ) + expect(screen.getByRole('button', { name: 'Expand' })).toBeTruthy() + expect(screen.getByRole('treeitem').getAttribute('aria-selected')).toBe('false') + expect(screen.getByRole('treeitem').style.paddingLeft).toBe('24px') + }) + + it('workspace row menu opens on the ellipsis, renames, and shows the danger delete row', () => { + const onRename = vi.fn() + const onToggle = vi.fn() + const group: GroupNode = { + key: 'project', workspaceId: wid('project'), cwd: '/projects/project', label: 'Project', + sessionCount: 0, expanded: false, containsCurrent: false, intentHere: false, sessions: [], + } + render(<ProjectRowItem group={group} onToggle={onToggle} onCreate={vi.fn()} onRename={onRename} />) + fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' })) + // Opening the menu neither toggles the group nor renames yet. + expect(onToggle).not.toHaveBeenCalled() + expect(screen.getByRole('menuitem', { name: 'Delete workspace' }).className).toMatch(/danger/) + fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' })) + expect(onRename).toHaveBeenCalledOnce() + expect(screen.queryByRole('menu')).toBeNull() + // Delete stays visual-only: selecting it just closes the menu. + fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' })) + expect(screen.queryByRole('menu')).toBeNull() + expect(onRename).toHaveBeenCalledOnce() + // Escape closes without selecting (Menu onClose path). + fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' })) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.queryByRole('menu')).toBeNull() + }) + + it('ungrouped bucket renders no workspace menu', () => { + const group: GroupNode = { + key: '', workspaceId: undefined, cwd: undefined, label: 'Ungrouped', + sessionCount: 0, expanded: false, containsCurrent: false, intentHere: false, sessions: [], + } + render(<ProjectRowItem group={group} onToggle={vi.fn()} onCreate={vi.fn()} />) + expect(screen.queryByRole('button', { name: /Workspace actions/ })).toBeNull() + }) + + it('session row menu opens without opening the session and closes on selection', () => { + const onOpen = vi.fn() + const node: SessionNode = { + id: sid('s1'), title: 'One', children: [], hasChildren: false, + expanded: false, running: false, updatedAt: 0, + } + render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={onOpen} onToggle={vi.fn()} />) + fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' })) + expect(onOpen).not.toHaveBeenCalled() + expect(screen.getByRole('menuitem', { name: 'Delete session' }).className).toMatch(/danger/) + fireEvent.click(screen.getByRole('menuitem', { name: 'Fork session' })) + expect(screen.queryByRole('menu')).toBeNull() + expect(onOpen).not.toHaveBeenCalled() + // Escape closes without selecting (Menu onClose path). + fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' })) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.queryByRole('menu')).toBeNull() + }) + + it('flat variant renders no twist even for a parent and ignores toggling', () => { + const node: SessionNode = { + id: sid('p'), title: 'Parent', children: [], hasChildren: true, + expanded: false, running: false, updatedAt: 0, + } + render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} flat />) + expect(screen.queryByRole('button', { name: 'Expand' })).toBeNull() + }) + + it('shows the hover card after the dwell and suppresses it while the row menu is open', () => { + vi.useFakeTimers() + try { + const node: SessionNode = { + id: sid('s1'), title: 'Hovered', children: [], hasChildren: false, + expanded: false, running: true, updatedAt: 0, + } + render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={60_000} onOpen={vi.fn()} onToggle={vi.fn()} />) + const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + // Card body: full title + relative time + running status. + expect(screen.getAllByText('Hovered')).toHaveLength(2) + expect(screen.getByText('1min ago')).toBeTruthy() + expect(screen.getByText('Running')).toBeTruthy() + fireEvent.pointerLeave(wrapper) + // Menu open (disabled=true) suppresses the card for the same hover. + fireEvent.click(screen.getByRole('button', { name: 'Session actions for Hovered' })) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(1000) }) + expect(screen.queryByText('1min ago')).toBeNull() + } finally { + vi.useRealTimers() + } + }) + + it('idle hover card shows the Idle status line', () => { + vi.useFakeTimers() + try { + const node: SessionNode = { + id: sid('s1'), title: 'Quiet', children: [], hasChildren: false, + expanded: false, running: false, updatedAt: 0, + } + render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} />) + fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByText('Idle')).toBeTruthy() + expect(screen.getByText('now ago')).toBeTruthy() + } finally { + vi.useRealTimers() + } + }) + + it('draggable row wires start/end and gates hover/drop on an active same-group drag', () => { + const node: SessionNode = { + id: sid('s1'), title: 'Drag me', children: [], hasChildren: false, + expanded: false, running: false, updatedAt: 0, + } + const inactive = dragProps() + const { rerender } = render( + <SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} drag={inactive} />, + ) + const row = screen.getByRole('treeitem') + stubRect(row) + expect(row.getAttribute('draggable')).toBe('true') + fireEvent.dragStart(row, { dataTransfer }) + expect(inactive.start).toHaveBeenCalledOnce() + // Inactive drag: hover and drop are rejected. + fireEvent.dragOver(row, { dataTransfer }) + fireEvent.drop(row, { dataTransfer }) + expect(inactive.hover).not.toHaveBeenCalled() + expect(inactive.drop).not.toHaveBeenCalled() + fireEvent.dragEnd(row) + expect(inactive.end).toHaveBeenCalledOnce() + + const active = dragProps({ active: true, marker: 'before' }) + rerender( + <SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} drag={active} />, + ) + stubRect(screen.getByRole('treeitem')) + // Top half hovers/drops 'before'; bottom half 'after' (row mid = 117). + fireDrag(screen.getByRole('treeitem'), 'dragOver', 105) + expect(active.hover).toHaveBeenCalledWith('before') + fireDrag(screen.getByRole('treeitem'), 'dragOver', 130) + expect(active.hover).toHaveBeenCalledWith('after') + fireDrag(screen.getByRole('treeitem'), 'drop', 130) + expect(active.drop).toHaveBeenCalledWith('after') + + const after = dragProps({ active: true, marker: 'after' }) + rerender( + <SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} drag={after} />, + ) + expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/) + }) +}) diff --git a/packages/client/ui-sidebar/tests/tree.spec.ts b/packages/client/ui-workspace/tests/tree.spec.ts similarity index 78% rename from packages/client/ui-sidebar/tests/tree.spec.ts rename to packages/client/ui-workspace/tests/tree.spec.ts index d114d43dea..b314e14571 100644 --- a/packages/client/ui-sidebar/tests/tree.spec.ts +++ b/packages/client/ui-workspace/tests/tree.spec.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest' import type { SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-runtime/client' -import { deriveGroups, formatRelativeTime, projectLabel, UNGROUPED_KEY, UNGROUPED_LABEL } from '../src/client/tree.ts' +import { deriveFlat, deriveGroups, formatRelativeTime, projectLabel, UNGROUPED_KEY, UNGROUPED_LABEL } from '../src/client/tree.ts' +import { createWorkspaceViewStore } from '../src/client/stores.ts' const sid = (id: string) => id as SessionId const wid = (id: string) => id as WorkspaceId @@ -52,6 +53,12 @@ describe('deriveGroups', () => { expect(deriveGroups({ ...list(), intent: hiddenIntent }, [target], view())[0]!.intentHere).toBe(false) }) + it('an Intent no longer forces its target group expanded (viewer owns expansion)', () => { + const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: wid('first') }, prompt: '', phase: 'connecting' as const } + const groups = deriveGroups({ ...list(), intent }, [workspace('first', [])], view()) + expect(groups[0]).toEqual(expect.objectContaining({ intentHere: true, expanded: false })) + }) + it('search filters real Sessions and omits the Intent placeholder', () => { const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: wid('first') }, prompt: '', phase: 'ready' as const } const groups = deriveGroups({ ...list(summary('match', 1)), intent }, [workspace('first', ['match'])], view([], 'match')) @@ -135,6 +142,39 @@ describe('deriveGroups', () => { }) }) +describe('deriveFlat', () => { + it('flattens every session — fork children included — newest-first with id tiebreak', () => { + const parent = summary('parent', 10) + const child = { ...summary('child', 30), parentId: parent.id } + const tieB = summary('tie-b', 20) + const tieA = summary('tie-a', 20) + const rows = deriveFlat(list(parent, child, tieB, tieA), { query: '' }) + expect(rows.map(row => row.id)).toEqual([sid('child'), sid('tie-a'), sid('tie-b'), sid('parent')]) + // Rows are branch-free: no children, no expansion. + expect(rows.every(row => row.children.length === 0 && !row.hasChildren && !row.expanded)).toBe(true) + }) + + it('search filters by case-insensitive display-title substring', () => { + const hit = { ...summary('hit', 2), displayTitle: 'Needle row' } + const miss = { ...summary('miss', 1), displayTitle: 'Other' } + expect(deriveFlat(list(hit, miss), { query: ' NEEDLE ' }).map(row => row.id)).toEqual([sid('hit')]) + }) + + it('tolerates ids whose summary has not landed yet', () => { + const partial: SessionListState = { ...list(summary('present', 1)), ids: [sid('ghost'), sid('present')] } + expect(deriveFlat(partial, { query: '' }).map(row => row.id)).toEqual([sid('present')]) + }) +}) + +describe('createWorkspaceViewStore', () => { + it('defaults to workspace grouping; setGroupBy is the sole mutation', () => { + const store = createWorkspaceViewStore().create() + expect(store.getSnapshot().groupBy).toBe('workspace') + store.actions.setGroupBy('flat') + expect(store.getSnapshot().groupBy).toBe('flat') + }) +}) + describe('projectLabel', () => { it('uses the Ungrouped fallback and extracts POSIX and Windows basenames', () => { expect(projectLabel(undefined)).toBe(UNGROUPED_LABEL) diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx new file mode 100644 index 0000000000..3e592c3168 --- /dev/null +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -0,0 +1,458 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, createEvent, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import type { + SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceListState, WorkspaceView, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { WorkspaceBrowserProps } from '../src/client/contract/slots.ts' +import { createWorkspaceViewStore } from '../src/client/stores.ts' +import { WorkspaceBrowser } from '../src/client/WorkspaceBrowser.tsx' + +afterEach(cleanup) +beforeEach(() => { localStorage.clear() }) + +const sid = (id: string) => id as SessionId +const wid = (id: string) => id as WorkspaceId +const summary = (id: string, updatedAt: number, overrides: Partial<SessionSummary> = {}): SessionSummary => ({ + id: sid(id), displayTitle: id, running: false, updatedAt, ...overrides, +}) +const sessionState = (items: readonly SessionSummary[], overrides: Partial<SessionListState> = {}): SessionListState => ({ + ids: items.map(item => item.id), + byId: Object.fromEntries(items.map(item => [item.id, item])), + current: undefined, + phase: 'ready', + intent: undefined, + ...overrides, +}) +const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView => ({ + workspaceId: wid(id), path: `/projects/${id}`, title, + sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', +}) +const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({ + items, intent: undefined, state: 'idle', phase: 'ready', error: null, baselinesReady: true, + recentWorkspaceId: items[0]?.workspaceId, +}) +const hook = <T,>(snapshot: T) => <S,>(selector: (state: T) => S): S => selector(snapshot) + +/** jsdom lacks DragEvent — the fireEvent fallback drops clientY, so pin it on the built event. */ +function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number): void { + const event = kind === 'dragOver' ? createEvent.dragOver(row) : createEvent.drop(row) + Object.defineProperty(event, 'clientY', { value: clientY }) + Object.defineProperty(event, 'dataTransfer', { value: { effectAllowed: '', dropEffect: '' } }) + fireEvent(row, event) +} + +function mount(overrides: Partial<WorkspaceBrowserProps> = {}) { + const store = createWorkspaceViewStore().create() + const props: WorkspaceBrowserProps = { + wide: true, + expandSidebar: vi.fn(), + useSessions: hook(sessionState([])), + useWorkspaces: hook(workspaceState([])), + useStore: bindSnapshotSelector(store), + actions: store.actions, + startSession: vi.fn(), + open: vi.fn(), + renameWorkspace: vi.fn(async () => {}), + insertSessionBefore: vi.fn(async () => {}), + createWorkspace: vi.fn(async () => workspace('created', [])), + ...overrides, + } + const view = render(<WorkspaceBrowser {...props} />) + return { view, props, store } +} + +/** Re-render with (possibly) changed props — WorkspaceBrowser has no side channel. */ +function rerender(b: ReturnType<typeof mount>, overrides: Partial<WorkspaceBrowserProps>) { + Object.assign(b.props, overrides) + b.view.rerender(<WorkspaceBrowser {...b.props} />) +} + +describe('WorkspaceBrowser', () => { + it('renders the grouped tree by default and switches to the flat list via Group by', () => { + const sessions = sessionState([summary('alpha-s', 2), summary('beta-s', 1)]) + const b = mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s']), workspace('beta', ['beta-s'])])), + }) + expect(screen.getByText('Workspaces')).toBeTruthy() + expect(screen.getByText('alpha')).toBeTruthy() + // Sessions hidden while their group is folded. + expect(screen.queryByText('alpha-s')).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: 'Group by' })) + expect(screen.getByText('Group by')).toBeTruthy() // the menu heading label + fireEvent.click(screen.getByRole('menuitem', { name: 'In one list' })) + // Store-driven flip: title changes, rows flatten newest-first, headers gone. + expect(b.store.getSnapshot().groupBy).toBe('flat') + expect(screen.getByText('Sessions')).toBeTruthy() + expect(screen.queryByText('alpha')).toBeNull() + expect(screen.getByText('alpha-s')).toBeTruthy() + expect(screen.getByText('beta-s')).toBeTruthy() + + // Back to workspace grouping through the same menu. + fireEvent.click(screen.getByRole('button', { name: 'Group by' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'WorkSpace' })) + expect(b.store.getSnapshot().groupBy).toBe('workspace') + expect(screen.getByText('Workspaces')).toBeTruthy() + + // Escape closes the menu without picking. + fireEvent.click(screen.getByRole('button', { name: 'Group by' })) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.queryByRole('menu')).toBeNull() + expect(b.store.getSnapshot().groupBy).toBe('workspace') + }) + + it('expands a group on click and opens a session row', () => { + const open = vi.fn() + mount({ + useSessions: hook(sessionState([summary('alpha-s', 1)])), + useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s'])])), + open, + }) + fireEvent.click(screen.getByText('alpha')) + fireEvent.click(screen.getByText('alpha-s')) + expect(open).toHaveBeenCalledWith(sid('alpha-s')) + // Collapse hides the row again. + fireEvent.click(screen.getByText('alpha')) + expect(screen.queryByText('alpha-s')).toBeNull() + }) + + it('unfolds a session subtree through the row twist', () => { + const parent = summary('parent-s', 2) + const child = { ...summary('child-s', 1), parentId: parent.id } + mount({ + useSessions: hook(sessionState([parent, child])), + useWorkspaces: hook(workspaceState([workspace('alpha', ['parent-s', 'child-s'])])), + }) + fireEvent.click(screen.getByText('alpha')) + expect(screen.queryByText('child-s')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Expand' })) + expect(screen.getByText('child-s')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Collapse' })) + expect(screen.queryByText('child-s')).toBeNull() + }) + + it('auto-expands the selected session group and starts a session from the group +', () => { + const startSession = vi.fn() + mount({ + useSessions: hook(sessionState([summary('alpha-s', 1)], { current: sid('alpha-s') })), + useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s'])])), + startSession, + }) + // The current-group effect expanded the owning group without a click. + expect(screen.getByText('alpha-s')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'New session in alpha' })) + expect(startSession).toHaveBeenCalledWith(wid('alpha')) + }) + + it('auto-expands the Ungrouped bucket for a loose current session; its header has no menu and its + is inert', () => { + const startSession = vi.fn() + mount({ + useSessions: hook(sessionState([summary('loose', 1)], { current: sid('loose') })), + useWorkspaces: hook(workspaceState([workspace('alpha', [])])), + startSession, + }) + // The loose session's group is UNGROUPED_KEY: expanded by the effect. + expect(screen.getByText('loose')).toBeTruthy() + expect(screen.queryByRole('button', { name: 'Workspace actions for Ungrouped' })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'New session in Ungrouped' })) + expect(startSession).not.toHaveBeenCalled() + }) + + it('keeps an already-expanded group when the selection moves within it', () => { + const first = sessionState([summary('a', 2), summary('b', 1)], { current: sid('a') }) + const b = mount({ + useSessions: hook(first), + useWorkspaces: hook(workspaceState([workspace('alpha', ['a', 'b'])])), + }) + expect(screen.getByText('a')).toBeTruthy() + // Selection hop inside the same group: the effect re-runs and leaves the + // expansion list unchanged (no duplicate key, group still open). + rerender(b, { useSessions: hook({ ...first, current: sid('b') }) }) + expect(screen.getByText('b')).toBeTruthy() + fireEvent.click(screen.getByText('alpha')) + expect(screen.queryByText('b')).toBeNull() + }) + + it('renders the intent placeholder in both modes', () => { + const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: wid('alpha') }, prompt: '', phase: 'connecting' as const } + const sessions = sessionState([], { intent, current: sid('intent') }) + const b = mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([workspace('alpha', [])])), + }) + // Grouped: the current-group effect expands the target group. + expect(screen.getByText('New session')).toBeTruthy() + b.store.actions.setGroupBy('flat') + rerender(b, {}) + expect(screen.getByText('New session')).toBeTruthy() + }) + + it('searches across groups, clears via the clear button, and shows the empty states', () => { + const sessions = sessionState([ + summary('needle-row', 2, { displayTitle: 'Needle row' }), + summary('other-row', 1, { displayTitle: 'Other row' }), + ]) + mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-row', 'other-row'])])), + }) + const input = screen.getByPlaceholderText<HTMLInputElement>('Search name, keywords...') + fireEvent.change(input, { target: { value: 'needle' } }) + // Search forces matches visible without expansion state. + expect(screen.getByText('Needle row')).toBeTruthy() + expect(screen.queryByText('Other row')).toBeNull() + fireEvent.change(input, { target: { value: 'zzz' } }) + expect(screen.getByText('No matches')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Clear search' })) + expect(input.value).toBe('') + // Clicking the field row focuses the input (wide mode). + fireEvent.click(input.parentElement as HTMLElement) + expect(document.activeElement).toBe(input) + }) + + it('shows the no-sessions empty state in both modes', () => { + const b = mount() + expect(screen.getByText('No sessions yet')).toBeTruthy() + b.store.actions.setGroupBy('flat') + rerender(b, {}) + expect(screen.getByText('No sessions yet')).toBeTruthy() + // Flat search misses show No matches. + fireEvent.change(screen.getByPlaceholderText('Search name, keywords...'), { target: { value: 'x' } }) + expect(screen.getByText('No matches')).toBeTruthy() + }) + + it('rail state renders icon controls that request expansion', () => { + vi.useFakeTimers() + try { + const expandSidebar = vi.fn() + const b = mount({ wide: false, expandSidebar }) + // No wide chrome in rail state. + expect(screen.queryByText('Workspaces')).toBeNull() + expect(screen.queryByPlaceholderText('Search name, keywords...')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Search sessions' })) + expect(expandSidebar).toHaveBeenCalledTimes(1) + // The wide flip mounts the input and focuses it after the slide. + rerender(b, { wide: true }) + const input = screen.getByPlaceholderText('Search name, keywords...') + act(() => { vi.advanceTimersByTime(300) }) + expect(document.activeElement).toBe(input) + // Wide search button is decorative (tabIndex -1, no expand call). + fireEvent.click(screen.getByRole('button', { name: 'Search sessions' })) + expect(expandSidebar).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('rail create-workspace expands the shell and opens the picker; wide toggles in place', () => { + const expandSidebar = vi.fn() + const b = mount({ wide: false, expandSidebar, useWorkspaces: hook(workspaceState([workspace('alpha', [])])) }) + fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) + expect(expandSidebar).toHaveBeenCalledTimes(1) + rerender(b, { wide: true }) + // The picker menu is open (anchored on the +); picking starts a session. + fireEvent.click(screen.getByRole('menuitem', { name: 'alpha' })) + expect(b.props.startSession).toHaveBeenCalledWith(wid('alpha')) + expect(screen.queryByRole('menu')).toBeNull() + // Wide toggle: open and close without expand requests. + fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) + expect(screen.getByRole('menu')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) + expect(screen.queryByRole('menu')).toBeNull() + expect(expandSidebar).toHaveBeenCalledTimes(1) + + // Escape closes the picker through its own onClose. + fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.queryByRole('menu')).toBeNull() + }) + + it('drag reorder reports the anchor to insertSessionBefore and skips no-op drops', () => { + const insertSessionBefore = vi.fn(async () => {}) + const sessions = sessionState([summary('one', 3), summary('two', 2), summary('three', 1)]) + mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two', 'three'])])), + insertSessionBefore, + }) + fireEvent.click(screen.getByText('alpha')) + const rows = screen.getAllByRole('treeitem').slice(1) // drop the group header + const [one, , three] = rows as [HTMLElement, HTMLElement, HTMLElement] + three.getBoundingClientRect = () => ({ + top: 200, bottom: 234, left: 0, right: 200, width: 200, height: 34, x: 0, y: 200, toJSON: () => ({}), + } as DOMRect) + const dataTransfer = { effectAllowed: '', dropEffect: '' } + fireEvent.dragStart(one, { dataTransfer }) + // Drop on the top half of "three": insert one before three. + fireDrag(three, 'dragOver', 205) + fireDrag(three, 'drop', 205) + expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), sid('three')) + + // Dropping right back onto its own position is a no-op — top half + // (anchor = itself) and bottom half (anchor = the next root) alike. + fireEvent.dragStart(one, { dataTransfer }) + one.getBoundingClientRect = () => ({ + top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}), + } as DOMRect) + fireDrag(one, 'dragOver', 105) + fireDrag(one, 'drop', 105) + expect(insertSessionBefore).toHaveBeenCalledTimes(1) + fireEvent.dragStart(one, { dataTransfer }) + fireDrag(one, 'drop', 130) + expect(insertSessionBefore).toHaveBeenCalledTimes(1) + }) + + it('still sends the reorder when the dragged row left the group mid-drag', () => { + const insertSessionBefore = vi.fn(async () => {}) + const sessions = sessionState([summary('one', 2), summary('two', 1)]) + const b = mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])), + insertSessionBefore, + }) + fireEvent.click(screen.getByText('alpha')) + const one = screen.getByText('one').closest('[role="treeitem"]') as HTMLElement + fireEvent.dragStart(one, { dataTransfer: { effectAllowed: '', dropEffect: '' } }) + // The host dropped "one" from the workspace account while the drag is in + // flight: the source index is gone but the drop still resolves its anchor. + rerender(b, { useWorkspaces: hook(workspaceState([workspace('alpha', ['two'])])) }) + const two = screen.getByText('two').closest('[role="treeitem"]') as HTMLElement + two.getBoundingClientRect = () => ({ + top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}), + } as DOMRect) + fireDrag(two, 'drop', 155) + expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), sid('two')) + }) + + it('drag end without a drop clears markers; bottom-half drop appends past the last row', () => { + const insertSessionBefore = vi.fn(async () => {}) + const sessions = sessionState([summary('one', 2), summary('two', 1)]) + mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])), + insertSessionBefore, + }) + fireEvent.click(screen.getByText('alpha')) + const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement] + two.getBoundingClientRect = () => ({ + top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}), + } as DOMRect) + const dataTransfer = { effectAllowed: '', dropEffect: '' } + fireEvent.dragStart(one, { dataTransfer }) + fireEvent.dragEnd(one) + // The drag ended: rows no longer accept drops. + fireDrag(two, 'drop', 180) + expect(insertSessionBefore).not.toHaveBeenCalled() + + // Bottom half of the last row: append (anchor omitted). + fireEvent.dragStart(one, { dataTransfer }) + fireDrag(two, 'dragOver', 180) + fireDrag(two, 'drop', 180) + expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), undefined) + }) + + it('logs and keeps the order when the reorder call rejects', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const insertSessionBefore = vi.fn(async () => { throw new Error('stale anchor') }) + const sessions = sessionState([summary('one', 2), summary('two', 1)]) + mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])), + insertSessionBefore, + }) + fireEvent.click(screen.getByText('alpha')) + const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement] + two.getBoundingClientRect = () => ({ + top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}), + } as DOMRect) + const dataTransfer = { effectAllowed: '', dropEffect: '' } + fireEvent.dragStart(one, { dataTransfer }) + fireDrag(two, 'drop', 180) + await waitFor(() => { expect(warn).toHaveBeenCalledWith('session reorder rejected:', expect.any(Error)) }) + } finally { + warn.mockRestore() + } + }) + + it('renames a workspace through the row menu dialog', async () => { + let resolveRename!: () => void + const renameWorkspace = vi.fn(() => new Promise<void>((resolve) => { resolveRename = resolve })) + mount({ + useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha'), workspace('beta', [], 'Beta')])), + renameWorkspace, + }) + fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' })) + const input = screen.getByLabelText<HTMLInputElement>('Workspace name') + expect(input.value).toBe('Alpha') + // Unchanged and blank names stay blocked. + expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true) + fireEvent.change(input, { target: { value: ' ' } }) + expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true) + // A duplicate of another workspace's title shows the inline conflict. + fireEvent.change(input, { target: { value: ' Beta ' } }) + expect(screen.getByRole('alert').textContent).toBe('A workspace named “Beta” already exists.') + expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true) + fireEvent.change(input, { target: { value: 'Gamma' } }) + fireEvent.click(screen.getByRole('button', { name: 'Rename' })) + expect(renameWorkspace).toHaveBeenCalledWith(wid('alpha'), 'Gamma') + // While renaming: input disabled, close blocked, Enter ignored. + expect(input.disabled).toBe(true) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.getByRole('dialog')).toBeTruthy() + await act(async () => { resolveRename() }) + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('rename via Enter, failure surfaces the error, Cancel closes', async () => { + const renameWorkspace = vi.fn(async () => { throw new Error('rename conflict') }) + mount({ + useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])), + renameWorkspace, + }) + fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' })) + const input = screen.getByLabelText<HTMLInputElement>('Workspace name') + // Enter with a blocked draft (unchanged) does nothing. + fireEvent.keyDown(input, { key: 'Enter' }) + expect(renameWorkspace).not.toHaveBeenCalled() + fireEvent.change(input, { target: { value: 'Renamed' } }) + fireEvent.keyDown(input, { key: 'a' }) + fireEvent.keyDown(input, { key: 'Enter' }) + expect(renameWorkspace).toHaveBeenCalledWith(wid('alpha'), 'Renamed') + await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('rename conflict') }) + // The dialog stays for retry; typing clears the error; Cancel closes. + fireEvent.change(input, { target: { value: 'Renamed2' } }) + expect(screen.queryByRole('alert')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('reports non-Error rename failures as text', async () => { + const renameWorkspace = vi.fn(async () => { throw 'denied' }) + mount({ + useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])), + renameWorkspace, + }) + fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' })) + fireEvent.change(screen.getByLabelText('Workspace name'), { target: { value: 'Other' } }) + fireEvent.click(screen.getByRole('button', { name: 'Rename' })) + await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') }) + }) + + it('search hides drag affordances (rows are not draggable during search)', () => { + const sessions = sessionState([summary('needle-a', 2, { displayTitle: 'Needle A' })]) + mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-a'])])), + }) + fireEvent.change(screen.getByPlaceholderText('Search name, keywords...'), { target: { value: 'needle' } }) + const row = screen.getByText('Needle A').closest('[role="treeitem"]') as HTMLElement + expect(row.getAttribute('draggable')).toBe('false') + }) +}) diff --git a/packages/client/web-react/README.i18n.yaml b/packages/client/web-react/README.i18n.yaml new file mode 100644 index 0000000000..091f2d8ad1 --- /dev/null +++ b/packages/client/web-react/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 7cc80f22bd5527838288d11c819e81b7ec4d17c4 +README.zh.md: ec7143db9a1fe97145a07a0a1fde45c9268c2425 diff --git a/packages/client/web-react/README.md b/packages/client/web-react/README.md index 704e09d96f..7cc80f22bd 100644 --- a/packages/client/web-react/README.md +++ b/packages/client/web-react/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-client-web-react +English | [中文](README.zh.md) + Shell-side React glue for the slot terminal design: createSlotRenderer (the SlotRenderer implementation the shell installs into the runtime SlotsService), SessionProvider (framework-wired render prop, also injected as a standard seat to entries declaring session-scope children), bindSnapshotSelector (the one hook constructor — hosts and engines traffic in bare observable sources; every hook binds here, cached per source), useInvoke. Chain-slot outlets run the registered selectors in chain order at render time and mount only the elected entry, its select return joining the props as `matched`; the `renderSlotChain` binding is per-entry cached like `renderSlot`. The snapshot-store engine and defineStore live in runtime (store relocation); business plugins depend on ui-slots types only, never on this package. ## Model Experience diff --git a/packages/client/web-react/README.zh.md b/packages/client/web-react/README.zh.md new file mode 100644 index 0000000000..ec7143db9a --- /dev/null +++ b/packages/client/web-react/README.zh.md @@ -0,0 +1,19 @@ +# @deepseek-ai/dsh-client-web-react + +[English](README.md) | 中文 + +slot 终端设计的外壳侧 React 胶水:createSlotRenderer(外壳安装到运行时 SlotsService 的 SlotRenderer 实现)、SessionProvider(框架接线的 render prop,也作为标准 seat 注入到声明会话 scope 子项的配置项)、bindSnapshotSelector(唯一的 hook 构造器:主机与引擎只传递裸 observable source;每个 hook 在此绑定,并按 source 缓存)、useInvoke。链式 slot outlet 在渲染时按链顺序运行已注册 selector,只挂载被选中的配置项,其 select 返回值以 `matched` 加入 props;`renderSlotChain` binding 与 `renderSlot` 一样按配置项缓存。快照 store 引擎与 defineStore 位于 runtime(store 已迁移);业务插件只依赖 ui-slots 类型,绝不依赖该包。 + +## 模型体验 + +无。ctx↔React 机制完全在浏览器中运行;这里没有任何内容进入模型请求。 + +#### KV Cache 影响 + +无;该包既不组装也不发送提供方请求。 + +## 已知限制与暂缓事项 + +- **persist 中间件会损坏原始值状态 store**:保存时它会对状态执行对象展开,因此 `SnapshotStore<string>` 往返后会变成字符 map;引擎改为自行实现持久化(见 `attachPersistence`)。 +- **`UseSession` 有意保持宽泛(`object` 快照)**:依赖方向(runtime → web-react,绝不反向)使真实 `ConversationSnapshot` 类型不可访问;会话 slot 消费方在其边界处缩窄一次。 +- **renderSlot 是唯一的 P-I 形式**:没有 Suspense,也没有逐配置项惰性加载;渐进式渲染表层会随其独立项目回归。 diff --git a/packages/client/web/README.i18n.yaml b/packages/client/web/README.i18n.yaml new file mode 100644 index 0000000000..3a82b79223 --- /dev/null +++ b/packages/client/web/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: a4325f5dbe8ddd9bbe77086eb16fdb7aed9adc83 +README.zh.md: 9c1eab795145cf0a1b69528bc9822a26ed226023 diff --git a/packages/client/web/README.md b/packages/client/web/README.md index 6cea165608..a4325f5dbe 100644 --- a/packages/client/web/README.md +++ b/packages/client/web/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-client-web +English | [中文](README.zh.md) + Web shell kernel: `new AppWebEntry(el, seams?).run()` mounts the whole client through the two-stage boot (web2). Stage one (module face): build the client module system (`@deepseek-ai/dsh-client-modules`) over the host-pushed entry graph (`window.__DSH_BOOT__`) and prefetch the `immediately` tier in parallel — bundle execution registers factories only. Stage two (plugin face): mount the vendored cordis Loader with the module system injected as its `internal` seam, create one loader entry per graph row plus the shell-own app-shell assembly entry (tree.import materializes each module), and gate AppRoot on the settle (loader quiesced + every entry fiber ACTIVE → full UI in one switch). Composition is entirely the host graph's: the roster and the immediately tier live in the composing app; the shell makes zero composition decisions. Shell self-sufficiency (web2 hard rule): the kernel value-imports no plugin package — the boot status store and signals are hand-rolled here (`loader-status.ts`), so the loading page works while (and especially when) plugins fail. The app-shell assembly (`@deepseek-ai/dsh-client-app-shell`, a shell-owned pseudo entry with no npm package behind it) is the only module registered through `registerStatic`; it inject-waits on slots/sessions/layout like any plugin. diff --git a/packages/client/web/README.zh.md b/packages/client/web/README.zh.md new file mode 100644 index 0000000000..9c1eab7951 --- /dev/null +++ b/packages/client/web/README.zh.md @@ -0,0 +1,26 @@ +# @deepseek-ai/dsh-client-web + +[English](README.md) | 中文 + +Web 外壳内核:`new AppWebEntry(el, seams?).run()` 通过两阶段启动(web2)挂载整个客户端。第一阶段(模块表层):构建客户端模块系统(`@deepseek-ai/dsh-client-modules`),以主机推送的配置项图(`window.__DSH_BOOT__`)为基础,并行预抓取 `immediately` 层级;执行组合包只会注册 factory。第二阶段(插件表层):挂载 vendored cordis Loader,并把模块系统作为其 `internal` seam 注入;为每一行图数据创建一个 loader 配置项,另创建外壳自身的 app-shell 组装配置项(tree.import 会物化各模块);以 settle 作为 AppRoot 的门禁(loader 完全停稳 + 每个配置项 fiber 都为 ACTIVE → 一次切换显示完整 UI)。组合完全由主机图决定:花名册和 immediately 层级都位于负责组合的应用中;外壳不作任何组合决策。 + +外壳自给自足(web2 硬性规则):内核不对任何插件包执行值导入;启动状态 store 与信号在这里手写(`loader-status.ts`),因此插件失败时(尤其在失败时)加载页面仍能工作。app-shell 组装(`@deepseek-ai/dsh-client-app-shell`,由外壳拥有、背后没有 npm 包的伪配置项)是唯一通过 `registerStatic` 注册的模块;它与任何插件一样,通过 inject 等待 slots/sessions/layout。 + +`PLATFORM_MODULES`(src/platform.ts)是共享模块表层的唯一真源:种子表 key、tsdown 客户端 external 和 vite alias 集都是它的投影。 + +可选 `seams` 参数会转发模块系统的 `fetchBundle`/`executeBundle` 传输覆盖(`BootSeams`);生产调用方省略此参数。它用于测试环境,因为此类环境中的 `<script>` 执行无法到达页面上下文(jsdom)。 + +外壳拥有浏览器标题投影。选中带有持久标题的会话时,它会渲染 `<session title> — <existing HTML title>` 并响应后续标题修订;未选择会话或选中无标题会话时,会保留现有标题;外壳卸载时恢复标题。现有 HTML 标题仍是可配置的产品后缀。 + +## 模型体验 + +无。配置项外壳负责启动浏览器插件树;这里没有任何内容进入模型请求。 + +#### KV Cache 影响 + +无;该包既不组装也不发送提供方请求。 + +## 已知限制与暂缓事项 + +- **有意采用一次性渲染**:UI 等待启动 settle;只要一个配置项失败,加载页面就会保留并高声逐项报告,不提供部分可用性(渐进式渲染会随其独立项目回归)。 +- **窄窗口验收暂缓**:ui-layout 已实现让步链,但外壳级窄视口演练是 P-II 验收项。 diff --git a/packages/client/web/src/boot.tsx b/packages/client/web/src/boot.tsx index bd755a5fc1..61dbbd850e 100644 --- a/packages/client/web/src/boot.tsx +++ b/packages/client/web/src/boot.tsx @@ -21,7 +21,7 @@ * switches to the real UI in one pass. * * Entry creation waits for the whole immediately tier: materialization runs - * synchronous cross-package require edges (e.g. i18n → runtime/client) that + * synchronous cross-package require edges (e.g. locale → runtime/client) that * fiber inject waiting cannot protect — a bundle's factory must be * registered before any dependent entry materializes. Per-row prefetch * failures still resolve silently (the create-side import refetches and diff --git a/packages/code-runtime/README.i18n.yaml b/packages/code-runtime/README.i18n.yaml new file mode 100644 index 0000000000..002a78c603 --- /dev/null +++ b/packages/code-runtime/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: dbe6b37ffa01d07c6902672a06ebf6f88548ff99 +README.zh.md: fd73e1376afe5ae4390457fa648b9f19591ee71c diff --git a/packages/code-runtime/README.md b/packages/code-runtime/README.md index 27ef599ebe..dbe6b37ffa 100644 --- a/packages/code-runtime/README.md +++ b/packages/code-runtime/README.md @@ -1,5 +1,7 @@ # code-runtime/ — code-execution capability family +English | [中文](README.zh.md) + The code-execution capability seam (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract runtime interface for executing one model-written program against host-provided async bindings, capturing what it printed and returned. The consumer is the tool registry's [Code Mode](../core/tools/README.md) (`tools: { mode: code }` — the `run_code` tool and the generated TypeScript SDK); design in the [Code Mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md). **Product** packages. | Package | Role | ctx key | diff --git a/packages/code-runtime/README.zh.md b/packages/code-runtime/README.zh.md new file mode 100644 index 0000000000..fd73e1376a --- /dev/null +++ b/packages/code-runtime/README.zh.md @@ -0,0 +1,12 @@ +# code-runtime/:代码执行能力家族 + +[English](README.md) | 中文 + +代码执行能力 seam(参见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):一个抽象运行时接口,用于针对宿主提供的异步绑定执行一段模型编写的程序,并捕获程序打印和返回的内容。消费方是工具注册表的 [Code Mode](../core/tools/README.md)(`tools: { mode: code }`,即 `run_code` 工具与生成的 TypeScript SDK);设计记录在 [Code Mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) 中。这些都是**产品** 包。 + +| 包 | 职责 | ctx 键 | +|---|---|---| +| `code-runtime/` | 抽象代码执行 seam(接口 + 词汇) | `ctx.codeRuntime` | +| [`code-runtime-worker/`](code-runtime-worker/README.md) | worker 线程后端:每次运行使用全新 worker,由宿主侧剥离 TypeScript 类型(类型注解仅供参考,绝不执行类型检查)、端口桥接绑定、预算/堆隔离 | 注册 `ctx.codeRuntime` | + +接口位于 `code-runtime/code-runtime/`,随附后端位于 `code-runtime/code-runtime-worker/`。不同后端可以采用不同执行基底(worker 线程、进程、容器)与源语言;二者都是服务上的只读描述符。后端注册 `ctx.codeRuntime`,无需修改接口或消费方;正是这种拆分,使未来可以直接换入强化后端。 diff --git a/packages/code-runtime/code-runtime-worker/README.i18n.yaml b/packages/code-runtime/code-runtime-worker/README.i18n.yaml new file mode 100644 index 0000000000..27a1ae9193 --- /dev/null +++ b/packages/code-runtime/code-runtime-worker/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: acc0e37e73d3887c70a77d16311305e52f5a4030 +README.zh.md: 15983dba00eff6fbc6e6d32a4d22df3a4249af76 diff --git a/packages/code-runtime/code-runtime-worker/README.md b/packages/code-runtime/code-runtime-worker/README.md index 1838919112..acc0e37e73 100644 --- a/packages/code-runtime/code-runtime-worker/README.md +++ b/packages/code-runtime/code-runtime-worker/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-code-runtime-worker +English | [中文](README.zh.md) + Worker-thread implementation of the [`@deepseek-ai/dsh-code-runtime`](../code-runtime/README.md) seam: `WorkerCodeRuntime` runs each program in ONE fresh Node `worker_threads.Worker` — TypeScript in, type-stripped host-side, bindings bridged over the message port, `{ value, logs, error? }` out. **Containment, not a security boundary**: trust posture is bash-equivalent by design (the [Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) § Trust posture), with containment bash does not have — separate isolate, empty environment, heap cap, hard termination. ## Config diff --git a/packages/code-runtime/code-runtime-worker/README.zh.md b/packages/code-runtime/code-runtime-worker/README.zh.md new file mode 100644 index 0000000000..15983dba00 --- /dev/null +++ b/packages/code-runtime/code-runtime-worker/README.zh.md @@ -0,0 +1,54 @@ +# @deepseek-ai/dsh-code-runtime-worker + +[English](README.md) | 中文 + +这是 [`@deepseek-ai/dsh-code-runtime`](../code-runtime/README.md) seam 的 worker 线程实现:`WorkerCodeRuntime` 会在每次运行中使用一个全新的 Node `worker_threads.Worker`,输入 TypeScript,由宿主侧剥离类型,通过消息端口桥接绑定,输出 `{ value, logs, error? }`。**这是隔离措施,而非安全边界**:其信任立场有意与 bash 等价(参见 [Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) 的 Trust posture 章节),但提供 bash 没有的隔离:独立 isolate、空环境、堆上限与强制终止。 + +## 配置 + +```yaml +- id: code-runtime + name: '@deepseek-ai/dsh-code-runtime-worker' + config: + computeMs: 60000 # busy-time budget (measured event-loop active time) + maxWallMs: 600000 # wall-clock ceiling; never pauses for anything + maxOutputBytes: 67108864 # combined serialized outer-output cap (64 MiB) + maxOldGenerationSizeMb: 512 # worker heap cap (resourceLimits) +``` + +每个字段都会验证并提供默认值;`maxOutputBytes` 必须是至少 4 字节的安全整数,其余字段必须是有限正数,此外没有其他可调项。 + +## 设计 + +- **每次运行使用一个全新 worker,不设池化**:程序所在的世界会随 worker 一同终止,不会留下需要记录的跨运行状态,也无法发生状态泄漏;仅凭会话日志即可重建运行。 +- **在执行上下文中,由宿主侧剥离类型**:程序会包裹在异步函数外壳中,通过 `node:module` 的 `stripTypeScriptTypes` 剥离类型(只支持可擦除语法;`enum`/namespace 会作为程序 `exception` 被拒绝,且不会启动 worker),再按字节位置切回原内容。之后程序作为 `AsyncFunction` 的函数体执行,因此顶层 `await`/`return` 可用。 +- **端口把对端视为不可信**:模型代码能够访问 `parentPort` 并伪造通信,因此任何代码读取入站消息前,系统都会验证其形状并重新构建(`null`、原始值、无效类型和格式错误的载荷会被静默丢弃;伪造的额外字段绝不会被带入);宿主对每个调用 id 最多响应一次,只将绑定名称解析为自有属性(伪造的 `constructor` 无法沿原型链访问),丢弃结算后的回复,并验证每个绑定 resolve 值与完成值是否为无损 JSON。伪造的 `log`/`done` 消息无法绕过外层上限:宿主会再次验证,并统计每条获准日志以及完成值或诊断。worker 侧命名空间使用 null-prototype 和 `defineProperty`,因此形似 `__proto__` 的绑定名称只是普通键。 +- **绑定 reject 类属于请求数据**:可选命名空间描述符会指定构造器全局变量,以及用于接收失败成员名称的自有属性。worker 会创建并注入该真实类,使 `instanceof` 生效,同时无需硬编码 `tools` 或 `ToolCallError`;全局变量无效或冲突的声明会在启动 worker 前失败。失败路径使用模块捕获的错误与属性定义 intrinsic,以及 null-prototype 描述符,因此模型之后的修改无法把被拒绝的绑定变成 worker 崩溃。 +- **两个独立预算,因为对端不可信**:`computeMs` 统计 worker 实际测得的忙碌时间(轮询 `worker.performance.eventLoopUtilization()`);热循环无法借助待完成的诱饵 dispatch 隐藏,程序等待慢工具时则不累计。`maxWallMs` 为忙碌时间无法观测的情况兜底(例如等待永远不会 resolve 的 promise)。二者最终都会调用 `worker.terminate()`,连同步热循环也能终止;堆溢出会表现为 worker 的 OOM 退出(`kind: 'worker-exit'`)。 +- **中间绑定值是完整 JSON**:绑定参数与 resolve 值会接受迭代式无损 JSON 验证。程序执行前,worker 会捕获自己 realm 中的普通容器原型身份,以及只用于外部 realm 的原生函数源码检查,因此构造器槽修改和用户编写的仿冒对象都无法改变容器分类。它还会捕获该 JSON 边界使用的每一个结构与计量 intrinsic,以无原型对象创建属性描述符,并绕过可变集合原型管理私有遍历状态;因此,模型对全局对象、原型方法或 `Object.prototype` 上形似描述符字段的修改,都无法改变验证、wire 传输或字节计量。值会展平为有深度上限的前序 wire 值,供 structured clone 使用,并在另一侧迭代式重建。它们没有字节、JavaScript 调用栈或嵌套 structured-clone 深度上限,绝不会进入外层输出账本或模型上下文;上限仍来自提供方/执行器获取限制与进程/worker 内存。 +- **日志主动流入一个外层账本**:console/stdout/stderr 文本按发送顺序穿过端口,因此超时或被终止的程序仍会显示已经打印的内容。worker 会按 JSON 字符串精确计费,并在发送完成值和异常诊断前,根据组合预算的剩余量预检;因此,抛出的百万字节 stack 会在 worker 边界变成固定的 `output-limit` 诊断。绕过补丁 stream 槽的原生写入会到达独立于完成端口的 pipe,因此宿主会针对这些字节和不可信伪造通信再次执行账本统计;在物化结果前,结算过程会持续进行有界 pipe 捕获,直到 worker 完成终止。`maxOutputBytes` 统计外层 `logs` 数组加完成值或失败消息载荷的 JSON 序列化;固定的 `CodeRunResult` 字段名、花括号、有界错误 kind 标签,以及后续呈现空白不计入这份可变载荷账本。未超过上限时会返回精确值;有损完成值属于 `invalid-output`,组合溢出属于 `output-limit`,不会用 inspected string 代替。失败会保留能容纳的已捕获前缀,之后按普通外层 `run_code` 落盘策略处理。 +- **空环境**:worker 使用 `env: {}` 和 `execArgv: []`,既没有环境凭据(比 spawn 命令的清理环境规则更严格),也不会继承 loader 标志。 +- **释放资源时等待完全停稳**:清理会把进行中的运行标记为 `abort`,并在 resolve 前等待每个 worker 退出。 + +## 未构建与已构建的 worker 入口 + +源代码模式通过 Node 原生类型剥离加载只包含可擦除语法的 `src/worker.ts`。其传递运行时闭包只包含 Node 内置模块和相对源模块,因此全新 checkout 绝不需要兄弟工作区包尚未构建的 `lib/` 导出。worker 本地 JSON 快照器会与会话自有的规范边界执行一致性测试;消息端口两侧都会展平并重建已验证值,使应用嵌套永远不会进入 structured clone。构建模式会把兄弟文件 `lib/worker.cjs` 作为文件系统路径传入,因为 pkg 的 VFS Worker hook 要求 CommonJS;同一路径也可在普通 Node 下使用。`tests/built-lib.e2e.ts` 固定了 [docs/testing.md](../../../docs/testing.md) 要求的真实加载路径。 + +SDK 接口是默认/具名 `WorkerCodeRuntime` 类与 `Config`。可操作的 `./worker` 子路径仅作为打包后的 spawn 入口存在;wire 协议与启动辅助模块是源代码私有的实现细节。 + +## 模型体验 + +通过 [`dsh-tools`](../../core/tools/README.md) 中的 Code Mode 间接提供;如果外层值能容纳则原样渲染,否则返回明确的 `invalid-output`/`output-limit` 失败。只有外层 `run_code` 结果进入模型上下文并使用普通落盘策略;绑定通信与中间值始终只存在于执行环境中。 + +#### KV Cache 影响 + +不会直接失效;由具名消费方负责请求前缀变更。 + +## 已知限制与暂缓工作 + +- **程序 spawn 的 OS 进程在终止后仍会存活**:`worker.terminate()` 只结束线程,比 bash-local 的进程组终止更弱;在容器后端出现前,孤儿清理属于部署职责。 +- **类型剥离依赖 Node 的实验性 `stripTypeScriptTypes` API**:依赖的行为由单元测试固定;如其发生变化,amaro/sucrase 是已经点名的直接替代品。 +- **`computeMs` 到期最多可能超过一个轮询间隔**:系统每 25 ms 采样一次忙碌时间(内部常量,有意不做成配置)。 +- **程序获得一个含 5 种方法的 `console` shim**(`log`/`info`/`warn`/`error`/`debug`):有意不提供 Node 的完整 console 接口。 +- **中间绑定值没有字节上限**:程序可以用永远不会成为外层输出的值耗尽进程或 worker 内存。 +- **默认 64 MiB 是拒绝边界,不是可恢复存储**:外层落盘只能保存发生 `output-limit` 后返回的有界日志和诊断;在运行时上限之外被拒绝的字节永远不会到达落盘层。 diff --git a/packages/code-runtime/code-runtime/README.i18n.yaml b/packages/code-runtime/code-runtime/README.i18n.yaml new file mode 100644 index 0000000000..57415166ac --- /dev/null +++ b/packages/code-runtime/code-runtime/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: c7a2d519e47d160f5ab123bfc887e7e9f24ec602 +README.zh.md: 10c1b900165e255a3ea57197ae323283822ab7bc diff --git a/packages/code-runtime/code-runtime/README.md b/packages/code-runtime/code-runtime/README.md index f8e09e301e..c7a2d519e4 100644 --- a/packages/code-runtime/code-runtime/README.md +++ b/packages/code-runtime/code-runtime/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-code-runtime +English | [中文](README.zh.md) + The **code-execution seam**: an abstract `CodeRuntime` service (`ctx.codeRuntime`) defining WHAT a code runtime does — run one model-written program against a set of host-provided async bindings and report `{ value, logs, error? }` — without saying HOW. This package is the interface third of the capability (the bash trio is the template — see [capability seams](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): implementations subclass `CodeRuntime` and register the service; the consumer is the tool registry's Code Mode, which generates the model-facing SDK and bridges tool dispatch — both specified in the [Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md), whose first implementation is a Node worker-thread backend. The runtime knows nothing about tools or sessions: it is handed named async functions and a program string, and everything tool-shaped stays with the consumer. diff --git a/packages/code-runtime/code-runtime/README.zh.md b/packages/code-runtime/code-runtime/README.zh.md new file mode 100644 index 0000000000..10c1b90016 --- /dev/null +++ b/packages/code-runtime/code-runtime/README.zh.md @@ -0,0 +1,36 @@ +# @deepseek-ai/dsh-code-runtime + +[English](README.md) | 中文 + +这是**代码执行 seam**:抽象的 `CodeRuntime` 服务(`ctx.codeRuntime`)只定义代码运行时做什么,即针对宿主提供的一组异步绑定运行一段模型编写的程序,并报告 `{ value, logs, error? }`,而不规定如何实现。 + +此包是该能力的接口层(以 bash 三包结构为模板,参见[能力 seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):实现通过继承 `CodeRuntime` 并注册服务接入;消费方是工具注册表的 Code Mode,它生成面向模型的 SDK,并桥接工具分发。两者都由 [Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) 规定,首个实现是 Node worker 线程后端。运行时不了解工具或会话:调用方只向它提供具名异步函数与程序字符串;所有工具形状的内容都留在消费方。 + +## 服务 API(`ctx.codeRuntime`) + +| 成员 | 语义 | +|---|---| +| `run(request)` | 针对请求的绑定执行一段程序。**每一种程序结果都通过 error 字段完成 resolve**:包括解析/转换失败、抛出异常、无效完成值、输出溢出、预算到期、中止或执行基底死亡(由 `CodeRunFailure` 的正交 `kind` 分类表示);只有调用方误用 seam 本身时才 reject(例如资源释放后仍提交运行)。程序作为异步函数的函数体运行,因此顶层 `await`/`return` 可用,无损 JSON 完成值会成为 `result.value`。 | +| `language` | 只读描述符:`run` 期望的源语言(已知值为 `'typescript'`)。仅供参考,不作门禁;生成语言专用呈现的消费方会对该值执行分支,遇到无法呈现的语言时明确失败。 | +| `isolation` | 只读描述符:执行基底(`'worker-thread'`、`'process'`、`'container'`)。供部署与诊断使用,**不构成安全声明**。 | + +每个实现都必须遵守以下语义(完整契约见类 JSDoc):绑定调用会桥接完整的无损 JSON 参数与 resolve 值,seam 层不设字节上限;程序被视为不可信对等方(任意绑定名称都是自有属性,格式错误的通信绝不能使宿主崩溃);不同运行之间不保留任何状态;资源释放会终止进行中的运行,并且在完成前等待其退出。 + +## 词汇 + +`CodeRunRequest`(`program`、`bindings`、`signal?`)携带运行时操作所需的全部内容;默认值解析(时间预算与外层输出上限)属于实现的已验证配置,绝不能是隐藏的 `??`,更不能藏在 `run()` 内部。`bindings` 是 `CodeBindingNamespace` 列表(`global` + `functions` + 可选 `errorClass`);每个命名空间会作为一个由异步可调用函数组成的全局对象公开给程序,这些函数返回 `CodeJsonValue`。后者是 seam 本地、与规范 `JsonValue` 结构等价的类型,使接口包保持独立于会话。`errorClass` 描述符点名真实的程序全局构造器,以及用于接收 reject 成员名称的自有属性;运行时不依赖 `ToolCallError` 等消费方术语。`CodeRunResult` 报告无损 JSON 完成值 `value?`、有序的 `logs: string[]` 和 `error?`(`CodeRunFailure`:`kind` + 可反馈给模型的 `message`)。完整契约见 `src/types.ts`。 + +## 模型体验 + +通过 `dsh-tools` 中的 Code Mode 间接提供;后者公开 `run_code`,并将程序日志、值或失败作为保留的工具结果 token 返回。 + +#### KV Cache 影响 + +不会直接失效;由具名消费方负责请求前缀变更。 + +## 已知限制与暂缓工作 + +- **`run()` 是一次性的**:`logs` 只有在 `CodeRunResult` resolve 后才能获得;seam 不提供活跃程序输出的流式日志或进度接口。 +- **持久 REPL 风格内核已记录为未来工作**:在持久内核后端带来自己的日志方案前,运行之间不保留状态的契约继续有效(参见 [Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md))。 +- **目前只提供 worker 线程后端**:`'process'`/`'container'` 是已经声明但没有实现的已知 `isolation` 值;硬安全边界需要等待容器后端。 +- **中间绑定值没有字节上限**:实现仍受 structured-clone 成本与进程内存约束,而提供方或执行器可能已经应用自己的获取上限。 diff --git a/packages/compact/README.i18n.yaml b/packages/compact/README.i18n.yaml new file mode 100644 index 0000000000..fcb73c5293 --- /dev/null +++ b/packages/compact/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 3c3644adce23c12db37241bf797ea614d273a0fb +README.zh.md: ad3abfd4f6064a44ecb22cd22b6c969247484de3 diff --git a/packages/compact/README.md b/packages/compact/README.md index be29e6093f..3c3644adce 100644 --- a/packages/compact/README.md +++ b/packages/compact/README.md @@ -1,5 +1,7 @@ # compact/ — compaction capability family +English | [中文](README.zh.md) + A compaction capability family (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract interface, a summarizing backend, a model-free tool-result pruning companion, and a deferred model-facing consumer. All **product** packages. | Package | Role | ctx key | diff --git a/packages/compact/README.zh.md b/packages/compact/README.zh.md new file mode 100644 index 0000000000..ad3abfd4f6 --- /dev/null +++ b/packages/compact/README.zh.md @@ -0,0 +1,14 @@ +# compact/:压缩能力家族 + +[English](README.md) | 中文 + +一个压缩(compaction)能力家族(见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):抽象接口、摘要后端、不依赖模型的工具结果剪枝配套组件,以及暂缓实现的面向模型消费方。这些全是**产品** 包。 + +| 包 | 职责 | ctx key | +|---|---|---| +| `compact/` | 抽象压缩 seam(接口 + `compact/*` 事件 + `CompactionResult`) | `ctx.compact` | +| `compact-basic/` | 后端:`ctx.tokenMeter` 压力 + token 预算保留 + `llm.stream()` 摘要生成 | (注册 `ctx.compact`) | +| `compact-tool-result-prune/` | 可选的不依赖模型的头/中/尾重写,在摘要压缩之前运行 | `ctx.toolResultPrune` | +| `tool-compact/`(暂缓) | 面向模型的 `/compact` 工具,基于 `ctx.compact` | (注册到 `ctx.tools`) | + +接口位于 `compact/compact/`,后端位于 `compact/compact-basic/`,确定性剪枝位于 `compact/compact-tool-result-prune/`。与 bash seam 不同,该接口依赖 `dsh-session` 和 `dsh-llm`,因为它的动词基于 `Session` 定义,输出则使用 `ContentBlock`。这项偏差记录在[压缩能力 seam Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) 中。token 测量仍是可复用的 LLM 家族服务;模板或模型支持的压缩器可以替换 `compact-basic`,而无需更改计量器、剪枝器或调用方。 diff --git a/packages/compact/compact-basic/README.i18n.yaml b/packages/compact/compact-basic/README.i18n.yaml new file mode 100644 index 0000000000..5b5c817fe8 --- /dev/null +++ b/packages/compact/compact-basic/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 2f65b0d8f223c4de8999006d005778e7087e8a4d +README.zh.md: c0be5d7dc92a60c649b792bfa181c0df7a12db9f diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index 7279cc029d..2f65b0d8f2 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-compact-basic +English | [中文](README.zh.md) + The **basic compaction backend**: a `BasicCompactService` implementing the `@deepseek-ai/dsh-compact` seam with reusable `ctx.tokenMeter` pressure, token-budget retention, and summarization as a direct one-shot `ctx.llm.stream()` call that replays the conversation prefix to reuse the provider's KV cache (interceptable at `llm/stream`). This is the implementation tier of the compaction capability — see the [interface package](../compact/README.md) for the seam and the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) for the design. diff --git a/packages/compact/compact-basic/README.zh.md b/packages/compact/compact-basic/README.zh.md new file mode 100644 index 0000000000..c0be5d7dc9 --- /dev/null +++ b/packages/compact/compact-basic/README.zh.md @@ -0,0 +1,161 @@ +# @deepseek-ai/dsh-compact-basic + +[English](README.md) | 中文 + +**基础压缩(compaction)后端**:`BasicCompactService` 实现 `@deepseek-ai/dsh-compact` seam,使用可复用的 `ctx.tokenMeter` 压力、token 预算保留与摘要。摘要是直接的一次性 `ctx.llm.stream()` 调用,它会回放会话前缀以复用提供方的 KV cache(可在 `llm/stream` 处拦截)。 + +这是压缩能力的实现层。seam 见 [接口包](../compact/README.md),设计见 [能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md)。 + +## 拥有的职责 + +该后端拥有压缩策略: + +- **测量**:单例 `ctx.tokenMeter` 会在同一个已消费日志 revision 上为最新规范已记录 envelope 与当前表层计价。因此,步骤后压力会包含实际系统提示词、工具、前缀、路由、assistant 完成、工具结果、缓冲上下文与 steering。 +- **路由策略**:主动压力从拥有最新持久提供方/模型路由的适配器解析容量,再将默认策略与可选的精确目标覆盖缩放为具体 token 预算。模型发现仍只提供建议,不会被咨询。 +- **不依赖模型的剪枝**:在压力或规范溢出符合条件后,可选的 [`ctx.toolResultPrune`](../compact-tool-result-prune/README.md) 服务会在选择范围之前改写超大工具结果。Compact-basic 通过 `ctx.tokenMeter` 重新测量;如果压力已回到安全范围,就跳过摘要,否则摘要已剪枝表层。低于压力的步骤后检查绝不剪枝。 +- **保留**:压缩最旧的完整表层单元,同时保留近期尾部,并通过 [`dsh-compact` 边界 helper](../compact/README.md#tool-pairing-boundaries) 保持工具调用/结果 cut 平衡。轮次边界不会保护失控轮次内的旧步骤。开启且不可分的尾部在关闭前会拒绝压缩。当闭合的超大工具单元以文本型结果为可移除主体时,可选 pruner 可以修复它;不可分的非工具单元与不可剪枝的工具剩余部分不在范围内。 +- **收敛**:最多按 `compactionRetries` 重试头部检查点压缩;拒绝不能缩小源内容的摘要,如果重试仍无法回到阈值以下,则抛出异常。 +- **摘要**:直接 `llm/stream` 调用使用已配置的提供方/模型对与上限,回退到最新已记录请求目标,然后再回退到 agent(智能体)目标,而不运行仅用于 loop 的 `agent/request` seam。该调用会逐字回放会话自身的系统提示词、工具与已遮蔽区域消息,并将压缩指令作为最后一条 user 消息追加,从而复用提供方的热前缀 cache,而非使它失效。它将 `GenerateOptions.purpose` 设为 `compaction`,适配器可将其作为请求归因转发(DeepSeek 适配器发送 `x-deepseek-harness-compact: 1`),但不会触碰模型可见主体。只有返回文本会进入检查点;会排除可能泄露私有推理或产生遗留调用的 reasoning 与工具调用。 +- **框定**:替换 user 消息使用 `<compacted-summary>` 标签标记已建立的检查点上下文。原始摘要保留在溯源事件上,后续自动周期会合并之前的检查点。 +- **生命周期**:`compactRegion()` 会更改 `agent.session`,并记录开始、摘要、替换与结束。异步摘要后,它会拒绝已改变的表层节点快照,而不相关的仅日志事件可以追加,不会使已选 span 失效。串行 `agent/post-step` listener 会在成功输出与工具工作持久后、`step/end` 之前检查压力。规范提供方溢出会在失败步骤关闭后通过 `agent/request-error` 处理。 +- **溢出恢复**:提供方已确认的溢出不需容量元数据。它会绕过常规压力与保留,执行剪枝,再尝试一次最大平衡头部缩减,并留下最新不可分单元。只要 `surface.replaceGeneration` 前进,就允许重试,包括剪枝在后续摘要工作抛出异常前已落地的情况。如果没有替换、精确目标上限已耗尽、已取消,或遇到未知/非规范错误,则保留原始提供方失败。 +- **失败处理**:不匹配的 `compact/start` 是惰性崩溃标记,因为没有摘要替换落地。区域失败会记录错误结束;除非剪枝已落地,否则表层保持不变。操作性步骤后失败会发出警告并继续;只当之前没有替换使表层前进时,溢出恢复失败才保留原始提供方错误。取消在任何进展后仍具有最高权威。 + +受保护的 `summarize()` 方法是唯一的子类 hook。基于模板或远程摘要器的子类可以覆盖该方法,同时压力、保留、溯源、缩减验证与已遮蔽 token 计量仍位于 `ctx.tokenMeter`。hook 会将摘要块与它使用的调用 envelope 一并返回(`{ summary, provider, model, maxTokens? }`),并记录在 `compact/summary` 上。 + +## 配置(`BasicCompactConfig`) + +所有设置都可选。顶层策略字段是每个已路由模型的默认值;`modelPolicies` 对精确提供方/模型对应用部分覆盖。出现压力时,compact-basic 会请求所属 LLM 适配器提供该路由的上下文容量,并解析绝对预算。无法识别的 key、重复目标、互斥保留形式,以及合并后的 `retainRatio` 不低于 `thresholdRatio`,都会使插件加载失败。不低于缩放后阈值的绝对 `retainTokens` 预算会在第一个可解析目标上失败,因为该比较需要模型容量。 + +| Key | 必填 | 含义 | +|---|---|---| +| `thresholdRatio` | 否(默认 `0.8`) | 在 `floor(routedContextWindow × ratio)` 处压缩。 | +| `retainRatio` | 否(默认 `0.16`) | 以已路由上下文窗口的一部分表示逐字保留的近期表层预算;与 `retainTokens` 互斥。 | +| `retainTokens` | 否 | 逐字保留的近期表层绝对预算;与 `retainRatio` 互斥,并且必须低于已解析阈值。 | +| `summarizationProvider` | 否(默认 `''`) | 与 `summarizationModel` 一起设置;空对会解析为最新已记录请求目标,再回退到 `AgentOptions` 对。 | +| `summarizationModel` | 否(默认 `''`) | 与 `summarizationProvider` 一起设置;空对会解析为最新已记录请求目标,再回退到 `AgentOptions` 对。 | +| `maxTokens` | 否(默认 `8192`) | 摘要调用的提供方生成上限;可包含 reasoning token。 | +| `compactionRetries` | 否(默认 `1`) | 压力仍高于阈值时,在首次尝试后进行的额外尝试次数。 | +| `maxOverflowRetries` | 否(默认 `1`) | 规范上下文窗口溢出后的最大重试次数;`0` 只禁用恢复。 | +| `modelPolicies` | 否(默认 `[]`) | 精确的 `{ provider, model, ...partialPolicy }` 覆盖;匹配使用两个字段,不依赖 `listModels()`。 | +| `auto` | 否(默认 `true`) | 注册步骤后压力与溢出恢复 listener。设为 `false` 则仅手动执行。 | + +每个 `modelPolicies` 配置项都接受上述策略字段,但不接受 `auto` 和 `modelPolicies` 自身。如果配置项提供任意一个保留字段,就替换默认策略的保留选择;否则继承保留设置。摘要提供方/模型在每个配置项内仍然成对。 + +适配器可能无法为有效动态路由返回容量,已解析容量也可能暴露无效的绝对保留预算。此时手动压力检查会抛出目标特定配置错误;自动 listener 会对该精确目标警告一次,并携带完整历史继续。不相关的操作性失败仍会独立可见。规范提供方溢出仍会尝试恢复,因为提供方已确立压缩的必要性。 + +## 用法 + +```ts +import type { Context } from 'cordis' +import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' +import TokenMeterService from '@deepseek-ai/dsh-token-meter' + +export const name = 'compact-basic' +export const inject = ['llm', 'tokenMeter'] + +export function apply(ctx: Context): void { + ctx.plugin(TokenMeterService) + ctx.plugin(BasicCompactService) +} +``` + +加载插件会注册 `ctx.compact`。在该插件之前添加同级 [`dsh-compact-tool-result-prune`](../compact-tool-result-prune/README.md) 以启用可选的不依赖模型 pass。当 `auto: true`(默认)时,它会在 token 压力下自动压缩;消费方(未来的 `/compact` 工具)也可直接调用 `ctx.compact.compactIfNeeded(...)` 或 `ctx.compact.compactRegion(...)`。 + +例如,同一个 compact 插件可以安全服务于容量不同的模型,并应用一项目标特定策略: + +```yaml +- name: '@deepseek-ai/dsh-compact-basic' + config: + thresholdRatio: 0.8 + retainRatio: 0.16 + modelPolicies: + - provider: local + model: small-context + thresholdRatio: 0.7 + retainTokens: 2048 +``` + +## 模型体验 + +### 会话历史 + +#### 模型看到的内容 + +成功步骤越过阈值后,如果已加载可选 pruner,超大工具结果会先被改写。如果仍需摘要,下一个请求会收到下方检查点前导、一个空行、`<compacted-summary>`、取决于数据的摘要以及 `</compacted-summary>`。溢出恢复会根据使表层前进的任何替换重建立即重试。检查点会替换已选较早范围,后面跟随已保留的近期单元。 + +##### 会话检查点前导 + +```markdown +This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint. +``` + +#### Token 影响 + +不依赖模型的剪枝可以完全避免辅助调用;否则它会在摘要替换较早范围之前缩减该调用的 transcript。替换会缩减未来输入历史,而非追加第二份副本。摘要会保留到后续压缩将其替换,但不可分的非工具单元仍可能超出预算。 + +#### KV Cache 影响 + +它是替换,而非仅追加。每个检查点都会使从第一个已替换历史 token 起的复用失效;该范围之前未更改的请求前缀仍可复用。 + +### 辅助摘要器请求 + +#### 模型看到的内容 + +摘要模型会接收逐字回放的会话:与上次已路由请求为已遮蔽区域发送的相同系统提示词、工具 schema 与消息,后面跟随一条最终 user 消息,即下方压缩指令。会话模型绝不会看到该私有请求或其推理;只有返回文本会被存储。 + +##### 压缩指令(最终 user 消息) + +```markdown +You are now acting as a compaction engine for this AI coding assistant. Condense the conversation ABOVE into a structured checkpoint that lets another model resume the work with no loss of essential context. + +Output EXACTLY the Markdown structure below: keep every section, in order. Use terse bullets, not prose paragraphs. Write "(none)" for an empty section — never drop a section. + +## Primary Request and Intent +- [the user's original and evolving goals; quote verbatim where the exact wording matters] + +## Key Technical Concepts +- [technologies, frameworks, patterns, and conventions in play] + +## Files and Code +- [exact path: why it matters, key changes or snippets] + +## Errors and Fixes +- [error: how it was resolved, plus any related user feedback] + +## Pending Tasks +- [explicitly requested work not yet completed] + +## Current Work +- [precisely what was in progress at this checkpoint] + +## Next Step +- [the single next action, directly in line with the most recent request, or "(none)"] + +## Critical Context +- [decisions and their rationale, constraints, user preferences, open questions, data needed to continue] + +Rules: +- Preserve exact file paths, commands, error strings, identifiers, and function signatures. +- Capture user feedback and explicit instructions faithfully, especially corrections. +- Do NOT mention this summarization request or that the context was compacted. +- Output only the checkpoint text: do not call any tool or take any other action. +- If the conversation already contains a <compacted-summary> block, it is a PRIOR checkpoint. Do not copy it forward verbatim: preserve still-true facts, drop stale ones, and merge newer information into a single consolidated summary under the same structure. +``` + +#### Token 影响 + +这是一次独立模型调用:输入是已回放会话前缀加固定指令,输出受 `maxTokens` 限制。收敛重试可能多次支付这项成本。 + +#### KV Cache 影响 + +已回放系统提示词、工具与已遮蔽区域消息与会话最后一个已路由请求逐字匹配,因此提供方的热前缀 cache 可复用至尾随指令之前;只有该指令与摘要输出未缓存。将摘要器路由到不同提供方/模型,或压缩非头部范围,都会放弃该复用。 + +## 已知限制与暂缓事项 + +- **Meter 准确度遵循固定启发式规则**:可复用提供方用量缺失时,会回退到字符数加结构开销,而非精确 tokenization。 +- **溢出分类由适配器维护**:提供方措辞可能改变;两个 DeepSeek 适配器将当前可识别的上下文限制失败规范化为 `CONTEXT_WINDOW_EXCEEDED`。 +- **部分不可分单元与仅 envelope 溢出仍不在表层压缩范围内**:恢复无法缩减系统/工具/前缀、拆分不可分的非工具节点,或修复不可剪枝剩余部分仍超出窗口的工具单元。可选 pruner 可以缩减原本不可分工具对内的文本型工具结果主体。 +- **`compactRegion` 要求开启轮次**:在完全关闭的会话上手动调用会抛出异常(「no open turn」),而不是执行压缩。 +- **摘要失败会保留最新持久表层**:任何替换前,自动路径会记录警告,并携带完整超预算历史继续。如果剪枝已落地,后续摘要失败会从该持久剪枝表层继续。在 `maxTokens` 处的摘要截断(可能由隐藏 reasoning token 耗尽)遵循同一规则。 +- **摘要调用没有 transcript 快照覆盖**:`dsh-llm-replay` 从 `assistant/chunk` 事件派生调用,因此这次不含 chunk 的直接 `ctx.llm.stream()` 调用无法回放([seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) 中明确的暂缓回放基础设施)。 diff --git a/packages/compact/compact-tool-result-prune/README.i18n.yaml b/packages/compact/compact-tool-result-prune/README.i18n.yaml new file mode 100644 index 0000000000..8da3d60991 --- /dev/null +++ b/packages/compact/compact-tool-result-prune/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: edeba52b189b3cee5530faf7efc04043a326917f +README.zh.md: fa471c0817cc937162b386cd3d249ce5ecd8c3ff diff --git a/packages/compact/compact-tool-result-prune/README.md b/packages/compact/compact-tool-result-prune/README.md index c06eab5405..edeba52b18 100644 --- a/packages/compact/compact-tool-result-prune/README.md +++ b/packages/compact/compact-tool-result-prune/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-compact-tool-result-prune +English | [中文](README.zh.md) + The replay-safe model-free pruning service (`ctx.toolResultPrune`). It rewrites over-budget `tool/result` surface nodes to a bounded head, a fixed omission marker, and a bounded tail while retaining the full original event in the append-only session log. This is a concrete companion to [`dsh-compact-basic`](../compact-basic/README.md), not a compaction backend or model-facing tool. Compact-basic reads it through optional `ctx.get('toolResultPrune')`, so either package remains independently composable. diff --git a/packages/compact/compact-tool-result-prune/README.zh.md b/packages/compact/compact-tool-result-prune/README.zh.md new file mode 100644 index 0000000000..fa471c0817 --- /dev/null +++ b/packages/compact/compact-tool-result-prune/README.zh.md @@ -0,0 +1,62 @@ +# @deepseek-ai/dsh-compact-tool-result-prune + +[English](README.md) | 中文 + +可安全回放、不依赖模型的剪枝服务(`ctx.toolResultPrune`)。它会将超出预算的 `tool/result` 表层节点改写为有界头部、固定省略标记和有界尾部,同时在仅追加会话日志中保留完整原始事件。 + +这是 [`dsh-compact-basic`](../compact-basic/README.md) 的具体配套服务,不是压缩后端或面向模型的工具。Compact-basic 通过可选的 `ctx.get('toolResultPrune')` 读取它,因此两个包都保持可独立组合。 + +## 服务 API + +`pruneSession(session)` 会扫描当前表层的一个稳定快照。每个超出预算的工具结果都会被一个新追加的 `tool/result` 替换,其携带 `{ surfaceOp: { op: 'replace', start: originalSeq, end: originalSeq }, sourceEventSeqs: [originalSeq] }`。替换会展开完整原始数据,只更改 `content`,保留 `turn`、`step`、`callId`、错误字段、`meta` 以及后续添加的数据。原始事件仍可用于持久化、回放和精确日志检查。 + +当会话拒绝替换时,该方法会同步抛出异常。本次 pass 中较早提交的替换仍然持久。 + +`measureContent(blocks)` 会统计 `text` 块中的 Unicode 码点。`pruneContent(blocks)` 会返回有界替换;如果内容已在阈值内,则返回 `null`。非文本块保持原始相对位置;文本切片绝不会拆分 UTF-16 surrogate pair,但可能拆分由多个码点组成的 grapheme cluster。 + +每个发出的结果在文本码点上都精确包含已配置的头部预算、固定标记和尾部预算,不大于 `thresholdChars`,且严格小于触发输入。因此第二次 pass 不会发出替换。 + +## 配置 + +无法识别的 key 会使插件在构造时失败。已解析配置与输入脱离,并且深度不可变。 + +| Key | 必填 | 含义 | +|---|---|---| +| `thresholdChars` | 否(默认 `8192`) | 合并文本超过此 Unicode 码点数时剪枝。 | +| `headChars` | 否(默认 `4096`) | 保留的开头 Unicode 码点数。 | +| `tailChars` | 否(默认 `1024`) | 保留的末尾 Unicode 码点数。 | + +所有值都必须是整数;阈值必须为正数,头部/尾部必须为非负数。`headChars + marker + tailChars` 必须能容纳在 `thresholdChars` 内,因此有效配置可以剪枝每个超出预算的结果,不会增长或重复改写。 + +## 用法 + +```ts +import type { Context } from 'cordis' +import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune' + +export function apply(ctx: Context): void { + ctx.plugin(ToolResultPruneService) +} +``` + +## 模型体验 + +### 已剪枝的工具结果 + +#### 模型看到的内容 + +一旦压缩触发器成立,后续请求会看到保留的头部、`\n\n[... tool result middle pruned ...]\n\n` 和保留的尾部,用它们替换已移除文本。富内容块保持顺序。模型不会看到原文的第二份副本。 + +#### Token 影响 + +每个已改写工具结果最多包含 `thresholdChars` 个文本码点。剪枝本身不会发起模型调用;重新测量的请求低于压力阈值时,compact-basic 会跳过摘要,否则摘要器会读取已剪枝的表层。 + +#### KV Cache 影响 + +替换较早的结果会使从第一个改变的 token 起的复用失效。当其路由、envelope 与之前的历史保持一致时,已剪枝前缀可以复用。 + +## 已知限制与暂缓事项 + +- **字符预算不是 token 预算**:不同提供方的 token 密度各异,因此 `ctx.tokenMeter` 仍负责判定剪枝是否缓解了请求压力。 +- **剪枝只基于语法**:它保留开头与结尾,不解释中间哪些行在语义上重要。 +- **Grapheme cluster 可能被拆分**:按码点切片可保护 surrogate pair,但不会执行感知 locale 的 grapheme 分割。 diff --git a/packages/compact/compact/README.i18n.yaml b/packages/compact/compact/README.i18n.yaml new file mode 100644 index 0000000000..921ee62a6d --- /dev/null +++ b/packages/compact/compact/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 17a5420ae9fa23ce4021b4d4927ae5b95962f979 +README.zh.md: d98251649cfdcf6b12e89a192a43b08b0f071f38 diff --git a/packages/compact/compact/README.md b/packages/compact/compact/README.md index ff47ef3b19..17a5420ae9 100644 --- a/packages/compact/compact/README.md +++ b/packages/compact/compact/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-compact +English | [中文](README.zh.md) + The **compaction seam**: an abstract `CompactService` (`ctx.compact`) defining WHAT compaction does — decide when history is too large and summarize an older range into a single surface node — without saying HOW. This package is the interface tier of the compaction capability, split so each concern evolves (and swaps) independently: diff --git a/packages/compact/compact/README.zh.md b/packages/compact/compact/README.zh.md new file mode 100644 index 0000000000..d98251649c --- /dev/null +++ b/packages/compact/compact/README.zh.md @@ -0,0 +1,82 @@ +# @deepseek-ai/dsh-compact + +[English](README.md) | 中文 + +**压缩 seam**:抽象 `CompactService`(`ctx.compact`)定义压缩做什么,即判定历史记录是否过大,并将较早范围摘要为单个表层节点,但不规定如何实现。 + +本包是压缩能力的接口层,因此各项职责可以独立演进(和替换): + +| 包 | 职责 | +|---|---| +| `@deepseek-ai/dsh-compact`(本包) | 接口:抽象服务 + `compact/*` 事件 + `CompactionResult` + 规范检查点源 + 工具配对边界 helper | +| `@deepseek-ai/dsh-compact-basic` | 后端:`ctx.tokenMeter` 压力 + token 预算保留 + `llm.stream()` 摘要 | +| `@deepseek-ai/dsh-tool-compact`(暂缓) | 面向模型的 `/compact` 工具,基于 `ctx.compact` 实现 | + +与 bash seam 不同,该接口依赖 `@deepseek-ai/dsh-session` 和 `@deepseek-ai/dsh-llm`。契约的动词基于 `Session` 定义,其输出使用 `ContentBlock` 词汇,因此无法在不指名这些包的情况下表达。这项对「接口只依赖 cordis」指引的偏离是有意的,并记录在 [压缩能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) 中。 + +## 服务 API(`ctx.compact`) + +两个方法都是**抽象方法**:触发策略、保留、事件顺序与摘要均属于后端。可复用的请求测量是独立服务 [`ctx.tokenMeter`](../../llm/token-meter/README.md),而非本接口的一部分。 + +| 成员 | 语义 | +|---|---| +| `compactIfNeeded(agent, trigger, signal)` | 为 `trigger: 'pressure' \| 'context-overflow'` 考虑自动压缩。压力触发可应用后端的阈值与保留尾部策略;已确认溢出可强制进行有效的平衡缩减。返回 `CompactionResult`,无安全范围时则返回 `null`。后端摘要请求是直接 `ctx.llm.stream()` 调用(不是 loop 步骤),因此每次调用在 `llm/stream` 处拦截。 | +| `compactRegion(start, end, agent, signal?)` | 强制将表层节点 `[start, end]`(包含两端 seq)从 `agent.session` 摘要为单个替换节点,其源为 `COMPACT_CHECKPOINT_SOURCE`。如果压缩已在进行、`start`/`end` 不是表层节点,或 `start` 在表层上位于 `end` 之后,则**抛出异常**。该范围是表层位置 span,不是数值 seq 区间:在之前的 replace 将新鲜高 seq 摘要节点放到已遮蔽范围的位置之后,表层顺序不再跟随 seq 顺序。 | + +`CompactionResult` 向调用方保留原始摘要与记账事件 seq,同时保留已遮蔽范围与 token 计量;其经漂移检查的形状位于 [压缩数据结构参考](../../../docs/core-data-structures/compaction.md#compactionresult)。 + +`compactIfNeeded` 要求必填 `signal`;`compactRegion` 的该参数可选。通过 `ctx.llm.stream()` 摘要的后端**必须** 将它转发到调用的 `GenerateOptions.signal`,因此 abort 或 fiber dispose 会停止进行中的摘要,不会留下越过取消时点继续运行的遗留模型调用。可以从所拥有会话的日志(当前开启的轮次)恢复 `compact/*` 事件所属轮次,因此后端从日志中标记该值,而不信任调用方提供的值。 + +## 工具配对边界 + +该接口导出 `toolPairingBalancedBefore(session, seq)` 与 `toolPairingBalancedAfter(session, seq)`,用于对齐和验证压缩边缘。安全边缘不会被尚未回答的 assistant 工具调用跨越。每个 helper 都会验证事件序列位于当前表层,并使用按表层顺序为每个 cut 缓存的 balance 返回答案。 + +每会话私有 cache 以 `session.surface.replaceGeneration` 与已处理表层配置项数为 key。generation 未变时,fold 只会扩展到尚未处理的尾部配置项;不含新表层配置项的仅日志追加不会读取事件,而 replace generation 会重建当前成员关系与 balance。事件 seq 缺失以及没有之前开启调用的 `tool/result` 会被拒绝为表层状态损坏。 + +## 表层契约 + +`SurfaceEventType` 是封闭联合:只有 `user/message`、`assistant/message`、`tool/result` 和 `steering/message` 可以携带 `surfaceOp`。因此 `compact/*` 事件**不能** 出现在表层上。成功压缩改为: + +1. 追加 `compact/start`(仅日志):获取锁; +2. 摘要该范围; +3. 追加 `compact/summary`(仅日志):溯源信息包括摘要、范围、已遮蔽 seq、token 数与提供方/模型调用 envelope; +4. 追加单个 `user/message`,其携带 `source: COMPACT_CHECKPOINT_SOURCE` 和包含摘要的 `surfaceOp: { op: 'replace', start, end }`:这是**本操作唯一的表层变更**; +5. 追加 `compact/end`(仅日志):释放锁。 + +表层变更(第 4 步)位于锁括号**内部**:`compact/end` 是最后一个事件,因此表层变更落地前绝不会释放锁。如果在 `compact/start` 与 `compact/end` 之间崩溃,会留下可检测的遗留锁(一个 `compact/start` 没有匹配的 `compact/end`),而不是虚假声称压缩已完成、但表层从未被遮蔽的 `compact/end`。 + +`deriveMessages()` 随后将摘要渲染为 user 角色消息,再跟上已保留节点。已遮蔽事件仍保留在原始日志中,因此回放具有确定性。 + +## 阻塞 + +压缩通过日志记录的锁串行化:`compactRegion` 会拒绝启动,条件是最后一个 `compact/start` 之后没有匹配的 `compact/end`。锁就是日志(不是内存 mutex),因此它能在回放后存活,持久化后端也可以在重新加载时检测遗留 `compact/start`。锁会括住**整个** 操作:摘要、`compact/summary` 溯源记录*以及* `user/message` 表层替换全部发生在 `compact/end` 之前,因此 `session/event` listener 即使在 `compact/end` 时触发,也绝不会看到锁已释放而表层变更仍在等待。基础后端会在摘要后重新验证已选表层:表层变更会导致拒绝,不相关的仅日志追加不会使替换失效。即使摘要抛出异常,也会追加 `compact/end`,因此失败绝不会将锁卡死。 + +## 事件 + +`compact/*` 事件通过 declaration merging 扩展 `SessionEventMap`(可合并扩展):它们是会话事件,不是 cordis `Events`,三者均仅存在于日志(不含 `surfaceOp`)。各事件 payload 与语义见生成的 [持久化日志事件目录](../../../docs/persistence-catalog.md)。 + +## 实现后端 + +继承 `CompactService`,实现 `compactIfNeeded` 与 `compactRegion`,再将子类作为插件加载:它会注册为 `ctx.compact`。每个成功后端都在替换 user 消息上使用 `COMPACT_CHECKPOINT_SOURCE`;`isCompactCheckpointSource()` 可在持久化或克隆后识别该标记,无需依赖后端身份。基于模板或模型的实现可以放在同级包中,不需更改调用方或共享 token meter。 + +## 模型体验 + +### 调用后端时的会话历史 + +#### 模型看到的内容 + +成功实现会用一个 user 角色摘要检查点替换较早表层范围,即一个 `user/message`,它携带 `surfaceOp: { op: 'replace', start, end }`;原始事件仍会记录,但不再出现在派生模型消息中。seam 本身不执行改写。 + +#### Token 影响 + +该接口不会直接产生 token。后端用一份摘要换取多个原本保留的历史 token,并保持近期尾部不变。 + +#### KV Cache 影响 + +成功的后端替换会使从第一个已遮蔽历史 token 起的复用失效;seam 本身不会改变请求。 + +## 已知限制与暂缓事项 + +- **尚无面向模型的消费方层**:`@deepseek-ai/dsh-tool-compact`(`/compact` 工具)已暂缓;只能通过直接 `ctx.compact` 调用或后端的自动 listener 进行压缩。 +- **部分单元溢出不在契约内**:平衡摘要压缩无法拆分一个不可分单元。当可移除的文本型工具结果体量较大时,可选剪枝配套服务仍可修复闭合工具对;无法压缩大型非工具节点,或不可剪枝剩余部分过大的工具单元。 +- **单独接近窗口大小的 envelope 不属于表层压缩工作**:压缩缩减派生历史,绝不缩减系统提示词、工具或会话前缀。 diff --git a/packages/context/README.i18n.yaml b/packages/context/README.i18n.yaml new file mode 100644 index 0000000000..f21e4bb973 --- /dev/null +++ b/packages/context/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: bc3237b98732c23e6a2b120e055f7713b91f9b7c +README.zh.md: b195a4c0b96b1f6f0b66bc6efa99a4a66b3c2fa2 diff --git a/packages/context/README.md b/packages/context/README.md index 933dd510ca..bc3237b987 100644 --- a/packages/context/README.md +++ b/packages/context/README.md @@ -1,5 +1,7 @@ # context/ — request-context extensions +English | [中文](README.zh.md) + Product plugins that add model-visible request context without defining a tool. `workspace-context` is included by the default `dsh-agent-spine-demo` bundle and can be disabled through bundle config; `time-context` is opt-in, while the standard TUI bundle composes `session-reference` explicitly. | Package | Role | ctx key | diff --git a/packages/context/README.zh.md b/packages/context/README.zh.md new file mode 100644 index 0000000000..b195a4c0b9 --- /dev/null +++ b/packages/context/README.zh.md @@ -0,0 +1,13 @@ +# context/:请求上下文扩展 + +[English](README.md) | 中文 + +这些产品插件无需定义工具,即可增加模型可见的请求上下文。`workspace-context` 包含在默认的 `dsh-agent-spine-demo` 组合包中,且可通过组合包配置将其禁用;`time-context` 需要选择启用,标准 TUI 组合包则会显式组合 `session-reference`。 + +| 包 | 职责 | ctx key | +|---|---|---| +| `session-reference/` | 其他会话当前表层的有界快照 | `ctx.sessionReferences` | +| `time-context/` | 持久的逐步骤当前时间与耗时上下文 | (无) | +| `workspace-context/` | `AGENTS.md`/`CLAUDE.md` 工作区上下文 loader | (监听 `agent/session-prefix` + `tools/post-execute`) | + +[`workspace-context` 决策记录](../../.agents/notes/implemented/feature/2026-06-24-workspace-context.md)解释了它的逐 agent/会话隔离与生命周期拆分。 diff --git a/packages/context/session-reference/README.i18n.yaml b/packages/context/session-reference/README.i18n.yaml new file mode 100644 index 0000000000..83155d4c0e --- /dev/null +++ b/packages/context/session-reference/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: c995256511742c193e064cf808fc89194b444974 +README.zh.md: e2e67cfee745c84d6c85e8792e53c50bf2648293 diff --git a/packages/context/session-reference/README.md b/packages/context/session-reference/README.md index 5ee3f34151..c995256511 100644 --- a/packages/context/session-reference/README.md +++ b/packages/context/session-reference/README.md @@ -1,5 +1,7 @@ # `@deepseek-ai/dsh-session-reference` +English | [中文](README.zh.md) + `ctx.sessionReferences` prepares bounded, read-only snapshots of other sessions as prompt-prefix context. It consumes `ctx.sessionQuery` and the backend-independent compact checkpoint marker; SQLite FTS is not required. The standard TUI bundle mounts it, while other hosts may call the service directly. ## Public API diff --git a/packages/context/session-reference/README.zh.md b/packages/context/session-reference/README.zh.md new file mode 100644 index 0000000000..e2e67cfee7 --- /dev/null +++ b/packages/context/session-reference/README.zh.md @@ -0,0 +1,50 @@ +# `@deepseek-ai/dsh-session-reference` + +[English](README.md) | 中文 + +`ctx.sessionReferences` 会把其他会话准备为有界、只读快照,作为提示词前缀上下文。它消费 `ctx.sessionQuery` 与后端无关的 compact 检查点标记;不需要 SQLite FTS。标准 TUI bundle 会装载它,其他宿主也可直接调用该服务。 + +## 公开 API + +- `listCandidates(agent, query?, limit?)` 会列出 `agent.id` 之外的会话,按 id 或 cwd 进行不区分大小写的筛选,再按同 cwd、无 cwd、其他 cwd 记录排序,同时保持每组内的 `listSessions()` 创建顺序。每个已选候选会话都使用最新的日志支持标题作为 mention label,并回退到会话 id;不搜索标题与消息主体。 +- `prepare(agent, content, references, signal?)` 会保留首次 mention 顺序、对 id 去重,并拒绝自引用或超过已配置不同源上限的情况。它会并行读取所有源,返回与输入脱离的内容,外加零个或一个聚合 `HookContext`。任何无效引用、读取失败、取消或预算失败都会在宿主调用 `followup()` 或 `steer()` 之前被拒绝。 +- `encodeSessionReferenceUri()` 与 `decodeSessionReferenceUri()` 实现 `dsh-session:<base64url(JSON.stringify(sessionId))>`,因此每个 JavaScript 字符串 id 都能精确往返。`formatSessionReferenceMention()` 发出 `@[label](uri)`,`parseSessionReferenceText()` 将 Markdown mention 或裸规范 URI 替换为可读的 `@label` 文本,并返回结构化引用。显式 Markdown mention 会拒绝每个格式错误的 URI;只当 scheme 后跟非空、符合 base64url 形状的 payload 时,裸文本才被视为引用,匹配但非规范的候选项仍会失败。空 scheme mention 或只含标点符号的 scheme mention 仍是普通讨论文本。 + +## 快照语义 + +准备阶段会对每个不同源调用一次 `ctx.sessionQuery.readSurface()`,入队后绝不重读。它仅投影折叠后当前表层中的直接 user `user/message`、直接 user `steering/message`、assistant 文本,以及 `user/message` 检查点;这类检查点携带规范 `dsh-compact` 源标记。对于已经包含烘焙前缀上下文的源提示词,投影只读取其对模型隐藏的显示内容,以防止快照递归传播。已遮蔽的压缩前事件、工具、reasoning、上下文、除已标记 compact 检查点外的插件生成 user 消息,以及未完成的 assistant chunk 均会被排除。因此,已压缩源贡献的是最新检查点与之后保留的会话,而非已恢复的遮蔽文本。 + +上下文源为 `{ kind: 'plugin', plugin: 'session-reference' }`,并携带 `placement: 'prompt-prefix'`。其元数据会记录版本 `1`、源 id 与 label、捕获 seq、是否存在 compact、已保留/已省略消息数、已省略 UTF-8 字节数与截断状态。AgentLoop 将快照、`## My request:` 分隔符和有效提示词写入同一个 `user/message` 或 `steering/message`;同一事件的模型隐藏 envelope 保留直接提示词与元数据,用于 UI 回放。后续源变更、压缩或删除都无法改变目标回放。 + +## 配置 + +| Key | 默认值 | 契约 | +|---|---:|---| +| `maxReferences` | `3` | 一条已准备消息中不同源会话的最大数量;必须不大于 `3`。 | +| `candidateLimit` | `50` | 返回给宿主的默认元数据候选数量。 | +| `maxReferenceBytes` | `65536` | 一个引用对象的最大序列化 JSON 字节数。 | + +保留会对每个源独立应用 `maxReferenceBytes`,保留 compact 检查点与最新消息,再丢弃较旧的非检查点单元,并使用 `dsh-retention` 头部/尾部截断和精确 UTF-8 省略通知。如果某个源的固定序列化字段无法容纳,准备会以 `SESSION_REFERENCE_BUDGET_EXCEEDED` 失败,而不返回部分上下文。 + +## 模型体验 + +### 引用会话背景 + +#### 模型看到的内容 + +模型会按此顺序看到一条 user 角色消息:`## Referenced sessions` 不受信任快照、`## My request:` 分隔符,随后是带可读 `@label` 的当前消息。警告禁止遵循快照中的指令、权限声明或工具请求,除非当前 user 重复这些内容。Label、cwd 值、id 与会话文本作为 JSON 在 `<referenced-sessions>` 标签中序列化;每个数据 `<` 都发出为无损 JSON 转义 `\u003c`,因此源文本无法拼出框定标签。 + +#### Token 影响 + +每条引用消息都会添加固定警告和最多三个序列化快照,每个快照都受 `maxReferenceBytes` 独立限制。精确快照会保留在目标历史中,直到目标压缩遮蔽或摘要它;源会话变更不会添加更多 token。 + +#### KV Cache 影响 + +组合快照与请求在目标消息边界处仅追加,并保留较早的可缓存历史。不同引用或源捕获内容只改变新后缀;后续目标压缩可能使从替换边界起的复用失效。 + +## 已知限制与暂缓事项 + +- **没有标题或全文发现**:候选会话只按会话 id 与 cwd 筛选,但已选行会显示最新标题。SQLite FTS 未来可以替换发现机制,而不改变 URI、快照或持久化契约。 +- **受信任调用方边界**:该服务假设宿主有权读取 `ctx.sessionQuery` 公开的每个会话;它不是面向模型的搜索工具。 +- **只投影文本**:不会在会话间传播非文本 user 与 assistant 块。 +- **没有实时链接**:引用是快照,不是 fork、恢复、订阅或源会话变更。 diff --git a/packages/context/time-context/README.i18n.yaml b/packages/context/time-context/README.i18n.yaml new file mode 100644 index 0000000000..8f0067c24b --- /dev/null +++ b/packages/context/time-context/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: db6d4e2cc9b68f94fe7cacd85c302c4242c88930 +README.zh.md: 06e13824109f76242aaae2d302e984a8598cbc98 diff --git a/packages/context/time-context/README.md b/packages/context/time-context/README.md index f4938982a1..db6d4e2cc9 100644 --- a/packages/context/time-context/README.md +++ b/packages/context/time-context/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-time-context +English | [中文](README.zh.md) + Opt-in durable context with the current zoned time and elapsed time sampled during model-request preparation. `dsh-agent-spine-demo` and shipped examples do not mount it. Decision record: [the durable time-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md). ## Config diff --git a/packages/context/time-context/README.zh.md b/packages/context/time-context/README.zh.md new file mode 100644 index 0000000000..06e1382410 --- /dev/null +++ b/packages/context/time-context/README.zh.md @@ -0,0 +1,70 @@ +# @deepseek-ai/dsh-time-context + +[English](README.md) | 中文 + +可选的持久上下文,包含模型请求准备期间采样的当前分区时间与已经过时间。`dsh-agent-spine-demo` 与已发布示例不装载它。决策记录:[持久 time-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md)。 + +## 配置 + +```yaml +- id: time-context + name: '@deepseek-ai/dsh-time-context' + config: + timeZone: Asia/Shanghai # optional IANA override; omit for the process zone + refreshIntervalMs: 60000 # optional; omit or set to 0 for every eligible attempt +``` + +省略 `timeZone` 时,插件会在加载时解析一次 Node 进程的系统时区。Node 遵循 `TZ`;如果没有该覆盖,时区由宿主或容器提供。显式 `timeZone` 必须是 IANA 标识符,并在插件加载时验证。 + +`refreshIntervalMs` 必须是非负安全整数。省略或设为 `0` 时,对每次信号尚未 abort 的合格步骤前尝试执行追加。正数值只会在会话没有早先 time-context 注入、墙上时间向后移动,或自最新注入起已经过至少相应毫秒数时执行追加。 + +## 时序语义 + +该插件会前置一个 `agent/pre-step` listener。需要注入时,它会追加一条注入的 `user/message`,通过 `agent.inject()` 完成,时机位于 `step/start` 和普通自动压缩之前,其源为 `{ kind: 'plugin', plugin: 'time-context' }`。被抑制的尝试不追加任何内容。 + +正间隔调度会扫描原始持久会话事件,查找最新的上述源 `user/message`,包括已被压缩遮蔽的 reading。因此,调度可以跨轮次和已恢复进程应用,不需要进程本地 cache 状态。它会降低追加频率与历史增长,但绝不移除现有 reading,且每个会话独立调度。 + +第 1 步从最新的前置模型可见消息起测量,包括开启轮次的提示词。后续步骤从同一轮次中前一个 time-context 事件起测量。两种基线都使用持久会话事件时间戳;墙上时钟向后移动时,已经过时间限制为零。如果第一步缺少基线,或者后续步骤因间隔抑制而没有较早的同轮次 reading,则报告 `unavailable`。 + +时间 reading 记录的是一次请求准备尝试,不是已提交步骤或已传输请求。因为 listener 首先运行,后续步骤前 listener 取消或使尝试失败时,该追加可能仍会保留。日志仅追加,该插件不执行回滚。 + +单独发布的 `./invariant` 配套模块会根据开启轮次、下一个步骤前位置、已经过时间基线与持久事件时间检查每个归因于插件的 reading。其渲染时间戳必须可解析,且不能晚于该事件;采样与追加之间的进程挂起不会使 reading 失效。 + +时间 reading 会保留在派生会话历史中,直到后续压缩遮蔽它。请求标头不含 time-context 状态。请求重建会在每个 `step/start` 处使用完整持久表层前缀,因此已传输请求无需与 reading 一一对应:失败的准备可能留下额外 reading,而间隔抑制可让请求复用现有历史,无需添加 reading。 + +## 模型体验 + +### 准备期时间上下文 + +#### 模型看到的内容 + +每次执行注入的准备尝试都会生成一条带源标记的上下文消息,包含下方两行。`<timestamp>` 是带数字偏移与 IANA 时区、形如 ISO 的本地时间戳;持续时间使用紧凑的整秒单位。正间隔可能使某次步骤尝试没有新 reading。 + +##### 第一步 + +```markdown +Time sampled while preparing turn <turn>, step 1: <timestamp> +Elapsed since the preceding model-visible message: <duration-or-unavailable>. +``` + +##### 后续步骤 + +```markdown +Time sampled while preparing turn <turn>, step <step>: <timestamp> +Elapsed since the preceding step context: <duration-or-unavailable>. +``` + +#### Token 影响 + +每条注入的两行消息都会累积,直到压缩遮蔽它。正间隔会减少添加;省略或设为 `0` 则会为每次合格准备尝试添加一条。 + +#### KV Cache 影响 + +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 配置项失效。 + +## 已知限制与暂缓事项 + +- **整秒显示**:时间戳与持续时间省略亚秒精度,尽管持久事件时间保留毫秒。 +- **会话事件基线**:已经过时间从持久追加时间戳起计算,而非客户端传输的原始发送时间戳。 +- **进程本地默认时区**:省略设置时,使用插件加载时捕获的 Node 进程 `TZ`、宿主或容器时区,而非远程 user 的时区;两者不同时,请配置显式 IANA 时区。 +- **压缩之间的历史成本**:省略设置或设为 `0` 会为每次合格准备尝试保留一条 reading,包括后续取消或失败的尝试;正间隔可以降低但无法消除该成本。 diff --git a/packages/context/workspace-context/README.i18n.yaml b/packages/context/workspace-context/README.i18n.yaml new file mode 100644 index 0000000000..2eb8fef513 --- /dev/null +++ b/packages/context/workspace-context/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 0edea95866bf606216b6c24d2667152a479f757a +README.zh.md: 0d5503dba2a816acf5fe7075278f98d31d769f48 diff --git a/packages/context/workspace-context/README.md b/packages/context/workspace-context/README.md index 67ab9b4245..0edea95866 100644 --- a/packages/context/workspace-context/README.md +++ b/packages/context/workspace-context/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-workspace-context +English | [中文](README.zh.md) + Per-session workspace instruction loading for `AGENTS.md`-compatible files. The plugin freezes the initial user-global and project instruction chain into the request prefix, then discovers nested files and reports later changes or removals through durable context messages after successful filesystem tool calls. ## Lifecycle diff --git a/packages/context/workspace-context/README.zh.md b/packages/context/workspace-context/README.zh.md new file mode 100644 index 0000000000..0d5503dba2 --- /dev/null +++ b/packages/context/workspace-context/README.zh.md @@ -0,0 +1,169 @@ +# @deepseek-ai/dsh-workspace-context + +[English](README.md) | 中文 + +为每个会话加载与 `AGENTS.md` 兼容的工作区指令文件。该插件会将初始 user 全局指令与项目指令链冻结到请求前缀中,随后发现嵌套文件,并在成功的文件系统工具调用后通过持久上下文消息报告后续变更或移除。 + +## 生命周期 + +基线会在每个 agent-loop 实例的 `agent/session-prefix` 上组合一次。它先读取 `$DSH_HOME/AGENTS.md`,随后针对项目根目录到 `agent.session.header.cwd` 的每个目录,先读取每个现有基础候选文件,再读取每个现有本地 overlay 候选文件。同一目录中,如果候选文件在去除首尾空白后字节完全一致,就会按已配置顺序折叠到最早候选文件,因此 `CLAUDE.md` 若只是复制同级 `AGENTS.md`,只会渲染一次。前缀放在所有派生历史之前,记录在 `EpochHeader.messagePrefix` 中,并为该 loop 实例逐字复用。因为插件在委托之前前置自身贡献,后注册的 skill catalog 会出现在工作区指令之后。 + +该插件还会监听 `tools/post-execute` 中成功的第一方 `read`、`write` 和 `edit` 调用。每次 touch 都会检查新达到的后代 scope 以及之前加载的每个 scope。每个已配置候选名称都是所在目录中的独立 scope:新出现的文件通过结果的 `additionalContexts` 附加;已改变文件追加替换;文件消失或成为同一目录中较早候选文件的重复项时,追加移除通知。原生调用与 Code Mode 子分派共享该路径:`run_code` 将每个嵌套上下文延迟到外层结果,因此 loop 仍会在工具调用/结果相邻关系完成后追加更新。这种发现跟随结构化文件系统活动,而不是 shell `cd`,因为每次本地 bash 调用都启动新 shell,解析任意 shell 语法也不可靠。 + +指令读取使用可选 `ctx.fs` 提供方。该插件不会静态注入 `fs`,因此没有提供方的产品树仍可启动,指令加载在提供方出现前不执行任何操作。它会解析每个候选文件并获取结果状态,因此会跟随最终组件 symlink 到其目标:指向常规文件的链接会加载目标内容,缺失路径或非文件目标(包括指向目录的链接)则已确认不存在。resolve 或 stat 异常会改为将该候选文件的 scope 标记为暂时不可用。前缀取消与动态工具取消会传播到解析、元数据探测与流式读取。文件加载后的提供方失败会视为暂时不可用,而非文件已删除的证据。 + +## 提示词形状 + +基线指令是仅请求的 user 角色前缀消息,使用熟悉的 system-reminder 模式框定: + +```md +<system-reminder> +The following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions. + +Instructions from: ~/.dsh/AGENTS.md + +... + +Instructions from: AGENTS.md + +... +</system-reminder> +``` + +新达到的 scope 使用持久注入 `user/message`(插件源): + +```md +<system-reminder> +Additional instructions from: packages/app/AGENTS.md + +These instructions apply to work under `packages/app`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions. + +... +</system-reminder> +``` + +同一文件的编辑以 `Updated instructions from: <path>` 开头,并说明使用新内容替代之前加载的内容。候选文件消失或成为同一目录中较早候选文件的重复项时,消息是 `Instructions removed: <path>`,后跟 `The previously loaded instructions from this file no longer apply.`。指令文件中的字面 `</system-reminder>` 文本会转义,因此文件内容无法关闭插件拥有的 frame。 + +该插件拥有完整 `<system-reminder>` framing,每个注入的 `user/message`(无论来自此插件还是其他插件)都会不加包装地逐字达到模型,成为 user 角色消息。 + +## 状态与刷新 + +模型可见文本不含隐藏状态标记。每个动态上下文事件改为携带 JSON 元数据,其中包含经版本化的 `{ action, scope, path, digest? }` 变更列表。每次相关工具 touch 时,插件会从可见会话事件重建已加载状态,并叠加一个短暂内存 pending 窗口,用于不可变顶层 `tools/result` 上存在但 loop 尚未追加的上下文。匹配的持久 `user/message` 会确认 pending 转换。如果所属 `step/end` 在匹配上下文进入日志之前到达,插件会清除 pending 转换及其版本快速路径,使下一次成功 touch 可以重新加载。嵌套 Code Mode 结果会在外层执行 token 下暂存 pending 变更,用于抑制同次运行中的重复项;外层结果会回滚该状态,再只重新提交经过外层策略的上下文。 + +路径与 SHA-1 内容 digest 都未变时,不会重复注入。每会话、每 scope 元数据 cache 只存储 `{ path, version, digest, trimmedDigest }`:当提供方的不透明 `FsVersion` 与有效可见状态都匹配时,对账会跳过内容读取;版本改变会在任何模型可见更新之前触发有界读取与 SHA-1 确认。`trimmedDigest` 是针对去除空白后内容的 SHA-1,也是每目录重复 key,因此较早候选文件与某个未更改文件的内容收敛后,后者仍可被移除。恢复可行,因为 SHA-1 状态持久化在会话日志中,而空的内存版本 cache 只会导致一次确认读取。压缩会在 scope 的上下文事件离开可见表层后重新启用它,即使缓存版本未变。移除是 tombstone,因此候选文件之后重新出现时会重新加载。只有在字节预算内实际渲染的模型可见变更才会进入元数据、pending 状态和版本 cache;已省略变更仍可在后续 touch 处理,而相同 digest 的版本刷新只更新元数据。 + +冻结基线自身不会在实例中途改写。其初始路径/digest map 保留为比较状态;下一次成功文件系统 touch 会追加任何基线替换或移除。恢复的 loop 会重新组合当前基线,并在前缀组合期间对账仍可见的动态 scope。没有文件 watcher,因此磁盘变更会在下一次成功 `read`、`write` 或 `edit` touch 时可见,也会在恢复 loop 组合前缀时可见。 + +## 配置 + +```ts +export interface Config { + dshHome?: string + projectRootMarkers?: string[] + maxBytes: number + maxSourceBytes?: number + instructionFileCandidates?: string[] + localInstructionFileCandidates?: string[] +} +``` + +`maxBytes` 必填,因此每个部署都必须显式选择提示词预算。`maxSourceBytes` 在渲染前限制每个源指令文件,默认为 1 MiB。`projectRootMarkers` 默认为 `['.git']`,`instructionFileCandidates` 默认为 `['AGENTS.md', 'CLAUDE.md']`。每个项目目录中的所有现有候选文件都会加载,在去除周围空白后与较早候选文件内容匹配的文件会被丢弃。因此,使用默认设置时,内容相同的 `AGENTS.md` 与 `CLAUDE.md` 只渲染一次(作为 `AGENTS.md`),真正不同的同级文件则同时应用。`localInstructionFileCandidates` 默认为 `['AGENTS.local.md', 'CLAUDE.local.md']`,会与同一目录的基础文件一起加载其现有 overlay(渲染在它们之后),并应用同一个每目录去重;空列表会禁用 overlay。两个列表的候选配置项都必须是同目录文件名,因此会忽略空配置项、`.`/`..` 以及包含 `/` 或 `\` 的配置项。 + +user 全局文件始终是 `$DSH_HOME/AGENTS.md`,没有本地 overlay;两个候选列表只控制项目 scope。`$DSH_HOME` 默认为 `~/.dsh`,已配置的 `~`、`~/...` 与 Windows 风格 `~\...` 前缀会基于操作系统 home 目录展开。非正数或非有限渲染预算会同时禁用基线与动态加载;已配置 `maxSourceBytes` 必须是正整数。 + +## 预算与有界读取 + +渲染会优先保留最具体的指令文件。它会先丢弃完整的较宽泛文件,再截断最具体文件,并发出可见 `Workspace instruction budget ...` 通知,其中指名已省略与已截断路径。渲染后字节数绝不超过 `maxBytes`。 + +即使提供方元数据省略大小,或文件在元数据探测后增长,指令内容仍会通过 `streamText()` 在 `maxSourceBytes` 下读取。超大文件会被忽略;在动态对账期间,它会暂时不可用,而不是被移除。该插件不保留进程级 cache,绝不缓存指令文本。其会话本地 scope cache 只将提供方版本用作快速失效信号;失效后,对有界读取计算的 SHA-1 仍是存储在结构化会话元数据中的跨提供方内容身份。 + +## 模型体验 + +### 基线会话前缀 + +#### 模型看到的内容 + +在每个 loop 实例的第一个请求中,模型会收到一条 user 角色前缀消息,其中按从宽泛到具体的顺序包含有界 user 全局指令与项目指令链。 + +##### 基线指令模板 + +```markdown +<system-reminder> +The following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions. + +Instructions from: ~/.dsh/AGENTS.md + +<user-global-instructions> + +Instructions from: AGENTS.md + +<project-instructions> +</system-reminder> +``` + +#### Token 影响 + +渲染后基线会被冻结,并在该 loop 实例的每个请求中重发。`maxBytes` 会限制完整消息,较宽泛文件在最具体文件截断之前被省略,空指令链不产生 token。 + +#### KV Cache 影响 + +由于基线已冻结,前缀在同一 loop 实例内保持稳定。新建或恢复的实例会重新组合它,因此指令、优先级、cwd、候选文件或字节预算变更可能使从第一个改变的基线 token 起的复用失效。 + +### 新发现的 scope 上下文 + +#### 模型看到的内容 + +成功的第一方文件系统调用达到更深目录后,下一个请求会包含一条保留的注入 `user/message`,其中包含新适用的指令文件。 + +##### 附加指令模板 + +```markdown +<system-reminder> +Additional instructions from: packages/app/AGENTS.md + +These instructions apply to work under `packages/app`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions. + +<nested-instructions> +</system-reminder> +``` + +#### Token 影响 + +每个已发现 scope 都会添加有界历史 token,直到压缩。可见会话状态与版本/digest 比较会抑制未更改内容,Code Mode 将同一消息延迟到外层 `run_code` 结果之后。 + +#### KV Cache 影响 + +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 配置项失效。 + +### 已改变或移除的指令上下文 + +#### 模型看到的内容 + +已改变文件会产生 `Updated instructions from: <path>` 加替换内容。消失或成为同一目录中较早候选文件重复项的候选文件会产生下方移除通知。 + +##### 移除通知 + +```markdown +<system-reminder> +Instructions removed: packages/app/AGENTS.md + +The previously loaded instructions from this file no longer apply. +</system-reminder> +``` + +#### Token 影响 + +每项已确认变更或移除都是一条受 `maxBytes` 限制的保留历史消息。提供方失败不添加消息,预算省略的更新仍可在后续文件系统 touch 中处理。 + +#### KV Cache 影响 + +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 配置项失效。 + +## 已知限制与暂缓事项 + +- **发现跟随结构化 fs 工具,而非 shell 导航**:更改目录的 `bash` 命令不会触发嵌套指令发现,因为 shell 语法与每次调用 shell 状态不是可靠的文件系统 seam。 +- **刷新由 touch 驱动**:没有 watcher;外部编辑会在下一次成功的第一方 `read`、`write` 或 `edit` 时可见,也会在恢复 loop 重新组合前缀时可见。 +- **候选语义有意保持简单**:不解释小写名称、`.claude/rules/` 与 `@path` import;项目 scope 默认加载 `AGENTS.local.md`/`CLAUDE.local.md` overlay,但 user 全局 `$DSH_HOME` scope 没有本地 overlay,其他自定义名称需要显式候选配置。 +- **每目录去重基于内容**:只有在去除首尾空白后字节完全一致时,才折叠同级候选文件。`CLAUDE.md` 若 symlink 到同级 `AGENTS.md`,会解析为相同内容,并像任何重复项一样折叠;从 `AGENTS.md` 漂移的独立实体副本则会与它一起完整加载。 +- **Symlink 指令文件会跨越信任边界跟随**:最终组件是 symlink 的候选文件会被解析并加载其目标,因此克隆仓库可以将树外文件内容呈现为较低权限的工作区指引(它绝不会覆盖 system、developer 或直接 user 指令)。加载不受信任仓库时,请用文件系统策略门禁或 OS 沙箱限制 `ctx.fs`。 +- **指令内容受限但不会摘要**:超出预算的宽泛文件会被省略,最具体文件可能被截断;该插件绝不请求模型压缩指令文本。 diff --git a/packages/cordis/README.i18n.yaml b/packages/cordis/README.i18n.yaml new file mode 100644 index 0000000000..1b70a52e70 --- /dev/null +++ b/packages/cordis/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: b3a70b07c57ae1a2e3dd975a64c840d90c5a84ad +README.zh.md: 5832310cfe14a299ac5998a28bcbbb500caf86b5 diff --git a/packages/cordis/README.md b/packages/cordis/README.md index c7c08bbb24..b3a70b07c5 100644 --- a/packages/cordis/README.md +++ b/packages/cordis/README.md @@ -1,5 +1,7 @@ # packages/cordis — the self-referential runtime toolset +English | [中文](README.zh.md) + Model-facing tools over the live cordis runtime the agent itself runs inside: inspect the loaded plugins and service surface, mount model-written plugins, and dispose them again. Design home: [the toolset Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). | Package | Role | ctx key | diff --git a/packages/cordis/README.zh.md b/packages/cordis/README.zh.md new file mode 100644 index 0000000000..5832310cfe --- /dev/null +++ b/packages/cordis/README.zh.md @@ -0,0 +1,9 @@ +# packages/cordis:自指运行时工具集 + +[English](README.md) | 中文 + +面向模型、作用于 agent(智能体)自身所在实时 Cordis 运行时的工具:检查已加载插件与服务接口、挂载模型编写的插件,以及再次释放这些插件。设计归档见[工具集 Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 + +| 包(package) | 角色 | ctx 键 | +|---|---|---| +| [`tool-cordis/`](tool-cordis/README.md) | `cordis_inspect`/`cordis_mount`/`cordis_unmount` 工具:读取运行时、在 `node:vm` 沙箱中求值模型编写的插件代码,并在同一个分组 fiber 下管理动态挂载 | 注册到 `ctx.tools` | diff --git a/packages/cordis/tool-cordis/README.i18n.yaml b/packages/cordis/tool-cordis/README.i18n.yaml new file mode 100644 index 0000000000..23535e1b06 --- /dev/null +++ b/packages/cordis/tool-cordis/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 022e25decad650e03aa621cfa2b3f33ccc8d9743 +README.zh.md: 99a79209a2a895ee8e11e3b7345a305da514ac1e diff --git a/packages/cordis/tool-cordis/README.md b/packages/cordis/tool-cordis/README.md index aabc893808..022e25deca 100644 --- a/packages/cordis/tool-cordis/README.md +++ b/packages/cordis/tool-cordis/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-tool-cordis +English | [中文](README.zh.md) + The self-referential cordis toolset: three model-facing tools over the live runtime the agent runs inside. Design home — sandbox semantics, mount lifecycle, cross-mount composition, the generated API catalog, standing decisions: [the toolset Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). ## What it does diff --git a/packages/cordis/tool-cordis/README.zh.md b/packages/cordis/tool-cordis/README.zh.md new file mode 100644 index 0000000000..99a79209a2 --- /dev/null +++ b/packages/cordis/tool-cordis/README.zh.md @@ -0,0 +1,87 @@ +# @deepseek-ai/dsh-tool-cordis + +[English](README.md) | 中文 + +自引用 cordis 工具集:三个面向模型的工具,操作 agent 所处的存活运行时。设计归属(沙箱语义、挂载生命周期、跨挂载组合、生成的 API 目录、既定决策)见[工具集 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 + +## 功能 + +- `cordis_inspect`:运行时的只读报告,包括服务、已加载插件列表、已注册工具、动态挂载表,以及目录支持的 `api`/`events` 参考。精确的 `name` 配合 `what: "api"` 或 `what: "events"` 可缩窄报告,并附上原始源代码 JSDoc。 +- `cordis_mount`:在 `node:vm` 沙箱中求值模型编写的 JavaScript(一个 async 函数的主体);代码必须 `return` 一个 cordis 插件,系统将其挂载在 `cordis-dynamic` 分组 fiber 下,并以 `dyn-<n>` 跟踪。 +- `cordis_unmount`:按 id 释放一项挂载,只在完全停稳后返回。 + +精确的面向模型 schema 见[生成的工具目录](../../../docs/tool-catalog.md)。 + +规范成功值分别为检查字符串、挂载 `{ id, pluginName, state, provides, waitingFor }`,以及卸载 `{ id, pluginName }`。原生 renderer 保留现有文本,因此程序可以使用 `mounted.id`,普通 Function Calling 仍会看到 `mounted dyn-1 (...)`。 + +## 信任立场 + +该沙箱隔离全局变量,但不是安全边界。Node 全局变量不存在,或会重定向到 `ctx.fs`、`ctx.web`、`ctx.bash` 等 Cordis 服务;写入 `globalThis` 的内容保持局部,但 host realm helper 使逃逸成为可能。已挂载插件收到不含框架内部机制的 façade,但获准服务仍会影响存活运行时。动态工具 schema 与 annotation 通过迭代式 JSON 克隆和 schema 规范化跨越 realm,因此有效的深层声明受内存而非调用栈限制;含 JSON 不可见 key 的 record,以及子类化或装饰过的 schema array,会在规范化前被拒绝。应当像对待 bash 访问一样对待该工具集;参见[设计与信任立场](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 + +## 配置 + +| 字段 | 默认值 | 含义 | +|---|---|---| +| `vmTimeoutMs` | `5000` | 挂载代码求值中同步部分的边界;async 主体可逃出该边界 | + +## 生成的 API 目录 + +`src/api-catalog.ts` 由 `scripts/gen-cordis-api.ts` 生成,使用与 [docs/cordis-catalog](../../../docs/cordis-catalog/services.md) 相同的 AST 遍历,并由 `pnpm run verify-cordis-api`(位于 `doc-sync` 中)实施新鲜度门禁,绝不可手工编辑。`cordis_inspect` 在调用时把该目录与存活服务 store 取交集。宽泛的 `api`/`events` 报告只渲染摘要与签名;精确 `name` 会选择保留的方法/事件 JSDoc,未知或未运行的服务目标会高声失败。 + +## 渲染 + +三个工具都渲染 `generic` 卡片(`read`/`execute`/`delete`);`cordis_mount` 以 `rawInput` 携带挂载代码。presenter 是 args 的纯函数;结果保留默认文本渲染。 + +## 导出形状 + +Namespace 插件:命名导出 `name`/`inject`/`Config`/`apply`,无默认导出([docs/postmortem/0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md))。 + +## 模型体验 + +### 工具 schema + +#### 模型看到的内容 + +该插件可见时,会话模型会看到生成的 [`cordis_inspect`、`cordis_mount` 和 `cordis_unmount` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-cordis)。 + +#### Token 影响 + +该工具视图中的每次请求承担固定 schema 成本。 + +#### KV Cache 影响 + +只要该工具视图不变,前缀就保持稳定。隐藏这些定义的 scope 或插件生命周期变更,可能使从第一个变化的 schema token 起的复用失效。 + +### 工具调用历史与结果 + +#### 模型看到的内容 + +检查会精确地用 `## <section>` 加换行及数据相关主体来拼接选中区段,各区段之间留一个空行。宽泛的 API/事件报告省略 JSDoc;`name` 配合 `what: "api"` 或 `what: "events"` 返回一个精确目标及其原始 JSDoc。挂载返回 `mounted <id> (plugin "<name>", state: <state>)`,并可在右括号前插入 ` — waiting for service(s): <names> (activates when provided)`。卸载返回 `unmounted <id> (plugin "<name>")`;未知 id 会变成 `Error: no dynamic plugin with id "<id>" (list mounts with cordis_inspect what:"dynamic")`。提交的挂载程序保留在 assistant 工具调用历史中。 + +#### Token 影响 + +检查输出与挂载代码取决于数据,并在压缩前重复发送;生命周期确认文本很短。 + +#### KV Cache 影响 + +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 配置项失效。 + +### 挂载后的后续请求 + +#### 模型看到的内容 + +已挂载插件可以注册工具、提示词贡献或监听器,改变其目标 scope 的后续请求;卸载会在完全停稳后移除这些贡献。 + +#### Token 影响 + +间接 token 影响等于已挂载插件的贡献,且只在挂载生命周期内持续。 + +#### KV Cache 影响 + +挂载或卸载提示词/工具贡献会改变后续请求前缀,并可能使从第一个变化的贡献起的复用失效;挂载集合不变时,前缀保持稳定。 + +## 已知限制与暂缓事项 + +- **沙箱只用于约束诚实代码,并非安全边界**:可以访问沙箱全局变量上的 host realm helper,因此挂载代码可以触达 Node;加载该插件时,应当像授予 bash 工具一样慎重(见 § 信任立场)。 +- **`ctx` façade 不公开 `effect()`**:挂载代码无法注册定制 disposer;`on`/`provide`/`tools.register` 已覆盖目前出现的每项挂载,受保护的 `effect` 会等待真实需求(`FIXME(sandbox-effect)`)。 +- **`vmTimeoutMs` 只限制同步求值**:async 挂载主体可逃出该边界;挂载代码没有 async 预算。 diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 37df1a65d3..1d1c6c3b00 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -946,10 +946,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'list(): Workspace[]', jsDoc: '/**\n * Synchronous workspace projection in durable registry order. Every\n * entity\'s `sessionIds` getter is already filtered by the startup/live\n * canonical-cwd header index; this method performs no persistence reads.\n * @returns a fresh ordered array of workspace entities.\n */', }, - { - signature: 'async touchSession(sessionId: SessionId): Promise<void>', - jsDoc: '/**\n * Move one accounted, cwd-validated session to the front of its workspace.\n * Ungrouped sessions and candidates filtered by the header check are\n * no-ops. The owning workspace\'s relative position never changes.\n * @param sessionId - Session whose activity was observed.\n * @returns resolution after the possible record write.\n */', - }, { signature: 'async resolveByPath(path: string): Promise<Workspace | undefined>', jsDoc: '/**\n * Resolve by canonical directory path without creating or mutating a\n * workspace. A missing path rejects during `realpath`; an existing unowned\n * directory returns `undefined`.\n * @param path - Existing directory path in any spelling.\n * @returns the workspace owning the canonical path, when one exists.\n */', @@ -2537,7 +2533,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'Workspace', - declaration: 'export interface Workspace {\n readonly id: WorkspaceId;\n readonly path: string;\n readonly title: string;\n readonly createdAt: string;\n readonly updatedAt: string;\n readonly sessionIds: readonly SessionId[];\n setTitle(title: string): Promise<void>;\n attachSession(sessionId: SessionId): Promise<void>;\n detachSession(sessionId: SessionId): Promise<void>;\n status(): Promise<\'ok\' | \'missing-dir\'>;\n}', + declaration: 'export interface Workspace {\n readonly id: WorkspaceId;\n readonly path: string;\n readonly title: string;\n readonly createdAt: string;\n readonly updatedAt: string;\n readonly sessionIds: readonly SessionId[];\n setTitle(title: string): Promise<void>;\n attachSession(sessionId: SessionId): Promise<void>;\n insertSessionBefore(sessionId: SessionId, beforeSessionId?: SessionId): Promise<void>;\n detachSession(sessionId: SessionId): Promise<void>;\n status(): Promise<\'ok\' | \'missing-dir\'>;\n}', }, ] diff --git a/packages/core/README.i18n.yaml b/packages/core/README.i18n.yaml new file mode 100644 index 0000000000..b5efad0494 --- /dev/null +++ b/packages/core/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 63ca0f7711c2c9deb193d5a9a574ff909a04602e +README.zh.md: a7310ff238eaa141ec53d1f2b5705397dbc04699 diff --git a/packages/core/README.md b/packages/core/README.md index f45705a3a4..63ca0f7711 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -1,5 +1,7 @@ # core/ — product API spine +English | [中文](README.zh.md) + The session log, system-prompt assembly, tool registry, agent vocabulary, and concrete loop that form the harness's default control spine. These are **product** packages — the stable surface plugins and consumers build against. | Package | Role | ctx key | diff --git a/packages/core/README.zh.md b/packages/core/README.zh.md new file mode 100644 index 0000000000..a7310ff238 --- /dev/null +++ b/packages/core/README.zh.md @@ -0,0 +1,20 @@ +# core/:产品 API 主干 + +[English](README.md) | 中文 + +会话日志、系统提示词组装、工具注册表、agent 词汇,以及构成 harness 默认控制主干的具体循环。这些是 **产品** 包(package),插件和消费方以其稳定表层为基础构建。 + +| 包 | 角色 | ctx 键 | +|---|---|---| +| `scope/` | 带作用域的上下文注册原语(作用域标签、按作用域筛选的分发) | (库,没有 ctx 键) | +| `session/` | 事件溯源会话日志与内存存储 | `ctx.sessions` | +| `system-prompt/` | 提示词段与工具 schema 组装注册表 | `ctx.systemPrompt` | +| `tools/` | 带作用域的工具注册表,以及前置策略、守卫、环绕分发、后置策略与最终结果观测 | `ctx.tools` | +| `agent/` | Agent 接口、实时注册表、进程本地发起方作用域、`agent/*` 事件词汇 | `ctx.agents` | +| `agent-loop/` | 实现公开 `Agent` 契约并拥有循环驱动器的具体插件 | `ctx.agentLoop` | + +`scope/` 是此处唯一的非服务包:它是不含依赖的库(`createScope`/`scopeOf`/`scopeTarget`),注册表和循环基于它实现按 agent 分域。它在模块图中位于 `session/` 和 `system-prompt/` 之下,正是为了让二者可以消费它而不形成环。 + +`agent-loop` 是 `agent` seam 的唯一具体实现,位于此处是因为它就是 harness 的默认产品循环。它在 `ctx.agents.withInitiator()` 中运行每个驱动器。扩展插件依赖 `agent`,即使需要发起调用的 Agent 也是如此;它们绝不直接依赖 `agent-loop`,因此循环保持可替换。 + +将这条主干接成可运行 agent 的默认组合位于 [`examples/agent-spine-demo`](../examples/agent-spine-demo/README.md):一个 bundle(组合包)插件,加载控制主干及所选默认能力(`timer` + `llm` + 会话 + 后备会话标题 + 系统提示词 + 工具 + agent + 不变式 + 本地[技能系列](../skill/README.md) + `tool-bash` + workspace 上下文 + `agent-loop`),并将 `agent-loop` 的 `agents` 列表作为自身配置转发。它位于 `examples/`,即开箱可运行的演示/参考组合包,而不是 `core/`:`core/` 交付可替换的主干组件,演示组合包则选定其中一种具体组合并添加前端入口。 diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml new file mode 100644 index 0000000000..41dccf8371 --- /dev/null +++ b/packages/core/agent-loop/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 3acf4d3828291d5f318306f2652e0d920c695675 +README.zh.md: 11ff8318813b1abd096e1a3549d389cbba88f12b diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 47250c354f..3acf4d3828 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -1,5 +1,7 @@ # dsh-agent-loop +English | [中文](README.zh.md) + THE concrete agent plugin and loop driver. Its package-internal implementation satisfies the `Agent` interface and drives the session/turn/step lifecycle. This is the only package in the harness that contains concrete loop logic. Everything else is an abstract service or a plugin against extension seams — new behavior goes into plugins, not here. diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md new file mode 100644 index 0000000000..11ff831881 --- /dev/null +++ b/packages/core/agent-loop/README.zh.md @@ -0,0 +1,129 @@ +# dsh-agent-loop + +[English](README.md) | 中文 + +唯一的实体 agent(智能体)插件与循环驱动器。其包(package)内部实现满足 `Agent` 接口,并驱动会话/轮次/步骤生命周期。 + +这是 harness 中唯一包含实体循环逻辑的包。其他所有内容要么是抽象服务,要么是针对扩展 seam 的插件:新行为应放入插件,而不是这里。 + +## 服务:`AgentLoop`(ctx 键:`agentLoop`) + +### 公开 API + +创建与恢复属于同一个受回滚保护的事务:构造私有会话、实体 agent 和带作用域的上下文;等待可选 setup;进入两个注册表;依次宣告 `session/created` 和 `agent/created`;发出 `agent/session-start`;此后才启动驱动器。Setup 接收完整的带作用域 `Context`,作为受信任的同进程组合代码,并且不得驱动尚未发布的 agent。普通的类型化身份与选项输入遵循只读契约以借用方式传入;seed 事件与会话元数据会跨越持久会话边界,因此系统会验证并快照它们。可选的 `AbortSignal` 只取消加载/setup/发布,并在返回的 handle 可见前分离。 + +调用方 fiber 与 AgentLoop 提供方共同拥有 agent。`AgentFactory.createAgent(ownerCtx, options)` 与 `resume(ownerCtx, options)` 显式接收调用方所有权,而工厂为 `sessions`/`llm`/`tools`/`systemPrompt` 保留自身的依赖上下文;这样,调用方可以只注入 `agents`,而不会缩减新 agent 的服务接口。调用方卸载、handle 释放或提供方卸载都会汇合到同一个记忆化的完全停稳边界。提供方关闭会同时等待资源 teardown,以及已经观测到停用的公开 create/resume 包装层,因此依赖消失后,任何 continuation 都无法继续发布。 + +每个 agent 与其会话共享一个由调用方选择的 `SessionId`,并假设它在全局唯一;意外的 UUID 冲突不属于受支持模型。两个使用同一 id 的并发操作都可以进行准备,但最终的 `enter()` 调用会裁决发布,所有失败方都会回滚各自的私有资源。每次 detach 都绑定到确切进入的对象,因此陈旧 disposer 无法移除之后出现的同 id 替代项。在同步创建通知期间请求的 detach 会等待该次分发退栈,从而保留 created/disposed 配对。Teardown 顺序为停止并 drain(包括尚未完成的空闲注入 flush)→ detach agent → detach 会话 → 撤销作用域;detach 完成后,即使私有作用域仍在完成清理,该 id 也可以复用。普通、不可 veto 的 `agent/*` 通知通过 `agentEvents(ctx, agent)` 发出;逐步骤组装通过 `assembleContextFor(agent)` 完成;轮次结束时的持久性检查点通过 `ctx.sessions.flush(session)` 完成。 + +- `ctx.agentLoop.create(id: SessionId, options?: AgentOptions, meta?: { cwd?: string }): Agent`:在确切共享的 agent/会话 id 下同步创建,不运行 setup,并随调用 fiber 释放。声明式配置把 `agents[].id` 视为稳定 label,通常会先生成 `${label}-session-<uuid>`,再调用此边界。应用也可以提供稳定且确切的 `sessionId`:首次使用时创建;重新挂载且持久化内容已存在时,则恢复已经实体化的历史。`resumeSessionId` 要求并加载现有的持久化 id,且与 `sessionId` 互斥。这样,默认的全新重启不会冲突,也无需保留第二个实时路由身份。 + +`AgentLoop` 还实现 `AgentFactory` seam,并通过 `ctx.agents.setFactory(this)` 注册自身,因此插件会通过接口 `ctx.agents` 创建/恢复 agent: + +- `ctx.agents.create({ sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise<AgentHandle>`:使用调用方提供的共享 id 以编程方式创建。它会等待尚未发布的 setup 事务,然后才返回;`meta` 携带 cwd/谱系/seed 边界元数据,`seed` 则在会话边界验证并快照持久值后,重建 fork 子级的前缀。`signal` 只在此 Promise 结算前生效。解析得到的 [`AgentHandle`](../agent/README.md) 拥有确切的 teardown。 +- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise<AgentHandle>`:通过 `ctx.sessionPersistence` 加载持久化会话(参见[会话持久化](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)),使用同一 id 注册 agent,重建历史,然后针对全新且尚未发布的 agent 作用域等待 setup,再执行受回滚保护的发布。轮次编号和派生历史从已加载日志继续。此操作要求存在会话持久化后端(不会硬注入,因此非持久化 demo 仍能工作;缺少持久化时,`resume` 会以明确错误拒绝)。`signal` 仅用于创建。返回 `AgentHandle`。 + +配置驱动的 `ctx.agentLoop.create()` 路径让循环 fiber 拥有其 agent(该路径会丢弃 handle)。对于以编程方式创建的 agent,handle 持有者是唯一面向消费方的 teardown 能力;AgentLoop 提供方卸载是一条独立的结构化 teardown 边,而不是向应用代码公开的另一个 handle。 + +### 注入的服务 + +`agents`、`sessions`、`llm`、`tools`、`systemPrompt`:全部 5 个接口服务。 + +### 不变量配套入口 + +可选的 `@deepseek-ai/dsh-agent-loop/invariant` 配套入口会向 `ctx.invariants` 注册请求重建。循环会把每个确切的冻结请求记录在 `dsh-llm` 拥有的进程本地身份集合中;随后,配套入口要求存在实时会话,并根据日志独立重建消息边界和折叠后的请求 header。即使调用方冻结直接的一次性调用,或为其附加会话 id,这类调用仍不属于该契约。 + +### 配置(Schemastery) + +```ts +interface Config { + maxParallelToolCalls?: number // default 10; 1 is serial + agents: Array<{ + id: string // required + provider?: string + model?: string + resumeSessionId?: string // load this persisted session instead of creating one + cwd?: string // optional workspace cwd for the fresh session + }> +} +``` + +通过配置创建的 agent 会自动启动。模型调用同时需要 `provider` 和 `model`;`agent/request` 可以在分发前补齐缺失的这一对值。`maxParallelToolCalls` 限制每个 agent 针对并行安全调用使用的滚动池,默认值为 `10`。`cwd` 仅应用于全新会话,而 `resumeSessionId` 保留持久化元数据。通过配置创建的 agent 使用部署 persona;编程式 setup 可以按 agent 遮蔽它。该插件提供逐 agent 的 `provider`、`model` 和 `cwd` 提示词变量;harness 身份与部署 persona 属于 `dsh-system-prompt`。 + +### 包内部实体驱动器 + +实体 `ReactLoopAgent` 适配器、其 `Inbox`、`runLoop`,以及绑定实例的发布/启动控制均为包内部实现。包根只导出插件/服务/配置契约,包导出映射不提供 `./src/*` 逃逸路径;生命周期拥有方通过 `ctx.agents` 创建 agent,而不是点名、构造或启动驱动器内部组件。一个准备完成的会话只能由一个实体驱动器认领;所有可观测行为都通过会话事件和 `agent/*` 事件分类体系发生。 + +`ReactLoopAgent.send()` 实现公开且完全解析的接纳路径。`followup()`/`queue()`/`steer()`/`inject()` 辅助方法会先解析每个可选字段,再委托给它;直接调用方通过 `ResolvedAgentInput` 提供必填的内容、来源、上下文、元数据、目标与唤醒事实。`followup()` 和 `queue()` 加入普通 FIFO,前者会唤醒空闲驱动器,后者则让其保持停驻。认领后的普通项是所属轮次的唯一消息;其上下文是提示词 waterfall(瀑布式事件)的默认附加上下文,只在通过接纳后实体化。缺少 placement 或 placement 为 `separate` 时,会追加一条独立注入的 `user/message`;placement 为 `prompt-prefix` 时,则把上下文、稳定的 `## My request:` 分隔符和有效请求写入同一条 `user/message`,其对模型隐藏的 envelope 保留显示内容和上下文描述符。waterfall 返回的允许决定具有权威性,因此,使用 `next()` 包装下游的监听器会保留下游 `content` 和 `additionalContexts`,除非它有意替换相应字段。后续普通项会等待前一普通轮次的检查点结算;取消、释放、提示词阻止或启动前失败则可能让上下文随消息一同丢弃。运行期间调用 `steer()`,或使用等效的 `send()` 路由,会在不分发 `agent/prompt-submit` 的情况下,把相同记录形态加入 steering FIFO;下一个检查点会对 `steering/message` 应用相同的独立或前缀 placement,但策略仍可以在另一步骤前停止。轮次及其检查点关闭后遗留的 steering 会连同上下文转为之后的排队输入,除非终止轮次策略、取消或释放将其丢弃。`inject()` 和不唤醒的下一步骤接纳要求上下文元组为空,绕过两个 FIFO 并直接追加持久上下文:轮次打开时,注入会在当前步骤执行 assistant 工具调用期间延后到一个 FIFO 中(成功批次把它放在所有结果之后,中断批次则在轮次关闭前 drain);空闲时,注入会包在一次性 `injection` 轮次中。每次 FIFO 入队都会发布 `agent/inbox/enqueue`;驱动器的认领会发布 `agent/inbox/dequeue`;`cancel()` 在不带 `keepInbox` 时会发布 `agent/inbox/discard`。格式错误的数据会在入队或追加前抛出。 + +### 循环生命周期(`loop.ts`) + +驱动器在其整个生命周期内拥有一个 agent,并在 `ctx.agents.withInitiator(agent, ...)` 内运行。包私有的编排入口点会恢复确切的 Agent,一次性派生 `agent.session`,并让操作局部的辅助函数捕获它,而不是通过浅层接口继续传递实体驱动器或每次操作的 `Session`。如果显式 `Session` 正是辅助函数的实际接口,该辅助函数会保留它;创建、持久化加载、未发布 setup、服务、worker、进程、持久化和 wire 协议则继续保留各自的显式身份。[agent 服务](../agent/README.md#initiating-agent-scope)规定传播、teardown 和分离工作规则。 + +每次提供方调用成功结束时,都会恰好追加一个 `assistant/message` 完成锚点,包括无内容调用和以 `max-tokens` 结束的调用。成功的 `agent/step-result` 存储其转换后内容;被拒绝的结果会先记录空内容,再继续抛出原始失败。该锚点保留确切的 chunk 溯源(流没有 chunk 时为 `[]`),并在用量可用时保留用量;空内容不会进入派生消息历史。 + +插件失败会结束当前轮次,而不是结束循环。只有最终适配器分发/迭代失败,以及带内的终止错误或中止结束原因,才进入 `agent/request-error`;中间件、结果处理、工具和 `agent/post-step` 仍属于普通轮次失败。失败步骤关闭后,恢复逻辑会接收确切的实时错误、不可变的提供方事实和不可变的先前失败。重试会在新的编号步骤中根据持久日志重建;成功会清除连续失败历史;耗尽后只在 `turn/end` 上记录一次结构化失败。AgentLoop 私下拥有一个取消持有者,其显式信号覆盖提示词策略、组装、每个步骤、模型与工具工作、恢复、continuation 和终止停止;它会在发布 `turn/end` 前立即退役该持有者,而驱动器可以在持久性 flush 期间继续保持 `running`。有效的 `cancel()` 会先发出仅存在于运行时的类型化 `user | parent` 原因,再清除待处理工作,并以协作方式中止该持有者;通知失败无法 veto 取消,通知观察方排队的工作会被清除,之后由中止观察方排队的工作属于下一轮次,空闲取消则不发出任何内容。持久 `turn/end` 仍使用粗粒度的 `aborted`;未分发的模型工具调用会收到合成的 `tool/call` 与 `ABORTED_BEFORE_DISPATCH` 结果对。释放会在终止分类中胜出;忽略信号的工作必须先结算,系统才能完全停稳。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)规定生命周期与竞态契约。终止 continuation 的停止决定在轮次关闭和持久性 flush 期间始终具有权威性。 + +在步骤内,独占调用形成屏障;并行安全调用使用有界滚动池,并在启动前重新分类。只有分发/主体会重叠。策略、持久结果和结果上下文仍保持模型顺序。中止会停止新调用、drain 已启动的结果,然后在轮次通过普通中止路径关闭前,drain 已接纳的批次上下文。 + +### 插件负责的内容 + +超出「调用模型、运行工具、重复」的所有内容,都属于监听事件分类体系的插件: +- 钩子与策略:相关的 `agent/*` 检查点,加上受守卫保护的 `tools/pre-execute` → `tools/execute` → `tools/post-execute` → 定义拥有的 `finalizeContent` → `tools/result` 流水线;确切事件签名与 mode 位于生成的[事件目录](../../../docs/cordis-catalog/events.md) +- 压缩(compaction):在 `agent/post-step` 上观测压力;在 `agent/request-error` 上处理规范上下文溢出 +- 瞬时模型恢复:`dsh-llm-retry` 监听 `agent/request-error`,使用有限且针对错误码的预算,并发出不进入表层的 `llm/retry` 状态事件 +- 沙箱、权限、计划模式:使用 `tools/pre-execute` 提供可扩展的拒绝/询问,使用 `tools.guard()` 提供单调拥有方策略,使用 `tools/post-execute` 处理结果决定,并使用 `tools/result` 进行最终观测 +- subagent:在循环外部实现为 `ctx.subagents` 提供方;进程内提供方使用 `ctx.agents.create()` 和拥有的 `AgentHandle` 进行 teardown,而通用的 [`ctx.tasks`](../../tasks/tasks/) 与 [`dsh-tool-subagent`](../../subagent/tool-subagent/) 负责后台收集。 +- 持久化:`session/event` + `session/flush` +- UI:`session/event`(assistant token 流、边界、工具活动)+ `agent/*` 控制事件(`agent/status`、`agent/created`/`agent/disposed`) + +## 模型体验 + +### 完整对话请求 + +#### 模型所见 + +每个步骤中,循环会发送针对该 agent 呈现的系统提示词、可见工具 schema、冻结的会话前缀和会话派生消息。它提供 `model` 与 `cwd` 变量值,但不添加固定文案。 + +#### Token 影响 + +每个步骤都会再次计入系统文本、schema 与前缀。逐 agent 作用域决定初始贡献,而权威组装 waterfall 可以改变最终请求,并使其监听器负责保持协议连贯。 + +#### KV Cache 影响 + +只有在同一提供方和模型路由下,系统文本、schema、会话前缀与先前历史保持逐字节相同时,才保持仅追加。携带 token 的组装改写或组合变更可能从第一个改变的请求 token 起使复用失效。 + +### 保留的消息历史 + +#### 模型所见 + +已接纳的 user 消息、assistant 消息、工具调用与结果、注入上下文和 steering 都会记录,并在后续步骤中发送。原始流分片、生命周期边界和其他仅写入日志的事件会被排除。 + +#### Token 影响 + +输入会随每条表层消息增长,直到压缩替换遮蔽较旧节点;包含多个步骤的工具轮次会在每个步骤重新发送累积的前缀与历史。 + +#### KV Cache 影响 + +普通历史增长仅追加,并保留可复用条目。接口替换或压缩会从第一个被遮蔽的历史 token 起使复用失效。 + +### 取消后未分发的调用 + +#### 模型所见 + +如果后续请求回放一个中止的步骤,取消所阻止分发的每个工具调用都有错误码 `ABORTED_BEFORE_DISPATCH`,结果文本为 `Error: tool call aborted before dispatch`。 + +#### Token 影响 + +每个跳过的调用都会在历史中保留一个固定错误结果,直到压缩将其遮蔽。 + +#### KV Cache 影响 + +仅追加;每个合成结果都位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 + +## 已知限制与暂缓工作 + +- **分类是一元的**:安全性取决于比较同级调用或资源的调用必须保持独占(参见[设计原理](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md))。 +- **配置 label 默认每次新建**:省略 `sessionId` 会在每次启动时创建全新的 `${id}-session-<uuid>`;确切的恢复或创建行为要求显式提供稳定的 `sessionId`,而 `resumeSessionId` 要求已有持久化历史。 +- **配置 agent 没有逐 agent persona 字段或 setup 钩子**:它们使用部署 persona;只有编程式 `ctx.agents.create()` / `resume()` 工厂选项支持带作用域的 persona/工具组合。 +- **没有内置轮次预算**:只要步骤包含工具调用或 steering,默认 continuation 就是 `continue`;限制失控轮次需要使用 `agent/turn-continuation` 强制停止插件。 diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml new file mode 100644 index 0000000000..c493c8d0e3 --- /dev/null +++ b/packages/core/agent/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: bbae91ff2f497208f1ce620e162c27968d666ac3 +README.zh.md: 95367b35a546c68491b9623daf45a54cd63f2731 diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 511ce32235..bbae91ff2f 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -1,5 +1,7 @@ # dsh-agent +English | [中文](README.zh.md) + Agent interface, registry, process-local initiator scope, and `agent/*` event vocabulary. Every plugin (UI, hooks, orchestrators) programs against the `Agent` handle defined here — it has zero loop dependency, so the loop is swappable. The optional `@deepseek-ai/dsh-agent/invariant` companion registers this package's agent-status transition checks with `ctx.invariants`. The root agent service does not load diagnostics implicitly. diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md new file mode 100644 index 0000000000..95367b35a5 --- /dev/null +++ b/packages/core/agent/README.zh.md @@ -0,0 +1,116 @@ +# dsh-agent + +[English](README.md) | 中文 + +Agent 接口、注册表、进程本地发起方作用域,以及 `agent/*` 事件词汇。每个插件(UI、钩子、编排器)都面向此处定义的 `Agent` handle 编程;它不依赖循环,因此循环可以替换。 + +可选配套包 `@deepseek-ai/dsh-agent/invariant` 会向 `ctx.invariants` 注册此包的 agent 状态转换检查。根 agent 服务不会隐式加载诊断。 + +## 服务:`AgentRegistry`(ctx 键:`agents`) + +跟踪实时 agent,并在异步驱动器工作中携带发起调用的 Agent,而无需导入具体循环包。 + +### 公开 API + +带作用域的注册表层:`Agent.ctx` 是 agent 的作用域上下文(`dsh-scope`,键 = 该 agent)。通过它注册工具/段/变量/监听器,只对该 agent 生效,并在释放时全部撤销。`agentEvents(ctx, agent)` 是普通 agent 主体操作的融合分发器(一次完成载体 + 注入主体);其通知 mode 会调用每个监听器,并同时收容同步抛出和返回 Promise 的拒绝。注册表生命周期对复用一个稳定路由载体。`assembleContextFor(agent)` 构建按 agent 的组装上下文(同时包含 `agent` + `scope`)。`installAgentLlmTarget(agentCtx, target)` 在提示词组装期间快照可变的提供方/模型选择,并将该对同时应用到一个步骤的提示词变量与请求路由。`CreateAgentOptions.setup(agentCtx)` 和 `ResumeAgentOptions.setup(agentCtx)` 在新建或恢复的 agent 尚未发布时,组合其带作用域的世界。Setup 是受信任、仅用于组合的同进程代码:只有创建完成后才能驱动 agent。 + +- `ctx.agents.register(agent: Agent): () => void`:记录一个 **已经构造完成** 的 agent。随调用 fiber 释放。 +- 高级有序生命周期:`enter(agent, owner): () => void` 强制 `agent.id === agent.session.id`,执行权威 ID 冲突检查,并在不通知的情况下插入;`owner` 显式记录实时创建方 agent 关系(根 agent 为 `undefined`),与持久会话谱系无关。`announce(agent)` 恰好发出一次 `agent/created`。创建监听器同步请求的 detach 会延后到该次分发结束;每次 detach 都会检查捕获的条目对象,因此陈旧能力无法删除后续使用同一 ID 的替代项。异步工厂使用这一拆分;普通插件使用 `register()`。 +- `ctx.agents.get(id: SessionId): Agent | undefined` +- `ctx.agents.isOwnedBy(id: SessionId, owner: Agent): boolean`:该确切实时条目是否通过父 agent 的作用域上下文创建;运行时所有权与持久会话谱系无关。 +- `ctx.agents.list(): Agent[]` +- `ctx.agents.roots(): Agent[]`:在没有所属 agent 上下文的情况下创建的实时 agent;带谱系的恢复会话仍可能是运行时根。 + +#### 发起方 Agent 作用域 + +`AgentLoop` 在发起方边界内运行每个具体驱动器的完整生命周期。并发驱动器彼此隔离:子驱动器的 continuation 携带子 agent,而 `withInitiator()` 返回后,父 continuation 立即重新取得父 agent;drain 跟踪持续到子驱动器的 Promise 结算。创建、持久化加载和未发布 setup 位于子边界之外,因此由父 agent 发起的 setup 会继承父 agent,而 `agentCtx.agent` 显式标识子 agent。 + +- `ctx.agents.currentInitiator(): Agent | undefined`:读取继承的发起方,不要求其存在。 +- `ctx.agents.requireInitiator(): Agent`:读取发起方,缺席时抛出 `no initiating agent is active`。 +- `ctx.agents.withInitiator(agent, operation)`:使用一个确切 Agent 运行,并保留操作的确切同步值或 Promise。 +- `ctx.agents.withoutInitiator(operation)`:对无关的进程本地工作隐藏继承的发起方。 + +该作用域携带 `Agent` 本身,并且只在进程内有效。环境中的身份既不是存活证明,也不是授权;在服务、worker、进程、持久化和 wire 边界,显式 Agent 字段仍是权威来源。Teardown 会拒绝新边界,允许注入的依赖方和返回 Promise 的边界 drain,然后禁用底层 `AsyncLocalStorage`;未返回的工作仍归将其分离的子系统所有。如果某个边界继承的异步链开始卸载一个拥有它的 Cordis fiber,该嵌套边界链会从 drain 中释放,使卸载不会等待自身;其 continuation 会在 teardown 后观察到已释放的服务。详细边界与 teardown 契约由[发起方作用域决策](../../../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)拥有。 + +#### 工厂 seam(创建) + +Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供,并通过 `setFactory` 注册。这样,创建功能留在 `dsh-agent` 接口上,消费方(UI、ACP 桥接层)可以面向 `ctx.agents` 编程,而不依赖具体循环包。注册表会把已经 traced 的 Service 规范化为具体目标,并通过调用方上下文重新 trace 每次调用;这既避免嵌套 Cordis shadow,也会把显式、绑定调用方的 `ownerCtx` 传给普通工厂。 + +- `ctx.agents.setFactory(factory: AgentFactory): () => void`:注册创建工厂(循环在构造时调用)。第二个工厂会导致抛出;释放时清空槽位。 +- `ctx.agents.create(options: CreateAgentOptions): Promise<AgentHandle>`:创建会话和 agent,在不发布的情况下等待可选 setup,然后通过最终的 `SessionStore.enter()` 与 `AgentRegistry.enter()` 检查发布。不支持并发创建同一 ID:多个操作可以进行准备,但只有一个能进入;每个失败方都会回滚其私有作用域/会话/驱动器。可选且只用于创建的 `signal` 会取消未发布的 setup,并在返回 handle 前分离;之后的取消使用 `handle.dispose()` 或 `agent.cancel()`。发布包含在回滚范围内,回滚期间每条已交付创建边都会成对处理。未注册工厂时拒绝。 +- `ctx.agents.resume(options: ResumeAgentOptions): Promise<AgentHandle>`:加载持久化会话([会话持久化](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)),创建新的未发布 agent 作用域,等待可选 setup,并使用相同的最终进入发布序列。其可选 `signal` 同样只用于创建。未注册工厂或未配置会话持久化时拒绝。 + +`AgentHandle = { agent: Agent; dispose(): Promise<void> }`。Disposer 是一项 **消费方能力**;仅持有裸注册表条目的观察方不能 teardown agent。调用方 fiber 和已注册工厂提供方是结构化共同拥有者:调用方卸载会强制结构化所有权,而工厂卸载必须停止旧实例,因为它们的作用域依赖表层属于该提供方。任意拥有者调用 `dispose()` 都会到达同一个记忆化静默边界:它停止循环,`await` 循环退出以及每次未完成的空闲注入刷新(而不只是 `disposed` 状态翻转),注销 agent,从存储中移除其会话,最后撤销其作用域世界。该顺序会在分离会话前捕获 agent 启动的每个 `session/flush`,并让作用域监听器存活到这些检查点完成。`ctx.agents.get(id)` 仍返回裸 `Agent`;ACP 桥接层与进程内 subagent 后端持有消费方 handle,而配置创建的 agent 已由循环 fiber 拥有。 + +### 实时事件 + +`dsh-agent` 声明实时 `agent/*` 协调词汇,使插件不必依赖具体循环。确切签名、分发 mode、作用域筛选规则与 payload 契约位于生成的 [Cordis 事件目录](../../../docs/cordis-catalog/events.md);[架构轮次流](../../../docs/architecture.md#turn-flow) 展示它们与持久会话事件的相对顺序。 + +生命周期边有两个重要的本地注意事项。`agent/created` 在作用域 setup 之后、会话与 agent 注册表条目都存在之后运行。Setup 是受信任、仅用于组合的代码;紧随其后且不可 veto 的 `agent/session-start` 通知是第一个受支持的启动注入点。`agent/disposed` 始终表示确切 agent 已离开注册表。AgentLoop 在其驱动器静默后发出该事件,而有序 teardown 此时可能仍在分离会话并撤销作用域;直接注册的自定义 agent 自行拥有任何更强的驱动器顺序契约。 + +大多数拦截点都是返回 seam 专属决策的协作式 waterfall。轮次作用域的异步 seam 接收一个显式 `AbortSignal`,其中 `signal` 紧邻 waterfall 最终的 `next`;监听器可以配合,但不得将它保留为控制另一轮次的权限。信号在终止策略执行期间仍是权威来源,并在发布 `turn/end` 前立即退役,因此终止观察方与之后的持久性刷新无法取消已完成的轮次工作。`agent/pre-step` 与 `agent/post-step` 是步骤持久工作前后的串行检查点,而 `agent/request-error` 是失败模型请求的恢复 waterfall:失败步骤关闭后,它接收确切错误、规范化失败事实、不可变的先前重试事实和信号;重试会打开一个新的编号步骤。`agent/turn-stop` 是终止串行 fold:它在普通 continuation 与 steering fold 之后运行;返回的停止会持续到轮次关闭和刷新,因此之后的 steering 不能创建额外步骤或轮次。普通排队提示词保持原样。有效的广义取消会先发出只观测的 `agent/cancel-requested` 及其解析后的类型化原因,再清空队列并中止;通知失败会被收容,不能 veto 停止。信号生命周期由[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)拥有;作用域分发与终止结算由 [agent 作用域 runtime 设计 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way)拥有。 + +`PromptDecision.additionalContexts` 是数组,因此每个上下文都保留自己的来源、元数据和放置位置。`SendOptions.contexts` 将同一形状绑定到一条排队消息,并且发生在提示词拦截前:默认允许决策会继续携带它,而被阻止的提示词不记录上下文。缺席或 `separate` 放置会写入独立注入的 `user/message`(plugin/goal 来源);`prompt-prefix` 会把上下文、`## My request:` 分隔符和有效提示词写入一条 `user/message` 或 `steering/message`,其对模型隐藏的 envelope 会保留直接提示词与上下文描述符供人类回放。包装下游允许决策的监听器会保留其 `content` 与 `additionalContexts`,除非有意替换任一字段;返回的允许决策是权威来源。`ContinuationDecision` 原因更窄:它成为不附带上下文元数据的 `steering/message`。 + +轮次和步骤边界以及模型 token 流是持久 `session/event` 事实,而不是镜像的 `agent/*` 通知。消费方从会话 feed 读取 `turn/*`、`step/*` 和 `assistant/chunk`;工具策略与结果观测属于 [`dsh-tools`](../tools/README.md) 记录的完整流水线。 + +### Agent 接口(`types.ts`) + +`Agent` 是结构化接口。`followup()`、`queue()`、`steer()` 与 `inject()` 指名常见调用方意图;调用方已经拥有确切路由事实时,`send(ResolvedAgentInput)` 公开同一接受路径([决策](../../../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md))。每个 `ResolvedAgentInput` 字段均为必填,其可辨识联合会排除附带上下文的非唤醒下一步骤注入。FIFO 接受会返回不透明 `AgentMessageId`,由该条目的 `agent/inbox/enqueue`/`dequeue`/`discard` 事件携带。驱动器会在通知和入队前,把内容、已解析来源、附带上下文与对模型隐藏的元数据快照为一条已分离、深度冻结的无损 JSON 记录;无效数据同步抛出。辅助方法应用默认值:在省略 `options.source` 的 `followup()`、`queue()` 或 `steer()` 调用中,会将直接人类输入声明为 `{ kind: 'user' }`,因此每个非人类生产方都要标记自身内容。 + +- `agent.followup(content, options?)`:将一条独立 FIFO 消息作为自己的轮次排队,并唤醒驱动器。接纳后,独立上下文成为注入的 `user/message` 事件,而 prompt-prefix 上下文会在同一 `user/message` 中写到有效请求之前;阻止或替换默认附加上下文决策可以丢弃它们。轮次原理由 [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)拥有。 +- `agent.queue(content, options?)`:将相同的普通消息排队,但不唤醒空闲驱动器。单独的排队项会让 `whenIdle()` 保持已解析,并在下一条唤醒消息前一并处理。 +- `agent.steer(content, options?)`:运行时为下一个检查点排队 steering,且不分发 `agent/prompt-submit`;空闲时创建会唤醒的普通轮次。附带上下文留在同一冻结记录中;独立上下文紧跟 steering 事件追加,prompt-prefix 上下文则写入该事件。二者都能在迟到 steering 转为排队输入时保留,并随消息在取消或终止丢弃时消失。策略仍可以在另一步骤前停止;轮次关闭及其检查点之后,剩余 steering 会成为稍后的排队输入,除非终止轮次策略、取消或释放将其丢弃。 +- `agent.inject(content, options?)`:接受已分离的会话内上下文而不运行模型;下一次请求会看到其 `user/message`(默认 plugin 来源),其中 `content` 逐字渲染为 user role 消息。`InjectOptions` 有意不提供附带上下文。`options.meta` 持久化不透明 JSON 状态,但不渲染。轮次打开时,注入加入该轮次;当前工具批次执行时会延后 FIFO,如果执行被中断则在轮次关闭前 drain。空闲时,它会被包在一次性 `injection` 轮次和持久性检查点内([轮次包围不变式](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md))。注入绕过 FIFO,不发出 `agent/inbox/*` 事件。 +- `agent.send(input)`:接受完整指定的路由,不应用辅助方法默认值。`next-turn` 指向普通 FIFO;带 wakeup 的 `next-step` 指向 steering,并在空闲时回退为会唤醒的普通轮次;不带 wakeup 的 `next-step` 是注入,且要求 `contexts: []`。调用方没有元数据时也要显式提供 `meta: undefined`。 +- `agent.cancel(cause?, options?)`:取消活动轮次,并在未设置 `options.keepInbox` 时取消全部待处理工作:省略原因表示 `{ kind: 'user' }`;TypeScript 把调用方限制在 `user | parent` 联合中,活动持有者会在中止前把其判别字段复制为已分离、冻结的信号原因。有效调用会在清除排队与 steering 工作前,随原因发出 `agent/cancel-requested`;丢弃项在 `agent/inbox/discard` 上报告,观察方可以同步状态,但不能 veto 取消。`keepInbox: true` 会中止轮次,但保留排队与 steering 项(不丢弃,且不删除尚未开始的工作)。同进程类型化 seam 不会为无类型调用方添加运行时校验或兼容回退。重复取消活动轮次时,首个信号生效;空闲取消是安全空操作,不发通知。ACP 映射到 `user`,进程内父传播映射到 `parent`。原因只存在于运行时;持久 `turn/end` 保持粗粒度的 `aborted`。 +- `agent.whenIdle()`:agent 从 `running` 结算后达到静默时解析(idle ⇒ 立即;disposed ⇒ 等待循环退出)。这是非拥有者的静默观测钩子:观察工作结算,但不 teardown agent。Teardown 独立存在;生命周期拥有者通过 `AgentHandle.dispose()` 停止并注销,并直接等待循环退出。 +- `agent.session`、`agent.status`、`agent.options`、`agent.id` + +`running` 描述驱动器范围的 drain 区间,而不是轮次仍打开的证明;它可以覆盖轮次关闭、持久性检查点和连续的排队轮次。 + +### 扩展点 + +- Agent 创建:`AgentLoop.create()` 是具体配置路径实现(位于 `dsh-agent-loop`),程序化消费方则通过 `ctx.agents.create()`/`ctx.agents.resume()` 创建或恢复有所有权的 agent。替换循环时,应实现 `Agent` 并通过 `ctx.agents.register()` 注册。 +- 事件监听器:全部 `agent/*` 事件都在此处声明,不需要依赖循环包。 +- Subagent 委派不是 `Agent` 方法;提供方通过工厂 seam 创建或驱动普通 handle,因此委派传输留在核心 agent 接口之外。 + +## 模型体验 + +### 用户、steering 与注入消息 + +#### 模型所见 + +四个意图辅助方法与完整解析的 `send` 路径会向所属会话提供输入。`agent/prompt-submit`、`agent/session-prefix` 和其他已声明事件让插件能够阻止提示词或添加请求材料;此接口本身不贡献固定文案。 + +#### Token 影响 + +已接受内容成为保留历史或重复会话前缀;被阻止内容不贡献请求 token。大小取决于调用方与插件。 + +#### KV Cache 影响 + +已接受历史与 steering 只追加;被阻止的提交不发送请求。会话前缀在循环实例内保持稳定,而新建或恢复的实例可能建立不同前缀。 + +### Agent 作用域的请求组合 + +#### 模型所见 + +通过 `agent.ctx` 进行的注册可以遮蔽提示词段或工具,也可以在未发布 setup 期间安装仅适用于该 agent 的拦截器。 + +#### Token 影响 + +此包自身不增加 token;带作用域贡献只影响该 agent,并在释放时消失。 + +#### KV Cache 影响 + +只要 agent 的作用域注册不变,前缀就保持稳定。改变提示词段、工具定义或请求监听器的 setup 或 reload,可能从第一个受影响的请求 token 起使复用失效。 + +## 已知限制与延后工作 + +- **发起方作用域只存在于进程内**:worker、子进程、HTTP、持久队列和重启会显式物化所需身份。 +- **环境身份可能比存活状态更久**:消费方在生命周期敏感工作前,仍要检查 `agent.status`、取消状态和所属能力契约。 +- **委派以外的 agent 间通道**:共享状态、流式子输出和后台/轮询语义仍在当前同步 `ctx.subagents` seam 之外。 +- **`agent/session-start` 不能为启动设置门禁**:它仍是同步且不可 veto 的通知;必须在发布前完成的异步组合属于工厂的 `setup(agentCtx)` 事务。 +- **`cancel()` 默认清空 inbox**:它会中止正在处理的轮次以及排队和 steering 工作;`cancel(cause, { keepInbox: true })` 只中止轮次并保留待处理项。仍不存在只中止步骤、同时让正在处理的轮次继续运行的操作([停止表层 Agent Note](../../../.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.md))。 +- **`HookContext` 恰好携带一个 `MessageSource`**:多个插件合并到一次工具调用上的贡献会归入一个来源;无法表示混合来源。 +- **`SessionStartSource` 预留 `'clear'`/`'compact'`,但还没有发出方**:在驱动子系统落地前,只会出现 `'startup'`/`'resume'`(`TODO(compaction)`)。 diff --git a/packages/core/scope/README.i18n.yaml b/packages/core/scope/README.i18n.yaml new file mode 100644 index 0000000000..734e6a52a7 --- /dev/null +++ b/packages/core/scope/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 4f32573779a15e8c34b4936bfe75549dfc86d9f6 +README.zh.md: 96c7d740bd3945558413e5a2763ddaaea5b85f2c diff --git a/packages/core/scope/README.md b/packages/core/scope/README.md index cfc09c1dca..4f32573779 100644 --- a/packages/core/scope/README.md +++ b/packages/core/scope/README.md @@ -1,5 +1,7 @@ # dsh-scope +English | [中文](README.zh.md) + Scoped registration primitive. `createScope(ctx, key)` creates a tagged Cordis context whose backing fiber owns every registration made through it. `scopeOf(ctx)` reads the tag, and `scopeTarget(base, key)` routes scoped events to listeners with the same key while leaving unscoped listeners global. The agent loop creates one scope per live agent, but the mechanism is key-agnostic so lower-level packages can use it without depending on agents. ## Public API diff --git a/packages/core/scope/README.zh.md b/packages/core/scope/README.zh.md new file mode 100644 index 0000000000..96c7d740bd --- /dev/null +++ b/packages/core/scope/README.zh.md @@ -0,0 +1,36 @@ +# dsh-scope + +[English](README.md) | 中文 + +带作用域的注册原语。`createScope(ctx, key)` 创建一个带标签的 Cordis 上下文,其底层 fiber 拥有通过该上下文进行的每项注册。`scopeOf(ctx)` 读取标签;`scopeTarget(base, key)` 将带作用域的事件路由到键相同的监听器,同时让无作用域监听器保持全局可见。Agent loop 为每个实时 agent 创建一个作用域,但该机制与键的具体含义无关,因此底层包无需依赖 agent 即可使用。 + +## 公开 API + +- `createScope(ctx: Context, key: ScopeKey): Scope`:在 `ctx` 的 fiber 下创建作用域。可以同步使用(effect 收集受 uid 门禁约束;服务解析会沿创建该作用域的插件依赖表层继续查找)。同进程、带类型的键受信任;处于非活动状态的创建上下文仍会通过 Cordis 失败(`INACTIVE_EFFECT`)。 +- `Scope.ctx`:带标签的上下文。通过它进行的注册既具备作用域可见性,也服从作用域生命周期。派生上下文(一次 `extend`、挂载于其下的 fiber)继承标签;嵌套作用域会遮蔽外层标签(最近的标签生效)。 +- `Scope.rawDispose`:底层 fiber 的原样 Cordis disposer。组合式(generator)effect 会 yield 此函数,从而把作用域 teardown 嵌套在该 yield 位置(Cordis 按函数标识去重嵌套 effect;yield 一个包装函数会使作用域 teardown 成为并行的同级操作)。 +- `Scope.dispose(): Promise<void>`:通过作用域进行的每项注册所共用的幂等静默边界。竞态调用或重复调用会等待同一次 teardown;即使 `rawDispose` 先调用了底层单次 Cordis disposer 也是如此。 +- `scopeOf(ctx: Context): ScopeKey | undefined`:上下文或其任意派生上下文携带的标签;`undefined` 表示上下文全局。 +- `scopeTarget(base: T, key: ScopeKey | undefined): Scoped<T>`:为按作用域筛选的事件构造不透明分发 `thisArg`。它把 `base` 现有的 `Context.filter` 与作用域谓词组合起来(无标签监听器 ⇒ 放行;有标签监听器 ⇒ 仅当标签 === key 时放行;`key === undefined` ⇒ 仅放行无标签监听器)。载体只包含路由状态;真实主体由事件参数携带。带 `{ global: true }` 的监听器绕过筛选(Cordis 语义)。 +- `Scoped<T>`:编译期不透明载体 brand。按作用域筛选的事件要求它作为 `this` 类型,因此使用裸主体分发会产生编译错误。类型参数记录主体类型,但不公开其属性。 +- `isScopeCarrier(value)`/`carrierKeyOf(value)`:运行时载体标记,开发不变式使用它们断言每次按作用域筛选的分发都携带载体,而且载体键与参数所指名的主体一致。 +- `ScopeLayer`:一个注册表的完整全局贡献或精确作用域贡献的聚合契约;`isEmpty()` 控制带作用域层的回收。 +- `ScopedLayers<L>`:拥有一个立即创建的全局层和按需创建的精确作用域层。`peek()` 从不创建;`merge()` 物化按插入顺序排列的具名遮蔽项;`effect()` 从同一上下文推导可见性与所有权,同时返回原样 Cordis disposer。 +- `NamedEntries<V>`:按插入顺序排列的具名存储,调用方拥有重复项诊断、查找,以及一个非空表世代内的实时迭代。表清空后,现有迭代器与后续插入项脱离;`insert()` 返回幂等的精确条目撤销函数。 +- `AnonymousEntries<V>`:按插入顺序排列的匿名存储;唯一内部键使相同值仍作为独立注册存在。它使用相同的清空世代迭代器边界;`append()` 返回幂等的精确条目撤销函数。 + +可选配套包 `@deepseek-ai/dsh-scope/invariant` 拥有该运行时断言。它使用生成的 `scoped-events.generated.ts` 解析器映射,要求每个已声明的带作用域事件都携带载体;当 payload 公开路由主体时,还要求主体与载体键标识相同。基于 Program 的生成器根据事件声明和真实的 `scopeTarget(base, key)` 调用生成该映射。 + +## 设计契约 + +注册上下文同时决定可见性和所有权,防止注册在一个作用域中可见、却随另一个作用域释放。作用域用于路由受信任的同进程插件;它们不是沙箱或权限边界。原理与明确排除的安全目标见 [agent 作用域 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals)。 + +感知作用域的服务会定义具体 `ScopeLayer`,聚合各自不同的表与领域辅助函数。`ScopedLayers.effect()` 接受一个返回同步撤销函数的同步动作,在可选通知前安装该撤销函数,并且只有在完整聚合为空时才回收精确作用域层。`notify` 默认为 `true`;所提供的回调拥有决定观测方失败是抛出还是受控的职责。`EntryValues` 保持内部可见;存储类从包根而非 `/store` 子路径导入;共享存储不定义注册表专属的筛选或迭代策略。详见[共享作用域层存储 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md)。 + +交出带作用域的上下文,也会交出创建该上下文的插件所能解析的服务表层(解析会沿创建者 fiber 的依赖链,而非持有者的依赖链行进),因此应由具备这些带作用域注册所需依赖的插件来创建它。 + +## 已知限制与延后工作 + +- **只有感知作用域的表层才会隔离状态**:注册表必须按 `scopeOf()` 归档,事件必须通过 `scopeTarget()` 分发;仅仅通过带作用域的上下文调用任意 Cordis 服务,并不会改变该服务仍为上下文全局这一事实。 +- **一个上下文只携带一个最近的作用域键**:嵌套作用域会遮蔽父作用域的标签,而不会形成层级策略集或多成员策略集。 +- **服务可达性来自作用域创建者**:交出 `Scope.ctx` 也会交出创建插件注入的服务表层,因此持有者无法再收窄一个较宽的创建者表层。 diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml new file mode 100644 index 0000000000..2ce7add3c2 --- /dev/null +++ b/packages/core/session/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 18d6d385ff0c35ddbe7dc9a172ce9cd563bc4c1c +README.zh.md: 93ea574eb01fd27fcd68f8b58a9e4187dfbd4fcb diff --git a/packages/core/session/README.md b/packages/core/session/README.md index a01bb21cc0..18d6d385ff 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -1,5 +1,7 @@ # dsh-session +English | [中文](README.zh.md) + Event-sourced session log and in-memory store. A `Session` is the append-only source of truth for an agent's whole interaction history — the LLM message history is *derived* from it. A **surface** layer (an ordered projection of message-producing events) is maintained on top of the raw log for efficient derivation and compaction. The optional `@deepseek-ai/dsh-session/invariant` companion registers this package's relational trace checks with `ctx.invariants`: monotonic sequence numbers, turn/step enclosure, and same-step tool call/result pairing. It replays existing sessions when loaded or reloaded; storage validation, snapshotting, freezing, provenance, and surface acceptance remain always-on responsibilities of the root session package. diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md new file mode 100644 index 0000000000..93ea574eb0 --- /dev/null +++ b/packages/core/session/README.zh.md @@ -0,0 +1,147 @@ +# dsh-session + +[English](README.md) | 中文 + +事件溯源的会话日志和内存存储。`Session` 是 agent(智能体)全部交互历史的仅追加真源,LLM(大语言模型)消息历史由它*派生*。原始日志之上维护一个 **surface** 层(产生消息事件的有序投影),以便高效派生和压缩(compaction)。 + +可选配套入口 `@deepseek-ai/dsh-session/invariant` 将此包(package)的关系轨迹检查注册到 `ctx.invariants`:序号单调递增、轮次/步骤闭合,以及同一步骤内的工具调用/结果配对。加载或重新加载时,它会回放现有会话;存储校验、快照、冻结、溯源信息和 surface 准入仍始终由根会话包负责。 + +## 服务:`SessionStore`(ctx 键:`sessions`) + +创建并持有事件溯源的 `Session` 实例。这里有意不实现持久化:插件订阅 `session/event`,在 `session/flush` 时刷新,并可镜像成对的 `session/created`/`session/disposed` 生命周期。 + +### 公共 API + +- `ctx.sessions.create(id?, { seed?, meta? }?)` 校验持久种子/头部数据并生成脱离副本,补齐版本和 id,在未提供 `createdAt` 时使用当前时间,发布会话并将其绑定到调用方 fiber。持久化重建会提供原始的 `createdAt`、`seedLength` 和 `delegationDepth`。 +- `ctx.sessions.flush(session)` 通过会话捕获的作用域分发受等待的并行持久性检查点。每个监听器都会启动;调用会等待全部结算后才报告失败。未发布、已脱离和陈旧的对象会被拒绝。 +- `ctx.sessions.appendOutOfBand(session, type, data, trigger)` 只接受已在 `OutOfBandSessionEventMap` 中显式准入的插件事件类型。若轮次已打开,它会直接追加;否则会原子地开启一个零步骤插件轮次,依次追加、关闭并刷新。即使目标事件追加失败,仍会关闭并刷新合成轮次,且在整个序列结算前延后脱离操作。 +- `findLastMessageTurnEnd(events)` 将由消息触发的开始与结束配对,并返回最近匹配的 `turn/end`。结果消费方使用该折叠逻辑,而不直接取最近的原始轮次边界,因为更晚的注入或插件所有的零步骤轮次具有自己的结果。 +- `ctx.sessions.fork(source, boundary?, childSessionId?): Session`:解析实时会话对象或 id,选取截至 `boundary` 事件序号(含该事件)的种子(默认为当前最后一个事件),要求边界为 `turn/end`,再创建带谱系元数据的实时子会话。 +- `ctx.sessions.get(id: SessionId): Session | undefined` +- `ctx.sessions.list(): Session[]` + +#### 高级:有序清理生命周期原语 + +仅在清理必须与另一项资源排序时使用拆分生命周期: + +- `prepare(id?, options?)` 校验并构造,但不发布。 +- `enter(session)` 执行冲突检查,在不通知的情况下发布,并返回一个绑定到该条目的幂等脱离函数。允许并发准备相同 id,但只有一个条目能够成功进入;陈旧的脱离函数无法移除其替代项。 +- `announce(session)` 发出唯一一次创建边,并拒绝重复或重入通知。该次分发期间请求的脱离操作会延后,之后再发出成对的释放边;未通知的条目不会发出任何生命周期边。 + +`dsh-agent-loop` 使用这一拆分,以保证循环的最终刷新先于会话脱离;详见[所有权 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md)。 + +### 实时服务事件 + +会话存储会将已通知的创建与释放配对,在提交后发布追加通知并逐个监听器收容失败,同时提供受等待的持久性检查点。确切签名和作用域行为见生成的[事件目录](../../../docs/cordis-catalog/events.md);载荷见[持久化目录](../../../docs/persistence-catalog.md)。 + +### 类:`Session` + +普通类(不是 Cordis 服务)。通过 `ctx.sessions.create()` 创建。 + +- `session.append(type, data, opts?)` 会为持久数据和 surface 元数据制作快照并冻结它们,校验标记形态、溯源信息、替换覆盖完整性,以及仅修改内容的单个 `tool/result` 重写,随后同步提交,再在彼此独立的失败收容下通知观察者。对已附加会话的重入追加会被拒绝,运行时检查也覆盖扩宽后的联合类型和已加载日志。 +- `session.deriveMessages()` 对每个新的 surface 条目只做一次增量投影,并返回一个新数组,数组元素引用共享的冻结消息。assistant 投影保留提供方/模型溯源信息及适配器私有回放状态。surface 重写会重建投影;不存在原始日志回退。 +- `session.deriveEventMessage(event)` 是重建和请求检查使用的规范逐事件投影。 +- `session.surface` 暴露只读 `SessionSurface` 视图,由会话唯一的增量 surface 管理器所有;每次提交重写,`replaceGeneration` 都会变化。 +- `session.events` 是按追加失效的缓存冻结快照;已接受事件保持深度冻结。 +- `session.seq`、`session.id`:当前序号和只读类型化身份。 +- `session.header: SessionHeader`:脱离、深冻结的创建元数据(`version`、`id`、`createdAt`,以及可选的 `cwd`/`parentSession`/`seedLength`/`delegationDepth`)。构造时会校验持久记录,并要求其中的 id 与 `session.id` 一致。 + +### 无损 JSON 工具 + +持久值需要一种已接受的表示,不能先检查再二次读取。`isJsonValue(value)` 是布尔判断函数;`snapshotJsonValue(value)` 在一趟迭代中校验并复制普通值,无效输入返回 `undefined`,getter 抛出的异常则向外传播。快照辅助函数接受除 `-0` 外的有限 JSON 数值(JSON 会将其改写为 `0`)、稠密普通数组、普通对象或 null 原型对象;它会在规范化前拒绝循环引用、不支持的标量和特殊原型,同时不施加调用栈深度限制。 + +### 分片行存储编解码器(`chunk-rows.ts`) + +提供方以 token 大小的增量流式输出,因此原始日志会存储数百行 `assistant/chunk`,其 JSON 封装远大于载荷。`packChunkRuns(events)` 将每段至少 3 个连续、同块的增量分片打包为一个存储行:`text-chunks`、`reasoning-chunks` 或 `tool-call-chunks`(不含斜杠的裸标签,属于存储词汇而不是 `SessionEventMap` 成员)。`decodeStorageRecord(value)` 则将已解析行展开回完全一致的事件(`seq0`/`time0` 加上每个成员的 `dt` 间隔,可重建每个 `seq`/`time`)。编码器只允许精确形态,并逐字存储任何无法识别的内容;解码器校验带行标签的值,形态错误时抛出异常。编解码器由此包所有,使 JSONL 后端和 fixture(测试前置数据)读取器(`dsh-llm-replay`、`dsh-acp-snapshot`)共享同一编解码器;写入侧开关是后端的 `packChunks` 配置。 + +### Surface 类型 + +- `SurfaceOp`:事件进入有序 surface 的方式,即 `'append'`(正常尾部追加)或 `{ op: 'replace', start, end }`(替换从 `start` 到 `end` 的条目,含两端;二者都必须是有效的 surface 序号;`start === end` 时替换一个条目)。压缩用它遮蔽旧事件而不删除它们。 +- `SurfaceIntent`:`{ surfaceOp: SurfaceOp; sourceEventSeqs?: number[] }`,可进入 surface 的类型调用 `session.append()` 时必需的第三个参数。 +- `SessionSurface`:实时只读 `nodes` 和 `replaceGeneration` 投影,由 `session.surface` 暴露;候选校验仍由 `Session` 私有。 +- `foldSurface(events)`:回放规范 surface 契约,得到脱离的当前事件序列与实际替换范围。同一趟处理会拒绝不连续序号、错位或畸形元数据、空或重复溯源信息、来源并非更早事件、无效位置范围,以及没有引用所有已遮蔽 surface 条目的替换。如果一个 `tool/result` 替换修改了当前某个结果的 `content` 之外的任何内容,也会被拒绝;`SurfaceManager` 共享该原子状态转换,但只保留自己的增量序列缓存。 +- `isSurfaceEvent(event)`/`isSurfaceEligibleType(type)`:前者将 `SessionEvent` 收窄为形态完整的 surface 事件;后者在校验种子或已加载日志时,检测缺少标记的可进入 surface 事件。 + +### 请求头重建(`request-header.ts`) + +`request/header` 记录非历史请求封装的完整规范快照,其原因为 `initial`、`resume` 或 `change`。`foldRequestHeader()` 选择最新快照;旧版增量事件和已移除的 `fallback` 原因会被拒绝。`messagePrefix` 与派生历史保持分离。详见[可重建请求 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)。 + +`user/message` 会将其 `content` 原样呈现为 user-role 消息,无论它是直接人类提示词(来源为 `user`)、合成注入(来源为 `plugin`/`goal`),还是已准入的 Goal Round;`source` 是区分三者的唯一通道。它可以附带 JSON `meta`,用于可回放的插件状态;元数据保持持久,但不包含在 `deriveMessages()` 中。带提示词前缀上下文的 `user/message` 或 `steering/message` 会在 `content` 中保留送给模型的精确合并字节,并存储一个模型不可见的 `envelope`,其中包含直接展示用的 `displayContent` 和前缀上下文的来源/元数据描述符。`displayPromptContent()` 选择面向人的提示词,而不改变派生历史。 + +`tool/result` 持久保存面向模型的内容、可选内部失败标识和可选呈现元数据。工具成功时的规范 `value` 和便于人类阅读的规范失败消息只存在于执行本地;渲染后的错误内容是回放权威消息。这样会保留现有事件形态,且不改变 `SESSION_FORMAT_VERSION`。 + +### 会话事件词汇(`types.ts`) + +生成的[持久化日志事件目录](../../../docs/persistence-catalog.md)逐成员列举仅追加日志的事件类型、载荷、surface 标记和溯源信息。Token 记账读取每个步骤的 `assistant/chunk { type: 'usage' }` 记录;如果没有用量分片,则将 `assistant/message.usage` 作为已提交步骤的后备。失败的模型请求尝试没有 assistant 消息。提供方/模型/回放溯源信息随 `assistant/message` 一同保存;运行错误的步骤记录在 `turn/end.reason` 上(此时为 `kind: 'error'`),最终模型请求失败时还包含结构化的提供方事实。 + +`SessionEventMap` 可通过合并扩展:插件使用声明合并添加自身类型(压缩 seam 的 `compact/*`、有界恢复的非 surface `llm/retry`、hook(钩子)桥接层的 `hook/*`);合并成员会出现在同一目录中。`OutOfBandSessionEventMap` 是独立、默认为空的标记映射:事件所有方必须在其中合并相同键,`appendOutOfBand()` 才接受该仅日志类型;surface 和生命周期类型仍被排除。 + +此包还定义 `TurnTriggerMap` 和 `TurnEndReasonMap`(用于类型化轮次边界、可合并扩展的和类型;以 `kind` 为标签而不是字符串)。最终模型请求错误保留一个结构化 `LlmFailure`;其他轮次错误保留消息/代码,两者均标识失败步骤。 + +被中断的实时轮次以粗粒度的 `{ kind: 'aborted' }` 结果结束。调用方身份属于 Agent 的运行时取消信号,不属于持久 transcript(文本记录);资源释放仍是独立的 `{ kind: 'disposed' }` 终态。 + +每个 `SessionEvent` 都有两个可选顶层字段(结构元数据): + +- `sourceEventSeqs?: number[]`:溯源信息的源序号(例如 `assistant/chunk` 的序号,它们是 `assistant/message` 的来源;或压缩替换条目背后被遮蔽的条目)。对于 `assistant/message`,存在的 `[]` 记录已知为空的提供方流;省略则表示旧版或其他未记录的溯源信息。其他 surface 事件若有此字段,则要求非空列表。 +- `surfaceOp?: SurfaceOp`:事件进入 surface 的方式。非 surface 事件(边界、分片、用量、错误)不含该字段。 + +### 元数据类型(`types.ts`) + +- `SessionHeader`:会话元数据,在发布为 `Session.header` 时写入一次;脱离和深冻结保证运行时不可变:`{ version, id, createdAt, cwd?, parentSession?, seedLength?, delegationDepth? }`。持久化 loader 可返回相同数据类型的可变脱离副本。该类型由此包与 `SessionId` 一同所有,因为 `Session.header` 以它为类型;持久化后端只是重新导出而不拥有它,否则会形成包循环依赖。 + +### 扩展点 + +- 持久化插件:订阅 `session/event`(延后写入),并在 `session/flush`(受等待)及 fiber dispose(资源释放)时排空。持久后端读取日志并重新加载到实时会话;这类后端会把元数据 seam(`SessionHeader`、`session.header`)与日志一同存储。 +- 回放/fork:`create(id, { seed })` 校验并冻结连续的当前格式日志,再重建 surface;请求头必须包含提供方/模型,assistant 消息必须包含提供方/模型溯源信息,而粗粒度中止结果必须只含 `{ kind: 'aborted' }`(带旧版原因的记录会被拒绝)。`fork(source, boundary?, childSessionId?)` 选择已完成轮次前缀并记录谱系。 +- 压缩:`dsh-compact-basic` 为摘要检查点追加一个替换用 `user/message`,而 `dsh-compact-tool-result-prune` 追加仅修改内容的 `tool/result` 替换。工具配对边界策略及其缓存归 [`dsh-compact` seam](../../compact/compact/README.md) 所有;此包拥有有序 surface 成员关系、替换校验与 `replaceGeneration`。 + +## 模型体验 + +### 派生消息历史 + +#### 模型看到的内容 + +模型会原样接收 `user/message`、`assistant/message`、`tool/result` 和 `steering/message` surface 条目的投影:每个投影都是一条 user-role 或 assistant-role 消息,其内容块保持不变。提示词封装只改变面向人的呈现;其前缀上下文和请求分隔符已经位于事件内容中。工具调用包含在 assistant 消息内。分片、边界、用量、hook 记录、todo 记录以及其他仅日志事件不会添加消息。 + +#### Token 影响 + +追加的 surface 条目会在后续步骤中重新发送。`replace` surface 操作会从未来输入中移除被遮蔽条目,但不删除其原始日志记录。 + +#### KV Cache 影响 + +追加的 surface 条目会保留可复用前缀。即使底层事件日志保持仅追加,`replace` 操作也会从首条被遮蔽消息起使缓存复用失效。 + +### 崩溃修复结果 + +#### 模型看到的内容 + +如果恢复发现 assistant 工具请求没有持久 `tool/call`,其合成 `TOOL_NOT_STARTED` 结果内容为 `The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.`。如果持久 `tool/call` 没有结果,其 `TOOL_OUTCOME_UNKNOWN` 结果内容为 `The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly.`。 + +#### Token 影响 + +完整会话的 token 增量为零。恢复时,每个修复后的调用都会添加保留的、针对具体风险的错误文本。 + +#### KV Cache 影响 + +保持仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 + +### 已记录的请求头 + +#### 模型看到的内容 + +会话会重建循环实际发送的系统提示词、工具 schema、调用配置和会话前缀。请求头事件不会向消息历史加入第二份副本;前缀在 `deriveMessages()` 外部前置。 + +#### Token 影响 + +日志记录不产生重复 token。重建的前缀、系统文本和 schema 仍会产生正常的逐请求开销。 + +#### KV Cache 影响 + +记录日志不会导致失效,精确重建会保持请求前缀一致。后续请求头若更改前缀、提示词或 schema,可能从第一处差异开始使复用失效。 + +## 已知限制与暂缓工作 + +- **会话分支/树**(pi 风格条目树):除非需要超越基于边界的 `fork()` 能力,否则暂缓。 +- **`fork()` 仅在实时会话已关闭轮次的边界处切分**:边界必须是 `turn/end` 事件,且源会话必须位于存储中;[fork API](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) 不支持对已持久化但未加载的会话进行 fork。 +- **`SESSION_FORMAT_VERSION` 固定为 `0`**:预发布阶段不承诺兼容性;后端会拒绝其他任何版本,首次发布前不提供迁移路径([政策](../../../AGENTS.md))。 +- **`TurnEndReasonMap` 不含 ACP(Agent Client Protocol)命名的 `refusal`/`max_turn_requests` 变体**:受生产方约束;只有当适配器或循环首次产生这些变体时才加入。 diff --git a/packages/core/system-prompt/README.i18n.yaml b/packages/core/system-prompt/README.i18n.yaml new file mode 100644 index 0000000000..9d82f19bfd --- /dev/null +++ b/packages/core/system-prompt/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 79badba0b84b27c01f25e9c31b5df78c556411ea +README.zh.md: 1d983e44721dbc637efc10824065aa3b88087e1d diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index 65e8f3e590..79badba0b8 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -1,5 +1,7 @@ # dsh-system-prompt +English | [中文](README.zh.md) + System prompt assembly registry. Plugins contribute ordered sections, tool schemas, and named variables. The loop assembles once per step and renders the result as the complete model prompt. This plugin owns the static harness identity and global deployment persona; an agent-scoped persona shadows the global default. ## Config diff --git a/packages/core/system-prompt/README.zh.md b/packages/core/system-prompt/README.zh.md new file mode 100644 index 0000000000..1d983e4472 --- /dev/null +++ b/packages/core/system-prompt/README.zh.md @@ -0,0 +1,86 @@ +# dsh-system-prompt + +[English](README.md) | 中文 + +系统提示词组装注册表。插件贡献有序段、工具 schema 和具名变量。循环在每个步骤组装一次,并将结果渲染为完整模型提示词。此插件拥有静态 harness 身份和全局部署 persona;agent 作用域的 persona 会遮蔽全局默认值。 + +## 配置 + +| 键 | 默认值 | 含义 | +|---|---|---| +| `persona` | `''` | 全局部署 persona 默认值:唯一由配置创作的提示词片段,渲染为顺序为 0 的 `deployment:persona` 段,除非 agent 作用域的贡献将其遮蔽。它是模板,完整的 `{{…}}` 组会严格按已注册变量解释(已交付循环注册 `{{model}}`/`{{cwd}}`),目前没有表达字面量花括号的转义语法。为空 ⇒ 渲染时删除该段。 | +| `toolOrder` | 无 | 显式的面向模型工具顺序:一个 `ToolSchema.name` 列表,包含一个 `'<unlisted-tools>'` 其余项(`TOOL_ORDER_REST`)。已列工具占据列出的位置;未列工具按名称字典序落在其余项位置。缺席 ⇒ 直接按名称字典序排列。在 `system-prompt/assemble` waterfall 之前应用于已收集工具;与段的 `order` 排序一样,它会规范化注册表贡献的内容(注册顺序是插件加载工件),而修改列表的 waterfall 监听器拥有其输出的确定性。配置错误会明确失败:列表没有恰好一个其余项或存在重复项,会在加载时抛出;已列名称没有对应已注册工具,会使每次 `assemble()` 被拒绝;工具提供方返回保留的其余项名称也会被拒绝。在已交付循环下,轮次会在任何模型请求前失败。为何采用中心列表而非每插件权重,见[显式面向模型工具顺序](../../../.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.md)。 | + +## 服务:`SystemPrompt`(ctx 键:`systemPrompt`) + +### 公开 API + +- `ctx.systemPrompt.section(section: PromptSection): () => void`:贡献一个段。层由调用上下文的作用域决定:`agent.ctx` 只为该 agent 贡献,并在该处遮蔽同名全局段。同一层中的重复名称和非有限顺序会抛出。随调用 fiber 释放。 +- `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => void`:贡献工具 schema;每次组装时使用该次组装的上下文求值。`ToolProviderResult` = `{ schemas, knownNames? }`:`schemas` 是限制后的可见集合;`knownNames` 是限制前由 `toolOrder` 使用的全集。提供方不得返回名为 `TOOL_ORDER_REST` 的 schema。带作用域提供方只在其作用域的组装中查询。随调用 fiber 释放。 +- `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => void`:贡献提示词变量,在段文本中以 `{{name}}` 引用。带作用域变量会为该 agent 遮蔽同名全局变量。同层重复或无法引用的名称会抛出;`undefined` 表示「本次组装没有值」。随调用 fiber 释放。 +- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>`:为一个调用方组装提示词:将全局层与 `context.scope` 的层合并,并在变换 seam 前分离工具 schema。它经过按作用域筛选的 `system-prompt/assemble` waterfall,并返回其权威结果。可选的 `context.signal` 显式控制本次组装请求;提供方与监听器可以配合该信号,但不得将它保留给另一轮次。当已配置的 `toolOrder` 指名提供方 `knownNames` 全集以外的工具,或提供方返回保留的其余项名称时,调用会被拒绝。 + +### 实时事件 + +`system-prompt/assemble` 是权威来源;替换条目的监听器必须保留任何活动 Code Mode 或结构化输出协议。筛选需要在呈现、查找与执行之间保持一致时,应使用 [`ToolRegistry.restrict()`](../tools/README.md)。注册表变更通知不经过筛选。生成的[事件目录](../../../docs/cordis-catalog/events.md) 拥有签名与分发契约。 + +### 关键类型 + +- `AssembleContext`:说明一次 `assemble()` 调用的用途。它可通过合并扩展;此处声明 `scope?: ScopeKey`(层选择器)与 `signal?: AbortSignal`(显式请求控制能力),而 `dsh-agent` 声明 `agent?: Agent`(类型化 DX 字段;绝不能在没有 `scope` 时设置,应使用 `assembleContextFor(agent, signal)`)。提供方必须容忍字段缺席,因为裸 `assemble()` 携带的是无作用域、无信号的空上下文。`signal` 是请求值,不是环境 Agent 执行 frame 的一部分。 +- `PromptSection`:`{ name, order, text }`。各段按 `order` 升序拼接。顺序区间:`-100` 是 harness 身份,`0` 是部署 persona,工具引导使用 `100–199`。 +- `PromptAssembly`:`{ sections: AssembledSection[], tools: ToolSchema[], variables: Record<string, string | undefined> }`。段文本到达时已解析,但尚未插值;`variables` 包含对上下文解析后的每个已注册变量。工具 schema 按设计属于组装结果:「模型获知自己能做什么」是一个连贯整体,尽管适配器把 schema 作为独立 wire 字段传输。 +- `renderPrompt(assembly)`:插值每个段中的 `{{variable}}` 引用,删除空段,并用空行连接。严格规则:未知引用(使用 `Object.hasOwn` 查找,因此 `{{constructor}}` 等原型名称未知)、已注册但无值的引用、格式错误的完整 `{{…}}` 组,或一个起始 `{{` 没有打开完整组、但后面仍有 `}}`(`{{{model}}}`),都会抛出;明确失败胜过交付格式错误的提示词。孤立的 `{{` 如果后面任何位置都没有 `}}`,会按字面量通过;替换值绝不再次扫描。 + +可通过合并扩展:插件可以借助声明合并,为 `PromptAssembly` 和 `AssembleContext` 声明额外字段。 + +### 扩展点 + +- 段提供方:工具包拥有跨调用引导(`tool:bash`、`tool:read` 等);此插件拥有 `harness:identity` 与 `deployment:persona`。 +- 变量提供方:agent loop 注册 `model` 与 `cwd`;任何插件都可以注册自己拥有的事实(未来的 `date`、git 状态等)。 +- 工具 schema 提供方:`ToolRegistry` 自动将自身注册为工具提供方。 +- [`system-prompt/assemble` waterfall](#live-events):按调用方协作式修改或替换组装结果。 + +设计原理:[提示词变量 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)。 + +## 模型体验 + +### 系统提示词 + +#### 模型所见 + +每次组装都从下方 harness 身份开始,然后在严格变量插值后追加已配置 persona 与有序插件段。空段会消失;带作用域的段和变量可以为一个 agent 遮蔽全局项。最终 `system-prompt/assemble` waterfall 结果是权威来源,因此专家监听器的变更决定交付的提示词与工具 schema。 + +##### Harness 身份 + +```markdown +You are an AI agent powered by the DeepSeek Harness SDK. +``` + +#### Token 影响 + +身份是每次请求的固定成本。Persona 与插件文本在每次请求中重复,成本随渲染内容增长。 + +#### KV Cache 影响 + +只要身份、persona、变量、段文本与顺序的渲染完全相同,前缀就保持稳定。任何变更都可能从第一个变化的系统提示词 token 起使复用失效。 + +### 工具 schema + +#### 模型所见 + +对于已交付工具,模型会收到[生成工具 schema](../../../docs/tool-catalog.md#tool-package-map) 中对每个 agent 可见的子集;限制与组装拦截完成后,按配置或字典序排列。扩展可以通过同一注册表贡献其他定义。段与 schema 提供方是独立的组装输入,因此工具限制不会移除独立注册的引导。 + +#### Token 影响 + +Schema token 在每次请求中重复。限制工具会为该 agent 移除其全部 schema 成本,但不会移除独立提示词段;重排序会改变 cache 形状,但不改变语义内容。 + +#### KV Cache 影响 + +只要可见 schema 集合、渲染与顺序不变,前缀就保持稳定。注册、限制或重排序可能从第一个变化的 schema token 起使复用失效。 + +## 已知限制与延后工作 + +- **部署创作的提示词文本只来自配置/组合**:此插件拥有全局 persona 默认值;创建方插件可以注册 agent 作用域的遮蔽项;其他段来自拥有相应事实的插件。不存在终端用户提示词编辑 API。 +- **没有表示字面量 `{{…}}` 花括号的转义语法**:每个完整组都会按已注册变量插值;只有实际提示词需要转义时才会实现。 +- **`toolOrder` 配置错误在提示词组装(首轮)时出现,而不是启动时**:只有形状违规会在配置加载时抛出。 +- **共享同一 `order` 值的段按注册顺序打破平局**:这是插件加载工件;确定性依赖不同顺序区间的约定,与已规范化的工具顺序不同。 diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml new file mode 100644 index 0000000000..4e6c0adbc2 --- /dev/null +++ b/packages/core/tools/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 3a9082f99663586936ce1eafaf09264a91798e28 +README.zh.md: 763ae1195d2c7a2606806970a3ba6371114d9d1c diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 5aaea1d296..3a9082f996 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -1,5 +1,7 @@ # dsh-tools +English | [中文](README.zh.md) + Tool registry and execution pipeline. Tool plugins register their schemas and executors; the agent loop executes each call through `tools/pre-execute` (the extensible allow/deny gate) → monotonic registered guards → `tools/execute` (an around-dispatch wrapper for timeout/retry/metrics plugins) → `tools/post-execute` (inspect/replace the result, attach context) → the definition-owned `finalizeContent` boundary → the observe-only `tools/result` notification. The registry also owns HOW its tools are presented to the model — its `mode` config selects native function calling, [Code Mode](#code-mode), or both. ## Service: `ToolRegistry` (ctx key: `tools`) diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md new file mode 100644 index 0000000000..763ae1195d --- /dev/null +++ b/packages/core/tools/README.zh.md @@ -0,0 +1,195 @@ +# dsh-tools + +[English](README.md) | 中文 + +工具注册表与执行流水线。工具插件注册各自的 schema 和执行器;agent loop(智能体循环)依次让每次调用经过 `tools/pre-execute`(可扩展的允许/拒绝门禁)→ 单调注册守卫 → `tools/execute`(供超时/重试/指标插件使用的环绕分发包装层)→ `tools/post-execute`(检查/替换结果、附加上下文)→ 由定义拥有的 `finalizeContent` 边界 → 仅观测的 `tools/result` 通知。注册表还负责决定如何向模型呈现其工具:`mode` 配置可以选择原生 Function Calling(函数调用)、[Code Mode](#code-mode),或同时选择两者。 + +## 服务:`ToolRegistry`(ctx 键:`tools`) + +### 配置 + +```yaml +tools: + mode: native # native (default) | code | both +``` + +`native` 以函数定义的形式贡献可见工具。`code` 贡献保留的 `run_code` 传输和生成的 `tools:sdk` 段;`both` 同时贡献两种形式。不能注册、遮蔽、限制或移除该保留传输。非原生模式要求存在 TypeScript `ctx.codeRuntime`;如果 `systemPrompt.toolOrder` 条目指向当前模式未贡献的工具,系统会拒绝组装提示词。`system-prompt/assemble` 监听器可以替换注册表贡献;它返回的组装结果具有权威性,因此该监听器负责保留可用的 Code Mode 协议。 + +### 公开 API + +- `ctx.tools.register(definition: ToolDefinition): () => void`:注册一个受信任、带类型的同进程定义,其中必须包含规范的 `output` 声明。所在层由调用上下文的作用域决定:普通插件上下文会全局注册;agent 的 `agent.ctx` 只为该 agent 注册,并在此处遮蔽同名全局工具。同一层内名称重复会抛出;非原生模式还会拒绝保留的 `run_code` 传输名称。缺失或不受支持的输出声明,以及非正数或非有限的 `timeoutMs`,都会使注册失败。可选的同步 `finalizeContent` 回调会在调用开始时创建快照;在所有流水线结果规范化之后,它只能替换最终面向模型的内容,包括实体化其他结果字段时发现的错误。随调用 fiber 释放。 +- `ctx.tools.restrict(filter)`:对全局工具应用 agent 作用域的允许/拒绝掩码;从普通上下文调用会抛出。筛选器在注册时创建快照;多个掩码取交集,随后再合并作用域本地工具。拒绝掩码会接纳后来出现且未点名的全局工具,而允许掩码会排除后来出现的名称。未知、本地或保留名称以及空筛选器都会被拒绝。这是实时可见性组合,不是权限边界;参见[作用域安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals)。 +- `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined`:按某个作用域所见的结果解析(应用遮蔽;被限制掉的全局工具视为不存在)。呈现器会传入发起调用的 agent,使卡片与实际执行内容一致。 +- `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]`:返回该作用域可见的所有 schema(不含 `execute` 函数)。已交付工具的 schema 收录在 [docs/tool-catalog.md](../../../docs/tool-catalog.md) 中;该目录通过启动每个工具插件并采集此方法的结果生成(参见[工具 schema 目录 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md))。 +- `ctx.tools.guard(guard: ToolGuard): () => void`:在 `tools/pre-execute` 之后注册单调同步执行守卫:返回理由会拒绝调用,返回 `undefined` 则保持原决定。普通上下文守卫全局生效;`agent.ctx` 守卫只对该 agent 生效。后续 waterfall(瀑布式事件)监听器无法将守卫的拒绝重新变为允许。随调用 fiber 释放。 +- `ctx.tools.execute(exec)`:以无损方式快照并冻结参数,分配不透明 token,运行完整的策略/分发/结果流水线,然后在最终观测前独立快照权威结果。无效参数会进入同一结果路径,但不会到达策略或工具主体。环绕包装层只能替换 `signal`;注册表会在调用主体前立即重新融合调用方的原始信号。 +- `ctx.tools.executionMode(exec)`:返回 `parallel` 的唯一条件是可见定义的 `isConcurrencySafe(exec.arguments)` 分类器恰好返回 `true`;未知、隐藏、未声明、无效或抛出异常的分类结果均为独占。 + +### 注入的服务 + +`SystemPrompt`:注册表通过 `ctx.systemPrompt.tools()` 自动将工具 schema 送入系统提示词组装。审批 seam 则按需使用(`ctx.get('approval')`,无静态注入):未部署该 seam 时仍会将询问退化为拒绝,而无论是否存在该 seam,注册表都会保持活动。 + +### 取消 + +取消采用协作方式,并等待完全停稳。每次类型化调用都提供由调用方拥有的 `AbortSignal`;工具主体通过必填的只读 `exec.signal` 接收它,只有 `tools/execute` 包装层可以临时替换这个必填信号。注册表会在替换期间保留调用方取消,并且绝不会在已启动的同进程 Promise 尚未结算时提前返回。调用主体前发生的取消为 `ABORTED_BEFORE_DISPATCH`;调用后的取消只能把成功结果替换为 `ABORTED`。拒绝、包装层失败、工具失败、后置策略失败或超时拥有的 `TOOL_TIMEOUT` 仍保留更具体的结果。入口处已中止的调用会实体化并冻结参数,随后跳过所有策略和分发阶段,只发布一个结果。每个异步工具都必须观测或转发该信号,并且只能在自身拥有的工作停止后结算。[工具取消 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md) 规定完整契约和强制终止边界。 + +### 实时事件 + +实时注册表流水线先经过 3 个可变换的 waterfall,再经过由定义拥有的内容终结器,最后到达仅观测的 `tools/result` 边界;注册表变更有意作为不过滤的共享状态通知。确切签名、分发 mode、作用域筛选和故障收容契约位于生成的 [Cordis 事件目录](../../../docs/cordis-catalog/events.md),完整顺序则在生成的[工具执行流水线](../../../docs/tool-execution-pipeline.md)中可视化。`tools/result` 是实时事件;名称相近的 `tool/result` 是 agent loop 随后追加的持久会话事件。 + +### 关键类型 + +- `ToolDefinition`:`ToolSchema` + 必填的 `output { schema, render, presentationMeta? }` + `execute(args, exec)`,以及可选的最终内容回调、呈现回调、协作式 `timeoutMs` 和逐调用的 `isConcurrencySafe(args)` 分类器。主体只能返回输出 schema 声明的规范 JSON 值,并通过 `exec.signal` 协作停止。`finalizeContent(exec, result)` 对每个规范化结果都恰好运行一次,包括绕过后置策略的失败,并且只能替换 `content`;它必须是同步且对所有输入都有定义的函数。 +- `ToolExecutionInput`:调用方提供的调用描述:`{ callId, name, arguments, signal, agent?, parent? }`;`signal` 必填且只读,调用方可以将外层执行的不透明 token 作为 `parent` 传入,但绝不能选择新执行自身的 token。 +- `ToolExecutionToken`:注册表分配的全新带品牌 `Symbol`。它只支持通过相等性进行关联,绝不会跨越模型、日志或 worker 边界。 +- `ToolExecution`:只读流水线视图:不可变的 `{ token, callId, name, arguments, signal, agent?, parent? }`;注册表会另行保留并重新融合调用方的原始信号。`ToolDispatchExecution` 是仅供 `tools/execute` 使用的视图,其必填信号可变,因此包装层可以替换并还原它,但不能删除它。嵌套调用的 `parent` 是 `ToolExecutionToken`,而不是执行对象。 +- `ToolRunContext`:传给工具主体的执行上下文,在 `ToolExecution` 基础上增加 `deferContext(context)`。组合工具借此把嵌套分发产生的上下文传递到外层结果,即使工具后来抛出或取消胜出也不例外;该方法绝不会立即注入上下文。 +- `ToolExecutionResult`:可辨识的执行局部结果。成功形态为 `{ isError:false, value:JsonValue, content, meta?, additionalContexts? }`;失败形态为 `{ isError:true, error:{ message, info? }, content, meta?, additionalContexts? }`,且不含值。调用身份保留在不可变的 `ToolExecution` 上。注册表会在呈现前快照、验证并冻结规范值,随后在最终观测前实体化持久呈现字段。`ToolFailure.info` 携带内部的 `{ name, code }`,用于表示 `HarnessError`;`additionalContexts` 为循环在结果后的 FIFO 保留每个延迟或后置执行的 `HookContext`。 +- `PreToolDecision`:`{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`。该类型有意不提供输入改写;`ask` 在挂载 [`ctx.approval`](../../ui/user-approval/README.md) 时由它处理,否则退化为拒绝。 +- `PostToolDecision`:接受决定可以替换 `content` 或 `value`(不能同时替换),并可附加 `additionalContexts`;阻止决定会把反馈变成无值失败。替换内容会保留规范值和元数据。替换值会重新验证,并重新呈现内容/元数据。接受决定会先保留工具延迟的上下文,再附加决定上下文;阻止决定会丢弃工具延迟的上下文,只公开阻止决定显式提供的上下文。 +- `ToolGuard`:`(execution) => string | undefined`;返回的字符串是最终单调拒绝理由,在可重排的前置执行 waterfall 之后、分发之前求值。 +- `ToolCallView` / `ToolResultView`:提供方无关、带 `card` 标签的呈现意图;工具通过 `presentCall` / `presentResult` 返回该意图,从而拥有 UI 呈现其自身调用的方式(参见「工具拥有的 UI 呈现」)。 + +### 扩展点 + +- 工具插件调用 `ctx.tools.register()`:schema 会自动流入组装结果。 +- `tools/pre-execute` 是可重排的允许/拒绝/询问门禁;`ctx.tools.guard()` 在其后添加单调的拥有方策略。 +- `tools/execute` 为超时、重试或指标环绕已经规范化的规范分发。包装层只能替换操作信号;包装层创作的成功结果会根据已解析工具的输出声明进行规范化。规范结果的来源属于一个不可变分发 token,因此,来自其他调用或工具的缓存结果会根据当前声明重新验证。 +- `tools/post-execute` 可以替换呈现内容、替换规范值、通过反馈阻止,或附加有序上下文。随后,定义可选的 `finalizeContent` 会在普通结果和外层流水线失败中维护其最终、仅涉及内容的不变式;`tools/result` 观测不可变的最终结果。内容替换不是保密边界:当编程消费方不得接收某个值时,应阻止或替换该值。 +- 确切签名与顺序位于生成的[事件目录](../../../docs/cordis-catalog/events.md)和[流水线](../../../docs/tool-execution-pipeline.md)中。 +- MCP 服务器:每个服务器使用一个插件;发现工具后,使用服务器的 schema 调用 `ctx.tools.register()`。 + +### 类型化工具参数 schema + +第一方插件作者可以使用本包导出的 `defineTool()` 辅助函数定义类型化工具参数 schema: + +```ts +import { readFile } from 'node:fs/promises' +import type { Context } from 'cordis' +import { defineTool } from '@deepseek-ai/dsh-tools' + +declare const ctx: Context + +ctx.tools.register(defineTool({ + name: 'read_file', + description: 'Read a file from disk.', + parameters: { + path: { type: 'string', required: true, description: 'Absolute file path' }, + offset: { type: 'number' }, + limit: { type: 'number' }, + }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, + async execute(args, exec) { + // args is typed: { path: string; offset?: number; limit?: number } + return readFile(args.path, { encoding: 'utf8', signal: exec.signal }) + }, +})) +``` + +统一 schema DSL 使用 `ParameterSchemaSpec` 表示隐式开放参数对象,使用 `ValueSchemaSpec` 表示任意 JSON 值根。它支持 `string`、`number`、`integer`、`boolean`、`null`、`array`、`object`、仅供作者使用的 `json`,以及恰好匹配一个分支的 `oneOf`;标量 `enum`/`const` 值会接受类型正确性检查。每个显式 DSL 对象都声明 `additionalProperties: true | false`,而隐式参数根和原始 JSON Schema 保持标准的开放默认值。schema 记录只接受自身可枚举字符串键,schema 数组必须是稠密普通数组。编译、验证、从注册表分离以及 schema 到 TypeScript 的呈现均使用显式工作栈,因此,对有效深层 schema 的运行时处理受内存而非调用栈限制;`InferValue` 在 16 层容器内保留精确类型,之后回退到 `JsonValue`,使 TypeScript 自身也保持栈安全。 + +`defineTool` 定义会在执行前验证模型参数,并把缺失必填值、基本类型错误、无效枚举成员和嵌套违规转换为 `ToolArgsError`(`INVALID_ARGS`),进入普通错误结果路径。它还会根据 `output.schema` 推断主体返回类型和纯输出投影器;注册表在呈现前快照并验证返回的无损 JSON。隐式参数根是开放的;显式对象只有在设置 `additionalProperties: true` 时才接受额外键,而没有声明属性的封闭对象只接受 `{}`。原始 JSON Schema 对象保持开放,除非显式设置 `additionalProperties: false`。系统不会应用默认值;没有 `properties` 的开放对象和没有 `items` 的数组只接受容器类型检查。通过原始方式注册的工具负责输入验证,但仍需声明输出,并由注册表强制校验输出。 + +有关详细信息,请参阅公开 API 中的 `defineTool`、`validateArgs`、`ToolArgsError`、`ValueSchemaSpec`、`ParameterSchemaSpec`、`InferValue`、`InferArgs`、`valueSchemaSpecToJsonSchema` 和 `parameterSchemaSpecToJsonSchema`。 + +可选的 `timeoutMs` 必须为正数且为有限值;它是策略元数据,不是模型可见的 schema。 + +可选的 `isConcurrencySafe(args)` 接收经过软验证的类型化参数。只有确切的 `true` 才允许并发分发/主体执行;无效输入和所有其他结果仍为独占。选择并发的主体不得改变父级拥有的状态;共享状态竞态必须具有交换性,否则必须安全拒绝。[并行工具调用 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md) 规定完整安全契约。 + +### 强制执行的原始 JSON Schema 子集 + +`JsonSchemaNode` 是工具输出、Code Mode 生成、subagent 和工作流共享的原始对应类型。它允许任意 JSON 根、一个仅含 annotation 的无约束 JSON 节点,以及恰好匹配一个分支的 `oneOf`;annotation 必须保持为无损 JSON。`assertSupportedJsonSchema()` 拒绝不受支持的构造,而 `validateJsonSchemaValue()` 返回带路径的违规信息。subagent 和工作流通过 `assertObjectJsonSchema()` 与 `ObjectJsonSchema` 保留调用方定义的对象根要求,而不是依赖共享词汇的限制。 + +### 工具拥有的 UI 呈现 + +工具可以选择拥有纯 `presentCall()` 和 `presentResult()` 呈现意图,使 UI 无需特殊处理工具名称: + +- 调用视图为 `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`、`{ card: 'terminal', title, description?, cwd? }` 或 `{ card: 'diff', title, diffs, locations? }`。 +- 结果视图为 `{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }` 或 `{ card: 'diff', title?, diffs }`。 + +返回 `undefined` 会选择通用回退。呈现器只依赖其参数和持久结果,因为 UI 会在实时流式输出和日志回放期间调用它们。`output.presentationMeta(args, value)` 为直接接口调用派生 JSON 元数据;该元数据随 `tool/result` 持久化并传回 `presentResult`,而规范值本身仍只存在于执行局部,绝不会回放。嵌套 Code 分发不会计算元数据。`defineTool` 会软验证较旧的日志参数并回退,而不会使回放崩溃。`dsh-tool-bash` 与 `dsh-tool-fs` 是参考实现;[规范输出 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md) 规定值/呈现拆分,[呈现意图 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) 规定卡片词汇。 + +### Code Mode + +在 `code` 或 `both` 模式下,注册表为当前作用域公开保留的 `run_code` 传输和确定性的 TypeScript SDK;只有程序的外层日志与返回值会重新进入模型上下文。SDK 为每个可见工具声明精确的 `ToolArgsMap` 和 `ToolOutputMap` 条目,每个绑定都会解析为该工具的规范 JSON 值。每个无损 JSON 绑定调用都会在原生调度契约下重新进入完整工具流水线(并发安全的调用最多可重叠 `maxParallelSubCalls` 个;独占调用单独运行并构成排序屏障),并在日志中与外层调用建立关联。拒绝及其他失败结果会以程序实际可见的 `ToolCallError` 进行 reject,且只携带 `toolName` 和 `message`;Native 内容和内部错误码留在 Code 契约之外。普通副作用不会回滚,子调用的 `additionalContexts` 会通过父结果延迟,以保持调用/结果相邻。运行结算会中止并 drain 尚未完成的绑定;运行时失败以 `CodeRunFailedError` 形式出现。参见 [Code Mode 基础](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)、[类型化返回契约](../../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md)和[代码运行时 seam](../../code-runtime/README.md)。可以运行 `pnpm run demo:code-mode` 试用。 + +- **SDK 段**(`tools:sdk`,顺序 150):一个惰性提示词段,每次组装时都会重新生成 `JsonValue`、精确的 `ToolArgsMap` / `ToolOutputMap`、`ToolName`、`ToolCallError` 声明、面向调用作用域可见最终能力的映射 `tools` 命名空间(特殊名称使用带引号的键),以及固定用法说明。其输出具有确定性:工具按字典序排列;工具集合不变时,文本逐字节相同(有利于前缀 cache)。导出的代码生成器 `jsonSchemaToTs` 会处理统一 schema 的每种构造,并将不受支持的原始构造降级为 `unknown`,绝不会在提示词组装期间抛出。 +- **分发桥接层**(`run_code` 的 execute):每个绑定调用都会在分发前快照为无损 JSON(`undefined`、`BigInt`、循环、稀疏数组、`-0` 和特殊对象会使该次调用被拒绝),经由每次运行独有、复用原生并发契约的池调度——调用严格按提交顺序启动,连续的 `isConcurrencySafe` 调用最多可重叠经校验的 `maxParallelSubCalls` 配置个(默认 10;设为 `1` 即恢复串行分发),被分类为独占的调用先排空池、单独运行并阻挡其后的调用——以外层执行的不透明 token 作为 `parent`,并经过完整的 pre-execute → guards → execute → post-execute → result 流水线。成功会返回策略处理后的最终规范值;失败以一条消息到达 worker,并成为 `ToolCallError(toolName, message)`。每个已启动的子调用在进入流水线时记录一条 `tool/code-dispatch-start` 事件(确定性 id `<parent>:code:<n>`,按提交顺序编号),并以一条携带完整模型可见 `content`/`isError` 结果的 `tool/code-dispatch` 事件完结(采用 `tool/result` 词汇,因此 UI 会沿原生路径呈现子调用——这对事件的 `time` 字段承载每个子调用的计时);因 run 结算而被放弃的排队调用两者都不记录。`deriveMessages()` 既不公开这两个事件,也不持久化规范值。token 关联让以提交为语义的观察器能够把内部成功延迟到最终 `run_code` 结果,而无需公开实时外层执行;普通工具副作用不会回滚。每个子调用的 `additionalContexts` 条目都会按分发顺序通过外层 `ToolRunContext` 延迟;循环只在父级 `run_code` 结果之后追加这些上下文,从而保持相邻关系,并且即使程序后来失败,也会保留各自的来源/元数据。 +- **结算纪律**:桥接层拥有一次运行作用域的中止;该中止会跟随传入的外层信号,并在运行因任何原因结算时触发,因此预算耗尽会中止正在运行的子工具,而不会将其遗留。桥接层随后会在返回之前 drain 队列,使每个 `tool/code-dispatch` 都落在仍打开的轮次内。失败的运行会抛出 `CodeRunFailedError`(`code: 'CODE_RUN_FAILED'`,message = 失败类型 + 已捕获日志),流水线会将其转换为模型可据以自我修正的结构化 `isError`。 +- **结果边界**:中间绑定值会完整跨越 worker 边界,且没有逐绑定字节上限。`run_code` 返回规范的 `{ logs: string[], result?: JsonValue }`;字符串原样呈现,其他所有存在的 JSON 根都通过栈安全的美化 JSON 遍历呈现,总缩进最多为 10 个字符(更深的子树保持紧凑),`null` 保持显式,而缺少 `result` 表示程序返回 `undefined`。worker 可配置的 `maxOutputBytes`(默认 64 MiB)只应用于组合序列化后的外层日志数组、完成值或失败消息载荷;固定的结果 envelope 语法和呈现空白不计入该账本。无效和超限的完成会明确失败,只有此外层结果可以使用普通 spill。 + +### 并行执行 + +agent loop 将连续的 `parallel` 调用归入有界滚动池,并把每个 `exclusive` 调用视为顺序屏障。只有分发/主体会重叠;策略、持久结果和上下文仍保持模型顺序。Code Mode 绑定通过桥接层自己的池复用同一套分类。[并行工具调用 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md) 规定已交付声明及其原理。 + +## 模型体验 + +### 普通工具 schema + +#### 模型所见 + +在普通模式下,模型会看到每个可见定义的确切名称、描述和 JSON schema;已交付定义记录在生成的[工具包映射和 schema 章节](../../../docs/tool-catalog.md#tool-package-map)中。agent 作用域的限制、遮蔽和扩展注册会改变该 agent 的最终工具集合。 + +#### Token 影响 + +每次请求的固定成本与可见定义成正比。隐藏工具的限制会为该 agent 移除其全部 schema 成本。 + +#### KV Cache 影响 + +只要可见定义及其顺序不变,前缀就保持稳定。注册、释放或作用域限制可能从第一个改变的 schema token 起使复用失效。 + +### Code Mode schema 与系统提示词 + +#### 模型所见 + +Code Mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools)、下方 SDK 说明,以及生成的精确 `declare const tools` 块。`both` 会同时公开普通 schema 与此 Code Mode 接口。 + +##### Code Mode SDK 说明 + +```markdown +## Writing code for run_code + +Pass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program: + +- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON. +- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue. +- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`. +- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need. + +The available tools: +``` + +#### Token 影响 + +每次请求的固定成本与可见定义成正比。Code Mode 使用生成的 SDK 文本加一个传输 schema 取代最终工具 schema,但不承诺普遍减少成本。 + +#### KV Cache 影响 + +只要 Code Mode 选择、生成的 SDK、传输 schema 和可见工具集合不变,前缀就保持稳定。模式或筛选器变更可能从第一个改变的提示词或 schema token 起使复用失效。 + +### 工具调用历史与结果 + +#### 模型所见 + +循环会保留模型发出的参数和注册表的最终内容。任何抛出或被拒绝的调用都会恰好变为 `Error: <message>`。Code Mode 只返回外层程序打印的行和呈现后的返回值;两者都为空时返回 `(run_code completed with no output)`;失败时返回 `Error: code run failed (<kind>): <message>`,并根据是否存在已捕获内容,在其后附加 `Captured output:` 与捕获的行。内部分发事件只保留在日志中;后置执行监听器可以在结果之后追加带来源归属的上下文。 + +#### Token 影响 + +参数、结果和附加上下文取决于数据,并会重复发送直至压缩。隐藏工具的限制还会在模型可以调用这些工具之前移除其 schema。 + +#### KV Cache 影响 + +仅追加;新的可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 + +## 已知限制与暂缓工作 + +- **并发策略不是事件 seam**:`executionMode()` 直接读取已解析的工具定义;插件只能在自身拥有的定义上声明分类器。 +- **`tools/pre-execute` 有意不允许改写 `exec.arguments`**:否则日志记录和呈现的参数会与实际运行内容失去同步;改写设计记录在[拟议的 Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)中。 +- **调用方定义的 subagent 与工作流结构化输出仍要求对象根**:这是消费方层面的守卫;共享 schema 词汇和工具输出支持任意 JSON 根。 +- **定义上的 `timeoutMs` 仅为声明**:注册表绝不会强制执行截止时间;要强制执行,必须使用 `@deepseek-ai/dsh-timeout-policy` 包装层。 +- **Code Mode 只支持 TypeScript,且呈现模式在服务内统一**:`mode: code`/`both` 会拒绝组装提示词,除非 `ctx.codeRuntime.language === 'typescript'`;作用域限制/遮蔽仍会选择每个 agent 的可见绑定,但不能让一个工具仅使用 Native,而另一个仅使用 Code。 +- **Code Mode 中间值只存在于执行局部,且没有字节上限**:这些规范的类型化值无法从会话回放重建,并可能耗尽进程或 worker 内存;只有外层 `run_code` 输出受 worker 可配置的硬上限约束。每个子调用的持久日志副本则**有**上限:`tools/code-dispatch-log` waterfall 允许 spill 策略把过大的 `tool/code-dispatch` 内容替换为预览加定位符([原理](../../../.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md))。 +- **每次运行都会获得全新的 `run_code` 状态**:MVP 不采用持久 REPL 风格内核(跨调用状态不会出现在日志中);参见 [Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)。 diff --git a/packages/examples/README.i18n.yaml b/packages/examples/README.i18n.yaml new file mode 100644 index 0000000000..615d4194c6 --- /dev/null +++ b/packages/examples/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: c229cef22087ac290bf862d6b3e31fdb533858c4 +README.zh.md: 5472fe7be76b015a2f3b06b2738b728a927acdd4 diff --git a/packages/examples/README.md b/packages/examples/README.md index 2f00d400f8..c229cef220 100644 --- a/packages/examples/README.md +++ b/packages/examples/README.md @@ -1,5 +1,7 @@ # examples/ — ready-to-run demo bundles +English | [中文](README.zh.md) + Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling the spine and a front door by hand. These are **demo / reference** packages — the `-demo` npm suffix marks each one as non-product surface, readable straight off the package name. The runnable leaves under the repo-root [`examples/`](../../examples/AGENTS.md) and the [Python SDK runtime](../../python/sdk-runtime/README.md) are the consumers; each is just its swappable backends plus one bundle entry. | Package | npm name | Role | diff --git a/packages/examples/README.zh.md b/packages/examples/README.zh.md new file mode 100644 index 0000000000..5472fe7be7 --- /dev/null +++ b/packages/examples/README.zh.md @@ -0,0 +1,23 @@ +# examples/:开箱可运行的演示组合包 + +[English](README.md) | 中文 + +预先组合的插件 bundle(组合包),供轻量叶节点 `cordis.yml` 加载,无需手工组装主干和前端入口。这些是 **演示/参考** 包;npm 名称的 `-demo` 后缀把每个包标为非产品表层,直接查看包名即可辨认。仓库根目录 [`examples/`](../../examples/AGENTS.md) 下的可运行叶节点与 [Python SDK runtime](../../python/sdk-runtime/README.md) 是消费方;每个叶节点都只包含可替换后端和一个组合包入口。 + +| 包 | npm 名称 | 角色 | +|---|---|---| +| `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | 不含执行器和 UI 的 agent 主干,打包为一个组合包插件,带后备会话标题和选用的持久目标栈 | +| `tui-demo/` | `@deepseek-ai/dsh-tui-demo` | 全屏终端应用组合包:主干 + 持久目标 + `/goal` 命令 + JSONL 持久化 + `dsh-tui` + 预创建的 `main` agent;没有 bin,由 [`dsh`](../../apps/cli/README.md) CLI 启动 | +| `cli-demo/` | `@deepseek-ai/dsh-cli-demo` | 无头单次应用:主干 + JSONL 持久化 + 预创建的 `main` agent,提供文本和 DSH 原生 JSON 输出 | +| `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP 自动化服务器应用:主干 + 持久目标 + JSONL 持久化 + [`acp`](../acp/acp/README.md) 桥接层(无 stdout logger),带启动 `bin` | +| `jsonrpc-demo/` | `@deepseek-ai/dsh-jsonrpc-demo` | 只有 bin 的 runtime,用于启动外部 `cordis.yml`,供 stdio JSON-RPC SDK 客户端使用 | + +`agent-spine-demo` 是共享组合包;`tui-demo`、`cli-demo` 和 `acp-demo` 分别将它与全屏终端、无头单次和 ACP 自动化前端入口组合。`cli-demo` 与 `acp-demo` 拥有各自的启动 bin;`tui-demo` 只交付组合包插件,产品 [`dsh`](../../apps/cli/README.md) CLI 是它的终端前端入口。`jsonrpc-demo` 自身不挂载任何组合,而是启动部署的 `cordis.yml` 所指名的任意插件树;Python SDK runtime 会启动它。 + +这些 **不是** 产品 API。它们打包的主干组件位于 [`core/`](../core/README.md),人类/SDK 通道和启动粘合代码位于 [`ui/`](../ui/README.md),自动化传输位于 [`acp/`](../acp/README.md),可替换后端位于各自能力组;演示组合包只选定其中一种具体组合。可以自由替换或 fork。 + +不要将此组与仓库根目录的 [`examples/`](../../examples/AGENTS.md) 混淆:该目录存放可运行的 `cordis.yml` **叶节点**;此组存放这些叶节点加载的 **组合包**。 + +## jsonrpc bin/exe 名称是历史遗留 + +`jsonrpc-demo` 已像同级包一样重命名,但其 bin 仍为 `dsh-jsonrpc-agent`,单文件可执行程序仍为 `dsh-jsonrpc-agent-pkg`(在 [Python 分发](../../python/sdk-runtime/README.md)各处被引用)。这些名称属于 SDK 的 runtime 启动表层;只有 SDK 统一该启动流程时才会协调它们,而不会在此次移动中处理。 diff --git a/packages/examples/acp-demo/README.i18n.yaml b/packages/examples/acp-demo/README.i18n.yaml new file mode 100644 index 0000000000..5a202076c8 --- /dev/null +++ b/packages/examples/acp-demo/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: ef76bbcbd80ef5007426c2fea8537eceec3d4577 +README.zh.md: 7eace737104310ac29f0c1e9db6d77aa911b8439 diff --git a/packages/examples/acp-demo/README.md b/packages/examples/acp-demo/README.md index 9666e4444f..ef76bbcbd8 100644 --- a/packages/examples/acp-demo/README.md +++ b/packages/examples/acp-demo/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-acp-demo +English | [中文](README.zh.md) + ACP automation server app: the default agent spine, client-created agents through [`@deepseek-ai/dsh-acp`](../../acp/acp/README.md), JSONL persistence, and semantic checkpointing behind one JSON-RPC stdio bin. Programmatic clients create fresh sessions; this package mounts no human UI. ## Composition diff --git a/packages/examples/acp-demo/README.zh.md b/packages/examples/acp-demo/README.zh.md new file mode 100644 index 0000000000..7eace73710 --- /dev/null +++ b/packages/examples/acp-demo/README.zh.md @@ -0,0 +1,59 @@ +# @deepseek-ai/dsh-acp-demo + +[English](README.md) | 中文 + +ACP 自动化服务器应用:默认 agent 主干、客户端通过 [`@deepseek-ai/dsh-acp`](../../acp/acp/README.md) 创建的 agent、JSONL 持久化,以及由一个 JSON-RPC stdio bin 提供的语义检查点。程序化客户端创建新会话;此包不挂载人类 UI。 + +## 组合 + +| 插件 | 角色 | +|---|---| +| `@deepseek-ai/dsh-agent-spine-demo` | 不含提供方且不预创建 agent 的 agent 主干;`session/new` 创建每个 agent。 | +| `@deepseek-ai/dsh-session-persistence-jsonl` | 检查点、可观测性和快照回放所使用的持久会话日志。 | +| `@deepseek-ai/dsh-session-checkpoint-policy` | 在模型调用和顶层工具 effect 前建立持久性屏障,并为已完成步骤建立检查点。 | +| `@deepseek-ai/dsh-session-query-sqlite` | 派生的精确/FTS 会话查询服务;先于 ACP 传输打开,使叶节点消费方在首次模型请求前就绪。 | +| `@deepseek-ai/dsh-acp` | 通过 stdin/stdout 提供的纯自动化 ACP 传输。 | + +应用不安装命令、用户交互、会话导航、配置选择器或 stdout logger。它通过一个有序 effect 拥有这些插件,因此查询服务会在 ACP 接受工作前就绪,而 ACP 会话会在检查点与持久化分离前静默。叶节点配置负责提供 LLM、执行器、沙箱、批准、文件系统和面向模型的工具插件。 + +## 配置 + +| 键 | 默认值 | 路由目标 | +|---|---|---| +| `provider` | 必填 | 每个由 ACP 创建的 agent 所用的提供方路由。 | +| `model` | 必填 | 每个由 ACP 创建的 agent 所用的模型。 | +| `maxParallelToolCalls` | agent-loop 默认值 | 正整数工具调用并发上限;`1` 表示串行。 | +| `persona` | 无 | 供 `dsh-system-prompt` 使用的部署 persona 模板。 | +| `toolOrder` | 字典序 | 供 `dsh-system-prompt` 使用的显式面向模型工具顺序。 | +| `tools` | `{ mode: 'native' }` | Native、Code Mode 或组合式模型工具传输。 | +| `dshHome` | `$DSH_HOME` 或 `~/.dsh` | bash 与本地 skill 发现共享的 harness 主目录。 | +| `sessionTitle` | 主干示例限制 | 持久后备标题限制;标题仍不会进入 ACP wire。 | +| `persistenceRoot` | `./.sessions` | JSONL 后端根目录,以及派生 `session-query.db` 索引的父目录。 | +| `packChunks` | `false` | 在存储中打包连续的增量 chunk 事件。 | +| `persistenceCompression` | `zstd` | 带校验和的 Zstandard 帧,或原始 `none`。 | +| `workspaceContext` | 必填 | Workspace 指令字节预算/配置,或 `false`。 | +| `skills` | 拥有者默认值 | Skill 注册表、本地提供方和面向模型的 skill 工具。 | +| `toolBash` | 拥有者默认值 | 面向模型的 bash 工具配置。 | +| `toolTasks` | 拥有者默认值 | 通用后台任务控制配置,或 `false`。 | +| `goals` | 拥有者默认值 | 持久的同会话目标领域与模型工具,或 `false`。 | +| `llmRetry` | 拥有者默认值 | 有界的瞬时模型请求重试策略。 | + +已交付的 [`examples/acp-agent/cordis.yml`](../../../examples/acp-agent/cordis.yml) 添加 DeepSeek 适配器、沙箱化 bash 与文件系统提供方、一次性批准策略、压缩、subagent、工作流、钩子,以及面向模型的工具。应用提供派生会话查询索引,而面向模型的查询消费方仍由叶节点显式选用。快照 overlay 只替换非确定性提供方或策略值。 + +## Bin + +`dsh-acp-demo [--config path-to-cordis.yml]`(短形式 `-c`;默认为 `./cordis.yml`)会加载 gitignore 排除的 `.env`,回放 mode 除外;`DSH_SNAPSHOT=replay` 选择同级 `cordis.snapshot.yml`;stdin EOF 会在退出前释放上下文并刷新会话。Loader 已安装的可选 peer `node-addon-require-builtin` 使纯 Node 下构建后的 bin 可以解析裸插件说明符。诊断使用 stderr,因为 stdout 是 ACP wire。 + +## 模型体验 + +模型通过 `dsh-agent-spine-demo` 和叶节点的面向模型插件间接获得体验。ACP 提示词文本会成为普通的已记录用户消息;协议元数据与权限选择不会进入模型请求。 + +#### KV Cache 影响 + +每个会话只追加;应用本身不添加请求前缀内容。 + +## 已知限制与延后工作 + +- **JSONL 持久化固定不变**:使用其他后端需要另一种组合。 +- **同级插件可能破坏 stdout**:应用无法阻止另一个条目写入非协议字节。 +- **只支持新建自动化会话**:恢复和人类交互属于其他前端入口。 diff --git a/packages/examples/agent-spine-demo/README.i18n.yaml b/packages/examples/agent-spine-demo/README.i18n.yaml new file mode 100644 index 0000000000..fe005dcae8 --- /dev/null +++ b/packages/examples/agent-spine-demo/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 736de2ea01e1524854c57f91d128b82a9fe0c9e8 +README.zh.md: 4ffe47ba82539d12c9b74b1690392d58d21a24b1 diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index 05ea5c75e2..736de2ea01 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-agent-spine-demo +English | [中文](README.zh.md) + The **default executor-less, UI-less agent spine** as ONE Cordis bundle plugin. It loads the fixed set of services every harness agent needs, including the local skill provider, and forwards the loop's `agents` list as its own config — so an app package composes a working agent by adding only a front door and the swappable backends. Read this package for the whole plugin tree and its composition order. diff --git a/packages/examples/agent-spine-demo/README.zh.md b/packages/examples/agent-spine-demo/README.zh.md new file mode 100644 index 0000000000..4ffe47ba82 --- /dev/null +++ b/packages/examples/agent-spine-demo/README.zh.md @@ -0,0 +1,83 @@ +# @deepseek-ai/dsh-agent-spine-demo + +[English](README.md) | 中文 + +将 **默认的不含执行器、不含 UI 的 agent 主干** 作为一个 Cordis 组合包插件。它加载每个 harness agent 所需的固定服务集合,包括本地 skill 提供方,并将循环的 `agents` 列表作为自身配置转发。因此,应用包只需添加前端入口和可替换后端,就能组合出可工作的 agent。 + +阅读此包可了解完整插件树及其组合顺序。 + +## 它加载的插件树 + +`apply(ctx, config)` 将以下每个插件挂载为组合包 fiber 的子节点: + +``` +@cordisjs/plugin-timer timer service (writes nothing to stdout) +@deepseek-ai/dsh-llm abstract LLM service + content-block vocabulary +@deepseek-ai/dsh-session event-sourced session log + store +@deepseek-ai/dsh-session-title log-backed title service + deterministic fallback +@deepseek-ai/dsh-system-prompt prompt-section + tool-schema assembly +@deepseek-ai/dsh-tools registry + guarded pre/around/post/final-result pipeline +@deepseek-ai/dsh-skill skill provider registry +@deepseek-ai/dsh-skill-local local filesystem skill provider +@deepseek-ai/dsh-agent agent registry + initiator scope + agent/* events +@deepseek-ai/dsh-goal optional persisted same-session goal domain +@deepseek-ai/dsh-tool-goal optional model-facing goal controls +@deepseek-ai/dsh-goal-session optional same-session goal-round driver +@deepseek-ai/dsh-llm-retry bounded transient request retry policy +@deepseek-ai/dsh-tasks generic background-task registry +@deepseek-ai/dsh-invariants configurable invariant registry service +@deepseek-ai/dsh-session/invariant +@deepseek-ai/dsh-agent/invariant +@deepseek-ai/dsh-scope/invariant +@deepseek-ai/dsh-agent-loop/invariant + package-owned relational checks +@deepseek-ai/dsh-tool-bash the model-facing bash schema +@deepseek-ai/dsh-workspace-context AGENTS.md/CLAUDE.md workspace context loader +@deepseek-ai/dsh-tool-skill session-prefix skill catalog + model-facing loader schema +@deepseek-ai/dsh-tool-tasks task_output/task_list/task_kill schemas + completion notices +@deepseek-ai/dsh-agent-loop THE concrete loop (gets the forwarded `agents`) + (dsh-system-prompt gets the forwarded `persona`) +``` + +## 有意留在组合包外的组件 + +主干包含每个前端入口都共有的全部组件。可替换组件和与前端入口耦合的组件留在外部,由加载组合包的一方选择: + +- **LLM 适配器**:组合包交付抽象 `llm` 服务;叶节点在 `ctx.llm` 上注册具体适配器(`llm-deepseek`、`llm-pi-ai`、`llm-replay`)。 +- **模型支持的会话标题提供方**:组合包挂载带可覆盖示例限制的后备服务(5 个词、40 个后备字节、80 个可接受标题字节);叶节点可以恰好选用一个首消息或全消息 LLM 提供方。 +- **bash 执行器**:组合包交付 `tool-bash`(消费方 schema);叶节点提供 `ctx.bash`(`bash-local` 或沙箱化实现)。 +- **非本地 skill 提供方**:组合包交付 skill 注册表、本地文件系统提供方和 `skill` 工具;部署可以把嵌入式目录或远程目录等其他提供方作为同级插件添加。 +- **前端入口与各应用基础设施**:终端 TUI 或 ACP 自动化传输,以及 `hmr`。应用包([`dsh-tui-demo`](../tui-demo/README.md)、[`dsh-acp-demo`](../acp-demo/README.md))拥有这些选择。`timer` 位于主干中,因为它是共有组件且不写 stdout;前端入口拥有 stdout,因此留在组合包外。 + +这把[接口/实现/消费方 seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md) 提升到组合层:组合包拥有共享主干,叶节点拥有后端,应用包拥有前端入口。 + +## 配置 + +```ts +import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' +// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, toolTasks?, goals?, invariants?, llmRetry? } +// workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. +``` + +组合包将每个字段转发给拥有它的子节点:`agents` 与 `maxParallelToolCalls` 交给 `agent-loop`(`agents` 默认为 `[]`,上限在该处默认),因此每个应用提供自己的预创建 agent;TUI 和无头应用预创建 `main`,ACP 应用则在 `session/new` 按需创建 agent;`llmRetry` 交给有界重试策略;`persona` 与 `toolOrder` 交给 `dsh-system-prompt`;`tools` 交给工具注册表以配置呈现 mode;`sessionTitle` 交给后备标题服务;`skills.registry`、`skills.local` 与 `skills.tool` 分别交给 skill 注册表、本地提供方和面向模型的消费方;必填的 `workspaceContext` 选择交给 `dsh-workspace-context`(`{ maxBytes }` 启用加载,`false` 禁用);`invariants` 交给不变式服务;`toolBash`/`toolTasks` 交给组合包拥有的两个面向模型工具插件。省略 `sessionTitle` 时采用显式示例策略:5 个词、40 个后备字节、80 个可接受标题字节。`goals` 对象会选用持久领域、模型工具和同会话驱动器,并将 `goals.domain` 与 `goals.tool` 转发给各自拥有者;省略或设为 `false` 会让整个栈缺席,使无头调用方继续以一轮结算。设置 `skills.enabled: false` 会同时省略本地提供方和面向模型的 skill 工具;设置 `toolTasks: false` 会保留供前台生产方使用的任务服务,但不公开 `task_output`/`task_list`/`task_kill`。它对 `dshHome` 只解析一次,解析通过 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 完成,并将所得绝对值转发给 tool-bash 的托管环境和已启用的本地 skill 发现。顶层 `dshHome` 缺席时采用 `skills.local.dshHome`;两者同时提供但解析后的路径不同会明确失败。`toolBash.enableRunInBackground` 只控制 bash 生产方;独立加载的生产方保留各自配置。Workspace 指令先于 skill 目录注册,因此其会话前缀消息先渲染。应用包使用 `pickSpineConfig()`,只复制这些由组合包拥有的字段。 + +例如,`{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` 会让包拥有的配套插件保持挂载,但抑制被阻止的拥有者。Blocklist 匹配优先于 allowlist 匹配;正则表达式与生命周期规则见 [`dsh-invariants`](../../support/invariants/README.md)。 + +## 为何使用代码组合包,而非共享 YAML include + +YAML include 可以去重配置,却无法拥有 bin 或提供前端入口默认值。ACP 应用包默认接出协议纯净的 stdout,但叶节点仍可添加不安全的 logger。组合包子节点把服务注册到根 isolate-keyed store,因此注入这些服务的叶节点同级插件无需依赖加载顺序即可看到它们。 + +有界重试策略可能在新的编号步骤中重复瞬时失败的请求。重试状态和失败的部分 chunk 不进入模型历史;每次提供方尝试仍可能产生计费;前端入口从所有已记录步骤推导用量;重建的请求保留先前前缀,以便复用提供方 cache。 + +## 模型体验 + +模型通过 `dsh-system-prompt`、`dsh-tool-skill`、`dsh-tool-bash`、`dsh-tools` 和 `dsh-llm-retry` 间接获得体验;还会通过 `dsh-tool-goal` 与目标轮次提示词获得体验,前提是启用 `goals`。组合包自身不添加面向模型的包装内容。 + +#### KV Cache 影响 + +不会直接失效;具名消费方拥有请求前缀的任何变更。 + +## 已知限制与延后工作 + +- **大部分主干集合固定在代码中**:`apply()` 始终挂载核心服务与 `tool-bash`;配置可以省略组合包内的目标、skill 与任务控制工具,但要替换循环或删除其他主干成员,就必须组合另一个 bundle。 +- **不变式 seam 与配套插件仍是固定成员**:`invariants.enabled: false` 或包筛选器会抑制检查,但不会移除服务或配套插件注册;Session 始终启用的校验与冻结是另一套机制。 diff --git a/packages/examples/cli-demo/README.i18n.yaml b/packages/examples/cli-demo/README.i18n.yaml new file mode 100644 index 0000000000..474225a1c3 --- /dev/null +++ b/packages/examples/cli-demo/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 4e8e5388e17ab2879582286adf593c72fb2cf78f +README.zh.md: 3cad72071184403a78b7906d637bba67bea1a64a diff --git a/packages/examples/cli-demo/README.md b/packages/examples/cli-demo/README.md index 8a931cc147..4e8e5388e1 100644 --- a/packages/examples/cli-demo/README.md +++ b/packages/examples/cli-demo/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-cli-demo +English | [中文](README.zh.md) + Headless one-shot app and bin for running one agent task without an interactive UI or editor client. It composes [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md), JSONL persistence, and exactly one fresh top-level agent. The bin submits the task, waits for its durable turn ending, renders the selected output, disposes to quiescence, and exits. The package mounts no console logger, interactive UI, user-interaction service, or `ask_user_question` tool. Stdout is reserved for the selected output format; diagnostics use stderr. diff --git a/packages/examples/cli-demo/README.zh.md b/packages/examples/cli-demo/README.zh.md new file mode 100644 index 0000000000..3cad720711 --- /dev/null +++ b/packages/examples/cli-demo/README.zh.md @@ -0,0 +1,79 @@ +# @deepseek-ai/dsh-cli-demo + +[English](README.md) | 中文 + +无头单次应用及 bin,用于在没有交互式 UI 或编辑器客户端的情况下运行一项 agent 任务。它组合 [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md)、JSONL 持久化,以及恰好一个新建顶层 agent。Bin 提交任务,等待其持久轮次结束,渲染所选输出,释放至静默,然后退出。 + +该包不挂载 console logger、交互式 UI、用户交互服务或 `ask_user_question` 工具。Stdout 专用于所选输出格式;诊断使用 stderr。 + +## 配置 + +| 键 | 默认值 | 路由目标 | +|---|---|---| +| `provider` | 必填 | 已配置 agent 的提供方路由 | +| `model` | 必填 | 已配置 agent 的模型 | +| `maxParallelToolCalls` | agent-loop 默认值 | 正整数并发工具调用上限;`1` 表示串行 | +| `persona` | 无 | `dsh-system-prompt` 中的部署 persona | +| `toolOrder` | 字典序 | `dsh-system-prompt` 中显式的面向模型工具顺序 | +| `tools` | `{ mode: 'native' }` | 通过 `dsh-agent-spine-demo` 提供的工具注册表呈现配置 | +| `dshHome` | `$DSH_HOME` 或 `~/.dsh` | 向模型 bash 公开并用于本地 skill 发现的 Harness 主目录 | +| `sessionTitle` | 主干示例限制 | 通过 `dsh-agent-spine-demo` 提供的后备标题词数/字节限制 | +| `skills` | 拥有者默认值 | Skill 注册表、本地提供方和面向模型的 skill 工具 | +| `toolBash` | 拥有者默认值 | 面向模型的 bash 配置,包括此生产方对后台任务的选用 | +| `toolTasks` | 拥有者默认值 | 通用 `task_output` 等待边界 | +| `llmRetry` | 拥有者默认值 | 有界的瞬时模型请求重试策略 | +| `persistenceRoot` | `./.sessions` | JSONL 会话根目录 | +| `persistenceCompression` | `'zstd'` | JSONL 工件编码(`'zstd'` 或原始 `'none'`) | +| `workspaceContext` | 必填 | Workspace 指令字节预算,或以 `false` 禁用加载 | + +## CLI 契约 + +```sh +dsh-cli-demo [--config path] [--output-format text|json|stream-json] <task> +``` + +`--config` 默认为 `./cordis.yml`;`--output-format` 默认为 `text`。必须恰好提供一个非空位置任务,因此含空格的任务需要加引号。`--help` 在不启动的情况下打印用法。不存在 `-p` 或 `--print` 标志。 + +根 headless-agent 示例提供其叶节点: + +```sh +pnpm run demo:headless "inspect the failing test and fix it" +``` + +Loader 配置通过仓库安装的可选原生辅助程序解析裸包说明符,因此根命令不需要特殊 Node 标志。 + +### 输出格式 + +- `text` 写入最后一条含文本的 assistant 消息,后跟一个换行符。 +- `json` 写入一条 DSH 原生结果记录:`{ type: "result", success, sessionId, turn, result, reason, usage? }`。`usage` 对任务轮次中的每个模型步骤恰好求和一次,包括产生用量但没有提交 assistant 消息的已计费失败重试。 +- `stream-json` 将顶层会话任务轮次中的每个规范事件写成 `{ type: "session_event", sessionId, event }`,然后写入同一结果记录。子 agent 活动只通过父工具事件与结果出现。 + +只有 `reason.kind === "completed"` 会成功退出。其他持久轮次结尾仍会输出部分文本或结果记录,向 stderr 添加诊断,并以非零状态退出。参数和启动失败会让 stdout 保持为空。SIGINT 与 SIGTERM 会取消活动工作,等待释放,并分别以 130 和 143 退出。 + +任务轮次会在最终输出前显式刷新。进程退出后,会话日志仍保留在 `persistenceRoot` 下。 + +## 操作安全 + +Headless-agent 叶节点提供本地 bash、文件系统、skill、subagent、工作流和 todo 能力。因此任务可以修改启动 workspace、运行命令、生成子 agent,并消耗提供方 token。请从目标项目目录运行 CLI,检查叶节点的能力与沙箱配置,不要把非交互式执行当作批准边界。 + +## 模型体验 + +### 单次任务轮次 + +#### 模型所见 + +位置任务会成为一条用户消息。通过 `dsh-agent-spine-demo`,顶层 agent 还会收到已配置的 workspace 指令与 persona、skill 目录、可见工具 schema,以及同一轮次后续步骤所需的保留工具结果。 + +#### Token 影响 + +每个模型步骤中的任务、提示词段、工具 schema、assistant 输出和工具结果都会消耗 token。JSON 事件流和最终渲染不增加模型 token;委派的子工作有自己的模型用量,不计入父结果的 `usage` 总量。 + +#### KV Cache 影响 + +只要单次 agent 的提示词、schema、模型路由和会话前缀保持不变,工具轮次历史就只追加。改变该组合会建立不同的请求前缀;JSON 输出 mode 不影响 cache。 + +## 已知限制与延后工作 + +- **每个进程只创建一个新的顶层会话**:其 workspace cwd 是启动目录;此应用不支持恢复、第二条提示词、stdin 上下文或并发顶层会话。 +- **没有交互式问题或批准提供方**:需要人类回答的工具无法完成,除非其他叶节点按显式策略组合一个非交互式提供方。 +- **流只包含顶层会话**:子会话不会平铺到流中,聚合用量只涵盖父任务轮次记录的模型步骤。 diff --git a/packages/examples/cli-demo/tsconfig.json b/packages/examples/cli-demo/tsconfig.json index df5758b7b8..095f7ce6a3 100644 --- a/packages/examples/cli-demo/tsconfig.json +++ b/packages/examples/cli-demo/tsconfig.json @@ -3,8 +3,7 @@ "compilerOptions": { "composite": true, "rootDir": "src", - "outDir": "lib/types", - "tsBuildInfoFile": "../../../.typecheck/cli-demo.tsbuildinfo" + "outDir": "lib/types" }, "include": ["src/**/*.ts"], "references": [ diff --git a/packages/examples/jsonrpc-demo/README.i18n.yaml b/packages/examples/jsonrpc-demo/README.i18n.yaml new file mode 100644 index 0000000000..3b3bbdf8b3 --- /dev/null +++ b/packages/examples/jsonrpc-demo/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 75e9e3943982c08e53afdbb73d1e9085b2e332bc +README.zh.md: 0e0f8ba33ab4cfbf57e28219a553a5d042b1cbed diff --git a/packages/examples/jsonrpc-demo/README.md b/packages/examples/jsonrpc-demo/README.md index 083d9a83ce..75e9e39439 100644 --- a/packages/examples/jsonrpc-demo/README.md +++ b/packages/examples/jsonrpc-demo/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-jsonrpc-demo +English | [中文](README.zh.md) + Bin-only app that boots an external `cordis.yml`; its [`jsonrpc`](../../ui/jsonrpc/README.md) entry serves SDK clients over newline-delimited stdio. The config composes the spine, backends, and serving plugin. `lib/bin.js` is also the [single-executable runtime](../../../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) entry. ## Config discovery diff --git a/packages/examples/jsonrpc-demo/README.zh.md b/packages/examples/jsonrpc-demo/README.zh.md new file mode 100644 index 0000000000..0e0f8ba33a --- /dev/null +++ b/packages/examples/jsonrpc-demo/README.zh.md @@ -0,0 +1,33 @@ +# @deepseek-ai/dsh-jsonrpc-demo + +[English](README.md) | 中文 + +只包含 bin 的应用,启动外部 `cordis.yml`;其 [`jsonrpc`](../../ui/jsonrpc/README.md) 入口通过按换行分隔的 stdio 为 SDK 客户端提供服务。配置负责组合主干、后端和服务插件。`lib/bin.js` 也是[单文件可执行 runtime](../../../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) 的入口。 + +## 配置发现 + +第一个非空通道生效:先 `$DSH_CORDIS_CONFIG`,再位置参数 `argv[2]`。如果二者都没有指向现有文件,bin 会向 stderr 打印单行用法并以 1 退出;没有工作目录回退或内置回退。[`dsh-app-boot`](../../ui/app-boot/README.md) 会使插件加载失败成为致命错误。此协议不使用 `DSH_SNAPSHOT`。 + +不含 `dsh-jsonrpc` 的配置仍然有效,只是不提供任何服务;bin 不会指定服务器插件。 + +## 退出生命周期 + +stdin EOF 和 `SIGTERM` 会将根上下文释放至静默并以 0 退出;`SIGINT` 完成同样的释放后以 130 退出。EOF 可能按[分发 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) 所述截断正在处理的轮次。`jsonrpc` 插件拥有先响应再退出的协议关闭流程;两条路径均幂等,可以安全竞态。 + +## stdout 是协议 + +stdout 只承载 JSON-RPC 帧。bin 和启动守卫在 stderr 上输出诊断,配置必须省略 stdout logger。 + +## 模型体验 + +模型通过外部 `cordis.yml` 加载的插件间接获得体验;每个插件拥有自身面向模型的提示词、schema、消息和结果,此 bin 不添加任何内容。 + +#### KV Cache 影响 + +不会直接失效;具名消费方拥有请求前缀的任何变更。 + +## 已知限制与延后工作 + +- **bin 无法证明配置提供 JSON-RPC 服务**:不含 `dsh-jsonrpc` 条目的有效配置也能成功启动,但不会提供任何服务。 +- **不存在内置或默认配置**:每次启动都必须提供 `DSH_CORDIS_CONFIG` 或位置路径;部署拥有完整插件树和 stdout 纪律。 +- **stdin EOF 会截断正在处理的工作**:客户端消失时立即释放根上下文;需要有序完成的调用方应使用协议级 `shutdown` 请求。 diff --git a/packages/examples/tui-demo/README.i18n.yaml b/packages/examples/tui-demo/README.i18n.yaml new file mode 100644 index 0000000000..bf1e760913 --- /dev/null +++ b/packages/examples/tui-demo/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 058ebe87af5f041bd19fbfb205a97753ccacf6b9 +README.zh.md: 254bee76dff0d400a7b133013e8322898599f73e diff --git a/packages/examples/tui-demo/README.md b/packages/examples/tui-demo/README.md index e2ad4d2ec5..058ebe87af 100644 --- a/packages/examples/tui-demo/README.md +++ b/packages/examples/tui-demo/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-tui-demo +English | [中文](README.zh.md) + The full-screen terminal app bundle: a Cordis plugin that composes [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md), persisted same-session goals, the human-command registry and `/goal` producer, JSONL persistence, keyboard-backed user interaction, a pre-created `main` agent, and [`@deepseek-ai/dsh-tui`](../../ui/tui/README.md). A `cordis.yml` mounts it as one entry; the [`dsh`](../../../apps/cli/README.md) CLI is the front door that boots such a config. Use [`@deepseek-ai/dsh-cli-demo`](../cli-demo/README.md) for pipes, scripts, and other non-interactive runs. This bundle requires a TTY pair and has no line-oriented fallback. diff --git a/packages/examples/tui-demo/README.zh.md b/packages/examples/tui-demo/README.zh.md new file mode 100644 index 0000000000..254bee76df --- /dev/null +++ b/packages/examples/tui-demo/README.zh.md @@ -0,0 +1,112 @@ +# @deepseek-ai/dsh-tui-demo + +[English](README.md) | 中文 + +全屏终端应用组合包:一个 Cordis 插件,组合 [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md)、持久的同会话目标、人类命令注册表与 `/goal` 生产方、JSONL 持久化、键盘支持的用户交互、预创建的 `main` agent,以及 [`@deepseek-ai/dsh-tui`](../../ui/tui/README.md)。一份 `cordis.yml` 将它作为单个条目挂载;[`dsh`](../../../apps/cli/README.md) CLI 是启动此类配置的前端入口。 + +管道、脚本和其他非交互式运行应使用 [`@deepseek-ai/dsh-cli-demo`](../cli-demo/README.md)。此组合包需要一对 TTY,不提供面向行的回退。 + +## 内置组件 + +| 插件 | 设置在此处的原因 | +|---|---| +| `@deepseek-ai/dsh-agent-spine-demo` | 共享服务、面向模型的工具,以及一个已配置的 `main` agent | +| `@deepseek-ai/dsh-commands` | 供 TUI 和命令插件消费的纯人类命令发现与分发 | +| `@deepseek-ai/dsh-command-goal` | 直接在主干的持久目标栈上提供 `/goal` 状态与变更 | +| `@deepseek-ai/dsh-session-persistence-jsonl` | 位于 `persistenceRoot` 下的持久会话日志 | +| `@deepseek-ai/dsh-session-checkpoint-policy` | 模型请求和顶层工具 effect 前的语义持久性屏障,以及已完成步骤的检查点 | +| `@deepseek-ai/dsh-session-query-sqlite` + `@deepseek-ai/dsh-session-reference` | TUI 消费的组合式精确/FTS 会话查询与有界 `@session` 快照;面向模型的查询工具仍由叶节点选用 | +| `@deepseek-ai/dsh-user-interaction` | 与提供方无关的人类问题服务 | +| `@deepseek-ai/dsh-tui` | 全屏记录、编辑器、工具卡片、计划与问题 overlay | +| `@deepseek-ai/dsh-tool-ask-user` | 面向模型的 `ask_user_question` 工具 | + +可替换的 LLM、bash、文件系统和其他能力提供方仍留在叶节点配置中。`@cordisjs/plugin-hmr` 也仍是仅叶节点使用的开发条目,因为它需要 Loader 内部表层。 + +## 配置 + +| 键 | 默认值 | 路由目标 | +|---|---|---| +| `provider` | 必填 | 已配置 `main` agent 的提供方 | +| `model` | 必填 | 已配置 `main` agent 的模型 | +| `maxParallelToolCalls` | agent-loop 默认值 | 组合包内循环的并发上限 | +| `persona` | 无 | 系统提示词 persona 模板 | +| `toolOrder` | 字典序 | 显式的面向模型工具顺序 | +| `tools` | 拥有者默认值 | 工具呈现 mode | +| `dshHome` | 拥有者默认值 | bash 与 skill 使用的 Harness 主目录 | +| `sessionTitle` | 主干示例限制 | 后备标题词数/字节限制 | +| `skills` | 拥有者默认值 | Skill 注册表、本地提供方和工具配置 | +| `toolBash` | 拥有者默认值 | 面向模型的 bash 工具配置 | +| `toolTasks` | 拥有者默认值 | 后台任务控制工具配置,或 `false` | +| `goals` | 拥有者默认值 | 持久目标领域与模型工具配置;`false` 会移除目标栈与 `/goal` 生产方 | +| `workspaceContext` | 必填 | Workspace 指令配置,或 `false` | +| `persistenceRoot` | `./.sessions` | JSONL 持久化根目录,以及派生 `session-query.db` 索引的父目录 | +| `persistenceCompression` | `'zstd'` | JSONL 工件编码(`'zstd'` 或原始 `'none'`) | +| `sessionReferences` | 服务默认值 | 路由到 `dsh-session-reference` 的跨会话候选项与快照限制 | +| `welcome` | `ready.` | TUI 副标题 | +| `resumeCommand` | 无 | 退出和无宿主回退的命令模板;选择器本身使用会话查询与宿主移交 | +| `ui` | 拥有者默认值 | 推理、颜色、卡片高度等 TUI 呈现设置 | +| `resumeSessionId` | 无 | 要恢复的确切持久化会话 | + +新运行会创建 `main-session-<uuid>` 会话 id,并将它同时传给 TUI 与已配置的 agent。恢复运行会将两个组件都绑定到 `resumeSessionId`。TUI 先于主干挂载,因此它可以渲染匹配的配置启动失败,而不会留下空白终端。应用为 `/resume` 组合持久化和会话查询;嵌入宿主还可以提供 `tuiResumeHost`,以原地移交进程。 + +## 前端入口 + +此包不交付 bin。[`dsh`](../../../apps/cli/README.md) CLI 是终端前端入口:裸 `dsh` 启动已交付的 `examples/tui-agent/cordis.yml`(它挂载此组合包),而 `dsh --config <path-to-cordis.yml>` 启动另一个挂载此组合包的叶节点配置。它加载 cwd 下可选的 `.env`,驱动 Cordis Loader,并等待完整插件树。仓库安装了 Loader 的可选原生辅助程序,因此裸包说明符可以在纯 Node 下解析。 + +## 叶节点示例 + +```yaml +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY +- id: bash + name: '@deepseek-ai/dsh-bash-local' +- id: tui-agent + name: '@deepseek-ai/dsh-tui-demo' + config: + provider: deepseek + model: deepseek-v4-flash + workspaceContext: + maxBytes: 65536 + welcome: 'Coding agent ready.' + ui: + showReasoning: true +``` + +## 模型体验 + +### 交互式终端轮次 + +#### 模型所见 + +每次非空、非命令的编辑器提交都会成为用户消息;运行中轮次内的提交成为 steering。斜杠命令输入和输出仍只面向人类,而已接受的 `/goal` 变更会追加领域拥有的模型可见状态。共享主干提供已配置的 persona、workspace 指令、skill 目录、目标控制和可见工具 schema。TUI 渲染本身对模型不可见。 + +#### Token 影响 + +用户、assistant 与工具历史按常规会话和压缩规则增长。Header、卡片、计划、Markdown 样式和快捷键不增加 token。 + +#### KV Cache 影响 + +只要组合后的提示词、schema、路由和保留历史前缀保持稳定,就只追加。组合变更与压缩可能从第一个变化的 token 起使复用失效。 + +### 人类问题答案 + +#### 模型所见 + +`ask_user_question` 会保留工具调用,以及 `dsh-tool-ask-user` 定义的精简答案或稳定中断错误。问题 overlay 只在终端显示。 + +#### Token 影响 + +只有已完成或失败的工具结果会增加保留 token。 + +#### KV Cache 影响 + +只追加;答案跟在可复用请求前缀之后。 + +## 已知限制与延后工作 + +- **只支持 TTY**:stdin 与 stdout 都必须是终端;自动化使用 `dsh-cli-demo`。 +- **一个已配置的终端会话**:记录与编辑器绑定到一个确切会话 id。 +- **应用集群固定不变**:JSONL 持久化与 ask-user 工具内置;不同策略需要另一种组合。 +- **批准机制独立存在**:此应用回答 `ctx.userInteraction`,而不是 `ctx.approval`;权限提示需要批准服务和回答方。 diff --git a/packages/fs/README.i18n.yaml b/packages/fs/README.i18n.yaml new file mode 100644 index 0000000000..1ead814be1 --- /dev/null +++ b/packages/fs/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 4d954455ea920be4882530bcfe90b48a364c29b5 +README.zh.md: e818210abaded987edbb8bf38c6d1b43d40ad9c7 diff --git a/packages/fs/README.md b/packages/fs/README.md index 161387160e..4d954455ea 100644 --- a/packages/fs/README.md +++ b/packages/fs/README.md @@ -1,5 +1,7 @@ # fs/ - filesystem capability family +English | [中文](README.zh.md) + The filesystem stack: a provider seam (text IO + atomic mutation with an optional version guard), a local implementation, a policy gate plugin (observed-state + read-before-edit + version-guarded write/edit), the model-facing file tools + executor, and the bash-backed discovery tools. All **product** packages. | Package | Role | ctx key | diff --git a/packages/fs/README.zh.md b/packages/fs/README.zh.md new file mode 100644 index 0000000000..e818210aba --- /dev/null +++ b/packages/fs/README.zh.md @@ -0,0 +1,20 @@ +# fs/:文件系统能力族 + +[English](README.md) | 中文 + +文件系统栈包括:提供方 seam(文本 I/O 与带可选版本防护的原子变更)、本地实现、政策门禁插件(已观察状态、编辑前读取、版本防护的写入/编辑)、面向模型的文件工具与执行器,以及基于 bash 的发现工具。全部都是**产品** 包。 + +| 包 | 角色 | ctx 键 | +|---|---|---| +| `fs/` | 提供方 seam:文本 I/O 与原子变更原语(可选版本防护);拥有 `fs/*` 政策事件 | `ctx.fs` | +| `fs-local/` | 本地文件系统 `FileSystem` 实现 | (注册 `ctx.fs`) | +| `fs-sandbox/` | 强制沙箱的 `FileSystem`:扩展 `fs-local`,并按每次调用的模式与工作区根政策约束写入/编辑(只读模式拒绝,工作区写入模式限制在会话工作区与临时根目录内);读取直接通过 | (注册 `ctx.fs`) | +| `fs-policy/` | 政策门禁插件:通过 `fs/*` 事件门禁提供已观察状态、编辑前读取和版本防护的写入/编辑 | (无服务,仅有 `fs/*` 监听器) | +| `tool-fs/` | 面向模型的 `read`/`write`/`edit` 工具以及执行器(通过 `ctx.fs` 读取,拥有读取窗口逻辑,分派 `fs/*`);为会话 cwd 相对路径保留文件系统语义,并在已挂载的 `ctx.fs` 实施约束时声明沙箱升级字段 | (注册到 `ctx.tools`) | +| `tool-fs-search/` | 面向模型的 `glob`/`grep` 发现工具;当 `rg` 位于 bash 执行器 `PATH` 上时注册,通过 `ctx.bash` 运行固定 ripgrep 命令,而不是使用 `ctx.fs` 提供方方法 | (注册到 `ctx.tools`) | + +接口位于 `fs/fs/`。沙箱化、远程或限定项目作用域的文件系统后端可以替换 `fs-local`,而无需更改 seam、政策门禁或面向模型的工具 schema;`fs-sandbox` 是第一个这样的替代实现(基于共享沙箱模式的进程内路径围栏;见[跨能力族 fs 沙箱 Agent Note](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md))。政策(`fs-policy/`)是一个只通过 `fs/*` 事件门禁参与的插件,不是工具注入的服务;因此移除它会平稳失去政策,留下不受约束的裸提供方,而不会破坏工具。加载 `tool-fs/` 的部署也应加载该插件。模式围栏与编辑前读取门禁彼此正交,可以组合。发现(`tool-fs-search/`)有意不扩展提供方 seam:搜索是在 bash 执行器上运行 `rg`、由进程支持的工作流,因此文件系统后端无需承担通用搜索契约;只有当执行器能找到 `rg` 时,其工具才会注册。如果 bash 工作目录与 `read` 根目录是同一工作区,结果就能继续读取,这也是其 README 所述的共置部署。 + +## 文件 I/O 不设超时 + +`read`/`write`/`edit` **不** 接受 `timeoutMs`,提供方 seam 也不启动 deadline。这与 bash 和 web(两者使用 [`@deepseek-ai/dsh-timeout`](../util/timeout/README.md))及基于 bash 的 `glob`/`grep` 不同(它们声明的 `timeoutMs` 由 `@deepseek-ai/dsh-timeout-policy` 强制执行):这些工作由进程支持,deadline 可以实际终止工作。本地系统调用至多只能尽力中止:超时无法强制正在进行的 `fsync`/`rename` 停止,因此这里的 deadline 会成为无法兑现承诺的配置项。在此添加 deadline 还会在「显式优于隐式」明确禁止的地方引入隐式默认值。两个参考 agent(Claude Code、Codex)出于同一原因都不为文件 I/O 计时;取消仍通过工具执行信号传播,在系统调用边界尽力中止。 diff --git a/packages/fs/fs-local/README.i18n.yaml b/packages/fs/fs-local/README.i18n.yaml new file mode 100644 index 0000000000..7533e614c9 --- /dev/null +++ b/packages/fs/fs-local/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 6d344fa3fef7f6bda6c0daa50184661156a925a7 +README.zh.md: 90813831768c09676cfd1b0f053e13e87c1c2fe8 diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md index ce2b013328..6d344fa3fe 100644 --- a/packages/fs/fs-local/README.md +++ b/packages/fs/fs-local/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-fs-local +English | [中文](README.zh.md) + The **local-filesystem implementation** of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)). Backs the eight `FileSystem` primitives with the host filesystem; loading it as a plugin populates `ctx.fs`. ```ts ignore-check diff --git a/packages/fs/fs-local/README.zh.md b/packages/fs/fs-local/README.zh.md new file mode 100644 index 0000000000..9081383176 --- /dev/null +++ b/packages/fs/fs-local/README.zh.md @@ -0,0 +1,41 @@ +# @deepseek-ai/dsh-fs-local + +[English](README.md) | 中文 + +`ctx.fs` 提供方 seam([`@deepseek-ai/dsh-fs`](../fs))的**本地文件系统实现**。它使用宿主文件系统支持八个 `FileSystem` 原语;将其作为插件加载会填充 `ctx.fs`。 + +```ts ignore-check +import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' + +await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) +// ctx.fs uses the local backend; load @deepseek-ai/dsh-fs-policy for the +// freshness policy gate and @deepseek-ai/dsh-tool-fs to expose read/write/edit. +``` + +## 行为 + +- **`resolve(path, opts?)`**:相对 `path` 在调用方提供 `opts.cwd` 时以该值为基准解析(面向模型的工具会传入调用 agent(智能体)的会话 cwd;见[每会话 cwd Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md)),否则以 `config.cwd` 为基准(默认 `process.cwd()`);绝对 `path` 会忽略两者。`opts.signal` 会在本地解析前后检查,远程同级后端则可以用它中止往返。`targetKey` 是文件的 `realpath`,因此经符号链接到达同一文件的两个输入路径会共享一个身份,写入/编辑落在链接目标上,同时保留链接。尚不存在的路径在父目录存在时使用 realpath 后的父目录加 basename;只有父目录无法解析时才回退到绝对路径。`displayPath` 是绝对但未经解析的路径。 +- **`stat` / `lstat`**:返回目标元数据;目标不存在时返回 `undefined`。`stat` 为已解析目标报告 `FsInfo`(`version` 是由 bigint `dev:ino:size:mtimeNs:ctimeNs` 派生的不透明 token,`type` 为 `file`/`directory`/`other`,`size` 以字节计);路径形态的 `lstat` 不跟随最后一个符号链接,报告 `FsPathInfo`,因此可以返回 `symlink`。两者都会在异步元数据探测前后检查取消,因此飞行中的中止会报告 `FS_ABORTED`,而非陈旧的不存在结果。 +- **`readText` / `streamText`**:只支持 UTF-8。`readText` 读取整个文件;`streamText` 按分片流式读取(跨分片解码),因此超大文件无需整体保存在内存中。两者都会拒绝无效 UTF-8、包含 NUL 字节的二进制样本(`FS_NOT_TEXT`)以及非普通文件目标。`read` 工具(`@deepseek-ai/dsh-tool-fs`)按大小决定调用哪个方法,并拥有行窗口逻辑。 +- **`listDir`**:按稳定的 `name.localeCompare()` 顺序列出一层目录。每个条目携带子项 basename、类型、解析后的子目标(`displayPath` 位于所列目录下,`targetKey` 是 realpath 身份)和低成本 stat 元数据(`version`,普通文件另有 `size`)。它绝不会打开或解码文件内容。缺失目标报告 `FS_NOT_FOUND`,文件/特殊文件目标报告 `FS_NOT_DIRECTORY`,已中止调用报告 `FS_ABORTED`,权限失败报告 `FS_PERMISSION_DENIED`,其他列出或子项元数据 I/O 失败报告 `FS_IO_ERROR`。损坏/消失的子项以无元数据的 `other` 返回,但解析子项时出现权限/I/O 失败会让整个列表以结构化 `FsError` 失败。 +- **`writeText`**:原子写入。它会向排他打开的临时文件(`wx`、`0o600`)写入;该文件位于目标旁随机命名的私有暂存目录(`0o700`)内。完成写入和 fsync 后,以 rename 覆盖目标。现有文件的 mode 会保留,新文件默认为 `0o600`;Windows 上的新文件继承目标目录的 DACL,而替换会在写入前把目标 DACL 复制到空临时文件,并通过 `ReplaceFileW` 发布,使原访问政策得以保留(见 [Windows DACL 保留 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md))。`expected` 防护是可选的:省略时无条件创建或覆盖;`createIfAbsent` 创建缺失目标并拒绝现有目标(`FS_NOT_OBSERVED`);`replaceIfVersion` 只在观察到的版本上替换(目标缺失或版本不匹配均为 `FS_STALE_VERSION`)。 +- **`editText`**:在同一原语之上依次执行原子的字面量读取、修改和写入,并通过变更锁按目标串行化。`expected` 防护是可选的:提供时,会在字面量匹配之前校验版本(陈旧编辑报告 `FS_STALE_VERSION`,绝不会针对较新内容报告 `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT`);省略时,无条件编辑当前内容。无论哪种情况,目标缺失都报告 `FS_STALE_VERSION`。匹配时规范化为 LF,随后恢复文件主要的 CRLF/LF 风格;空 `oldString` / 零匹配报告 `FS_EDIT_NOT_FOUND`,未设置 `replace_all` 的多个匹配则报告 `FS_AMBIGUOUS_EDIT`。 + +包根 SDK 接口包含默认/具名 `LocalFileSystem` 类和 `Config`。原始 I/O 位于 `src/fsio.ts`(不依赖 Cordis,单独进行单元测试);`src/index.ts` 是轻量服务接线。 + +## 模型体验 + +通过 [`dsh-tool-fs`](../tool-fs/README.md) 间接产生影响;该消费方把本提供方带行窗口的 UTF-8 内容、变更确认和精确提供方消息渲染为有上限且保留的结果,而版本、原子写入机制和目录元数据保持内部可见。 + +#### KV Cache 影响 + +不会直接使缓存失效;具名消费方负责请求前缀的任何变化。 + +## 已知限制与延期工作 + +- **`config.cwd` 不是沙箱**:它是解析默认值,而非约束;绝对路径和 `..` 可以逃逸。请使用更严格的 `ctx.fs` 后端或 `tools/execute` waterfall(瀑布式事件)上的权限插件实施约束(见[能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md#consequences))。 +- **覆盖会把整个旧文件读入内存**:只用于 UI diff;在大小阈值之上限制这次预读取的工作延期处理(`TODO(overwrite-diff-bound)`)。 +- **版本 token 是 `mtimeMs:size`**:如果外部变更在文件系统时间戳粒度内保持两者不变,就能绕过陈旧防护。 +- **`editText` 会把整个文件及编辑后的副本保存在内存中**:只有读取路径支持流式处理。 +- **二进制检测不对称**:读取只对前 8192 字节执行 NUL 采样,编辑则扫描整个 buffer,因此 NUL 出现在后部的文件可以读取,但编辑会被拒绝。 +- **每目标变更锁仅限进程内**:其他进程中的写入方只会被可选版本防护发现,绝不会被串行化。 diff --git a/packages/fs/fs-policy/README.i18n.yaml b/packages/fs/fs-policy/README.i18n.yaml new file mode 100644 index 0000000000..548f9b530c --- /dev/null +++ b/packages/fs/fs-policy/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: dc4e9377793570c80b8d71ec84196bebe7fe583a +README.zh.md: 956eb132f8ba42cbdf585d21d86ec7098ecb3446 diff --git a/packages/fs/fs-policy/README.md b/packages/fs/fs-policy/README.md index 9d54491a8b..dc4e937779 100644 --- a/packages/fs/fs-policy/README.md +++ b/packages/fs/fs-policy/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-fs-policy +English | [中文](README.zh.md) + The **fs-policy plugin**: it adds observed-state, read-before-edit, and version-guarded write/edit on top of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)) — through the `fs/*` event gate, **NOT** through a method service. This plugin registers **no** `ctx.fsPolicy` service and has no public `read`/`write`/`edit`/`resolve` methods. It is the policy third of the filesystem stack: not a swappable seam, but the policy that does not belong on the `FileSystem` provider base class. ```ts diff --git a/packages/fs/fs-policy/README.zh.md b/packages/fs/fs-policy/README.zh.md new file mode 100644 index 0000000000..956eb132f8 --- /dev/null +++ b/packages/fs/fs-policy/README.zh.md @@ -0,0 +1,73 @@ +# @deepseek-ai/dsh-fs-policy + +[English](README.md) | 中文 + +**fs-policy 插件**:它在 `ctx.fs` 提供方 seam([`@deepseek-ai/dsh-fs`](../fs))之上增加已观察状态、编辑前读取和版本防护的写入/编辑;它通过 `fs/*` 事件门禁参与,**不是** 通过方法服务。该插件**不** 注册 `ctx.fsPolicy` 服务,也没有公开的 `read`/`write`/`edit`/`resolve` 方法。它是文件系统栈的政策层:不是可替换 seam,而是不应位于 `FileSystem` 提供方基类上的政策。 + +```ts +import type { Context } from 'cordis' +import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' + +declare const ctx: Context + +// No service to inject — this plugin only registers the three fs/* listeners. +// Load it alongside a ctx.fs provider (e.g. @deepseek-ai/dsh-fs-local) and the +// @deepseek-ai/dsh-tool-fs tools; the tools dispatch the fs/* events this plugin +// decides. Order does not matter for resolution (no inject), but the policy +// listener should be the first decider registered for the fs/*-intent slots. +await ctx.plugin(FsPolicy) +``` + +## 四层拆分 + +| 层 | 包 | 角色 | +|---|---|---| +| 工具/执行器 | `@deepseek-ai/dsh-tool-fs` | 面向模型的 schema、读取窗口和文本渲染;通过 `ctx.fs` 读取/写入/编辑,并分派 `fs/*` 事件 | +| 政策 | `@deepseek-ai/dsh-fs-policy`(本包) | 已观察状态、编辑前读取和版本防护的写入/编辑,通过 `fs/*` 事件门禁贡献(无服务) | +| 提供方 seam | `@deepseek-ai/dsh-fs` | `ctx.fs`:文本 I/O 与原子变更原语(可选版本防护);拥有 `fs/*` 事件词汇 | +| 提供方 | `@deepseek-ai/dsh-fs-local` | `ctx.fs` 的本地实现 | + +## 门禁的参与方式 + +三个 `fs/*` 事件(由 `@deepseek-ai/dsh-fs` 声明,`@deepseek-ai/dsh-tool-fs` 分派): + +| 事件 | 本插件的监听器 | +|---|---| +| `fs/write-intent` | 先前未观察 → `{ kind: 'createIfAbsent' }`;先前已观察 → `{ kind: 'replaceIfVersion', version: vObserved }`。单槽决策;不调用 `next()`。 | +| `fs/edit-intent` | 要求该所有者先前已观察,否则抛出 `FS_NOT_OBSERVED`;返回 `{ version: vObserved }` 作为 CAS 基础。单槽决策;不调用 `next()`。 | +| `fs/observed` | 为该所有者与目标记录 `{ version }`。同步、只有副作用的 `WeakMap.set`。 | + +## 已观察状态是先前观察记录;新鲜度由提供方 CAS 保证 + +已观察状态是一张从弱引用所有者映射到目标版本的表,每次读取或变更成功后都会更新;记录存在本身就是先前观察凭据。插件不执行文件系统 I/O:它把观察到的版本提供给提供方的原子变更防护。窗口读取会观察整个文件的版本,因此只有文件保持不变时才允许后续的定向编辑。插件 dispose(资源释放)时会丢弃状态,并且不会跨会话持久化。 + +## 单槽、先到者胜 + +`fs/write-intent`/`fs/edit-intent` 槽位只容纳一个决策器;本插件会完整决策,不调用 `next()`。槽位按注册顺序先到者胜;由本插件拥有槽位只是默认部署约定,不是事件强制的不变式(更早注册或通过 `prepend` 注册的决策器会胜出)。这不是可组合的授权链;分层权限/审计/沙箱拦截属于 `tools/execute`。 + +## 不与方法耦合 + +由于插件只通过事件影响外部世界,移除它不会在服务注入边界破坏 `@deepseek-ai/dsh-tool-fs`:工具会直接落到裸 `ctx.fs` 提供方(无条件写入/编辑,无已观察状态)。重新加载则会再次叠加政策。相比必需的方法服务,这种可平稳增删的性质正是事件门禁的全部目的。 + +## 模型体验 + +### 文件系统工具结果 + +#### 模型看到的内容 + +该插件不添加提示词或 schema。编辑前未读取时,它会以代码 `FS_NOT_OBSERVED` 和精确消息 `edit requires reading "<path>" first` 拒绝。观察版本陈旧的防护变更会传播由提供方拥有的 `FS_STALE_VERSION` 错误。[`dsh-tool-fs`](../tool-fs/README.md)拥有面向模型的错误包装;观察状态绝不会显示。 + +#### Token 影响 + +允许的操作除了普通工具结果外不增加 token。拒绝会添加少量保留的错误结果,并避免产生成功 payload。 + +#### KV Cache 影响 + +仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 + +## 已知限制与延期工作 + +- **已观察状态无法跨会话恢复**:`WeakMap` 记录的持久化延期处理,因此恢复的会话必须重新读取文件,才能执行防护写入/编辑。 +- **没有 agent 会话的参与者绝无法满足政策**:它们的编辑会抛出 `FS_NOT_OBSERVED`,写入总会解析为 `createIfAbsent`,因此非 agent(智能体)调用方无法通过门禁覆盖现有文件。 +- **直接 `ctx.fs` 读取不会发出 `fs/observed`**:在 `read` 工具之外读取的文件仍未观察;后续防护编辑会以 `FS_NOT_OBSERVED` 拒绝,直到工具读取该文件。 +- **授权依据是版本新鲜度,而非视图完整性**:任何窗口读取都会授权对未变文件执行全文件覆盖,这有意弱于完整视图规则(见 [seam 拆分 Agent Note](../../../.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md))。 diff --git a/packages/fs/fs-sandbox/README.i18n.yaml b/packages/fs/fs-sandbox/README.i18n.yaml new file mode 100644 index 0000000000..daa23d9bfe --- /dev/null +++ b/packages/fs/fs-sandbox/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 790444a4184b9bcccd3a0798cf0c09cb6f1b166e +README.zh.md: ad0adacda7cfde41f7dcb1d0603da3bad115e8fa diff --git a/packages/fs/fs-sandbox/README.md b/packages/fs/fs-sandbox/README.md index 53fb4324ce..790444a418 100644 --- a/packages/fs/fs-sandbox/README.md +++ b/packages/fs/fs-sandbox/README.md @@ -1,5 +1,7 @@ # dsh-fs-sandbox — the sandbox-enforcing filesystem backend +English | [中文](README.zh.md) + `SandboxedFileSystem` extends [`LocalFileSystem`](../fs-local/README.md) and registers as `ctx.fs`. It inherits every text-storage mechanic verbatim (resolve, stat, read/stream, list, the atomic write, the read-match-write edit critical section) and adds only a per-call MODE fence on `writeText`/`editText`. Reads always pass through — every mode permits reading. Loading it INSTEAD OF `dsh-fs-local`, together with a [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/README.md), is the whole swap; the model-facing tools (`dsh-tool-fs`) are untouched. The tool layer resolves the calling session's mode and cwd into the SAME per-call policy bash receives, so the two families never confine to different roots. diff --git a/packages/fs/fs-sandbox/README.zh.md b/packages/fs/fs-sandbox/README.zh.md new file mode 100644 index 0000000000..ad0adacda7 --- /dev/null +++ b/packages/fs/fs-sandbox/README.zh.md @@ -0,0 +1,35 @@ +# dsh-fs-sandbox:强制沙箱的文件系统后端 + +[English](README.md) | 中文 + +`SandboxedFileSystem` 扩展 [`LocalFileSystem`](../fs-local/README.md) 并注册为 `ctx.fs`。它逐字继承全部文本存储机制(解析、stat、读取/流式读取、列出、原子写入、按读取、匹配、写入顺序执行的编辑临界区),只为 `writeText`/`editText` 增加按调用的模式围栏。读取始终直接通过:所有模式都允许读取。 + +只需加载它来替代 `dsh-fs-local`,并同时加载 [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/README.md),即可完成替换;面向模型的工具(`dsh-tool-fs`)无需改动。工具层把调用会话的模式和 cwd 解析为与 bash 相同的按调用政策,因此两个能力族绝不会约束到不同根目录。 + +## 围栏 + +按调用政策携带有效模式(会话覆盖值或升级授权)和调用会话不可变的 cwd 根目录;只有没有会话的调用才回退到部署政策: + +- `read-only`:以结构化 `FS_SANDBOX_DENIED` 拒绝所有变更; +- `workspace-write`:只有目标规范化后位于可写根目录下,才允许变更。可写根包括工作区根目录和平台临时区域(`/tmp`、`os.tmpdir()`),与 Seatbelt profile 授权的集合相同;该集合由唯一的 [`writableRoots`](../../sandbox/README.md) 函数派生,使 fs 围栏与 bash runner 不会漂移。规范拼写使用词法快速路径;基于身份的祖先回退可以识别 Windows 长名称和 8.3 名称等别名等价根目录,而不会把无关前缀视为包含关系。委托前会立即重新规范化目标,因此工具解析后被替换的祖先符号链接也会被发现; +- `danger-full-access`:不加围栏直接委托。 + +## 威胁模型:政策围栏,而非内核边界 + +围栏是在可信代码中检查模型控制的路径。操作本身属于 seam(open、rename),只有目标路径不可信,因此「规范化后检查包含关系」就是该接口的完整答案。这与 `code-runtime` 的立场相同:提供约束,但不是安全边界。不可信代码的内核级隔离仍由 `ctx.bash` 负责([`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md))。剩余 TOCTOU(在包含关系复查与系统调用之间替换祖先符号链接)会通过写入前立即重新规范化来缩小,并为该威胁模型所接受;内核严密边界需要 `openat2` 一类原语,其可移植性成本在此不值得。 + +拒绝是结构化 `FsError`(`FS_SANDBOX_DENIED`,携带有效模式),不通过 stderr 文本推断(不同于 bash 的内核拒绝),因为进程内围栏准确知道自己拒绝了什么。面向模型的 `[sandbox: file access denied under <mode> mode]` 标记以及唯一一次获批的更宽权限重试位于工具层(`dsh-tool-fs`),与 bash 完全相同。见[跨能力族 fs 沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)。 + +## 模型体验 + +通过 `dsh-tool-fs` 间接产生影响;该消费方把本后端的 `FS_SANDBOX_DENIED` 拒绝渲染为 `[sandbox: file access denied under <mode> mode]` 标记和同轮次升级提示。 + +#### KV Cache 影响 + +不会直接使缓存失效;具名消费方负责请求前缀的任何变化。 + +## 已知限制与延期工作 + +- **政策围栏,而非内核边界**:该检查是可信代码处理模型控制的路径,因此解析到系统调用之间残留的 TOCTOU 会被原位重新规范化缩小,但不会消除;对抗性宿主进程不在范围内。不可信代码的内核级隔离仍属于 `ctx.bash`。 +- **围栏与 runner 的一致性来自派生,而非断言**:可写集合来自 `writableRoots`,该函数与 Seatbelt profile 共享,并由一致性测试固定;不通过该函数更改可写集合的 runner profile 会发生漂移。 +- **要求 `ctx.sandboxPolicy`**:工具使用它解析每个会话政策,后端用它处理无 agent 调用的回退;未组合该服务时,后端不会实施约束。 diff --git a/packages/fs/fs/README.i18n.yaml b/packages/fs/fs/README.i18n.yaml new file mode 100644 index 0000000000..0bde296f21 --- /dev/null +++ b/packages/fs/fs/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: a40b70e52172ae340321be53b127b7064c501f66 +README.zh.md: 1533e815429f845efe524c5fa2fa191708fd494b diff --git a/packages/fs/fs/README.md b/packages/fs/fs/README.md index 9209c237f8..a40b70e521 100644 --- a/packages/fs/fs/README.md +++ b/packages/fs/fs/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-fs +English | [中文](README.zh.md) + The **filesystem provider seam**: an abstract `FileSystem` service (`ctx.fs`) defining the storage primitives a backend provides — resolve a path, stat metadata, no-follow path metadata, read/stream text, list directories, write atomically, and apply a literal edit — without saying HOW. Both mutations take their version guard **optionally**, so `ctx.fs` on its own is a complete, unconstrained text-storage seam. This package also owns the `fs/*` policy event vocabulary the tool dispatches and the policy plugin listens for. This package is the provider-seam layer of the four-layer filesystem stack, split so each concern can evolve (and be swapped) independently (see [the capability-seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md), [the filesystem capability-seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md), [the split-the-filesystem-seam Agent Note](../../../.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md), and [the file-context event-gate Agent Note](../../../.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md)): diff --git a/packages/fs/fs/README.zh.md b/packages/fs/fs/README.zh.md new file mode 100644 index 0000000000..1533e81542 --- /dev/null +++ b/packages/fs/fs/README.zh.md @@ -0,0 +1,62 @@ +# @deepseek-ai/dsh-fs + +[English](README.md) | 中文 + +**文件系统提供方 seam**:抽象 `FileSystem` 服务(`ctx.fs`),定义后端提供的存储原语,包括路径解析、stat 元数据、不跟随链接的路径元数据、读取/流式读取文本、列出目录、原子写入和应用字面量编辑,但不规定实现方式。两个变更操作都**可选** 接收版本防护,因此 `ctx.fs` 本身就是完整且不受约束的文本存储 seam。本包还拥有由工具分派、政策插件监听的 `fs/*` 政策事件词汇。 + +本包是四层文件系统栈中的提供方 seam 层;该拆分使每个关注点可以独立演进和替换(见[能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)、[文件系统能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md)、[拆分文件系统 seam Agent Note](../../../.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md)和[文件上下文事件门禁 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md)): + +| 层 | 包 | 角色 | +|---|---|---| +| 工具/执行器 | `@deepseek-ai/dsh-tool-fs` | 面向模型的 `read`/`write`/`edit` schema、读取窗口和文本渲染;通过 `ctx.fs` 读取/写入/编辑,并分派 `fs/*` 事件 | +| 政策 | `@deepseek-ai/dsh-fs-policy` | 已观察状态、编辑前读取和版本防护的写入/编辑,通过 `fs/*` 事件门禁贡献(无服务) | +| 提供方 seam | `@deepseek-ai/dsh-fs`(本包) | `ctx.fs`:文本 I/O 与原子变更原语(可选版本防护);拥有 `fs/*` 事件词汇 | +| 提供方 | `@deepseek-ai/dsh-fs-local` | 宿主文件系统实现 | + +未来的沙箱化、虚拟或远程后端只需实现该接口,政策层和工具层无需改变。 + +## 服务 API(`ctx.fs`) + +后端继承 `FileSystem` 并实现八个原语。 + +| 成员 | 语义 | +|---|---| +| `resolve(path, opts?)` | 把路径解析为稳定的 `FsTarget`(不透明 `targetKey`、`displayPath`)。`opts.cwd` 是相对 `path` 解析所依据的基准(调用方提供其会话工作区;绝对路径忽略该值;省略时使用后端默认值),`opts.signal` 则中止后端往返。该方法是异步的,因为远程后端可能需要 I/O。经不同路径到达的同一文件必须产生相同 `targetKey`。 | +| `stat(target, signal?)` | 返回 `FsInfo` 元数据(`version`、`type`、可选 `size`);目标不存在时返回 `undefined`。绝不返回内容。 | +| `lstat(path, opts?, signal?)` | 当最后一个路径组件是符号链接时,不跟随该组件,返回 `FsPathInfo` 元数据。该方法采用路径形态,使消费方能在 `resolve` 跟随仓库所有的符号链接进入目标前拒绝它。 | +| `readText(target, signal?)` | 把整个普通文本文件读取为一个解码后的字符串。负责普通文件检查、UTF-8 解码和二进制/NUL 拒绝(`FS_NOT_TEXT`)。 | +| `streamText(target, signal?)` | 为大文件按解码后的分片流式读取相同文本(跨分片 UTF-8 解码仍由此处负责)。 | +| `listDir(target, signal?)` | 按稳定名称顺序列出直接子项。返回条目名称、条目类型、解析后的子目标和低成本元数据(若可用则包括 `version`/文件 `size`);绝不读取文件内容。缺失目标抛出 `FS_NOT_FOUND`,非目录抛出 `FS_NOT_DIRECTORY`,权限失败抛出 `FS_PERMISSION_DENIED`,其他后端 I/O 失败抛出 `FS_IO_ERROR`。损坏/消失的子项可以作为无元数据的 `other` 返回;子项权限/I/O 失败会使用相同结构化代码使整个列表失败。 | +| `writeText(target, content, expected?, signal?)` | 原子创建/替换。`expected` 是可选的:省略 ⇒ 无条件创建或覆盖;提供 `FsWriteIntent`(`createIfAbsent`/`replaceIfVersion`)⇒ 添加防护。 | +| `editText(target, edit, expected?, signal?)` | 字面量编辑。`expected` 是可选的:省略 ⇒ 无条件编辑当前内容;提供 `{ version }` ⇒ 添加防护,并在匹配之前校验。无论哪种情况,目标缺失都报告 `FS_STALE_VERSION`。应用和写入以原子方式完成,使用同一个变更临界区。 | + +无论是否有版本防护,变更都在后端的每目标锁内运行,因此无条件写入/编辑仍是原子的;「无条件」只移除*版本*前置条件,不移除原子性。 + +## `fs/*` 政策事件 + +本包声明三个事件(见已生成的[事件目录](../../../docs/cordis-catalog/events.md)),使发出方(`@deepseek-ai/dsh-tool-fs`)和政策监听器(`@deepseek-ai/dsh-fs-policy`)共享词汇,而无需让发出方依赖政策插件。`fs/write-intent` 和 `fs/edit-intent` 是单槽决策 waterfall(监听器完整决策,绝不调用 `next()`);`fs/observed` 是发后即忘的记录事件。它们只携带 `dsh-fs` 词汇和一个不透明 `object` 参与者,不含面向模型的概念或 agent(智能体)/会话所有者结构。 + +## 提供方 seam,不是政策层 + +`ctx.fs` 有意接近 fsspec 风格的存储原语,比字节级 `cat`/`open` 高半层,因为它会解码文本并拒绝二进制,使政策层绝不接触原始字节。它负责 UTF-8 解码、二进制拒绝、原子写入和字面量编辑临界区。它**不** 负责行窗口、编号行、渲染 footer 或已观察状态。已观察状态、编辑前读取和版本防护的写入/编辑属于插件(`@deepseek-ai/dsh-fs-policy`)通过提供可选防护而添加的政策,并非提供方行为,因此沙箱化/远程后端不会继承任何面向模型的观察政策。 + +`editText` 留在该 seam 上,不由政策层通过读取加写入组合,因为版本防护、字面量匹配和原子重写必须处于同一临界区内,才能正确归因错误并实现一方胜出/一方陈旧的并发;远程后端也可以将其实现为原生比较并编辑操作。 + +## 词汇 + +`FsTargetKey` / `FsVersion` 是带品牌的不透明 id(见[品牌 id Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-branded-ids.md));消费方不得解析 `targetKey` 或解释 `version`,只有 `displayPath` 用于模型/UI 输出。`FsWriteIntent` 是显式的防护写入意图(`createIfAbsent` 创建缺失目标,并以 `FS_NOT_OBSERVED` 拒绝现有目标;`replaceIfVersion` 只在观察版本上替换,否则为 `FS_STALE_VERSION`);从 `writeText` 中省略该值就是第三种无条件状态。`FsPathInfo` 是可报告 `symlink` 的不跟随链接元数据形态,区别于目标级 `FsInfo`。失败会抛出 `FsError`(继承 `HarnessError`;见[结构化错误分类 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.md)),并携带稳定的 `FsErrorCode`(`FS_NOT_FOUND`、`FS_NOT_DIRECTORY`、`FS_NOT_TEXT`、`FS_NOT_REGULAR_FILE`、`FS_PERMISSION_DENIED`、`FS_IO_ERROR`、`FS_STALE_VERSION`、`FS_NOT_OBSERVED`、`FS_AMBIGUOUS_EDIT`、`FS_EDIT_NOT_FOUND`、`FS_ABORTED`);工具注册表公开 `{ name, code }`,并将其附在 `isError` 结果上。完整契约见 `src/types.ts`。 + +## 模型体验 + +通过 `dsh-tool-fs` 间接产生影响;该消费方把提供方文本和错误渲染为有界且保留的文件系统工具结果。 + +#### KV Cache 影响 + +不会直接使缓存失效;具名消费方负责请求前缀的任何变化。 + +## 已知限制与延期工作 + +- **契约只支持文本**:后端以 `FS_NOT_TEXT` 拒绝二进制/非 UTF-8 内容;二进制安全操作是[工具 schema Agent Note](../../../.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md)有意延期的工作。 +- **只有八个原语**:没有删除、重命名/移动、复制或监视;`listDir` 只支持一层,递归、glob、分页和搜索不在范围内,见[目录列出 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md)。 +- **没有 I/O deadline**:该 seam 不启动超时;取消只是每个原语上尽力而为的可选 `AbortSignal`(见有意采用的 [fs 能力族立场](../README.md))。 +- **先解析后操作使远程后端每次工具调用需要两次往返**:折叠或缓存解析由这种后端自行决定。 diff --git a/packages/fs/tool-fs-search/README.i18n.yaml b/packages/fs/tool-fs-search/README.i18n.yaml new file mode 100644 index 0000000000..07aaa3c9dc --- /dev/null +++ b/packages/fs/tool-fs-search/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 88a80fb51d7161e6940a3460b7f506575592f9cb +README.zh.md: 87be92bb5a8e06dfc275aa6a1fcf97274a761025 diff --git a/packages/fs/tool-fs-search/README.md b/packages/fs/tool-fs-search/README.md index 5d0655bcc9..88a80fb51d 100644 --- a/packages/fs/tool-fs-search/README.md +++ b/packages/fs/tool-fs-search/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-tool-fs-search +English | [中文](README.zh.md) + The **model-facing filesystem discovery tools**—`glob`, `grep`—are backed by the **bash executor seam**, not by `ctx.fs` provider methods. At load, the package probes `command -v rg` through `ctx.bash`; if the executor cannot find ripgrep on its `PATH`, it logs a warning and registers no tools or prompt sections. Each call assembles a fixed ripgrep command (every model-controlled value through one package-private shell-quoting helper), runs it via `ctx.bash.resolve(request)` → `ctx.bash.run(spec)` as an ordinary foreground tool call, parses the raw `rg` output, and returns a workdir-relative canonical value. The package injects `tools`, `systemPrompt`, and `bash`—deliberately **not** `fs`; `ctx.spillStore` is read opportunistically with `ctx.get()` because formatted-result spill is optional. ```ts ignore-check diff --git a/packages/fs/tool-fs-search/README.zh.md b/packages/fs/tool-fs-search/README.zh.md new file mode 100644 index 0000000000..87be92bb5a --- /dev/null +++ b/packages/fs/tool-fs-search/README.zh.md @@ -0,0 +1,124 @@ +# @deepseek-ai/dsh-tool-fs-search + +[English](README.md) | 中文 + +**面向模型的文件系统发现工具**(`glob`、`grep`)由 **bash 执行器 seam** 支持,而不是由 `ctx.fs` 提供方方法支持。加载时,本包探测 `command -v rg`,探测通过 `ctx.bash` 进行;如果执行器无法在其 `PATH` 上找到 ripgrep,就记录警告,并且不注册工具或提示词段。每次调用都会组装固定的 ripgrep 命令(所有模型控制的值都经过同一个包私有 shell 引用辅助函数),通过 `ctx.bash.resolve(request)` → `ctx.bash.run(spec)` 作为普通前台工具调用运行,解析原始 `rg` 输出,并返回相对于工作目录的规范值。本包注入 `tools`、`systemPrompt` 和 `bash`,有意**不** 注入 `fs`;格式化结果 spill 为可选功能,因此机会性读取 `ctx.spillStore`,调用方式为 `ctx.get()`。 + +```ts ignore-check +// Default deployment: a bash executor whose PATH includes rg, then the discovery tools. +await ctx.plugin(LocalBashExecutor, { cwd: process.cwd() }) // @deepseek-ai/dsh-bash-local +await ctx.plugin(ToolFsSearch) // this package — conditionally registers glob/grep +// Optional: a spill backend makes capped results fully recoverable. +await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-spill-local +``` + +采用 bash 支持的原因:本地工作区发现天然是由进程支持的 `rg` 工作流;如果把搜索放到 `ctx.fs` 上,就会迫使每个文件系统后端扩展搜索 API。bash 执行器负责请求默认值/上限、子进程执行、进程组终止、环境清理、原始输出捕获和后端替换(本地、沙箱化、远程);本包负责 schema、参数校验、shell 引用、解析、保留、格式化结果 spill 和超时声明。工具绝不调用 `ctx.bash.start()`,也不公开 bash task id;只有在 `rg` 退出、超时、中止或失败后,调用才会返回。 + +## 部署要求:rg 与共置的 bash/文件系统 + +已挂载的 bash 执行器必须能在插件加载时解析 `rg`,其来源是执行器的 `PATH`;否则面向模型的工具 schema 中不会出现 `glob` 和 `grep`。返回路径会相对于解析后的 bash 工作目录显示(调用 agent(智能体)存在会话 cwd 时使用该值,否则使用执行器配置的默认值);只有 bash 工作目录与文件系统根目录是同一工作区时,才能用 `read` 继续读取。v1 只记录这项共置要求,不执行运行时跨服务校验;远程或虚拟文件系统搜索需等待共享工作区契约或特定提供方的搜索后端。 + +## 配置 + +所有键均为可选;默认值是随产品交付的搜索上限。 + +| 键 | 默认值 | 含义 | +|---|---|---| +| `globMaxResults` | `100` | 一次 `glob` 调用内联保留的最大路径数(与 Claude Code 的 `GlobTool` 上限相同);后续路径写入格式化 spill 产物。 | +| `grepMaxMatches` | `250` | 一次 `grep` 调用内联保留的最大平铺匹配数(与 Claude Code 的 `GrepTool` `head_limit` 相同);后续匹配写入格式化 spill 产物。 | +| `grepMaxLineBytes` | `2000` | 每条匹配行预览的字节上限;截断会保留 UTF-8 边界,并标记为 `(line truncated)`。 | +| `rawOutputMaxBytes` | `20000000` | 搜索将解析的完整原始 `rg` stdout 上限(与 Claude Code 的 ripgrep 原始 buffer 相同);更大的原始输出以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败。 | +| `timeoutMs` | `30000` | 附加到两个工具定义上的协作式工具调用预算,由 `@deepseek-ai/dsh-timeout-policy` 通过 `exec.signal` 强制执行;bash 后端自身的超时仍作为第二道安全上限。 | + +## 工具 + +| 工具 | 参数 | 行为 | +|---|---|---| +| `glob` | `pattern`、`path?` | 运行 `rg --files --glob <pattern> --sort=modified --no-ignore --hidden`,并排除 VCS 元数据(`.git`、`.svn`、`.hg`、`.bzr`、`.jj`、`.sl`)。`path` 是可选的**目录** 搜索根;省略时使用解析后的 bash 工作目录。每行返回一个路径,按修改时间排序。 | +| `grep` | `pattern`、`path?`、`include?` | 按行解析 `rg --json`,避免按冒号拆分的歧义。`pattern` 是 ripgrep 正则表达式;`path` 是可选的**文件或目录** 目标;`include` 是一个正向 glob 过滤器,前置拒绝逗号分隔列表或否定值(`!…`),但允许 `*.{ts,tsx}` 等花括号交替。返回按文件分组、形如 `Line N: <preview>` 的匹配。 | + +常规预算不进入面向模型的 schema(没有 `head_limit`/`offset`/`case_insensitive`/输出模式):模型需要周边上下文时,用 `read` 读取匹配文件;需要后续结果时,遵循返回的 spill locator 检索提示。 + +## 两类预算、两类产物 + +原始 `rg` stdout 是内部传输细节。每次搜索从 bash seam 请求 `stdoutMaxBytes: rawOutputMaxBytes`,且只解析完整保留的 stdout;如果执行器仍返回 `stdout.truncated`,搜索会以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败,并要求模型缩小查询。成功的 `glob` 在 `{ paths }` 中保留所有已取得路径;`grep` 保留所有已取得的 `{ path, lineNumber, line }`,并将其存入 `{ matches }`。内联条目和每行预览上限只应用于 Native 渲染器。直接接口调用的逻辑结果超过内联上限时,后置政策会尽力通过 `ctx.spillStore.saveText()` 保存完整格式化预览,并只把呈现替换为头部页面加 locator。嵌套 Code 分派会跳过 spill,因为其完整规范值不会进入模型上下文。spill 缺失/失败时保留内联页面,并报告完整结果无法保存,绝不会成为 `isError`。 + +## 错误 + +搜索失败携带本包拥有的 `SearchError`(`HarnessError` 子类),以 `{ name, code }` 公开在 `isError` 结果上:`SEARCH_INVALID_PATTERN`(ripgrep 拒绝正则/glob)、`SEARCH_FAILED`(注册后 `rg` 在运行时消失、目标不可访问、信号终止、`--json` 输出格式错误)、`SEARCH_RAW_OUTPUT_OVERFLOW`(原始输出超过 `rawOutputMaxBytes`,或在请求 stdout 捕获预算后仍被截断)和 `SEARCH_ABORTED`(工具超时、调用方取消或 bash 执行器自身超时)。ripgrep 退出语义由工具拥有:退出 0 表示成功且有结果,退出 1 表示成功的空搜索(`No files found` / `No matches found`),只有其他退出值表示失败。模型参数错误(空白 pattern、列表值 `include`)仍是普通工具参数错误。 + +## 模型体验 + +### 系统提示词 + +#### 模型看到的内容 + +加载时 `rg` 探测成功后,该插件注册作用域内的每个请求都包含下方独立注册的 glob 与 grep 指导。agent 作用域的工具限制可以隐藏任一 schema,而不移除其提示词段。 + +##### Glob 指导 + +```markdown +Use the glob tool — not shell find or ls — to discover files by path pattern. Results are sorted by modification time and include hidden and ignored files. +``` + +##### Grep 指导 + +```markdown +Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context. +``` + +#### Token 影响 + +工具注册期间,每个请求支付固定指导成本。 + +#### KV Cache 影响 + +只要插件作用域和指导文本不变,前缀就保持稳定。启用或 dispose(资源释放)可能从该提示词段开始使复用失效。 + +### 工具 schema + +#### 模型看到的内容 + +当前接口可见时,公开已生成的 [`glob` 和 `grep` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs-search);前提是加载时 `rg` 探测成功。 + +#### Token 影响 + +工具可见的每个请求都支付固定 schema 成本。 + +#### KV Cache 影响 + +只要工具可见性和定义不变,前缀就保持稳定。注册生命周期或作用域限制可能从首个变化的 schema token 开始使复用失效。 + +### 结果与 spill 通知 + +#### 模型看到的内容 + +`glob` 每行返回一个路径;`grep` 在每个路径下对 `Line <line>: <preview>` 匹配分组。空搜索返回 `No files found` 或 `No matches found`。达到上限的结果末尾会附加省略数量、spill locator 和后端检索提示,或说明完整结果无法保存。 + +#### Token 影响 + +内联路径和匹配受 `globMaxResults`、`grepMaxMatches` 与 `grepMaxLineBytes` 限制;调用和保留结果会留在历史中,直到上下文压缩(compaction)。 + +#### KV Cache 影响 + +仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 + +### 工具错误 + +#### 模型看到的内容 + +失败会规范化为 `Error: <message>`,并向调用方提供结构化的 `SEARCH_INVALID_PATTERN`、`SEARCH_FAILED`、`SEARCH_RAW_OUTPUT_OVERFLOW` 或 `SEARCH_ABORTED` 元数据。 + +#### Token 影响 + +只有失败调用会添加这些保留 token。 + +#### KV Cache 影响 + +仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 + +## 已知限制与延期工作 + +- **搜索和文件访问没有共享工作区证明**:只有 bash 工作目录和文件系统根目录表示同一工作区时,返回路径才能继续读取;本包不执行运行时跨服务校验。 +- **Ripgrep 是部署依赖**:缺失 `rg` 可执行文件时,本包不注册工具或指导;可执行文件不兼容或注册后消失时,调用以 `SEARCH_FAILED` 失败。远程或虚拟文件系统需要共置执行器或其他搜索消费方。 +- **schema 只公开一个有界页面**:offset 分页、大小写模式开关、其他输出模式和提供方支持的发现均不在本包内;达到上限的完整输出需要 spill 后端。 diff --git a/packages/fs/tool-fs/README.i18n.yaml b/packages/fs/tool-fs/README.i18n.yaml new file mode 100644 index 0000000000..13f1ecd649 --- /dev/null +++ b/packages/fs/tool-fs/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 4ff9b043525e8e7a0b59e3d91410951d88bb9a69 +README.zh.md: f94a903c9c37f7d45b7f8cebabe21082388bd041 diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index a99316c181..4ff9b04352 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-tool-fs +English | [中文](README.zh.md) + The **model-facing filesystem tools** — `read`, `write`, `edit` — and their **executor**. This is the consumer layer of the filesystem stack: it owns tool names, JSON schemas, argument validation, prompt sections, **read windowing**, and result formatting. It reads/writes/edits through the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)) **directly** — it injects `fs` (plus `tools`/`systemPrompt`), **not** a policy service. The freshness/observation policy is contributed by a separate plugin ([`@deepseek-ai/dsh-fs-policy`](../fs-policy)) through the `fs/*` event gate; the tool is not method-coupled to it. ```ts ignore-check diff --git a/packages/fs/tool-fs/README.zh.md b/packages/fs/tool-fs/README.zh.md new file mode 100644 index 0000000000..f94a903c9c --- /dev/null +++ b/packages/fs/tool-fs/README.zh.md @@ -0,0 +1,151 @@ +# @deepseek-ai/dsh-tool-fs + +[English](README.md) | 中文 + +**面向模型的文件系统工具**(`read`、`write`、`edit`)及其**执行器**。这是文件系统栈的消费方层:拥有工具名称、JSON schema、参数校验、提示词段、**读取窗口逻辑** 和结果格式化。它**直接** 通过 `ctx.fs` 提供方 seam([`@deepseek-ai/dsh-fs`](../fs))读取/写入/编辑:注入 `fs`(以及 `tools`/`systemPrompt`),**不** 注入政策服务。新鲜度/观察政策由独立插件([`@deepseek-ai/dsh-fs-policy`](../fs-policy))通过 `fs/*` 事件门禁贡献;工具不与其方法耦合。 + +```ts ignore-check +// Default deployment: a ctx.fs provider, the policy plugin, then the tools. +await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) // @deepseek-ai/dsh-fs-local +await ctx.plugin(FsPolicy) // @deepseek-ai/dsh-fs-policy (policy gate) +await ctx.plugin(ToolFs) // this package — registers read/write/edit +``` + +`@deepseek-ai/dsh-fs-policy` 是**可选的**:省略时,工具直接使用裸提供方(无条件写入/覆盖/编辑,无已观察状态)。加载这些工具的部署也应加载该插件,从而提供编辑前读取行为。 + +## 配置 + +所有键均为可选;默认值是随产品交付的读取上限。 + +| 键 | 默认值 | 含义 | +|---|---|---| +| `readLimit` | `2000` | 一次 `read` 调用返回的默认和最大行数(工具 schema 将其声明为 `limit` 默认值)。 | +| `readMaxLineLength` | `2000` | 每行截断前保留的字符数(后缀会说明上限)。 | +| `readMaxBytes` | `51200` | 一次 `read` 调用所选行的字节上限;溢出时以「已达上限」footer 结束窗口。 | +| `readStreamMinSize` | `10485760` | 大于等于该大小或大小未知的文件采用流式读取,而不是整体加载到内存。 | + +## 工具(schema 见[文件系统工具 schema Agent Note](../../../.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md)) + +| 工具 | 参数 | 行为 | +|---|---|---| +| `read` | `file_path`、`offset?`、`limit?` | 带行号的 UTF-8 内容和分页 footer。`offset` 从 1 开始;`limit` 默认为配置的 `readLimit`(2000),上限也为该值。 | +| `write` | `file_path`、`content` | 创建文件或完整替换文件。有政策插件时:覆盖现有文件要求先在未变版本上执行 `read`;创建新文件不需要。没有插件时:无条件执行。 | +| `edit` | `file_path`、非空 `old_string`、`new_string`、`replace_all?` | 字面量替换;除非 `replace_all` 为 true,否则要求唯一匹配。有政策插件时:要求先执行 `read`(任何窗口),且文件此后未变。没有插件时:无条件执行。 | + +字段名使用 snake_case,与 Claude Code 和现有 harness 工具 schema 一致。 + +规范成功值分别为:`read` → `{ path, offset, lines: [{ number, text }], totalLines }`,`write` → `{ path, operation: 'create' | 'update', before: string | null, after }`,`edit` → `{ path, before, after }`。Native 渲染器会保留下方带行号的读取结果和变更确认。写入/编辑从这些值派生可回放的 diff 卡片元数据;值本身仅用于执行,不会添加到 `tool/result`。 + +## 工具就是执行器;政策是事件门禁 + +工具**不** 注入政策服务,也不检查任何缓存。每个工具通过 `ctx.fs.resolve(path, { cwd, signal })` 解析路径;它会传入调用 agent(智能体)的会话 cwd(`exec.agent.session.header.cwd`),使相对路径以会话工作区为基准解析并与 `dsh-tool-bash` 一致,同时把工具取消转发到解析过程(见[每会话 cwd Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md))。随后执行: + +- **read**:一次 `ctx.fs.stat`(用于类型、大小路由和版本),随后调用 `readText`/`streamText`,构建行窗口,再发出 `fs/observed`,使用普通 `ctx.emit`。(1 次 stat。) +- **write**:调用 `ctx.waterfall('fs/write-intent', target, exec, () => undefined)` 取得可选防护,然后调用 `ctx.fs.writeText(target, content, intent)`,再发出 `fs/observed`。(0 次 stat。) +- **edit**:调用 `ctx.waterfall('fs/edit-intent', target, exec, () => undefined)` 取得可选防护,然后调用 `ctx.fs.editText(target, edit, intent)`,再发出 `fs/observed`。(0 次 stat。) + +工具在每次分派中把 `exec`(工具执行上下文)作为不透明 `actor` 传入。默认 thunk 返回 `undefined`(不受约束的裸提供方)。加载 `@deepseek-ai/dsh-fs-policy` 后,它会占用单个决策槽:返回 `createIfAbsent`/`replaceIfVersion`/`{ version }` 或抛出 `FS_NOT_OBSERVED`,并在 `fs/observed` 时记录。后端错误(`FsError`)和抛出的 `FS_NOT_OBSERVED` 会流经 `ToolRegistry.execute()`,变成 `isError` 工具结果,并附带 `{ name, code }`。 + +## `fs/observed` 发后即忘 + +`fs/observed` 在读取/写入/编辑已经成功之后,通过普通 `ctx.emit` 发出。监听器的契约是同步且只有副作用的记录器(`@deepseek-ai/dsh-fs-policy` 使用 `WeakMap.set`);工具不保护这次发出,因此监听器抛出会作为工具的 `isError` 结果出现。异步或可能失败的观察不属于该事件。 + +`read` 允许并发调度,因为其唯一变更是同步版本记录器。稍后的 `write` 或 `edit` 会在目标锁内重新检查版本,因此记录器竞态会以拒绝方式关闭;两个变更工具仍保持互斥。见[并行工具调用 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md)。 + +包根目录只导出 Cordis 插件契约(`name`、`inject`、`Config` 和 `apply`)。读取渲染(行窗口与输出格式化)位于 `src/read-render.ts`(不依赖 Cordis,单独进行单元测试);`src/read.ts`/`write.ts`/`edit.ts` 是工具执行器,`src/index.ts` 负责组合。 + +## 模型体验 + +### 系统提示词 + +#### 模型看到的内容 + +该插件注册作用域内的每个请求都会收到下方独立注册的 read、write 与 edit 指导。作用域工具限制可以隐藏 schema,而不移除这些段。 + +##### Read 指导 + +```markdown +Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. +``` + +##### Write 指导 + +```markdown +Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. +``` + +##### Edit 指导 + +```markdown +Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. +``` + +#### Token 影响 + +插件启用期间,每个请求支付固定指导成本;即使限制隐藏了一个或多个工具也一样。 + +#### KV Cache 影响 + +只要插件作用域和指导文本不变,前缀就保持稳定。工具限制不会移除该段,但插件启用或 dispose(资源释放)可能从该段开始使复用失效。 + +### 工具 schema + +#### 模型看到的内容 + +模型会看到已生成的 [`read`、`write` 和 `edit` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs),参数使用 snake_case。作用域工具限制可以为某个 agent 移除任一定义。 + +#### Token 影响 + +该工具视图中的每个请求都支付固定 schema 成本。 + +#### KV Cache 影响 + +只要可见工具定义和顺序不变,前缀就保持稳定。注册生命周期或作用域限制可能从首个变化的 schema token 开始使复用失效。 + +### 读取结果 + +#### 模型看到的内容 + +成功读取结果精确为 `<path><displayPath></path>`、换行、`<type>file</type>`、换行、`<content>`、形如 `<lineNumber>: <text>` 的编号行、一个空行、一条 footer 和 `</content>`。footer 精确为 `(Output capped. Showing lines <start>-<end>. Use offset=<next> to continue.)`、`(Showing lines <start>-<end> of <total>. Use offset=<next> to continue.)` 或 `(End of file - total <total> lines)`。长行结尾精确为 `... (line truncated to <max> chars)`。 + +#### Token 影响 + +读取输出受 `readLimit`、`readMaxLineLength` 和 `readMaxBytes` 限制;保留的调用与结果会反复发送,直到上下文压缩(compaction)。 + +#### KV Cache 影响 + +仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 + +### 写入与编辑结果 + +#### 模型看到的内容 + +写入精确返回五行包络:`<path><displayPath></path>`、`<type>file</type>`、`<content>`、`Created file` 或 `Updated file`,以及 `</content>`。编辑精确返回 `The file <displayPath> has been updated successfully.`;对于 `replace_all`,精确返回 `The file <displayPath> has been updated. All occurrences were successfully replaced.`。完整写入或替换文本仍保留在 assistant 工具调用参数中。 + +#### Token 影响 + +成功文本很少,但大型变更参数和所有结果会反复发送,直到上下文压缩。 + +#### KV Cache 影响 + +仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 + +### 工具错误 + +#### 模型看到的内容 + +失败会规范化为 `Error: <message>`。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`limit must be less than or equal to <max>`、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "<path>": not found`、`cannot read "<path>": not a regular file` 和 `offset <offset> is out of range for "<path>" (<total> lines)`;提供方和政策模板在各自包的 README 中逐字列出。 + +#### Token 影响 + +只有失败调用会添加这些保留 token。 + +#### KV Cache 影响 + +仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 + +## 已知限制与延期工作 + +- **未交付面向模型的目录列出工具**:`ctx.fs.listDir` 服务于 skill(技能)发现等提供方代码,同级 [`dsh-tool-fs-search`](../tool-fs-search/) 包则提供基于 bash 的 `glob` 与 `grep`,而不是扩展文件系统 seam。 +- **`read` 只处理 UTF-8 文本文件**:二进制安全读取和 PDF/图像/多模态内容均延期处理;目录目标为 `FS_NOT_REGULAR_FILE`。 +- **没有超时接口**:`read`/`write`/`edit` 不接受超时参数,也不声明 `timeout-policy` 预算;取消只通过 `exec.signal` 传递(见有意采用的 [fs 能力族立场](../README.md))。 diff --git a/packages/goal/README.i18n.yaml b/packages/goal/README.i18n.yaml new file mode 100644 index 0000000000..e72c1d21db --- /dev/null +++ b/packages/goal/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: f43dfd8258eabe8342207c0b1b9d6acc9e215e9f +README.zh.md: f663939bb19d8bc295a96fc0a673c56251e9a2a9 diff --git a/packages/goal/README.md b/packages/goal/README.md index 95cd975b69..f43dfd8258 100644 --- a/packages/goal/README.md +++ b/packages/goal/README.md @@ -1,5 +1,7 @@ # goal/ — persisted same-session goals +English | [中文](README.zh.md) + The goal family owns durable objective state independently of the model-facing tools and continuation policy that consume it. | Package | Role | ctx key | diff --git a/packages/goal/README.zh.md b/packages/goal/README.zh.md new file mode 100644 index 0000000000..f663939bb1 --- /dev/null +++ b/packages/goal/README.zh.md @@ -0,0 +1,14 @@ +# goal/:持久化的同会话目标 + +[English](README.md) | 中文 + +goal 家族负责持久目标状态,与消费该状态的面向模型工具和续行策略相互独立。 + +| 包 | 职责 | ctx 键 | +|---|---|---| +| `goal/` | 事件溯源的目标生命周期、回放折叠、比较并设置变更,以及进程本地激活 | `ctx.goals` | +| `goal-session/` | 同会话 goal round 的准入、结果映射与生命周期竞态隔离 | 无 | +| `tool-goal/` | 面向模型的读取/创建/更新工具,并在执行时检查权限 | 无 | +| `command-goal/` | 面向用户的 `/goal` 状态,以及通过命令平面执行的生命周期控制 | 无 | + +目标状态属于所属会话日志。消费方依赖 `dsh-goal`,而不是具体的 agent loop;续行行为由基于公开 agent seam 的独立插件负责。 diff --git a/packages/goal/command-goal/README.i18n.yaml b/packages/goal/command-goal/README.i18n.yaml new file mode 100644 index 0000000000..3bc8846656 --- /dev/null +++ b/packages/goal/command-goal/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 8e1a5b417467c8701ea935e25acfece11c5a70d4 +README.zh.md: fc7229e1fa14160b8247ae0c33a5fbc3cb062254 diff --git a/packages/goal/command-goal/README.md b/packages/goal/command-goal/README.md index 2bf52eb5f2..8e1a5b4174 100644 --- a/packages/goal/command-goal/README.md +++ b/packages/goal/command-goal/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-command-goal +English | [中文](README.zh.md) + Human-facing `/goal` control over [`ctx.goals`](../goal/README.md). The plugin registers one global command through [`ctx.commands`](../../ui/commands/README.md), so every composed command adapter discovers it; the shipped TUI executes it without a model turn. The [human goal-command Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-human-goal-command.md) owns the UX and composition decisions. ## Command contract diff --git a/packages/goal/command-goal/README.zh.md b/packages/goal/command-goal/README.zh.md new file mode 100644 index 0000000000..fc7229e1fa --- /dev/null +++ b/packages/goal/command-goal/README.zh.md @@ -0,0 +1,58 @@ +# @deepseek-ai/dsh-command-goal + +[English](README.md) | 中文 + +面向用户的 `/goal` 控制,基于 [`ctx.goals`](../goal/README.md) 实现。该插件通过 [`ctx.commands`](../../ui/commands/README.md) 注册一个全局命令,因此每个已组合的命令适配器都能发现它;随附 TUI 无需模型轮次即可执行。[用户 goal 命令 Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-human-goal-command.md)负责用户体验与组合决策。 + +## 命令契约 + +| 输入 | 结果 | +|---|---| +| `/goal` | 显示当前目标、持久 phase、round 计数/上限、进程本地激活状态与有效的下一步命令;被阻塞的 goal 还会显示策略代码和说明,没有 goal 时则显示用法。 | +| `/goal <objective>` | 创建并激活 goal,或用全新身份替换已完成 goal。未完成 goal 绝不会在没有显式 clear 的情况下被替换。 | +| `/goal edit <objective>` | 编辑当前目标,不改变其 phase 或激活状态。编辑已完成 goal 会创建新的 active goal。 | +| `/goal pause` | 暂停 active goal,并撤销续行激活。 | +| `/goal resume` | 恢复已停止 goal,或在会话 resume/fork 后重新激活 active goal;仍受剩余 round 上限约束。 | +| `/goal clear` | 清除当前指针,同时保留其持久历史和 tombstone。 | + +只有控制词占据完整输入时才不区分大小写。其他任何非空后缀都属于目标,因此 `/goal pause after verification` 会创建该字面目标。goal 领域会修剪并验证目标。由于通用命令平面没有模态编辑器或确认原语,`edit` 会内联接收替换内容;若替换内容不完整,则直接返回错误,提示用户执行 edit 或 clear。 + +可预期的领域拒绝会变成稳定的直接命令错误,不公开品牌化 id 或 revision。意外实现失败仍会 reject 分发,使适配器能将其报告为命令失败。通用命令文本和输出只属于活跃 UI 状态;每项已接受变更都由 `dsh-goal` 持久化并提供给模型,而不是由此插件完成。 + +## 组合 + +生产方注入 `commands` 和 `goals`。自定义应用会挂载它们的所有者与此插件;自动续行仍是独立选择: + +```yaml +- id: commands + name: '@deepseek-ai/dsh-commands' +- id: goal + name: '@deepseek-ai/dsh-goal' +- id: command-goal + name: '@deepseek-ai/dsh-command-goal' +``` + +TUI 应用默认启用完整的持久 goal 栈和此命令。ACP 自动化应用会启用领域与模型工具,但不挂载命令注册表;`goals: false` 会移除该栈。无 UI 的 `agent-spine-demo` 必须显式配置 `goals: {}`,避免无头单次调用方在不知情时从一个物理轮次变为多 round 操作。 + +## 模型体验 + +### 用户 `/goal` 控制 + +#### 模型看到的内容 + +斜杠输入与直接状态/错误输出不会进入模型请求。已接受的变更稍后会通过 goal 领域的原始 `<goal_state>` 快照或 clear tombstone 出现;这样既满足模型可见内容必须记录日志的不变量,也无需记录呈现文本。 + +#### Token 影响 + +读取状态或收到直接命令错误不会增加模型 token。每项已接受变更都会增加 goal 领域保留的完整快照;已启用的同会话驱动器还可能增加后续 goal-round 提示词。 + +#### KV Cache 影响 + +命令发现与直接输出不会影响缓存。变更会追加到可复用历史前缀之后;后续压缩可能替换派生历史后缀。 + +## 已知限制与暂缓工作 + +- **仅纯文本交互**:通用命令注册表没有模态编辑表单或替换确认回调;内联 edit 与显式 clear 能在不同适配器中保持确定的破坏性意图。 +- **没有逐命令 round 上限参数**:`defaultMaxGoalRounds` 仍是部署配置;用户直接请求时,可以要求模型通过另行授权的 goal 工具编辑 `max_goal_rounds`。 +- **没有持续状态组件**:裸 `/goal` 是可移植的观察接口;适配器专用徽标和可重新连接的命令输出仍属于未来 UI 工作。 +- **随附应用中只有 TUI 使用此命令**:无头 CLI、ACP 自动化和 JSON-RPC 适配器不消费 `ctx.commands`。如果组合中包含面向模型的 goal 工具,普通提示词仍能授权它们。 diff --git a/packages/goal/goal-session/README.i18n.yaml b/packages/goal/goal-session/README.i18n.yaml new file mode 100644 index 0000000000..e14d52c557 --- /dev/null +++ b/packages/goal/goal-session/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 6a1c3b9455c93762c2458109c753588ce9a08d9a +README.zh.md: d06d2d96deb845f72c5c88bc47a04a1af621c078 diff --git a/packages/goal/goal-session/README.md b/packages/goal/goal-session/README.md index fe7be735a1..6a1c3b9455 100644 --- a/packages/goal/goal-session/README.md +++ b/packages/goal/goal-session/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-goal-session +English | [中文](README.zh.md) + Same-session continuation driver for [`ctx.goals`](../goal/README.md). It turns an active, armed goal into sequential [goal rounds](../../../docs/glossary.md#goal-round) through the public `Agent` and session seams; the [same-session driver Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.md) owns the race and lifecycle rationale. ## Composition diff --git a/packages/goal/goal-session/README.zh.md b/packages/goal/goal-session/README.zh.md new file mode 100644 index 0000000000..d06d2d96de --- /dev/null +++ b/packages/goal/goal-session/README.zh.md @@ -0,0 +1,73 @@ +# @deepseek-ai/dsh-goal-session + +[English](README.md) | 中文 + +[`ctx.goals`](../goal/README.md) 的同会话续行驱动器。它通过公开 `Agent` 与会话 seam,把活跃且已激活的目标转换为连续的 [goal round](../../../docs/glossary.md#goal-round);[同会话驱动器 Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.md)负责竞态和生命周期理由。 + +## 组合 + +```yaml +- id: goal + name: '@deepseek-ai/dsh-goal' + +- id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' + +- id: goal-session + name: '@deepseek-ai/dsh-goal-session' +``` + +该插件没有可调配置。`maxGoalRounds` 属于目标定义,面向模型的阻塞阈值则属于 [`dsh-tool-goal`](../tool-goal/README.md);在驱动器中重复任一数值都可能产生分歧策略。 + +## Round 契约 + +当完全相同的活跃 agent 处于 idle 状态,且目标 active、已经激活并有剩余容量时,驱动器先为待处理 goal 变更创建检查点,再预留 `roundsStarted + 1`,对应当前 `{ goalId, revision }`。它会排入一条 `<goal_round>` 提示词,并携带 `GoalMessageSource`。通过 `agent/prompt-submit` 准入时,会在下游提示词 hook 前后同时验证完整的排队记录与当前 goal;只有被接受的 `user/message` 才会增加 `roundsStarted`。因陈旧而被拒绝的预留不会消耗 round 编号。 + +一个 goal round 拥有一个普通会话轮次,该轮次可以包含多个模型/工具步骤。驱动器只会把预留与 `message` 轮次配对,且该轮次必须携带完全相同的 `GoalMessageSource`;可通过声明合并扩展的插件轮次触发器不会准入或替换该预留。用户消息仍是普通轮次,不消耗 goal 上限。如果用户工作在预留前进入 inbox,或加入预留的待处理批次,自动工作会让行,直到用户工作结算;混合批次中的待处理自动提示词会被拒绝,只有 agent 再次 idle 后才重新预留。 + +保留的提示词会点明经过 JSON 引用的目标与 `round/maxGoalRounds`,将当前工作区、工具结果和持久会话状态视为权威信息,要求在完成前提供证据,并要求在工作仍未完成时保持目标 active。引用可将多行或形似标签的目标文本保留为数据。goal 生命周期变更仍必须通过 `dsh-tool-goal` 的独立权限检查。 + +## 结算策略 + +| 持久轮次结果 | Goal 操作 | 自动重试 | +|---|---|---| +| goal 仍 active 且已激活时的 `completed` | 准入下一 round;达到上限时以代码 `round-limit` 阻塞 | 是 | +| 已预留/准入 goal round 的取消,或其 `aborted` 结果 | `paused` | 否 | +| 未尝试 goal round 时取消 | 保留持久 phase;撤销激活 | 否 | +| `error` 且带 `RATE_LIMIT` 或 `QUOTA` | 设为 `blocked`,代码为 `usage-limited` | 否 | +| 其他 `error`、`max-tokens` 或非陈旧提示词拒绝 | 以诊断代码和消息设为 `blocked` | 否 | +| 持久性失败、资源释放、中断或未知未来结果 | 撤销激活或阻塞,以便检查 | 否 | + +某个 goal 在自身 round 中发生的变更,会取代旧 revision 的结算。因此,即使物理轮次随后关闭,完成、暂停、阻塞和编辑仍具有最终决定权。任何异常结果都不会自动重试。 + +## 生命周期与持久性 + +`goal/changed` 会产生持久性义务。排队工作前,驱动器会等待 `ctx.sessions.flush()`,并在等待后重新检查 goal revision 与竞争输入。关闭时的 flush 失败通过 `agent/error` 到达;即使后续一次性注入已经追加另一轮次,驱动器仍会把失败关联到完全相同的已关闭轮次,然后撤销激活,避免另一 round 启动。 + +此插件加载到现有 agent 上时绝不会继承激活状态。`GoalService.disarm()` 会移除进程本地权限,而不改变持久 phase、revision 或历史;之后由用户明确授权的 resume 会记录重新激活。会话 resume 和 fork 后,goal 领域通过 `agent/session-start` 处理应用相同规则。 + +取消采用先观察、后行动的顺序:具体循环会在清空队列或中止轮次前,发送带类型 cause 的 `agent/cancel-requested`。只有取消操作拥有已预留或已准入的 goal 尝试时,插件才会持久暂停 active goal;取消无关用户工作只会撤销进程本地续行权限。如果 pause 变更失败,驱动器会回退到撤销激活。插件 teardown 会关闭准入,撤销所有活跃 goal 的激活,以 `parent` cause 取消已经准入的 round,并在事件隔离仍安装的情况下等待驱动器和 agent 完全停稳。 + +## 模型体验 + +### Goal-round 提示词 + +#### 模型看到的内容 + +每个已准入 round 都是一段保留的用户角色 `<goal_round>` 块,其中点明完整目标与正 round 编号。更早的用户消息、goal 状态快照、assistant 输出与工具记录仍保留在同一会话历史中。 + +#### Token 影响 + +每个已准入 round 会增加一个固定指令块和目标。后续请求会重新发送保留的 round,直到压缩将其遮蔽;不会创建新 agent,也不会复制对话前缀。 + +#### KV Cache 影响 + +在一个 epoch 内仅追加:每个已准入 round 都会在可复用前缀后扩展现有对话。压缩可能替换派生历史后缀,并移动可复用边界。 + +## 已知限制与暂缓工作 + +- **没有独立评估器**:面向模型的 goal 策略会判断证据是否足以完成,以及 blocker 在语义上是否未变;评估器支持的认证仍保持暂缓。 +- **只在同一会话执行**:此包有意不 spawn 新 agent、不 fork 会话前缀,也不实现 Ralph 风格的独立尝试;该工作流属于自己的插件层。 +- **已接受队列的卸载竞态**:Cordis 插件卸载是异步的。已经被 agent inbox 接受的 goal 提示词可以在卸载开始前启动并消耗其 round;teardown 随后会取消请求、撤销 goal 激活并等待完全停稳。不会再启动后续 round。 +- **只有 round 上限,不是资源预算**:token、货币、时间与提供方配额策略保持独立;观察到 `RATE_LIMIT` 和 `QUOTA` 时,只会映射为阻塞原因代码 `usage-limited`。 +- **异常情况不自动重试**:短暂的提供方与持久化失败需要之后由用户授权 resume,而不是隐式重试策略。 diff --git a/packages/goal/goal/README.i18n.yaml b/packages/goal/goal/README.i18n.yaml new file mode 100644 index 0000000000..07bd4fa4f4 --- /dev/null +++ b/packages/goal/goal/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 2fee818a30a634ded705235b4aae33df6b32b978 +README.zh.md: c59ad1ed23638d78d839f3bec71caee37743788c diff --git a/packages/goal/goal/README.md b/packages/goal/goal/README.md index 1df6d08f5c..2fee818a30 100644 --- a/packages/goal/goal/README.md +++ b/packages/goal/goal/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-goal +English | [中文](README.zh.md) + Event-sourced same-session goal state. The service retains one current completion objective in an agent's existing session while keeping permission to continue as process-local activation. The [goal-domain Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md) owns the design rationale; the [goal type catalog](../../../docs/core-data-structures/goal.md) records the literal data shapes. ## Config diff --git a/packages/goal/goal/README.zh.md b/packages/goal/goal/README.zh.md new file mode 100644 index 0000000000..c59ad1ed23 --- /dev/null +++ b/packages/goal/goal/README.zh.md @@ -0,0 +1,58 @@ +# @deepseek-ai/dsh-goal + +[English](README.md) | 中文 + +事件溯源的同会话目标状态。该服务在 agent(智能体)的现有会话中保留一个当前完成目标,同时将继续执行的权限作为进程本地激活状态。[goal 领域 Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md)负责设计理由;[goal 类型目录](../../../docs/core-data-structures/goal.md)记录字面数据形状。 + +## 配置 + +```yaml +- id: goal + name: '@deepseek-ai/dsh-goal' + config: + defaultMaxGoalRounds: 256 +``` + +`defaultMaxGoalRounds` 必须是正安全整数。`create()` 会在提交目标前于内部物化这项部署默认值;请求级取值可以覆盖它。 + +## 服务契约 + +`ctx.goals` 只接受以对应 id 注册的完全相同的活跃 `Agent` 实例。`get()` 返回分离的 `GoalView`;变更通过 `GoalRef { id, revision }` 比较并设置限制,并拒绝陈旧引用。服务通过生成的[服务目录](../../../docs/cordis-catalog/services.md)公开 create、edit、pause、resume、complete、block 和 clear 动词。创建默认值在内部解析。`disarm()` 是仅供生命周期使用的例外:它移除进程本地续行权限,不写入新 revision,也不发送变更事件。 + +最多只有一个当前目标。创建操作会生成 revision 为 1 的活跃目标并将其激活。未完成的目标必须编辑、转换或清除;已完成目标可以由拥有全局新 id 的目标替换。编辑会保留 phase、blocker reason 与 activation。暂停、完成、阻塞和清除都会撤销激活。阻塞会记录策略自有的 lower-kebab-case 代码和规范化的自由文本说明;提供方限制、配置预算、执行错误与请求用户输入都使用这一种持久 phase,不会扩增生命周期状态。只有配置的 round 上限仍有剩余容量时,resume 才接受已停止 phase 或撤销激活的 active 目标;它会清除原 blocker reason。活跃且已激活的目标会拒绝冗余操作。 + +每次非 clear 变更都会通过 `agent.inject()` 追加完整的版本化快照;clear 则追加带 revision 的 tombstone。逐字投影给模型的 round-zero `user/message` 内容、其 `{ kind: 'goal' }` 来源与元数据必须完全一致。回放会拒绝形状错误、来源/内容漂移、不连续 revision、非法生命周期转换、每目标时间戳非单调,以及不连续的 goal round。墙上时间倒退时,变更时间戳会限制在不早于上一次目标更新的值。 + +注入可以立即追加,也可能在活跃工具批次 FIFO 中等待。服务会在内存中覆盖已经接受但尚待写入的变更,并在每个确切载荷进入日志时执行协调,因此连续的模型工具变更可以看到自身最新 revision,而不会把尚未记录的缓存当作持久状态。可重入追加观察者会且只会看到每项已接受变更一次;增量回放会把游标保留在第一个损坏事件处。追加或入队成功后才触发 `goal/changed`;监听器失败会受到隔离。 + +激活状态绝不持久化。新缓存与每条 `agent/session-start` 边都会撤销激活,即使回放找到了持久 phase 为 active 的目标。续行驱动器在卸载前或持久性不确定后也会调用 `disarm()`。因此,会话恢复、fork 与驱动器替换会保留目标、phase、revision 和已准入 round 数量,却不会启动工作;之后必须通过显式 resume 变更重新激活续行。 + +单独发布的 `./invariant` 配套模块会为每个已挂接会话维护独立折叠。它会在候选事件进入持久日志前拒绝错误的 goal 元数据、来源或模型可见内容漂移、不连续 revision、非法生命周期转换、时间戳回退,以及不连续的已准入 round。 + +## 扩展点 + +策略插件调用服务动词,并响应限定范围的 `goal/changed` 事件。续行消费方将 round 准入为 `user/message` 事件,并携带 `GoalMessageSource`;普通用户轮次绝不会增加 `roundsStarted`。消费方使用 `Agent` 接口和事件,不导入 `dsh-agent-loop`。 + +## 模型体验 + +### 目标状态变更 + +#### 模型看到的内容 + +每项变更都是一个原始用户角色上下文块。快照渲染为 `<goal_state>{"goal":...,"roundsStarted":...,"createdAt":...,"updatedAt":...}</goal_state>`;clear 会渲染 tombstone id/revision 与 `clearedAt`。日志外不存在隐藏状态摘要。这种描述性 XML 分隔符遵循仓库已有的 `<workspace_context>` 约定和 [Anthropic 发布的 XML 标签提示词指南](https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/claude-prompting-best-practices#structure-prompts-with-xml-tags);它是公开的模型体验先例,并非关于任何提供方专有训练语料的声明。 + +#### Token 影响 + +每项保留的变更都会向派生历史增加一份完整快照,直到压缩将其遮蔽。完整快照让每条记录都能独立检查,但会重复目标和生命周期字段。 + +#### KV Cache 影响 + +在一个 epoch 内仅追加:每项变更都位于可复用请求前缀和既有历史之后。压缩可能替换派生历史后缀,并移动可复用边界。 + +## 已知限制与暂缓工作 + +- **只负责状态,不负责任务调度**:此包不决定已激活目标何时继续,不重试异常失败,也不取消活跃轮次;这些策略属于 agent seam 消费方。 +- **只有 round 数量预算**:`maxGoalRounds` 不计量 token、货币、墙上时间或提供方配额。 +- **没有独立评估器**:记录完成或阻塞的调用方拥有最终决定权;由评估器支持的认证暂缓到独立策略层。 +- **只有一个当前目标**:系统有意不支持并行目标或独立目标数据库;替换或清除后,历史仍可在会话日志中读取。 +- **信任进程内生产方**:能直接访问 `Session` 的插件可以追加伪造的 goal 元数据。严格回放会检测错误或不一致的记录,并使 goal 访问从该记录起失败,直到日志修复;这是完整性检测,不是插件隔离。 diff --git a/packages/goal/tool-goal/README.i18n.yaml b/packages/goal/tool-goal/README.i18n.yaml new file mode 100644 index 0000000000..992b4800e0 --- /dev/null +++ b/packages/goal/tool-goal/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: c8906471510b729b4f999cc0175872b7374e8193 +README.zh.md: 7d19d668041c3cd2fa62c9e867cafbe2d5b60692 diff --git a/packages/goal/tool-goal/README.md b/packages/goal/tool-goal/README.md index 68fd695c9f..c890647151 100644 --- a/packages/goal/tool-goal/README.md +++ b/packages/goal/tool-goal/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-tool-goal +English | [中文](README.zh.md) + The model-facing control surface for [`ctx.goals`](../goal/README.md): `get_goal`, `create_goal`, and `update_goal`. The [goal-tool Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md) owns the authority split and Codex-shaped UX. ## Tools diff --git a/packages/goal/tool-goal/README.zh.md b/packages/goal/tool-goal/README.zh.md new file mode 100644 index 0000000000..7d19d66804 --- /dev/null +++ b/packages/goal/tool-goal/README.zh.md @@ -0,0 +1,80 @@ +# @deepseek-ai/dsh-tool-goal + +[English](README.md) | 中文 + +[`ctx.goals`](../goal/README.md) 的面向模型控制接口:`get_goal`、`create_goal` 和 `update_goal`。[goal 工具 Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md)负责权限拆分与 Codex 风格用户体验。 + +## 工具 + +- `get_goal()` 返回当前 goal 或 `null`,包括比较并设置 id/revision、持久 phase、已经准入/受限的 goal round、任何 blocker reason,以及当前进程本地激活状态。 +- `create_goal(objective, max_goal_rounds?)` 从顶层用户直接轮次创建一个 goal。模型可以从长期 goal 意图中推断,而无需精确命令短语;非用户轮次和 subagent 会在执行时被拒绝。 +- `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` 支持 `edit`、`pause`、`resume`、`complete` 和 `blocked`。替换值只属于 `edit`;`blocked_reason` 只有在 action 为 `blocked` 时才必填,并以稳定代码 `model-reported` 持久化。严格 schema 下的空字符串和零填充值视为省略,而有意义的值仍限定到各自 action。 + +所有调用都互斥,因此模型排序的批次能观察到更早变更及其新 revision。UI 客户端会收到纯通用卡片:`get_goal` 使用 read,变更使用 other。变更卡片选择第一个有意义的 action 值,否则显示 goal id,因此已接受的填充值绝不会产生空输入。 + +3 个规范值都与已经渲染给 Native 调用方的紧凑 JSON 一致:`{ goal: null }` 或 `{ goal: { id, revision, objective, phase, roundsStarted, maxGoalRounds, blockedReason? }, activation }`。因此,编程消费方无需解析渲染后的 JSON,即可收到相同领域结构。 + +自主 goal round 成功报告 `complete` 或 `blocked` 时,会为该物理轮次贡献现有终结 `agent/turn-stop` 决策。用户直接变更绝不会贡献该停止决策:assistant 可以确认变更,并发的用户 steering 仍可进入循环。 + +## 权限 + +执行要求完全相同的活跃 `exec.agent`、其继承的 `AgentRegistry` initiator、running 状态与开放轮次。create、edit、pause 和 resume 还要求运行时根 agent 的当前轮次中存在已接受的 `{ kind: 'user' }` 消息或 steering 事件。持久 fork 谱系不会降低已恢复根 agent 的等级;活跃 subagent 所有权会降低。 + +`{ kind: 'user' }` 是宿主证明。`Agent.followup()` 与 `steer()` 会在调用方省略 source 时分配该值,因此插件、调度器与其他非用户生产方必须传入自己的 source,不能继承用户权限。 + +complete 与 blocked 还接受完全相同的当前 goal round:来源为 goal 的 `user/message`,其 id、revision 和 round 与折叠后的当前 goal 相等。在达到 `blockedAfterConsecutiveRounds` 前,goal-round 的 blocked 调用会被机械拒绝;模型判断同一条件是否确实持续,并必须在 `blocked_reason` 中说明。用户直接权限可以立即停止 goal。 + +## 配置 + +```yaml +- id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' + config: + blockedAfterConsecutiveRounds: 3 +``` + +该值必须是正安全整数。它既提供模型自行阻塞的硬下限,也决定模型指引中点名的数量。 + +## 模型体验 + +### 系统提示词 + +#### 模型看到的内容 + +固定 goal 策略说明何种用户语义意图值得创建 goal,要求更新前先精确读取 ref,解释会话 resume/fork 后如何重新激活,并限制完成/阻塞声明。配置的阈值会插入该指引。 + +##### Goal 策略 + +```markdown +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. +``` + +#### Token 影响 + +此插件的提示词注册位于请求范围内时,每次请求都会产生少量固定输入成本。 + +#### KV Cache 影响 + +插件范围、配置阈值和指引文本不变时,前缀保持稳定。激活、资源释放或配置变更可能使此提示词章节的复用失效。 + +### 工具 schema 与结果 + +#### 模型看到的内容 + +生成的 [`get_goal`、`create_goal` 和 `update_goal` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal)。成功结果是紧凑 JSON。变更结果之后是工具批次结束后由 goal 领域产生的原始 `<goal_state>` 快照。结果中的 `activation` 是活跃观察值,绝不会成为回放权限依据。 + +#### Token 影响 + +固定 schema 成本,加上每次调用的一条紧凑结果。变更还会保留领域快照,直到压缩。 + +#### KV Cache 影响 + +Schema 的定义与可见性不变时,前缀保持稳定。调用、结果和生成的 goal 快照会追加到可复用请求前缀之后,不会使更早条目失效。 + +## 已知限制与暂缓工作 + +- **语义意图仍由模型判断**:执行只能证明直接用户来源,无法证明请求是否足够重大而值得创建 goal。 +- **阻塞条件是否相同仍由模型判断**:运行时强制执行不同的已准入 round 计数,而不是障碍的语义等价性;独立评估器保持暂缓。 +- **不负责调度或直接用户呈现**:这些工具只变更状态;同会话驱动器与 [`dsh-command-goal`](../command-goal/README.md) 是同一领域的独立消费方。 +- **Goal-round 权限需要驱动器**:除非续行驱动器准入 goal 来源的用户轮次,否则自主 `complete`/`blocked` 路径不会启用;只挂载此工具包不会创建这些轮次。 +- **提示词注册与过滤相互独立**:某个范围可能隐藏工具,却保留指引,除非部署将两项注册限定在同一范围。 diff --git a/packages/guard/README.i18n.yaml b/packages/guard/README.i18n.yaml new file mode 100644 index 0000000000..30638b2a21 --- /dev/null +++ b/packages/guard/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: b7375fd2bb12ae0cec94b13e6a1012c6f143bdad +README.zh.md: ef218ef62c37ed315ce27541fbc52ffa12ae8e75 diff --git a/packages/guard/README.md b/packages/guard/README.md index e7066ba434..b7375fd2bb 100644 --- a/packages/guard/README.md +++ b/packages/guard/README.md @@ -1,5 +1,7 @@ # guard/ — loop-hygiene guard family +English | [中文](README.zh.md) + Behavioral guard plugins that watch the agent loop for unproductive patterns and nudge the model back on course. A single **product** package — there is no interface/implementation seam here, because a guard is a self-contained consumer of existing core seams (`tools/post-execute`, `agent/prompt-submit`, `agent/status`), not a swappable capability. | Package | Role | ctx key | diff --git a/packages/guard/README.zh.md b/packages/guard/README.zh.md new file mode 100644 index 0000000000..ef218ef62c --- /dev/null +++ b/packages/guard/README.zh.md @@ -0,0 +1,11 @@ +# guard/:循环健康 guard 家族 + +[English](README.md) | 中文 + +这组行为 guard 插件会监视 agent(智能体)循环中的无效模式,并提醒模型调整方向。这里只有一个**产品** 包,不设接口/实现 seam:guard 是现有核心 seam(`tools/post-execute`、`agent/prompt-submit`、`agent/status`)的自包含消费方,并非可替换能力。 + +| 包 | 职责 | ctx 键 | +|---|---|---| +| `repeat-tool-guard/` | 当 agent 对完全相同的工具调用反复循环时给出提示 | (监听 `ctx.tools` 的 waterfall(瀑布式事件)) | + +提示以 `additionalContexts` 形式附在 `tools/post-execute` 决策中传递;agent loop 会在该步骤的工具结果之后,将其追加为有日志记录、来源为插件的 `user/message` 事件(参见[工具包](../core/tools))。因此,guard 告诉模型的所有内容都能从会话日志中重建。 diff --git a/packages/guard/repeat-tool-guard/README.i18n.yaml b/packages/guard/repeat-tool-guard/README.i18n.yaml new file mode 100644 index 0000000000..4b2c1daa28 --- /dev/null +++ b/packages/guard/repeat-tool-guard/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: b9d6337d2145d279758f5494c2ad51ed5e00154f +README.zh.md: 99190c262b015bf28627debd68e67f6c64618a09 diff --git a/packages/guard/repeat-tool-guard/README.md b/packages/guard/repeat-tool-guard/README.md index e7bd79732e..b9d6337d21 100644 --- a/packages/guard/repeat-tool-guard/README.md +++ b/packages/guard/repeat-tool-guard/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-repeat-tool-guard +English | [中文](README.zh.md) + An advisory loop-breaker, not a model-facing tool: it never appears in the tool list, never vetoes or rewrites a call, and adds exactly one behavior — it watches each agent's stream of tool calls, counts runs of consecutive calls to the same tool with identical canonicalized arguments, and at configured run lengths injects an escalating advisory reminder telling the model to stop repeating itself, re-read the last result, and either change approach or conclude. The decision (retry differently, gather more evidence, or finish) stays entirely with the model: a legitimately repeated call is delayed by nothing and blocked by nothing. Decision record: [the repeat-tool-guard Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-repeat-tool-guard.md). ## Config diff --git a/packages/guard/repeat-tool-guard/README.zh.md b/packages/guard/repeat-tool-guard/README.zh.md new file mode 100644 index 0000000000..99190c262b --- /dev/null +++ b/packages/guard/repeat-tool-guard/README.zh.md @@ -0,0 +1,94 @@ +# @deepseek-ai/dsh-repeat-tool-guard + +[English](README.md) | 中文 + +这是一个仅提供建议的循环中断器,而非面向模型的工具:它不会出现在工具列表中,不会否决或改写调用,只增加一种行为。它监视每个 agent(智能体)的工具调用流,统计以完全相同的规范化参数连续调用同一工具的次数;达到所配置的连续次数时,它会注入逐级增强的提示,要求模型停止重复、重新阅读上一次结果,并改用其他方案或结束任务。究竟是换一种方式重试、收集更多证据还是完成任务,仍完全由模型决定:合理的重复调用既不会延迟,也不会受阻。决策记录见 [repeat-tool-guard Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-repeat-tool-guard.md)。 + +## 配置 + +```yaml +- id: repeat-tool-guard + name: '@deepseek-ai/dsh-repeat-tool-guard' + config: + thresholds: [3, 5, 8] # default; consecutive counts that trigger a reminder + include: [] # tool-name patterns to track; empty ⇒ all tools + exclude: [todo_write] # tool-name patterns transparent to the chain + argumentsPreviewChars: 500 # default; cap on arguments quoted in the detailed reminder +``` + +插件加载时,`thresholds` 会对错误配置快速失败:空列表、非整数、小于 2 的值或重复值都会抛出错误,绝不静默回退到默认值;`argumentsPreviewChars` 同样只接受大于等于 1 的整数。系统会将列表按升序规范化;第一个阈值只发送简短的通用提醒,后续每个阈值都会发送详细版本,列出工具、连续次数和规范参数。参数内容在 `argumentsPreviewChars` 处从头截断,并附带省略字符数标记,避免循环中的 `write`/`edit` 载荷无限制进入下一次请求(链键始终比较完整的规范字符串;此上限只约束提醒,不影响检测)。 + +`include`/`exclude` 条目支持 `*` 通配符,并针对调用时实际存在的工具执行谓词判断,而不是引用注册表条目。因此,与当前任何已注册工具都不匹配的模式并非错误(未加载 MCP 工具的部署中,`exclude: [mcp_*]` 仍然有效);这与 `toolOrder` 的引用目标检查不同。 + +## 链语义 + +链键为「`(tool name, canonical arguments)`」:规范化过程会对键进行深度排序,然后执行 `JSON.stringify`,因此仅属性顺序不同的参数对象会视为相同。若某次调用与上一条受跟踪调用相同,该 agent 的连续计数器递增;换成另一条受跟踪调用则重置为 1。 + +- **不受跟踪的调用对链透明。** 被 `include`/`exclude` 排除的调用既不递增计数器,也不重置计数器;因此,`grep X → todo_write → grep X` 仍算作连续两次 `grep X`,即使 `todo_write` 已被排除。这正是排除机制的价值:循环中穿插的记录类工具不能掩盖循环。 +- **被拒绝的调用也计数。** 检测位于 `tools/post-execute`;即便调用被 `tools/pre-execute` 监听器拒绝,该事件也会运行。模型反复尝试被拒绝的调用,恰恰是需要打断的循环。 +- **忽略没有 agent 的调用。** 直接调用 `ctx.tools.execute()` 的调用方没有需要提醒的模型,也没有可作为键的活跃 agent 对象。 +- **按 agent 分键。** 工具注册表位于上下文层级,subagent 会交错通过同一个 waterfall,因此每条链使用 `WeakMap<Agent, Chain>`,以活跃 agent 对象为键。一个 agent 的重复调用绝不会触发另一个 agent 的提醒。用户提示词(`agent/prompt-submit`)会重置提交该提示词的 agent 链;对象生命周期会自然限制弱引用条目的寿命,无需资源释放监听器。 +- **仅驻留内存。** 从持久化恢复的会话会从一条全新的链开始:guard 是启发式提醒,并非有日志记录的不变量;这是接受的代价,即后续提醒可能重新开始。 + +## 提醒传递 + +提醒通过 post-execute 决策中的 `additionalContexts`(来源为 `{kind: 'plugin', plugin: 'repeat-tool-guard'}`)传递,绝不替换 `content`;用于审计的 `tool/result` 事件仍保留工具自己的输出。循环会缓冲这段上下文,并在该步骤的工具结果之后将其作为注入的 `user/message` 追加;会话会将它渲染为普通的合成用户消息。因此,提醒对模型可见、带有来源归属,并且无需增加会话事件即可从会话日志重建。guard 始终通过 `next()` 委派,并将自己的提醒放在下游决策的上下文数组之前(两种结果都适用:被阻止的调用也会收到提醒);每个条目保留自己的来源和元数据。 + +## 测试 + +单元测试使用 mock 适配器(无网络)驱动真实 agent loop,并对上述链语义实现逐文件 100% 覆盖率。快照层负责 transcript(文本记录)接口:脚本化回放场景会将同一调用重复 5 次,并在 ACP transcript 中固定两个提醒层级,即第 3 次的温和提醒和第 5 次的详细提醒;二者均为注入的 `user/message`。 + +## 模型体验 + +### 首个阈值的上下文消息 + +#### 模型看到的内容 + +达到第一个配置的连续重复阈值时,对应 agent 会收到以下提醒。系统不会添加工具 schema 或正常调用文本。 + +##### 首个阈值提醒 + +```markdown +You are repeating the exact same tool call with identical arguments. Carefully analyze the previous result before calling again: if the task is not complete, try a different approach or different arguments instead of repeating the call. +``` + +#### Token 影响 + +达到阈值前为零 token。提醒会作为该 agent 的历史记录保留。 + +#### KV Cache 影响 + +仅追加;新出现的内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 + +### 后续阈值的上下文消息 + +#### 模型看到的内容 + +达到后续阈值时,agent 会收到以下详细提醒模板。受上限约束的参数预览严格以 `… (+<omitted> more chars)` 结尾。 + +##### 后续阈值提醒 + +```markdown +Repeated tool call detected: +- tool: <toolName> +- consecutive_calls: <count> +- arguments: <canonicalArguments> +The repeated calls are not making progress. Do not call this tool with these exact arguments again. Inspect the latest result and choose a different action, different arguments, or finish the task if enough evidence has been gathered. +``` + +#### Token 影响 + +每条提醒都会作为历史记录保留;`argumentsPreviewChars` 会限制随数据变化的参数文本长度,而各 agent 仍使用独立计数器。 + +#### KV Cache 影响 + +仅追加;新出现的内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 + +## 已知限制与暂缓工作 + +- **仅检测精确匹配**:规范化过程会对键进行深度排序,因此近似变体(稍作修改的路径、值内增加的空白)可以绕过链;在没有需求证据前,不采用模糊匹配。 +- **压缩不会重置链**:跨越压缩检查点的链会继续计数。 +- **仅提供建议**:尚未实现达到较高阈值后升级为 `block`,但 `PostToolDecision` 已支持阻止调用。 +- **subagent 之间不共享链**:链始终按 agent 隔离;即使父 agent 与其 subagent 重复相同调用,也不会合并计数。 +- **合理的幂等轮询超过阈值后仍会收到提醒**:可通过 `thresholds`/`exclude` 配置释放压力。 +- **超过最高阈值后链不再提醒**:提醒只在精确达到所配置的次数时触发,超过后不会继续发送。 diff --git a/packages/hooks/README.i18n.yaml b/packages/hooks/README.i18n.yaml new file mode 100644 index 0000000000..af9d4aa4a8 --- /dev/null +++ b/packages/hooks/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 23478fb5e9b813a3370ce465104b1f9db8b0a26a +README.zh.md: 21c75f0476c76c0be75dc3af25ffb9a2be28dc4e diff --git a/packages/hooks/README.md b/packages/hooks/README.md index 0bd64e3f99..23478fb5e9 100644 --- a/packages/hooks/README.md +++ b/packages/hooks/README.md @@ -1,5 +1,7 @@ # hooks/ — hook bridges + shared protocol +English | [中文](README.zh.md) + The hooks subsystem lets users extend the agent at lifecycle points the way Claude Code and Codex do — by pointing a bridge plugin at an existing `hooks.json` (or settings) so those external shell hooks run faithfully. The canonical extension surface itself is the harness's typed interception seams ([the interception-seams Agent Note](../../.agents/notes/implemented/feature/2026-06-30-interception-seams.md)); a "native hook" is just an ordinary cordis plugin on those seams. These packages are the **bridges** that translate the external shell-hook protocol onto that same surface, plus the shared wire-protocol library they build on. | Package | Role | Shape | diff --git a/packages/hooks/README.zh.md b/packages/hooks/README.zh.md new file mode 100644 index 0000000000..21c75f0476 --- /dev/null +++ b/packages/hooks/README.zh.md @@ -0,0 +1,13 @@ +# hooks/:hook 桥接 + 共享协议 + +[English](README.md) | 中文 + +hooks 子系统让用户可以像使用 Claude Code 和 Codex 一样,在 agent 生命周期节点扩展 agent:把桥接插件指向现有的 `hooks.json`(或 settings),即可忠实运行这些外部 shell hook。规范的扩展表层本身是 harness 的类型化拦截 seam(见[拦截 seam Agent Note](../../.agents/notes/implemented/feature/2026-06-30-interception-seams.md));「原生 hook」只是这些 seam 上的普通 cordis 插件。这些包是把外部 shell-hook 协议转换到同一表层的**桥接**,另含它们共同依赖的共享协议格式库。 + +| 包 | 职责 | 形态 | +|---|---|---| +| `hook-protocol/` | 共享协议格式核心:matcher 原语、退出码/stdout codec、`runHook`(通过 `ctx.bash`)、最严格合并、`hook/*` 会话事件、分离运行完全停稳 | 库(非插件) | +| `hooks-claude/` | Claude Code `hooks.json`/settings 的桥接 | 插件 | +| `hooks-codex/` | Codex `hooks.json` 的桥接 | 插件 | + +Codex 有意重新实现 Claude Code 协议的一个*子集*(`hooks.json` 形状相同、5 个事件而非 CC 的众多事件、仅命令、仅正则表达式 matcher、没有 env/替换),因此 `hook-protocol` 拥有真正相同的原语,每个桥接只拥有不同部分(逐事件 stdin 载荷、env,以及把 hook 的中性结果映射到 harness 类型化 Decision 的方式)。参见 [hook-protocol/README.md](hook-protocol/README.md)。 diff --git a/packages/hooks/hook-protocol/README.i18n.yaml b/packages/hooks/hook-protocol/README.i18n.yaml new file mode 100644 index 0000000000..15da549daa --- /dev/null +++ b/packages/hooks/hook-protocol/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: ec25bce4b00102d4de587e69d27e8009ca199b78 +README.zh.md: c0a4c199293eebfeec8a773feb434bbd43e0504b diff --git a/packages/hooks/hook-protocol/README.md b/packages/hooks/hook-protocol/README.md index 3a84807285..ec25bce4b0 100644 --- a/packages/hooks/hook-protocol/README.md +++ b/packages/hooks/hook-protocol/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-hook-protocol +English | [中文](README.zh.md) + The **shared core** of the Claude Code / Codex hook wire protocol. NOT a cordis plugin — it registers nothing and injects nothing. It is a **library** of dialect-neutral primitives the two bridge plugins (`@deepseek-ai/dsh-hooks-claude`, `@deepseek-ai/dsh-hooks-codex`) import so neither re-implements the identical halves of the protocol. Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claude Code hook protocol — the same `hooks.json` matcher-group shape, the same exit-code/stdout output contract, the same command-hook execution model. The genuinely-shared parts live here; each bridge owns only what differs. diff --git a/packages/hooks/hook-protocol/README.zh.md b/packages/hooks/hook-protocol/README.zh.md new file mode 100644 index 0000000000..c0a4c19929 --- /dev/null +++ b/packages/hooks/hook-protocol/README.zh.md @@ -0,0 +1,45 @@ +# @deepseek-ai/dsh-hook-protocol + +[English](README.md) | 中文 + +Claude Code/Codex hook 协议格式的**共享核心**。它不是 cordis 插件:不注册也不注入任何内容。它是一个**库**,提供两个桥接插件(`@deepseek-ai/dsh-hooks-claude`、`@deepseek-ai/dsh-hooks-codex`)导入的方言无关原语,使两者都无需重复实现协议中相同的部分。 + +共享 lib 存在的原因是:Codex 有意重新实现了 Claude Code hook 协议的一个*子集*,包括相同的 `hooks.json` matcher group 形状、相同的退出码/stdout 输出契约以及相同的 command hook 执行模式。真正共享的部分位于此处;每个桥接只拥有不同之处。 + +## 共享内容(此处)与每方言内容(桥接) + +| 关注点 | 此处(`dsh-hook-protocol`) | 桥接(`dsh-hooks-claude` / `-codex`) | +|---|---|---| +| Matcher 测试 | `matchesMatcher(pattern, query, mode)`:根据 `mode` 使用字面匹配或正则匹配 | 选择自身 `mode`(`claude` = 字面或正则,`codex` = 始终使用正则) | +| 运行 hook | `runHook(bash, hook, opts, now)`:通过 `ctx.bash` 提供 stdin payload + env,再解码 | 构造每个事件的 stdin **payload** + 该方言的 **env** | +| 解码输出 | `parseHookOutput(exit, stdout, stderr)` → 中性 `HookOutput` | 将中性 `HookOutput` 映射到 seam 特定的类型化 Decision | +| 合并 N 个 hook | `mergeHookOutputs(outputs)` → 最严格的 `MergedHookOutcome` | (无) | +| 持久记录 | `appendHookInvoked` / `appendHookResult`(`hook/*` 会话事件;结果的 `decision`/`stderrSummary` 从此处的 `HookOutput` 派生) | 在每次调用前后调用它们 | +| 脱离运行完全停稳 | `createDetachedRuns()`:跟踪发射后不再等待的运行链;`drain()` 先 abort,再等待它们 | 将 `signal` 传给每个脱离的 `runHook`,并将 `drain` 注册为 effect disposer | + +## 原语 + +- **`matchesMatcher(matcher, query, mode)`**:缺失、`''` 或 `'*'` 时匹配全部;`claude` 模式将纯 `[A-Za-z0-9_|]+` pattern 视为字面值(pipe = 精确匹配交替),其他 pattern 视为正则;`codex` 模式始终使用未锚定正则。无效正则不匹配任何内容(绝不抛出异常)。 +- **`runHook(bash, hook, options, now)`**:要求并转发调用方拥有的 `options.signal`,将 `options.payload` 序列化到 hook stdin(当且仅当 `options.trailingNewline` 时添加尾随换行符),在执行器凭证清理后合并 `options.env`(`dsh-bash` 受信任插件表层),遵循 hook 的 `timeoutSec`(否则使用 `options.defaultTimeoutMs`;默认值属于桥接,其配置默认为 lib 的 `DEFAULT_HOOK_TIMEOUT_MS` 10 分钟参考值),再解码结果(将 `options.expectedEventName` 传递给 codec)。因此取消会到达执行器的进程组终止与 join 边界。它绝不抛出异常:执行器拒绝(基础设施故障)会变为 `HookOutput`,其 `exitCode: undefined`(非阻塞错误)。`now` 会被注入,以便测试持续时间。 +- **`parseHookOutput(exitCode, stdout, stderr, expectedEventName?)`** 解码退出状态与结构化 stdout。退出码 2 使用 stderr 阻塞;其他失败不阻塞。匹配的 hook 特定权限决策会覆盖遗留顶层决策;事件判别字段不匹配或缺失只会抑制事件特定字段。顶层字段仍与事件无关,成功但非 JSON 的输出会留给桥接处理。 +- **`mergeHookOutputs(outputs)`**:折叠在一个点上匹配的每个 hook 结果:权限优先级为 **deny > ask > allow**,首个 `continue:false` 使 halt 粘滞,阻塞原因用 `\n\n` 连接,`additionalContext`/`systemMessages` 按顺序累积。 +- **`createDetachedRuns()`**:为脱离运行的 emit 形状点跟踪完全停稳(没有 seam 等待它们)。桥接会跟踪每条运行链,包括 hook 运行及其 continuation,并将 `drain()` 注册为 effect disposer。drain 会触发 tracker 的 abort `signal`(因此仍在运行的 hook 进程会通过 `runHook` 终止,而不是等待到超时),随后在所有已跟踪链结算后 resolve。因此 `fiber.dispose()` resolve 时,没有脱离 hook 工作会留下并触发已 dispose 的上下文(见 [防御模式](../../../docs/defensive-patterns.md):dispose 必须达到完全停稳)。 + +## `hook/*` 会话事件 + +通过 declaration merging 合并到 `SessionEventMap`(仅日志,与 `compact/*` 相同;不是 `SurfaceEventType`,没有 `surfaceOp`):`hook/invoked`(hook 命令已运行)与 `hook/result`(其结果,按 `handlerId` 配对,由 `appendHookResult` 拥有决策规则)。Payload 与每事件 JSDoc 位于生成的 [持久化日志事件目录](../../../docs/persistence-catalog.md);`stderrSummary` 会截断到记录的 `stderrSummaryMaxChars`(桥接配置,参考默认值 `DEFAULT_STDERR_SUMMARY_MAX_CHARS` = 500;为空时省略)。 + +与每个事件一样,它们必须位于开启轮次内。轮次中点(`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`)按构造位于 loop 的开启轮次中;`SessionStart` 没有 `hook/*` 记录(其注入的 `user/message` 是持久证据),详见 hooks Agent Note。 + +## 模型体验 + +通过 `dsh-hooks-claude` 与 `dsh-hooks-codex` 间接影响;它们可以将解析后 hook 输出转为提示词上下文、已阻塞结果或 continuation 反馈。 + +#### KV Cache 影响 + +不会直接失效;请求前缀变更由具名消费方负责。 + +## 已知限制与暂缓事项 + +- **`HookOutput.updatedInput` 会被解析但不会应用**:输入改写是已暂缓的一致性设计问题(见 [pre-tool-input-rewrite Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md));当 hook 设置它时,桥接会记录 + 警告。完整契约见 `src/types.ts`。 +- **无效 matcher 正则会静默地不匹配任何内容**:`matchesMatcher` 绝不抛出异常;显示该错误需要返回诊断的变体或解析时验证(`TODO(matcher-diagnostics)`)。 diff --git a/packages/hooks/hooks-claude/README.i18n.yaml b/packages/hooks/hooks-claude/README.i18n.yaml new file mode 100644 index 0000000000..15cc2c6dd9 --- /dev/null +++ b/packages/hooks/hooks-claude/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 25709d440146e3afef954940080b29a3e1c1ec1a +README.zh.md: 07eb53673f9a954589eac3a8a7e55e4ef95b44dd diff --git a/packages/hooks/hooks-claude/README.md b/packages/hooks/hooks-claude/README.md index b492b2939d..25709d4401 100644 --- a/packages/hooks/hooks-claude/README.md +++ b/packages/hooks/hooks-claude/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-hooks-claude +English | [中文](README.zh.md) + A cordis plugin that runs the supported command-hook subset of a user's existing **Claude Code** hook config (a `hooks.json`, or a settings file's `hooks` key) on the harness's canonical interception seams. It is the **CC dialect** half of the hooks subsystem: it owns the bridge's CC-shaped per-event stdin payloads, CC's env + `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` substitution, and the mapping from a hook's neutral outcome onto the harness's typed Decisions. The dialect-agnostic primitives (matcher, exit-code/stdout codec, `ctx.bash` execution, most-restrictive merge, the `hook/*` events) come from [`@deepseek-ai/dsh-hook-protocol`](../hook-protocol/README.md). A native cordis plugin could do everything this bridge does — more powerfully, with typed returns and no serialization boundary. **The bridge exists only as a compatibility path for the mapped CC command-hook subset**; anything bespoke should be a native plugin on the same seams (see [the interception-seams Agent Note](../../../.agents/notes/implemented/feature/2026-06-30-interception-seams.md)). diff --git a/packages/hooks/hooks-claude/README.zh.md b/packages/hooks/hooks-claude/README.zh.md new file mode 100644 index 0000000000..07eb53673f --- /dev/null +++ b/packages/hooks/hooks-claude/README.zh.md @@ -0,0 +1,97 @@ +# @deepseek-ai/dsh-hooks-claude + +[English](README.md) | 中文 + +一个 cordis 插件,在 harness 的规范拦截 seam 上运行 user 现有 **Claude Code** hook 配置(`hooks.json` 或 settings 文件的 `hooks` key)中受支持的 command hook 子集。它是 hooks 子系统的 **CC 方言** 一半,拥有桥接的 CC 形状每事件 stdin payload、CC env + `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` 替换,以及从 hook 中性结果到 harness 类型化 Decision 的映射。方言无关原语(matcher、退出码/stdout codec、`ctx.bash` 执行、最严格合并、`hook/*` 事件)来自 [`@deepseek-ai/dsh-hook-protocol`](../hook-protocol/README.md)。 + +原生 cordis 插件可以完成此桥接的所有工作,功能更强,且具有类型化返回,没有序列化边界。**该桥接只是已映射 CC command hook 子集的兼容路径**;所有定制行为都应当使用相同 seam 上的原生插件(见 [拦截 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-30-interception-seams.md))。 + +## 配置 + +```ts +import type { Config } from '@deepseek-ai/dsh-hooks-claude' +const config: Config = { + configPath: '/path/to/hooks.json', // required: a hooks.json or a settings file with a `hooks` key + pluginRoot: '/path/to/plugin', // optional: replaces ${CLAUDE_PLUGIN_ROOT} in command strings + projectDir: '/path/to/project', // optional: replaces ${CLAUDE_PROJECT_DIR} AND sets the hook env var; defaults to the session cwd when omitted + defaultTimeoutMs: 600_000, // optional: per-hook timeout when a hook sets none (CC default) + stderrSummaryMaxChars: 500, // optional: char cap on the hook/result event's persisted stderr summary +} +``` + +在 `cordis.yml` 中: + +```yaml +- dsh-hooks-claude: + configPath: ./.claude/hooks.json + pluginRoot: ./.claude/plugins/my-plugin + projectDir: . +``` + +配置只在加载时解析**一次**。`configPath` 是**进程级** 配置:相对路径在加载时根据进程启动 cwd 解析,因此一份配置应用于整个进程。尚未进行每会话(`session/new.cwd`)配置发现(`TODO(per-session-hook-config)`)。读取/解析失败会被容纳:桥接记录警告且不注册任何内容,而不是使启动崩溃(路径拼写错误不应使 agent 停止)。只运行 shell 形式 `type: 'command'` hook;`http`/`mcp_tool`/`prompt`/`agent` hook 会被解析并跳过,同时记录警告。没有每 hook `timeout` 的 hook 会使用协议参考默认值 `DEFAULT_HOOK_TIMEOUT_MS`(来自 `dsh-hook-protocol`,10 分钟,即 CC 默认值)。 + +hook **本身** 会在 agent 的会话工作区中运行:对 agent scope 点,桥接会将会话 `cwd`(`session/new.cwd`)作为 hook 进程工作目录,因此 hook 的 `pwd`/相对路径/marker 作用于 user 项目树,而非服务器启动目录。 + +## Hook 点 → seam Decision + +| CC hook | Harness seam | 映射 | +|---|---|---| +| `SessionStart` | `agent/session-start`(emit) | additionalContext → `agent.inject()` 到新会话(无法阻塞) | +| `UserPromptSubmit` | `agent/prompt-submit`(waterfall) | `deny` → `PromptDecision.block`;仅 additionalContext → 通过 `next()` 委托,再将一个单独标记源的上下文前置到下游 `additionalContexts`(后续 listener 仍可阻塞/改写) | +| `PreToolUse` | `tools/pre-execute`(waterfall) | `deny` → `PreToolDecision.deny`;`ask` → `PreToolDecision.ask` | +| `PostToolUse` | `tools/post-execute`(waterfall) | `deny` → 带反馈的 `block`;仅 additionalContext → 通过 `next()` 委托,再将一个单独标记源的上下文前置到下游决策;Code Mode 将子调用上下文延迟到外层 `run_code` 结果 | +| `Stop` | `agent/turn-continuation`(waterfall) | 阻塞 Stop hook 强制 `continue`,并将原因作为下一步 steering | +| `SubagentStart` | `subagent/start`(emit) | additionalContext → `agent.inject()` 到实时同进程 child;远程 child 没有本地注入目标 | +| `SubagentStop` | `subagent/end`(emit) | 只观测 | + +三个 emit 点都脱离运行:没有 seam 会等待 `SessionStart`/`SubagentStart`/`SubagentStop` hook。每条运行链都会被跟踪;dispose 桥接会中止仍在运行的 hook 进程,再排空 continuation,然后 dispose resolve(`createDetachedRuns`,位于 `dsh-hook-protocol`)。 + +matcher subject 是工具名称(`PreToolUse`/`PostToolUse`)、会话源(`SessionStart`),或常量 `agent_type`,其值为 `general-purpose`(`SubagentStart`/`SubagentStop`)。harness subagent seam 不携带每 kind label,因此桥接报告 Claude Code 自身 Task 工具默认值;默认/`*`/空 `agent_type` matcher 会触发,特定 kind matcher 不会触发。`UserPromptSubmit`/`Stop` 忽略 matcher。一个点上文件配置的多个 hook 会**按配置顺序串行运行**,并按最严格方式折叠(`deny > ask > allow`,见 `dsh-hook-protocol`)。串行使每个 hook 的 `hook/invoked`/`hook/result` 对在日志中相邻,决策折叠与顺序无关(见 Agent Note 的「run serially, not concurrently」说明)。 + +每个 agent scope stdin payload 都携带 `session_id` 与字符串形状的 `transcript_path`。可用时,桥接通过 `ctx.sessionPersistence.locate(session.header)` 解析后者,否则发送 `''`。查找不会创建或 flush 产物,因此第一个轮次结束检查点之前路径可能不存在,也可能省略当前开启轮次。 + +## 上下文源 + +注入上下文携带显式 `{ kind: 'plugin', plugin: 'hooks-claude' }` 源。`agent.inject()` 会将缺失源默认为 `{ kind: 'user' }`,这会将插件上下文错误标记为 user 提示词,因此桥接始终标注自身。 + +## 模型体验 + +### Hook 提供的上下文 + +#### 模型看到的内容 + +`SessionStart`、已接受提示词、工具后和实时同进程 subagent-start hook 可以添加带源归因的上下文消息;阻塞 `Stop` hook 将原因添加为下一步 steering。远程 child 注入没有本地目标。 + +#### Token 影响 + +hook 不返回上下文时没有成本。Hook 文本取决于数据,会被记录,并在后续会话请求中重发,直到压缩。 + +#### KV Cache 影响 + +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 配置项失效。 + +### 已阻塞提示词或工具结果 + +#### 模型看到的内容 + +提供方提供的原因逐字传递。缺失原因时,已阻塞提示词精确使用 `blocked by UserPromptSubmit hook`,已拒绝工具变为 `Error: blocked by PreToolUse hook`,已阻塞工具后反馈精确为 `blocked by PostToolUse hook`,阻塞 stop 则精确添加 steering `continue: blocked by Stop hook`。`systemMessage` 与 `updatedInput` 会被记录或警告,但在此实现中对模型不可见。 + +#### Token 影响 + +阻塞提示词会移除该提示词的请求 token;拒绝或反馈会添加保留的回退或提供方文本;强制 continuation 需要另一个完整请求。 + +#### KV Cache 影响 + +已阻塞提示词不发送请求,不会导致失效。拒绝、反馈与强制 continuation 上下文会追加在可复用前缀之后,不改写前缀。 + +## 已知限制与暂缓事项 + +- **不支持的 hook 事件(Claude Code 当前 30 项中的 23 项):** `Setup`、`InstructionsLoaded`、`UserPromptExpansion`、`MessageDisplay`、`PermissionRequest`、`PostToolUseFailure`、`PostToolBatch`、`PermissionDenied`、`Notification`、`TaskCreated`、`TaskCompleted`、`StopFailure`、`TeammateIdle`、`ConfigChange`、`CwdChanged`、`FileChanged`、`WorktreeCreate`、`WorktreeRemove`、`PreCompact`、`PostCompact`、`SessionEnd`、`Elicitation` 和 `ElicitationResult`。这些事件的配置会被解析,但绝不分派。比较基线是 Claude Code [官方 hook 事件参考](https://code.claude.com/docs/en/hooks#hook-events)。 +- **`SessionStart` 只支持部分功能:** 会消费 JSON `additionalContext`,但不支持纯 stdout 上下文、`initialUserMessage`、`sessionTitle`、`watchPaths`、`reloadSkills` 与 `CLAUDE_ENV_FILE`。hook 脱离运行,因此上下文可能错过第一个请求(`TODO(session-start-gating)`),payload 会省略 `model`、`agent_type` 和 `session_title` 等当前可选字段。 +- **`UserPromptSubmit` 只支持部分功能:** 支持阻塞与 JSON `additionalContext`,但不支持纯 stdout 上下文、`sessionTitle` 和 `suppressOriginalPrompt`。除非被覆盖,否则桥接还会使用自身 600 秒默认值,而非 Claude Code 的事件特定 30 秒 command 超时。 +- **`PreToolUse` 只支持部分功能:** `deny` 与 `ask` 决策可用;`allow` 不会预批准,不支持 `defer`,`additionalContext` 会被忽略,`updatedInput` 会被记录 + 警告但不应用(见 [pre-tool-input-rewrite Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md))。 +- **`PostToolUse` 只支持部分功能:** 支持阻塞反馈与 JSON `additionalContext`,但不支持 `updatedToolOutput` 和 `updatedMCPToolOutput`,`tool_response` 会展平为文本。 +- **`SubagentStart` 与 `SubagentStop` 只支持部分功能:** 两者均报告常量 `agent_type`,其值为 `general-purpose`,并在 Claude Code 报告父会话的位置使用 child 会话 id。Start 上下文是尽力而为,且只能到达实时同进程 child;stop 只观测,无法阻塞 subagent 或向其提供上下文。Start 省略 `transcript_path`;stop 还省略 `agent_transcript_path`、`last_assistant_message`、`background_tasks` 和 `session_crons`,并始终报告 `stop_hook_active: false`。 +- **`Stop` 只支持部分功能:** 阻塞会强制另一个模型轮次,但 `stop_hook_active` 始终为 `false`,会省略 `last_assistant_message`、`background_tasks` 和 `session_crons`,且未实现连续阻塞上限(`TODO(stop-loop-guard)`)。因此,无条件阻塞 hook 会在每个步骤中强制 continuation,除非它自我限制。 +- **通用 payload 与输出字段只支持部分功能:** 已映射事件会省略 Claude Code 原本会提供的 `prompt_id`、`transcript_path`、`permission_mode` 和 `effort`。`systemMessage` 会被记录 + 警告但不呈现;`{"continue": false}` 会被记录但不会停止运行;不会应用 `suppressOutput`、`stopReason` 和 `terminalSequence`(`TODO(hook-continue-false)`)。 +- **Handler 与配置只支持部分功能:** 只运行 shell 形式 command handler。会跳过 `http`、`mcp_tool`、`prompt` 和 `agent` handler;不遵循 `args`、`async`、`asyncRewake`、`shell`、`if`、`once` 和 `statusMessage` 等 command handler 选项。匹配 handler 串行运行且不去重,而 Claude Code 会并行运行并对相同 handler 去重。一个进程级 `configPath` 会在加载时解析一次;尚未实现 Claude Code 的分层项目、user、plugin 与 policy 发现和实时重新加载(`TODO(per-session-hook-config)`)。 diff --git a/packages/hooks/hooks-codex/README.i18n.yaml b/packages/hooks/hooks-codex/README.i18n.yaml new file mode 100644 index 0000000000..c1e6cf4a81 --- /dev/null +++ b/packages/hooks/hooks-codex/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 451d497a0e397bae4ecd5059d1e6c3d5f8d9705a +README.zh.md: a7148f8046ecb1183adbf41d11744778cf4a7dcb diff --git a/packages/hooks/hooks-codex/README.md b/packages/hooks/hooks-codex/README.md index 0c8c4c16ba..451d497a0e 100644 --- a/packages/hooks/hooks-codex/README.md +++ b/packages/hooks/hooks-codex/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-hooks-codex +English | [中文](README.zh.md) + A cordis plugin that runs the supported subset of a user's existing **Codex** hook config on the harness's canonical interception seams. The **Codex dialect** half of the hooks subsystem. The dialect-agnostic primitives come from [`@deepseek-ai/dsh-hook-protocol`](../hook-protocol/README.md); this bridge owns the Codex-shaped payloads, matcher mode, and decision mapping. This bridge implements a deliberate subset of Codex's current hook protocol: diff --git a/packages/hooks/hooks-codex/README.zh.md b/packages/hooks/hooks-codex/README.zh.md new file mode 100644 index 0000000000..a7148f8046 --- /dev/null +++ b/packages/hooks/hooks-codex/README.zh.md @@ -0,0 +1,100 @@ +# @deepseek-ai/dsh-hooks-codex + +[English](README.md) | 中文 + +一个 cordis 插件,在 harness 的规范拦截 seam 上运行 user 现有 **Codex** hook 配置的受支持子集。它是 hooks 子系统的 **Codex 方言** 一半。方言无关原语来自 [`@deepseek-ai/dsh-hook-protocol`](../hook-protocol/README.md);该桥接拥有 Codex 形状 payload、matcher 模式和决策映射。 + +该桥接实现 Codex 当前 hook 协议的一个明确子集: + +- **10 个 hook 点中的 5 个:** `PreToolUse`、`PostToolUse`、`SessionStart`、`UserPromptSubmit` 和 `Stop`。 +- **只使用正则 matcher**(没有字面快速路径;matcher 始终是未锚定正则)。 +- **snake_case stdin payload**,携带 `turn_id`/`model` 额外字段,写入时**不带** 尾随换行符。 +- **没有 Codex 插件 env 注入,也没有配置时 placeholder 替换**(命令仍会接收执行器环境,并通过其 shell 运行)。 +- **没有工具前批准或改写路径**:hook 可以阻塞,但桥接不会预批准或替换工具输入。 + +原生 cordis 插件可以完成此桥接的所有工作,并且功能更强;该桥接只是已映射 Codex 子集的兼容路径(见 [拦截 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-30-interception-seams.md))。 + +## 配置 + +```ts +import type { Config } from '@deepseek-ai/dsh-hooks-codex' +const config: Config = { + configPath: '/path/to/.codex/hooks.json', // required + model: 'deepseek-v4', // optional: stamped on every payload (Codex includes `model`) + defaultTimeoutMs: 600_000, // optional: per-hook timeout when a hook sets none + stderrSummaryMaxChars: 500, // optional: char cap on the hook/result event's persisted stderr summary +} +``` + +在 `cordis.yml` 中: + +```yaml +- dsh-hooks-codex: + configPath: ./.codex/hooks.json + model: deepseek-v4 +``` + +配置只在加载时解析**一次**。`configPath` 是**进程级** 配置:相对路径在加载时根据进程启动 cwd 解析,而非每会话解析(`TODO(per-session-hook-config)`)。读取/解析失败会被容纳(记录 + 不注册任何内容)。只运行同步 `type: 'command'` hook;非 command 或 `async: true` hook 会被解析并跳过,同时记录警告。hook 接受 `timeout` 或 `timeoutSec` alias;两者都未设置时,使用协议参考默认值 `DEFAULT_HOOK_TIMEOUT_MS`(来自 `dsh-hook-protocol`,10 分钟)。五个桥接支持点之外的事件会在解析时丢弃。 + +hook 本身会在 agent 的会话工作区中运行:对 agent scope 点,桥接会将会话 `cwd` 作为 hook 进程工作目录,因此 hook 作用于 user 项目树,而非服务器启动目录。 + +## Hook 点 → seam Decision + +| Codex hook | Harness seam | 映射 | +|---|---|---| +| `SessionStart` | `agent/session-start`(emit) | 纯 stdout hook 的输出 → additionalContext → `agent.inject()` | +| `UserPromptSubmit` | `agent/prompt-submit`(waterfall) | `block`(退出码 2)→ `PromptDecision.block`;仅 additionalContext → 通过 `next()` 委托,再将一个单独标记源的上下文前置到下游 `additionalContexts` | +| `PreToolUse` | `tools/pre-execute`(waterfall) | `block` → `PreToolDecision.deny`(没有 `allow`/`ask`) | +| `PostToolUse` | `tools/post-execute`(waterfall) | `block` → 带反馈的 `block`;仅 additionalContext → 通过 `next()` 委托,再将一个单独标记源的上下文前置到下游决策;Code Mode 将子调用上下文延迟到外层 `run_code` 结果 | +| `Stop` | `agent/turn-continuation`(waterfall) | 阻塞 Stop hook 使用原因作为下一步 steering,强制 `continue` | + +工具调用的 payload 携带真实 `tool_name`(matcher 测试的相同值)与 Codex `tool_input: { command }` 形状(存在 `command` arg 时使用该值,否则使用 `''`)。matcher subject 是工具名称(`PreToolUse`/`PostToolUse`)或会话源(`SessionStart`);`UserPromptSubmit`/`Stop` 忽略 matcher。 + +每个 agent scope stdin payload 都携带 `session_id` 和 `transcript_path`。可用时,桥接通过 `ctx.sessionPersistence.locate(session.header)` 解析后者,否则发送 `null`,保留 Codex `string | null` 形状。查找不会创建或 flush 产物,因此第一个轮次结束检查点之前路径可能不存在,也可能省略当前开启轮次。 + +`SessionStart` 是唯一的 emit 点,它会脱离运行。每条运行链都会被跟踪;dispose 桥接会中止仍在运行的 hook 进程,再排空 continuation,然后 dispose resolve(`createDetachedRuns`,位于 `dsh-hook-protocol`)。 + +## 上下文源 + +注入上下文携带显式 `{ kind: 'plugin', plugin: 'hooks-codex' }` 源(否则 `agent.inject()` 会将其默认为 `{ kind: 'user' }`)。 + +## 模型体验 + +### Hook 提供的上下文 + +#### 模型看到的内容 + +`SessionStart`、已接受提示词和工具后 hook 可以添加带源归因的上下文消息;阻塞 `Stop` hook 将其原因添加为下一步 steering。 + +#### Token 影响 + +hook 不返回上下文时没有成本。Hook 文本取决于数据,会被记录,并重发直到压缩。 + +#### KV Cache 影响 + +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 配置项失效。 + +### 已阻塞提示词或工具结果 + +#### 模型看到的内容 + +提供方提供的原因逐字传递。缺失原因时,已阻塞提示词精确使用 `blocked by UserPromptSubmit hook`,已拒绝工具变为 `Error: blocked by PreToolUse hook`,已阻塞工具后反馈精确为 `blocked by PostToolUse hook`,阻塞 stop 则精确添加 steering `continue: blocked by Stop hook`。Codex `systemMessage` 不会呈现。 + +#### Token 影响 + +阻塞提示词会移除其请求 token;拒绝或反馈会添加保留的回退或提供方文本;强制 continuation 需要另一个完整请求。 + +#### KV Cache 影响 + +已阻塞提示词不发送请求,不会导致失效。拒绝、反馈与强制 continuation 上下文会追加在可复用前缀之后,不改写前缀。 + +## 已知限制与暂缓事项 + +- **不支持的 hook 事件(Codex 当前 10 项中的 5 项):** `PermissionRequest`、`PreCompact`、`PostCompact`、`SubagentStart` 和 `SubagentStop`。这些事件的配置会在解析期间静默丢弃。比较基线是 Codex [官方 hook 参考](https://learn.chatgpt.com/docs/hooks)。 +- **`SessionStart` 只支持部分功能:** 支持纯 stdout 与 JSON `additionalContext`,但 hook 脱离运行,因此上下文可能错过第一个请求(`TODO(session-start-gating)`)。 +- **`UserPromptSubmit` 只支持部分功能:** 支持阻塞加纯 stdout 或 JSON 上下文,但不会强制执行通用 `systemMessage` 和 `{"continue": false}` 控制。 +- **`PreToolUse` 只支持部分功能:** 支持阻塞,但会忽略 `additionalContext`、`permissionDecision: "allow"` 和 `updatedInput`。每个工具都表示为 `tool_input: { command }`,因此非 shell 工具参数不会如实公开给 hook。 +- **`PostToolUse` 只支持部分功能:** 支持阻塞反馈与 JSON `additionalContext`,但不会强制执行 `{"continue": false}`,非 shell 工具参数会缩减为 `{ command }`,结构化工具输出会在 `tool_response` 中展平为文本。 +- **`Stop` 只支持部分功能:** 阻塞会强制另一个模型轮次,但 `stop_hook_active` 始终为 `false`,`last_assistant_message` 始终为 `null`,且不会强制执行 `{"continue": false}`。因此,无条件阻塞 hook 会在每个步骤中强制 continuation,除非它自我限制(`TODO(stop-loop-guard)`)。 +- **通用 payload 与输出字段只支持部分功能:** 每个已映射事件都报告 `transcript_path: null`、静态配置的 `model` 与 `permission_mode: "default"`,而非当前 Codex 运行时值。`systemMessage` 会被记录 + 警告但不呈现,`{"continue": false}` 会被记录但不会应用 Codex 事件特定停止行为(`TODO(hook-continue-false)`)。 +- **配置加载与执行只支持部分功能:** 一个进程级 `configPath` 会在加载时解析;尚未实现 Codex 的活动 user、project、session、system/managed 和 plugin 分层、信任控制与内联 `config.toml` hook 形式(`TODO(per-session-hook-config)`)。只运行同步 `command` handler,忽略 `statusMessage` 与 `commandWindows` 等当前元数据,匹配 handler 串行运行,而非使用 Codex 的并发启动语义。 diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml new file mode 100644 index 0000000000..13000e2010 --- /dev/null +++ b/packages/host/apiproxy/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 8113357bbcff2d654db7fc68c4e7903ecf0ccd72 +README.zh.md: a5bf1d3cb8c96bc754938abd0dc5c70533476751 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 58cab1f2cc..8113357bbc 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-host-apiproxy +English | [中文](README.zh.md) + The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model, workspaceRoot?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves. The shipped core composition lives in [`apps/cli/cordis.yml`](../../../apps/cli/cordis.yml). ## Contract layer (`/api`) diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md new file mode 100644 index 0000000000..a5bf1d3cb8 --- /dev/null +++ b/packages/host/apiproxy/README.zh.md @@ -0,0 +1,33 @@ +# @deepseek-ai/dsh-host-apiproxy + +[English](README.md) | 中文 + +所有客户端形态共用的 API 网关:TS 契约(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{provider, model, workspaceRoot?}`,提供 `ctx.apiProxy`)。该包(package)在设计上与传输方式无关,不注册任何路由;载体(目前为 HTTP,未来可以是 IPC)自行包装 `ctx.apiProxy`。已发布的核心组合位于 [`apps/cli/cordis.yml`](../../../apps/cli/cordis.yml)。 + +## 契约层(`/api`) + +协议消息组成一个四象限可辨识联合:发起方 × 请求/响应,与物理通道解耦。四种消息分别是 `ClientRequest`(POST `/api/<method>` 的请求体)、`ServerResponse`(该 POST 的响应体)、`ServerRequest`(SSE 帧)和 `ClientResponse`(POST `/api/respond` 的请求体)。响应始终回显对应请求的 `rpcId`,绝不签发新值。方法的参数与返回值结构只存在于领域接口签名(`SessionsApi`、`HostApi`、`EventsApi`)中;`RpcMethodMap` 注册方法,其他所有位置均通过 `RequestPayload<K>`/`ResponseValue<K>` 派生。Zod schema 以 `satisfies z.ZodType<Wire<T>>` 锚定类型,并分两层解析:先解析信封,再解析业务载荷,随后按方法分发。业务错误由 `RpcResult` 的错误分支承载(`RpcErrorDetailsMap` 封闭错误码集合);HTTP 状态只表达载体层结果。 + +分层与协议决策记录在 [GUI 分层与 RPC 协议 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)中;浏览器侧消费架构记录在 [Web 客户端架构 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)中。 + +mux 流会在每个已附加会话的订阅基线之后,以及对应的实时原始标题事件之后,立即把基于日志的最新标题投影为经过校验的 `session/title` 控制帧。该投影不会把标题加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。 + +Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。前端 Workspace Intent 与 Session Intent 只存在于客户端,没有协议方法。 + +## 载体层(`/client` + 根路径) + +`AbstractApiClient` 持有全部协议不变量:签发 rpcId、包装/解包信封、Zod 解析、SSE 帧解码、一元请求超时,以及按微任务批处理的信封观测(`subscribeEnvelopes`);平台子类只提供 `doFetch` 传输环节。`InProcessApiClient` 以 `toFetchHandler(api)` 为基础,是同构接点:它运行完整的协议序列化与校验路径而不经过网络,供 `dsh -p` headless 模式使用。 + +## 模型体验 + +无。该包定义客户端与宿主间的协议契约和载体,其中没有任何内容会进入模型请求。 + +#### KV 缓存影响 + +无;该包既不组装也不发送提供方请求。 + +## 已知限制与延期工作 + +- **`respond` 路由已经发布,但待处理交互状态仍属宿主侧工作**:协议形状(POST `/api/respond`、`RpcReceipt`)已经定型;使延迟或重复回答具有明确语义的待处理表位于 `src/api-proxy.ts`,目前仍很精简(只支持问题,不支持审批)。 +- **预留 seam 不进入 `RpcMethodMap`**:`session.fork`、`prompt.mode: 'inject'`、`task.list`、`host.listModels` 和描述字段 `hostInstanceId` 都是已记录的预留项;未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。 +- **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 201bc555a9..c7d3cb7ef9 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -14,7 +14,8 @@ import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' import { - workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId, WorkspaceNameConflictError, + workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId, + WorkspaceMoveInvalidError, WorkspaceNameConflictError, } from '@deepseek-ai/dsh-workspace' // Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters). import type {} from '@deepseek-ai/dsh-tools' @@ -298,6 +299,15 @@ class SessionCwdConflict extends Error { /** Host failed before the registry could adopt a name-created directory. */ class WorkspaceDirectoryCreationError extends Error {} +/** Shared workspace-not-found error response of the workspace.* mutation rows. */ +function workspaceNotFound<T>(request: RpcRequest<unknown>, workspaceId: string): RpcResponse<T> { + return err(request, { + code: 'workspace-not-found', + message: `workspace "${workspaceId}" not found`, + details: { workspaceId }, + }) +} + /** Wire projection of one workspace entity (the workspace.* value row). */ function workspaceView(workspace: Workspace): WorkspaceView { return { @@ -680,6 +690,60 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } }, + async rename(request) { + const { payload } = request + const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId)) + if (workspace === undefined) return workspaceNotFound(request, payload.workspaceId) + const title = payload.title.trim() + // Uniqueness AND the same-title no-op both ride the create chain so + // they observe the state left by earlier queued renames — checked + // up front, a queued A→A could report success while an earlier A→B + // still lands afterwards. + const operation = workspaceCreationChain.then(async () => { + if (title === workspace.title) return + if (ctx.workspace.list().some(other => other.id !== workspace.id && other.title === title)) { + throw new WorkspaceNameConflictError(title) + } + await workspace.setTitle(title) + }) + workspaceCreationChain = operation.then(() => undefined, () => undefined) + try { + await operation + } catch (error: unknown) { + if (error instanceof WorkspaceNameConflictError) { + return err(request, { + code: 'workspace-name-conflict', + message: error.message, + details: { name: error.workspaceName }, + }) + } + throw error + } + return ok(request, { workspace: workspaceView(workspace) }) + }, + + async insertSessionBefore(request) { + const { payload } = request + const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId)) + if (workspace === undefined) return workspaceNotFound(request, payload.workspaceId) + try { + await workspace.insertSessionBefore(payload.sessionId, payload.beforeSessionId) + } catch (error: unknown) { + // Only the entity's unaccounted-id rejection is the business code; + // storage/durability failures propagate as internal errors. + if (!(error instanceof WorkspaceMoveInvalidError)) throw error + return err(request, { + code: 'workspace-move-invalid', + message: error.message, + details: { + workspaceId: payload.workspaceId, + sessionId: payload.sessionId, + ...payload.beforeSessionId === undefined ? {} : { beforeSessionId: payload.beforeSessionId }, + }, + }) + } + return ok(request, { workspace: workspaceView(workspace) }) + }, }, host: { diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index 1f0f9acef4..68b6289858 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -19,6 +19,8 @@ export interface RpcMethodMap { 'host.describe': HostApi['describe'] 'workspace.list': WorkspaceApi['list'] 'workspace.create': WorkspaceApi['create'] + 'workspace.rename': WorkspaceApi['rename'] + 'workspace.insertSessionBefore': WorkspaceApi['insertSessionBefore'] } /** Business request payload of method K (reaches through the RpcRequest narrow form to payload). */ diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index 3b290e18c3..d83ae2ce98 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -40,6 +40,7 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code', z.object({ code: z.literal('workspace-not-found'), message: z.string(), details: z.object({ workspaceId: z.string() }) }), z.object({ code: z.literal('workspace-invalid-path'), message: z.string(), details: z.object({ path: z.string() }) }), z.object({ code: z.literal('workspace-name-conflict'), message: z.string(), details: z.object({ name: z.string() }) }), + z.object({ code: z.literal('workspace-move-invalid'), message: z.string(), details: z.object({ workspaceId: z.string(), sessionId: z.string(), beforeSessionId: z.string().optional() }) }), z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }), z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }), ]) as unknown as z.ZodType<RpcError> diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index dbcc975d04..ad06c42fbe 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -37,6 +37,7 @@ export interface RpcErrorDetailsMap { 'workspace-not-found': { workspaceId: string } 'workspace-invalid-path': { path: string } 'workspace-name-conflict': { name: string } + 'workspace-move-invalid': { workspaceId: string; sessionId: SessionId; beforeSessionId?: SessionId } 'agent-busy': { reason: string } 'internal': {} } diff --git a/packages/host/apiproxy/src/api/workspace.schema.ts b/packages/host/apiproxy/src/api/workspace.schema.ts index a193fb0c57..47c3ae6d59 100644 --- a/packages/host/apiproxy/src/api/workspace.schema.ts +++ b/packages/host/apiproxy/src/api/workspace.schema.ts @@ -44,3 +44,29 @@ export const workspaceCreateValueSchema = z.object({ workspace: workspaceViewSchema, created: z.boolean(), }) satisfies z.ZodType<Wire<ResponseValue<'workspace.create'>>> + +/** workspace.rename request payload: the new title must be non-blank. */ +export const workspaceRenameRequestSchema = z.object({ + workspaceId: workspaceIdSchema, + title: z.string(), +}).refine( + payload => payload.title.trim() !== '', + { message: 'workspace.rename requires a non-blank title' }, +) satisfies z.ZodType<Wire<RequestPayload<'workspace.rename'>>> + +/** workspace.rename response value. */ +export const workspaceRenameValueSchema = z.object({ + workspace: workspaceViewSchema, +}) satisfies z.ZodType<Wire<ResponseValue<'workspace.rename'>>> + +/** workspace.insertSessionBefore request payload (anchor omitted = append to end). */ +export const workspaceInsertSessionBeforeRequestSchema = z.object({ + workspaceId: workspaceIdSchema, + sessionId: sessionIdSchema, + beforeSessionId: sessionIdSchema.optional(), +}) satisfies z.ZodType<Wire<RequestPayload<'workspace.insertSessionBefore'>>> + +/** workspace.insertSessionBefore response value. */ +export const workspaceInsertSessionBeforeValueSchema = z.object({ + workspace: workspaceViewSchema, +}) satisfies z.ZodType<Wire<ResponseValue<'workspace.insertSessionBefore'>>> diff --git a/packages/host/apiproxy/src/api/workspace.ts b/packages/host/apiproxy/src/api/workspace.ts index 86c20e2ff5..6ec636126b 100644 --- a/packages/host/apiproxy/src/api/workspace.ts +++ b/packages/host/apiproxy/src/api/workspace.ts @@ -24,7 +24,10 @@ export interface WorkspaceView { path: string /** Unique display title (defaults to the path basename at create). */ title: string - /** Sessions accounted under this workspace, newest-first for display. */ + /** + * Sessions accounted under this workspace, in manually owned order + * (attach prepends, insertSessionBefore reorders; activity never does). + */ sessionIds: SessionId[] /** ISO-8601 creation instant. */ createdAt: string @@ -52,4 +55,27 @@ export interface WorkspaceApi { */ create(request: RpcRequest<{ path?: string; name?: string }>): Promise<RpcResponse<{ workspace: WorkspaceView; created: boolean }>> + + /** + * Renames a workspace. `title` is trimmed and must be non-empty + * (schema-enforced). An unknown id fails with `workspace-not-found`; a + * title equal to another workspace's fails with `workspace-name-conflict`. + * Renaming to the current title is a no-op success (no durable write). + */ + rename(request: RpcRequest<{ workspaceId: WorkspaceId; title: string }>): + Promise<RpcResponse<{ workspace: WorkspaceView }>> + + /** + * Moves an accounted session within its workspace's manual order, + * DOM-insertBefore-like: with `beforeSessionId` the session is inserted + * before that anchor; omitted appends to the end. An unknown workspace + * fails with `workspace-not-found`; a session or anchor not accounted by + * the workspace fails with `workspace-move-invalid`. A move to the current + * position is a no-op success. + */ + insertSessionBefore(request: RpcRequest<{ + workspaceId: WorkspaceId + sessionId: SessionId + beforeSessionId?: SessionId + }>): Promise<RpcResponse<{ workspace: WorkspaceView }>> } diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 81749fc219..91c4405ace 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -23,7 +23,9 @@ import { } from '../api/sessions.schema.ts' import { workspaceCreateValueSchema, + workspaceInsertSessionBeforeValueSchema, workspaceListValueSchema, + workspaceRenameValueSchema, } from '../api/workspace.schema.ts' /** @@ -55,6 +57,8 @@ export interface IApiClient { workspace: { list(payload: RequestPayload<'workspace.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.list'>>> create(payload: RequestPayload<'workspace.create'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.create'>>> + rename(payload: RequestPayload<'workspace.rename'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.rename'>>> + insertSessionBefore(payload: RequestPayload<'workspace.insertSessionBefore'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.insertSessionBefore'>>> } events: { mux(payload: Parameters<ApiProxy['events']['mux']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<MuxFrame>> @@ -77,6 +81,8 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV 'host.describe': hostDescribeValueSchema, 'workspace.list': workspaceListValueSchema, 'workspace.create': workspaceCreateValueSchema, + 'workspace.rename': workspaceRenameValueSchema, + 'workspace.insertSessionBefore': workspaceInsertSessionBeforeValueSchema, } /** Default unary timeout (rpc-compare 2026-07-19: a hung host must not leave callers pending forever). */ @@ -266,6 +272,8 @@ export abstract class AbstractApiClient implements IApiClient { readonly workspace: IApiClient['workspace'] = { list: (payload, signal) => this.callUnary('workspace.list', payload, signal), create: (payload, signal) => this.callUnary('workspace.create', payload, signal), + rename: (payload, signal) => this.callUnary('workspace.rename', payload, signal), + insertSessionBefore: (payload, signal) => this.callUnary('workspace.insertSessionBefore', payload, signal), } readonly events: IApiClient['events'] = { diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index e876d664b2..91762810e8 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -24,7 +24,9 @@ import { import { hostDescribeRequestSchema } from '../api/host.schema.ts' import { workspaceCreateRequestSchema, + workspaceInsertSessionBeforeRequestSchema, workspaceListRequestSchema, + workspaceRenameRequestSchema, } from '../api/workspace.schema.ts' /** @@ -50,6 +52,8 @@ const UNARY_ROUTES: UnaryRoutes = { 'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) }, 'workspace.list': { schema: workspaceListRequestSchema, invoke: (api, r) => api.workspace.list(r) }, 'workspace.create': { schema: workspaceCreateRequestSchema, invoke: (api, r) => api.workspace.create(r) }, + 'workspace.rename': { schema: workspaceRenameRequestSchema, invoke: (api, r) => api.workspace.rename(r) }, + 'workspace.insertSessionBefore': { schema: workspaceInsertSessionBeforeRequestSchema, invoke: (api, r) => api.workspace.insertSessionBefore(r) }, } /** Route lookup that narrows an arbitrary path segment to a map key (single cast point for the string→key refinement). */ diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 7dec5980eb..a81aadd94b 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -37,6 +37,8 @@ function scriptedApi(overrides: { workspace: { list: r => ok(r, { items: [] }), create: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' }, created: true }), + rename: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }), + insertSessionBefore: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }), }, events: { mux: () => empty<MuxFrame>(), host: () => empty<HostFrame>(), ...overrides.events }, respond: overrides.respond ?? (() => Promise.resolve({ accepted: false as const, reason: 'not-pending' as const })), @@ -66,6 +68,19 @@ describe('unary round trip', () => { expect(response.result).toEqual({ ok: true, value: { items: [{ sessionId: 's1', updatedAt: 7, running: false }] } }) }) + it('routes workspace rename and insertSessionBefore through the wire', async () => { + const api = scriptedApi() + const c = client(api) + const renamed = await c.workspace.rename({ workspaceId: 'w1' as never, title: 'next' }) + expect(renamed.result.ok).toBe(true) + const blankTitle = await c.workspace.rename({ workspaceId: 'w1' as never, title: ' ' }) + expect(blankTitle.result).toMatchObject({ ok: false, error: { code: 'bad-request' } }) + const anchored = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1'), beforeSessionId: sid('s2') }) + expect(anchored.result.ok).toBe(true) + const appended = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1') }) + expect(appended.result.ok).toBe(true) + }) + it('passes business errors through as 200 + err result, not a throw', async () => { const api = scriptedApi({ sessions: { diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index ede6acb9ac..e4b1d33e87 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -52,6 +52,18 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' }, created: true } }, } }, + async rename(request) { + return { + rpcId: request.rpcId, + result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' } } }, + } + }, + async insertSessionBefore(request) { + return { + rpcId: request.rpcId, + result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' } } }, + } + }, }, events: { mux: (_request, signal) => stream(muxFrames, signal), diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index d0f2ddd128..ebb7931e6e 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { RpcId } from '../src/api/rpc.ts' +import { RpcId, transportError } from '../src/api/rpc.ts' import { clientRequestSchema, clientResponseSchema, rpcErrorSchema, rpcIdSchema, rpcMessageSchema, rpcReceiptSchema, rpcResultSchema, serverRequestSchema, serverResponseSchema, @@ -13,8 +13,10 @@ import { } from '../src/api/sessions.schema.ts' import { hostDescribeRequestSchema, hostDescribeValueSchema } from '../src/api/host.schema.ts' import { - workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema, workspaceListRequestSchema, - workspaceListValueSchema, workspaceViewSchema, + workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema, + workspaceInsertSessionBeforeRequestSchema, workspaceInsertSessionBeforeValueSchema, + workspaceListRequestSchema, workspaceListValueSchema, + workspaceRenameRequestSchema, workspaceRenameValueSchema, workspaceViewSchema, } from '../src/api/workspace.schema.ts' import { hostFrameSchema, muxFrameSchema, askUserQuestionItemSchema } from '../src/api/events.schema.ts' import { approvalRequestIdSchema, approvalResponsePayloadSchema } from '../src/api/approvals.schema.ts' @@ -30,6 +32,13 @@ describe('RpcId', () => { }) }) +describe('transportError', () => { + it('folds Error and non-Error throws into the internal error branch', () => { + expect(transportError(new Error('wire down'))).toEqual({ ok: false, error: { code: 'internal', message: 'wire down', details: {} } }) + expect(transportError('raw')).toMatchObject({ ok: false, error: { code: 'internal', message: 'raw' } }) + }) +}) + describe('rpcErrorSchema', () => { it('accepts every code branch with its required details', () => { expect(rpcErrorSchema.parse({ code: 'bad-request', message: 'm', details: { issues: [] } }).code).toBe('bad-request') @@ -40,6 +49,7 @@ describe('rpcErrorSchema', () => { expect(rpcErrorSchema.parse({ code: 'workspace-not-found', message: 'm', details: { workspaceId: 'w' } }).code).toBe('workspace-not-found') expect(rpcErrorSchema.parse({ code: 'workspace-invalid-path', message: 'm', details: { path: '/x' } }).code).toBe('workspace-invalid-path') expect(rpcErrorSchema.parse({ code: 'workspace-name-conflict', message: 'm', details: { name: 'x' } }).code).toBe('workspace-name-conflict') + expect(rpcErrorSchema.parse({ code: 'workspace-move-invalid', message: 'm', details: { workspaceId: 'w', sessionId: 's' } }).code).toBe('workspace-move-invalid') expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy') expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal') }) @@ -154,6 +164,19 @@ describe('workspace domain schemas', () => { expect(workspaceCreateValueSchema.parse({ workspace: view, created: false }).created).toBe(false) }) + it('rename requires a non-blank title (both refine arms)', () => { + expect(workspaceRenameRequestSchema.parse({ workspaceId: 'w1', title: 'new' }).title).toBe('new') + expect(() => workspaceRenameRequestSchema.parse({ workspaceId: 'w1', title: ' ' })).toThrow(/non-blank/) + expect(workspaceRenameValueSchema.parse({ workspace: view }).workspace.workspaceId).toBe('w1') + }) + + it('insertSessionBefore accepts an anchored and an anchorless move', () => { + expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1', beforeSessionId: 's2' }).beforeSessionId).toBe('s2') + expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).beforeSessionId).toBeUndefined() + expect(() => workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1' })).toThrow() + expect(workspaceInsertSessionBeforeValueSchema.parse({ workspace: view }).workspace.workspaceId).toBe('w1') + }) + }) describe('events frame schemas', () => { diff --git a/packages/host/webserver/README.i18n.yaml b/packages/host/webserver/README.i18n.yaml new file mode 100644 index 0000000000..9addd33a69 --- /dev/null +++ b/packages/host/webserver/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: c589c32c4e641e188f19ac6c5ad2e88e3eb79be3 +README.zh.md: 767195086b90a76160d87865caebf514ca75b0e3 diff --git a/packages/host/webserver/README.md b/packages/host/webserver/README.md index f00984ea90..c589c32c4e 100644 --- a/packages/host/webserver/README.md +++ b/packages/host/webserver/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-host-webserver +English | [中文](README.zh.md) + Plain HTTP route-registration plugin (default-exported `WebServerService`, config `{host, port, distIndex}`): a `node:http` server that listens on activation and provides `ctx.webServer` — `register(route)` adds a named `exact`/`prefix` route (duplicate `(kind, path)` throws: route patterns are a composition-level contract, so a collision is a misconfiguration; the returned disposer removes the route), `tapIndex(transform)` adds an index.html transform applied in registration order, and `port` reads the listening port (the OS-assigned value when `port` is 0). The match order is fixed — exact over the whole table, then longest prefix, then the static dist fallback with the locked semantics: traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, non-GET/HEAD is 405. Registration order carries no request-facing semantics. The package knows no harness concepts: the `/api` bridge is the connection plugin's route, plugin bundles and the HMR event stream are the modules/hmr plugins' routes. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure); `distIndex` is an assembly fact the composing app resolves and injects, never self-resolved (dist location is workspace knowledge of the app). Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell. diff --git a/packages/host/webserver/README.zh.md b/packages/host/webserver/README.zh.md new file mode 100644 index 0000000000..767195086b --- /dev/null +++ b/packages/host/webserver/README.zh.md @@ -0,0 +1,25 @@ +# @deepseek-ai/dsh-host-webserver + +[English](README.md) | 中文 + +朴素的 HTTP 路由注册插件(默认导出 `WebServerService`,配置为 `{host, port, distIndex}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.webServer`。`register(route)` 添加具名的 `exact`/`prefix` 路由;重复的 `(kind, path)` 会抛错,因为路由模式是组合层契约,冲突即配置错误;返回的 disposer 会移除该路由。`tapIndex(transform)` 添加按注册顺序应用的 index.html 转换,`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值)。匹配顺序固定不变:先在整张表中匹配精确路由,再匹配最长前缀,最后回退到静态 dist,并遵循固定语义:越出 dist 根目录的遍历返回 403,任何未命中项都以 HTTP 200 回退到 `index.html`(SPA 路由),未知扩展名按 octet-stream 提供,GET/HEAD 之外的方法返回 405。注册顺序不承载任何面向请求的语义。 + +该包不了解任何 harness 概念:`/api` 桥接是 connection 插件的路由,插件 bundle 与 HMR(热模块替换)事件流则是 modules/hmr 插件的路由。`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(有意向网络开放);`distIndex` 是由组合应用解析并注入的组装事实,绝不会自行解析,因为 dist 位置属于应用的工作区知识。该服务器只服务 Web(浏览器)形态;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch,而不使用本服务器。该包从不打印内容;URL 行属于 shell。 + +监听失败(EADDRINUSE……)会从激活过程抛出,使 fiber 进入 FAILED 状态并由启动流程的快速失败扫描报告。处理请求时抛错(例如格式错误的百分号转义传入 `decodeURIComponent`,或客户端在请求体传输中途断开)时,服务器会响应 400;若响应头已经发出,则销毁 socket,并记录 warning,但绝不会退出进程。资源释放会把 `close()` 与 `closeAllConnections()` 配对,因为一直保持打开的响应(SSE)不会自行结束。 + +在开发环境中,客户端插件注册表会在返回前同步捕获每个已构建 bundle 的 stat 基线,随后轮询这些基线,并在内容变化后重新计算哈希。每次重新扫描都会先暂存候选表、图和监听 map,再统一发布,因此基线失败会保留先前的图。这样,即时重建不会消失在异步建立的监听基线中;重命名窗口会把路径标记为脏,保留最近一次成功基线,并在 bundle 重新出现时强制重新计算哈希,即使其元数据完全相同也不例外。 + +## 模型体验 + +无。该包只是浏览器与其他插件所注册路由之间的纯 HTTP 载体,其中没有任何内容会进入模型请求。 + +#### KV 缓存影响 + +无;该包既不组装也不发送提供方请求。 + +## 已知限制与延期工作 + +- **不提供 TLS、认证或来源策略**:绑定非回环地址会向对应网络公开服务器;面向部署的加固措施(或在前方放置真正的反向代理)有意不纳入面向开发环境的 v1。 +- **初始 MIME 表很精简**:Vite 输出集合以外的扩展名会回退到 `application/octet-stream`;实际发布新的资产类别时再扩展该表。 +- **Socket 选项固定不变**:配置只选择绑定宿主与端口;在具体部署产生需求前,backlog 和其他 socket 设置仍保持内部实现。 diff --git a/packages/llm/README.i18n.yaml b/packages/llm/README.i18n.yaml new file mode 100644 index 0000000000..9749e012de --- /dev/null +++ b/packages/llm/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 0278a4a582e535125d001e09736b89f13be72a0c +README.zh.md: e3e2b9559d69e4be10cd4d373bbda2dd47396b72 diff --git a/packages/llm/README.md b/packages/llm/README.md index 0c937c17dc..0278a4a582 100644 --- a/packages/llm/README.md +++ b/packages/llm/README.md @@ -1,5 +1,7 @@ # llm/ — LLM capability family +English | [中文](README.zh.md) + The LLM seam and its provider adapters. The interface package (`llm`) owns the abstract service, the content-block vocabulary, and the stream-chunk assembler; the adapters are concrete implementations that register on `ctx.llm`. All **product** packages. | Package | Role | ctx key | diff --git a/packages/llm/README.zh.md b/packages/llm/README.zh.md new file mode 100644 index 0000000000..e3e2b9559d --- /dev/null +++ b/packages/llm/README.zh.md @@ -0,0 +1,15 @@ +# llm/:LLM 能力家族 + +[English](README.md) | 中文 + +LLM seam 及其提供方适配器。接口包(`llm`)拥有抽象服务、内容块词汇和流分片组装器;适配器是在 `ctx.llm` 上注册的具体实现。这些全是**产品** 包。 + +| 包 | 职责 | ctx key | +|---|---|---| +| `llm/` | 抽象 LLM 服务 + 内容块词汇 + 分片组装器 | `ctx.llm` | +| `token-meter/` | 感知回放的请求与表层 token 测量 | `ctx.tokenMeter` | +| `llm-retry/` | 有界的暂时性请求重试策略 | (监听 `agent/request-error`) | +| `llm-deepseek/` | DeepSeek API 适配器(手写 fetch/SSE) | (注册到 `ctx.llm`) | +| `llm-pi-ai/` | 通过 `@earendil-works/pi-ai` 实现的多提供方适配器 | (注册到 `ctx.llm`) | + +接口位于 `llm/llm/`;适配器、重试策略和可复用的 token 计量器都是该分组下的扁平兄弟包。请求按 `provider` 路由,而 `model` 会原样传给选中的适配器。拥有路由的适配器可以解析精确的提供方/模型上下文容量;token 计量器仍与模型无关。新的提供方适配器只需在 `ctx.llm` 上注册一个或多个提供方路由,无需改动接口或消费方。两个已交付实现见[双生 LLM 适配器](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md),测量归属见[回放 token 计量器 Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md),容量与压缩策略归属见[路由模型上下文 Agent Note](../../.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md)。 diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml new file mode 100644 index 0000000000..f5b4d04574 --- /dev/null +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: e191f3fcd265a6ca9cec3a8dae5f730ce27accf1 +README.zh.md: 268096e5f1a145e8d5cf6469524d36fe48984617 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index f4f71bb5b7..e191f3fcd2 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-llm-deepseek +English | [中文](README.zh.md) + DeepSeek chat-completions adapter for the harness LLM seam: hand-rolled `fetch` + SSE translation from the official wire format (source of truth: the API docs — guides/thinking_mode, guides/tool_calls, api/create-chat-completion) into the `StreamChunk` protocol. A second, library-backed implementation of the same seam exists in `@deepseek-ai/dsh-llm-pi-ai`. This package always owns the `deepseek` provider route; mounting a pi-ai profile with `provider: deepseek` in the same context throws `LlmError('DUPLICATE_ADAPTER')` by design. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md new file mode 100644 index 0000000000..268096e5f1 --- /dev/null +++ b/packages/llm/llm-deepseek/README.zh.md @@ -0,0 +1,94 @@ +# @deepseek-ai/dsh-llm-deepseek + +[English](README.md) | 中文 + +harness LLM seam 的 DeepSeek chat-completions 适配器:手写 `fetch` + SSE,将官方协议格式(真源:API 文档 guides/thinking_mode、guides/tool_calls、api/create-chat-completion)转换为 `StreamChunk` 协议。 + +同一 seam 的第二个库支持实现位于 `@deepseek-ai/dsh-llm-pi-ai`。本包始终拥有 `deepseek` 提供方路由;在同一上下文中装载 `provider: deepseek` 的 pi-ai profile 会按设计抛出 `LlmError('DUPLICATE_ADAPTER')`。 + +包根目录公开 Cordis 插件契约与 `DeepSeekAdapter`;协议序列化、SSE 解析与 chunk 转换 helper 不属于该根契约。 + +## 配置 + +```yaml +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY # or rely on the env fallback + baseURL: !!js process.env.DEEPSEEK_BASE_URL # default: https://api.deepseek.com + thinking: enabled # optional; provider default is enabled + reasoningEffort: high # optional; high | max — omitted ⇒ not sent + streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default + defaultContextWindow: 256000 # optional positive-integer fallback for models without an exact value + models: # optional; defaults to V4 Flash and V4 Pro + - id: deepseek-v4-flash + name: DeepSeek V4 Flash + - id: private-reasoner + description: Company-hosted reasoning model + contextWindow: 64000 +``` + +该插件注册唯一提供方路由 `deepseek`。请求使用 `provider: deepseek` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash` 和 `deepseek-v4-pro`,两者的上下文窗口均为 128,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。Catalog 配置项通过 `ctx.llm.listModels('deepseek')` 公开给 UI selector 与部署自省,但仍只提供建议:未列出模型 id 仍原样传递。省略配置项 name 默认为其 id。 + +`contextWindow` 对每个已配置模型都可选,不会通过建议 catalog 公开。`ctx.llm.resolveModelContext('deepseek', model)` 先返回精确模型值,再对不含容量的配置项或未列出原样传递 id 返回 `defaultContextWindow`。两者都不存在时返回 `undefined`,不会使路由失效。因此,压力敏感插件可以获得部署拥有的容量,不会将模型 selector 视为权威。为 `deepseek` 注册另一个适配器会抛出 `LlmError('DUPLICATE_ADAPTER')`。 + +`reasoningEffort` 默认**省略**:未设置时,不发送 `reasoning_effort` 协议字段,服务器会为模型应用自身默认值。只接受 `high` 和 `max`(DeepSeek 官方 effort 级别)。只有在启用 thinking 时才有意义(提供方默认启用)。 + +`thinking`/`reasoningEffort` 是适配器级请求默认值,序列化为官方顶层 `thinking: {type}`/`reasoning_effort` 协议字段。它们位于适配器配置中(而非 `GenerateOptions`),以保持核心词汇与提供方无关。携带 `GenerateOptions.purpose: 'session-title'` 的请求会强制禁用 thinking 并省略 `reasoning_effort`,将有界输出保留给可见标题文本,不改变会话或压缩默认值。 + +`streamIdleTimeoutMs` 会限制每次未完成提供方读取,包括初始 `fetch`,但不计入消费方在 chunk 间花费的时间。一个稳定 abort 信号会在整个调用中达到请求与 body reader;过期会停止传输并抛出 `LlmError('TIMEOUT')`,较早的调用方 abort 则抛出 `LlmError('ABORTED')`。适配器每次 `stream()` 调用精确发起一次提供方请求;agent 级重试是独立插件策略。 + +## 应用归因 + +每个请求都携带 dsh-llm `attributionHeaders()` 的共享归因标头,即用于识别 harness 的必需 `User-Agent` 基线(见 [dsh-llm § 应用归因](../llm/README.md#app-attribution-attributionts))。在该适配器契约下,直接 DeepSeek 请求与 OpenAI 兼容 gateway 请求都不会获得提供方特定应用归因标头;OpenRouter 应用归因暂缓到未来的显式 OpenRouter 适配器或模式。`GenerateOptions.purpose` 为 `compaction` 的请求(dsh-compact-basic 的辅助摘要调用)还会携带 `x-deepseek-harness-compact: 1`,让宿主可以将压缩流量与会话请求分开。 + +## 协议格式说明(已通过实时请求与官方文档验证) + +- 只支持流式输出(`stream_options.include_usage` 始终开启)。`usage` 可能附着在 finish chunk 上,也可能作为尾随仅 usage chunk 到达;转换器会将两者都延迟到 `[DONE]`,因此 `usage` 始终位于 `finish` 之前,`finish` 之后不会出现任何内容。 +- 第一个 thinking 模式 chunk 携带 `reasoning_content: ""`,系统会处理它(不会产生多余 reasoning 块)。 +- **Reasoning 回传规则**:对携带工具调用的 assistant 轮次,会将 `reasoning_content` 序列化回历史(thinking 模式 API 必需);对不含工具调用的轮次,它会被丢弃(不会使用,可节省 token)。 +- Cache 计量:`cacheReadTokens` ← `prompt_cache_hit_tokens` / `prompt_tokens_details.cached_tokens`;DeepSeek 不报告 cache-write 指标。 + +## 错误 + +非 2xx 响应会抛出稳定 code 的 `LlmError`:`AUTH`(401/403)、`QUOTA`(提供方详细信息标识配额、余额或点数耗尽的响应)、`RATE_LIMIT`(其他 429)、`CONTEXT_WINDOW_EXCEEDED`(提供方 code、type 或 message 标识上下文溢出的 400)、`INVALID_REQUEST`(其他 400)、`SERVER`(5xx),其他情况为 `HTTP_<status>`。其可序列化 `failure` 保留 HTTP 状态,以及有效的正 `Retry-After` 秒数/日期延迟和存在时的 `x-request-id` / `x-deepseek-request-id`。响应前传输失败(DNS、连接被拒绝、TLS、proxy)会抛出命名已配置端点的 `TRANSPORT`,并将原始拒绝链接为 `cause`;调用方 abort 抛出 `ABORTED`,loop 的取消信号仍最具权威。协议违例抛出 `STREAM_CLOSED`(没有 `[DONE]`)或 `MALFORMED_RESPONSE`(JSON payload 错误)。未知协议 `finish_reason`(例如 `content_filter`、`insufficient_system_resource`)会变为 `finish {kind: 'error', failure}` chunk;已完成流如果使用 `stop`(或缺失)finish 但没有开启内容块,就会变为 `finish {kind: 'error'}`,code 为 `EMPTY_RESPONSE`(默认策略会重试)。 + +## 测试 + +单元套件使用本地 `node:http` mock SSE 服务器(无网络),覆盖结构化 HTTP 事实、格式错误/截断流、调用方 abort、连接失败,以及 idle 超时确实会 abort 实际 body 的证明。真实 API 覆盖位于 `tests/adapter.e2e.ts`(`pnpm run test:e2e`,由 key 调节):V4 Flash + V4 Pro,覆盖 thinking 启用/禁用与两种官方 effort 级别,包括 thinking + 工具往返与 reasoning 回传。 + +## 模型体验 + +### DeepSeek 请求 + +#### 模型看到的内容 + +所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、stop sequence 和调用配置,不含适配器撰写的提示词文本。当之前的 assistant 轮次包含工具调用时,会按要求回传其 reasoning 内容;不含工具调用的轮次会省略 reasoning。 + +#### Token 影响 + +精确输入取决于提供方 tokenization。有条件 reasoning 回传会增加工具往返上下文,丢弃其他 reasoning 则避免再次支付这些 token;可用时会报告 cache-read 用量。 + +#### KV Cache 影响 + +未更改的已组装前缀可使用 DeepSeek cache 复用,适配器会在 usage 中报告它。模型路由变更,或任何上游提示词、schema、前缀或历史变更,都可能使从第一个改变 token 起的复用失效;reasoning 回传会在工具往返期间追加。 + +### DeepSeek 响应 + +#### 模型看到的内容 + +Reasoning、文本与原始字符串工具参数会转换为 harness chunk,供 loop 记录和组装。 + +#### Token 影响 + +生成 token 遵循提供方 thinking 与 effort 设置及请求的 `maxTokens`;只有 loop 保留的块会影响后续输入。 + +#### KV Cache 影响 + +loop 保留的响应块会追加到下一个请求,并保留其较早可复用前缀;已丢弃块不会影响后续 cache。更改提供方或模型会选择不同 cache 域。 + +## 已知限制与暂缓事项 + +- **未映射 `tool_choice`**:它不属于核心词汇(MVP 取舍,与 pi-ai twin 共享)。 +- **请求使用原始 `fetch`,而非 `@cordisjs/plugin-http`**:没有共享 proxy/拦截配置;采用暂缓到第二个适配器需要该功能时(`TODO(http)`)。 +- **序列化会将 user 与工具结果内容展平为文本块**:会跳过插件添加的块类型,空工具输出会以字面 `(no output)` 跨越协议。 diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml new file mode 100644 index 0000000000..4b3e7ad4f0 --- /dev/null +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 8a5c955edf9418352a6916e17766b3c06b62c9ba +README.zh.md: 2b953e2aa30da29b7aa307abe3f92c14fb0906d6 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 381026227b..8a5c955edf 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-llm-pi-ai +English | [中文](README.zh.md) + Generic multi-provider adapter for the harness LLM seam backed by [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai). One plugin instance owns an explicit list of provider profiles; every request selects a profile with `GenerateOptions.provider` and resolves `GenerateOptions.model` dynamically from pi-ai's installed catalog. The package root exposes the Cordis plugin contract and `PiAiAdapter`; profile resolution, model construction, replay conversion, and stream conversion remain package-internal. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md new file mode 100644 index 0000000000..2b953e2aa3 --- /dev/null +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -0,0 +1,102 @@ +# @deepseek-ai/dsh-llm-pi-ai + +[English](README.md) | 中文 + +基于 [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai) 的 harness LLM seam 通用多提供方适配器。一个插件实例拥有显式提供方 profile 列表;每个请求使用 `GenerateOptions.provider` 选择 profile,并从 pi-ai 已安装 catalog 中动态解析 `GenerateOptions.model`。 + +包根目录公开 Cordis 插件契约与 `PiAiAdapter`;profile 解析、模型构造、回放转换和流转换保留在包内部。 + +## 配置 + +按提供方配置凭证与部署特定传输设置。省略 `apiKey` 会将认证委托给 pi-ai 的提供方原生环境发现。`baseURL` 只会覆盖所选 catalog 模型的端点,保留其 API 家族与兼容性元数据,因此仍支持 `https://proxy.example.com:8443` 等私有 proxy。 + +```yaml +- id: llm + name: '@deepseek-ai/dsh-llm-pi-ai' + config: + providers: + - provider: openai + apiKey: !!js process.env.OPENAI_API_KEY + baseURL: https://proxy.example.com:8443 + reasoning: high + - provider: anthropic + apiKey: !!js process.env.ANTHROPIC_API_KEY + streamIdleTimeoutMs: 300000 + - provider: openrouter + apiKey: !!js process.env.OPENROUTER_API_KEY + headers: + X-Deployment: production +``` + +每个提供方名称必须存在于 pi-ai 已安装 catalog 中,且在此插件实例中最多出现一次。向 `ctx.llm` 注册具有原子性:如果与另一适配器已拥有的任何提供方路由冲突,插件会加载失败,不注册剩余路由。模型 id 不是生命周期配置;未知模型会在发起任何提供方请求前以 `LlmError('UNKNOWN_MODEL')` 失败。 + +适配器通过 `ctx.llm.listModels(provider)` 公开每个已配置提供方已安装的 pi-ai 模型。这是从 `getModels(provider)` 派生的提供方无关 selector 元数据;请求时解析仍会执行权威 catalog 查找,因此发现不会创建第二个模型注册表。`ctx.llm.resolveModelContext(provider, model)` 执行相同的精确 descriptor 查找并返回其上下文窗口,让容量元数据保留在拥有路由的适配器上,而非消费插件上。 + +受支持的 profile 字段是 `provider`、`apiKey`、`baseURL`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs` 和 `streamIdleTimeoutMs`。流 idle 间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。Harness 应用归因会胜过名称冲突的已配置标头。 + +适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries` 和 `maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent 级重试预算。Idle 过期会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`。 + +## 提供方/模型路由与回放 + +所选 pi-ai catalog descriptor 提供协议实现。这包括原生 API 差异,例如 descriptor 使用 Responses API 而非 Chat Completions 的 OpenAI 模型;harness 适配器不会按模型名称硬编码端点选择。 + +成功的 assistant 响应会在自身持久提供方/模型溯源旁存储经版本化的无损 JSON 回放状态。请求时,`LlmService` 只有在历史提供方路由与目标提供方路由当前由同一个 `PiAiAdapter` 实例拥有时,才会传递回放状态。即使目标提供方或模型改变,适配器也会验证状态并恢复 pi-ai 响应 id 与提供方 signature;随后由 pi-ai 判定目标 API 可以复用哪些元数据。没有回放状态的历史会被转换为外来提供方无关内容,绝不伪装为原生 pi-ai 响应。 + +如果 listener 改写已组装 assistant 内容,loop 会在记录消息前丢弃回放状态,因为其提供方元数据不再描述该内容。无效版本、格式错误元数据、溯源提供方/模型不匹配,以及内容/块不匹配都会显式以 `LlmError('INVALID_REPLAY_STATE')` 失败。 + +## 词汇差异 + +- pi-ai 工具调用参数是已解析对象;harness 存储原始 JSON 字符串。适配器会解析输入,并将输出重新字符串化。 +- pi-ai 将失败报告为流内错误事件;它们会映射到 `finish {kind:'error'|'aborted', failure}` chunk。提供方特定错误文本会区分终端 `QUOTA` 与短暂 `RATE_LIMIT`,针对已解析模型上下文窗口评估的文本与 usage 信号则将溢出规范化为 `CONTEXT_WINDOW_EXCEEDED`。携带零个内容块消息的终止 `stop` 会映射为 `finish {kind:'error'}`,code 为 `EMPTY_RESPONSE`(默认策略会重试),而非成功空消息。 +- pi-ai 将 reasoning token 折叠到输出 usage 中;没有可映射的独立 reasoning 计数。 +- `GenerateOptions.stop` 会以 `UNSUPPORTED_OPTION` 被拒绝,因为 pi-ai 的通用流式输出表层无法保证所有提供方都支持它。 + +## 应用归因 + +每个请求都携带 dsh-llm `attributionHeaders()` 的共享归因标头,并通过 pi-ai `headers` 流选项合并。不会合成提供方特定应用归因标头。详见 [dsh-llm § 应用归因](../llm/README.md#app-attribution-attributionts)。 + +## 依赖重量 + +pi-ai 会安装多个提供方 SDK,并延迟加载 catalog 模型所选的 SDK。依赖重量隔离在该可选适配器包中。 + +## 测试 + +单元测试使用重定向到本地 mock 服务器的 pi-ai catalog 模型,覆盖提供方/profile 路由、每次适配器调用一次协议请求、idle-timeout 响应终止、调用方 abort、原生 API 选择、端点覆盖、归因、转换、回放状态验证,以及一个适配器实例内的跨提供方/模型回放。真实 API 覆盖仍位于由 key 调节的 `pnpm run test:e2e` 下。 + +## 模型体验 + +### 通过 pi-ai 发起的提供方请求 + +#### 模型看到的内容 + +所选 catalog 模型会收到 `GenerateOptions.system`、历史、工具,以及 pi-ai 通用流式 API 支持的采样字段。本包不添加提示词文本。只有当适配器验证提供方原生回放元数据与历史内容匹配时,才会恢复这些元数据。 + +#### Token 影响 + +精确输入取决于提供方 tokenization。转换不添加模型可见文本;回放元数据可能让原生 API 复用提供方侧状态。 + +#### KV Cache 影响 + +转换保留逻辑请求顺序,不添加文本;复用取决于所选提供方的序列化与回放状态。更改适配器实例、提供方、模型或任何上游请求 token,都可能使从第一个差异起的复用失效。 + +### 提供方响应 + +#### 模型看到的内容 + +pi-ai 事件会变为 harness reasoning、文本、工具调用、usage 与 finish chunk。已解析工具参数以原始 JSON 字符串形式跨越 harness 边界。 + +#### Token 影响 + +只有在 loop 记录生成内容后,它才会影响后续输入。提供方不单独报告 reasoning token 时,pi-ai 会将其折叠到输出 usage 中。 + +#### KV Cache 影响 + +已记录响应内容会追加到下一个请求,不会使其较早可复用前缀失效。未记录传输元数据与 usage 计量不影响 cache 身份。 + +## 已知限制与暂缓事项 + +- **必须属于 catalog**:已安装 pi-ai catalog 中不存在的自定义模型 id 会以 `UNKNOWN_MODEL` 失败,即使提供方 profile 配置了自定义端点。 +- **不支持 `GenerateOptions.stop`**:pi-ai 的通用流选项无法保证所有提供方都支持 stop sequence,因此适配器会拒绝该字段。 +- **历史中的 `system` 消息使用 pi-ai 通用上下文转换**:提供方特定位置由 pi-ai 决定,而非由 harness 拥有的协议覆盖决定。 +- **无法获取提供方 HTTP 状态**:pi-ai 错误事件不会在所有提供方上公开稳定 HTTP 状态;失败只公开稳定 harness 错误 code。 +- **重试策略不是适配器选项**:SDK 重试已禁用,因此持久 agent 步骤与 `llm/retry` 事件拥有每次可见尝试;直接 `ctx.llm.stream()` 调用仍只尝试一次。 diff --git a/packages/llm/llm-retry/README.i18n.yaml b/packages/llm/llm-retry/README.i18n.yaml new file mode 100644 index 0000000000..7307d889d2 --- /dev/null +++ b/packages/llm/llm-retry/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 96dc2314bac59b36a97627e038ac614f3db5f9b3 +README.zh.md: cbee3291d688dfda1c4109fb87630c030bf4b45c diff --git a/packages/llm/llm-retry/README.md b/packages/llm/llm-retry/README.md index da1084ba31..96dc2314ba 100644 --- a/packages/llm/llm-retry/README.md +++ b/packages/llm/llm-retry/README.md @@ -1,5 +1,7 @@ # `@deepseek-ai/dsh-llm-retry` +English | [中文](README.zh.md) + Function plugin that retries selected transient model-request failures on the agent loop's closed-step recovery seam. It does not wrap `ctx.llm.stream()`: every adapter call remains one provider attempt, and every retry opens a fresh numbered step. The default policy permits two retries for `EMPTY_RESPONSE`, `RATE_LIMIT`, `SERVER`, `TIMEOUT`, and `TRANSPORT`, using bounded exponential backoff from 500 ms to 10 seconds with 10 percent jitter. `EMPTY_RESPONSE` is the adapters' classification of a degenerate provider completion (a terminal stop with zero content blocks); the attempt produced nothing durable, so repeating it is safe. Delay bounds must fit Node's supported timer range. A valid `providerRetryAfterMs` replaces local backoff when it is within the configured cap; an over-cap instruction delegates to the next recovery policy instead. diff --git a/packages/llm/llm-retry/README.zh.md b/packages/llm/llm-retry/README.zh.md new file mode 100644 index 0000000000..cbee3291d6 --- /dev/null +++ b/packages/llm/llm-retry/README.zh.md @@ -0,0 +1,43 @@ +# `@deepseek-ai/dsh-llm-retry` + +[English](README.md) | 中文 + +一个函数插件,在 agent loop 的已关闭步骤恢复 seam 上重试特定的短暂模型请求失败。它不包装 `ctx.llm.stream()`:每次适配器调用仍是一次提供方尝试,每次重试都会开启新的编号步骤。 + +默认策略允许为 `EMPTY_RESPONSE`、`RATE_LIMIT`、`SERVER`、`TIMEOUT` 和 `TRANSPORT` 重试两次,使用从 500 ms 到 10 秒的有界指数退避与 10% jitter。`EMPTY_RESPONSE` 是适配器对退化提供方完成的分类(携带零个内容块的终止 stop);该尝试未产生持久内容,因此可安全重复。延迟边界必须适合 Node 支持的定时器范围。有效 `providerRetryAfterMs` 在已配置上限内时替换本地退避;超出上限的指令会委托给下一项恢复策略。 + +等待之前,插件会追加一个非表层 `llm/retry` 事件,携带失败与计划延迟。取消与插件 dispose 会中止等待;dispose 会排空插件的活跃退避,dispose 前捕获的 callback 如果在之后调用,将快速失败。 + +单独发布的 `./invariant` 配套模块会检查每个重试记录是否指向当前开启轮次及其最新已关闭步骤,是否拥有唯一步骤记录与递增重试编号,以及是否携带正数有界重试预算和非负有界定时器延迟。完整 jitter 可以在下界调度为零毫秒。 + +```yaml +- name: '@deepseek-ai/dsh-llm-retry' + config: + maxTransientRetries: 2 + initialDelayMs: 500 + maxDelayMs: 10000 + jitterRatio: 0.1 + retryableCodes: [EMPTY_RESPONSE, RATE_LIMIT, SERVER, TIMEOUT, TRANSPORT] +``` + +## 模型体验 + +### 短暂请求恢复 + +#### 模型看到的内容 + +模型不会看到重试事件、延迟或失败文本。重试后,下一个编号步骤会从持久会话历史中重建相同的显式提供方/模型请求;失败 chunk 绝不会进入派生消息。 + +#### Token 影响 + +每次重试都是新的提供方请求,可能重复计费输入 token。有限预算会限制尝试次数;`llm/retry` 自身不产生 token。 + +#### KV Cache 影响 + +重建请求保留之前的前缀,并可根据该提供方的规则复用 cache。非表层状态事件不会改变 cache 身份。 + +## 已知限制与暂缓事项 + +- **Agent 步骤是唯一重试边界**:直接 `ctx.llm.stream()` 消费方仍只尝试一次,因为原始流无法将已发出 chunk 持久分隔为不同尝试。 +- **有限插件预算可叠加**:该策略只统计已配置短暂 code;上下文溢出压缩只统计自身 code。未来如有 code 重叠的策略,必须记录并测试注册顺序行为。 +- **`llm/retry` 记录调度,不是完成**:后续步骤与轮次事件用于确立成功、耗尽或取消。 diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml new file mode 100644 index 0000000000..38ccfa49a7 --- /dev/null +++ b/packages/llm/llm/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 1e725d13af70bdb2f326b43350323763f95b579b +README.zh.md: fe749ca1032b576bef10c1dbdfd41d6299b6cf50 diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 54306b14d5..1e725d13af 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -1,5 +1,7 @@ # dsh-llm +English | [中文](README.zh.md) + Provider-neutral LLM vocabulary and abstract service. This package defines the canonical language spoken by the agent loop, session logs, and every plugin. ## Service: `LlmService` (ctx key: `llm`) diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md new file mode 100644 index 0000000000..fe749ca103 --- /dev/null +++ b/packages/llm/llm/README.zh.md @@ -0,0 +1,80 @@ +# dsh-llm + +[English](README.md) | 中文 + +提供方无关的 LLM 词汇与抽象服务。本包定义 agent loop、会话日志和每个插件使用的规范语言。 + +## 服务:`LlmService`(ctx key:`llm`) + +一个适配器注册表加单一流式调用表层,可通过 waterfall 事件拦截。 + +### 公开 API + +- `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): () => void` 为给定提供方路由注册一个适配器实例。注册要么全部成功,要么全部不生效,并且会随调用 fiber dispose。 +- `ctx.llm.listProviders(): LlmProviderInfo[]` 按注册顺序描述已注册提供方路由。 +- `ctx.llm.listModels(provider: string): Promise<LlmModelInfo[]>` 发现某个已注册提供方当前公布的模型。 +- `ctx.llm.resolveModelContext(provider: string, model: string): Promise<LlmModelContext | undefined>` 从拥有精确路由的适配器解析权威上下文容量。 +- `ctx.llm.stream(options: GenerateOptions): AsyncIterable<StreamChunk>` 将一次模型调用流式输出为原始 chunk(token 级 delta)。消费方使用 `BlockAssembler` 将 chunk 组装为块/消息。 + +`LlmService` 保留来自最终适配器选择、同步 dispatch、iterator 构造与迭代的错误,并将其溯源绑定到该次模型调用返回的精确流句柄。`isLlmAdapterFailure(stream, value)` 只报告该调用最终适配器边界的错误;`llmFailureOf(stream, value)` 返回相邻的不可变 `LlmFailure`。嵌套模型调用、`llm/stream` middleware 和下游消费方失败对外层调用仍未分类。分类绝不替换或更改适配器的原始编码 `Error`。 + +提供方与模型元数据是发现表层,不是路由白名单。`registerAdapter()` 仍拥有提供方排他性,适配器则可以接受 `listModels()` 中不存在的模型 id;消费方禁止因模型未列出而拒绝请求。返回的元数据与输入脱离,无效或重复适配器配置项会以 `INVALID_ADAPTER` 或 `INVALID_CATALOG` 失败。 + +上下文容量是独立的正确性查询,不是 catalog 装饰或全局 LLM 设置。`resolveModelContext()` 会询问拥有精确提供方/模型路由的适配器;适配器可以描述未列出的动态模型,`undefined` 只表示容量不可用。无效的返回容量以 `INVALID_MODEL_CONTEXT` 失败。 + +### 事件 + +| 事件 | 模式 | 用途 | +|---|---|---| +| `llm/stream` | waterfall | 拦截/包装每次流式模型调用,用于缓存、日志或路由 | + +### 扩展点 + +- 继承 `LlmAdapter` 并调用 `ctx.llm.registerAdapter(providers, adapter)`,添加一条或多条提供方路由。`GenerateOptions.provider` 选择适配器;`GenerateOptions.model` 属于适配器,可以动态解析。覆盖 `providerInfo()` 和异步 `listModels()` 以公开 selector 元数据,在已知精确容量时覆盖 `resolveModelContext()`;默认实现将路由 id 用作名称,不公布模型,也不返回容量。 +- 包装 `llm/stream` 时,通过 `ctx.on()` waterfall listener 实现缓存、日志或路由。发出 chunk 后重试的包装层没有持久尝试边界;因此已发布 agent 重试策略改用 `agent/request-error`。 + +### 内容块词汇(`types.ts`) + +消息是类型化内容块数组:`text`、`reasoning`、`tool-call`、`tool-result`。联合从可合并扩展的 `ContentBlockMap` 派生,因此插件可以通过 declaration merging 添加块类型。loop 产生的 assistant 消息还会携带提供方/模型溯源与可选适配器私有回放状态。dispatch 前,`LlmService` 只在历史提供方路由与目标提供方路由当前由完全相同的适配器实例拥有时才保留该状态;随后由适配器判定能否在模型/提供方间恢复或转换该状态。核心块集只包含每条已发布路径都支持的块。多模态内容(图像、音频等)没有核心块类型;需要它的功能会通过 map 添加,并一并添加支持它的适配器/UI/压缩实现。 + +流式输出是原始 chunk 协议(`block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta`、`block-end`、`usage`、`finish`)。`BlockAssembler` 是将 chunk 组装为块/消息的唯一共享实现。 + +### 调用配置(`call-config.ts`) + +`LlmCallConfig` 是一个会话请求的提供方 + 模型 + 采样标量(`provider`、`model`、`temperature`、`maxTokens`、`stop`,每个都与同名 `GenerateOptions` 字段 1:1 映射)。它是作为请求标头一部分记录在会话日志中的每会话状态(见 dsh-session `request/header` 事件),绝不是可静默调整的每次调用旋钮:`agent/request` waterfall 会提议替换,loop 则记录真实变更。`callConfigEquals(a, b)` 是逐字段真实变更检测器;`deepFreeze(value)` 是 loop 在 dispatch 前对每个已构建请求应用的所有权 helper(`llm/stream` listener 与适配器只读,绝不改写)。`markAgentLoopRequest()` 为该精确对象添加进程本地 loop 溯源,`isAgentLoopRequest()` 让观测方可以将其与同样可能冻结并关联会话、但独立记录的辅助调用区分。`GenerateOptions.purpose` 对已记录辅助压缩与会话标题调用分类,让适配器可以应用目的特定传输策略,而不改变普通会话请求。 + +### 应用归因(`attribution.ts`) + +每个产品适配器都会在提供方 HTTP 请求上发送应用身份。`attributionHeaders(identity?)` 构建标准 `User-Agent`,默认为公开 `APP_IDENTITY`;白标部署可以替换它,但不能抑制它。适配器会直接验证 wire 标头,或通过自身库 hook 验证。详见 [归因 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md)。 + +### 类 + +- `LlmAdapter`:提供方适配器的抽象基类。唯一必需方法是 `stream()`。 +- `BlockAssembler`:将原始 chunk 逐步组装为完整内容块与 assistant 消息。agent loop 向它提供原始 chunk(同时记录以供回放),并读取已组装块/消息以构建历史。 +- `HarnessError`:harness 错误分类体系的基类,包含稳定 `code` 字符串(与面向人的 `message` 不同)加 `cause` 链接。它位于所有其他包都导入的叶子包中,因此可以共享单一基类,无需新的依赖边。每包错误(`LlmError`、`ToolArgsError`、`InvariantError` 等)都会扩展它。`isHarnessError(value)` 在 seam 处收窄类型。 +- `LlmError`:扩展 `HarnessError`;其稳定 `code` 字符串(`NO_ADAPTER`、`DUPLICATE_ADAPTER` 与 `AUTH`/`RATE_LIMIT` 等适配器 code)与冻结可序列化 `failure.code` 匹配。Payload 还可以保留已验证状态、`Retry-After` 和品牌化提供方请求 id 事实;策略位于错误之外。 +- `errorChain(value)`:渲染抛出值的完整 `cause` 链与 AggregateError 成员,供诊断表层使用,包括 UI 通知、logger 行和持久 `turn/end` 消息。因此 undici 的 `TypeError: fetch failed` 等传输包装层会显示底层 `ECONNREFUSED`/DNS/TLS 详细信息,而不是将其遮蔽。该函数只负责渲染:请按 `code` 路由,绝不解析结果。 +- `CONTEXT_WINDOW_EXCEEDED_CODE`:当请求超过模型上下文窗口时,无论通过抛出 HTTP 还是带内 finish 交付,两个 DeepSeek 适配器都使用的提供方无关 code。`isContextWindowExceededError(detail)` 是它们针对 OpenAI 兼容提供方详细信息的共享保守分类器。 +- `QUOTA_EXCEEDED_CODE`:帐户配额、余额、点数、预算或用量限制耗尽时使用的非短暂提供方无关 code。`isQuotaExceededError(detail)` 使这些失败与请求速率限制保持区分。 +- `EMPTY_RESPONSE_CODE`:对退化提供方完成使用的提供方无关 code,两个适配器均使用:一个不携带任何内容块的终止 `stop`。它会被分类为错误 finish(而非成功空消息),因为尝试未产生持久内容;`dsh-llm-retry` 默认重试它。 + +### 真实适配器 + +两个适配器使用不同内部机制实现 `LlmAdapter`:[`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) 针对 `deepseek` 路由使用手写 fetch/SSE,[`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) 则通过 `@earendil-works/pi-ai` 动态解析已配置提供方/模型对。两者都遵循 `StreamChunk` 约定,定义见 `types.ts`:usage 先于 finish,工具参数保持原始字符串,错误使用两种已批准路径之一。设计理由见 [双 LLM 适配器](../../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md)。 + +## 模型体验 + +无。该适配器注册表转发已组装的请求,不添加或更改任何模型边界文本、schema 或消息。 + +#### KV Cache 影响 + +透传;注册表保留已组装请求前缀,cache 复用与路由边界属于所选适配器和提供方。 + +## 已知限制与暂缓事项 + +- **本服务不内置默认重试/缓存/速率限制策略**:`llm/stream` 仍是单次尝试调用包装 seam;agent loop 会将已验证模型请求失败单独提供给 `agent/request-error`,其默认行为是保留原始失败。`@deepseek-ai/dsh-llm-retry` 是共享示例 spine 加载的可选策略插件。 +- **`GenerateOptions` 采样只包含 `temperature`/`maxTokens`/`stop`**:没有 `tool_choice`、`top_p` 或 penalty 字段;有产生方落地时词汇才会增长(见 [已删除惰性旋钮](../../../.agents/notes/implemented/simplification/2026-07-04-drop-inert-request-knobs.md))。 +- **由产生方调节的变体在实际产生前保持在外**:`prefill`、每工具 `strict`、块 `cache` 提示与 `agent` 消息源变体因没有产生方而被剪除(见 [Agent Note](../../../.agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md))。 +- **`BlockAssembler` 只处理核心块 kind**:如果插件添加块类型的流从未由 `block-end` 关闭,`blocks()` 会抛出异常。 +- **`APP_IDENTITY.url` 指向一个尚不存在的仓库**:`FIXME`:创建公开 `deepseek-ai/deepseek-harness-sdk` 仓库是首次发布的前置条件。 +- **`GenerateOptions.sessionId` 是本地声明的品牌类型**:导入 dsh-session 的 `SessionId` 会产生循环;未来拥有 id 的包可以消除该权宜之计。 diff --git a/packages/llm/token-meter/README.i18n.yaml b/packages/llm/token-meter/README.i18n.yaml new file mode 100644 index 0000000000..f5a86d1808 --- /dev/null +++ b/packages/llm/token-meter/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: ccb18d725feaa397520f5ee17e2900355e7d08c2 +README.zh.md: 6ab48b0f5a704fa85e4bceffd886490462287f6a diff --git a/packages/llm/token-meter/README.md b/packages/llm/token-meter/README.md index 18f828ddd4..ccb18d725f 100644 --- a/packages/llm/token-meter/README.md +++ b/packages/llm/token-meter/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-token-meter +English | [中文](README.zh.md) + Replay-aware token measurement through the singleton `ctx.tokenMeter` service. It advances one isolated fold per session from the durable log, so compaction and other pressure-sensitive plugins can share accounting without depending on `CompactService`. ## Configuration diff --git a/packages/llm/token-meter/README.zh.md b/packages/llm/token-meter/README.zh.md new file mode 100644 index 0000000000..6ab48b0f5a --- /dev/null +++ b/packages/llm/token-meter/README.zh.md @@ -0,0 +1,46 @@ +# @deepseek-ai/dsh-token-meter + +[English](README.md) | 中文 + +通过单例 `ctx.tokenMeter` 服务进行感知回放的 token 测量。它从持久日志为每个会话推进一个隔离 fold,因此压缩与其他压力敏感插件可以共享计量,无需依赖 `CompactService`。 + +## 配置 + +估算器没有设置。它有意使用一项固定启发式规则:每个 token 按四个字符估算,再加上角色、块与请求 envelope 字段的结构开销。任何 key 都会被拒绝,包括已废弃的全局 `contextWindow`;模型容量属于拥有精确提供方/模型路由的适配器,可通过 `ctx.llm.resolveModelContext()` 获取。 + +## 测量契约 + +`ctx.tokenMeter` 直接公开两个操作: + +- `measure(session, requestHeader?)` 在同一个已消费日志 revision 上返回请求压力与当前已计价表层。 +- `estimateMessage(message)` 使用固定启发式规则为一条消息计价。 + +`measure()` 会同步一次,返回一个与输入脱离、深度不可变的快照。`totalTokens` 是请求与响应压力,`surfaceTokens` 是仅表层启发式总量,等于 `nodes[].tokens` 之和。`requestHeader` 覆盖只影响压力字段;表层字段仍描述当前会话。每次调用都会克隆带位置的节点,因此测量是 O(surface)。 + +fold 跟踪完整请求标头快照、步骤边界、表层追加与替换、成功 assistant 消息、提供方用量和 assistant chunk 溯源。只有当最新成功调用的规范请求 envelope 与已测量 envelope 匹配,且其总量不低于该调用的完整启发式锚点时,才会复用提供方用量;后续成功会替换较早锚点。否则估算完整当前 envelope 与表层。表层变更保持相对于匹配锚点的带符号值,包括缩减替换后的负 delta。 + +用量计量会求和不重叠的输入、cache-read、cache-write 与输出 bucket;不会再次添加 reasoning。每次成功调用都会记录一个 assistant 锚点,包括无内容调用。显式空溯源列表表示已知空提供方流,而缺失的遗留溯源会保守地将持久 assistant 输出视为提供方输出。 + +## 组合 + +```yaml +- name: '@deepseek-ai/dsh-token-meter' +- name: '@deepseek-ai/dsh-compact-basic' +``` + +两个插件都有可用默认值。meter 保持与模型路由和可选压缩无关。部署会在 LLM 适配器上配置容量,并在 `dsh-compact-basic` 上配置压缩策略。 + +## 模型体验 + +通过 `dsh-compact-basic` 等消费方间接影响;该服务自身不添加提示词、消息、schema、工具或模型调用。 + +#### KV Cache 影响 + +不会直接失效;请求前缀变更由具名消费方负责。 + +## 已知限制与暂缓事项 + +- **固定启发式规则是近似值**:没有可复用提供方用量的内容按字符数加结构开销计价,而不是使用精确提供方 tokenizer 或请求 serializer。 +- **每次测量都会克隆当前表层**:连贯不可变快照使读取成为 O(surface),包括低于阈值的压力检查。 +- **提供方用量只能为完全相同的规范 envelope 复用**:提示词、前缀、工具、提供方、模型或调用配置变更都会有意回退到完整启发式估算。 +- **遗留溯源采取保守策略**:没有 `sourceEventSeqs` 的 assistant 消息无法区分提供方输出与 listener 改写,因此 fold 不会声称已知空流或精确 chunk 流。 diff --git a/packages/lsp/README.i18n.yaml b/packages/lsp/README.i18n.yaml new file mode 100644 index 0000000000..96991875e8 --- /dev/null +++ b/packages/lsp/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 7b5d9e0f50e733113539cf1ea1ed72e8651ad94c +README.zh.md: 9b4dbb9f3ab40cf631a40d0c6d5c654689cf7061 diff --git a/packages/lsp/README.md b/packages/lsp/README.md index 147888a259..7b5d9e0f50 100644 --- a/packages/lsp/README.md +++ b/packages/lsp/README.md @@ -1,5 +1,7 @@ # lsp/ - LSP capability family +English | [中文](README.zh.md) + The language-server capability seam: an abstract LSP interface, a generic stdio provider, and the model-facing `lsp` tool. All **product** packages. | Package | Role | ctx key | diff --git a/packages/lsp/README.zh.md b/packages/lsp/README.zh.md new file mode 100644 index 0000000000..9b4dbb9f3a --- /dev/null +++ b/packages/lsp/README.zh.md @@ -0,0 +1,15 @@ +# lsp/ - LSP 能力家族 + +[English](README.md) | 中文 + +语言服务器能力 seam:抽象 LSP 接口、通用 stdio 提供方,以及面向模型的 `lsp` 工具。这些全是**产品** 包。 + +| 包 | 职责 | ctx key | +|---|---|---| +| `lsp/` | 抽象 LSP seam(按品牌化 id + 扩展名映射组织的提供方注册表、逐查询选择、词汇、`LspError`) | `ctx.lsp` | +| `lsp-local/` | 通用多服务器本地后端(spawn、JSON-RPC、临时打开查询) | (在 `ctx.lsp` 上注册提供方) | +| `tool-lsp/` | 面向模型的 `lsp` 工具(四种操作、从 1 开始的 UTF-16 光标坐标) | (注册到 `ctx.tools`) | + +接口位于 `lsp/lsp/`。该 seam 恰好公开四种语义操作:`goToDefinition`、`findReferences`、`goToImplementation`、`hover`,且不提供通用 JSON-RPC 逃生口;因此,替换提供方不会改变模型请求导航的方式,也不会让协议载荷或未经评审的修改进入模型契约。提供方注册的是**能力** 而非工具;`tool-lsp` 是面向模型名称、schema、提示词指引和呈现的唯一 owner。 + +设计原理见 [LSP 能力 seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md),其中也解释了文档为何在每次查询时临时打开、本地主机为何通过 Node API 而非 `ctx.fs` 读取,以及扩展名归属为何在同一运行时内互斥。 diff --git a/packages/lsp/lsp-local/README.i18n.yaml b/packages/lsp/lsp-local/README.i18n.yaml new file mode 100644 index 0000000000..dcef438cf7 --- /dev/null +++ b/packages/lsp/lsp-local/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 877c131ca4e34fdce59a46f820b889a1b9a73555 +README.zh.md: 58cf5a0558c680abd12b599ac7ef7696ce044877 diff --git a/packages/lsp/lsp-local/README.md b/packages/lsp/lsp-local/README.md index 7c6c05b7df..877c131ca4 100644 --- a/packages/lsp/lsp-local/README.md +++ b/packages/lsp/lsp-local/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-lsp-local +English | [中文](README.zh.md) + A **generic local stdio language-server backend** for `ctx.lsp`. One plugin instance accepts a named server table and registers one isolated provider per entry. This is a generic host, not a language-server catalog or installer — deployments configure commands and mappings explicitly; presets belong in `cordis.yml` overlays. Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export). diff --git a/packages/lsp/lsp-local/README.zh.md b/packages/lsp/lsp-local/README.zh.md new file mode 100644 index 0000000000..58cf5a0558 --- /dev/null +++ b/packages/lsp/lsp-local/README.zh.md @@ -0,0 +1,58 @@ +# @deepseek-ai/dsh-lsp-local + +[English](README.md) | 中文 + +`ctx.lsp` 的**通用本地 stdio 语言服务器后端**。一个插件实例接受一张命名服务器表,并逐配置项注册一个隔离的提供方。这是通用主机,而不是语言服务器目录或安装器:部署需要显式配置命令与映射,preset 应放在 `cordis.yml` overlay 中。 + +Namespace 插件(`name`/`inject`/`Config`/`apply`,无默认导出)。 + +## 功能 + +- 在注册前解析每项服务器局部设置;无效映射或注册冲突会回滚较早配置项,因此加载失败不会留下提供方路由。 +- 每个 `(server id, canonical workspace realpath)` 惰性 single-flight 一个服务器进程。存活服务器错误不会回放;如果选中的池化传输在只读查询之前或期间失败,提供方会等待其释放,并在新进程上重试该查询一次。 +- 每次查询都使用兼容性优先的**临时打开** 序列:通过 Node API 规范化并读取源文件、`textDocument/didOpen`(版本 1、完整文本)、所请求操作,然后执行 `textDocument/didClose`,该操作位于 `finally` 中。写入 `didOpen` 失败或取消时,会先终止实例再允许池复用。文档在每次调用后关闭,因此第一版不需要 `didChange`、内容 cache 或文档 LRU。 +- 通过一条逐 Workspace、可中止的队列,串行执行每个源读取/打开/查询/关闭生命周期,因此排队调用只会在轮到自身时读取当前源;不同 Workspace 并行运行。 +- 协议 shutdown 失败后,通过 POSIX 进程组信号或同步 Windows `taskkill /T /F` 终止服务器后代树。Windows 只抑制 taskkill 报告的树已不存在结果;命令、权限与其他树终止失败仍保持可见。 +- 通过子进程 host namespace 中的 Node 文件系统 API 读取源文件,绝不使用 `ctx.fs`,也不发出 `fs/observed`:只有 LSP 结果对模型可见,因此查询不满足先读后写策略。 + +## 配置 + +`servers` 记录的 key 是在 `ctx.lsp` 上保留的稳定提供方 id;每个值具有以下形状: + +| 服务器 key | 默认值 | 含义 | +|---|---|---| +| `command` | (必填) | 要 spawn 的可执行文件:绝对路径,或在加载时从子进程 PATH 解析。不使用 shell 启动。 | +| `args` | `[]` | 传给可执行文件的参数。 | +| `env` | `{}` | 合并到已清理 credential 的环境之上的额外 env(匹配 `KEY`/`SECRET`/`TOKEN` 的变量不会转发)。 | +| `extensionToLanguage` | (必填) | 小写、以点开头的扩展名 → LSP language id(例如 `{ '.ts': 'typescript' }`)。 | +| `initializationOptions` | `null` | 转发给服务器的静态 `initialize` 选项。 | +| `configuration` | `null` | 每个 `workspace/configuration` 配置项的静态答案。 | +| `maxMessageBytes` | `16000000` | 从服务器接受的单条 framed 消息最大大小。 | +| `maxStderrBytes` | `1000000` | 为诊断保留的 stderr 尾部最大大小。 | +| `maxDocumentBytes` | `4000000` | 该主机可打开的最大源文件。 | +| `shutdownTimeoutMs` | `5000` | 升级前用于优雅 `shutdown`/`exit` 的预算。 | +| `killGraceMs` | `2000` | 请求取消及 SIGTERM→SIGKILL 升级的宽限期。 | + +`servers` 必须至少包含一个配置项,每个 id 都必须非空。定时器预算必须是正整数,且不超过 Node 的 `2_147_483_647` ms 定时器上限。所有可执行文件都会在清理 credential 后于加载时解析;后面的坏配置项会阻止所有提供方注册。进程在第一次匹配查询时惰性启动。 + +## 协议行为 + +初始化会声明 `general.positionEncodings: ['utf-16']`、`workspace: { workspaceFolders: true, configuration: true }`、`textDocument.hover.contentFormat: ['markdown', 'plaintext']`,以及定义与实现使用的 `linkSupport: true`,且不进行动态注册。服务器返回的能力具有最终决定权:不受支持的操作,或缺少临时打开/关闭的同步方式,会使查询失败。服务器省略 `positionEncoding` 时默认为 `utf-16`;其他值都属于协议错误。客户端通过静态配置回答 `workspace/configuration`,接受生命周期记账请求,并拒绝 `workspace/applyEdit`:它绝不应用编辑或运行命令。导航直接映射 `Location`,并从 `LocationLink` 的 `targetUri` + `targetSelectionRange` 映射;hover 规范化会取得有效的 `MarkupContent.value`,保留 string `MarkedString`,把带 language tag 的值渲染为围栏代码,并用一个空行连接数组。缺失结果、格式错误的范围或位置,以及格式错误的 hover 编码,都会作为结构化 `LSP_MALFORMED_RESPONSE` 错误失败。 + +## 安全边界 + +提供方信任其配置的服务器,不声明任何沙箱限制。它通过 Node API 规范化并读取源文件,拒绝缺失、非普通文件、非 UTF-8、过大,或规范路径位于规范 Workspace 外部的源文件(符号链接别名共享一个实例)。结果位置可以在外部,但外部路径不能成为查询源。因此,第一版要求可信的主机本地部署;受限、远程或虚拟 Workspace 需要另一个提供方。 + +## 模型体验 + +通过 `dsh-tool-lsp` 间接影响;该工具呈现此提供方的规范化结果,该主机自身不贡献提示词或 schema。 + +#### KV Cache 影响 + +不会直接失效;请求前缀变更由 `dsh-tool-lsp` 负责。 + +## 已知限制与暂缓事项 + +- **仅限可信主机本地环境**:没有沙箱限制,也没有私有 cache/temp 写入契约;支持不受信任 binary 或受限/远程/虚拟 Workspace,需要后续的进程/文件系统契约及不同提供方(见 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md))。限制逻辑先解析 `realpath`,再通过一个带 `O_NOFOLLOW | O_NONBLOCK` 的 handle 打开源文件(最终组件符号链接防护,并以非阻塞方式拒绝 FIFO),同时进行有界读取;并发修改方如果在解析与打开之间把*祖先*目录替换为符号链接,会造成残余 TOCTOU。在该可信部署模型下接受此风险,不使用不可移植的 `openat` 逐 segment 遍历来封闭。 +- **临时打开兼容性下限**:同步能力省略打开/关闭(或声明 `None`)的服务器不受支持,即使关闭文档查询能够工作;固定的 TypeScript e2e 只建立一项兼容性下限,不代表跨语言承诺。 +- **逐服务器/Workspace 串行化延迟**:共享同一个服务器与 Workspace 的并行 agent 会在一个进程后排队;长生命周期 Workspace 进程会占用内存直到释放。 diff --git a/packages/lsp/lsp/README.i18n.yaml b/packages/lsp/lsp/README.i18n.yaml new file mode 100644 index 0000000000..e63252c7f8 --- /dev/null +++ b/packages/lsp/lsp/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: f96fc67ec8cb95f423eff9b312b7b591ec9d3008 +README.zh.md: 13ae9700e284ff238147538a571622066efc5747 diff --git a/packages/lsp/lsp/README.md b/packages/lsp/lsp/README.md index df9ced7dc3..f96fc67ec8 100644 --- a/packages/lsp/lsp/README.md +++ b/packages/lsp/lsp/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-lsp +English | [中文](README.zh.md) + The **LSP capability seam**: an abstract `LspService` (`ctx.lsp`) defining WHAT semantic code navigation the harness has — go to definition, find references, find implementations, hover — over language-server providers, without binding the model contract to local subprocesses. This package is the interface third of the LSP capability: diff --git a/packages/lsp/lsp/README.zh.md b/packages/lsp/lsp/README.zh.md new file mode 100644 index 0000000000..13ae9700e2 --- /dev/null +++ b/packages/lsp/lsp/README.zh.md @@ -0,0 +1,44 @@ +# @deepseek-ai/dsh-lsp + +[English](README.md) | 中文 + +**LSP 能力 seam**:抽象 `LspService`(`ctx.lsp`)定义 harness 具备哪些语义代码导航能力(转到定义、查找引用、查找实现、悬停),并通过语言服务器提供方实现,不把模型契约绑定到本地子进程。 + +该包是 LSP 能力中负责接口的三分之一: + +| 包 | 职责 | +|---|---| +| `@deepseek-ai/dsh-lsp`(本包) | 接口:服务、以品牌化 id + 扩展名映射为 key 的提供方注册表、逐查询选择、请求/结果词汇、`LspError` 分类体系 | +| `@deepseek-ai/dsh-lsp-local` | 通用本地后端,注册已配置的 stdio 语言服务器提供方 | +| `@deepseek-ai/dsh-tool-lsp` | 面向模型的 `lsp` 工具,基于 `ctx.lsp` | + +该 seam 恰好公开四种语义操作:`goToDefinition`、`findReferences`、`goToImplementation`、`hover`,且没有通用 JSON-RPC 逃生口,因此任何协议载荷或未经评审的命令/修改都无法通过 `ctx.lsp` 到达提供方。 + +## 服务 API(`ctx.lsp`) + +| 成员 | 语义 | +|---|---| +| `registerProvider(provider)` | 注册后端,以原子方式保留其品牌化 `id` 与每个规范化文件扩展名。任何无效输入或冲突都不会发布内容,并抛出 `LspError`(`LSP_INVALID_PROVIDER`/`LSP_CONFLICT`)。返回释放所有保留项的 disposer。随调用 fiber 释放。 | +| `query(request, signal?)` | 按文件最终扩展名选择提供方,从该提供方的映射派生 `languageId`,并运行一次查询。没有匹配项时抛出 `LspError` `LSP_UNAVAILABLE`。 | + +选择逐查询进行且与顺序无关:一个提供方独占一组扩展名,因此注册和 HMR 顺序绝不会改变路由。扩展名 key 规范化为小写且以点开头;`languageId` 只用于同步临时文档,绝不参与选择。第一版没有 glob、language-id 或显式路由 selector。 + +提供方注册的是**能力** 而非工具。`dsh-tool-lsp` 是面向模型名称、描述、提示词指引、schema 和呈现的唯一 owner。 + +## 词汇 + +`LspQueryRequest`(`operation`、`filePath`、`position`、`workspaceRoot`):每个字段都必填,因此没有字段需要实现默认值,也不存在 `resolve()` 步骤。位置与范围使用从零开始的 UTF-16,与协议一致;工具拥有从 1 开始的光标约定。`findReferences` 始终包含声明,提供方在内部强制执行,因此调用方没有 flag。`LspQueryResult` 是封闭的判别联合:导航使用 `{ kind: 'locations'; locations; resolvedWorkspaceRoot }`,悬停使用 `{ kind: 'hover'; hover }`(内容或 `null`);消费方通过 `switch` 实现穷尽检查,因此新增分支会使编译失败,直到完成处理。`resolvedWorkspaceRoot` 是提供方对请求 `workspaceRoot` 的规范形式,也是其 `file:` URI 所相对的根;调用方把显示路径相对化时使用该值,而非可能含符号链接的请求根。完整契约见 `src/types.ts`;`src/index.ts` 给出 `LspError` code,包括 `LSP_DISPOSED` 和 `LSP_MALFORMED_RESPONSE`。 + +## 模型体验 + +通过 `dsh-tool-lsp` 间接影响;该工具拥有面向模型的 `lsp` schema、提示词与渲染结果,本注册表自身不贡献提示词或 schema。 + +#### KV Cache 影响 + +不会直接失效;请求前缀变更由 `dsh-tool-lsp` 负责。 + +## 已知限制与暂缓事项 + +- **同一运行时内扩展名归属互斥**:两个提供方不能同时声明 `.ts`,即使 language id 不同;重叠会使注册失败。预期扩展是在注册之上增加部署配置的 selector;它可以放宽互斥保留,而无需把提供方选择加入模型输入(见 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md))。 +- **仅四种操作**:symbol 与 call hierarchy 暂缓(它们需要不同 schema);diagnostics 需要独立的新鲜度/累积规则;修改操作(rename、code action、formatting)需要独立工具,并集成预览、权限和写入策略。 +- **没有观测表层**:可用性只能通过运行 `query()` 并按抛出的 `LspError` code 路由来观测;没有提供方变更事件或能力状态查询。 diff --git a/packages/lsp/tool-lsp/README.i18n.yaml b/packages/lsp/tool-lsp/README.i18n.yaml new file mode 100644 index 0000000000..e27d2f9344 --- /dev/null +++ b/packages/lsp/tool-lsp/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 9b4130015ddf7e1cad6fa9a0e131be86f3bd4bcc +README.zh.md: e08ffcef0ff272a46d58ea17c032bb46153b0d96 diff --git a/packages/lsp/tool-lsp/README.md b/packages/lsp/tool-lsp/README.md index e1817a0ff7..9b4130015d 100644 --- a/packages/lsp/tool-lsp/README.md +++ b/packages/lsp/tool-lsp/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-tool-lsp +English | [中文](README.zh.md) + The model-facing **`lsp` tool** over `ctx.lsp`: one read-only tool with four operations for precise code navigation. It owns the model schema, prompt guidance, coordinate conversion, result limits and formatting, and UI presentation; it imports no provider. Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export). Injects `tools`, `lsp`, and `systemPrompt`. diff --git a/packages/lsp/tool-lsp/README.zh.md b/packages/lsp/tool-lsp/README.zh.md new file mode 100644 index 0000000000..e08ffcef0f --- /dev/null +++ b/packages/lsp/tool-lsp/README.zh.md @@ -0,0 +1,90 @@ +# @deepseek-ai/dsh-tool-lsp + +[English](README.md) | 中文 + +面向模型的 **`lsp` 工具**,基于 `ctx.lsp`:一个只读工具,通过四种操作执行精确代码导航。它拥有模型 schema、提示词指引、坐标转换、结果限制与格式化,以及 UI 呈现;不导入任何提供方。 + +Namespace 插件(`name`/`inject`/`Config`/`apply`,无默认导出)。注入 `tools`、`lsp` 和 `systemPrompt`。 + +## 工具 + +`lsp` 接受 `operation`(`goToDefinition` | `findReferences` | `goToImplementation` | `hover`)、`file_path`、`line` 和 `character`。`line` 与 `character` 是正的、从 1 开始的 UTF-16 光标坐标;工具将其转换为 seam 从零开始的位置,并把渲染位置转换回来。`findReferences` 包含声明,因此影响分析不会遗漏定义位置。提供方、language id、Workspace 根、限制、超时、初始化和可执行文件均不进入模型输入。 + +该工具要求从会话 `header.cwd` 取得 Workspace 根,没有回退值:缺失时会在查询前以 `LSP_WORKSPACE_REQUIRED` 失败。其规范结果是完整的已规范化 seam 联合:`{ kind: "locations", locations, resolvedWorkspaceRoot }` 或 `{ kind: "hover", hover }`;Code Mode 可以直接检查每个已取得的位置和从零开始的范围。原生渲染随后投影按文件稳定分组的 `path:line:character` 配置项,并相对于结果的 `resolvedWorkspaceRoot`(提供方的规范根)而非会话 cwd;因此,即使 cwd 包含符号链接,Workspace 内结果仍渲染为相对路径。`file:` URI 在内部时成为 Workspace 相对路径,在外部时成为绝对路径,其他 URI 保持原样。空位置和 `null` hover 都是成功的无结果响应;格式错误的提供方载荷仍是结构化错误。 + +## 配置 + +| Key | 默认值 | 含义 | +|---|---|---| +| `maxLocations` | `100` | 出现省略标记前可渲染位置的最大数量。 | +| `maxResultChars` | `16000` | 完整渲染结果的最大长度,包括截断元数据。 | +| `timeoutMs` | `60000` | 由 `dsh-timeout-policy` 强制执行的工具调用超时预算;覆盖完整的排队打开/查询/关闭生命周期,且模型不可配置。 | + +## 模型体验 + +### 系统提示词 + +#### 模型看到的内容 + +一个系统提示词区段(顺序 112)将 LSP 定位为精确辅助工具,文本如下: + +##### 逐字指引 + +```markdown +Use search/read for ordinary navigation. Use lsp when textual matches are ambiguous or before a change requires precise definitions, implementations, or references. Positions are one-based line and character (UTF-16) at the cursor; an off-symbol position may return no results. findReferences always includes the declaration. +``` + +#### Token 影响 + +插件处于活跃状态时,每次请求承担固定指引成本。 + +#### KV Cache 影响 + +只要插件 scope 与指引文本不变,前缀就保持稳定;激活或释放可能使从该区段起的复用失效。 + +### 工具 schema + +#### 模型看到的内容 + +模型会看到生成的 [`lsp` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-lsp)。 + +#### Token 影响 + +启用期间,每次请求承担固定 schema 成本;`timeoutMs` 预算绝不会发给模型。 + +#### KV Cache 影响 + +只要可见工具定义与顺序不变,前缀就保持稳定;注册生命周期或 scope 限制可能使从第一个变化的 schema token 起的复用失效。 + +### 结果 + +#### 模型看到的内容 + +按文件分组的 `path:line:character` 位置行或规范化 hover 文本,先由 `maxLocations` 限制,再由 `maxResultChars` 限制;省略与截断标记计入完整字符上限。这些上限只影响原生/模型呈现,不影响规范值。空结果使用不同的 `No results.`/`No hover information.` 行。 + +#### Token 影响 + +每项工具结果以 `maxResultChars` 为上限,`maxLocations` 还会限制导航项数量。 + +#### KV Cache 影响 + +工具结果追加在已缓存请求前缀之后,不会直接使其失效。 + +### UI 呈现 + +#### 模型看到的内容 + +无。客户端渲染通用搜索卡片:`{ card: 'generic', kind: 'search', title, locations: [{ path, line }] }`;从 args 派生的标题携带操作与从 1 开始的光标,跟随焦点对准查询行,标题则保留列号。 + +#### Token 影响 + +直接 token 影响为零,因为渲染只发生在客户端。 + +#### KV Cache 影响 + +无;UI 呈现位于模型请求之外。 + +## 已知限制与暂缓事项 + +- **UTF-16 光标坐标**:列坐标与协议精确一致,但模型难以在非 BMP 字符周围计数;非 symbol 位置可能返回空结果,因此提示词解释了该约定,但不会鼓励宽泛使用 LSP(见 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md))。 +- **不承诺跨服务器完整性**:受支持的服务器仍可能根据索引就绪情况返回空或部分结果;该工具不承诺跨语言或服务器的完整性。 diff --git a/packages/mcp/README.i18n.yaml b/packages/mcp/README.i18n.yaml new file mode 100644 index 0000000000..34e534336e --- /dev/null +++ b/packages/mcp/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 3bde9023368da318ec572a72e2e86a7fa2d3ad8d +README.zh.md: 13410c013e67468a17bcf7173af519c7bb239e63 diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 153afde8a9..3bde902336 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -1,5 +1,7 @@ # MCP — Model Context Protocol +English | [中文](README.zh.md) + Packages bridging the harness to the MCP ecosystem. | Package | Role | diff --git a/packages/mcp/README.zh.md b/packages/mcp/README.zh.md new file mode 100644 index 0000000000..13410c013e --- /dev/null +++ b/packages/mcp/README.zh.md @@ -0,0 +1,9 @@ +# MCP:Model Context Protocol + +[English](README.md) | 中文 + +连接 harness 与 MCP 生态的包(package)。 + +| 包 | 角色 | +|---|---| +| `mcp-client/` | MCP 客户端桥接:连接外部 MCP 服务器,并将其工具注册到 `ctx.tools` | diff --git a/packages/mcp/mcp-client/README.i18n.yaml b/packages/mcp/mcp-client/README.i18n.yaml new file mode 100644 index 0000000000..5e1728972d --- /dev/null +++ b/packages/mcp/mcp-client/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 82d974635cb35878d6f0365b1aa7a9745436240e +README.zh.md: 006e662ad69c61a011d92998f0daeb8a1b55a1c9 diff --git a/packages/mcp/mcp-client/README.md b/packages/mcp/mcp-client/README.md index 252cd27bfc..82d974635c 100644 --- a/packages/mcp/mcp-client/README.md +++ b/packages/mcp/mcp-client/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-mcp-client +English | [中文](README.zh.md) + MCP client bridge plugin: connects to external [Model Context Protocol](https://modelcontextprotocol.io/) servers and registers their tools on `ctx.tools`, making them available to the model as native tools under server-qualified names (`mcp__<serverName>__<rawName>`). ## Usage diff --git a/packages/mcp/mcp-client/README.zh.md b/packages/mcp/mcp-client/README.zh.md new file mode 100644 index 0000000000..006e662ad6 --- /dev/null +++ b/packages/mcp/mcp-client/README.zh.md @@ -0,0 +1,108 @@ +# @deepseek-ai/dsh-mcp-client + +[English](README.md) | 中文 + +MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelcontextprotocol.io/) 服务器,把它们的工具注册到 `ctx.tools`,使模型能够通过服务器限定名称(`mcp__<serverName>__<rawName>`)将其作为原生工具使用。 + +## 用法 + +`cordis.yml` 中每个 MCP 服务器使用一个插件实例: + +```yaml +- id: mcp-github + name: '@deepseek-ai/dsh-mcp-client' + config: + serverName: github + transport: stdio + command: npx + args: ['-y', '@modelcontextprotocol/server-github'] + env: + GITHUB_TOKEN: !!js process.env.GITHUB_TOKEN + +- id: mcp-web + name: '@deepseek-ai/dsh-mcp-client' + config: + serverName: web + transport: streamable-http + url: http://localhost:3000/mcp + headers: + Authorization: !!js '`Bearer ${process.env.MCP_TOKEN}`' +``` + +模型会看到 `mcp__github__create_issue`、`mcp__web__search` 等工具,这与 Claude Code 和 Codex 使用的服务器限定形状相同。HMR 会热替换:编辑配置项会触发断开 + 重新连接,无需重启进程;`serverName` 不变时会生成完全相同的工具名称。 + +## 配置 + +| 字段 | 传输 | 必填 | 描述 | +|---|---|---|---| +| `transport` | 两者 | 是 | `"stdio"` 或 `"streamable-http"` | +| `serverName` | 两者 | 是 | 该服务器面向模型工具名称的 namespace;`[A-Za-z0-9_-]{1,32}`,在存活实例中唯一 | +| `command` | stdio | 是 | 要 spawn 的可执行文件 | +| `args` | stdio | 否 | 传给命令的参数 | +| `env` | stdio | 否 | 合并到已清理环境之上的额外环境变量 | +| `cwd` | stdio | 否 | 子进程工作目录 | +| `url` | http | 是 | MCP 服务器 URL | +| `headers` | http | 否 | 额外标头(例如认证 token) | +| `toolCallTimeoutMs` | 两者 | 否 | 每次 `callTool` 调用的超时(默认 60000) | + +## 工具命名 + +每个 MCP 工具都有两个名称:通过 `tools/call` 在协议上传送的原始 MCP 名称,以及公开名称 `mcp__<serverName>__<rawName>`,后者注册到 `ctx.tools`。公开名称会规范化为 DeepSeek 函数名称契约(64 个字符、`[A-Za-z0-9_-]`);如果替换或截断改变名称,就会追加 `(serverName, rawName)` 的确定性 12 位十六进制 hash,确保不同工具绝不会折叠为同一个名称。名称是 `(serverName, rawName)` 的纯函数:连接顺序、重新同步和其他服务器永远不会重命名工具。 + +- 发布相同原始名称(例如 `search`)的两个服务器会在各自 namespace 下共存。 +- 存活实例中的重复 `serverName` 会使后加载的插件实例失败。 +- 服务器在工具列表中两次列出同一工具名称时,该列表会作为无效工具列表被拒绝。 +- 外部注册抢占该服务器 namespace 时,会回滚整个世代(绝不保留部分集合),并高声报错。 + +## 行为 + +- 连接时:`listTools()` → 通过 `ctx.tools.register()` 使用各自公开名称注册每个工具。 +- 监听 `notifications/tools/list_changed` → 重新同步;同步失败时保留上一世代的注册。 +- 工具执行:`client.callTool({ name: rawName, arguments }, { signal })`,支持超时 + 中止;公开名称绝不会发给服务器。 +- 规范成功值是 `{ content: JsonValue[], structuredContent? }`;完整的 JSON MCP 块会保留给编程调用方。受支持且已声明的 `outputSchema` 会验证 `structuredContent`;不受支持的 schema 词汇会回退为不受约束的 `JsonValue`。 +- 原生/模型渲染保留现有文本投影:文本块以换行连接,图片、音频、资源和不受支持的块会变成占位符。 +- 断开/崩溃时:注销所有工具;不自动重新连接。 + +## 消费的服务 + +| 服务 | 用途 | +|---|---| +| `ctx.tools` | 注册/注销 MCP 工具 | + +## 模型体验 + +### 已发现的 MCP 工具 + +#### 模型看到的内容 + +初始发现成功后,每个已声明的 MCP 工具都会显示为名为 `mcp__<serverName>__<rawName>`(或其确定性规范化形式)的原生工具,并携带服务器提供的描述和输入 schema。成功的重新同步会替换整个世代;插件释放会移除它。 + +#### Token 影响 + +工具注册期间,每次请求都会承担数据相关的 schema 成本。重新同步会替换而非累积 schema,服务器限定名称也会为每个工具定义和调用增加 token。 + +#### KV Cache 影响 + +只要已发现工具集合及其 schema 不变,前缀就保持稳定。增加、移除、重命名或更改工具的重新同步会替换定义,并可能使从第一个变化的 schema token 起的复用失效。 + +### 工具调用历史与结果 + +#### 模型看到的内容 + +公开工具名称和 JSON 参数会保留在 assistant 历史中。文本结果块会以换行连接为一个保留的原生文本结果;图片、音频、资源和不受支持的块在其中变为简短占位符。它们的完整 JSON 块及可选结构化内容保留在执行局部的规范值中;MCP `isError` 会通过注册表的错误路径拒绝调用。 + +#### Token 影响 + +参数和映射后的文本会保留到压缩发生时。二进制与资源载荷会被丢弃,而不会加入上下文。 + +#### KV Cache 影响 + +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 配置项失效。 + +## 已知限制与暂缓事项 + +- **初始发现是异步的**:插件加载不会等待连接和 `listTools()`,因此在启动或 HMR 后立即开始的轮次可能在 MCP 工具注册前完成组装。 +- **只桥接 MCP 的工具能力**:资源和提示词没有 harness 消费表层,暂缓实现。 +- **崩溃恢复需要手动触发**:传输关闭会注销服务器工具,但重新连接需要 HMR 重载或重启 harness。 +- **原生非文本渲染有损**:图片、音频与资源载荷在模型上下文中会变成占位符,即使执行局部的规范值保留了其 JSON 块。更丰富的原生多媒体投影暂缓实现。 +- **不强制执行不受支持的 MCP 输出 schema**:已声明 schema 使用 harness 子集之外的词汇时,`structuredContent` 会回退到 `JsonValue`。 diff --git a/packages/plan/README.i18n.yaml b/packages/plan/README.i18n.yaml new file mode 100644 index 0000000000..f38be94b01 --- /dev/null +++ b/packages/plan/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: eeb58703c34acae0eb2146b87b01a56815e1362a +README.zh.md: cedb8dac138fff15595b4b935000334f8a36c262 diff --git a/packages/plan/README.md b/packages/plan/README.md index 4aedb8c9aa..eeb58703c3 100644 --- a/packages/plan/README.md +++ b/packages/plan/README.md @@ -1,9 +1,11 @@ # plan/ — plan collaboration state +English | [中文](README.zh.md) + Plan mode is one logged, per-agent collaboration state. It is a single **product** package, not a generic mode registry or a capability-seam trio. | Package | Role | ctx key | |---|---|---| | `plan-mode/` | `plan/mode` vocabulary + fold, boundary-applied state, the `plan:policy` guidance section, `/plan [message]` entry and `/plan off` exit, and the model-facing `exit_plan_mode` review tool | `ctx.planMode` | -The active state is a pure function of the session log, so resume and fork restore it without extra machinery. The deployment supplies plan instructions through Cordis config, while `exit_plan_mode` stays registered when planning is inactive to keep the request tool catalog stable. Interactive adapters use the plugin-owned `/plan` command; sandbox mode and approval policy remain independent enforcement settings. Design: [plan-mode Agent Note](../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md) and [plan-specific state simplification](../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md). +The active state is a pure function of the session log, so resume and fork restore it without extra machinery. The deployment supplies plan instructions through Cordis config, while `exit_plan_mode` stays registered when planning is inactive to keep the request tool catalog stable. Interactive adapters use the plugin-owned `/plan` command; sandbox mode and approval policy remain independent enforcement settings. Design: [plan-specific collaboration state](../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md). diff --git a/packages/plan/README.zh.md b/packages/plan/README.zh.md new file mode 100644 index 0000000000..cedb8dac13 --- /dev/null +++ b/packages/plan/README.zh.md @@ -0,0 +1,11 @@ +# plan/:plan 协作状态 + +[English](README.md) | 中文 + +Plan mode 是一种按 agent 分开记录到日志的协作状态。它是单一 **产品** 包(package),而非通用 mode 注册表或能力 seam 三包组合。 + +| 包 | 职责 | ctx 键 | +|---|---|---| +| `plan-mode/` | `plan/mode` 词汇与折叠、在边界生效的状态、`plan:policy` 引导段、`/plan [message]` 进入命令与 `/plan off` 退出命令,以及面向模型的 `exit_plan_mode` 评审工具 | `ctx.planMode` | + +活跃状态是会话日志的纯函数,因此恢复和 fork 无需额外机制即可还原该状态。部署通过 Cordis 配置提供 plan 指令,而 `exit_plan_mode` 在 plan mode 未激活时仍保持注册,以稳定请求工具目录。交互式适配器使用插件拥有的 `/plan` 命令;沙箱模式和批准策略仍是独立的强制执行设置。设计详见 [plan 专用协作状态](../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md)。 diff --git a/packages/plan/plan-mode/README.i18n.yaml b/packages/plan/plan-mode/README.i18n.yaml new file mode 100644 index 0000000000..7d691fb667 --- /dev/null +++ b/packages/plan/plan-mode/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: c86586603dd3671c4c69342ba62c350e96dd351f +README.zh.md: 018b32ef4bd1b2de7655ae97993df02011bee185 diff --git a/packages/plan/plan-mode/README.md b/packages/plan/plan-mode/README.md index 8032e83c4c..c86586603d 100644 --- a/packages/plan/plan-mode/README.md +++ b/packages/plan/plan-mode/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-plan-mode +English | [中文](README.zh.md) + Logged, per-agent plan collaboration state with deployment-owned guidance, direct `/plan [message]` entry and `/plan off` exit commands, and the reviewed `exit_plan_mode` exit. Plan mode is soft guidance; sandbox mode and approval policy remain independent enforcement axes. ## Durable state @@ -29,7 +31,7 @@ The TUI consumes the plugin-owned `/plan` command; other front doors may drive t `section` is required and non-empty. Unknown keys fail at load. The package does not accept arbitrary named modes, tool filters, sandbox settings, or approval policy. -Design: [plan-mode Agent Note](../../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md) and [plan-specific state simplification](../../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md). +Design: [plan-specific collaboration state](../../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md). ## Model Experience diff --git a/packages/plan/plan-mode/README.zh.md b/packages/plan/plan-mode/README.zh.md new file mode 100644 index 0000000000..018b32ef4b --- /dev/null +++ b/packages/plan/plan-mode/README.zh.md @@ -0,0 +1,91 @@ +# @deepseek-ai/dsh-plan-mode + +[English](README.md) | 中文 + +按 agent(智能体)分开记录到日志的 plan 协作状态,提供部署拥有的引导内容、直接 `/plan [message]` 进入命令、`/plan off` 退出命令,以及经评审的 `exit_plan_mode` 退出。Plan mode 是软引导;沙箱模式和批准策略仍是独立的强制执行轴。 + +## 持久状态 + +`plan/mode`(`{ active: boolean }`)是一个仅写日志、整值替换的 `SessionEventMap` 成员。`foldPlanMode(events)` 返回最后记录的值,如果没有则返回 `false`,因此恢复、fork 和压缩(compaction)都能直接从会话日志恢复 plan 状态。UI 通过 `session/event` 观察已提交的切换。 + +`ctx.planMode.set(agent, active)` 记录一个待生效选择,并在下一个轮次边界内刷新它。`get(agent)` 返回 `{ active, pending? }`,将塑造当前步骤的日志状态与用户的乐观选择分开。提示词提交、常规续行和请求恢复重试都在覆盖范围内;当最后记录的请求头描述了另一状态时,用户选择的变更会贡献一条插件来源的 `user/message` 通知。 + +## 模型与人类界面 + +激活时,`plan:policy` 会渲染已配置的 `section`。插件始终注册 `exit_plan_mode`,使工具 schema 在转换期间保持稳定;其 execute 路径只接受已激活的 plan mode,且只有通过 `ctx.userInteraction` 获得精确用户批准后才退出。 + +组合 `ctx.commands` 时,该包(package)会注册 `/plan [message]`,并保留精确参数 `off` 用于直接退出。不带参数的 `/plan` 选择 plan mode;任何其他非空参数都会先选择 plan mode,再通过 `agent.steer()` 提交,因此它会在 plan 引导下成为下一步骤的常规已记录用户消息。`/plan off` 选择未激活状态,不发送模型输入;它还可以在 plan mode 进入选择到达请求之前取消该待生效选择。 + +TUI 消费插件拥有的 `/plan` 命令;其他入口可以直接驱动同一服务,无需定义第二套 mode 词汇。 + +## 配置 + +```yaml +- id: plan-mode + name: '@deepseek-ai/dsh-plan-mode' + config: + section: | + You are in plan mode. Explore and design before presenting the complete + plan through exit_plan_mode. +``` + +`section` 必填且非空。未知键会在加载时失败。该包不接受任意具名 mode、工具过滤器、沙箱设置或批准策略。 + +设计:[plan 专用协作状态](../../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md)。 + +## 模型体验 + +### Plan 策略系统提示词 + +#### 模型所见内容 + +Plan mode 激活时,模型会在提示词顺序 50 处看到部署所提供的精确 `section` 文本;未激活 mode 不贡献文本。 + +##### 配置示例 + +```markdown +You are in plan mode. Explore and design before presenting the complete plan through exit_plan_mode. +``` + +#### Token 影响 + +未激活 mode 不增加 token;已激活 mode 会在每个请求中添加已配置段。 + +#### KV Cache 影响 + +该段在 plan mode 内稳定,但进入或退出会从顺序 50 开始改变系统提示词。 + +### 人类命令 + +#### 模型所见内容 + +`/plan`、`/plan off` 及其终端结果留在模型历史之外。除精确 `off` 参数以外的非空后缀会在选择 plan mode 后,通过 `agent.steer()` 成为一个去除首尾空白的用户文本块。只有在最后一个请求头描述了 plan mode 时,已激活的 `/plan off` 选择才会贡献标准已记录用户切换通知;取消待生效进入不会贡献通知,因为没有请求观测到它。 + +#### Token 影响 + +可选消息的历史 token 成本与单独提交该文本相同;不带参数的 `/plan` 和 `/plan off` 不增加 token。经叙述的激活退出会添加一条短小且保留的切换通知。 + +#### KV Cache 影响 + +用户块是仅追加的对话增长。进入或退出 plan mode 会改变更早的策略段;经叙述的退出通知追加在可复用请求前缀之后。 + +### 退出工具 schema 与评审交换 + +#### 模型所见内容 + +[`exit_plan_mode` schema](../../../docs/tool-catalog.md#deepseek-aidsh-plan-mode) 在两种状态下均可用;在 plan mode 外执行会失败,而 plan mode 内经批准的评审会返回规范 `{ approved: true }` 值,并渲染现有确认文本。拒绝仍是携带评审反馈的失败调用。 + +#### Token 影响 + +稳定 schema 的成本取决于 ToolRegistry mode,每个 plan 参数与评审结果都保留在对话历史中。 + +#### KV Cache 影响 + +Mode 转换不改变工具目录;plan 参数与评审结果按常规方式扩展对话。 + +## 已知限制与延后工作 + +- Plan mode 只进行引导,而不强制执行;需要硬边界的部署必须组合独立的沙箱与批准控制。 +- 如果进程在下一个边界之前退出,空闲时作出的待生效选择会丢失,因此 UI 必须重新应用它。 +- Fork 的 agent 会继承已记录的 plan 状态,新 spawn 的 agent 则从未激活状态开始;不存在创建时 plan 选项。 +- `exit_plan_mode` 评审弧(提交 → 人类评审 → 已批准切换或已拒绝反馈)仅由包测试覆盖;其组装应用快照随已退役 ACP UI 场景一起离开([仅面向自动化的 ACP](../../../.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md)),TUI 无密钥场景只演练 `/plan` 进入和 `/plan off` 退出。 diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index 26ad904d30..be895fb5e1 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -15,8 +15,7 @@ * The exit tool remains registered while plan mode is inactive so crossing a * boundary changes only the prompt section, not the request tool catalog. * - * Agent Notes: - * - .agents/notes/implemented/feature/2026-07-07-plan-mode.md + * Agent Note: * - .agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md * * @module @deepseek-ai/dsh-plan-mode diff --git a/packages/pty/README.i18n.yaml b/packages/pty/README.i18n.yaml new file mode 100644 index 0000000000..ef4c7b5c13 --- /dev/null +++ b/packages/pty/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: a9121455519a5f83a63a005cb857fec0f0e06b92 +README.zh.md: 9fc262787b960d5bf03a59cd01bf36bd5c76614b diff --git a/packages/pty/README.md b/packages/pty/README.md index 31fdbe3ab5..a912145551 100644 --- a/packages/pty/README.md +++ b/packages/pty/README.md @@ -1,5 +1,7 @@ # pty/ — persistent PTY capability family +English | [中文](README.zh.md) + `PTY` stands for **Pseudo-Terminal**(伪终端). This capability provides persistent, owner-scoped terminal sessions for workflows that require state across tool calls or interactive stdin. PTY complements the one-shot bash and filesystem tools; it does not replace their stronger per-operation contracts. | Package | Role | ctx key | diff --git a/packages/pty/README.zh.md b/packages/pty/README.zh.md new file mode 100644 index 0000000000..9fc262787b --- /dev/null +++ b/packages/pty/README.zh.md @@ -0,0 +1,13 @@ +# pty/:持久 PTY 能力家族 + +[English](README.md) | 中文 + +`PTY` 的全称是 **Pseudo-Terminal(伪终端)**。这项能力提供持久且限定所有者范围的终端会话,适用于需要跨工具调用保留状态或使用交互式 stdin 的工作流。PTY 是单次 bash 与文件系统工具的补充,不会取代后两者更严格的逐操作契约。 + +| 包 | 职责 | ctx 键 | +|---|---|---| +| [`pty`](pty/README.md)(`@deepseek-ai/dsh-pty`) | 后端注册表、品牌化 id、精确的 Agent 所有权、会话操作与等待完成的清理 | `ctx.pty` | +| `pty-local`(`@deepseek-ai/dsh-pty-local`) | 本地 `node-pty` 后端、就绪检测、有界终端状态、沙箱与进程会话监管 | 注册到 `ctx.pty` | +| `tool-pty`(`@deepseek-ai/dsh-tool-pty`) | 6 个面向模型的工具,并为后台发送集成通用任务 | 注册到 `ctx.tools` | + +设计与暂缓边界记录在[持久 PTY Agent Note](../../.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md) 中。 diff --git a/packages/pty/pty-local/README.i18n.yaml b/packages/pty/pty-local/README.i18n.yaml new file mode 100644 index 0000000000..772631a834 --- /dev/null +++ b/packages/pty/pty-local/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 0ac80db3571a1c9a8c472e12a675eba17031cf5e +README.zh.md: d6f7c3639bcff570dd9d7be8615bfeadc222f2b3 diff --git a/packages/pty/pty-local/README.md b/packages/pty/pty-local/README.md index a2b3aec391..0ac80db357 100644 --- a/packages/pty/pty-local/README.md +++ b/packages/pty/pty-local/README.md @@ -1,12 +1,14 @@ # @deepseek-ai/dsh-pty-local +English | [中文](README.zh.md) + Local Linux/macOS `node-pty` backend for `ctx.pty`; loading it on another platform fails as unsupported. It starts an interactive shell under the shared `ctx.sandboxPolicy`, strips credential-shaped ambient environment variables, retains bounded line-oriented output, detects readiness, and tears down the captured process tree rooted at the `node-pty` child. ## Plugin (`pty-local`) The plugin injects `pty`, `sandbox`, and `sandboxPolicy`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly; confined modes wrap the exact shell argv through `ctx.sandbox`. The effective session mode is resolved at spawn. A change to a different effective mode is rejected before its `sandbox/mode` event commits while that owner has an open PTY or a spawn in progress; the fence is attached to the exact owner and therefore outlives a local-provider reload that retains existing sessions. Wait for creation to settle and close the sessions before changing modes, so a terminal opened with wider access cannot survive a downgrade. -Linux readiness combines a foreground-verified private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the verified prompt marker plus silence/timeout because it has no `/proc` syscall surface. A marker is not ready until printable prompt text arrives, including when the OSC marker and `PS1` are split across data callbacks; when bash prints the marker before the kernel publishes its return to the foreground process group, polling retains the candidate until bash ownership is observable or the ordinary silence bound expires. An interactive child that inherits `PROMPT_COMMAND` therefore cannot suppress inferred-idle readiness until the absolute timeout. Unrecognized or unreadable process state is never a positive exact-idle signal. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason even when its foreground process group is not observable yet; if that close fails, `PtyBackendCleanupError` separately preserves the cleanup failure for registry disposal. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; a trailing carriage return is carried across callbacks so split CRLF becomes one newline. +Linux readiness combines a foreground-verified private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the verified prompt marker plus silence/timeout because it has no `/proc` syscall surface. A marker is not ready until printable prompt text arrives, including when the OSC marker and `PS1` are split across data callbacks; when bash prints the marker before the kernel publishes its return to the foreground process group, polling retains the candidate through one final poll after the ordinary silence bound so a coincident handoff can win. An interactive child that inherits `PROMPT_COMMAND` therefore cannot suppress inferred-idle readiness until the absolute timeout. Unrecognized or unreadable process state is never a positive exact-idle signal. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason even when its foreground process group is not observable yet; if that close fails, `PtyBackendCleanupError` separately preserves the cleanup failure for registry disposal. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; a trailing carriage return is carried across callbacks so split CRLF becomes one newline. Send cancellation resolves the current foreground process group and delivers a real `SIGINT`; it never emulates interruption by writing `\x03`, so raw-mode programs remain cancellable. Close sends `SIGTERM` to descendants, waits, then sends `SIGKILL` to the union of captured survivors and newly scanned descendants so reparenting cannot hide a process from teardown. It verifies that every retained identity is gone or, on Linux, a non-executing zombie before stopping the shell; zombie entries are quiescent and are reaped as the shell exits. A survivor failure does not cache a permanently rejected close; a later close retries the teardown. diff --git a/packages/pty/pty-local/README.zh.md b/packages/pty/pty-local/README.zh.md new file mode 100644 index 0000000000..d6f7c3639b --- /dev/null +++ b/packages/pty/pty-local/README.zh.md @@ -0,0 +1,36 @@ +# @deepseek-ai/dsh-pty-local + +[English](README.md) | 中文 + +这个本地 Linux/macOS `node-pty` 后端实现 `ctx.pty`;在其他平台加载时会以不支持为由失败。它在共享 `ctx.sandboxPolicy` 下启动交互式 shell,移除形似凭据的环境变量,保留有界的逐行输出,检测就绪状态,并清理以 `node-pty` 子进程为根的已捕获进程树。 + +## 插件(`pty-local`) + +该插件注入 `pty`、`sandbox` 和 `sandboxPolicy`,然后注册所配置的后端类型(`shell`)。`danger-full-access` 会直接启动 shell;受限模式则通过 `ctx.sandbox` 包装确切的 shell argv。系统在 spawn 时解析会话的实际模式。当某个所有者存在开放的 PTY 或正在进行 spawn 时,如果配置变更会得到不同的实际模式,系统会在对应 `sandbox/mode` 事件提交前拒绝该变更。该限制绑定到确切所有者,因此即使本地提供方重新加载并保留现有会话,它仍然有效。更改模式前,请等待创建结算并关闭会话,避免以更宽权限打开的终端在权限降级后继续存在。 + +Linux 的就绪检测结合以下机制:由前台状态验证的私有 bash 提示符标记、前台进程组 syscall 检查、静默回退和绝对超时。macOS 没有 `/proc` syscall 接口,因此使用经过验证的提示符标记以及静默/超时。当可打印的提示符文本尚未到达时,即使 OSC 标记和 `PS1` 被拆到多个数据回调中,系统也不会把标记视为就绪。如果 bash 在内核发布其重新取得前台进程组的状态前打印标记,轮询会将该候选状态保留到普通静默上限之后的最后一次轮询,使恰好同时发生的前台交接有机会胜出。因此,继承 `PROMPT_COMMAND` 的交互式子进程无法持续压制推断空闲就绪,最多只能延续到绝对超时。无法识别或读取的进程状态绝不会作为精确空闲的正向信号。尚未发布的启动过程中,回退路径要求已经观察到输出;零输出静默不能发布空会话,超时则拒绝 spawn。取消操作会关闭尚未发布的 shell,并以调用方提供的确切中止原因拒绝,即使当时还无法观察其前台进程组。如果关闭失败,`PtyBackendCleanupError` 会单独保留清理失败,供注册表释放资源时处理。未完成的终端控制序列受 `maxReadBytes` 限制;超过上限后,系统会丢弃内容直到其终止符。末尾的回车会跨回调保留,使拆分的 CRLF 合并为一个换行。 + +取消发送时,系统会解析当前前台进程组并发送真正的 `SIGINT`;它绝不会通过写入 `\x03` 模拟中断,因此原始模式程序仍可取消。关闭操作先向后代发送 `SIGTERM` 并等待,再向已捕获的存活进程与新扫描到的后代之并集发送 `SIGKILL`,防止进程通过重新设定父进程而逃避清理。系统确认每个保留的进程身份都已消失;在 Linux 上,非执行中的僵尸进程也视为完全停稳,并会随 shell 退出而回收。如果仍有进程存活,失败结果不会缓存成永久拒绝的关闭操作;后续关闭仍会重试清理。 + +## 模型体验 + +### 间接消费方 + +#### 模型看到的内容 + +没有直接可见内容。模型通过 `@deepseek-ai/dsh-tool-pty` 可能收到有界的 MOTD、发送增量、scrollback 页、就绪原因和清理错误。 + +#### Token 影响 + +消费方返回有界的后端输出前没有影响。此包不会把保留的 PTY scrollback 放入模型历史。 + +#### KV Cache 影响 + +不会直接失效;提示词、schema 与追加结果由消费方负责。 + +## 已知限制与暂缓工作 + +- 输出按行规范化;不支持全屏备用缓冲区交互。 +- Linux 精确探针支持 x64 与 arm64 UAPI 表;其他架构使用提示符标记和静默/超时就绪机制。 +- 如果后代进程在清理前守护化并重新设定父进程,它会脱离已捕获的进程树;清理绝不会扩大到启动器 PID 所属的整个 POSIX 会话,因为其中可能包含无关进程。 +- 会话无法跨 harness 进程退出保留。 diff --git a/packages/pty/pty-local/src/session.ts b/packages/pty/pty-local/src/session.ts index 4b1faccc94..f05ad19e1d 100644 --- a/packages/pty/pty-local/src/session.ts +++ b/packages/pty/pty-local/src/session.ts @@ -331,8 +331,11 @@ export class LocalPtySession implements PtyBackendSession { // A prompt candidate can race bash's foreground handoff, but an interactive // child also inherits PROMPT_COMMAND. Silence therefore remains the bound // on waiting for shell ownership instead of letting a child marker suppress - // readiness until the absolute timeout. - if (startupHasOutput && Date.now() - this.lastOutputAt >= this.config.idleSilenceMs) { + // readiness until the absolute timeout. One final poll lets a foreground + // handoff coincident with that boundary win before the fallback settles. + const idleFor = Date.now() - this.lastOutputAt + const handoffGrace = this.promptSeen ? this.config.pollIntervalMs : 0 + if (startupHasOutput && idleFor >= this.config.idleSilenceMs && idleFor - this.config.idleSilenceMs >= handoffGrace) { this.settleActive('inferred_idle') return } diff --git a/packages/pty/pty-local/tests/session.spec.ts b/packages/pty/pty-local/tests/session.spec.ts index 4be21c79a6..3b76c9d71c 100644 --- a/packages/pty/pty-local/tests/session.spec.ts +++ b/packages/pty/pty-local/tests/session.spec.ts @@ -298,7 +298,7 @@ describe('LocalPtySession readiness and output', () => { void operation.done.then(() => { settled = true }) inspector.pgid = 789 terminal.emitData('\x1b]133;D;0\x07dsh> ') - await vi.advanceTimersByTimeAsync(40) + await vi.advanceTimersByTimeAsync(50) expect(settled).toBe(false) inspector.pgid = 456 diff --git a/packages/pty/pty/README.i18n.yaml b/packages/pty/pty/README.i18n.yaml new file mode 100644 index 0000000000..063950ec0c --- /dev/null +++ b/packages/pty/pty/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 0f8b8e499dc81ce91e249f44bb38c8cc1af89d3f +README.zh.md: 9158afd6a7c82db27820d94cdbebd4eaa537daab diff --git a/packages/pty/pty/README.md b/packages/pty/pty/README.md index 77bc23e546..0f8b8e499d 100644 --- a/packages/pty/pty/README.md +++ b/packages/pty/pty/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-pty +English | [中文](README.zh.md) + Owner-scoped persistent PTY seam. `PtyService` registers as `ctx.pty`, mints opaque session ids, routes creation through named backends, fences every operation to the exact live `Agent`, and awaits backend quiescence when that agent or the service disposes. ## Contract diff --git a/packages/pty/pty/README.zh.md b/packages/pty/pty/README.zh.md new file mode 100644 index 0000000000..9158afd6a7 --- /dev/null +++ b/packages/pty/pty/README.zh.md @@ -0,0 +1,41 @@ +# @deepseek-ai/dsh-pty + +[English](README.md) | 中文 + +限定所有者范围的持久 PTY seam。`PtyService` 注册为 `ctx.pty`,生成不透明的会话 id,通过具名后端路由创建操作,将每个操作限制在完全相同的活跃 `Agent` 内,并在该 agent 或服务释放资源时等待后端完全停稳。 + +## 契约 + +- 后端注册一个稳定的 `type`,并返回尚未发布的 `PtyBackendSession`;失败或取消的设置过程必须清理部分资源。若清理失败,则以 `PtyBackendCleanupError` 拒绝,使注册表能在取消之后继续保留该资源。 +- spawn 取消会保留调用方提供的确切中止原因。后端设置完成后,服务资源释放与所有者消失仍分别对应可供机器路由的不同失败。 +- 所有者与服务的资源释放会通过服务所有的信号中止尚未发布的设置,并等待后端结算和回滚后才返回。 +- 如果回滚关闭失败,或后端报告启动清理失败,资源释放生命周期会以拒绝结束,不会声称已经完全停稳。调用方触发的取消仍收到其确切原因;生命周期触发的回滚失败也会拒绝待完成的 spawn。 +- 调用方取消后发生的后端清理失败仍算作所有者活动,直到所有者或服务释放资源并消费、报告该失败,避免生命周期策略把失败的清理误判为完全停稳。 +- `hasOwnerActivity(owner)` 覆盖从尚未发布的设置到最终关闭的全过程,使生命周期策略能精确限制对应所有者,不受发布竞态影响。 +- 成功的 spawn 会发布一个 `PtySessionId`。可选的 `name` 只是所有者本地的显示元数据,绝不代表权限。 +- 一个会话最多接受一个活跃的发送操作。读取和信号操作可以观察该发送;在当前操作结算前,另一项发送会失败。 +- `PtySendResult.waitReason` 与 `sessionStatus` 相互独立。`session_exit` 描述顶层 PTY 进程,而不是任意前台命令。 +- `kill()` 与资源释放只会在后端捕获的进程树完全停稳后完成。清理失败会以拒绝结束,而非声称成功;同时它会清除匹配的后端和注册表限制,使后续关闭能够重试,且不会干扰较新的尝试。 + +该 seam 不包含 `node-pty`、沙箱、工具 schema、提示词、任务或终端渲染策略。实现负责终端机制;消费方负责模型呈现和可选的后台任务注册。 + +## 模型体验 + +### 间接消费方 + +#### 模型看到的内容 + +没有直接可见内容。此包不注册提示词或工具;可见 schema 和结果文本由 `@deepseek-ai/dsh-tool-pty` 负责。 + +#### Token 影响 + +没有直接影响。活跃会话状态会保留在进程本地,直到消费方返回有界结果。 + +#### KV Cache 影响 + +不会直接失效;由具名消费方负责请求前缀变更。 + +## 已知限制与暂缓工作 + +- 会话只存在于进程本地,harness 重启后不会恢复。 +- 系统有意不支持跨 agent 共享;未来的共享会话设计需要独立的权限契约。 diff --git a/packages/pty/tool-pty/README.i18n.yaml b/packages/pty/tool-pty/README.i18n.yaml new file mode 100644 index 0000000000..ea1d0cc895 --- /dev/null +++ b/packages/pty/tool-pty/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 417cf7bd0e7881f6ffefc47f5200164464b516f6 +README.zh.md: cdce3c25a6364676a6cd69c30dd877cbc8091778 diff --git a/packages/pty/tool-pty/README.md b/packages/pty/tool-pty/README.md index f4f1e7af7e..417cf7bd0e 100644 --- a/packages/pty/tool-pty/README.md +++ b/packages/pty/tool-pty/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-tool-pty +English | [中文](README.zh.md) + Six model-facing tools over `ctx.pty`: `terminal_open`, `terminal_send`, `terminal_read`, `terminal_signal`, `terminal_close`, and `terminal_list`. Every operation requires the exact initiating `Agent`, so a model cannot address another agent's terminal even if it learns the id. `terminal_send(run_in_background: true)` reuses `ctx.tasks`; task preflight and the PTY service's exclusive per-session send reservation occur before the task id is returned, completion is collected with `task_output`, and `task_kill` delivers `SIGINT` to the foreground process group. Foreground sends use terminal call/result cards. Background sends use a generic execute card; open, read, signal, close, and list use generic `execute`, `read`, `execute`, `delete`, and `read` cards respectively. None declares source locations. diff --git a/packages/pty/tool-pty/README.zh.md b/packages/pty/tool-pty/README.zh.md new file mode 100644 index 0000000000..cdce3c25a6 --- /dev/null +++ b/packages/pty/tool-pty/README.zh.md @@ -0,0 +1,71 @@ +# @deepseek-ai/dsh-tool-pty + +[English](README.md) | 中文 + +基于 `ctx.pty` 提供 6 个面向模型的工具:`terminal_open`、`terminal_send`、`terminal_read`、`terminal_signal`、`terminal_close` 和 `terminal_list`。每项操作都要求提供完全相同的发起 `Agent`,因此即使模型获知另一个 agent 的 id,也无法操作其终端。 + +`terminal_send(run_in_background: true)` 会复用 `ctx.tasks`;任务预检和 PTY 服务对每会话发送的独占预留都发生在返回 task id 之前。系统通过 `task_output` 收集完成结果,`task_kill` 则向前台进程组发送 `SIGINT`。前台发送使用终端调用/结果卡片。后台发送使用通用执行卡片;打开、读取、发送信号、关闭和列出操作则分别使用通用 `execute`、`read`、`execute`、`delete` 和 `read` 卡片。所有操作都不声明源位置。 + +## 配置 + +| 键 | 默认值 | 含义 | +|---|---:|---| +| `enableRunInBackground` | `true` | 公开并接受 `run_in_background`;设为 false 时,schema 会省略该字段,并拒绝强行传入未声明的参数 | +| `maxResultBytes` | `262144` | 每个完整终端结果或 PTY 任务输出的 UTF-8 上限(最小值 `64`);在等待、会话、分页、截断和任务状态元数据全部加入后计算 | + +两个值都会在加载时验证。最小结果上限可保证注册表签发的每个会话或 task id 都能出现在创建确认中。结果超过 `maxResultBytes` 时,只要空间允许,渲染会为控制元数据和截断标记预留空间;截断会保留 UTF-8 边界。每个终端定义的最终内容回调都会应用同一个上限,涵盖经过规范化的 pre-execute、around-execute 与 post-execute 策略失败、拒绝、短路、替换或阻止;结构化的多块策略结果保留其形状。 + +## 模型体验 + +### 系统提示词 + +#### 模型看到的内容 + +该插件贡献以下固定指引章节: + +##### 终端指引 + +```markdown +Use a terminal session only when work needs persistent terminal state or interactive stdin; prefer bash/read/write/edit for bounded one-shot operations. Track every terminal session id and close sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited. +``` + +#### Token 影响 + +插件活跃期间,每次请求都会产生少量固定输入成本。 + +#### KV Cache 影响 + +注册范围和指引文本不变时,前缀保持稳定。 + +### 工具 schema + +#### 模型看到的内容 + +6 个生成的 schema 列在 [`dsh-tool-pty` 目录章节](../../../docs/tool-catalog.md#deepseek-aidsh-tool-pty)中。此插件活跃时,请求中会包含它们的固定 schema token;按 agent 范围过滤工具时可能隐藏这些 schema。 + +#### Token 影响 + +工具可见的请求会产生固定的 schema 成本。 + +#### KV Cache 影响 + +工具可见性与定义不变时,前缀保持稳定。 + +### 工具结果与任务上下文 + +#### 模型看到的内容 + +spawn 会返回 id 和有界 MOTD。发送/读取会返回有界终端文本以及就绪/历史标记。后台模式返回通用 task id。所有终端自身或策略产生的单文本结果,在经过规范化的工具或流水线错误、拒绝、短路、替换、阻止与通用任务状态文本之后,都受 `maxResultBytes` 限制。结构化的多块策略结果保留其形状。结果会保留在会话历史中直到压缩;增量任务读取不会重复已经消费的输出。编程调用方会收到带类型的会话快照、有界的提供方读取/发送 DTO、信号与关闭结果,或 `{ kind: "background", taskId }`;Native 渲染会应用上述呈现上限。 + +#### Token 影响 + +终端自身与策略产生的单文本结果随数据变化,并受 `maxResultBytes` 限制;如果策略有意替换为结构化多块内容,则由该策略负责限制内容。每个返回结果都会保留在历史中直到压缩。 + +#### KV Cache 影响 + +仅追加;新结果位于可复用请求前缀之后。 + +## 已知限制与暂缓工作 + +- 不公开具名按键序列、TUI、BEL、调整大小、自动启动或跨 agent 共享 schema。 +- 后台模式同时依赖 `@deepseek-ai/dsh-tasks` 及其面向模型的控制接口。 diff --git a/packages/sandbox/README.i18n.yaml b/packages/sandbox/README.i18n.yaml new file mode 100644 index 0000000000..4e60781469 --- /dev/null +++ b/packages/sandbox/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 4a651f262ee817edf5e9620d1cbf911b2d51b90b +README.zh.md: 63eaef2488a0bd478ec1f7f34cd96085faf63e45 diff --git a/packages/sandbox/README.md b/packages/sandbox/README.md index 1ab5fbcc07..4a651f262e 100644 --- a/packages/sandbox/README.md +++ b/packages/sandbox/README.md @@ -1,5 +1,7 @@ # sandbox/ — process-sandbox capability family +English | [中文](README.zh.md) + The confinement half of the [capability-seam split](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md): an abstract provider interface, platform backends, and the shared policy home. Consumers hand `ctx.sandbox` the exact argv they are about to spawn and spawn the returned (wrapped) argv instead; a complete `SandboxExecutionPolicy` (mode + workspace root) rides each capability call, and its confined subset becomes the provider's `SandboxPolicy`. Different sessions and consumers can therefore confine under different policies at the same instant. All **product** packages. | Package | Role | ctx key | diff --git a/packages/sandbox/README.zh.md b/packages/sandbox/README.zh.md new file mode 100644 index 0000000000..63eaef2488 --- /dev/null +++ b/packages/sandbox/README.zh.md @@ -0,0 +1,15 @@ +# sandbox/:进程沙箱能力家族 + +[English](README.md) | 中文 + +[能力 seam 拆分](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)中负责限制的一半:抽象提供方接口、平台后端和共享策略归属位置。消费方把即将 spawn 的精确 argv 交给 `ctx.sandbox`,改为 spawn 返回的已包装 argv;完整的 `SandboxExecutionPolicy`(模式 + Workspace 根)随每次能力调用传递,其中受限制的子集成为提供方的 `SandboxPolicy`。因此,不同会话与消费方可以同时按不同策略施加限制。这些全是**产品** 包。 + +| 包 | 职责 | ctx key | +|---|---|---| +| `sandbox/` | 抽象进程沙箱 seam(`SandboxProvider` 契约 + 模式/强制执行/策略词汇),加共享 ESCALATION 工具包(`approveEscalation`、严格变宽的阶梯、拒绝/提示标记),以及所有强制执行方言共享的 `writableRoots` 派生 | `ctx.sandbox` | +| `sandbox-local/` | 按平台链选择的本地后端:Linux 使用 `bwrap`,否则使用 `landlock-run` launcher(通过 npm 分发的 [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) 家族,在自身仓库构建发布);darwin 使用 `sandbox-exec`/Seatbelt。多候选链会执行功能探测,唯一候选项直接选择,结论缓存,快速失败 | (注册 `ctx.sandbox`) | +| `sandbox-policy/` | 策略解析器:部署回退值,加每个会话的持久模式与不可变 cwd 根。两个强制执行家族都消费完整的逐调用结果,因此 bash 与 fs 不会限制到不同根目录 | `ctx.sandboxPolicy` | + +该 seam 只限制与宿主共享文件系统和内核的子进程。容器、microVM 和远程执行器都不是这里的后端:它们会以环境一致的分组替换整个能力实现(`ctx.bash`、`ctx.fs`);边界记录在[沙箱 Agent Note](../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) 中。 + +当前消费方:[`bash/bash-sandbox`](../bash/bash-sandbox/)(包装 `['bash', '-c', command]` 并通过 `ctx.sandbox` 执行)和 [`fs/fs-sandbox`](../fs/fs-sandbox/)(进程内路径隔离,而非 argv 包装层;读取 `ctx.sandboxPolicy`,对写入/编辑强制执行共享模式)。跨家族边界是沙箱 Agent Note 的[跨家族 fs 沙箱](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)阶段;共享词汇使两个家族可以向模型传授同一种拒绝标记与升权流程。 diff --git a/packages/sandbox/sandbox-local/README.i18n.yaml b/packages/sandbox/sandbox-local/README.i18n.yaml new file mode 100644 index 0000000000..e07ea66030 --- /dev/null +++ b/packages/sandbox/sandbox-local/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 923d983be8c2ccd60ed6eabcf9212dd89ef9bce3 +README.zh.md: 9c5a79ed8df36281932959af7b0ad0b801dbaa55 diff --git a/packages/sandbox/sandbox-local/README.md b/packages/sandbox/sandbox-local/README.md index ed85802cf7..923d983be8 100644 --- a/packages/sandbox/sandbox-local/README.md +++ b/packages/sandbox/sandbox-local/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-sandbox-local +English | [中文](README.zh.md) + Local implementation of the [`dsh-sandbox`](../sandbox/) seam. It selects and caches one platform runner: Linux prefers a working `bwrap` then Landlock; macOS uses Seatbelt. Multiple candidates are probed in order, while a sole candidate is selected directly. The package root exports the default and named `LocalSandboxProvider` plugin, `Config`, and its public test-injection seam; platform profile builders stay internal. diff --git a/packages/sandbox/sandbox-local/README.zh.md b/packages/sandbox/sandbox-local/README.zh.md new file mode 100644 index 0000000000..9c5a79ed8d --- /dev/null +++ b/packages/sandbox/sandbox-local/README.zh.md @@ -0,0 +1,40 @@ +# @deepseek-ai/dsh-sandbox-local + +[English](README.md) | 中文 + +[`dsh-sandbox`](../sandbox/) seam 的本地实现。它选择并缓存一个平台 runner:Linux 优先选择可工作的 `bwrap`,否则选择 Landlock;macOS 使用 Seatbelt。多个候选项会按顺序探测,只有一个候选项时则直接选择。 + +包根导出默认及命名的 `LocalSandboxProvider` 插件、`Config` 和公共测试注入 seam;平台 profile builder 保持内部状态。 + +不受支持的平台和不可用 runner 会以 `SANDBOX_UNAVAILABLE` 快速失败;执行绝不会静默回退为不受限制。每次包装都携带 runner 失败签名,使消费方能够区分损坏的沙箱与命令失败。[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)拥有选择原理与 profile 差异。 + +策略逐调用传入;提供方只存储机制与缓存的 runner 结论。每次包装都会报告强制执行完整度,以及后端专用的拒绝和 runner 失败签名。`runnerCommand` 是操作方对 bwrap 形状 runner 的断言,会跳过探测;但命令缺失或不可执行时,执行仍会快速失败。由于其机制未知,它会同时携带两种 Linux 拒绝方言。`probeTimeoutMs` 限制功能探测。[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)拥有选择与失败语义。 + +Seatbelt profile 默认允许,但带 `(deny file-write*)` 和写入 allow-list,因此恰好治理对应模式承诺的文件 effect:`read-only` 只授予 `/dev/null` 字面路径;`workspace-write` 另加 Workspace 根、`/tmp` 和逐用户 darwin 临时目录(`os.tmpdir()`,即平台供 mkstemp 家族工具使用的真实临时区域)。每个根都经过规范化,因为 Seatbelt 匹配解析后的路径(`/tmp` 就是 `/private/tmp`)。Apple 将 `sandbox-exec` CLI 标为 deprecated,但每个 macOS 仍会提供它;若情况发生变化,功能探测会快速失败。 + +[`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run)提供平台 launcher、功能探测和 CLI 参数词汇。该提供方只拥有模式到授权的映射与 runner 选择。把路径解析和探测解析保留在带版本的 binary 中,可防止契约漂移。 + +每个阶梯都有会自行跳过的无密钥 world-effect 测试;CI 在真实内核上运行平台 job,并拒绝所有测试静默跳过。打包安装测试通过纯 Node 消费方运行 registry launcher 与可执行模式。 + +```yaml +- id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' +``` + +消费方:[`@deepseek-ai/dsh-bash-sandbox`](../../bash/bash-sandbox/);可运行的默认组合见 [acp-agent 示例](../../../examples/acp-agent/)。 + +## 模型体验 + +通过 [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md) 和 [`dsh-tool-bash`](../../bash/tool-bash/README.md) 间接影响;它们渲染该提供方的强制执行与拒绝事实,而 [`dsh-sandbox`](../sandbox/README.md) seam 拥有 `SANDBOX_UNAVAILABLE` 文本,runner 选择与 profile 则不进入上下文。 + +#### KV Cache 影响 + +不会直接失效;请求前缀变更由命名消费方负责。 + +## 已知限制与暂缓事项 + +- **Windows 没有 runner**:`win32` 以 `SANDBOX_UNAVAILABLE` 快速失败;AppContainer 家族后端暂缓实现。 +- **Landlock 可能只实现部分强制执行**:较旧且受支持的内核 ABI 只能限制自身公开的访问类别,因此报告 `enforcement: 'partial'`,不会夸大为完整强制执行。 +- **Seatbelt 依赖 deprecated 的 `sandbox-exec`**:macOS 仍会提供它,但若 Apple 移除该私有策略引擎,该提供方无法替换或探测。 +- **runner 选择在提供方生命周期内缓存**:安装、移除或修复 runner 后,必须重载插件才能改变选择。 +- **`runnerCommand` 是操作方断言**:配置的自定义 runner 会跳过功能探测,并假定它诚实实现 bwrap 形状 profile。 diff --git a/packages/sandbox/sandbox-policy/README.i18n.yaml b/packages/sandbox/sandbox-policy/README.i18n.yaml new file mode 100644 index 0000000000..a098b0ba0d --- /dev/null +++ b/packages/sandbox/sandbox-policy/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: e01db9f618fcc8ad139c7b7aaa3b942150df3194 +README.zh.md: 2341850cefadff1bba8d0f738320028e00ea9ae5 diff --git a/packages/sandbox/sandbox-policy/README.md b/packages/sandbox/sandbox-policy/README.md index bb43a555d6..e01db9f618 100644 --- a/packages/sandbox/sandbox-policy/README.md +++ b/packages/sandbox/sandbox-policy/README.md @@ -1,5 +1,7 @@ # dsh-sandbox-policy — the sandbox policy home (`ctx.sandboxPolicy`) +English | [中文](README.zh.md) + The single owner of sandbox-policy resolution: the deployment's default [`SandboxMode`](../sandbox/README.md) and fallback root, plus each session's durable mode override and immutable workspace root. Every enforcing capability family receives one resolved mode-and-root policy per call. ## Why a shared home diff --git a/packages/sandbox/sandbox-policy/README.zh.md b/packages/sandbox/sandbox-policy/README.zh.md new file mode 100644 index 0000000000..2341850cef --- /dev/null +++ b/packages/sandbox/sandbox-policy/README.zh.md @@ -0,0 +1,41 @@ +# dsh-sandbox-policy:沙箱策略归属位置(`ctx.sandboxPolicy`) + +[English](README.md) | 中文 + +沙箱策略解析的唯一 owner:部署默认 [`SandboxMode`](../sandbox/README.md) 与回退根目录,加上每个会话的持久模式覆盖和不可变 Workspace 根。每个执行强制限制的能力家族在每次调用时收到一项解析完成的模式与根策略。 + +## 为何需要共享归属位置 + +两个家族强制执行同一套模式词汇:沙箱化 bash 执行器(`@deepseek-ai/dsh-bash-sandbox`)与沙箱化文件系统提供方(`@deepseek-ai/dsh-fs-sandbox`)。如果两者各自解析 `mode` + `workspaceRoot`,就可能漂移成分裂世界:bash 限制在一个根目录,fs 却隔离另一个根目录,正是[沙箱 RFC](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)所警告的情况。两个工具层都通过 `ctx.sandboxPolicy` 解析策略,两个执行后端也都消费完整的逐调用结果。[跨家族 fs 沙箱 RFC](../../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)记录了共享策略决策。 + +## 配置 + +- `mode`:部署默认 `SandboxMode`(`read-only`/`workspace-write`/`danger-full-access`),加载时验证。默认为 `read-only`(故障安全)。 +- `workspaceRoot`:agentless 调用或没有 cwd 的会话在 `workspace-write` 下可写入的回退目录。默认为 `process.cwd()`,两种情况下都会解析为其绝对文件系统标识。普通 agent 调用改用其会话头中不可变的 `cwd`。 + +## 表层 + +- `ctx.sandboxPolicy.resolve({ session?, mode? })`:解析一项完整的逐调用策略。显式批准的模式优先于会话最后一条 `sandbox/mode` 事件,后者又优先于 `defaultMode`;会话不可变的 `cwd` 会先按文件系统语义规范化,再成为 `workspaceRoot`,否则使用配置的回退值。规范化先于词法归一化,因此 `symlink/..` 与进程工作目录解析保持一致。 +- `ctx.sandboxPolicy.defaultMode`/`ctx.sandboxPolicy.workspaceRoot`:`resolve()` 使用的部署默认值与回退根。 +- `effectiveSandboxMode(events)`:会话 `sandbox/mode` 事件的纯 fold(最后一次切换胜出,没有则为 `undefined`),在 `resolve()` 内使用。 +- `setSandboxMode(session, mode)`:逐会话覆盖的唯一写入路径:恰好追加一条 `sandbox/mode` 事件。切换本身就是事件;不会在带外修改模式。 +- `SANDBOX_MODES`:所有模式,用于选项展示与运行时验证。 + +可选的 `./invariant` 配套组件会拒绝伪造的持久 `sandbox/mode` 事件,只要其值不在该封闭词汇中;Session 与其配套组件拥有周围的存储与轮次封闭规则。 + +## 逐会话 store + +运行时切换是在对应会话日志中追加的一条 `sandbox/mode` 事件。`effective = explicit grant ?? fold(events) ?? deployment default`,因此覆盖会通过回放跨重启保留,两个会话也绝不会看到彼此状态。Workspace 标识无需另一条事件:创建时记录的不可变 `SessionHeader.cwd` 是该会话每次调用使用的根。该事件只进入日志(沿用 `approval/*` 先例):模型通过强制执行工具的拒绝标记获知模式,绝不会从事件获知。 + +## 模型体验 + +通过 `dsh-tool-bash` 和 `dsh-tool-fs` 间接影响;它们会在 `[sandbox: …]` 拒绝标记和升权提示词中渲染该服务持有的有效模式,`sandbox/mode` 事件本身绝不会到达模型。 + +#### KV Cache 影响 + +不会直接失效;请求前缀变更由命名消费方负责,且提示词有意不包含模式。 + +## 已知限制与暂缓事项 + +- **每个会话只有一个主要 Workspace 根**:策略解析 `SessionHeader.cwd`;额外可写根不属于 `SandboxExecutionPolicy`。 +- **只有文件 effect 模式**:`SandboxMode` 治理文件 effect;网络和进程策略不在其词汇中,因此这里没有限制它们的旋钮。 diff --git a/packages/sandbox/sandbox/README.i18n.yaml b/packages/sandbox/sandbox/README.i18n.yaml new file mode 100644 index 0000000000..6619765ed7 --- /dev/null +++ b/packages/sandbox/sandbox/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 99f0641560937f66df6db76ae55c90595329792f +README.zh.md: 402c24135359137b5acde10e11aa676c2460584a diff --git a/packages/sandbox/sandbox/README.md b/packages/sandbox/sandbox/README.md index 2b2d6e8df7..99f0641560 100644 --- a/packages/sandbox/sandbox/README.md +++ b/packages/sandbox/sandbox/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-sandbox +English | [中文](README.zh.md) + Abstract process-sandbox seam. Owns the `ctx.sandbox` service contract ([`SandboxProvider`](src/index.ts)) and the confinement vocabulary the harness shares: `SandboxMode` (`read-only` / `workspace-write` / `danger-full-access`, file effects only), `SandboxEnforcement` (`full` / `partial`, per kernel ABI), `SandboxExecutionPolicy` (the complete per-call mode + workspace root), `SandboxPolicy` (its confined subset), and the fail-closed `SANDBOX_UNAVAILABLE` error. Interface package of the [capability-seam split](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md): depends only on cordis (+ the harness error base), never on a backend. The contract in one line: `ctx.sandbox.confine(argv, policy)` returns the argv to spawn INSTEAD of your own — wrapped so the process (and everything it spawns) runs confined — plus two facts about the selected backend: the enforcement completeness it achieves and its denial dialect (`denialSignatures`, the stderr substrings its kernel prints on a denied file effect — what stderr-inferring consumers match instead of a cross-backend union); when no backend is usable it throws rather than passing the argv through unconfined. diff --git a/packages/sandbox/sandbox/README.zh.md b/packages/sandbox/sandbox/README.zh.md new file mode 100644 index 0000000000..402c241353 --- /dev/null +++ b/packages/sandbox/sandbox/README.zh.md @@ -0,0 +1,42 @@ +# @deepseek-ai/dsh-sandbox + +[English](README.md) | 中文 + +抽象进程沙箱 seam。拥有 `ctx.sandbox` 服务契约([`SandboxProvider`](src/index.ts))与 harness 共享的限制词汇:`SandboxMode`(`read-only`/`workspace-write`/`danger-full-access`,仅限文件 effect)、`SandboxEnforcement`(`full`/`partial`,逐内核 ABI)、`SandboxExecutionPolicy`(完整的逐调用模式 + Workspace 根)、`SandboxPolicy`(其中受限制的子集),以及快速失败的 `SANDBOX_UNAVAILABLE` 错误。它是[能力 seam 拆分](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)的接口包:只依赖 cordis(及 harness 错误基类),绝不依赖后端。 + +用一句话概括契约:`ctx.sandbox.confine(argv, policy)` 返回应当取代自有 argv 用于 spawn 的 argv。返回值经过包装,使进程及其 spawn 的一切都在限制下运行;另附所选后端的两个事实:它达到的强制执行完整度,以及拒绝方言(`denialSignatures`,即内核在文件 effect 被拒绝时打印到 stderr 的子字符串;通过 stderr 推断的消费方会匹配这些字符串,而不是跨后端联合)。没有可用后端时,它会抛出异常,绝不会原样传递 argv 使其不受限制地运行。 + +策略随调用传递,而不属于提供方:两个消费方可以同时按不同策略施加限制(bash 使用 `read-only`,而受限制子 agent 保持其状态目录可写);获批的升权重试只是使用更宽策略发起的新调用。 + +**只支持与宿主共享文件系统和内核的限制。** 后端与宿主共享文件系统和内核(`bwrap`、Landlock、Seatbelt);`workspaceRoot` 指向文件系统规范化后的真实主机目录。系统先解析 Workspace 标识,再做词法规范化,因此包含 `symlink/..` 的有效 cwd 会授权 `chdir` 实际到达的目录,而非无关的词法父目录。容器、microVM 与远程执行器都不是该 seam 的后端:它们会以环境一致的分组替换整个能力实现(`ctx.bash`、`ctx.fs`)。边界及其原理见[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。 + +实现:[`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/)(Linux:`bwrap`,否则使用逐平台 Landlock launcher;macOS:`sandbox-exec`/Seatbelt)。消费方:[`@deepseek-ai/dsh-bash-sandbox`](../../bash/bash-sandbox/)(包装 `['bash', '-c', command]`)。 + +## 模型体验 + +### 间接的限制错误 + +#### 模型看到的内容 + +通过 [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md) 和 [`dsh-tool-bash`](../../bash/tool-bash/README.md),无法强制执行所请求模式时会生成 code `SANDBOX_UNAVAILABLE` 及以下精确错误。执行期 runner 失败会追加 ` Runner failure: <detail>`。 + +##### 精确错误 + +```markdown +sandbox mode "<mode>" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS) — Windows has no confinement backend yet — or switch the consumer to danger-full-access. +``` + +#### Token 影响 + +条件性错误文本对该次调用可见,并保留在历史中直到压缩。 + +#### KV Cache 影响 + +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 配置项失效。 + +## 已知限制与暂缓事项 + +- **文件 effect 是完整的策略词汇**:该 seam 不表达网络、进程、syscall、设备或 credential 限制。 +- **只支持与宿主共享文件系统和内核的限制**:容器、microVM 与远程执行需要替换能力实现,而不是在此处增加提供方。 +- **拒绝报告是一种 stderr 方言**:该 seam 返回后端签名,而非类型化运行时拒绝通道,因此需要分类的消费方必须从子进程输出推断。 +- **每个上下文只有一个提供方**:同时组合不同沙箱机制需要提供方级阶梯或独立 Cordis 上下文;调用方逐调用选择策略,而非后端标识。 diff --git a/packages/sdk/README.i18n.yaml b/packages/sdk/README.i18n.yaml new file mode 100644 index 0000000000..bee258c478 --- /dev/null +++ b/packages/sdk/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 53995820a575d68bbd3322f82e21fa6d3456b38e +README.zh.md: d3481cab032a9bede9b85ce0f4010566592befbd diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 9bd32b4017..53995820a5 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -1,5 +1,7 @@ # SDK packages +English | [中文](README.zh.md) + Developer tooling for creating, editing, building, and running DeepSeek Harness projects. The [feature Agent Note](../../.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.md) owns the developer workflow; the [architecture Agent Note](../../.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.md) owns the package and project-editing boundaries. diff --git a/packages/sdk/README.zh.md b/packages/sdk/README.zh.md new file mode 100644 index 0000000000..d3481cab03 --- /dev/null +++ b/packages/sdk/README.zh.md @@ -0,0 +1,17 @@ +# SDK 包 + +[English](README.md) | 中文 + +用于创建、编辑、构建和运行 DeepSeek Harness 项目的开发者工具。 + +[功能 Agent Note](../../.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.md)负责开发者工作流;[架构 Agent Note](../../.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.md)负责包与项目编辑边界。 + +| 包 | 职责 | +|---|---| +| [`helper`](helper/README.md) | 项目聚合、编辑会话、内置功能、项目文档、模板、包管理器与提示词抽象 | +| [`scripts`](scripts/README.md) | `dsh-sdk` 启动器:`start`、`dev`、`build` 和交互式 `config` | +| [`create-sdk`](create-sdk/README.md) | `npm create @deepseek-ai/sdk` 初始化器 | + +`@deepseek-ai/create-sdk` 是仓库 `@deepseek-ai/dsh-*` 命名规则的唯一例外:npm 的 scoped initializer 约定要求使用该名称,才能支持 `npm create @deepseek-ai/sdk`。 + +生成的项目始终以 `cordis.yml` 作为唯一运行时插件树。`dsh-sdk dev` 只是在同一文件周围增加 TypeScript 与本地工作区解析,不会创建仅供开发环境使用的配置。 diff --git a/packages/sdk/create-sdk/README.i18n.yaml b/packages/sdk/create-sdk/README.i18n.yaml new file mode 100644 index 0000000000..9fe9c61cb9 --- /dev/null +++ b/packages/sdk/create-sdk/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: aa09236832a50abdcd2b158e0561db26bce19cf6 +README.zh.md: 7900f243f944b61cdf114bf63ec7fd5c177806d7 diff --git a/packages/sdk/create-sdk/README.md b/packages/sdk/create-sdk/README.md index c0d1f5d026..aa09236832 100644 --- a/packages/sdk/create-sdk/README.md +++ b/packages/sdk/create-sdk/README.md @@ -1,5 +1,7 @@ # `@deepseek-ai/create-sdk` +English | [中文](README.zh.md) + Interactive initializer for `npm create @deepseek-ai/sdk [directory]`. Directory/name/description have visible editable defaults. A tree picker selects features and configures finite options with Right/Left navigation; secret text follows only for selected options. Local plugin creation is one none/plugin/tool choice. The supported package surface is the `create-sdk` bin. The package root exports no symbols, and workflow, bin, source, and package-manifest subpaths are not exported. diff --git a/packages/sdk/create-sdk/README.zh.md b/packages/sdk/create-sdk/README.zh.md new file mode 100644 index 0000000000..7900f243f9 --- /dev/null +++ b/packages/sdk/create-sdk/README.zh.md @@ -0,0 +1,25 @@ +# `@deepseek-ai/create-sdk` + +[English](README.md) | 中文 + +用于 `npm create @deepseek-ai/sdk [directory]` 的交互式初始化器。目录/名称/描述都提供可见且可编辑的默认值。树形选择器用于选择功能;可选项通过 Right/Left 导航配置,只有选中相应选项后才会询问密钥文本。本地插件创建提供 none/plugin/tool 三选一。 + +受支持的包接口是 `create-sdk` bin。包根不导出任何符号,也不导出 workflow、bin、source 或 package-manifest 子路径。 + +初始化器拒绝任何已经存在的目标路径,创建一个 `SdkProject` 编辑会话,验证并提交该会话,然后询问是否安装 NPM 依赖并构建。安装或构建失败时会保留生成的项目,并打印重试命令。 + +公开标志包括 `[directory]`、`--description`、`--provider`、`--base-url`、`--api-key`、`--model`、`--interface`、`--pm`、`--install`/`--no-install`,以及无头模式标志 `--config <path>`/`--config-json <json>` 和 `--json`。交互式标志会预填对应问题;无头 spec(`--config`/`--config-json`)会预先提供所有答案和功能方案,因此创建过程无需 TTY,并通过 `HeadlessPromptPort` 驱动;若缺少任何必填答案,该端口会明确失败。`--json` 会发送 NDJSON 生命周期事件(`done`/`action-required`/`error`),使 agent(智能体)能够补充其中点名的缺失输入并重新运行。 + +提供方可以选择 DeepSeek,也可以选择由 `llm-pi-ai` 支持的自定义端点。选择 DeepSeek 时只询问 API key,并使用公共端点与 `deepseek-v4-flash`;自定义端点还会询问 base URL。密钥为空时必须确认;系统会创建包含注释和空 `.env` 变量的文件,使提供方在填写变量前启动时明确失败。现有插件的默认值会被省略;必填 SDK 预设仍按所属包的 Config 保持类型约束。 + +## 模型体验 + +通过生成的项目组合及其所选运行时插件间接提供;此外,无头 `--config-json` + `--json` 接口允许 agent 端到端创建项目,并响应 `action-required` 事件。 + +#### KV Cache 影响 + +不会直接失效;由具名消费方负责请求前缀变更。 + +## 已知限制与暂缓工作 + +- **无头本地插件**:无头 spec 会提供项目答案和功能方案;目前还不能在 spec 中表达本地插件脚手架(交互式 none/plugin/tool 选择),默认使用 none。 diff --git a/packages/sdk/helper/README.i18n.yaml b/packages/sdk/helper/README.i18n.yaml new file mode 100644 index 0000000000..4e25e5c082 --- /dev/null +++ b/packages/sdk/helper/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 8c6ed9e87be0a97af67849793edb7fa30ffb33ab +README.zh.md: 6798962acd4f282068394cae9bc40aace2288879 diff --git a/packages/sdk/helper/README.md b/packages/sdk/helper/README.md index bfe5d31d3f..8c6ed9e87b 100644 --- a/packages/sdk/helper/README.md +++ b/packages/sdk/helper/README.md @@ -1,5 +1,7 @@ # `@deepseek-ai/dsh-helper` +English | [中文](README.zh.md) + Shared project domain and infrastructure for `create-sdk` and `dsh-sdk config`. `SdkProject` is a read-only snapshot; `ProjectEditSession` is the only mutation and commit boundary. The [SDK architecture Agent Note](../../../.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.md) owns the rationale. The package owns the builtin typed-spec catalog, provider/app behavior entities, structured project file objects, helper-owned project templates, the shared typed `TextTemplate` renderer, package-manager strategies, local-plugin blueprints, typed questions, and the clack prompt adapter. It never boots a Cordis application. diff --git a/packages/sdk/helper/README.zh.md b/packages/sdk/helper/README.zh.md new file mode 100644 index 0000000000..6798962acd --- /dev/null +++ b/packages/sdk/helper/README.zh.md @@ -0,0 +1,29 @@ +# `@deepseek-ai/dsh-helper` + +[English](README.md) | 中文 + +供 `create-sdk` 与 `dsh-sdk config` 共用的项目领域和基础设施。`SdkProject` 是只读快照;`ProjectEditSession` 是唯一的变更与提交边界。设计理由由 [SDK 架构 Agent Note](../../../.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.md)负责。 + +该包负责内置的类型化 spec 目录、提供方/应用行为实体、结构化项目文件对象、helper 自有项目模板、共享的类型化 `TextTemplate` 渲染器、包管理器策略、本地插件蓝图、类型化问题,以及 clack 提示词适配器。它绝不会启动 Cordis 应用。 + +所有业务验证与文档验证都会在提交写入任何受影响文件前完成。提交会检测编辑会话打开后发生的外部修改,但在开始写入后,有意不提供跨文件回滚。 + +内置功能包括 provider、bash、app、persistence、HMR、filesystem、todo、skill、web、subagent、workflow、compaction、hooks、repeat-tool guard、timeout policy 和 ask-user。目录负责功能选项、必填和非默认 Cordis 插件配置、功能依赖、资源贡献与往返标记;create 与 config 使用同一注册表和配置器。ACP 应用选项只贡献自动化桥;交互式服务属于 TUI 或 Web 组合。 + +`SdkProject.open()` 只要求根目录下的 `package.json` 和 `cordis.yml` 可读。Cordis 配置项用于锚定功能安装;如果某个包只存在于链接的 NPM 依赖闭包中,则该功能仍视为不存在。一旦所属的 Cordis 配置项存在,资源形状不完整就是 `inconsistent`,无法自动修改。 + +`.env.example` 跟随当前所选功能。`.env` 仅追加:helper 可以补充缺失且名称不同的变量,但绝不会更新或删除现有内容。 + +包根明确只导出 `create-sdk` 和 `dsh-scripts` 使用的对象;内部模块不提供 `src/*` 或 package-manifest 子路径导出。 + +## 模型体验 + +无。项目领域只编辑文件,绝不会挂载活跃 agent 或模型请求。 + +#### KV Cache 影响 + +无;此包既不组装也不发送提供方请求。 + +## 已知限制与暂缓工作 + +- **提交不具备跨文件事务性**:每次写入前都会检测外部修改,但后续失败不会回滚已经写入的文件。 diff --git a/packages/sdk/scripts/README.i18n.yaml b/packages/sdk/scripts/README.i18n.yaml new file mode 100644 index 0000000000..651cd50e81 --- /dev/null +++ b/packages/sdk/scripts/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 9a696bf5a4de9a80f0741f07a7e753733bc2f998 +README.zh.md: 85c2e52c7bca0a9b7c40ebd6ede7c40f113168de diff --git a/packages/sdk/scripts/README.md b/packages/sdk/scripts/README.md index 13c46b47ad..9a696bf5a4 100644 --- a/packages/sdk/scripts/README.md +++ b/packages/sdk/scripts/README.md @@ -1,5 +1,7 @@ # `@deepseek-ai/dsh-scripts` +English | [中文](README.zh.md) + The `dsh-sdk` launcher owns SDK project startup and configuration. | Command | Behavior | diff --git a/packages/sdk/scripts/README.zh.md b/packages/sdk/scripts/README.zh.md new file mode 100644 index 0000000000..85c2e52c7b --- /dev/null +++ b/packages/sdk/scripts/README.zh.md @@ -0,0 +1,37 @@ +# `@deepseek-ai/dsh-scripts` + +[English](README.md) | 中文 + +`dsh-sdk` 启动器负责 SDK 项目启动与配置。 + +| 命令 | 行为 | +|---|---| +| `dsh-sdk start [target] [-- args…]` | 导入模块目标并调用 `main(bootContext)`;省略目标时启动 `cordis.yml`;`--` 后的参数原样转发 | +| `dsh-sdk dev [target] [-- args…]` | 注册 TypeScript 与本地工作区源代码解析,然后进入 start 路径 | +| `dsh-sdk build [args…]` | 使用项目参数调用项目已安装的 tsdown | +| `dsh-sdk config` | 打开一个交互式编辑会话,审阅累计变更,统一提交一次;NPM 依赖变化时只安装一次 | +| `dsh-sdk create <source>` | 从原生包管理器来源(`pkg@version` 或 `github:owner/repo#ref`)添加外部 Cordis 插件:确认后执行 `<pm> add <source>`,再将解析出的依赖挂载到 `cordis.yml`。不使用 giget/pacote;由包管理器解析并固定来源(GitHub 依赖会在管理器策略下通过自身 `prepare` 构建) | + +`ProjectBuild(tsdownConfig)` 与 `PluginBuild(tsdownConfig)` 只从 `@deepseek-ai/dsh-scripts/dev/tsdown-config` 导出。开发环境与生产环境读取同一个 `cordis.yml`。 + +生成项目的脚本通过 `dsh-sdk` 执行 dev、build、start 和 config;类型检查直接运行 `tsc -b`。HMR 始终是显式的 `cordis.yml` 功能,并由 dev 与 start 同时加载。 + +运行时库导出 `startSDK(source)`,用于加载 `.env` 和 `cordis.yml` 并返回活跃上下文;还导出 `runSDK(target)`,用于导入项目模块并调用其 `main(bootContext)`(不带目标的 `runSDK()` 会委派给 `startSDK('./cordis.yml')`)。`SdkBootContext` 携带原样转发的 `argv`、通用 `args`、启动器的绝对 `cwd`,以及 `start`/`dev` 模式。启动器不声明项目选项:Node `parseArgs()` 使用空 schema 运行,因此带值的标志写作 `--key=value`,裸标志变为布尔值,`--no-cache` 变为 `args.cache = false`,选项名称保留 Node 的拼写(`--max-depth=3` → `args['max-depth']`)。 + +`start` 绝不构建。`dev` 注册项目已安装的 tsx 转换,并建立从 `plugins/*/package.json` 中的精确包名到各自 `src/index.ts` 的映射,然后沿用相同的 start 路径。`build` 调用项目已安装的 tsdown 并转发其参数;缺少 tsdown 配置时视为成功且不执行操作。 + +`config` 要求 TTY。一个功能树用于选择期望的启用集合;变更行会高亮,Right 用于修改有限功能选项,必填行无法取消选择,不一致行会显示诊断,自定义/手动 Cordis 配置项支持启用/禁用。工作流会将该目标协调到一个编辑会话中。Review & Apply 只提交一次;之后,如果 NPM 依赖有变更,则触发一次包管理器安装。安装失败不会撤销已提交文件。 + +根库导出 `startSDK`、`runSDK` 以及 `SdkBootArgs`/`SdkBootContext` 类型;命令组合仍由 bin 私有持有。不导出 `src/*`、bin 或 package-manifest 子路径。 + +## 模型体验 + +通过项目 `cordis.yml` 树间接提供;该树由 `start` 或 `dev` 加载。 + +#### KV Cache 影响 + +不会直接失效;由具名消费方负责请求前缀变更。 + +## 已知限制与暂缓工作 + +- **启动器参数没有 schema**:`start` 和 `dev` 会保留 Node `parseArgs()` 输出,而不会验证项目专用标志。 diff --git a/packages/sdk/telemetry/README.i18n.yaml b/packages/sdk/telemetry/README.i18n.yaml new file mode 100644 index 0000000000..039d5a94cb --- /dev/null +++ b/packages/sdk/telemetry/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 1d33915f36e0af10eedac5f9ab34f2534268a327 +README.zh.md: 3b60040f02ceaf17292bcbd1c046d82bdf86dafe diff --git a/packages/sdk/telemetry/README.md b/packages/sdk/telemetry/README.md index c2966b1f38..1d33915f36 100644 --- a/packages/sdk/telemetry/README.md +++ b/packages/sdk/telemetry/README.md @@ -1,5 +1,7 @@ # `@deepseek-ai/dsh-telemetry` +English | [中文](README.zh.md) + Launcher-side telemetry primitives for the dsh-sdk toolchain. This is a plain library the launcher imports around each command; it is **not** a Cordis plugin, because `build` and first-init `create` never boot Cordis. Wiring the reporter into the launcher command dispatch and adding the telemetry consent feature to the `dsh-helper` catalog live in their owning packages, not here. | Export | Role | diff --git a/packages/sdk/telemetry/README.zh.md b/packages/sdk/telemetry/README.zh.md new file mode 100644 index 0000000000..3b60040f02 --- /dev/null +++ b/packages/sdk/telemetry/README.zh.md @@ -0,0 +1,30 @@ +# `@deepseek-ai/dsh-telemetry` + +[English](README.md) | 中文 + +用于 dsh-sdk 工具链的启动器侧 telemetry 原语。这是启动器在每个命令周围导入的普通库;它**不是** Cordis 插件,因为 `build` 与首次初始化的 `create` 从不启动 Cordis。将 reporter 接入启动器命令分发,并把 telemetry consent 功能加入 `dsh-helper` 目录,属于各自所属包的职责,而不是此包的职责。 + +| 导出 | 职责 | +|---|---| +| `SecretRedactor` | 保守的安全后备:在已解析值(`redactValue`)与原始文本(`redactText`)中,将形似密钥的值(密钥式键名、已知 token 形状、PEM 块、URL 凭据、高熵不透明 token)替换为占位符。绝不删除字段或行。 | +| `ConsentResolver` | 解析项目 `cordis.yml`(绝不启动),读取 telemetry 配置项的启用/禁用状态作为 consent;`DO_NOT_TRACK`/CI 环境会强制彻底退出。 | +| `buildTelemetryPayload` | 组装 `{command, durationMs, success, cordisYmlContent, packageJsonContent}`,对完整的 `cordis.yml` 与 `package.json` 文本运行 redactor。绝不读取 `.env`;发送 `package.json` 的前提是同时存在 `cordis.yml`,因此在非 SDK 目录运行的命令不会上传该目录中无关的 manifest。 | +| `getOrCreateAnonymousId` | 将随机 UUID 持久化到 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析出的 harness home(`$DSH_HOME` > `~/.dsh`);其范围限定为该 home,而不是整台机器,且绝不从 git 派生。 | +| `TelemetryReporter` | 即发即弃发送:`report()` 绝不阻塞或抛出;所有路径都会结算发送;`flush()` 可以在上限内排空进行中的发送。 | + +Consent 由 `cordis.yml` 中的 telemetry 配置项承载,因此禁用 telemetry 就是禁用该配置项。telemetry 默认上报,只有已经存在的 telemetry 配置项被显式设为 `disabled` 时才关闭:缺少 `cordis.yml`(首次 `create`)、配置项已启用,或 `cordis.yml` 中没有 telemetry 配置项时都会上报。`DO_NOT_TRACK`/CI 始终拒绝。无配置与缺少配置项的默认值可以通过 `ConsentResolver` 配置。 + +收集端点是固定常量(`DSH_TELEMETRY_ENDPOINT`);发布前必须将其 `.invalid` 占位值替换为真实端点。 + +## 模型体验 + +无。reporter 从启动器发送开发周期 telemetry,绝不会进入模型请求。 + +#### KV Cache 影响 + +无;此包既不组装也不发送提供方请求。 + +## 已知限制与暂缓工作 + +- **占位端点**:`DSH_TELEMETRY_ENDPOINT` 指向 `.invalid`,直到配置真实端点。 +- **脱敏依赖启发式规则**:这只是保守后备,不是保证;密钥应存放于 `.env`,而该文件绝不会被读取或上报。 diff --git a/packages/session-persistence/README.i18n.yaml b/packages/session-persistence/README.i18n.yaml new file mode 100644 index 0000000000..842251bd2d --- /dev/null +++ b/packages/session-persistence/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: ac4e0a8310152b9d2ba5daae61fbbf1eb0ed54ec +README.zh.md: cae5cbd83bac5ceed59067217635b98b194949ce diff --git a/packages/session-persistence/README.md b/packages/session-persistence/README.md index d1e4b2286e..ac4e0a8310 100644 --- a/packages/session-persistence/README.md +++ b/packages/session-persistence/README.md @@ -1,5 +1,7 @@ # session-persistence/ — persistence capability family +English | [中文](README.zh.md) + The durable session-persistence seam and its storage backends. The interface package owns the abstract `SessionPersistence` service and the shared write coordinator; the backends are concrete implementations that register on `ctx.sessionPersistence`. All **product** packages. | Package | Role | ctx key | diff --git a/packages/session-persistence/README.zh.md b/packages/session-persistence/README.zh.md new file mode 100644 index 0000000000..cae5cbd83b --- /dev/null +++ b/packages/session-persistence/README.zh.md @@ -0,0 +1,14 @@ +# session-persistence/:持久化功能家族 + +[English](README.md) | 中文 + +持久会话持久化 seam 及其存储后端。接口包负责抽象 `SessionPersistence` 服务和共享写入协调器;后端是注册到 `ctx.sessionPersistence` 的具体实现。全部都是**产品** 包。 + +| 包 | 职责 | ctx 键 | +|---|---|---| +| `session-persistence/` | 持久化 seam + 共享写入协调器 | `ctx.sessionPersistence` | +| `session-checkpoint-policy/` | agent 请求和工具执行的语义持久性屏障 | (包装 `ctx.llm` / `ctx.tools`,监听 agent 事件) | +| `session-persistence-jsonl/` | JSONL sidecar 持久化后端 | (注册 `ctx.sessionPersistence`) | +| `session-persistence-sqlite/` | SQLite 持久化后端 | (注册 `ctx.sessionPersistence`) | + +接口位于 `session-persistence/session-persistence/`;后端是平级同级包。新存储后端在此加入,并注册到 `ctx.sessionPersistence`。详见[会话持久化](../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)。 diff --git a/packages/session-persistence/session-checkpoint-policy/README.i18n.yaml b/packages/session-persistence/session-checkpoint-policy/README.i18n.yaml new file mode 100644 index 0000000000..5e6fddbbe7 --- /dev/null +++ b/packages/session-persistence/session-checkpoint-policy/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 9dfabe4042598bbff9ce4f1bddbcda54f7d19498 +README.zh.md: a2ad538cb4ed9ad2050867178eb239e5adbaeeea diff --git a/packages/session-persistence/session-checkpoint-policy/README.md b/packages/session-persistence/session-checkpoint-policy/README.md index 004c49c5cb..9dfabe4042 100644 --- a/packages/session-persistence/session-checkpoint-policy/README.md +++ b/packages/session-persistence/session-checkpoint-policy/README.md @@ -1,5 +1,7 @@ # dsh-session-checkpoint-policy +English | [中文](README.zh.md) + Semantic durability policy for persisted agents. It checkpoints the event-sourced session before a model adapter receives a request, before a top-level tool body may produce an external side effect, and after a step has recorded its complete assistant message and ordered tool results. The final `turn/end` checkpoint remains owned by `dsh-agent-loop`. ## Plugin (namespace: `session-checkpoint-policy`) diff --git a/packages/session-persistence/session-checkpoint-policy/README.zh.md b/packages/session-persistence/session-checkpoint-policy/README.zh.md new file mode 100644 index 0000000000..a2ad538cb4 --- /dev/null +++ b/packages/session-persistence/session-checkpoint-policy/README.zh.md @@ -0,0 +1,47 @@ +# dsh-session-checkpoint-policy + +[English](README.md) | 中文 + +持久化 agent 的语义持久性策略。它会在模型适配器收到请求前、顶层工具正文可产生外部副作用前,以及步骤已记录完整 assistant 消息和有序工具结果后,为事件溯源会话创建检查点。最终 `turn/end` 检查点仍由 `dsh-agent-loop` 负责。 + +## 插件(命名空间:`session-checkpoint-policy`) + +该零配置函数插件消费 `ctx.sessions`、`ctx.llm`、`ctx.tools` 以及 `ctx.sessionPersistence` 的存在性。将其与一个持久化后端一起加载: + +```yaml +- id: session-persistence + name: '@deepseek-ai/dsh-session-persistence-jsonl' + +- id: session-checkpoints + name: '@deepseek-ai/dsh-session-checkpoint-policy' +``` + +持久化与检查点调度刻意拆分为独立 Cordis 插件。持久化后端使每个已请求 `session/flush` 持久;该策略选择请求、工具分派和已完成步骤检查点。不带此策略加载后端是有效的,仍保留 loop 请求的检查点,包括最终 `turn/end`;但崩溃恢复可能丢失正在进行轮次的其余部分。第一方持久化应用和运行时显式挂载两个插件;专用部署可以刻意省略或替换策略。 + +策略延迟包装 `llm/stream`,因此下游流只会在实时会话缓冲请求事件持久后构造。它在预执行策略和保护后包装 `tools/execute`;只有在已记录调用持久后,顶层工具正文才会运行。如果取消在 flush 等待期间到达,包装层会返回规范 `ABORTED_BEFORE_DISPATCH` 结果,不进入工具正文。嵌套工具分派重用外层模型可见调用的检查点。`agent/post-step` 在继续工作前持久完整响应/结果批次。 + +Loop 在分派 `agent/post-step` 前记录 assistant 消息和有序工具结果,因此策略总能捕获该核心批次。另一个 `agent/post-step` 监听器追加的事件只有在该监听器先于策略注册时才在此检查点捕获;Cordis 注册顺序是这类扩展的显式组合规则。 + +在模型和工具边界,检查点拒绝会快速失败:适配器和顶层工具正文都不运行。步骤后拒绝会在另一个请求开始前使轮次失败。并发工具检查点共享会话存储的串行持久化 drain,无法复制序列号。 + +## 模型体验 + +### 中断调用 + +#### 模型所见 + +插件不添加提示词或工具 schema。工具检查点后、结果前的硬崩溃会留下持久的未匹配调用;会话恢复会提供模型可见的 `TOOL_OUTCOME_UNKNOWN` 结果,该结果由 `dsh-session` 负责。该消息允许重试只读或幂等工作,并要求对可能有副作用的调用验证状态或请求用户确认。 + +#### Token 影响 + +成功检查点不添加 token,也不改变请求。恢复会添加一条短工具结果消息,以平衡中断 transcript。 + +#### KV 缓存影响 + +修复结果追加在可重用前缀之后,因此不会使较早的缓存条目失效。 + +## 已知限制与待完成工作 + +- 该策略持久记录执行意图,而非通用的精确一次副作用。当提供方支持时,有副作用的工具应将 `exec.callId` 作为幂等键转发。 +- 流式 `assistant/chunk` 事件没有每分片检查点。它们在下一个语义检查点到达存储,因此硬崩溃可能丢失当前部分响应。 +- 持久调用没有结果时,无法证明其外部副作用是否完成。因此,恢复会记录未知结果,而不是自动重试。 diff --git a/packages/session-persistence/session-persistence-jsonl/README.i18n.yaml b/packages/session-persistence/session-persistence-jsonl/README.i18n.yaml new file mode 100644 index 0000000000..ecaf2cea28 --- /dev/null +++ b/packages/session-persistence/session-persistence-jsonl/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: a0d718cf8bd0090df0409e7c60e6f7fd559b6f7d +README.zh.md: 307bef8efb506c2df7ef229e85b3224a8e7c29e1 diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index 3fa7e74599..a0d718cf8b 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-session-persistence-jsonl +English | [中文](README.zh.md) + The JSONL durable session-persistence backend — a concrete `SessionPersistence` (the `dsh-session-persistence` seam). Each session has one append-only logical JSONL log, stored as `.jsonl.zstd` by default or raw `.jsonl` when compression is disabled. ## On-disk layout diff --git a/packages/session-persistence/session-persistence-jsonl/README.zh.md b/packages/session-persistence/session-persistence-jsonl/README.zh.md new file mode 100644 index 0000000000..307bef8efb --- /dev/null +++ b/packages/session-persistence/session-persistence-jsonl/README.zh.md @@ -0,0 +1,75 @@ +# @deepseek-ai/dsh-session-persistence-jsonl + +[English](README.md) | 中文 + +JSONL 持久会话持久化后端:一个具体 `SessionPersistence`(`dsh-session-persistence` seam)。每个会话有一个仅追加逻辑 JSONL 日志,默认存储为 `.jsonl.zstd`;禁用压缩时使用原始 `.jsonl`。 + +## 磁盘布局 + +``` +<root>/ + --<normalized-cwd>--/ # readable project directory (or _no-cwd/) + <encoded-id>/ # session-owned directory + session.jsonl.zstd # default: checksummed header frame + append frames + session.jsonl # only with compression: 'none' +``` + +- 第一个逻辑行是不可变的 `SessionHeader`,标记为 `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, delegationDepth }`。`delegationDepth` 在磁盘上必需,顶层会话为 `0`;缺失或无效值会拒绝日志。后续每个逻辑行是一条存储记录;`assistant/chunk` 事件绝不丢弃,且 `seq` 在解码日志中保持连续(`events[i].seq === i`)。 +- 存储记录是原样 `SessionEvent` JSON,或仅在 `packChunks` 下写入的**打包分片行**(`text-chunks` / `reasoning-chunks` / `tool-call-chunks`;像 header 的 `session` 一样不带斜杠,因此行 tag 不会与事件类型混淆):一行保存至少 3 个连续同 block `assistant/chunk` delta 事件,`seq0`/`time0` 和每成员 `dt` 间隔精确重建每个成员的 `seq`/`time`。无损 codec 位于 `@deepseek-ai/dsh-session`(`packChunkRuns`/`decodeStorageRecord`),并使用精确形态 allowlist:任何未识别内容原样存储。读取与布局无关:`load` 始终解码行,因此打包、非打包和混合文件加载结果一致。 +- 项目目录保留规范化 cwd 可读,并限制在文件系统组件上限内。分隔符替换和截断刻意有损,因此规范化相同的 cwd 字符串共享项目目录;会话 id 仍选择不同会话目录。在不区分大小写的文件系统上,只有文件系统规范化将两种写法解析到同一 transcript 时,身份验证才接受备选路径写法。配置根仍由部署控制:可以是项目本地、共享、临时或集中式。[项目会话目录决策](../../../.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md) 记录这项取舍。 +- 会话 id 是未验证的品牌化字符串,因此在使用前单射转义为一个安全路径段(无遍历、无冲突)。结果目录保留给其他会话自有产物;发现只读取固定 transcript 文件名。 + +## 配置 + +| 键 | 类型 | 说明 | +|---|---|---| +| `root` | `string` (required) | 所有会话文件的根目录。**无默认值**:`process.cwd()` 默认值会随进程 cwd 变更(bash 调用、子进程)而分散文件。现有根必须是可读目录;缺失根在第一次实体化时创建。 | +| `packChunks` | `boolean` (default `false`) | 将 delta 分片运行写为打包行(在真实编码会话上测得逻辑日志约小 60%)。关闭时,写入逻辑布局与打包前格式字节相同;无论开关如何,都能读取打包行。快照预期输出仍是每事件一行时默认关闭:开启打包记录会重写每个 fixture `session.jsonl`。 | +| `compression` | `'zstd' \| 'none'` | 默认 `'zstd'`;`'none'` 保留换行分隔 UTF-8 文本。 | + +`locate(meta)` 返回已解析项目/会话目录内固定 transcript 的 `{ kind: 'jsonl', path }`。它不执行文件系统 I/O:可以在目录或文件存在前返回目标,现有文件也只包含最后 flush 前缀。 + +## 物理编码 + +默认产物是独立 [Zstandard frame](../../../.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md) 的标准连接:一个仅包含 header 行的带 checksum frame,后跟每个持久 append 批次一个带 checksum frame。后端使用 Node 内置 Zstandard API 和默认压缩级别,不提供级别开关。列表只读取并验证 header frame。`compression: 'none'` 在原始表示中保留相同逻辑行。 + +一个根只属于一种编码。启动发现和定向查找会拒绝相反 suffix,错误会命名不兼容产物,并指示调用方选择匹配 mode 或独立根。平铺 `<project>/<id>.jsonl*` 产物也会被拒绝,而不是忽略。不提供迁移、混合根回退或双写。 + +## 持久性与崩溃语义 + +- **绑定存储身份。** 查找要求可读项目目录中只有一个匹配会话目录,然后验证 header id 等于请求 id,且 header id/cwd 派生所选 transcript 路径。列表应用同一路径检查,并拒绝重复 id。身份失败发生在修复或 append 前。 +- **延迟实体化。**`create(meta)` 不写入;第一次 `append` 将编码 header 和第一批写入临时文件并执行 `fsync`。POSIX 通过硬链接无覆盖发布,并对父目录 `fsync`。Windows 通过 `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` 无覆盖发布,并通过同一 write-through pattern 创建缺失目录。已创建但从未 append 的会话不留下磁盘内容,不在 `list` 中。 +- **仅追加。** 已 flush 事件绝不重写。后续原始批次 append 行;压缩批次 append 一个 frame。两条路径都执行 `fsync`,并在捕获写入或同步失败时回滚到之前字节长度。 +- **崩溃恢复:保留有效尾部工作。**`load` 验证每个完整压缩 frame,并扫描解压 JSONL。最后 frame 结构不完整时,读取器保留其完整解码记录,从 frame 开头截断,并使用共享[持久化契约](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md) 需要的合成工具、步骤和轮次 closer 重新编码这些记录。原始 mode 从第一个不完整行截断。完整 frame 中的 checksum/解压失败,或最后已提交 `turn/end` 之前或当时的缺陷属于损坏,会被拒绝。 +- **非变更检查。**`inspect()` 返回脱离的有效前缀,不截断不完整尾部或关闭中断轮次,并保持轻量修订不变。 +- **连续 seq。**`append` 拒绝第一个 `seq` 不继续已存储日志的批次,并拒绝非 JSON 可序列化 `event.data`,同时命名违规事件类型。 +- **轻量修订。**`listSnapshots(signal?)` 使用 device、inode、size 和纳秒时间戳标识日志,避免解析完整日志,同时在 append、修复、替换或存储变更后改变。它通过产物发现转发精确信号,并在每个 `stat` 前后检查取消;由于文件系统 `stat` 不可中断,取消会等待活动调用结算,然后在不启动另一次调用的情况下拒绝。 + +## 写入路径 + +插件将冻结会话事件复制到每个实时会话的一个 controller,并启动急切 drain。并发事件共享当前写入;期间接纳的事件形成后续批次,`session/flush` 则等待当前和 pending 批次持久。每会话游标防止恢复会话重新 append 已存储事件,插件加载时会为实时会话播种。所属后端实例串行化单会话操作;dispose 在拆卸前 drain 每个保留 controller。 + +## 模型体验 + +### 恢复的对话历史 + +#### 模型所见 + +JSONL 存储不贡献实时提示词或 schema。加载恢复已存储接口历史,并保留之前的请求 header 用于重建;新 loop 组合当前 envelope。恢复将无持久调用的 assistant 请求平衡为 `TOOL_NOT_STARTED`;有持久调用但无结果时变为 `TOOL_OUTCOME_UNKNOWN`,它要求模型只重试只读或幂等工作,并验证可能副作用或请求用户。原始 `assistant/chunk` 记录不重复消息。 + +#### Token 影响 + +实时请求为零 token。恢复 agent 支付已保留历史和当前 envelope,以及每个中断调用的引用修复结果。 + +#### KV 缓存影响 + +JSONL 存储不修改实时请求前缀。只有重建历史、当前 envelope 和模型路由匹配时,恢复 loop 才能重用提供方缓存;崩溃修复结果仅追加。 + +## 已知限制与待完成工作 + +- **只加载已配置编码和当前 `SESSION_FORMAT_VERSION` (v0)**:更改压缩需要独立/全新根,或选择遗留原始 mode;预发布格式没有迁移。 +- **平铺文件存储布局不加载**:加载前使用独立根,或将预发布产物移入项目/会话目录布局。 +- **压缩文件不能直接按行读取**:使用后端加载;或在写入新根前选择 `compression: 'none'`,以便文本 fixture 或外部行 reader 使用。 +- **不删除会话文件**:日志在 `root` 下累积,直到外部移除(seam 无删除接口)。 +- **每会话一个实时 writer**:append 和修复只在所属后端实例内协调。在 owner 完全停稳 dispose 前,其他后端实例或进程不得写入同一会话;初始同 id 发布仍通过 POSIX 无覆盖硬链接或 Windows 无替换 write-through rename 保持冲突安全。 +- **POSIX 实体化需要硬链接支持**:第一次 append 使用 `link()`,使同 id 竞态失败,而不覆盖已提交日志;Windows 使用无替换 write-through rename。 diff --git a/packages/session-persistence/session-persistence-sqlite/README.i18n.yaml b/packages/session-persistence/session-persistence-sqlite/README.i18n.yaml new file mode 100644 index 0000000000..b232652911 --- /dev/null +++ b/packages/session-persistence/session-persistence-sqlite/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 394b10a70fc757d75f19178050c0d63699a59e54 +README.zh.md: f186d71912e61c5eb664973195ec1d05070a3cef diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index 04091e6e21..394b10a70f 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-session-persistence-sqlite +English | [中文](README.zh.md) + A SQLite durable session-persistence backend — a second `SessionPersistence` implementation ([session persistence](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)), built to validate that the abstract seam and the shared `runPersistenceContract` suite are genuinely backend-agnostic. It satisfies the SAME contract as `dsh-session-persistence-jsonl` (append-only, contiguous-seq, lazy materialization, interrupted-turn close on load), expressed over `node:sqlite` rows instead of file bytes. `locate(meta)` returns `undefined`: all sessions share one database, so there is no honest independent per-session transcript path. diff --git a/packages/session-persistence/session-persistence-sqlite/README.zh.md b/packages/session-persistence/session-persistence-sqlite/README.zh.md new file mode 100644 index 0000000000..f186d71912 --- /dev/null +++ b/packages/session-persistence/session-persistence-sqlite/README.zh.md @@ -0,0 +1,61 @@ +# @deepseek-ai/dsh-session-persistence-sqlite + +[English](README.md) | 中文 + +SQLite 持久会话持久化后端:第二个 `SessionPersistence` 实现(见[会话持久化](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)),用于验证抽象 seam 和共享 `runPersistenceContract` 套件真正与后端无关。它满足与 `dsh-session-persistence-jsonl` 相同的契约(仅追加、连续 seq、延迟实体化、在 load 时关闭中断轮次),但用 `node:sqlite` 行而非文件字节表达。 + +`locate(meta)` 返回 `undefined`:所有会话共享一个数据库,因此不存在真实的独立每会话 transcript 路径。 + +> **TODO:** 该后端直接调用 `node:sqlite`。如果采用 Cordis 数据库服务(`cordis/db` / `@cordisjs` SQL driver 插件),应改为通过该服务路由,而不在此保持原始 `DatabaseSync`;契约接口(`SessionPersistence`)不会变,只更换存储 driver。 + +## 存储模型 + +每个 `SessionEvent` 1:1 映射到 `events` 表中的一行 `(session_id, seq, type, time, data, source_event_seqs, surface_op)`;`data` 是作为 JSON 文本的事件 payload,因此行形态就是原样事件(包括 `assistant/chunk`,保持 `seq` 连续)。两个 `TEXT` 列 `source_event_seqs` 和 `surface_op` 可为空,存储事件可选接口元数据字段(见[会话接口](../../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md))。日志外元数据(`SessionHeader`)、每实体化 incarnation id 和每日志单调修订位于 `sessions` 行;`createdAt` 是存储在 strict `INTEGER` 列中的非负安全整数。单例状态行携带不可变存储 id。`sessions` 行只由第一次 `append` 写入,其存在性是延迟实体化信号(`list` 精确报告有行的会话)。 + +仓库支持的 Node 范围可不加 flag 使用 `node:sqlite`。数据库启用外键,并使用已配置 journal mode(默认 `wal`;WAL 共享内存文件不适用时使用 rollback mode)。`PRAGMA application_id` 标识规范持久化数据库,`PRAGMA user_version` 存储布局版本。新数据库必须没有 application identity 或用户定义 schema 对象;初始化在一个事务中创建全部表并盖上两个 pragma。非 pristine 无版本数据库、外部 application identity 和所有非当前版本在 journal-mode 变更前拒绝,因为该未发布格式无迁移。 + +在具有 POSIX mode 的文件系统上,后端为缺失目录请求 mode `0700`,并在 SQLite 打开前以 mode `0600` 排他创建缺失数据库;进程 umask 可进一步限制两者。新 WAL、共享内存和持久 rollback-journal sidecar 获得数据库最终的仅所有者 mode。现有目录、数据库文件和 sidecar 保留原 mode;除已存在数据库外的文件系统设置错误会使初始化失败。这些默认值防止宽松进程 umask 造成的意外暴露,但当其他 principal 能替换父目录中的数据库条目时,不保护数据库机密性或完整性。 + +## 行上的契约语义 + +- **Append = 事务。**`append` 围绕批次运行 `BEGIN`/`COMMIT`:它实体化 `sessions` 行(如果仍延迟),并 INSERT 每个事件,首先断言连续 seq 契约(第一个事件 `seq` 必须等于已存储 next-seq)。批次中失败(重复 seq 上的 UNIQUE 违规)会完全回滚,使已存储日志和内存游标保持一致。(`load()` 已平衡已存储日志,因此 `append` 不必修复崩溃尾部。) +- **延迟实体化。**`create()` 只在内存记录意图,第一次 `append` 前不写行。从未 append 的会话没有 `sessions` 行,因此不在 `list()` 中(它精确报告有行的会话)。 +- **在 load 时关闭中断轮次。**`load()` 实现共享[崩溃恢复契约](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md):保留有效中断轮次,在一个事务中追加合成关闭事件,并只移除撕裂尾部行。已提交解析错误或序列缺口使会话无法加载。恢复会变更已存储行,因此下一次 append 从平衡日志和准确游标开始。 +- **非变更检查。**`inspect()` 返回脱离的有效行前缀,不删除撕裂尾部行或追加恢复 closer,并保持轻量修订不变。 +- **轻量修订。**`listSnapshots(signal?)` 组合不可变存储与数据库文件身份、每实体化 incarnation id,以及在每个变更事务中递增的每会话计数器。它在不解析事件行的情况下保持未变观察稳定,并区分独立存储和重建的同 id 日志。它在共享就绪和同步元数据查询前后检查取消;查询本身不可抢占。 + +## 配置(schemastery) + +```ts +interface Config { + path: string // SQLite database file path, or ':memory:' for an in-process DB + journalMode?: 'wal' | 'delete' | 'truncate' | 'persist' // journal_mode pragma; default 'wal' +} +``` + +## 写入路径 + +与 JSONL 后端一样,插件将每个冻结 `session/event` 复制到每个实时会话的一个 controller,并启动急切 drain。并发事件共享当前事务;期间接纳的事件形成后续批次,`session/flush` 则等待当前和 pending 批次持久。Controller 对 fork 种子持久一次,保留写入游标,使 resume 绝不重新 append 已存储事件,并在 apply 时为实时会话播种,因为 HMR 不回放 `session/created`。Dispose 在关闭数据库前 drain 每个保留 controller。 + +## 模型体验 + +### 恢复的对话历史 + +#### 模型所见 + +SQLite 存储不贡献实时提示词或 schema。加载恢复与 JSONL 相同的接口历史,并保留之前的 header 用于重建;新 loop 组合当前 envelope。恢复将无持久调用的 assistant 请求平衡为 `TOOL_NOT_STARTED`;有持久调用但无结果时变为 `TOOL_OUTCOME_UNKNOWN`,它要求模型只重试只读或幂等工作,并验证可能副作用或请求用户。行元数据和原始分片不是消息。 + +#### Token 影响 + +实时请求为零 token。Resume 恢复已保留历史并支付当前 envelope,以及每个中断调用的引用修复结果。 + +#### KV 缓存影响 + +SQLite 存储不修改实时请求前缀。只有重建历史、当前 envelope 和模型路由匹配时,恢复 loop 才能重用提供方缓存;崩溃修复结果仅追加。 + +## 已知限制与待完成工作 + +- **`DatabaseSync` 是同步的**:每个 append 事务在整个期间阻塞事件 loop;对本地存储可接受,对繁忙多会话服务器是吞吐上限。 +- **写入争用无等待或重试策略**:后端不设置 busy timeout,也不重试 locked-database 错误,因此其他连接持有写事务时操作立即拒绝。 +- **只打开 pristine 新数据库或当前自有 `SCHEMA_VERSION`**:无版本 schema 对象、外部 application identity 和所有其他 schema 版本被拒绝,而不是迁移(未发布软件,无持久用户数据需要保留)。 +- **不删除已存储会话**:行会累积,直到外部移除(seam 无删除接口;`ON DELETE CASCADE` 已为这种带外清理接线)。 diff --git a/packages/session-persistence/session-persistence/README.i18n.yaml b/packages/session-persistence/session-persistence/README.i18n.yaml new file mode 100644 index 0000000000..37714fddbd --- /dev/null +++ b/packages/session-persistence/session-persistence/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: c99905e8aca0bdaf810de34841ea277b105a9d0f +README.zh.md: 106c28c5f9cd4330cf69b1648b669a04392e4e8a diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md index 199cad10c5..c99905e8ac 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-session-persistence +English | [中文](README.zh.md) + The abstract durable session-persistence seam (`ctx.sessionPersistence`). Defines WHAT a persistence backend does — durably store, reload, and list sessions — without saying HOW. Mirrors the `dsh-bash` capability-seam template ([capability seams](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract service here, a concrete implementation in a sibling package, consumers that inject the interface. The persisted unit IS the existing `SessionEvent` (event-sourced model — the log is the single source of truth), so there is no parallel "persisted message" type. Metadata that is NOT replayable conversation state (format version, cwd, lineage, seed boundary, delegation depth) travels separately as `SessionHeader`, owned by `dsh-session` and re-exported here. diff --git a/packages/session-persistence/session-persistence/README.zh.md b/packages/session-persistence/session-persistence/README.zh.md new file mode 100644 index 0000000000..106c28c5f9 --- /dev/null +++ b/packages/session-persistence/session-persistence/README.zh.md @@ -0,0 +1,83 @@ +# @deepseek-ai/dsh-session-persistence + +[English](README.md) | 中文 + +抽象的持久会话持久化 seam(`ctx.sessionPersistence`)。它定义持久化后端做什么:持久存储、重新加载和列出会话,而不规定如何实现。它与 `dsh-bash` 功能 seam 模板一致(见[功能 seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):本包提供抽象服务,同级包提供具体实现,消费方注入接口。 + +持久化单元就是现有 `SessionEvent`(事件溯源模型:日志是唯一真源),因此不存在并行的「持久消息」类型。不可回放的对话状态元数据(格式版本、cwd、血缘、种子边界、委托深度)作为 `SessionHeader` 单独传输,该类型归 `dsh-session` 所有,并在此重新导出。 + +## 服务 API(`ctx.sessionPersistence`) + +| 方法 | 契约 | +|---|---| +| `locate(meta): SessionLocation \| undefined` | 在不执行 I/O 或实体化的情况下解析绝对的每会话产物目标。没有独立本地产物的后端返回 `undefined`。 | +| `create(meta): Promise<void>` | 注册新会话元数据。可以将物理写入延迟到第一次 `append`(延迟实体化)。 | +| `append(id, events): Promise<void>` | 持久保存一个批次。仅追加;任何修复后,第一个事件 `seq` == 已存储 next-seq;非 JSON 可序列化数据会被拒绝,并命名违规类型。 | +| `load(id): Promise<{ meta; events }>` | 返回已存储 header 和平衡、连续日志。实时 load 先 flush 其快照,并在轮次开放时拒绝;冷 load 保留中断的最终轮次,并用合成 `tool/result`/`step/end?`/`turn/end {interrupted}` 事件关闭它。只丢弃撕裂尾部碎片;已提交损坏和未知 `version` 会被拒绝。 | +| `inspect(id, signal?): Promise<{ meta; events }>` | 返回脱离的有效已存储前缀,不截断撕裂尾部、合成恢复 closer 或发布协调器状态。它与同 id 写入串行化;可选信号会迅速拒绝已排队调用方,阻止该后端读取启动,并取消活动后端读取工作。用于绝不应恢复日志的读模型和其他观察者。 | +| `list(signal?): Promise<SessionHeader[]>` | 从元数据轻量列出,不解析完整日志。可选信号取消后端列表工作。零事件延迟实体化会话不在 `list` 中。 | +| `listSnapshots(signal?): Promise<SessionPersistenceSnapshot[]>` | 返回轻量元数据和不透明品牌化每日志修订,不加载事件日志。日志及其后端存储不变时,修订保持相等;append 或变更性 load 修复后会改变;不会仅因两个存储使用相同本地计数器而冲突。可选信号请求取消后端发现工作;第一方后端在拒绝前结算已启动列表工作,使已等待调用完全停稳。 | + +## 每个后端必须遵守的不变量 + +- **仅追加;崩溃轮次会被关闭,而非截断。** 已 flush 事件绝不重写。崩溃可留下未关闭最终轮次,其事件真实且可能很大;`load` 保留它们,并持久追加合成 closer(为每个未回答 assistant 调用添加按风险分类错误 `tool/result`,再添加 `step/end?`+`turn/end {interrupted}`),以平衡日志,并确保重新载入的历史仍是有效的提供方 transcript。只丢弃从未完整写入的撕裂尾部碎片。 +- **连续 seq。**`load` 拒绝日志中间的 `seq` 缺口/解析错误;`append` 的第一个 `seq` 必须等于已存储 next-seq。 +- **JSON 可序列化数据。**`append` 通过共享单遍无损 JSON 边界实体化每个直接/回放批次。实时 `Session` 事件已深度冻结,但写入协调器仍将每个事件复制到持久化自有缓冲区。 +- **持久性。**`append` 只在批次持久后返回。 + +## 写入协调器 + +`PersistenceCoordinator` 负责每 id 状态和串行化、每个实时会话的一个急切写入 controller、延迟实体化、崩溃尾部修复、会话接管和完全停稳 dispose。第一方后端组合一个协调器,实现小型 `PersistenceBackend` 存储钩子接口,并委托其有状态方法。因此 JSONL 和 SQLite 共享生命周期正确性,同时保留不同存储原语;见[协调器 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md) 和 [flush controller 简化](../../../.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.md)。 + +每个 `session/event` 将事件复制到会话 controller,并在不阻塞生产者的情况下启动急切 drain。并发通知共享当前 drain;写入期间接纳的事件保持 pending,并触发下一批。`session/flush` 是观察屏障,会等待 controller 无当前或 pending 批次。急切失败会记录日志并保留批次;下一次显式 flush 或后端拆卸重试,并向调用方公开失败。 + +崩溃修复只适用于冷状态。对于实时 id,`load(id)` 为权威内存日志制作快照,等待该快照持久,并只在平衡时将其与协调器已存储 header 一起返回;开放实时轮次会被拒绝,而不会收到合成中断 closer。冷 load 在后端读取和修复写入期间保留 id,因此同 id 实时 `Session` 的并发发布会拒绝并回滚。HMR 接管通过 `loadStored` 读取,应用协调器 cwd 检查,并绝不关闭活动轮次。 + +实时会话发出 `session/disposed` 时,协调器等待其 controller,串行化最终 drain,然后释放该精确 `Session` 对象拥有的状态。失败退役会将 controller 保留在实时会话 map 中,使后端拆卸可重试。后端拆卸先停止事件接纳,flush 每个剩余 controller,等待每 id 操作,最后才关闭存储句柄。 + +无副作用 `locate` 和轻量 `listSnapshots` 查询仍由后端负责,因为它们描述存储拓扑和修订身份,而非写入编排。`listSnapshots(signal?)` 将调用方的精确信号传入后端发现,使观察者可在不脱离该工作的情况下取消。 + +`PersistenceBackend<TornMarker>` 钩子(协调器与存储之间的唯一 seam): + +| 钩子 | 职责 | +|---|---| +| `name` | dispose 失败 `AggregateError` 的后端标签。 | +| `loadStored(id, signal?)` | 在全部存储范围中按 id 读取已存储前缀。用于 resume/load、非变更 inspect、实时接管和 create 冲突探测。可选信号属于仅观察读取。返回元数据标识 `id`;当且仅当必须截断撕裂尾部时才存在不透明 `tornMarker`。 | +| `appendBatch(meta, events, isMaterialized)` | 持久追加连续批次;尚未实体化时以原子方式延迟实体化。 | +| `commitRepair(meta, tornMarker, closers)` | 使崩溃修复持久:截断撕裂尾部(当且仅当 `tornMarker !== undefined`;标记可为 falsy,例如 seq/offset `0`),并追加 `closers`。不要求原子性。由 load(截断 + closer)和实时接管(仅截断)使用。 | +| `list(signal?)` | 列出全部已存储元数据,观察可选取消。 | +| `close?()` | 可选生命周期拆卸(例如关闭 db 句柄),在 dispose drain 后等待。 | + +协调器断言已存储 id,并在修复或实时接管前比较已存储/实时 cwd。其 `inspect()` 路径验证并克隆前缀,不调用 `commitRepair` 或发布写入状态。`tornMarker` 完全不透明:协调器只测试 `!== undefined`,并将其原样往返给 `commitRepair`,绝不检查值(JSONL 后端使用待截断字节偏移,SQLite 后端使用待删除 seq)。第三方后端可以不用协调器直接实现抽象服务,但必须提供相同非变更检查和可信轻量快照修订。详见[写入协调器 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md)。 + +## 测试后端 + +导入 `runPersistenceContract`(公开 API,包括稳定/变更敏感的轻量修订),其来源为 `tests/contract.ts`;再导入 `runCoordinatorContract`(共享写入路径编排:接管、HMR、冲突、dispose drain、崩溃尾部修复),其来源为 `tests/coordinator-contract.ts`,并使用后端 fixture 调用两者。每个后端都遵守相同仅追加/连续 seq/延迟实体化/可序列化语义和相同编排,因此后端自身 spec 只需在其上测试存储机制(路径净化、fsync 回滚;schema 版本、事务回滚)。 + +三个后端运行这些套件:内存参考(位于 `tests/`)、`dsh-session-persistence-jsonl`(仅追加文件日志)和 `dsh-session-persistence-sqlite`(`node:sqlite`,每个 `SessionEvent` 是一行 `(session_id, seq, type, time, data, source_event_seqs, surface_op)`)。它们全部通过同一契约 + 协调器套件,证明 seam 真正与后端无关:延迟实体化、load 时崩溃尾部和连续 seq 在文件字节与事务存储上表现相同。 + +## 元数据与位置类型 + +从 `dsh-session` 重新导出:`SessionHeader`(不可变会话元数据:`version`、`id`、`createdAt`、`cwd?`、`parentSession?`、`seedLength?`、`delegationDepth?`)。`SessionLocation` 是 `{ readonly kind: string; readonly path: string }`;其 path 是绝对后端目标,不证明产物已存在或包含未 flush 轮次。 + +## 模型体验 + +### 恢复的对话历史 + +#### 模型所见 + +该 seam 不添加提示词或 schema。Resume 将已存储接口事件恢复为消息历史;已存储请求 header 重建较早调用,新 loop 则为下一次请求组合当前系统提示词、工具和会话前缀。崩溃修复将没有持久调用的 assistant 请求标记为 `TOOL_NOT_STARTED`;有持久调用但无结果时变为 `TOOL_OUTCOME_UNKNOWN`,其文本允许模型重试只读或幂等工作,但要求验证副作用或请求用户,而不是盲目重试。 + +#### Token 影响 + +普通持久化期间为零 token。Resume 恢复已保留历史成本,并正常支付当前请求 envelope;每个已修复调用添加引用的已保留错误文本。 + +#### KV 缓存影响 + +持久化不修改实时请求前缀。只有当重建历史、当前 envelope 和模型路由匹配时,恢复 loop 才能重用提供方缓存;崩溃修复结果仅追加,不重写较早历史。 + +## 已知限制与待完成工作 + +- **无删除或保留接口**:剪枝已存储会话是带外后端维护。 +- **`list()` 无分页且无过滤**:它返回每个已存储会话的 header;适合本地存储,大规模时无索引。 +- **修复时合成 closer 是唯一崩溃方案**:后端必须在 load 时合成 `tool/result`/`step/end`/`turn/end` closer;没有继续中断轮次而不先关闭它的部分轮次 resume。 diff --git a/packages/session-query/README.i18n.yaml b/packages/session-query/README.i18n.yaml new file mode 100644 index 0000000000..aadf733553 --- /dev/null +++ b/packages/session-query/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 38edbf6b0303e3d5a7bd0dc1d180cd127c60f9dc +README.zh.md: 448fae8470e11ebd5a3f7e9d30e1eb82ab995e7b diff --git a/packages/session-query/README.md b/packages/session-query/README.md index 503a9cfed8..38edbf6b03 100644 --- a/packages/session-query/README.md +++ b/packages/session-query/README.md @@ -1,5 +1,7 @@ # session-query/ — session retrieval capability family +English | [中文](README.zh.md) + Trusted exact reads, relationship traces, provider-independent semantic filtering, and SQLite full-text search over live and durable session logs. | Package | Role | ctx key | diff --git a/packages/session-query/README.zh.md b/packages/session-query/README.zh.md new file mode 100644 index 0000000000..448fae8470 --- /dev/null +++ b/packages/session-query/README.zh.md @@ -0,0 +1,13 @@ +# session-query/:会话取回功能家族 + +[English](README.md) | 中文 + +针对实时和持久会话日志提供可信的精确读取、关系跟踪、与提供方无关的语义过滤和 SQLite 全文搜索。 + +| 包 | 职责 | ctx 键 | +|---|---|---| +| [`session-query/`](session-query/README.md) | 组合式服务契约:提供具体的逻辑语料库读取、跟踪和语义过滤,以及抽象全文方法 | `ctx.sessionQuery` | +| [`session-query-sqlite/`](session-query-sqlite/README.md) | 具体服务后端:使用 SQLite FTS5 持久基库和实时覆盖层 | `ctx.sessionQuery` | +| [`tool-session-query/`](tool-session-query/README.md) | 工作区授权的面向模型搜索、血缘、关系和精确事件工具 | 无 | + +查询服务与压缩无关:它读取规范血缘、接口操作、已记录来源信息和语义事件文本,但不参与压缩策略或执行。一个抽象服务组合全部查询操作;一个具体后端负责全文生命周期,无需提供方注册表或协调器;消费方将过大的纯文本结果交给通用执行后 spill 策略。 diff --git a/packages/session-query/session-query-sqlite/README.i18n.yaml b/packages/session-query/session-query-sqlite/README.i18n.yaml new file mode 100644 index 0000000000..9c5f95f8ce --- /dev/null +++ b/packages/session-query/session-query-sqlite/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: ceffb3ac25bc8b5252d6cc40cd6389839dfce1e2 +README.zh.md: 4e11ae9c9b8012045a7f3bab5d5c45724e553303 diff --git a/packages/session-query/session-query-sqlite/README.md b/packages/session-query/session-query-sqlite/README.md index 3d2f2ce16b..ceffb3ac25 100644 --- a/packages/session-query/session-query-sqlite/README.md +++ b/packages/session-query/session-query-sqlite/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-session-query-sqlite +English | [中文](README.zh.md) + Concrete `ctx.sessionQuery` backend. `SessionQuerySqlite` inherits exact reads, traces, and provider-independent filters from the interface package and implements its two full-text methods with SQLite FTS5. Search uses the live-preferred logical session corpus and groups cross-session results by their strongest event. ## Search contract diff --git a/packages/session-query/session-query-sqlite/README.zh.md b/packages/session-query/session-query-sqlite/README.zh.md new file mode 100644 index 0000000000..4e11ae9c9b --- /dev/null +++ b/packages/session-query/session-query-sqlite/README.zh.md @@ -0,0 +1,54 @@ +# @deepseek-ai/dsh-session-query-sqlite + +[English](README.md) | 中文 + +具体 `ctx.sessionQuery` 后端。`SessionQuerySqlite` 从接口包继承精确读取、跟踪和与提供方无关的过滤,并使用 SQLite FTS5 实现其两个全文方法。搜索使用实时优先的逻辑会话语料库,并按其匹配最强的事件对跨会话结果分组。 + +## 搜索契约 + +`searchSessions(request, exec?)` 返回跨语料库的 `SessionSearchHit` 分页结果;`searchEvents(request, exec?)` 返回单个会话内的 `SessionEventSearchHit` 分页结果。查询不得省略,会被修剪并将空白规范化为字面短语。引号、`OR`、`NEAR` 和 `*` 等 FTS5 语法被视为数据,而非可执行 MATCH 语法。元数据过滤器是在排名前应用的参数化 SQL 谓词。为使 SQLite FTS5 MATCH 保持在受支持的外层谓词上下文中,跨会话请求最多可编译 14 个组合会话与事件过滤谓词;会话内请求最多可编译 13 个过滤谓词,因为固定目标会话谓词占用一个槽位。每个范围端点编译为一个谓词。请求超过任一谓词预算,或超过 SQLite 可移植的 32,766 总绑定上限(包括固定查询和分页值)时,会在准备语句前以 `SESSION_QUERY_INVALID_FILTER` 失败。 + +持久表和 TEMP 表之间的相关性可比:先按实际 FTS5 高亮匹配 span 数降序,再按已存储文档码点长度升序。事件时间、适用时的会话 id 和 seq 打破其余平局。跨会话结果将所选事件公开为 `bestMatch`;两种范围都从 FTS5 高亮位置派生空白规范化的纯文本,并按 Unicode 码点限制长度。游标是不透明的品牌化值,绑定到规范化请求和服务实例,并在相关世代变更时失败。会话内游标可在不相关会话变更后延续使用;跨会话游标则不能。 + +默认可搜索全部三种接口(`current`、`shadowed` 和 `log-only`)。传入接口过滤器可缩小范围。 + +## 来源与索引生命周期 + +该服务需要 `ctx.sessions`,并动态观察可选的 `ctx.sessionPersistence`。一个串行化状态机比较来源限定的轻量持久化快照修订,以非变更方式只检查新日志或已更改日志,提取共享语义文档,以事务方式对账变更,然后运行查询。会话查询绝不会调用持久化后端会修复崩溃的 `load()`;检查期间附加的 owner 无法修改其日志,稳定观察重试使结果优先使用实时来源。TEMP 实时行仍会记录持久化可用性,而持久基库会在该实时 owner 脱离后刷新。重复查询和未变的同存储重新打开不会执行完整持久化日志检查;切换存储,或观察到新增、已更改、已删除或经外部 load 修复的来源时,会在下次稳定观察时对账。来源或事务失败不会提交任何内容,下一次搜索会重试。 + +持久化 FTS 行位于专用派生数据库中。连接本地 TEMP 表保存实时行,这些行会遮蔽同一会话的持久化基库,并在实时 owner 消失后使其重新可见。卸载持久化会隐藏持久行,但不会丢弃缓存;重新挂载会对账缓存。关闭或重新打开数据库会删除全部实时覆盖层,但保留持久行。 + +该数据库可丢弃,但 reset 受到保护:每个已识别 schema 版本都会在修改 journal mode 前拒绝未知用户表;只有包含派生表的已识别不兼容 schema 才会原地重建。不相关数据库或规范数据库将被拒绝。绝不能将 `path` 指向 session-persistence 数据库。在具有 POSIX mode 的文件系统上,缺失的目录和数据库会以仅所有者可访问的方式创建(进程 umask 前为 `0700` 和 `0600`),SQLite sidecar 继承数据库 mode;现有 mode 保持不变。每个派生索引路径在一个进程中只能由一个服务拥有;不支持外部写入者或第二个进程,因为世代和 TEMP 遮蔽状态归连接所有。 + +## 配置 + +| 键 | 默认值 | 契约 | +|---|---:|---| +| `path` | required | 专用派生索引 SQLite 路径;支持 `:memory:`。在 POSIX 文件系统上,缺失的文件系统路径会以仅所有者可访问的方式创建。 | +| `journalMode` | `wal` | `wal`、`delete`、`truncate` 或 `persist`。 | +| `defaultLimit` | `20` | 请求省略 `limit` 时的分页大小;最多为 `Number.MAX_SAFE_INTEGER - 1`。 | +| `maxLimit` | `100` | 接受的最大请求分页大小;最多为 `Number.MAX_SAFE_INTEGER - 1`。 | +| `snippetChars` | `240` | 按 Unicode 码点计算的最大 snippet 长度。 | +| `readWindowMax` | `50` | `before` 或 `after` 的最大原始事件数,用于继承的 `readEvent()`。 | +| `persistedInspectConcurrency` | `4` | 继承批量读取的最大并发持久化日志检查数;必须是正安全整数。 | + +## Tokenizer 与限制 + +该索引使用 FTS5 `unicode61`。在实现实验中,它支持双字符查询 `AI`,产生的索引比 trigram 备选方案小约 2.1 倍。取舍是 token/短语召回而非任意子字符串召回:`AI` 不匹配 token `BRAID`。需要执行字面的空白弹性子字符串扫描时,使用 `ctx.sessionQuery.filterEvents()` 并传入 `text` 子句。查询会拒绝 NUL;文档中的保留高亮标记和 NUL 会在索引前被规范化,使展示标记无法与源文本冲突。 + +中止信号会停止已排队工作,并原样流经快照列表和非变更检查。来源工作一旦开始,串行化状态机会自行等待该后端 promise,即使后端忽略取消,之后也会在启动任何其他列表、检查、对账或查询工作前检查信号。因此,调用方只会在已启动后端工作完全停稳后观察到取消,而后续搜索在该清理尚未完成时无法进入 serializer。Node 的同步 `DatabaseSync` API 无法中断已在 JavaScript 线程上执行的元数据或 MATCH 语句;系统会在这些不可抢占调用前后立即检查信号。 + +## 模型体验 + +无。该可信搜索后端只向调用方返回命中,不注册面向模型的提示词、schema、工具或消息。 + +#### KV 缓存影响 + +无;该包既不组装也不发送提供方请求。 + +## 已知限制与待完成工作 + +- **无调用方授权**:这是上下文范围内的可信服务;模型工具或 UI 必须强制执行自己的访问策略。 +- **同步查询执行**:`DatabaseSync` 在 MATCH 执行期间会阻塞 JavaScript 线程,且无法中断已运行的语句。 +- **Token 召回,而非任意子字符串**:`unicode61` tokenizer 不会匹配更大 token 中的子字符串;对字面扫描使用 `filterEvents()`。 +- **单 owner 派生索引**:每个索引路径必须由一个进程中的一个服务拥有;不支持外部写入者和多进程共享。 diff --git a/packages/session-query/session-query/README.i18n.yaml b/packages/session-query/session-query/README.i18n.yaml new file mode 100644 index 0000000000..1db5958d5f --- /dev/null +++ b/packages/session-query/session-query/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: ebc577975f874a1a60c84061f9148282742bfdf2 +README.zh.md: c4d0c27c846bad6b9b621b6db391be1d4ee69fed diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md index 27191c3e5f..ebc577975f 100644 --- a/packages/session-query/session-query/README.md +++ b/packages/session-query/session-query/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-session-query +English | [中文](README.zh.md) + `SessionQueryService` is the combined abstract `ctx.sessionQuery` contract. It implements exact session-history retrieval, relationship tracing, and provider-independent filtering over live `ctx.sessions` plus optional dynamically mounted `ctx.sessionPersistence`; concrete backends implement its two full-text methods. Matching ids produce one record: live events win, while `live` and `persisted` report both source availabilities. Conflicting immutable headers fail with `SESSION_QUERY_SOURCE_CONFLICT`. ## Reads diff --git a/packages/session-query/session-query/README.zh.md b/packages/session-query/session-query/README.zh.md new file mode 100644 index 0000000000..c4d0c27c84 --- /dev/null +++ b/packages/session-query/session-query/README.zh.md @@ -0,0 +1,56 @@ +# @deepseek-ai/dsh-session-query + +[English](README.md) | 中文 + +`SessionQueryService` 是组合式抽象 `ctx.sessionQuery` 契约。它对实时 `ctx.sessions` 和可选的动态挂载 `ctx.sessionPersistence` 实现精确会话历史取回、关系跟踪和与提供方无关的过滤;具体后端实现它的两个全文方法。匹配 id 只产生一条记录:实时事件优先,而 `live` 和 `persisted` 会报告两种来源的可用性。如果不可变 header 存在冲突,则以 `SESSION_QUERY_SOURCE_CONFLICT` 失败。 + +## 读取 + +- `listSessions(signal?)` 读取当前持久化元数据,以实时记录优先的方式合并它们,并按确定性的最新优先顺序返回克隆记录。 +- `readSession(sessionId)` 在执行与恢复相同的核心回放验证后,返回一份完整、脱离存储的原始日志;它绝不会将该会话放入实时存储。 +- `filterSessions(filters, signal?)` 对同一份克隆逻辑语料库应用与提供方无关的会话元数据和可用性谓词。 +- `filterEvents(sessionId, filters)` 提取第一方语义文档,并按 seq 升序应用与提供方无关的元数据和字面文本谓词。 +- `readTitleSnapshots(sessionIds, signal?)` 从一次实时优先的语料库观察中解析唯一 id,将取消传递给持久化列表和检查,并按顺序返回每个会话的结算结果,使某个缺失或格式错误的标题来源不会丢弃其他来源。每个实时来源直接 fold,每个持久化 worker fold 为脱离存储的 header/标题结果,并在出队下一个 id 前释放完整日志。取消会拒绝整个批次。`readTitleSnapshot(sessionId, signal?)` 是单次观察视图;`readTitle(sessionId, signal?)` 只返回其可选的 folded `session/title`。 +- `listEvents(sessionId)` 加载实时优先的原始日志,将每个事件分类为 `current`、`shadowed` 或 `log-only`;该分类使用共享 `dsh-session` 接口 fold。 +- `readSurface(sessionId)` 返回一个克隆 header、原始日志捕获边界,以及按模型历史顺序排列的完整 folded 当前接口。实时会话优先于持久化;压缩只会在其替换追加之前或之后被观察,绝不会出现合成混合。 +- `readEvent(request, signal?)` 返回一个克隆 header、完整目标事件和有界的原始 seq 窗口。`before` 和 `after` 默认为 0,且不得超过 `readWindowMax`。 +- `traceSession(sessionId, signal?)` 只读取一次语料库,返回从直接到向外的祖先,以及确定性的递归后代树。`complete: false` 标识第一个缺失父级;与目标相连的循环会以 `SESSION_QUERY_INVALID_LINEAGE` 失败。 +- `traceEvent(request, signal?)` 只加载一次逻辑日志,返回其克隆源 header、直接位置替换和直接已记录来源信息。`replacementChain` 沿位置替换者跟踪到最终替换;来源链接仍不传递。 + +持久化是可选的,可动态挂载或卸载。已挂载持久化无法读取时,跨语料库列表和血缘跟踪以 `SESSION_QUERY_PERSISTENCE_FAILED` 失败。针对已知实时会话的标题读取、事件跟踪或事件读取不会查询持久化,因此持久后端的健康状态无法使当前内存状态变得不可读。持久化标题和事件操作在加载前列表,并在元数据不匹配时拒绝,而不会组合不一致的观察。血缘跟踪取消传递到持久化列表;事件跟踪和事件读取取消传递到持久化列表和检查。每项操作都会等待已启动的后端调用结算,然后使用信号的精确原因拒绝,即使后端忽略了该信号。预先中止的已知实时标题读取、事件跟踪或事件读取会在 fold 或快照之前拒绝,且不查询持久化。批量标题观察执行一次元数据列表,使用最多 `persistedInspectConcurrency` 个 worker 检查唯一持久化 id,并保留每个标题自己观察到的 header,供下游授权使用。取消不会启动已排队检查,且只在已启动 worker 结算后拒绝。`listSessions()` 仍保持轻量,不加载日志或索引标题。 + +## 过滤与提取 + +`SessionResultFilter` 覆盖 id、可空 cwd、创建时间范围、可空父级和来源可用性。`SessionEventResultFilter` 覆盖 seq/时间范围、事件类型、接口和语义文本。过滤器数组使用 AND;同一列表子句内的值使用 OR。空列表值不匹配任何内容,范围包含端点,而格式错误的范围或封闭联合值以 `SESSION_QUERY_INVALID_FILTER` 失败。 + +文本子句刻意与 FTS 提供方无关:调用方文本会被转义为不区分大小写的 Unicode 正则表达式,每个空白运行匹配一个或多个空白字符。它是字面语义文本扫描,而非全文查询。`extractSessionEventText()` 和 `buildSessionEventSearchDocuments()` 定义共享的第一方文档投影;结构边界、流分片、请求 header 和未知声明合并变体不产生文档。 + +## 全文方法 + +`SessionQueryService.searchSessions(request, exec?)` 按匹配最强的事件对逻辑语料库分组;`searchEvents(request, exec?)` 搜索一个逻辑会话。这两个是服务仅有的抽象方法。两者都返回分页结果,其延续信息是自有的品牌化 `SessionSearchCursor`;接受可选取消,并在不使用提供方专用数值分数的情况下公开 snippet。事件搜索分页结果还携带来自与命中相同索引世代的克隆目标 header,使授权消费方可将策略绑定到 payload 观察。搜索请求只接受元数据事件过滤器,因为字面文本过滤使用上文所述扫描路径。 + +该包没有提供方协调器、回退实现或独立具体插件。具体服务后端继承已实现的读取、过滤和跟踪,同时负责全文观察、对账、排名、游标世代和查询执行;第一个实现是 [`@deepseek-ai/dsh-session-query-sqlite`](../session-query-sqlite/README.md)。 + +`SessionQueryError.code` 是一个封闭联合,覆盖请求验证、缺失目标、格式错误的接口、来源冲突、持久化/索引失败、取消,以及无效或陈旧游标;精确字面值在 [`src/config.ts`](src/config.ts) 中定义。 + +`listEvents()`、`readSurface()` 和 `traceEvent()` 执行同一个单遍 `dsh-session` 接口 fold。只有当事件 seq 从零开始且连续、接口标记符合事件类型资格、来源数组非空且无重复、引用指向较早事件,且每个位置替换都命名并引用它移除的每个接口节点时,加载的日志才有效;任何违规都以 `SESSION_QUERY_INVALID_SURFACE` 失败。 + +## 配置 + +| 键 | 默认值 | 契约 | +|---|---:|---| +| `readWindowMax` | `50` | `before` 或 `after` 的最大原始事件数。 | +| `persistedInspectConcurrency` | `4` | 一次批量读取中的最大并发持久化日志检查数;必须是正安全整数。 | + +## 模型体验 + +无。该可信查询服务只向调用方返回克隆会话记录,不注册面向模型的提示词、schema、工具或消息。 + +#### KV 缓存影响 + +无;该包既不组装也不发送提供方请求。 + +## 已知限制与待完成工作 + +- **无调用方授权**:这是上下文范围内的可信基础设施;未来的模型工具或 UI 必须限制调用方可检查的会话。 +- **无注册表或面向模型工具**:尚未提供提取器和搜索提供方注册表、递归事件来源遍历以及面向模型的工具。[跟踪决策](../../../.agents/notes/implemented/feature/2026-07-13-session-query-tracing.md) 负责关系语义;SQLite 归属和 tokenizer 决策位于[已实现搜索记录](../../../.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md)。 diff --git a/packages/session-query/tool-session-query/README.i18n.yaml b/packages/session-query/tool-session-query/README.i18n.yaml new file mode 100644 index 0000000000..15156a8eeb --- /dev/null +++ b/packages/session-query/tool-session-query/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: d973daf1124c4be05f7335b18661d431d45be39f +README.zh.md: 94cbd4e36c7146c7759b49421cf651abb327b5f4 diff --git a/packages/session-query/tool-session-query/README.md b/packages/session-query/tool-session-query/README.md index ef957765c4..d973daf112 100644 --- a/packages/session-query/tool-session-query/README.md +++ b/packages/session-query/tool-session-query/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-tool-session-query +English | [中文](README.zh.md) + Workspace-authorized model tools over `ctx.sessionQuery`. The opt-in package depends only on the unified interface and registers `session_search`, `session_event_search`, `session_trace`, `session_event_trace`, and `session_event_read`; shipped host compositions do not mount it by default. ## Configuration diff --git a/packages/session-query/tool-session-query/README.zh.md b/packages/session-query/tool-session-query/README.zh.md new file mode 100644 index 0000000000..94cbd4e36c --- /dev/null +++ b/packages/session-query/tool-session-query/README.zh.md @@ -0,0 +1,76 @@ +# @deepseek-ai/dsh-tool-session-query + +[English](README.md) | 中文 + +位于 `ctx.sessionQuery` 之上、经工作区授权的模型工具。该 opt-in 包只依赖统一接口,并注册 `session_search`、`session_event_search`、`session_trace`、`session_event_trace` 和 `session_event_read`;已发布的宿主组合默认不挂载它。 + +## 配置 + +| 键 | 默认值 | 含义 | +|---|---:|---| +| `maxSearchResults` | `100` | 在内部提供方分页中收集的最大已授权非自身命中数 | +| `searchTimeoutMs` | `30000` | 连接到两个全文搜索工具的协作式 deadline | + +调用方只能来自 `ToolExecution.exec.agent`。跨会话访问要求目标和调用方会话的 `cwd` 值严格相等;没有 `cwd` 的调用方只能检查自己。搜索绝不公开提供方游标、偏移、分页大小或模型可控上限。由于一次搜索会在内部消费与世代绑定的提供方游标,两个搜索工具都与同级工具调用排他执行;三个精确跟踪/读取工具选择并行执行。每个精确执行器都将未更改的执行信号传递给授权和服务跟踪/读取,因此取消会等待协作式持久化清理,并保留信号的精确原因。工具边界上的时间戳要求显式 `Z` 或数字偏移,并转换为包含端点的 epoch 毫秒过滤器。 + +`session_search` 始终省略调用方会话。请求的父 id 会被去重,并在 FTS 前根据调用方工作区权限检查;只有已授权 id 会到达提供方,而缺失猜测和跨工作区猜测的行为完全相同,root 标记仍独立使用 OR。当前会话中的 `session_event_search` 会在调用它的步骤之前立即停止,因此当前 assistant 输出和已记录工具调用无法匹配自身。直接目标在跟踪、事件或标题读取前完成授权。血缘输出会用不含隐藏会话 id 的标记替换未授权祖先和后代边界。 + +每个可信 `ctx.sessionQuery` 调用都会经过一个模型边界净化器。首先检查调用方取消,并精确保留。可用语料库和提供方诊断(包括可安全检查的嵌套原因)会尽力记录到内部日志;不可打印的失败使用固定日志占位符。诊断格式化和错误分类各自独立受保护,因此不可打印的原因无法逃逸,也无法阻止已安全分类的外层错误;不安全的分类或日志记录则回退到固定 `SESSION_QUERY_TOOL_FAILED` 代码和消息。本地参数验证和授权错误保留精确的工具自有消息。 + +该包刻意不执行字节或字符截断,也不导入 spill 后端。需要限制内联输出的部署应挂载 `@deepseek-ai/dsh-spill-policy`,它可在执行后替换已渲染文本,同时保留完整结果。 + +## 模型体验 + +### 系统提示词 + +#### 模型所见 + +模型会收到一个固定的既往历史指引章节。 + +##### 既往历史指引 + +```markdown +Use session_search to find relevant work from prior sessions, or session_event_search to search earlier events in one session. Search results are cursor-free and workspace-scoped. Follow a useful hit with session_trace, session_event_trace, or session_event_read when you need lineage, relationships, or exact data. +``` + +#### Token 影响 + +插件挂载期间,每次请求都存在一个固定精简章节。 + +#### KV 缓存影响 + +插件和指引文本不变时,前缀稳定。 + +### 工具 schema + +#### 模型所见 + +模型会看到生成的 [`session_search`、`session_event_search`、`session_trace`、`session_event_trace` 和 `session_event_read` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-session-query)。搜索过滤器会增加固定 schema token,而游标、工作区路径、输出分页和模型可控结果上限仍不存在。 + +#### Token 影响 + +可见期间,每次请求都会发送 5 个固定只读 schema。 + +#### KV 缓存影响 + +工具可见性和定义不变时,前缀稳定。 + +### 工具结果 + +#### 模型所见 + +每次成功调用都会发出一个纯文本块。搜索结果包含标题和最佳匹配摘要;跟踪包含全部已授权关系;事件读取包含未缩写的目标 JSON。通用 spill 策略可以将过大的内联文本替换为预览、不透明定位信息和取回指引。 + +#### Token 影响 + +结果取决于数据,并保留在已记录工具历史中直到压缩;`maxSearchResults` 限制搜索命中数。 + +#### KV 缓存影响 + +仅追加的结果文本位于可重用请求前缀之后,不会使较早的缓存条目失效。 + +## 已知限制与待完成工作 + +- 搜索最多返回部署上限,匹配更多时会请模型缩小查询;不提供延续 token。 +- 工作区身份使用保守的字符串精确 `cwd` 相等性,因此符号链接等价的路径不共享权限。 +- 未挂载通用 spill 策略的自定义组合会在内联位置接受完整跟踪和事件 payload。 diff --git a/packages/session-title/README.i18n.yaml b/packages/session-title/README.i18n.yaml new file mode 100644 index 0000000000..1609fda4ae --- /dev/null +++ b/packages/session-title/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 64bca8153e566e1590776af511662a873198106e +README.zh.md: b6779f3e798d695a3c22634bb298f2a7681edbfb diff --git a/packages/session-title/README.md b/packages/session-title/README.md index 8c26cf1785..64bca8153e 100644 --- a/packages/session-title/README.md +++ b/packages/session-title/README.md @@ -1,5 +1,7 @@ # session-title/ — log-backed session-title capability family +English | [中文](README.zh.md) + Durable session-title state, one optional asynchronous provider seam, and two opt-in model-backed implementations. The built-in first-message fallback is part of the service, so every composition can title a session without an auxiliary model call. | Package | Role | ctx key | diff --git a/packages/session-title/README.zh.md b/packages/session-title/README.zh.md new file mode 100644 index 0000000000..b6779f3e79 --- /dev/null +++ b/packages/session-title/README.zh.md @@ -0,0 +1,14 @@ +# session-title/:日志支持的会话标题能力家族 + +[English](README.md) | 中文 + +持久会话标题状态、一个可选异步提供方 seam,以及两个可选启用的模型后端实现。内置首消息回退属于服务本身,因此任何组合都能在不调用辅助模型的情况下为会话生成标题。 + +| 包 | 职责 | ctx 键 | +|---|---|---| +| [`session-title/`](session-title/README.md) | 日志折叠、确定性回退、提供方注册表与刷新 API | `ctx.sessionTitle` | +| [`session-title-llm/`](session-title-llm/README.md) | 共享路由、请求日志记录、提示词、超时、流与验证辅助模块 | 无 | +| [`session-title-first-message-llm/`](session-title-first-message-llm/README.md) | 使用第一条符合条件的用户消息的可选提供方 | 注册到 `ctx.sessionTitle` | +| [`session-title-all-messages-llm/`](session-title-all-messages-llm/README.md) | 使用所有符合条件的用户消息的可选提供方 | 注册到 `ctx.sessionTitle` | + +同一时间只能注册一个提供方。共享 demo 主干会挂载回退服务,但默认组合不包含两个模型提供方,因此部署会显式选择辅助成本和重新生成标题的节奏。 diff --git a/packages/session-title/session-title-all-messages-llm/README.i18n.yaml b/packages/session-title/session-title-all-messages-llm/README.i18n.yaml new file mode 100644 index 0000000000..352cfe4f68 --- /dev/null +++ b/packages/session-title/session-title-all-messages-llm/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 25ec92432b5c2d18624f2f4d851552ee2b3bf5d0 +README.zh.md: 685046a2a4d0859e6eca86cfba67690b8271afaf diff --git a/packages/session-title/session-title-all-messages-llm/README.md b/packages/session-title/session-title-all-messages-llm/README.md index 5ca63aa18d..25ec92432b 100644 --- a/packages/session-title/session-title-all-messages-llm/README.md +++ b/packages/session-title/session-title-all-messages-llm/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-session-title-all-messages-llm +English | [中文](README.zh.md) + Optional `ctx.sessionTitle` provider that summarizes every eligible human message through `ctx.llm`. It registers the `all-user-messages` cadence and starts a new revision after each new human prompt, using seeded history as well as child-session prompts. A newer revision aborts and supersedes older work; even a provider that ignores cancellation cannot commit stale output. The plugin uses the complete required [shared LLM configuration](../session-title-llm/README.md#configuration). Omit both `provider` and `model` to inherit the exact route from each current logged main request, or set both to route title generation independently. If the final framed aggregate prompt exceeds `maxInputBytes`, the request fails instead of truncating history; automatic use warns and keeps the prior title. diff --git a/packages/session-title/session-title-all-messages-llm/README.zh.md b/packages/session-title/session-title-all-messages-llm/README.zh.md new file mode 100644 index 0000000000..685046a2a4 --- /dev/null +++ b/packages/session-title/session-title-all-messages-llm/README.zh.md @@ -0,0 +1,28 @@ +# @deepseek-ai/dsh-session-title-all-messages-llm + +[English](README.md) | 中文 + +可选的 `ctx.sessionTitle` 提供方,通过 `ctx.llm` 总结所有符合条件的用户消息。它注册 `all-user-messages` 节奏,并在每条新用户提示词后启动新 revision,同时使用 seed 历史与子会话提示词。较新的 revision 会中止并取代旧工作;即使提供方忽略取消,也无法提交陈旧输出。 + +该插件使用完整且必填的[共享 LLM 配置](../session-title-llm/README.md#configuration)。同时省略 `provider` 与 `model` 时,会继承每个当前已记录主请求的确切路由;也可以同时设置二者,使标题生成使用独立路由。如果最终封装的聚合提示词超过 `maxInputBytes`,请求会失败而不是截断历史;自动使用时会发出警告并保留先前标题。 + +## 模型体验 + +### 全消息标题请求 + +#### 模型看到的内容 + +标题模型会收到共享标题指令,以及一个 JSON 数组,其中按日志顺序包含截至当前 revision 的所有符合条件用户消息和确切 seq。Seed 历史也包含在内。 + +#### Token 影响 + +每条符合条件的新提示词之后都可能发送一项辅助请求,每次请求受 `maxInputBytes` 和 `maxOutputTokens` 约束;显式刷新可能增加调用。主 agent 请求不会增加 token。 + +#### KV Cache 影响 + +不会使主请求失效。每条提示词后,辅助输入都会增长或变化,因此提供方专用缓存复用会在第一个变化的 JSON token 处结束。 + +## 已知限制与暂缓工作 + +- 输入溢出时保留先前标题;对于很长的会话,此提供方没有摘要再摘要机制或保留策略。 +- 它平等对待所有符合条件的用户消息,不提供权重、过滤或手动标题优先级。 diff --git a/packages/session-title/session-title-first-message-llm/README.i18n.yaml b/packages/session-title/session-title-first-message-llm/README.i18n.yaml new file mode 100644 index 0000000000..ed6f3e7fe8 --- /dev/null +++ b/packages/session-title/session-title-first-message-llm/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: c24d4de1fa945d11b5a868cad88a94ed14ad3891 +README.zh.md: a3adc5366c841ed6aa08220209ed372db1fc8666 diff --git a/packages/session-title/session-title-first-message-llm/README.md b/packages/session-title/session-title-first-message-llm/README.md index 2fb083d05c..c24d4de1fa 100644 --- a/packages/session-title/session-title-first-message-llm/README.md +++ b/packages/session-title/session-title-first-message-llm/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-session-title-first-message-llm +English | [中文](README.zh.md) + Optional `ctx.sessionTitle` provider that summarizes the first eligible human message through `ctx.llm`. It registers the `first-message` cadence, runs automatically only when a fresh non-fork session first creates its fallback, and attributes the result to that message's exact seq. An automatic failure retains the fallback and is retried only through `ctx.sessionTitle.refresh()`. The plugin uses the complete required [shared LLM configuration](../session-title-llm/README.md#configuration). Omit both `provider` and `model` to inherit the exact route from the current logged main request, or set both to route title generation independently. diff --git a/packages/session-title/session-title-first-message-llm/README.zh.md b/packages/session-title/session-title-first-message-llm/README.zh.md new file mode 100644 index 0000000000..a3adc5366c --- /dev/null +++ b/packages/session-title/session-title-first-message-llm/README.zh.md @@ -0,0 +1,28 @@ +# @deepseek-ai/dsh-session-title-first-message-llm + +[English](README.md) | 中文 + +可选的 `ctx.sessionTitle` 提供方,通过 `ctx.llm` 总结第一条符合条件的用户消息。它注册 `first-message` 节奏,只在全新非 fork 会话首次创建回退时自动运行,并将结果归因于该消息的确切 seq。自动失败会保留回退,之后只能通过 `ctx.sessionTitle.refresh()` 重试。 + +该插件使用完整且必填的[共享 LLM 配置](../session-title-llm/README.md#configuration)。同时省略 `provider` 与 `model` 时,会继承当前已记录主请求的确切路由;也可以同时设置二者,使标题生成使用独立路由。 + +## 模型体验 + +### 首消息标题请求 + +#### 模型看到的内容 + +标题模型会收到共享标题指令,以及一个只包含第一条符合条件用户消息的 JSON 数组。后续提示词与继承的 fork 历史不会触发另一项自动调用。 + +#### Token 影响 + +全新会话最多自动发出一项辅助请求,并受 `maxInputBytes` 和 `maxOutputTokens` 约束;显式刷新可能发出额外调用。主 agent 请求不会增加 token。 + +#### KV Cache 影响 + +不会使主请求失效。辅助请求使用已配置或已记录路由,其缓存行为由提供方决定。 + +## 已知限制与暂缓工作 + +- 对于长期会话,第一条消息可能不再具有代表性;如果后续提示词应触发重新生成标题,请使用全消息提供方。 +- Fork 会保留继承的标题,绝不会自动运行此提供方,即使 seed 中的首消息来自父会话。 diff --git a/packages/session-title/session-title-llm/README.i18n.yaml b/packages/session-title/session-title-llm/README.i18n.yaml new file mode 100644 index 0000000000..5911c38ba7 --- /dev/null +++ b/packages/session-title/session-title-llm/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 5d004b634e6242a8a558182e6c953e4c43b25e1f +README.zh.md: 36567d950e612561d8af804eca64ac6f9f3cd788 diff --git a/packages/session-title/session-title-llm/README.md b/packages/session-title/session-title-llm/README.md index 74f4950a48..5d004b634e 100644 --- a/packages/session-title/session-title-llm/README.md +++ b/packages/session-title/session-title-llm/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-session-title-llm +English | [中文](README.zh.md) + Shared implementation policy for model-backed session-title providers. It resolves the auxiliary route, frames exact selected human messages as JSON, records the exact dispatchable request, applies a language-aware title instruction, enforces input and output budgets, composes timeout and caller cancellation, assembles the stream, and returns normalized text with exact source seqs and model provenance. This package is a library, not a Cordis plugin. The provider plugins call `registerSessionTitleLlmProvider()` with their cadence and message selector; it validates shared config and delegates each revision to `generateSessionTitleWithLlm()`, so registration, route, prompt, cancellation, and validation behavior cannot drift between them. diff --git a/packages/session-title/session-title-llm/README.zh.md b/packages/session-title/session-title-llm/README.zh.md new file mode 100644 index 0000000000..36567d950e --- /dev/null +++ b/packages/session-title/session-title-llm/README.zh.md @@ -0,0 +1,47 @@ +# @deepseek-ai/dsh-session-title-llm + +[English](README.md) | 中文 + +模型后端会话标题提供方的共享实现策略。它解析辅助路由,将精确选中的用户消息封装为 JSON,记录可分发的确切请求,应用语言感知的标题指令,强制执行输入和输出预算,组合超时与调用方取消,组装流,并返回带有确切来源 seq 和模型 provenance 的规范化文本。 + +此包是普通库,不是 Cordis 插件。提供方插件调用 `registerSessionTitleLlmProvider()`,传入各自节奏与消息选择器;该函数验证共享配置,并将每个 revision 委派给 `generateSessionTitleWithLlm()`,使两者的注册、路由、提示词、取消与验证行为不会漂移。 + +## 路由与失败契约 + +`provider` 和 `model` 覆盖项都是可选的,但必须同时作为非空字符串提供。如果没有这一对取值,辅助模块会使用当前会话已记录 `request/header` 中捕获的确切提供方/模型路由;因此,在任何路由出现前显式刷新时必须提供覆盖项。辅助模块在记录或分发前,以 `maxInputBytes` 测量最终 JSON 封装的用户提示词,包括 seq 字段、包装层与 JSON 转义,而不是将其截断。消费流期间和流完成后都会重新检查超时与调用方取消,因此即使 interceptor 或适配器忽略 abort,也不能接受迟到的成功结果。格式错误或空输出、工具调用和非 stop 结束原因同样会 reject;会话标题服务决定该 reject 属于自动警告还是显式调用方失败。 + +路由与输入验证完成后,辅助模块会在模型分发前追加仅写入日志的 `session/title-llm-request` 事件。它包含标题提供方 id、确切来源 seq、路由、系统提示词、消息列表,以及该调用使用的输出 token 上限。追加操作共享标题能力的逐会话结算队列,因此取代当前请求的新请求不会与更早回退、请求记录或已接受标题的 flush 冲突。分发的 envelope 会深度冻结,携带 `purpose: 'session-title'`,且有意不包含 dsh-agent-loop 的进程本地请求身份。Interceptor 会与记录保持一致,而循环专用重建观察者不会把它与对话 header 比较。DeepSeek 适配器会将该 purpose 映射为关闭 thinking,使少量输出预算全部用于可见标题文本;其他适配器负责自身 purpose 专用行为。后续模型失败会保留请求记录;从未成为可分发请求的验证失败不会创建记录。该事件始终位于派生模型历史之外。 + +## 配置 + +除成对的路由覆盖项外,每个字段都必填;库不提供默认值。 + +| 键 | 契约 | +|---|---| +| `targetWords` | 非 CJK 标题的正整数目标词数。 | +| `targetCjkCharacters` | 中文、日文或韩文标题的正整数目标字符数。 | +| `maxInputBytes` | 最终 JSON 封装用户提示词的正整数 UTF-8 字节上限。 | +| `maxOutputTokens` | 辅助生成的正整数 token 上限。 | +| `timeoutMs` | 运行时定时器限制内的端到端正数 deadline。 | +| `provider`, `model` | 可选显式路由;二者同时提供或同时省略。 | + +## 模型体验 + +### 辅助标题请求 + +#### 模型看到的内容 + +标题模型会收到固定系统指令,要求以输入语言返回一个简洁且无装饰的标题,其中包含所配置的词数与 CJK 字符数目标。它唯一的用户消息包含一个 JSON 数组,其中是精确选中的用户消息及其 seq。 + +#### Token 影响 + +辅助请求根据所选输入大小和 `maxOutputTokens` 消耗 token。它与主 agent 请求相互独立,不会向 agent 历史增加标题文本或封装内容。DeepSeek 标题调用会关闭 thinking;主对话保留自身配置的 thinking 模式。 + +#### KV Cache 影响 + +不会使主请求失效。辅助缓存复用由提供方决定;固定指令可复用,而 JSON 消息数组会随每个 revision 变化。 + +## 已知限制与暂缓工作 + +- 辅助模块只接受文本输出,并拒绝工具调用;不公开结构化输出适配器或提供方专用提示词变体。 +- 它对整个封装用户提示词强制执行字节上限,不会剪裁单条消息或应用保留策略。 diff --git a/packages/session-title/session-title/README.i18n.yaml b/packages/session-title/session-title/README.i18n.yaml new file mode 100644 index 0000000000..29748c59a5 --- /dev/null +++ b/packages/session-title/session-title/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 2be6f37ce37f525dd567772144e925886ad51b42 +README.zh.md: 7f55b7bba912bf4611f5a058a0390d67b8860b24 diff --git a/packages/session-title/session-title/README.md b/packages/session-title/session-title/README.md index e9fadff0aa..2be6f37ce3 100644 --- a/packages/session-title/session-title/README.md +++ b/packages/session-title/session-title/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-session-title +English | [中文](README.zh.md) + Log-backed session titles with an immediate deterministic fallback and one optional asynchronous provider. Every accepted revision is a log-only `session/title` event; `foldSessionTitle()` and `ctx.sessionTitle.get()` select the latest event and return its event seq and timestamp. Only text blocks from human `user/message` events are eligible. The first eligible prompt schedules a fallback from its first words within the configured UTF-8 byte limit. Whitespace is normalized, terminal control sequences are removed, and truncation never splits a code point. Empty and non-text prompts wait for later eligible input. diff --git a/packages/session-title/session-title/README.zh.md b/packages/session-title/session-title/README.zh.md new file mode 100644 index 0000000000..7f55b7bba9 --- /dev/null +++ b/packages/session-title/session-title/README.zh.md @@ -0,0 +1,54 @@ +# @deepseek-ai/dsh-session-title + +[English](README.md) | 中文 + +由日志支持的会话标题,提供即时确定性回退与一个可选异步提供方。每个已接受 revision 都是仅写入日志的 `session/title` 事件;`foldSessionTitle()` 与 `ctx.sessionTitle.get()` 会选择最新事件,并返回其事件 seq 和时间戳。 + +只有用户 `user/message` 事件中的文本块符合条件。第一条符合条件的提示词会安排回退,从其开头若干词生成标题,并受所配置 UTF-8 字节上限约束。系统会规范化空白、移除终端控制序列,且截断绝不会切断 code point。空提示词和非文本提示词会等待后续符合条件的输入。 + +## 服务:`SessionTitleService`(ctx 键:`sessionTitle`) + +- `get(session)` 从活跃或回放日志折叠最新已接受标题。 +- `refresh(session, signal?)` 在需要时物化回退,然后显式运行已注册提供方,处理当前符合条件的消息。提供方错误与调用方取消会 reject;取消不会回滚已经进入持久化流程的回退追加。 +- `register(provider)` 安装唯一可选提供方,并返回可等待的 Cordis effect disposer。第二次注册会立即抛出;资源释放会中止待处理和活跃调用,等待其结算,之后才允许注册另一个提供方。 + +自动工作绝不会延迟主 agent 响应。只有当带标记、由循环构建的请求,其确切路由与当前已记录的 `request/header` 匹配时,提供方才会启动;即使 header 未变而无需新快照,也适用此规则。延迟完成会加入开放轮次,或使用已经 flush 的零步骤 `session-title` 轮次,并通过 `ctx.sessions.appendOutOfBand()` 追加。自动失败会发出警告并保留最新标题。新的全消息 revision、提供方资源释放、会话资源释放和显式刷新都会中止旧工作,陈旧完成值无法追加。并发显式刷新会在等待回退持久化前预留顺序;重叠的自动/显式回退请求共享一个会话本地进行中追加。服务与随附模型提供方记录使用 `appendSessionTitleOutOfBand()`,共享逐会话结算队列,因此替换请求记录会等待更早标题写入,但无需串行等待被取代的模型调用本身。服务 teardown 会取消排队工作,并在卸载完成前排空忽略取消的调用。 + +Fork 会原样继承 seed 中的标题事件。首消息节奏不会自动为子会话重新生成标题;全消息节奏可以在子会话收到后续用户提示词后追加新 revision。 + +## 配置 + +所有上限都是必填项;该库不提供默认值。 + +| 键 | 契约 | +|---|---| +| `fallbackMaxWords` | 确定性回退中以空白分隔的最大正整数词数。 | +| `fallbackMaxBytes` | 回退允许的最大正整数 UTF-8 字节数;不得超过 `maxTitleBytes`。 | +| `maxTitleBytes` | 接受任何来源标题的最大正整数 UTF-8 字节数。 | + +## 提供方契约 + +提供方会提供品牌化稳定 id、自动模式(`first-message` 或 `all-user-messages`)和 `generate(request)`。请求携带活跃会话、截至一个固定 revision 的所有符合条件消息、可用时当前已记录的主请求路由,以及取消信号。结果包含非空标题、该请求中唯一且有序的来源消息 seq,以及可选模型 provenance。服务会在结果持久化前进行规范化和验证。 + +参见[会话标题数据结构](../../../docs/core-data-structures/session-title.md)与[已实现决策](../../../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md)。 + +## 模型体验 + +### 会话标题状态 + +#### 模型看到的内容 + +无。`session/title` 只写入日志,绝不会进入会话接口、`deriveMessages()`、系统提示词、工具 schema 或请求前缀。 + +#### Token 影响 + +回退与已接受的提供方 revision 不会向主 agent 请求增加 token。可选提供方的独立辅助请求由对应提供方包记录。 + +#### KV Cache 影响 + +不影响主请求;标题事件不会改变重建内容或缓存键。 + +## 已知限制与暂缓工作 + +- 手动重命名、删除标题、生成标题与用户标题的优先级、搜索和列表索引都不属于此服务。 +- 提供方注册表有意最多接受一个实现,因此部署若要组合相互竞争的标题策略,必须编写一个自行负责优先级的提供方。 diff --git a/packages/skill/README.i18n.yaml b/packages/skill/README.i18n.yaml new file mode 100644 index 0000000000..5843c4fa86 --- /dev/null +++ b/packages/skill/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 5c75661de17826e7ea4763e90b494e9e7a0a7c0f +README.zh.md: d219f710c0185298af89ba2e074d9e3b2e093896 diff --git a/packages/skill/README.md b/packages/skill/README.md index 447b82fc47..5c75661de1 100644 --- a/packages/skill/README.md +++ b/packages/skill/README.md @@ -1,5 +1,7 @@ # skill/ - skill capability family +English | [中文](README.zh.md) + The canonical three-package capability seam for reusable agent instructions: a provider registry, a local implementation, and the model-facing catalog/loader consumer. All are **product** packages. | Package | Role | ctx key | diff --git a/packages/skill/README.zh.md b/packages/skill/README.zh.md new file mode 100644 index 0000000000..d219f710c0 --- /dev/null +++ b/packages/skill/README.zh.md @@ -0,0 +1,13 @@ +# skill/ - skill 功能家族 + +[English](README.md) | 中文 + +可复用 agent 指令的规范三包功能 seam:提供方注册表、本地实现,以及面向模型的目录/加载器消费方。全部都是**产品** 包。 + +| 包 | 职责 | ctx 键 | +|---|---|---| +| `skill/` | 提供方注册表、优先级解析、稳定目录快照和完整定义查找 | `ctx.skills` | +| `skill-local/` | 项目/自定义/用户文件系统提供方 | (注册到 `ctx.skills`) | +| `tool-skill/` | 会话前缀目录和面向模型的 `skill` 加载器 | (注册到 `ctx.tools`) | + +接口位于 `skill/skill/`。提供方同步注册,并通过 `ctx.skills` 执行异步发现;`tool-skill` 只消费该接口,因此嵌入式或远程提供方可替换或补充 `skill-local`,无需改变面向模型的契约。`agent-core` 默认加载该家族,但它仍然是核心控制主干之外的功能,与 [`bash/`](../bash/README.md)、[`fs/`](../fs/README.md)、[`web/`](../web/README.md) 和 [`subagent/`](../subagent/README.md) 并列。 diff --git a/packages/skill/skill-local/README.i18n.yaml b/packages/skill/skill-local/README.i18n.yaml new file mode 100644 index 0000000000..603c1e0c90 --- /dev/null +++ b/packages/skill/skill-local/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: c488fdc4b1d97b5aa1113e41a470484063526ded +README.zh.md: 796c814a3c4064d545a966465caf6f99e9dd8601 diff --git a/packages/skill/skill-local/README.md b/packages/skill/skill-local/README.md index 5abc155103..c488fdc4b1 100644 --- a/packages/skill/skill-local/README.md +++ b/packages/skill/skill-local/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-skill-local +English | [中文](README.zh.md) + Local filesystem provider for the `ctx.skills` registry. This package implements one skill source. It scans local project, custom, and user skill roots, parses `SKILL.md` or flat Markdown skill files, and registers the provider on `ctx.skills`. The registry remains in `@deepseek-ai/dsh-skill`; the session-prefix catalog and model-facing loader tool remain in `@deepseek-ai/dsh-tool-skill`. diff --git a/packages/skill/skill-local/README.zh.md b/packages/skill/skill-local/README.zh.md new file mode 100644 index 0000000000..796c814a3c --- /dev/null +++ b/packages/skill/skill-local/README.zh.md @@ -0,0 +1,54 @@ +# @deepseek-ai/dsh-skill-local + +[English](README.md) | 中文 + +`ctx.skills` 注册表的本地文件系统提供方。 + +该包实现一个 skill 来源。它扫描本地项目、自定义和用户 skill 根,解析 `SKILL.md` 或平铺 Markdown skill 文件,并将提供方注册到 `ctx.skills`。注册表仍位于 `@deepseek-ai/dsh-skill`;会话前缀目录和面向模型的加载器工具仍位于 `@deepseek-ai/dsh-tool-skill`。 + +## 插件 + +需要 `ctx.skills` (`inject: ['skills']`)。 + +### 配置 + +| 字段 | 默认值 | 含义 | +|---|---|---| +| `dshHome` | `$DSH_HOME` or `~/.dsh` | 由 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析的 DeepSeek Harness 配置根;扫描该目录下的 `skills`。 | +| `agentsHome` | `$DSH_AGENTS_HOME` or `~/.agents` | 为兼容 skill 扫描的共享 agent 配置根。 | +| `customSkillDirs` | `[]` | 在项目根之后、用户根之前扫描的其他本地 skill 根。 | + +## 发现 + +默认根按该提供方的 rank 顺序解析: + +| Rank | 来源 | 路径 | +|---|---|---| +| 100 | `project-dsh` | `<projectRoot>/.dsh/skills` | +| 200 | `project-agents` | `<projectRoot>/.agents/skills` | +| 300 | `custom` | `Config.customSkillDirs` | +| 400 | `user-dsh` | `<dshHome>/skills` | +| 500 | `user-agents` | `<agentsHome>/skills` | + +项目根是包含 `.git` 的最近祖先;如果不存在,则使用当前 cwd。用户 DSH 根会跳过其 `.system` 子级,因此系统所有目录不会被当作普通用户 skill。该提供方提供项目和用户 skill;其他提供方可提供内置系统 skill。 + +当 `ctx.fs` 可用时,发现通过 `ctx.fs.listDir` 列出根,通过 `ctx.fs.readText` 读取 skill 文件,并通过文件系统服务探测 `.git`。完整 skill 加载会将查找中止信号转发给文件系统元数据和内容读取。如果没有文件系统服务,提供方回退到可中止的 Node 文件系统 I/O,使最小本地上下文仍能加载 skill。缺失、不可读或格式错误的 skill 文件会警告并跳过,而不会使整个请求失败。 + +## Skill 格式 + +Skill 可以是单层目录 bundle(`<name>/SKILL.md`),也可以是平铺 Markdown 文件(`<name>.md`)。v1 刻意不包含嵌套 `**/SKILL.md` 发现。Frontmatter 使用 `yaml` 包解析为 YAML;它要求 `name` 和 `description`,而 `whenToUse`、`disableModelInvocation` 和 `metadata` 可选。名称必须使用 kebab-case。 + +## 模型体验 + +通过 `dsh-tool-skill` 间接影响模型。它将该提供方的可调用名称和有上限描述渲染到会话前缀目录中,并将所选指令正文与资源基底指引渲染到已保留工具历史中;路径、提供方 rank 和已禁用 skill 仍被隐藏。 + +#### KV 缓存影响 + +不直接导致失效;指定的消费方负责其引起的任何请求前缀变更。 + +## 已知限制与待完成工作 + +- **发现深度为一层**:只识别 `<root>/<name>/SKILL.md` 和 `<root>/<name>.md`;忽略嵌套 skill 树和包 manifest。 +- **项目范围为最近 `.git` 祖先**:没有该标记的工作区回退到提供的 cwd,不支持其他项目根标记或 monorepo 子项目选择。 +- **不可读或格式错误的条目会随警告消失**:模型目录不会收到每个 skill 的诊断,无法区分缺失的 skill 与被跳过的 skill。 +- **无文件系统 watcher**:先前已收集 cwd 重新发现之前,编辑操作依赖注册表缓存被驱逐,或因提供方重新加载而失效。 diff --git a/packages/skill/skill/README.i18n.yaml b/packages/skill/skill/README.i18n.yaml new file mode 100644 index 0000000000..d9e7e42df7 --- /dev/null +++ b/packages/skill/skill/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 639616d0b75f960e9ccd48546d44db841372bbe2 +README.zh.md: 3afdd415397927ebf107d6f862422c711a51888b diff --git a/packages/skill/skill/README.md b/packages/skill/skill/README.md index 21a8791716..639616d0b7 100644 --- a/packages/skill/skill/README.md +++ b/packages/skill/skill/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-skill +English | [中文](README.zh.md) + Pure agent skill provider registry. This package owns the `ctx.skills` interface. It does not know whether skills come from local files, embedded plugin data, HTTP, or another backend; providers register those sources with `ctx.skills.registerProvider(...)`. The shipped local implementation is [`@deepseek-ai/dsh-skill-local`](../skill-local). diff --git a/packages/skill/skill/README.zh.md b/packages/skill/skill/README.zh.md new file mode 100644 index 0000000000..3afdd41539 --- /dev/null +++ b/packages/skill/skill/README.zh.md @@ -0,0 +1,53 @@ +# @deepseek-ai/dsh-skill + +[English](README.md) | 中文 + +纯 agent skill 提供方注册表。 + +该包负责 `ctx.skills` 接口。它不知道 skill 来自本地文件、嵌入式插件数据、HTTP 还是其他后端;提供方通过 `ctx.skills.registerProvider(...)` 注册这些来源。已发布的本地实现是 [`@deepseek-ai/dsh-skill-local`](../skill-local)。 + +## 服务:`SkillService`(ctx 键:`skills`) + +### 公开 API + +- `ctx.skills.registerProvider(provider): () => void` 使用唯一 `provider.name` 注册只读提供方。重复提供方名称会抛错,`runtime` 保留给 `ctx.skills.register(...)`。注册表借用提供方对象,并直接调用其方法。注册作用域绑定到 effect,可安全用于 HMR;精确的 Cordis disposer 支持有序组合拆卸。 +- `ctx.skills.list({ cwd?, signal? })` 借用只读查找选项,然后返回当前工作区中模型可调用的摘要;这些摘要跨提供方合并,并按名称排序。 +- `ctx.skills.get(name, { cwd?, signal? })` 在发现和加载中使用同一组只读选项和胜出候选项;在发现或缓存命中后重新检查取消,让提供方加载与信号竞速,验证已加载定义,然后将其返回,包括已对模型禁用的 skill。 +- `ctx.skills.register(skill): () => void` 注册只读运行时嵌入式 skill,省略时添加 `provider: "runtime"`。同名运行时注册使用先到先得:重复项会记录警告,并获得无操作 disposer。成功注册会返回精确的 Cordis disposer,以供有序组合拆卸。 + +### 配置 + +| 字段 | 默认值 | 含义 | +|---|---|---| +| `collectCacheMaxEntries` | `128` | 内存中保留的最大已完成 cwd/提供方目录数。 | + +## 提供方契约 + +提供方同步注册,并在已等待的 `list(options)` 调用中执行远程设置、身份验证和发现。提供方对象、查找选项、候选项和定义都以只读方式借用,而不是克隆或重新绑定。提供方应遵守 `options.signal`;取消后,注册表也会停止等待不协作的发现或加载。 + +注册表在缓存前验证候选项,在返回前验证定义。胜出提供方会收到同一候选项和不透明 `locator`,两者都是它从 `list()` 返回的内容,从而支持后端专用文件、URL、id 或版本句柄。调用方和提供方必须保持只读契约。 + +契约违反会快速失败。被拒绝的 `list()` 视为瞬时来源失败:系统记录它、跳过它,并且不缓存。只缓存已完成目录;提供方或运行时修订变更会丢弃正在进行的结果并重试。重复名称按 rank、提供方注册顺序,然后按提供方本地顺序解析。摘要按 skill 名称排序。 + +## 运行时 Skill + +`ctx.skills.register(...)` 是嵌入式运行时 skill 的便利接口。运行时 skill 使用 rank `250`:项目提供方可覆盖它们,它们则覆盖已发布本地提供方的自定义根和用户根。运行时定义和嵌套资源元数据均以只读方式借用;服务只实体化提供默认 `provider` 所需的顶层定义。运行时贡献内的注册使用先到先得,因此重复贡献无法通过其 disposer 移除活动项。 + +## 消费方边界 + +注册表不渲染模型指引,也不注册面向模型的工具。[`@deepseek-ai/dsh-tool-skill`](../tool-skill) 消费 `ctx.skills` 以提供会话前缀目录和 `skill` 工具,因此提供方仍与模型接口独立。 + +## 模型体验 + +通过 `dsh-tool-skill` 间接影响模型;该包将提供方摘要渲染到会话前缀中,并将已加载指令渲染到已保留工具结果中。 + +#### KV 缓存影响 + +不直接导致失效;指定的消费方负责其引起的任何请求前缀变更。 + +## 已知限制与待完成工作 + +- **已完成目录没有 TTL 或 watcher 失效机制**:提供方的底层文件或远程数据可在注册修订不变的情况下更改,因此已缓存 cwd 会保持陈旧,直到被驱逐或重新加载提供方/运行时。 +- **提供方依次查询**:一个缓慢的协作提供方会延迟之后注册的所有提供方;取消会停止调用方等待,但无法终止不协作提供方持续运行的工作。 +- **提供方列表失败会移除该请求的整个来源**:注册表会记录并跳过它,不提供模型可见诊断或部分目录恢复契约。 +- **重复解析使用先到先得**:系统会记录并隐藏较晚出现的低优先级候选项;不提供检查全部被遮蔽定义的 API。 diff --git a/packages/skill/tool-skill/README.i18n.yaml b/packages/skill/tool-skill/README.i18n.yaml new file mode 100644 index 0000000000..868bdd0344 --- /dev/null +++ b/packages/skill/tool-skill/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 50a0e06ac06ca8a3b89c3d5ac604dcf2dc423533 +README.zh.md: 56fb2b87adcf072cf2b8b6670864fa274ed5f66f diff --git a/packages/skill/tool-skill/README.md b/packages/skill/tool-skill/README.md index 578c6e7300..50a0e06ac0 100644 --- a/packages/skill/tool-skill/README.md +++ b/packages/skill/tool-skill/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-tool-skill +English | [中文](README.zh.md) + The model-facing skill catalog and `skill` tool. Requires `ctx.tools` and `ctx.skills` (`inject: ['tools', 'skills']`). diff --git a/packages/skill/tool-skill/README.zh.md b/packages/skill/tool-skill/README.zh.md new file mode 100644 index 0000000000..56fb2b87ad --- /dev/null +++ b/packages/skill/tool-skill/README.zh.md @@ -0,0 +1,148 @@ +# @deepseek-ai/dsh-tool-skill + +[English](README.md) | 中文 + +面向模型的 skill 目录和 `skill` 工具。 + +需要 `ctx.tools` 和 `ctx.skills` (`inject: ['tools', 'skills']`)。 + +## 会话前缀目录 + +该插件贡献一个用户角色 `<system-reminder>` 目录,并通过 `agent/session-prefix` 提供它。它为调用会话的 cwd 解析 skill,将前缀中止信号转发到发现,并只列出已排序的 `name` 和 `description` 条目;skill 正文、路径、来源、提供方和 `whenToUse` 提示仍位于目录之外。如果没有模型可调用 skill,则省略目录;如果该 agent 的工具视图排除已发布的 `skill` 工具,或解析出一个同名作用域遮蔽,也会省略目录。这项精确定义检查使提示词指引、模型可见 schema 和可执行分派保持对齐。 + +`catalogDescriptionMaxLength` 控制规范化且经 XML 转义的目录描述。其默认值是 `500`,且必须是不小于 `3` 的整数,以便为截断省略号保留空间。[会话前缀 Agent Note](../../../.agents/notes/implemented/feature/2026-07-07-session-prefix.md) 定义了该消息仅存在于请求中、记录于 header 的生命周期。 + +## 工具:`skill` + +| 参数 | 类型 | 说明 | +|---|---|---| +| `name` | string(必填) | 可用 skill 列表中精确的 kebab-case skill 名称。 | + +执行使用调用 agent 的 `session.header.cwd`,使工作区敏感提供方解析胜出 skill。成功调用返回规范 `{ name, provider, resourceBase?, content }`,排除目录 rank 和提供方内部机制;其 Native 渲染器产生一个文本结果,其中包含 `<skill_content name="...">`、`<skill_resources>` 和 `<skill_instructions>`。 + +资源指引只会根据 `resourceBase` 解析指令显式引用的路径或 URL;脚本、参考资料和产物按需加载,结果不会列举 skill 目录。本地提供方可以提供目录,而远程或嵌入式提供方可以提供 URL 或不透明加载指引。 + +无法解析的名称会报告 skill 未知或已不可用。无效名称和 `disableModelInvocation: true` skill 产生不同的错误结果。 + +该工具在 v1 中不调用 `agent.inject()`。其结果已作为工具结果记录,并在下一个模型步骤可用,无需将内容重复为合成上下文。 + +## 模型体验 + +### 会话前缀 + +#### 模型所见 + +如果存在模型可调用 skill,且该精确 `skill` 工具可见,agent 会收到下方目录模板,其中包含每个已排序 skill 的一条数据依赖条目。该目录是冻结的用户角色会话前缀。 + +##### Skill 目录模板 + +```markdown +<system-reminder> +A skill is a reusable set of task-specific instructions. The following skills are available in this session: + +<available_skills> +- `<name>`: <normalized-and-capped-description> +</available_skills> + +If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded. +</system-reminder> +``` + +#### Token 影响 + +重复输入成本随 skill 数量和 `catalogDescriptionMaxLength` 增长;当列表为空或工具被隐藏或遮蔽时,不会发送目录 token。 + +#### KV 缓存影响 + +会话前缀组合完成后,在一个循环实例内前缀稳定。如果新建或恢复的实例具有不同提供方、skill、描述、可见性或目录上限,则可能从第一个变更目录 token 起使重用失效。 + +### 工具 schema + +#### 模型所见 + +模型会看到生成的 [`skill` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-skill)。 + +#### Token 影响 + +工具可见时,每次请求都有固定 schema 成本。 + +#### KV 缓存影响 + +工具定义和可见性不变时,前缀稳定。遮蔽、限制或插件生命周期变更可能从该 schema 起使重用失效。 + +### 工具结果 + +#### 模型所见 + +成功调用使用下方结果模板,以及由提供方管理、目录、URL 或不透明的资源指引。 + +##### Skill 结果模板 + +```markdown +<skill_content name="<escaped-name>"> +<skill_resources> +<resource-guidance> +</skill_resources> + +<skill_instructions> +<provider-owned-instruction-body> +</skill_instructions> +</skill_content> +``` + +##### 提供方管理的资源指引 + +```markdown +Resources for this skill are managed by provider "<provider>". +Load referenced resources only as needed. +``` + +##### 目录资源指引 + +```markdown +Base directory for this skill: <path> +Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed. +``` + +##### URL 资源指引 + +```markdown +Base URL for this skill: <url> +Resolve relative URLs mentioned by this skill against the base URL before using them. Load referenced resources only as needed. +``` + +##### 不透明资源指引 + +```markdown +Resources for this skill: <description> +Load referenced resources only as needed. +``` + +#### Token 影响 + +已加载指令是取决于数据的工具结果 token,并在后续步骤中重新发送,直到压缩;不会制作重复的 `agent.inject()` 副本。 + +#### KV 缓存影响 + +仅追加;新可见内容位于可重用请求前缀之后,不会使现有 KV 缓存条目失效。 + +### 工具错误 + +#### 模型所见 + +无效或陈旧选择会精确返回 `Error: invalid skill name "<name>"`、`Error: skill "<name>" is unknown or no longer available` 或 `Error: skill "<name>" is not available for model invocation`。提供方抛出的查找文本取决于数据,并接收同一个 `Error: <message>` 包装层。 + +#### Token 影响 + +只有失败调用会添加这些已保留 token。 + +#### KV 缓存影响 + +仅追加;新可见内容位于可重用请求前缀之后,不会使现有 KV 缓存条目失效。 + +## 已知限制与待完成工作 + +- **目录省略 `whenToUse`、来源和提供方元数据**:路由只基于名称和有上限描述;`whenToUse` 仍是提供方元数据,加载后的包装层也不渲染它。 +- **已加载指令正文没有大小上限**:提供方可返回足以占用大量下一步上下文的 skill;只有目录描述会被截断。 +- **资源是指引,而非附件**:工具报告基础目录/URL/不透明提示,但既不列举也不为模型获取引用文件。 +- **加载是一次性文本**:远程提供方缓慢或 skill 正文很大时,不提供部分、流式或缓存内容句柄。 diff --git a/packages/spill/README.i18n.yaml b/packages/spill/README.i18n.yaml new file mode 100644 index 0000000000..6b2792801c --- /dev/null +++ b/packages/spill/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: cb6f1e9af94ee5b0d0c06a8c4293e37e2d1eb856 +README.zh.md: 96de589f5aaf15a03d5d3d1b906dd274eea79440 diff --git a/packages/spill/README.md b/packages/spill/README.md index c7b59adf74..cb6f1e9af9 100644 --- a/packages/spill/README.md +++ b/packages/spill/README.md @@ -1,5 +1,7 @@ # spill/ - spill storage capability family +English | [中文](README.zh.md) + The tool-output spill capability seam: an abstract storage interface, a local filesystem implementation, and the tool-result policy that uses it. All **product** packages. | Package | Role | ctx key | diff --git a/packages/spill/README.zh.md b/packages/spill/README.zh.md new file mode 100644 index 0000000000..96de589f5a --- /dev/null +++ b/packages/spill/README.zh.md @@ -0,0 +1,15 @@ +# spill/ - spill 存储功能家族 + +[English](README.md) | 中文 + +工具输出 spill 的功能 seam:一个抽象存储接口、一个本地文件系统实现,以及一个使用该实现的工具结果策略。全部都是**产品** 包。 + +| 包 | 职责 | ctx 键 | +|---|---|---| +| `spill/` | 抽象 spill 存储 seam(`saveText`:持久化过大的工具文本,返回定位信息与取回指引) | `ctx.spillStore` | +| `spill-local/` | 本地文件系统后端:使用防路径遍历名称的私有会话级文件 | (注册到 `ctx.spillStore`) | +| `spill-policy/` | `tools/post-execute` 策略:将过大的纯文本结果替换为预览和 spill 定位信息 | (无服务接口) | + +接口位于 `spill/spill/`。这种拆分方式与 bash/fs 相同:seam 只负责存储,`spill-local` 负责文件系统机制,`spill-policy` 负责决定何时 spill 以及面向模型的通知。预览机制位于 [`util/retention`](../util/README.md);策略只组合两者,不会让任何一方承担对方的职责。 + +设计原理见[工具输出 spill Agent Note](../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md),其中说明了为什么最终结果 spill 要与工具自行提前 spill(bash 流、subagent rollout)分离,以及为什么创建操作应由运行时 spill seam 而非面向模型的 `write` 工具承担。 diff --git a/packages/spill/spill-local/README.i18n.yaml b/packages/spill/spill-local/README.i18n.yaml new file mode 100644 index 0000000000..03b817e00f --- /dev/null +++ b/packages/spill/spill-local/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 2270a65d9270e1549a9e48d6a36b821e48c29070 +README.zh.md: 2c5cd901d5a35e2129b7908082b6bc5e2b53be64 diff --git a/packages/spill/spill-local/README.md b/packages/spill/spill-local/README.md index cef794b548..2270a65d92 100644 --- a/packages/spill/spill-local/README.md +++ b/packages/spill/spill-local/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-spill-local +English | [中文](README.zh.md) + The **local-filesystem** implementation of the [`@deepseek-ai/dsh-spill`](../spill) storage seam. Registers as `ctx.spillStore` and persists a tool's oversized text to a private, session-scoped file; its locator is the file path and its retrieval hint tells the model to use `read` or `grep` on that path. ## Storage layout diff --git a/packages/spill/spill-local/README.zh.md b/packages/spill/spill-local/README.zh.md new file mode 100644 index 0000000000..2c5cd901d5 --- /dev/null +++ b/packages/spill/spill-local/README.zh.md @@ -0,0 +1,34 @@ +# @deepseek-ai/dsh-spill-local + +[English](README.md) | 中文 + +[`@deepseek-ai/dsh-spill`](../spill) 存储 seam 的**本地文件系统** 实现。它注册为 `ctx.spillStore`,将工具过大的文本持久化到私有的会话级文件;定位信息是文件路径,取回指引会告诉模型对该路径使用 `read` 或 `grep`。 + +## 存储布局 + +文件存放在 `<root>/session-<hash>/​<random>-<safeName>`: + +- **`root`**:使用配置中的 `root`(解析为绝对路径);如果省略,则在操作系统临时目录下延迟创建每进程私有(0700)目录。可预测且全球可读的根目录会让其他本地用户读取 spill 工具输出,或在其中预置符号链接。 +- **`session-<hash>`**:短 `sha256(sessionId)` 前缀,用于将一个会话的 spill 文件归组,以便未来的清理操作可按会话删除。 +- **`<random>-<safeName>`**:不可预测的十六进制前缀(防止在共享根目录中预置符号链接),加上经过清理的调用方 `suggestedName`,使其成为单个安全路径段(防路径遍历;与 JSONL 持久化后端的 `encodeSegment` 一致)。写入操作为排他且仅所有者可读写(`open(path, 'wx', 0o600)`):如果路径已经存在,无论是否为符号链接,操作都会失败,因此预置的目标无法重定向写入。 + +## 配置 + +| 键 | 默认值 | 含义 | +|---|---|---| +| `root` | 私有 0700 临时目录 | spill 文件的根目录。进行设置可将它们保存在已知位置。 | + +`saveText` 在发生真实存储故障(权限、ENOSPC)时拒绝;spill 策略会将该拒绝作为尽力而为的失败,并保留内联结果。词汇见 seam README,设计见[工具输出 spill Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md)。 + +## 模型体验 + +通过渲染本地路径以及 `read`/`grep` 取回指引的 spill 消费方间接影响模型。 + +#### KV 缓存影响 + +不直接导致失效;指定的消费方负责其引起的任何请求前缀变更。 + +## 已知限制与待完成工作 + +- **本地 spill 文件会持续存在,直到外部清理为止**:该后端不提供会话生命周期删除或按时间保留的策略,因为已持久化、已恢复和 fork 后的会话可能仍在引用某个路径。 +- **定位信息需要与其位于同一文件系统的消费方**:远程或虚拟部署需要另一个 `SpillStore` 后端,其定位信息和取回指引在该环境中有明确含义。 diff --git a/packages/spill/spill-policy/README.i18n.yaml b/packages/spill/spill-policy/README.i18n.yaml new file mode 100644 index 0000000000..e5cc04e53c --- /dev/null +++ b/packages/spill/spill-policy/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 7638f62c0426964d83d7a635ebf73d0f117abd78 +README.zh.md: bf6d52a8c6b2b2e56052075af4a2150fba357f84 diff --git a/packages/spill/spill-policy/README.md b/packages/spill/spill-policy/README.md index 3e89e22f9c..7638f62c04 100644 --- a/packages/spill/spill-policy/README.md +++ b/packages/spill/spill-policy/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-spill-policy +English | [中文](README.zh.md) + The **tool-result spill policy**: a `tools/post-execute` transformer that keeps oversized plain-text tool results out of the model's context. When a final result exceeds `maxInlineBytes`, it saves the FULL text through [`ctx.spillStore`](../spill) and replaces the model-facing result with a bounded head/tail preview plus the backend's locator and retrieval hint. This plugin registers **no service** and owns no storage or preview mechanics: preview is [`@deepseek-ai/dsh-retention`](../../util/retention) (`TextRetainer`), storage is `ctx.spillStore`. It only decides WHEN to spill and composes the notice. diff --git a/packages/spill/spill-policy/README.zh.md b/packages/spill/spill-policy/README.zh.md new file mode 100644 index 0000000000..bf6d52a8c6 --- /dev/null +++ b/packages/spill/spill-policy/README.zh.md @@ -0,0 +1,58 @@ +# @deepseek-ai/dsh-spill-policy + +[English](README.md) | 中文 + +**工具结果 spill 策略**:一个 `tools/post-execute` 转换器,用于防止过大的纯文本工具结果进入模型上下文。当最终结果超过 `maxInlineBytes` 时,它会通过 [`ctx.spillStore`](../spill) 保存完整文本,并将面向模型的结果替换为有界的首尾预览、后端定位信息与取回指引。 + +该插件**不注册任何服务**,也不负责存储或预览机制:预览由 [`@deepseek-ai/dsh-retention`](../../util/retention) (`TextRetainer`)负责,存储由 `ctx.spillStore` 负责。它只决定何时 spill,并组合通知。 + +## 配置 + +| 键 | 默认值 | 含义 | +|---|---|---| +| `maxInlineBytes` | *(省略)* | 面向模型的纯文本结果上下文上限,以 UTF-8 字节数计(在加载时验证为非负整数)。**省略时完全禁用该策略**(插件不注册任何内容)。设置后,较大的结果会被 spill,并替换为从同一预算派生的预览(首尾拆分)。 | + +## 行为 + +1. 允许工具运行(通过 `next()` 委托,因此可以限制任何下游钩子接受的内容)。 +2. 跳过嵌套执行(存在 `exec.parent`——其持久副本由下方的 dispatch-log 分支设界)、已接受的值替换(注册表必须重新验证并渲染它们)、`read`(避免 `read → spill → read again` 循环)以及任何非 `accept` 决定(`block` 的纠正反馈会原样通过)。 +3. 仅在已接受的内容为**纯文本**(全部都是 `text` 块)时才将其展平;包含任何非文本块的结果都保持不变。 +4. 如果 UTF-8 大小为 `≤ maxInlineBytes`,则保持不变。 +5. 否则,保存完整文本,并将结果替换为预览和以下通知。系统会调整大小,使整个替换内容(预览、空行和通知)不超过 `maxInlineBytes`:先从预算中保留通知所需字节,再缩小预览以适配剩余空间,因此面向模型的结果绝不会超过上限: + + ```text + <retained head/tail preview> + + (Omitted N bytes. Full formatted result stored at: /…/session-…/…-web_fetch.txt. Use read with offset/limit, or grep this path to search within it.) + ``` + + 当通知本身已占满预算时(上限极小或定位信息很长),预览为空,只返回通知。如果仅通知的替换内容仍会超过 `maxInlineBytes`,策略将保留内联结果;它绝不会发出超过上限的替换内容(而且上限内的替换内容总比原结果更小,因此这也意味着 spill 绝不会增加字节数)。 + +**尽力而为**:没有会话 owner、没有 `ctx.spillStore` 后端,或 `saveText` 拒绝 ⇒ 策略记录警告并返回原始结果。spill 失败绝不会将成功调用变为 `isError`,也不会隐藏内联结果。成功替换时只会更改 `content`;规范程序值保持不变。 + +**dispatch-log 分支:**注册在 `tools/code-dispatch-log` 上的第二个监听器,把同一套上限、替换流水线与尽力而为的回退应用到每个 `run_code` 子调用结果的持久副本上(工件标签为 `dispatch`,按子调用 id 归档)。程序取得的值不受影响——它早已完整跨过 worker 边界;`read` 子调用同样设界:日志副本不是模型上下文,因此不会发生 read-again 循环,而 `read` 恰恰是最容易产生巨型日志的工具([原理](../../../.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md))。 + +## 范围 + +该策略只能看到最终格式化接口结果,看不到工具的内部资源或规范值。如果提供方已经截断内容(例如 `web-fetch-local.maxBodyChars`),spill 产物保存的是工具返回的完整格式化结果,而非完整原始源。提供方/资源上限仍必须存在,并且与该策略分离。`glob`/`grep` 负责对项级接口结果执行 spill,因为渲染前仍然存在完整的已获取值;bash 流负责在获取时 spill。通用策略预先注册自己的 waterfall 监听器,然后再委托,因此无论插件加载顺序如何,普通工具拥有的异步投影都会在通用字节限制之前完成。详见[工具输出 spill Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md)。 + +## 模型体验 + +### 过大的纯文本结果 + +#### 模型所见 + +大小不超过 `maxInlineBytes` 的结果、嵌套结果、`read` 结果、已阻止的决定和包含非文本块的结果都保持不变。过大的纯文本接口结果会变为有界的首尾预览,后面附加 `(Omitted <bytes> bytes. Full formatted result stored at: <locator>. <retrievalHint>)`;存储或 owner 失败时,原始结果仍然可见。 + +#### Token 影响 + +成功替换后的内容最多为 `maxInlineBytes` 个 UTF-8 字节,并会保留在历史中直到压缩;完整 spill 文本不会重新发送给模型。 + +#### KV 缓存影响 + +仅追加;新可见内容位于可重用请求前缀之后,不会使现有 KV 缓存条目失效。 + +## 已知限制与待完成工作 + +- **只能对最终纯文本结果执行 spill**:混合内容结果、阻止反馈和 `read` 会原样通过;无法在此恢复先前已经发生的提供方截断或工具自有保留。 +- **通知无法容纳时,该次调用的替换功能会禁用**:当上限极小或定位信息很长时,后端已经保存了无引用的 spill,但过大的原始结果仍会保留在内联位置。 diff --git a/packages/spill/spill/README.i18n.yaml b/packages/spill/spill/README.i18n.yaml new file mode 100644 index 0000000000..b4117377bb --- /dev/null +++ b/packages/spill/spill/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: c3434f90a8f7ab30baa6b237beab6dd9763f9bff +README.zh.md: 4ceb4b1f4298d945f1f673403ad47a379263a674 diff --git a/packages/spill/spill/README.md b/packages/spill/spill/README.md index 8e64e72608..c3434f90a8 100644 --- a/packages/spill/spill/README.md +++ b/packages/spill/spill/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-spill +English | [中文](README.zh.md) + The **spill storage seam**: an abstract `SpillStore` service (`ctx.spillStore`) defining WHAT a spill backend does — persist a tool's oversized text and return a model-facing locator plus retrieval guidance — without saying HOW. This package is one third of the spill capability, split so each concern evolves (and swaps) independently: diff --git a/packages/spill/spill/README.zh.md b/packages/spill/spill/README.zh.md new file mode 100644 index 0000000000..4ceb4b1f42 --- /dev/null +++ b/packages/spill/spill/README.zh.md @@ -0,0 +1,42 @@ +# @deepseek-ai/dsh-spill + +[English](README.md) | 中文 + +**spill 存储 seam**:抽象的 `SpillStore` 服务(`ctx.spillStore`)定义 spill 后端做什么,即持久化某个工具过大的文本,并返回面向模型的定位信息与取回指引;它不规定如何实现。 + +该包是 spill 功能的三个组成部分之一。拆分后,各项关注点可独立演进和替换: + +| 包 | 职责 | +|---|---| +| `@deepseek-ai/dsh-spill` (本包) | 接口:抽象服务与词汇类型 | +| `@deepseek-ai/dsh-spill-local` | 实现:位于宿主文件系统中的私有会话级文件 | +| `@deepseek-ai/dsh-spill-policy` | 对过大最终结果执行 spill 的工具结果策略 | + +这种拆分方式与 bash/fs seam 相同。未来的远程或虚拟后端(例如 `spill://…` URI、数据库键或后端专用取回工具)可实现此接口,无需修改策略插件。 + +## 服务 API(`ctx.spillStore`) + +| 成员 | 语义 | +|---|---| +| `saveText(input)` | 逐字保存 `input.content`;解析并返回 `SpillRef`(不透明定位信息、写入的精确字节数和取回指引)。如果出现真实存储故障(权限、ENOSPC、后端不可用),则**拒绝**;由调用方决定如何降级。 | + +存储操作以请求的 `owner` 会话作为保存时命名空间进行分组;后端自行选择私有表示,并可以从调用方的 `suggestedName` 派生名称,但绝不能将其当作可信路径。该 seam 只负责存储:不提供保留策略(由 [`@deepseek-ai/dsh-retention`](../../util/retention) 负责),不替换工具结果(由 `@deepseek-ai/dsh-spill-policy` 负责),也不提供取回/搜索 API(后端的 `retrievalHint` 会告诉模型如何使用定位信息)。 + +## 词汇 + +`SaveTextSpill` (owner、source、suggestedName、content)是请求;`SpillRef` (locator、bytes、retrievalHint)是结果。`SpillLocator` 已经[品牌化](../../util/brand),并以不透明字符串的形式呈现给模型;对 `dsh-spill-local` 而言它是本地路径,但未来的后端可以返回 URI、键或命令 token,无需修改策略/工具消费方。`SpillOwner.sessionId` 是保存时存储命名空间:fork 后的会话会从种子日志继承现有定位信息,无需复制文件或更改其归属;fork 后新产生的 spill 使用子会话 id。`SpillSource` (toolName、callId、label)是供后端命名和检查使用的描述性来源信息,而非访问控制信息。完整契约见 `src/types.ts`。 + +设计原理见[工具输出 spill Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md),其中说明了为什么创建操作应由运行时 spill seam 而非面向模型的 `write` 工具承担。 + +## 模型体验 + +通过渲染后端定位信息和取回指引的 spill 消费方间接影响模型。 + +#### KV 缓存影响 + +不直接导致失效;指定的消费方负责其引起的任何请求前缀变更。 + +## 已知限制与待完成工作 + +- **该 seam 没有取回或删除 API**:消费方只能渲染后端的定位信息与指引;生命周期和访问语义仍由后端自行决定。 +- **存储不等于访问控制**:`SpillOwner` 会区分写入命名空间,但不会授予定位信息的读取权限;每个后端和取回消费方都必须自行强制执行访问边界。 diff --git a/packages/storage/README.i18n.yaml b/packages/storage/README.i18n.yaml new file mode 100644 index 0000000000..01831e2e2b --- /dev/null +++ b/packages/storage/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: c9eca7355fd63e1023e9723486c86673e4c3574b +README.zh.md: 4e848f16ea670eb4832a45eb7b7d28be3cb2da90 diff --git a/packages/storage/README.md b/packages/storage/README.md index fc541e7ea5..c9eca7355f 100644 --- a/packages/storage/README.md +++ b/packages/storage/README.md @@ -1,5 +1,7 @@ # storage/ — non-session storage family +English | [中文](README.zh.md) + The storage family persists everything that is not a session event log: a hub where named backends and typed data forms meet. Design record: [domain KV storage Agent Note](../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md). | Package | Role | ctx key | diff --git a/packages/storage/README.zh.md b/packages/storage/README.zh.md new file mode 100644 index 0000000000..4e848f16ea --- /dev/null +++ b/packages/storage/README.zh.md @@ -0,0 +1,14 @@ +# storage/:非会话存储家族 + +[English](README.md) | 中文 + +存储家族持久化会话事件日志之外的一切数据:命名后端与类型化数据形式在一个中心相接。设计记录:[领域 KV 存储 Agent Note](../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md)。 + +| 包 | 职责 | ctx key | +|---|---|---| +| `storage/` | 中心:命名后端注册表 + 可合并扩展的数据形式挂载、后端 facet 词汇、共享一致性测试套件 | `ctx.storage` | +| `storage-json/` | JSON 后端:每个单元一个人类可读文件,以原子方式重写整个文件 | 注册后端 `json` | +| `storage-sqlite/` | SQLite 后端:一个数据库承载所有已路由单元,每行一个文档 | 注册后端 `sqlite` | +| `domain/` | 领域数据形式:经 zod 验证的记录、逐领域写入链、`domain/changed` 事件、按配置路由后端 | `ctx.storageDomain` + `ctx.storage.domain` | + +每个后端拥有一种介质,并公开数据形状 **facet**(目前为 `kv`;为未来的会话后端迁移预留 append-log facet)。每个后端插件都会在注册后发布内部生命周期服务;领域插件在公开自身服务前注入每个已配置的后端 key,因此配置树中的行序不携带启动语义。消费方绝不直接接触后端,而是注入 `storageDomain` 并通过它打开已声明的领域。 diff --git a/packages/storage/storage-domain/README.i18n.yaml b/packages/storage/storage-domain/README.i18n.yaml new file mode 100644 index 0000000000..dc2e052c11 --- /dev/null +++ b/packages/storage/storage-domain/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 41e777c3d9a870530593a414839f905514a27134 +README.zh.md: cbef75e4ec445afae0a218259fe717fef6e23ae2 diff --git a/packages/storage/storage-domain/README.md b/packages/storage/storage-domain/README.md index 3a707f0b16..41e777c3d9 100644 --- a/packages/storage/storage-domain/README.md +++ b/packages/storage/storage-domain/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-storage-domain +English | [中文](README.zh.md) + Domain data form for the DeepSeek Harness storage hub: exposes the injectable `ctx.storageDomain` service and the matching `ctx.storage.domain` projection after every configured backend is registered. A domain is declared once with `defineDomain` (zod record schemas, `z.infer`-derived types), opened through `DomainFacility.open`, and served from authoritative in-memory state — reads are synchronous, writes serialize on one per-domain chain, reach durability on the routed backend first, then update memory and emit `domain/changed`. The opening consumer owns the handle's lifecycle and releases it with `Domain.close()` (idempotent; typically its own `ctx.effect` disposer); domains still open when the plugin unmounts are closed by the facility. Design rationale, open semantics, and the storage/domain layer split live in the [Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md). diff --git a/packages/storage/storage-domain/README.zh.md b/packages/storage/storage-domain/README.zh.md new file mode 100644 index 0000000000..cbef75e4ec --- /dev/null +++ b/packages/storage/storage-domain/README.zh.md @@ -0,0 +1,35 @@ +# @deepseek-ai/dsh-storage-domain + +[English](README.md) | 中文 + +DeepSeek Harness 存储中心的领域数据形式:在每个已配置后端注册后,公开可注入的 `ctx.storageDomain` 服务及对应的 `ctx.storage.domain` 投影。一个领域通过 `defineDomain`(zod 记录 schema、从 `z.infer` 派生的类型)声明一次,通过 `DomainFacility.open` 打开,并由具有最终决定权的内存状态提供服务:读取同步执行;写入在一条逐领域链上串行化,先在已路由后端达到持久状态,再更新内存并发出 `domain/changed`。打开消费方拥有 handle 的生命周期,并通过 `Domain.close()` 释放它(幂等;通常作为其自身的 `ctx.effect` disposer);插件卸载时,facility 会关闭仍处于打开状态的领域。 + +设计原理、打开语义和存储/领域分层见 [Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md)。 + +## 配置 + +| key | 含义 | +| --- | --- | +| `backend` | 每个领域的默认后端名称(必填;不存在普遍正确的介质)。 | +| `routes` | 逐领域覆盖:领域名称 → 后端名称。 | + +## 模型体验 + +### 持久领域状态 + +#### 模型看到的内容 + +无。该包不注册工具、不注入提示词,也不追加会话事件;它在 `ctx.storageDomain` 后面存储非会话数据(Workspace 记录、未来的会话伴随元数据),只发出进程内 `domain/changed` 事件。只有消费方包通过自身已记录的表层渲染该事件时,它才会到达模型。 + +#### Token 影响 + +为零。该包的文本不会进入任何模型请求。 + +#### KV Cache 影响 + +相互独立:领域读写绝不触碰请求前缀,因此这里没有任何内容能使提供方 cache 复用失效。 + +## 已知限制与暂缓事项 + +- **变更只在单进程内可见**:`domain/changed` 是进程内事件;在 Agent Note 暂缓的跨进程 revision 模式落地前,第二个主机进程或重新连接的 GUI 无法观察变更。 +- **没有跨表事务、二级索引或多 segment key**:每次写入只触碰一条记录;这些扩展的 trigger 和返工点列在 Agent Note 的暂缓工作清单中。 diff --git a/packages/storage/storage-json/README.i18n.yaml b/packages/storage/storage-json/README.i18n.yaml new file mode 100644 index 0000000000..efc252e025 --- /dev/null +++ b/packages/storage/storage-json/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: c3417846a70f8a227a19a5abf834156d530e3597 +README.zh.md: 6a566aa1c2cdeac18760ec6e5964a623a3643928 diff --git a/packages/storage/storage-json/README.md b/packages/storage/storage-json/README.md index 5cf0fc9d61..c3417846a7 100644 --- a/packages/storage/storage-json/README.md +++ b/packages/storage/storage-json/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-storage-json +English | [中文](README.zh.md) + JSON backend for the [storage hub](../storage/README.md): one human-readable `<unit>.json` file per unit under a configured root, registered as backend `json`. Design: [domain KV storage Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md). ## Model diff --git a/packages/storage/storage-json/README.zh.md b/packages/storage/storage-json/README.zh.md new file mode 100644 index 0000000000..6a566aa1c2 --- /dev/null +++ b/packages/storage/storage-json/README.zh.md @@ -0,0 +1,38 @@ +# @deepseek-ai/dsh-storage-json + +[English](README.md) | 中文 + +[存储中心](../storage/README.md)的 JSON 后端:配置根目录下每个单元使用一个人类可读的 `<unit>.json` 文件,注册为后端 `json`。设计见[领域 KV 存储 Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md)。 + +## 模型 + +- 内存中的单元状态具有最终决定权;每个写入原语都会通过临时写入 + fsync + 原子 `rename()` 替换重新发布整个文件。单元文件始终是完整的当前净状态:可读性是该后端存在的理由,规模问题则属于 SQLite 后端。 +- 缺失文件会作为空单元打开,并在第一次写入时物化。外部或无法解析的文件以 `malformed-medium` 拒绝;已存版本与 descriptor 不同时以 `version-mismatch` 拒绝(预发布立场,不迁移)。 +- 跨调用的写入顺序属于调用方(领域层的写入链);每个单独调用具备原子性,并在 resolve 后持久。 + +## 配置 + +| Key | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `root` | string | 必填,无默认值(cwd 回退会让文件散落各处) | 保存单元文件的目录;按需以 `0o700` 创建 | + +## 模型体验 + +### 已存领域记录 + +#### 模型看到的内容 + +无。该后端不贡献提示词、工具或 schema;它在 `ctx.storage` 后面持久化非会话领域数据,只供主机侧消费方使用。 + +#### Token 影响 + +实时请求 token 为零。 + +#### KV Cache 影响 + +无:该后端从不触碰实时请求前缀。 + +## 已知限制与暂缓事项 + +- Windows 持久性依赖 libuv 的 `rename()`(使用替换的 `MoveFileExW`),没有显式 write-through 标志;append-log facet 落地时,计划把会话日志后端更严格的 Win32 write-through 发布辅助函数下移到此处(见 Agent Note 的迁移章节)。 +- 没有跨进程写锁:两个进程写入同一根目录时,可能交错执行整文件替换(最后写入者胜出)。当前消费方采用单主机进程部署;多进程方案按 Agent Note 的范围外表格暂缓。 diff --git a/packages/storage/storage-sqlite/README.i18n.yaml b/packages/storage/storage-sqlite/README.i18n.yaml new file mode 100644 index 0000000000..389f4f99f4 --- /dev/null +++ b/packages/storage/storage-sqlite/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: efc1b3ca54181a43c067594cc339ccc3a8fea510 +README.zh.md: bcf2dd44c5334ec0c2b6623d22244b3b6b77f18e diff --git a/packages/storage/storage-sqlite/README.md b/packages/storage/storage-sqlite/README.md index 272b27f979..efc1b3ca54 100644 --- a/packages/storage/storage-sqlite/README.md +++ b/packages/storage/storage-sqlite/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-storage-sqlite +English | [中文](README.zh.md) + SQLite backend for the [storage hub](../storage/README.md): registers as backend `sqlite`, serving the `kv` facet over one `node:sqlite` database file (or `:memory:`). Design and trade-offs: [domain KV storage Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md). ## Storage model diff --git a/packages/storage/storage-sqlite/README.zh.md b/packages/storage/storage-sqlite/README.zh.md new file mode 100644 index 0000000000..bcf2dd44c5 --- /dev/null +++ b/packages/storage/storage-sqlite/README.zh.md @@ -0,0 +1,43 @@ +# @deepseek-ai/dsh-storage-sqlite + +[English](README.md) | 中文 + +[存储中心](../storage/README.md)的 SQLite 后端:注册为后端 `sqlite`,通过一个数据库文件提供 `kv` facet;该文件使用 `node:sqlite`(也可以是 `:memory:`)。设计与取舍见[领域 KV 存储 Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md)。 + +## 存储模型 + +每行一个文档:每个单元表都会成为一个物理 STRICT 表 `"u_<unit>_<table>" (key TEXT PRIMARY KEY, value TEXT)`,其中 `value` 是记录的 JSON 文本,因此一个 key 只更新一行(高频变更领域路由到这里而非 JSON 后端的原因)。单元标识位于两个元数据表中:`units` 在单元首次打开时标记其格式版本,descriptor 不同时以 `version-mismatch` 拒绝;`unit_globals` 保存每个单元的全局 singleton 行。物理布局版本位于 `PRAGMA user_version`;其他任何标记值都会被拒绝(未发布格式,不迁移)。单元名和表名在进入 DDL 之前依据中心的 `UNIT_NAME_RE` 接受验证,因此不会把外部输入插值到 SQL 标识符中。 + +每个写入原语都是一条 prepared statement:SQLite 的逐语句原子性无需显式事务即可满足 KV 契约,写入顺序仍由调用方负责(领域层写入链)。缺失目录和数据库文件会以仅 owner 可访问的权限创建(`0o700`/`0o600`),与 session-persistence SQLite 后端一致;在计划的介质层提取完成前,该包逐字复用了后者的打开顺序。 + +## 配置(schemastery) + +```ts +interface Config { + path: string // SQLite database file path, or ':memory:' for an in-process DB + journalMode?: 'wal' | 'delete' | 'truncate' | 'persist' // journal_mode pragma; default 'wal' +} +``` + +## 模型体验 + +### 已存领域记录 + +#### 模型看到的内容 + +无。该后端不贡献提示词、工具或 schema;它在 `ctx.storage` 后面持久化非会话领域数据(Workspace 记录、未来的会话伴随元数据),只供主机侧消费方使用。 + +#### Token 影响 + +实时请求 token 为零。 + +#### KV Cache 影响 + +无:该后端从不触碰实时请求前缀。 + +## 已知限制与暂缓事项 + +- **`DatabaseSync` 是同步的**:每次写入会在其持续时间内阻塞事件循环(一条语句);在领域数据规模下可以接受。 +- **没有 busy-wait 或重试策略**:另一个连接持有写事务时,该操作会立即被拒绝;多进程写入保护列在设计的未来工作清单中。 +- **只打开当前的 `STORAGE_SQLITE_SCHEMA_VERSION`**:其他任何已标记版本都会被拒绝而不是迁移(预发布立场)。 +- **`openDatabase` 重复了 session-persistence SQLite 打开顺序**:提取到共享介质层的工作暂缓至计划的会话后端迁移(见 Agent Note 的复用审计)。 diff --git a/packages/storage/storage/README.i18n.yaml b/packages/storage/storage/README.i18n.yaml new file mode 100644 index 0000000000..584d89f953 --- /dev/null +++ b/packages/storage/storage/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 994285842925539e73f8f11780f19495f71d0280 +README.zh.md: 43cf542ceec7284228495e563dfa69b5c9af3157 diff --git a/packages/storage/storage/README.md b/packages/storage/storage/README.md index c2f7da9fd1..9942858429 100644 --- a/packages/storage/storage/README.md +++ b/packages/storage/storage/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-storage +English | [中文](README.zh.md) + Storage hub (`ctx.storage`) for non-session data: a named backend registry plus mounted data-form facilities. The hub performs no IO itself — backends own media, data forms own semantics. Design and trade-offs: [domain KV storage Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md). ## Shape diff --git a/packages/storage/storage/README.zh.md b/packages/storage/storage/README.zh.md new file mode 100644 index 0000000000..43cf542cee --- /dev/null +++ b/packages/storage/storage/README.zh.md @@ -0,0 +1,41 @@ +# @deepseek-ai/dsh-storage + +[English](README.md) | 中文 + +非会话数据的存储中心(`ctx.storage`):命名后端注册表加已挂载的数据形式 facility。中心自身不执行 IO:后端拥有介质,数据形式拥有语义。设计与取舍见[领域 KV 存储 Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md)。 + +## 形状 + +- `ctx.storage.backend`:名称 → 后端表。多个后端并排保持挂载(`json`、`sqlite`);为消费方提供服务的后端由该消费方自身的配置决定(领域层的路由表),绝非中心的全局选择。`register()` 返回 disposer;重复名称和未知 lookup 会高声失败。 +- `ctx.storage.mount(form, facility)`/`ctx.storage.form(form)`:数据形式挂载。`StorageForms` 可合并扩展;领域层合并 `domain`,并通过 `ctx.storage.domain` 访问。 +- 后端拥有一种介质(文件树根、数据库文件),并公开可选的数据形状 **facet**:目前为 `kv`;为未来的会话后端迁移预留 append-log facet。`src/backend.ts` 是规范契约文本;`tests/contract.ts` 导出每个后端都会运行的共享一致性测试套件。 + +## 该分组中的包 + +| 包 | 职责 | +| --- | --- | +| `dsh-storage` | 中心服务 + 后端词汇 + 共享一致性测试套件 | +| `dsh-storage-json` | JSON 后端:每个单元一个人类可读文件,以原子方式重写整个文件 | +| `dsh-storage-sqlite` | SQLite 后端:一个数据库承载所有已路由单元,每行一个文档 | +| `dsh-storage-domain` | 领域数据形式(`ctx.storage.domain`):类型化 schema、写入链、变更事件 | + +## 模型体验 + +### 后端与形式注册 + +#### 模型看到的内容 + +无。`ctx.storage` 是主机侧注册表;中心不注册工具、不注入提示词,也不写入会话事件。 + +#### Token 影响 + +每次请求的直接 token 为零。 + +#### KV Cache 影响 + +与实时请求相互独立:中心绝不触碰请求前缀,因此无法使提供方 cache 复用失效。 + +## 已知限制与暂缓事项 + +- **`kv` 是唯一的数据形状**:设计记录为未来的会话后端迁移预留了 append-log facet,但尚未定义;后端目前恰好只有一个 facet 需要实现。 +- **形式惰性解析**:在领域插件挂载前读取 `ctx.storage.domain` 会抛出 `form-not-mounted`;组装会按相应顺序排列插件(错误配置会高声失败,而不是静默等待)。 diff --git a/packages/subagent/README.i18n.yaml b/packages/subagent/README.i18n.yaml new file mode 100644 index 0000000000..7be70e0fe5 --- /dev/null +++ b/packages/subagent/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 5e3bddc67d213d74766a75da65cc44a21c8bb149 +README.zh.md: 4391809ee83c822fcada25f0bdc021af44be9354 diff --git a/packages/subagent/README.md b/packages/subagent/README.md index ccc6ab9cba..5e3bddc67d 100644 --- a/packages/subagent/README.md +++ b/packages/subagent/README.md @@ -1,5 +1,7 @@ # subagent/ — subagent capability family +English | [中文](README.zh.md) + The subagent seam: an agent delegating work to a child agent. Like the [bash](../bash/README.md) and [llm](../llm/README.md) families this is a capability seam (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)) — but with one defining difference: **multiple provider implementations coexist in one context**, registered by name, rather than the single-implementation bash shape. The registry mirrors the LLM adapter registry. | Package | Role | ctx key | diff --git a/packages/subagent/README.zh.md b/packages/subagent/README.zh.md new file mode 100644 index 0000000000..4391809ee8 --- /dev/null +++ b/packages/subagent/README.zh.md @@ -0,0 +1,19 @@ +# subagent/:subagent 能力族 + +[English](README.md) | 中文 + +subagent seam 允许 agent(智能体)把工作委派给子 agent。与 [bash](../bash/README.md) 和 [llm](../llm/README.md) 能力族一样,这也是一种能力 seam(见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)),但有一个关键差异:**多个提供方实现在同一上下文中共存,并按名称注册**,而不是采用 bash 的单实现形态。该注册表仿照 LLM(大语言模型)适配器注册表。 + +| 包 | 角色 | ctx 键 | +|---|---|---| +| `subagent/` | 抽象 subagent seam:具名提供方注册表与词汇 | `ctx.subagents` | +| `subagent-inprocess/` | 共享进程内运行驱动器(不提供提供方;每次运行使用一个清理 effect) | 无 | +| `subagent-spawn/` | 进程内后端:全新的子 agent | (注册到 `ctx.subagents`) | +| `subagent-fork/` | 进程内后端:以父 agent 已完成轮次的前缀作为初始内容的子 agent | (注册到 `ctx.subagents`) | +| `subagent-subprocess/` | 共享进程外机制:环境变量清理、dispose(资源释放)阶梯、隔离配置目录(纯库;不注册任何内容) | 无 | +| `subagent-acp/` | 进程外后端:在派生子进程中运行并通过 ACP(Agent Client Protocol)驱动的子 agent | (注册到 `ctx.subagents`) | +| `tool-subagent/` | 面向模型的 `subagent` 委派工具,基于 `ctx.subagents` | (注册到 `ctx.tools`) | + +接口位于 `subagent/subagent/`。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不提供提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` 后端则构建于 `subagent-subprocess` 库之上(凭据环境变量清理、dispose 阶梯、隔离配置目录)。测试只用包内 fixture(测试前置数据)替换子 agent 边界。 + +提案与设计理由见 [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)。 diff --git a/packages/subagent/subagent-acp/README.i18n.yaml b/packages/subagent/subagent-acp/README.i18n.yaml new file mode 100644 index 0000000000..72377b5f77 --- /dev/null +++ b/packages/subagent/subagent-acp/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: d1ba03cf5256ad4889c4893bfe11af42bd627f9d +README.zh.md: 5763ee9a22c1d0bfe12c7da2b7d996911b55cc49 diff --git a/packages/subagent/subagent-acp/README.md b/packages/subagent/subagent-acp/README.md index 45d9abe2b8..d1ba03cf52 100644 --- a/packages/subagent/subagent-acp/README.md +++ b/packages/subagent/subagent-acp/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-subagent-acp +English | [中文](README.zh.md) + The ACP provider runs each subagent in a fresh subprocess and drives it as an Agent Client Protocol client. It is the out-of-process alternative to spawn and fork: the child has its own runtime, session, model configuration, and tools. ## Start and ownership diff --git a/packages/subagent/subagent-acp/README.zh.md b/packages/subagent/subagent-acp/README.zh.md new file mode 100644 index 0000000000..5763ee9a22 --- /dev/null +++ b/packages/subagent/subagent-acp/README.zh.md @@ -0,0 +1,103 @@ +# @deepseek-ai/dsh-subagent-acp + +[English](README.md) | 中文 + +ACP(Agent Client Protocol)提供方会在全新的子进程中运行每个 subagent,并作为 Agent Client Protocol 客户端驱动它。这是 spawn 与 fork 的进程外替代方案:子 agent(智能体)拥有自己的运行时、会话、模型配置和工具。 + +## 启动与所有权 + +`start(request)` 先解析子 agent 的工作目录,再依次执行 `spawn` → ACP `initialize` → `newSession`,然后才兑现。因此,兑现表示远程会话已就绪,所有权也已转移给调用方。派生、初始化、新建会话或发布前取消失败时,只有在子进程已回收后才会拒绝;工作目录解析失败则会在派生任何内容前拒绝。 + +工作目录优先使用已配置的 `cwd` 覆盖值,否则使用执行委派的父会话 cwd,绝不使用服务器进程自身的 cwd,因为同一个服务器进程会服务来自多个工作区的会话。从父级取得的值必须是绝对路径,指向 harness 可以进入的目录(具备搜索权限,这是子进程 cwd 的要求);解析后的同一路径同时作为子进程 cwd 和 ACP `session/new` 工作区。 + +返回的运行 id 在父级命名空间中生成。子服务器的会话 id 只用于 ACP 协议调用,因为 ACP 只保证它在该全新子进程中唯一;若将其用作父级生命周期 id,可能与另一个远程运行或本地 agent 冲突。 + +发布后,提供方发送提示词,并把流式 `agent_message_chunk` 文本收集到 `SubagentResult.output`。提示词/传输失败会以 `stopReason: 'error'` 兑现;如果必需的请求信号或 dispose 请求了取消,则以 `aborted` 兑现。 + +`dispose()` 是幂等的。它会移除信号监听器,在可行时请求 ACP 取消,关闭 stdin,并等待 `disposeEofGraceMs`。随后 POSIX 先升级到 SIGTERM,等待 `disposeGraceMs` 后再使用 SIGKILL;Windows 会直接强制终止,因为 Node 会把两个信号都映射到 `TerminateProcess`。强制终止后,各平台最多再等待 `disposeGraceMs` 以确认退出;若信号出错或未退出,则拒绝。每次运行都使用全新进程;尚未实现进程池。 + +## 能力与上下文 + +ACP 不声明任何启动时能力,因为当前进程无法强制执行远程子 agent 的深度、工具过滤、persona 或结构化输出运行时。它也报告 `inheritsParentContext: false`:远程会话从全新状态开始,唯一源自父级的输入是上述工作区 cwd;对话上下文不会跨越进程边界。 + +## 配置 + +| 键 | 默认值 | 含义 | +|---|---|---| +| `providerName` | `acp` | `ctx.subagents` 上的注册表名称。 | +| `command` | 必填 | 每次运行时派生的可执行文件。 | +| `args` | `[]` | 命令参数。 | +| `cwd` | 父会话 cwd | 子进程及其 ACP 会话的工作目录覆盖值;不得为空。相对值会在加载时以 harness 启动目录为基准解析,结果必须指向 harness 可以进入的目录。 | +| `permission` | `reject` | 自动回答权限请求:拒绝,或选择第一个允许形态的选项。 | +| `env` | `{}` | 显式子进程环境,叠加到已清理凭据的父进程环境之上。 | +| `disposeEofGraceMs` | `6000` | stdin EOF 之后、平台终止之前的宽限时间。 | +| `disposeGraceMs` | `3000` | 终止后的退出确认宽限时间;POSIX 在 SIGTERM 后、SIGKILL 前也会等待同样时长。 | + +```yaml +- id: subagent-acp + name: '@deepseek-ai/dsh-subagent-acp' + config: + providerName: acp + command: node + args: ['--import', 'tsx', './packages/examples/acp-demo/src/bin.ts', '--config', './examples/acp-agent/cordis.yml'] + permission: reject + env: + DEEPSEEK_API_KEY: !!js process.env.DEEPSEEK_API_KEY +``` + +## 结束原因映射 + +| ACP | Harness | +|---|---| +| `end_turn` | `completed` | +| `max_tokens` | `max-tokens` | +| `refusal` | `refusal` | +| `cancelled` | `aborted` | +| `max_turn_requests` 或未知值 | `error` | + +## 进程边界 + +子进程环境由 [`buildChildEnv`](../subagent-subprocess/README.md) 构建:先移除名称形似凭据的环境变量,再应用显式 `config.env` 值。ACP 协议是真正的序列化边界;同进程 subagent 值不会为防御目的而克隆。 + +本包没有默认导出。否则 Cordis loader 的解包会隐藏具名 `inject` 元数据;见[事故复盘 0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)。 + +无密钥测试通过真实 stdio 驱动脚本化 ACP 子进程,其中包括一个由 Loader 组合的 stdio 应用,用于端到端证明父会话 cwd 继承。带密钥 e2e 会驱动仓库中的真实 ACP agent;没有 `DEEPSEEK_API_KEY` 时自行跳过。 + +## 模型体验 + +### 子 agent 请求 + +#### 模型看到的内容 + +远程子 agent 通过 ACP 接收独立任务内容,并使用其自身进程配置的系统提示词、工具和全新会话。它不接收父级对话。该提供方不声明任何可选启动时能力,因此本地服务会拒绝要求 persona、工具过滤、深度强制或结构化输出的请求,而不是静默省略这些要求。 + +#### Token 影响 + +子 agent 为独立的完整上下文及其多步骤历史支付 token 成本。这些 token 绝不会进入父级上下文。 + +#### KV Cache 影响 + +与父级请求缓存相互独立。每个 ACP 子 agent 只能在其自身提供方、模型、组合和历史均相同时复用前缀;其余情况下,子 agent 步骤仅追加增长。 + +### 父级工具结果(间接) + +#### 模型看到的内容 + +通过 `dsh-tool-subagent`,父级只接收子 agent 最终的流式 assistant 文本,或该消费方给出的精确结束原因错误;不接收中间消息或工具流量。发布前已经取消的请求会精确变为 `Error: subagent request was aborted before the ACP child started`;其他启动失败按原样传递为 `Error: <message>`。 + +#### Token 影响 + +父级输入只增加最终结果或错误,其内容依赖数据,并保留到上下文压缩为止。该提供方自身不会添加父级 schema。 + +#### KV Cache 影响 + +仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 + +## 已知限制与延期工作 + +- **每次运行使用全新进程**:持久进程池属于后续优化(见 [seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md))。 +- **仅支持本地工作区**:解析后的 cwd 是交给同一台机器上子进程的本地路径;远程 ACP agent 的工作区映射需要独立的后端能力,本包尚未设计。 +- **不支持可选启动时能力**:该提供方无法在远程进程内应用本地 harness 的 `outputSchema`、深度上限、工具过滤器或 persona,因此不会声明这些能力;服务会拒绝需要它们的请求。 +- **只收集已提交的 `agent_message_chunk` 文本**:自动化服务器把推理、工具活动、计划和其他 trace 数据保留在子 agent 会话日志中,不通过 ACP 发出。 +- **权限提示自动回答**(`permission: allow | reject`):当前版本不会把子 agent 的 `session/request_permission` 呈现给人。 +- **没有快照层回放覆盖率**(`TODO(acp-subagent-replay)`):ACP 子 agent 拥有独立进程和独立回放形态,该工作延期处理。 diff --git a/packages/subagent/subagent-fork/README.i18n.yaml b/packages/subagent/subagent-fork/README.i18n.yaml new file mode 100644 index 0000000000..73456dc604 --- /dev/null +++ b/packages/subagent/subagent-fork/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: b448dc309bff07c744443530a648c7c30e4d20d9 +README.zh.md: be6730ebe23510a017ffa80563016133dc87cae3 diff --git a/packages/subagent/subagent-fork/README.md b/packages/subagent/subagent-fork/README.md index 5e4b9bf708..b448dc309b 100644 --- a/packages/subagent/subagent-fork/README.md +++ b/packages/subagent/subagent-fork/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-subagent-fork +English | [中文](README.zh.md) + The fork provider creates an in-process child seeded with the parent's completed conversation turns. It shares all run mechanics with spawn; the session seed is the only behavioral difference. ## Seed boundary diff --git a/packages/subagent/subagent-fork/README.zh.md b/packages/subagent/subagent-fork/README.zh.md new file mode 100644 index 0000000000..be6730ebe2 --- /dev/null +++ b/packages/subagent/subagent-fork/README.zh.md @@ -0,0 +1,61 @@ +# @deepseek-ai/dsh-subagent-fork + +[English](README.md) | 中文 + +fork 提供方会创建一个进程内子 agent(智能体),并以父 agent 已完成的对话轮次作为初始内容。它与 spawn 共用全部运行机制;唯一的行为差异是会话初始内容。 + +## 初始内容边界 + +subagent 启动时,父 agent 当前的工具调用轮次仍未结束:其日志包含 assistant 工具调用,但尚无匹配的工具结果或 `turn/end`。直接复制这份原始日志会给子 agent 一个无效且不平衡的会话。 + +因此,fork 会计算截至最后一个 `turn/end` 的连续前缀。子 agent 能看到父 agent 所有已完成轮次,但看不到进行中的轮次。如果父 agent 尚未完成任何轮次,初始内容为空,子 agent 的行为与全新 spawn 相同。 + +初始内容只传递对话历史。子 agent 仍会获得全新的扁平注册作用域;它不继承父 agent 的工具限制或权限。 + +## 启动与能力 + +`start(request)` 将已完成轮次的初始内容传给 [`startInProcessRun`](../subagent-inprocess/README.md),并等待子 agent 发布。共享驱动器负责取消、深度、定制、结果读取和 dispose(资源释放)。 + +fork 声明 `{ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }`,与 spawn 相同。 + +## 配置 + +| 键 | 含义 | +|---|---| +| `providerName` | `ctx.subagents` 上的注册表名称(默认 `fork`)。 | +运行生命周期、模型继承与深度跟踪均为共享行为,见 [`dsh-subagent-spawn`](../subagent-spawn/README.md)。 + +## 模型体验 + +### 子 agent 历史与包络 + +#### 模型看到的内容 + +子 agent 先接收父 agent 平衡的已完成轮次界面前缀,再逐字接收新的任务内容。配置的 persona 会在子 agent 的全新作用域中遮蔽提示词文本;工具限制会过滤其全局协议 schema、可执行工具查找和 Code Mode SDK 绑定,但不影响独立注册的指导内容。父 agent 的工具视图与权限不会被继承。可选的结构化输出请求会添加仅属于子 agent 的契约。父 agent 当前进行中的轮次会被排除。 + +#### Token 影响 + +fork 会把保留的已完成历史复制到独立的子 agent 请求中;随后子 agent 独立累积自己的 token。persona 会改变重复提示词的成本,过滤会改变 schema 或生成 SDK 的成本,而首轮 fork 没有继承历史。 + +#### KV Cache 影响 + +在提供方和模型相同的前提下,子 agent 可以复用继承的逐字节相同前缀。persona、工具过滤、生成 SDK 或路由变化可能在继承历史之前使复用失效;后续子 agent 历史仅追加。 + +### 父 agent 工具结果(间接) + +#### 模型看到的内容 + +父 agent 只通过 `dsh-tool-subagent` 接收子 agent 自身的最终输出,不接收继承的前缀或中间工作。 + +#### Token 影响 + +父 agent 输入增加一个依赖数据的最终结果,并保留到上下文压缩(compaction)为止。 + +#### KV Cache 影响 + +仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 + +## 已知限制与延期工作 + +- **运行不公开 `sendMessage`/`resume`**:进程内运行不具备这些可选运行时能力。 +- **初始内容是一次性快照**:子 agent 只能看到 fork 时父 agent 已完成的轮次,看不到父 agent 此后记录的任何内容;不会实时共享上下文。 diff --git a/packages/subagent/subagent-inprocess/README.i18n.yaml b/packages/subagent/subagent-inprocess/README.i18n.yaml new file mode 100644 index 0000000000..2f11ff88b5 --- /dev/null +++ b/packages/subagent/subagent-inprocess/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: d4483b7ff3aa3869f496c2bf2c41ac2fd151f4ef +README.zh.md: 765580a1614e8ec46e751334f8618f6ca1380392 diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 48c72e9cf0..d4483b7ff3 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-subagent-inprocess +English | [中文](README.zh.md) + This package is the shared run driver for the two in-process providers. Spawn passes no session seed; fork passes the parent's completed-turn prefix. Everything else—depth, child creation, optional child customization, result reading, cancellation, and disposal—has one implementation here. ## Start contract diff --git a/packages/subagent/subagent-inprocess/README.zh.md b/packages/subagent/subagent-inprocess/README.zh.md new file mode 100644 index 0000000000..765580a161 --- /dev/null +++ b/packages/subagent/subagent-inprocess/README.zh.md @@ -0,0 +1,112 @@ +# @deepseek-ai/dsh-subagent-inprocess + +[English](README.md) | 中文 + +本包是两个进程内提供方共用的运行驱动器。spawn 不传入会话初始内容;fork 传入父 agent(智能体)已完成轮次的前缀。其余机制,包括深度、子 agent 创建、可选的子 agent 定制、结果读取、取消和 dispose(资源释放),都在此共用同一套实现。 + +## 启动契约 + +`startInProcessRun(request, options): Promise<SubagentRun>` 只在子 agent 发布到 `ctx.agents` 后才兑现。启动被拒绝时,agent 工厂的未发布创建事务已经完全停稳,因此调用方绝不会收到创建到一半的句柄。 + +驱动器按以下顺序运行: + +1. 校验父 agent 深度和可选的绝对 `maxDepth`,然后把子 agent 深度推导为父 agent 深度加一,并将其持久化到子 agent 会话 header。 +2. 直接调用 `parent.ctx.agents.create`,把必需的请求信号传入工厂的创建事务。 +3. 在该事务未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时。 +4. 发布子 agent,保留返回的 `AgentHandle`,并通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。 +5. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除任何 fork 初始内容和后续由插件拥有的零步骤轮次。 + +子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 模型。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。 + +## 取消与所有权 + +必需的请求信号同时覆盖启动阶段和实时运行。发布前,`AgentCreationTransaction` 会观察该信号、回滚并拒绝。工厂返回前会移除仅用于创建阶段的监听器;驱动器随即再次检查信号,然后安装最小化的实时运行监听器,从而消除交接竞态。发布后,中止会取消子 agent。 + +兑现后,调用方拥有该运行。提供方插件卸载不会撤销它。`dispose()` 会移除实时中止监听器、记录取消,并委托给返回的 `AgentHandle.dispose()`;后者通过可复用的完全停稳事务停止循环、移除 agent 和会话,并展开有作用域的注册。取消决定所有尚未完成的进行中结果,并将其报告为 `aborted`;已经完成的轮次仍保持完成状态。 + +## Spawn 与 fork 输入 + +`InProcessRunOptions` 的形态为 `{ seed?: SessionEvent[] }`。spawn 省略该值。fork 提供平衡的已完成轮次前缀,并记录其长度,确保结果读取器不会把作为初始内容的父 agent 消息误认为子 agent 输出。 + +深度强制在 `startInProcessRun` 内部完成:它通过 `delegationDepthOf` 读取父 agent 深度(持久化的 `SessionHeader.delegationDepth` 具有权威性;运行时 `AgentOptions.subagentDepth` 可以加深但绝不能降低该值,因此恢复后的子 agent 会保留预算),缺失值按顶层深度零处理,拒绝格式错误的存储值,并报告尝试的子 agent 深度超过 `maxDepth`。超过安全整数范围、无法表示的深度会触发 `RangeError`。子 agent 深度写入子 agent header,因此会在持久化和恢复后保留。 + +## 结构化输出 + +`attachStructuredRuntime(childCtx, schema)` 会在子 agent 作用域中安装完整契约: + +- 使用请求 schema 注册的 `structured_output` 工具会校验并暂存模型值。 +- 一个顺序为 190 的系统提示词段会告诉子 agent,该工具调用就是终态答案。 +- 两项贡献都是普通的子 agent 作用域注册。专家级 `system-prompt/assemble` 监听器可以替换它们,因此负责为该子 agent 保留结构化输出协议。 +- `tools/result` 观察器只会在该次执行的权威最终工具结果成功后提交暂存值;Code Mode 子分派外层的 `run_code` 结果也包括在内。 +- 单调工具防护会在捕获值后阻止后续调用,`agent/turn-stop` 则在结构化结果提交后结束轮次。 + +正常结束却始终未提交必需结构化值的轮次会报告 `error`;驱动器不会重新提示。所有注册都附着于子 agent fiber,并随其一同消失。 + +## 模型体验 + +### 子 agent 请求 + +#### 模型看到的内容 + +共享驱动器把任务逐字作为子 agent 的用户消息发送;若有请求,还会在未发布子 agent 的全新作用域中遮蔽 persona,并限制全局工具 schema、查找、执行和 Code Mode SDK 绑定。父 agent 的限制不会被继承,独立的工具指导段仍会保留。spawn 不提供历史;fork 提供平衡的初始内容。 + +#### Token 影响 + +子 agent 输入与父 agent 隔离,并通过子 agent 自身的步骤增长。persona 会改变重复提示词文本;过滤会改变 schema 或生成 SDK 的成本,但不影响独立注册的指导内容。 + +#### KV Cache 影响 + +与父 agent 请求缓存相互独立。子 agent 后续历史仅追加,而 persona、工具过滤、生成 SDK、提供方或模型变化会建立不同的子 agent 前缀。 + +### 结构化输出系统提示词、schema 与结果 + +#### 模型看到的内容 + +结构化运行会添加下方的结构化输出指令。它还会添加子 agent 作用域的 `structured_output` 定义,其精确描述为 `Report your final structured result. Call this exactly once, when your answer is complete; the arguments must match this tool's parameter schema exactly.`,参数使用请求的 schema。该仅运行时存在的定义不在已生成并随产品发布的[工具包索引](../../../docs/tool-catalog.md#tool-package-map)中。其规范确认值是 `{ recorded: true }`,渲染为 `Structured output recorded.`;后续调用会变为 ``Error: structured output already recorded: the run is complete, so `<tool>` is not executed``。 + +##### 结构化输出指令 + +```markdown +When you have your final answer, you MUST report it by calling the `structured_output` tool with arguments matching its parameter schema exactly. Do not finish with a plain text answer: only the tool call counts as your result. +``` + +#### Token 影响 + +固定指令和能力 token 仅由该子 agent 支付。结果文本进入子 agent 历史,而只有捕获的值会成为父 agent 结果。 + +#### KV Cache 影响 + +只要结构化输出指令和 schema 不变,子 agent 内部的前缀就保持稳定。更改 schema 或能力可能从该早期片段开始使子 agent 缓存失效;结果会分别追加到子 agent 和父 agent 历史中。 + +### 父 agent 启动错误(间接) + +#### 模型看到的内容 + +通过 `dsh-tool-subagent`,无效深度状态会精确变为 `Error: agent subagentDepth must be a non-negative safe integer`、`Error: subagent child depth exceeds the safe-integer range` 或 `Error: subagent depth <attempted> exceeds maxDepth <max>`。发布前取消的中止原因会通过注册表的 `Error: <message>` 包装传递。 + +#### Token 影响 + +启动成功时为零 token;只有失败的父 agent 工具调用会保留这段文本。 + +#### KV Cache 影响 + +仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 + +### 父 agent 结果(间接) + +#### 模型看到的内容 + +驱动器只提取子 agent 自身最后的 assistant 输出或捕获的结构化值;作为初始内容的父 agent 消息和子 agent 中间工作不会成为结果。 + +#### Token 影响 + +父 agent 通过消费方接收一个依赖数据的结果;其他所有子 agent token 都留在子 agent 会话中。 + +#### KV Cache 影响 + +仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 + +## 已知限制与延期工作 + +- **运行不公开 `sendMessage`/`resume`**:进程内运行不具备这些可选运行时能力。 +- **结构化捕获只接受 `defineTool` schema 子集**:不支持的 JSON Schema 构造会在子 agent 创建前失败;需要更广 schema 词汇的提供方必须采用不同的运行时。 diff --git a/packages/subagent/subagent-spawn/README.i18n.yaml b/packages/subagent/subagent-spawn/README.i18n.yaml new file mode 100644 index 0000000000..68d37783c7 --- /dev/null +++ b/packages/subagent/subagent-spawn/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 868f829edbcfe2eb4d66ccd0ff9988924c70298b +README.zh.md: 823291baae0ca09b86565516af1843aff033f354 diff --git a/packages/subagent/subagent-spawn/README.md b/packages/subagent/subagent-spawn/README.md index cd400689d7..868f829edb 100644 --- a/packages/subagent/subagent-spawn/README.md +++ b/packages/subagent/subagent-spawn/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-subagent-spawn +English | [中文](README.zh.md) + The spawn provider creates a fresh child `Agent` in the current process. The child has its own session, sees no parent conversation history, and reuses the host's agent factory and LLM/tool services. ## Behavior diff --git a/packages/subagent/subagent-spawn/README.zh.md b/packages/subagent/subagent-spawn/README.zh.md new file mode 100644 index 0000000000..823291baae --- /dev/null +++ b/packages/subagent/subagent-spawn/README.zh.md @@ -0,0 +1,56 @@ +# @deepseek-ai/dsh-subagent-spawn + +[English](README.md) | 中文 + +spawn 提供方会在当前进程中创建一个全新的子 `Agent`。子 agent(智能体)有自己的会话,看不到父 agent 的对话历史,并复用宿主的 agent 工厂及 LLM(大语言模型)/工具服务。 + +## 行为 + +`start(request)` 不提供初始内容,直接委托给 [`startInProcessRun`](../subagent-inprocess/README.md),并在子 agent 发布后才返回。子 agent 获得父 agent 的工作目录/会话谱系,并默认继承父 agent 模型(除非覆盖),但以空对话开始运行。 + +共享驱动器负责深度检查、persona 与工具过滤器设置、结构化输出、必需信号取消、单次执行、结果读取和完全停稳后的 dispose(资源释放)。启动失败不会留下已发布的子 agent;提供方插件在完成后卸载,也不会撤销由持有方拥有的运行。 + +## 能力 + +spawn 声明 `{ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }`,因为它控制子 agent 的创建窗口,能够强制执行全部四项功能。 + +## 配置 + +| 键 | 含义 | +|---|---| +| `providerName` | `ctx.subagents` 上的注册表名称(默认 `spawn`)。 | + +## 模型体验 + +### 子 agent 请求 + +#### 模型看到的内容 + +全新的子 agent 逐字接收独立任务内容,默认继承父 agent 的模型和工作区,并看到带有已配置子 agent 作用域 persona 遮蔽的全局提示词。工具过滤器会为该子 agent 移除全局协议 schema、可执行工具查找和 Code Mode SDK 绑定,但保留独立注册的指导内容。它不接收任何父 agent 对话消息;过滤控制的是可见性与组合,并非从父 agent 继承的权限授权。 + +#### Token 影响 + +子 agent 为全新的独立上下文和历史支付 token 成本;不会复制父 agent 历史 token。persona 会改变该子 agent 的重复提示词成本,过滤则会改变其 schema 或生成 SDK 的成本。 + +#### KV Cache 影响 + +与父 agent 请求缓存相互独立。子 agent 历史仅追加;persona、工具过滤、生成 SDK、提供方或模型变化会建立不同的子 agent 前缀。 + +### 父 agent 工具结果(间接) + +#### 模型看到的内容 + +通过 `dsh-tool-subagent`,父 agent 只接收子 agent 的最终输出或结束原因错误。 + +#### Token 影响 + +父 agent 输入增加一个依赖数据的结果,并保留到上下文压缩(compaction)为止。 + +#### KV Cache 影响 + +仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 + +## 已知限制与延期工作 + +- **运行不公开 `sendMessage`/`resume`**:进程内运行不具备这些可选运行时能力。 +- **全新表示不含父 agent transcript**:子 agent 会继承 cwd、谱系、模型及显式配置的 persona/工具限制,但不继承父 agent 的任何对话;需要已完成轮次上下文时,请使用 fork 提供方。 diff --git a/packages/subagent/subagent-subprocess/README.i18n.yaml b/packages/subagent/subagent-subprocess/README.i18n.yaml new file mode 100644 index 0000000000..35efba2b98 --- /dev/null +++ b/packages/subagent/subagent-subprocess/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 6ae1778af1ca38a6c49c7f462e536a9c16c7e6bb +README.zh.md: 01847710df7c12ace7f87e45abc8e83958469740 diff --git a/packages/subagent/subagent-subprocess/README.md b/packages/subagent/subagent-subprocess/README.md index bd1900d612..6ae1778af1 100644 --- a/packages/subagent/subagent-subprocess/README.md +++ b/packages/subagent/subagent-subprocess/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-subagent-subprocess +English | [中文](README.zh.md) + Shared machinery for **out-of-process subagent backends** — providers that spawn an external agent as a child process, such as the [ACP backend](../subagent-acp/README.md). A pure library (no provider, no registration, no Config): what every spawn-a-CLI-child backend needs to keep the parent deployment's credentials out of the child, tear the child down to quiescence, and isolate it from the host user's on-disk CLI state. Design rationale: [the Claude Code / Codex subagent backends Agent Note](../../../.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md). Every tunable is a **parameter**: the dispose ladder takes its grace periods per call, the config-dir helper takes an optional pinned path. Defaults live in each consuming plugin's Config (defaulted, validated fields changeable from `cordis.yml`), never in this library. diff --git a/packages/subagent/subagent-subprocess/README.zh.md b/packages/subagent/subagent-subprocess/README.zh.md new file mode 100644 index 0000000000..01847710df --- /dev/null +++ b/packages/subagent/subagent-subprocess/README.zh.md @@ -0,0 +1,55 @@ +# @deepseek-ai/dsh-subagent-subprocess + +[English](README.md) | 中文 + +用于**进程外 subagent 后端** 的共享机制:这类提供方会把外部 agent(智能体)作为子进程派生,例如 [ACP 后端](../subagent-acp/README.md)。这是纯库(无提供方、无注册、无 Config),提供所有「派生 CLI 子进程」后端都需要的机制:阻止父级部署凭据进入子进程、把子进程清理至完全停稳,以及将子进程与宿主用户的磁盘 CLI 状态隔离。设计理由见 [Claude Code / Codex subagent 后端 Agent Note](../../../.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md)。 + +每个可调项都是**参数**:dispose(资源释放)阶梯每次调用时接收宽限时间,配置目录辅助函数接收可选的固定路径。默认值位于各个消费插件的 Config 中(带默认值且经过校验的字段,可从 `cordis.yml` 修改),绝不位于本库。 + +## 导出内容 + +### `buildChildEnv(extra)` + +凭据环境变量清理采用与 [bash 执行器](../../bash/bash-local/README.md)相同的模式:子进程环境等于环境继承值移除名称形似凭据的变量(`/KEY|SECRET|TOKEN/i`)后,再把 `extra` 叠加到清理结果之后。`PATH`、`HOME`、`TMPDIR`、locale 和代理变量会保留,使子 CLI 正常运行;父级自身的秘密绝不会隐式泄漏,而显式提供的凭据(后端 `env` 配置中子进程自己的密钥)仍会传给子进程。 + +### `spawnFailure(child)` + +派生失败捕获:返回一个 promise,它会以子进程的第一个 `error` 事件兑现(绝不拒绝)。`ENOENT` 等派生失败是事件而非抛出的异常;没有监听器时 Node 会使父进程崩溃。因此,请在调用 `spawn()` 的同一个 tick 内调用此函数,并在运行结果路径中将其纳入竞速;错误命令随后会作为普通的子进程级失败结算。对于正常派生的子进程,该 promise 永不结算。 + +### `disposeChildProcess(child, graces)` + +平台感知的 dispose 阶梯只会在子进程确实退出后兑现:达到完全停稳,而不只是发出请求(见[防御性模式](../../../docs/defensive-patterns.md)): + +1. stdin EOF(如果 stdin 已建立管道),然后等待 `graces.disposeEofGraceMs`:可协作的子进程自行完全停稳,同时保留其 flush 与嵌套子进程清理; +2. 在 POSIX 上发送 `SIGTERM`,然后等待 `graces.disposeGraceMs`; +3. 强制终止:POSIX 使用 `SIGKILL`,Windows 使用 Node 映射的 `TerminateProcess`;然后最多等待 `graces.disposeGraceMs` 以确认退出。信号错误或未退出会导致 dispose 拒绝。 + +两个宽限时间(`DisposeLadderGraces`)来自消费插件的 `disposeEofGraceMs`/`disposeGraceMs` Config 字段。POSIX 在优雅信号和强制信号之后都使用 `disposeGraceMs`;Windows 跳过冗余的优雅信号,但用该值限定强制退出确认时间。EOF 窗口有意独立设置且通常更宽,因为协作式清理可能要等待捕获信号的孙进程和最后一次 flush。 + +退出等待逻辑位于该阶梯内部。无论结算结果如何,它们都会清理自己的 timer 和监听器,因此升级过程不会在子进程上累积监听器。 + +### `createIsolatedConfigDir(prefix, pinnedPath?)` + +为外部 CLI 子进程创建每次运行独立的隔离配置目录(`CLAUDE_CONFIG_DIR` / `CODEX_HOME` 式重定向的目标),使子进程行为只取决于部署配置,绝不取决于宿主上任何 `~/.claude` / `~/.codex` 式状态。返回一个 `IsolatedConfigDir` 句柄:`path` 写入子进程环境,`remove()` 在 dispose 时运行。 + +- **全新(默认)**:OS 临时根目录下的私有(0700)`mkdtemp` 目录;`remove()` 会尽力删除它,且绝不拒绝(留下临时目录胜过 dispose 失败),并且是幂等的。 +- **固定**(设置 `pinnedPath`):原样返回该路径,绝不创建、绝不移除。通过固定目录在运行间共享子进程状态的部署负责该目录的生命周期。 + +## 测试 + +`tests/subagent-subprocess.spec.ts`:环境变量清理和配置目录辅助函数使用真实进程环境与真实文件系统运行(rm 失败路径在 fs 边界注入拒绝,因为真实递归 rm 失败无法跨平台稳定触发,而且 root 会忽略权限位);退出等待和平台终止路径使用可脚本化的假子进程。[ACP 后端测试套件](../subagent-acp/README.md)会针对真实子进程端到端执行这些机制。 + +## 模型体验 + +通过基于进程的 subagent 后端间接产生影响;这些后端的子进程组合受凭据清理和隔离配置目录约束。 + +#### KV Cache 影响 + +不会直接使缓存失效;具名消费方负责请求前缀的任何变化。 + +## 已知限制与延期工作 + +- **凭据清理基于名称**:只移除匹配 `KEY` / `SECRET` / `TOKEN` 的变量;除非后端提供更严格的环境,否则 `PASSWORD` 等名称不同的秘密仍会传入。 +- **信号只针对直接子进程**:清理依赖可协作的 CLI 在退出前回收其后代;重新托管或独立脱离的孙进程可能比该阶梯存活更久。 +- **全新配置目录的清理是尽力而为**:`rm` 失败时会在 OS 临时根目录下留下私有状态,而不会使 dispose 失败。 +- **固定配置目录完全由操作方负责**:辅助函数既不创建、校验、锁定,也不移除这些目录,因此并发运行可能共享该状态并发生竞态。 diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml new file mode 100644 index 0000000000..e5d5d48e33 --- /dev/null +++ b/packages/subagent/subagent/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 3d5d5e7498b1700c07486cc6e894e72fed681bec +README.zh.md: 3eb3d2bb379ec1a39035f42ffbda08bed6698823 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 105af77450..3d5d5e7498 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-subagent +English | [中文](README.zh.md) + The subagent seam lets one agent delegate work to a child through a named provider. Callers use one service API (`ctx.subagents`); providers decide whether the child runs in this process, in another process, or through a future transport. ## Package roles diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md new file mode 100644 index 0000000000..3eb3d2bb37 --- /dev/null +++ b/packages/subagent/subagent/README.zh.md @@ -0,0 +1,82 @@ +# @deepseek-ai/dsh-subagent + +[English](README.md) | 中文 + +subagent seam 允许一个 agent(智能体)通过具名提供方把工作委派给子 agent。调用方使用统一的服务 API(`ctx.subagents`);提供方决定子 agent 在当前进程、另一进程还是未来的传输之上运行。 + +## 包角色 + +该能力族把稳定接口与实现、面向模型的工具分开: + +| 包 | 角色 | +|---|---| +| `@deepseek-ai/dsh-subagent` | 提供方注册表、请求/结果类型和生命周期事件。 | +| `@deepseek-ai/dsh-subagent-spawn` | 全新的进程内子 agent。 | +| `@deepseek-ai/dsh-subagent-fork` | 以父 agent 已完成轮次作为初始内容的进程内子 agent。 | +| `@deepseek-ai/dsh-subagent-acp` | 全新的进程外 ACP(Agent Client Protocol)子 agent。 | +| `@deepseek-ai/dsh-tool-subagent` | 基于一个已配置提供方、面向模型的工具。 | + +多个提供方可以使用不同名称共存。因此,部署可以同时公开低成本的进程内子 agent 和隔离的 ACP 子 agent,而无需改变服务契约。 + +## 服务 API + +`SubagentService` 有四个主要操作: + +| 成员 | 含义 | +|---|---| +| `registerProvider(provider)` | 按名称注册一个可信的同进程实现。注册受 effect 作用域约束;移除注册会阻止新的启动,但不会撤销已返回给调用方的运行。重复名称会立即失败。 | +| `getProvider(name)` | 返回提供方;不存在时返回 `undefined`。 | +| `list()` | 按插入顺序返回提供方名称。 | +| `start(name, request)` | 校验请求的能力和语义值,然后等待提供方,直到真实子 agent 就绪。兑现时返回由持有方拥有的 `SubagentRun`;拒绝表示提供方已清理所有局部启动资源。 | + +`SubagentStartRequest.signal` 是必填项,也是规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消实时子 agent。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。 + +同进程请求、描述符、结果和事件 payload 都是以不可变方式借用的可信类型值。服务不会克隆或冻结它们;序列化和不可信输入校验属于真实的进程、worker、持久化和模型边界。 + +## 能力 + +启动时功能通过 `provider.capabilities` 声明,因为服务必须在创建子 agent 前拒绝不受支持的请求: + +- `outputSchema`:强制执行结构化最终结果; +- `depthLimit`:强制执行 `maxDepth`; +- `toolFilter`:应用请求的子 agent 工具限制; +- `persona`:应用每个子 agent 独立的 persona。 + +## 委派深度 + +该 seam 拥有实现和消费方共享的深度词汇:`AgentOptions.subagentDepth` 声明、`assertSubagentMaxDepth` 和 `delegationDepthOf(agent)`。持久化的 `SessionHeader.delegationDepth` 具有权威性且单调:运行时选项可以加深计数,但绝不能降低它,因此恢复后的子 agent 不会被重新计为顶层。 + +运行时功能是 `SubagentRun` 上的可选方法:`sendMessage?` 会引导实时子 agent,`resume?` 则异步创建延续运行。方法是否存在就是能力检查。 + +`inheritsParentContext` 只用于描述,不能强制执行。它仅说明子 agent 是否能看到父级已完成的对话历史(`fork` 可以;`spawn` 和 ACP 不可以),不表示是否继承工具、服务或权限。 + +## 所有权与生命周期 + +`provider.start(request): Promise<SubagentRun>` 是所有权转移边界。兑现前,提供方拥有设置过程,并且每次失败时都必须取消、回滚并使局部资源完全停稳。兑现后,调用方拥有该运行,并且必须在每条路径上调用 `dispose()`。 + +`SubagentRun.result` 兑现为 `{ output, structured?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。`dispose()` 是幂等的,会取消剩余工作,并等待子 agent 资源完全停稳。 + +本地运行会在 `start()` 兑现前发布普通的子 agent/会话,把该共享会话 id 作为 `SubagentRun.id` 返回,以 `SubagentRun.localAgent` 公开准确的子 agent,并把 `request.parent.session.id` 记录到子 agent 的 `parentSession` header。远程提供方则生成父级作用域的生命周期 id,并返回 `localAgent: undefined`。 + +服务只会发出 `subagent/start`,而且是在 `start()` 兑现后。它在同步通知前附加结果观察器,因此即使子 agent 已经结算,也仍会先产生 `subagent/start`,再产生 `subagent/end`。这对事件共享服务生成的 `runId`;其 `local` 标志取自提供方准确 `localAgent` 的快照,因此观察器绝不会从可复用的提供方/会话名称推断运行身份或本地性。 + +运行事件受执行委派的父级作用域约束。每个监听器都独立隔离:同步抛出或返回的 promise 被拒绝时,只会记录日志,不会阻塞同级监听器或改变运行。 + +提供方新增和移除还会发出 `subagent/provider-added` 与 `subagent/provider-removed`。面向模型的工具等消费方使用这些事件,因为 Cordis 可能并发加载同级插件;配置顺序不能证明注册顺序。 + +## 收集模型 + +面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。后台委派不会改变该 seam;消费方把启动过程和最终运行注册到通用 `ctx.tasks` 运行时,随后使用共享任务工具进行收集和取消。完整契约见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。 + +## 模型体验 + +通过 `dsh-tool-subagent` 间接产生影响;它渲染提供方特定的 schema,以及前台或通用后台结果,同时子 agent 工作上下文只留在子 agent 中。 + +#### KV Cache 影响 + +不会直接使缓存失效;具名消费方负责请求前缀的任何变化。 + +## 已知限制与延期工作 + +- **运行时引导和延续只是 seam 能力**:当前工具中没有消费 `sendMessage` 和 `resume` 的面向模型消费方。 +- **生命周期事件只供观察**:影响运行的 `subagent/end` 延续或决策接口仍需等待具体消费方。 diff --git a/packages/subagent/tool-subagent/README.i18n.yaml b/packages/subagent/tool-subagent/README.i18n.yaml new file mode 100644 index 0000000000..bf88b03740 --- /dev/null +++ b/packages/subagent/tool-subagent/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 0f1bc6eae00dce7b1832f76d0267edd2c6ef2a93 +README.zh.md: 49fca6af9fd039d4436f8c209a328d2fb0efcf07 diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 08e143f94d..0f1bc6eae0 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-tool-subagent +English | [中文](README.zh.md) + The model-facing delegation tool over one configured `ctx.subagents` provider. Changing the provider changes transport without changing the execution contract. ## Provider selection and lifecycle diff --git a/packages/subagent/tool-subagent/README.zh.md b/packages/subagent/tool-subagent/README.zh.md new file mode 100644 index 0000000000..49fca6af9f --- /dev/null +++ b/packages/subagent/tool-subagent/README.zh.md @@ -0,0 +1,81 @@ +# @deepseek-ai/dsh-tool-subagent + +[English](README.md) | 中文 + +基于一个已配置 `ctx.subagents` 提供方、面向模型的委派工具。更换提供方只会改变传输,不会改变执行契约。 + +## 提供方选择与生命周期 + +每个插件实例把一个 `provider` 绑定到一个 `toolName`;模型不会收到提供方选择器。如需公开另一种传输,请加载另一个名称不同的实例。工具只在其提供方存在时注册,从而避免对同级加载顺序和提供方重新加载的依赖。工具描述遵循 `provider.inheritsParentContext`:全新子 agent(智能体)需要独立提示词,而 fork 子 agent 已能看到父级已完成轮次。 + +前台调用会让执行信号贯穿启动和执行,等待 `run.result`,并且在返回前总会等待 `run.dispose()`。只有 `completed` 会返回规范值 `{ kind: 'foreground', runId, output: JsonValue[] }`,并渲染为相同的最终文本;中止、拒绝、token 上限和其他失败都会变成出错的工具结果,不包含局部输出。 + +设置 `run_in_background: true` 后,工具会在启动提供方前注册父级拥有的任务,并返回规范值 `{ kind: 'background', taskId }`,渲染为 `started background subagent task <id>`。任务拥有的信号覆盖待处理的启动阶段,以及启动调用返回后的子 agent。`task_kill` 和所有者 dispose(资源释放)会中止它。结算会等待启动回滚或子 agent dispose,然后把完成的最终文本映射为完成、中止映射为 `killed`、其他失败映射为 `failed`。任务不提供增量读取;通用任务工具负责后续状态、收集、取消和通知。见[后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)。 + +`toolFilter` 会改变子 agent 的全局工具层,但不是从父级派生的权限上限。见 [agent 作用域的安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。 + +## 配置 + +| 键 | 含义 | +|---|---| +| `provider`(必填) | 提供方名称(`spawn`、`fork`、`acp` 等)。 | +| `toolName` | 面向模型的名称,默认 `subagent`;每个已加载实例必须不同。 | +| `enableRunInBackground` | 公开后台模式,默认 `true`;禁用时也会拒绝强制后台调用。 | +| `agentOptions` | 默认子 agent 选项,目前包括 `model`。 | +| `persona` | 每个子 agent 独立的 persona;要求提供方具备 `persona` 能力。 | +| `toolFilter` | 每个子 agent 独立的全局工具限制;要求提供方具备 `toolFilter` 能力。 | +| `maxDepth` | 绝对委派深度上限,默认 `3`(`0` 禁止委派);数值上限要求 `depthLimit` 能力,缺失时挂载失败。对于预算由子 harness 拥有的进程外提供方,`'provider-managed'` 不发送上限。工具在达到上限时仍然可见;每次尝试启动都会检查调用 agent 的当前深度,被拒绝时返回出错的工具结果。 | + +## 并发 + +前台调用与后台调用互斥。子 agent 可能共享父级工作区或外部资源,一元分类器无法证明同级委派的效果彼此不相交。见[并行工具调用 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md)。 + +## 模型体验 + +### 工具 schema + +#### 模型看到的内容 + +当提供方存在时,以当前实例配置的名称公开已生成的默认 [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent)。提供方是否继承上下文会改变工具描述和提示词描述;启用后台模式会添加 `run_in_background`。 + +#### Token 影响 + +每个父级请求支付固定 schema 成本;每个提供方实例增加一个 schema。 + +#### KV Cache 影响 + +只要提供方实例、名称、描述和 schema 不变,前缀就保持稳定。提供方注册生命周期可能从首个变化的工具定义开始,使父级复用失效。 + +### 前台结果 + +#### 模型看到的内容 + +调用会保留描述和提示词。成功时只包含子 agent 的最终文本;其他结果变为 `Error: <message>`。子 agent 中间步骤不会进入父级。 + +#### Token 影响 + +提示词和结果会留在父级历史中,直到上下文压缩(compaction);子 agent 工作上下文留在子 agent 中。 + +#### KV Cache 影响 + +仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 + +### 后台任务结果 + +#### 模型看到的内容 + +启动时精确返回 `started background subagent task <id>`。通用任务接口提供后续状态、最终输出、取消响应和通知。 + +#### Token 影响 + +确认消息会被保留;最终输出只在收集或注入时进入父级历史。 + +#### KV Cache 影响 + +仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 + +## 已知限制与延期工作 + +- **后台运行只公开最终输出**:子 agent 中间步骤留在子 agent 会话中。 +- **等待中实例的重复名称发现较晚**(`TODO(subagent-dup-toolname)`):若要阻止提供方注册回滚,需要一份预期名称注册表。 +- **每个实例的子 agent 策略固定**:其他模型、persona、工具过滤器或深度上限都需要另一个名称不同的工具。 diff --git a/packages/support/README.i18n.yaml b/packages/support/README.i18n.yaml new file mode 100644 index 0000000000..4870773e06 --- /dev/null +++ b/packages/support/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: b9550fd54feb36448227faae8485fe8b6dbf4fb0 +README.zh.md: dc017c5f6544e29533f14600deb5b9261e351536 diff --git a/packages/support/README.md b/packages/support/README.md index 31a64aa357..b9550fd54f 100644 --- a/packages/support/README.md +++ b/packages/support/README.md @@ -1,5 +1,7 @@ # support/ — dev/test/example infrastructure +English | [中文](README.zh.md) + Packages that exist to serve development, testing, and the examples rather than to ship as product API. They are real workspace packages (typed, tested, under the coverage gate), but they carry **lower compatibility expectations**: they may change or be removed when the development need behind them does, without the deprecation care a product package would warrant. | Package | Role | ctx key | diff --git a/packages/support/README.zh.md b/packages/support/README.zh.md new file mode 100644 index 0000000000..dc017c5f65 --- /dev/null +++ b/packages/support/README.zh.md @@ -0,0 +1,16 @@ +# support/:开发/测试/示例基础设施 + +[English](README.md) | 中文 + +这些包用于开发、测试和示例,而非作为产品 API 发布。它们是真实工作区包(有类型、经过测试、受覆盖率门禁约束),但具有**较低的兼容性预期**:当其背后的开发需求变化时,它们可以改变或被移除,无需像产品包那样谨慎执行弃用流程。 + +| 包 | 职责 | ctx 键 | +|---|---|---| +| `acp-snapshot/` | ACP 测试工具包:共享子进程/客户端启动器、快照 harness、规范化器和套件工厂 | (库:由 ACP e2e 和 `*.snapshot.ts` 套件导入) | +| `agent-loop-testkit/` | 为测试具体 agent loop 的测试挂载共享先决条件 | (库:由 AgentLoop 集成测试导入) | +| `invariants/` | 用于开发诊断的运行时事件契约断言 | (监听 `session/*`、`agent/*`) | +| `loader-smoke/` | 共享的真实 Loader 子进程 harness,用于无密钥示例冒烟测试 | (库:由示例 e2e 套件导入) | +| `llm-mock-server/` | 可编脚本的 OpenAI 兼容 HTTP/SSE 故障服务器与 CLI,用于 LLM 恢复测试 | (独立服务器和测试库) | +| `llm-replay/` | 录制/回放适配器:从已记录的会话 JSONL 短路 `llm/stream`(无密钥快照测试) | (监听 `llm/stream`) | + +`invariants` 是开发支持,但没有环境保护:无论在何处注册,它都会运行;默认 `dsh-agent-spine-demo` bundle 无条件挂载它。`agent-loop-testkit` 为手工构建的 AgentLoop 测试集中管理必需服务主干,而不负责其 loop 或场景。`llm-replay` 支撑演示和受每文件覆盖率门禁约束的快照测试层,`llm-mock-server` 则通过确定性 HTTP/SSE 故障驱动真实提供方适配器。`acp-snapshot` 包含 ACP 子进程/客户端边界以及快照 harness、规范化器和套件机制,`loader-smoke` 负责无密钥示例 e2e 套件使用的并列真实 Loader 启动边界。只有当某个包获得已记录的产品消费方时,它才会从 `support/` 升级到产品分组。 diff --git a/packages/support/acp-snapshot/README.i18n.yaml b/packages/support/acp-snapshot/README.i18n.yaml new file mode 100644 index 0000000000..fd0fe03cb8 --- /dev/null +++ b/packages/support/acp-snapshot/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: f3817a386a286e1dca40334fed7cb169643cb7e4 +README.zh.md: 2f87e9ef7b29f65f81f8b464f59725c13a057003 diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index c42c547a09..f3817a386a 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -1,5 +1,7 @@ # `@deepseek-ai/dsh-acp-snapshot` +English | [中文](README.zh.md) + The ACP snapshot suite kit: the shared machinery behind the keyless snapshot tier (`pnpm run test:snapshot`, [testing policy](../../../docs/testing.md)). An example gets a full snapshot suite from a scenario table plus a fixtures directory; every compare/guard mechanic lives here, under the per-file coverage gate, instead of being copied per example. Four layers, importable separately: diff --git a/packages/support/acp-snapshot/README.zh.md b/packages/support/acp-snapshot/README.zh.md new file mode 100644 index 0000000000..2f87e9ef7b --- /dev/null +++ b/packages/support/acp-snapshot/README.zh.md @@ -0,0 +1,72 @@ +# `@deepseek-ai/dsh-acp-snapshot` + +[English](README.md) | 中文 + +ACP 快照套件工具包:无密钥快照层(`pnpm run test:snapshot`,见[测试策略](../../../docs/testing.md))背后的共享机制。示例只需场景表和 fixture 目录就能获得完整快照套件;每项比较/保护机制都位于此处,受每文件覆盖率门禁约束,而不是在每个示例中复制。 + +四层可单独导入: + +- **`launchAcpTestAgent`(启动器)**:从指定 cwd 在 tsx 下启动源 agent,或在普通 Node 下启动已构建 `lib` agent;通过原始字节 stdout tee 连接 SDK 客户端,收集会话更新和 stderr,在启动过程中公开异步 spawn 失败,对未处理权限请求快速失败,并负责优雅或带信号关闭。关闭会等待进程退出、继承 stdio 关闭和 ACP parser 耗尽,然后才解析或传播子级错误,使捕获内容完整,且调用方可在任一结果后移除自有路径。当 Windows 接受强制终止但异步发布退出标记时,关闭会给该标记有界宽限,然后才将回退拒绝视为第二次失败。快照和普通 e2e 套件共享该进程边界;测试只需提供 agent 路径、cwd、环境覆盖和任何权限策略。 +- **`runScenario`(harness)**:通过启动器从确定性 `input.json` 脚本驱动 ACP JSON-RPC stdio,将原始 stdout tee 给预期输出和纯度检查,并在优雅 stdin EOF 后收集每个持久化原始 JSONL 会话日志(父级和 subagent 子级,主级优先)。`AgentUnderTest` 提供绝对 `binScript`、可选 `libBinScript`、`configPath` 和 `tsconfigPath` 路径,因为子进程 cwd 位于仓库外。当生成子级 cwd 自身位于待测授权中时,`workspaceParent` 可以将它从平台临时目录移出。启动失败会在拒绝诊断中保留已捕获 agent stderr。 +- **规范化器**:将两个已捕获接口转换为稳定文本的纯函数:`normalizeStdout`(JSON-RPC id → 首次出现序列;UUID 以及生成 cwd 的每个原生/JavaScript 文件系统写法 → token,按最长优先;根据 cwd 的分隔符选择规范 `/` 或宿主原生形式;同时作为 stdout 纯度检查)、`normalizeSessionLog`(时间归零、保留 `seq`、使用同一 cwd 路径策略)、`scrubSystemPrompts`(提示词文本 → `{{system}}`)、`scrubToolSchemas`(schema bulk → `{{tools}}`)和 `scrubRequestHeaders`(每个 pin 之外的所有 header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}`,保留结构;见[header 固定 Agent Note](../../../.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 +- **`defineAcpSnapshotSuite`(工厂)**:为场景表注册完整 describe/it 树:每场景预期输出与重新持久化日志比较、录制/刷新 fixture 回写、拒绝结构化 `UNKNOWN_TOOL` 结果、每 header 类别 pin(`system-prompt.expected.md` 加 `tool-schemas.expected.json`)及其实时一致性保护,以及 fixture 保护块(无遗留场景目录、必需文件存在、每类别恰好一个 pin、每个 JSONL 的提示词/schema 已擦除、非 pin fixture 的 header 已完全擦除)。刷新会在对齐现有可变事件时间前展开打包时序 envelope,因此切换打包/非打包布局无法移动后续记录;新分片碎片数组仍为权威数据。新插入的 `session/title` 使用前一个事件的时间,因此功能驱动的插入不会扰动 fixture 余下部分。每个场景目录的 `session.jsonl` 和连续 `session.<n>.jsonl` 同级文件是有序主级/子级清单;场景表不重复其数量。必须在 vitest 收集时调用。 + +消费方 `*.snapshot.ts` 就是场景表加一次工厂调用: + +```ts +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { + defineAcpSnapshotSuite, + type Scenario, + type SnapshotSuiteOptions, +} from '@deepseek-ai/dsh-acp-snapshot' + +function snapshotMode(value: string | undefined): SnapshotSuiteOptions['mode'] { + switch (value) { + case undefined: + case '': + case 'replay': return 'replay' + case 'record': return 'record' + case 'refresh': return 'refresh' + default: throw new Error(`unknown DSH_SNAPSHOT mode: ${value}`) + } +} + +const SCENARIOS: Scenario[] = [ + { name: 'text-turn', hasModelTurn: true, recorded: true, pinsHeader: true }, +] + +defineAcpSnapshotSuite({ + agent: { // absolute paths, resolved from the suite's own location + binScript: fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url)), + configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)), + tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)), + }, + snapshotsDir: join(dirname(fileURLToPath(import.meta.url)), 'snapshots'), + scenarios: SCENARIOS, // exactly one entry per header class sets pinsHeader + mode: snapshotMode(process.env.DSH_SNAPSHOT), +}) +``` + +启动不同组合树的场景会设置自己的 `configPath`(一个 basename 仍以 `cordis.yml` 结尾的 overlay,使 bin 的回放交换可找到同级 `*cordis.snapshot.yml`);当该组合改变请求 header 时,还会设置自己的 `headerClass` 和 pin 场景,acp-agent 示例的 Code Mode 与文件系统场景是模板。当临时目录授权自身待测时,`workspaceParent` 将生成 cwd 移出平台临时区域;harness 仍只拥有并移除生成的子级。每个 pin 目录将规范化的完整提示词序列存入生成的 `system-prompt.expected.md`,将对应完整工具 schema 序列存入生成的 `tool-schemas.expected.json`;`session.jsonl` 存储 `"system":"{{system}}","tools":"{{tools}}"`,同时保留配置、原因和任何模型可见前缀。具有合法运行中 header 变更的 pin 声明 `expectedHeaderChanges`,用于固定两个 sidecar 序列的长度。 + +每个场景都比较 `stdout.expected.jsonl`,其中以 cwd 为根的分隔符规范化为 `/`。在 Windows 上,`pinsNativeWindowsStdout` 还会在共享预期输出之后比较完整 `stdout.expected.windows.jsonl`,并在启用时精确要求该 sidecar。驱动行为需要 POSIX 进程语义的场景(例如取消实时 bash 调用会终止脱离进程组)声明 `posixOnly`,在 Windows 上跳过运行测试,但 fixture 保护仍在所有平台覆盖其已提交文件。 + +示例还发布 `cordis.snapshot.yml` 回放 overlay,位于 `cordis.yml` 旁边(bin 在 `DSH_SNAPSHOT=replay` 下交换它们,见[单源回放配置 Agent Note](../../../.agents/notes/implemented/testing/2026-07-04-single-source-acp-replay-config.md));回放 fixture 由 [`dsh-llm-replay`](../llm-replay/README.md) 提供,该包通过对子级设置的 `DSH_SNAPSHOT_*` env var 指向它。`pnpm run test:snapshot:record` 调用实时 LLM,并重写已记录场景的模型 fixture;`pnpm run test:snapshot:refresh` 保持无密钥,运行回放 overlay,并从已提交模型脚本重写 stdout、可比较会话日志预期输出,以及每个 pin 的提示词与工具 schema sidecar。Fixture 角色、录制/回放/刷新语义和场景表字段记录在 `Scenario` 以及[快照 Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md) 中。 + +约束:`suite.ts` 导入 vitest,因此包入口只能在 vitest 运行中导入(启动器、harness 和规范化器没有此依赖,但从同一入口发布)。启动器和套件工厂按设计专用于 ACP,启动器使用 SDK 的 `ClientSideConnection`;规范化器是与传输无关的会话日志/文本辅助工具,还由 TUI 快照套件和 web 浏览器 e2e lane 消费。输入脚本覆盖初始化、新建会话、文本提示、取消、预期 RPC 失败和持久轮次边界等待。权限往返是选项类别选择(`allow_once`、`reject_once`等)的 FIFO 队列,映射到 agent 发出的 `optionId`;缺少或耗尽的队列回答 `cancelled`,未提供类别会拒绝运行。 + +## 模型体验 + +无。该测试专用 harness 记录、规范化并比较 ACP transcript,不会改变 agent 组装的模型请求。 + +#### KV 缓存影响 + +无;该包既不组装也不发送提供方请求。 + +## 已知限制与待完成工作 + +- **会话收集需要原始 JSONL mode**:`runScenario` 收集持久化 `.jsonl` 日志,因此快照配置使用 `persistenceCompression: 'none'`;压缩 JSONL 和 SQLite 组合没有快照收集路径。 +- **构建 mode 需要当前产物**:先运行 `pnpm run build`,再选择 `DSH_EXAMPLE_MODE=lib`;源 mode 仍是零构建路径。 +- **后端覆盖仍使用 ACP 驱动器**:保留场景为何使用该传输,见[仅自动化 ACP 决策](../../../.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary)。 diff --git a/packages/support/agent-loop-testkit/README.i18n.yaml b/packages/support/agent-loop-testkit/README.i18n.yaml new file mode 100644 index 0000000000..d919a89cf5 --- /dev/null +++ b/packages/support/agent-loop-testkit/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 18c46069d3cfd402c83b5ecab68458667738163b +README.zh.md: 93af58a279558007c252ef0734cc95b2ad79a5a5 diff --git a/packages/support/agent-loop-testkit/README.md b/packages/support/agent-loop-testkit/README.md index 07a2db02ec..18c46069d3 100644 --- a/packages/support/agent-loop-testkit/README.md +++ b/packages/support/agent-loop-testkit/README.md @@ -1,5 +1,7 @@ # `@deepseek-ai/dsh-agent-loop-testkit` +English | [中文](README.zh.md) + Shared prerequisite mounting for tests that exercise the concrete `AgentLoop`. `mountAgentLoopTestDependencies(ctx, options?)` installs the LLM, session, system-prompt, tool, and agent services in dependency order, then returns before the loop is mounted. The caller registers adapters and optional plugins, mounts `AgentLoop` with the configuration under test, and disposes its own Context. System-prompt and tool-registry configuration can be forwarded through `options`; the helper does not provide test defaults beyond those owned by the services. A plugin-load failure rejects the helper call, while services activated earlier in the sequence remain owned by the caller's Context. diff --git a/packages/support/agent-loop-testkit/README.zh.md b/packages/support/agent-loop-testkit/README.zh.md new file mode 100644 index 0000000000..93af58a279 --- /dev/null +++ b/packages/support/agent-loop-testkit/README.zh.md @@ -0,0 +1,33 @@ +# `@deepseek-ai/dsh-agent-loop-testkit` + +[English](README.md) | 中文 + +为测试具体 `AgentLoop` 的测试挂载共享先决条件。`mountAgentLoopTestDependencies(ctx, options?)` 按依赖顺序安装 LLM、会话、系统提示词、工具和 agent 服务,然后在 loop 挂载前返回。 + +调用方注册适配器和可选插件,使用待测配置挂载 `AgentLoop`,并 dispose 自己的 Context。系统提示词和工具注册表配置可通过 `options` 转发;辅助工具不提供超出服务自有默认值的测试默认值。插件加载失败会拒绝辅助工具调用,而顺序中较早激活的服务仍归调用方的 Context 所有。 + +```ts +import { Context } from 'cordis' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' + +const ctx = new Context() + +await mountAgentLoopTestDependencies(ctx) +// Register the test adapter and any optional plugins here. +await ctx.plugin(AgentLoop, { agents: [] }) +``` + +针对注入失败、部分拓扑、服务加载顺序或服务拆卸的测试会直接挂载其依赖,而不使用此辅助工具。 + +## 模型体验 + +无。该测试专用组合辅助工具既不驱动也不修改模型请求。 + +#### KV 缓存影响 + +无;该包既不组装也不发送提供方请求。 + +## 已知限制与待完成工作 + +- **只共享必需的先决主干**:适配器、可选插件、`AgentLoop`、agent 和 Context 拆卸仍由调用方负责,以使场景专用顺序保持可见。 diff --git a/packages/support/invariants/README.i18n.yaml b/packages/support/invariants/README.i18n.yaml new file mode 100644 index 0000000000..4cf92d6542 --- /dev/null +++ b/packages/support/invariants/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 203dbd5ad09f5b1378061fbf9adcff885889eae2 +README.zh.md: e101a30046c6b1ac18870c268ea6a2c960cb5d90 diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index 2dd0cde142..203dbd5ad0 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -1,5 +1,7 @@ # dsh-invariants +English | [中文](README.zh.md) + Configurable registry service for package-owned runtime invariant checks. The root plugin registers `ctx.invariants`; it contains no product checks or product-package imports. Every workspace package publishes a `./invariant` companion that registers its exact npm package name. ## Service: `InvariantService` (`ctx.invariants`) diff --git a/packages/support/invariants/README.zh.md b/packages/support/invariants/README.zh.md new file mode 100644 index 0000000000..e101a30046 --- /dev/null +++ b/packages/support/invariants/README.zh.md @@ -0,0 +1,85 @@ +# dsh-invariants + +[English](README.md) | 中文 + +用于包自有运行时不变量检查的可配置注册表服务。根插件注册 `ctx.invariants`;它不包含产品检查或产品包导入。每个工作区包都发布一个 `./invariant` 配套入口,用于注册其精确 NPM 包名。 + +## 服务:`InvariantService`(`ctx.invariants`) + +```ts +interface Config { + enabled?: boolean + package_allowlist?: string[] + package_blocklist?: string[] +} +``` + +默认值为 `enabled: true`、`package_allowlist: []` 和 `package_blocklist: []`。只有在服务启用、allowlist 为空或至少一个 allowlist pattern 匹配完整 NPM 名称,且没有 blocklist pattern 匹配时,包才被选中。因此,blocklist 匹配优先于 allowlist 匹配。 + +每个条目都是区分大小写的 JavaScript 正则表达式源,使用 `new RegExp(pattern)` 编译。除非源提供 `^` 和 `$`,否则匹配不锚定;不解析 `/pattern/flags` 语法。同一列表中的空白、带前后空白、无效或重复条目会使服务启动失败。有效 pattern 可以不匹配任何当前已加载包,以使后续加载和 HMR 保持确定性。 + +`ctx.invariants.register(packageName, installer)` 为完整 NPM 包名保留一个活动注册,即使过滤器使其 installer 保持非活动,并返回 disposer。已启用贡献在专用子 Cordis fiber 中运行。Installer 可以通过 `installer.inject` 声明所需服务接口,并收到 `fail(message)`;后者抛出绑定到注册包的 `InvariantError`。在注册成功前,系统会等待同步或异步 installer 完成;失败会 dispose 子级,并原子释放归属。 + +服务拥有每个注册 fiber,返回的 disposer 同时属于配套 fiber。卸载任一侧都会移除监听器、跟踪状态和保留。因此,配套入口可以重新加载并注册同一包名,而不保留旧状态。由会话支撑的配套入口从持久事件重建 baseline;仅实时配套入口观察重新加载后开始的操作。 + +`InvariantError` 扩展 `Error`,携带稳定 `code: 'INVARIANT'`,并公开所属 `packageName`,而不向服务添加产品依赖。 + +在每个组合中,Session 自身负责不可变且接口有效的日志存储:它对每个候选项制作一份无损 JSON 快照,验证完整来源和位置替换,将 `tool/result` 替换限制为一个当前结果的 `content`,深度冻结已接受记录,并通过不可变数组快照公开日志。`dsh-session` 不变量配套入口检查 Session 不负责的其余跨记录规则。 + +## 包配套入口 + +发布和注册覆盖全部包;运行时断言刻意不使用合成内容。只有当包拥有可观察事件关系或相关可变数据关系时,配套入口才安装检查。确认必需方法、插件名称、注入、effect 或固定纯函数结果属于类型、加载或单元测试关注点,而非运行时不变量。 + +如果不存在合理的运行时关系,配套入口使用空 installer,并以包专用的前置 `No runtime invariant:` 注释说明原因。纯工具、行为已通过 seam 观察的薄实现、仅组合包、二进制程序、契约需要崩溃/往返测试的持久化适配器和测试支持包通常属于此类。当 owner 获得可变状态或事件协议时,必须重新审视该说明。 + +当前可执行配套入口保护以下关系: + +| 配套入口 | 检查 | +|---|---| +| `dsh-session`, `dsh-agent`, `dsh-scope`, `dsh-agent-loop` | 会话包含关系和调用/结果跟踪、agent 状态转换、inbox FIFO 守恒、作用域 subject 和模型请求重建。 | +| `dsh-llm`, `dsh-llm-retry`, `dsh-tools`, `dsh-system-prompt` | 流语法、持久重试位置和边界、工具流水线阶段与冻结结果,以及权威提示词组装数据。 | +| `dsh-compact`, `dsh-hook-protocol`, `dsh-sandbox-policy` | 持久压缩与钩子配对、压缩元数据和沙箱 mode 词汇。 | +| `dsh-fs`, `dsh-subagent`, `dsh-workflow` | 文件系统事件身份、提供方/子级配对和工作流/agent 生命周期身份。 | +| `dsh-goal`, `dsh-goal-session` | 持久 goal 来源/内容一致性、修订和生命周期转换、时间戳、顺序接纳 Round 和重建的继续提示词。 | +| `dsh-permission`, `dsh-user-approval` | 活动 preset 引用和审批询问/决定审计配对。 | +| `dsh-tasks`, `dsh-tool-todo` | 任务快照生命周期/归属字段和持久整表 todo 结构。 | +| `dsh-time-context` | 持久时钟读数与会话开放轮次、下一个步骤前位置和已用 baseline 一致;渲染时间可解析,且不晚于其事件。 | + +每个 owner 的根入口仍与诊断独立。单独加载服务不会安装产品检查;在没有服务时加载配套入口,会等待其声明的 `invariants` 注入。 + +`pnpm run verify-package-invariants` 发现全部工作区包。它拒绝生成标记、未说明的空 installer、省略或忽略 reporter 的非空 installer、错误注册名称,以及不完整的导出、发布、依赖、TypeScript 引用或 bundle 接线。该源规则是最低归属检查;聚焦测试证明每个可执行配套入口的语义。 + +## 组合 + +```ts +import type { Context } from 'cordis' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' + +declare const ctx: Context + +ctx.plugin(InvariantService, { + enabled: true, + package_allowlist: ['^@deepseek-ai/dsh-'], + package_blocklist: ['^@deepseek-ai/dsh-agent-loop$'], +}) +ctx.plugin(SessionInvariant) +``` + +标准 agent 主干挂载服务和 4 个核心有状态配套入口。自定义组合为希望检查其契约的其他已加载包显式添加配套入口;过滤器可以在不改变包入口的情况下禁用或选择注册。 + +每个普通 Vitest 拓扑都挂载显式启用的服务和当前测试包的配套入口。聚焦套件覆盖可执行配套入口的有效和无效观察,一个穷尽拓扑则挂载全部配套入口,以证明注册和 dispose 接线。 + +## 模型体验 + +无。服务和配套入口观察运行时事件和可变快照,不会更改提示词、消息、schema、流或工具结果。 + +#### KV 缓存影响 + +无;不变量检查不组装或发送提供方请求。 + +## 已知限制与待完成工作 + +- 请求重建覆盖 loop 在冻结前显式标记的请求;直接一次性 LLM 调用即使由调用方冻结或附加会话 id,仍不在该标记契约内。 +- 仅实时生命周期配套入口无法重建自身重新加载前开始的操作。标准组合和测试组合会在相应操作开始前挂载它们。 +- 正则表达式过滤器在服务生命周期内固定;更改它们需要执行普通 Cordis 插件重新加载。 diff --git a/packages/support/llm-mock-server/README.i18n.yaml b/packages/support/llm-mock-server/README.i18n.yaml new file mode 100644 index 0000000000..0e14fec455 --- /dev/null +++ b/packages/support/llm-mock-server/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 77a5c5e35fe0b4b1c92968eecea85b6059c889fc +README.zh.md: bf84a1c5f5428e845a6917a82287733d82405144 diff --git a/packages/support/llm-mock-server/README.md b/packages/support/llm-mock-server/README.md index 6fca303b47..77a5c5e35f 100644 --- a/packages/support/llm-mock-server/README.md +++ b/packages/support/llm-mock-server/README.md @@ -1,5 +1,7 @@ # `@deepseek-ai/dsh-llm-mock-server` +English | [中文](README.zh.md) + A scriptable OpenAI-compatible HTTP/SSE server for exercising real LLM adapters, the agent loop, and recovery policy without a provider key. It accepts `POST /chat/completions` and `POST /v1/chat/completions`; each accepted request consumes one configured behavior in arrival order. Invalid methods, paths, bearer tokens, and JSON do not consume the script. The library entry exports `startMockLlmServer(options)`, behavior and telemetry types, the default random stress weights, the accepted Node timer bound, and a running handle with the bound `baseURL`, generated or configured `randomSeed`, captured requests, and idempotent `close()`. Closing force-terminates stalled connections. diff --git a/packages/support/llm-mock-server/README.zh.md b/packages/support/llm-mock-server/README.zh.md new file mode 100644 index 0000000000..bf84a1c5f5 --- /dev/null +++ b/packages/support/llm-mock-server/README.zh.md @@ -0,0 +1,86 @@ +# `@deepseek-ai/dsh-llm-mock-server` + +[English](README.md) | 中文 + +可编脚本的 OpenAI 兼容 HTTP/SSE 服务器,用于在无提供方密钥的情况下测试真实 LLM 适配器、agent loop 和恢复策略。它接受 `POST /chat/completions` 和 `POST /v1/chat/completions`;每个已接受请求按到达顺序消费一个已配置行为。无效 method、path、bearer token 和 JSON 不消费脚本。 + +库入口导出 `startMockLlmServer(options)`、行为和 telemetry 类型、默认随机压力权重、可接受的 Node timer 边界,以及带有绑定 `baseURL`、已生成或已配置 `randomSeed`、已捕获请求和幂等 `close()` 的运行句柄。关闭会强制终止停滞连接。 + +## 独立使用 + +从本仓库运行源入口: + +```sh +pnpm run mock:llm -- \ + --port 8000 \ + --api-key mock-key \ + --sequence partial_disconnect,success \ + --partial-text "discard this half" +``` + +将发布的 DeepSeek 适配器指向服务器;它会将 `/chat/completions` 追加到已配置 base: + +```sh +DEEPSEEK_BASE_URL=http://127.0.0.1:8000/v1 \ +DEEPSEEK_API_KEY=mock-key \ +pnpm run demo:headless "test provider recovery" +``` + +构建包还公开 `dsh-llm-mock-server`。Stdout 是 JSONL:`ready` 记录携带 `/v1` base URL 和随机种子,后续请求/结果记录同时命名脚本行为和实际选中的具体行为。 + +## 行为脚本 + +`--sequence` 是逗号分隔的 FIFO。耗尽时返回结构化 HTTP 500;`--repeat-last` 显式重用最后一项。 + +| 行为 | 协议结果 | +|---|---| +| `connection_reset` | 在 HTTP header 前销毁 socket | +| `stream_disconnect` | 发送 SSE header,然后在第一个事件前 reset | +| `partial_disconnect` | 发送文本 delta,然后 reset socket | +| `stall` | 发送 SSE header,并保持空闲,直到客户端/服务器取消 | +| `empty` | 发送有效的无内容 stop 和 `[DONE]` | +| `empty_body` / `stream_eof` / `partial_eof` | 正常结束,但缺少必需的 `[DONE]` 边界 | +| `malformed_json` / `malformed_event` | 发送无效 SSE JSON 或无效提供方分片形态 | +| `rate_limit` / `server_error` / `service_unavailable` | 返回面向重试的 429/500/503 JSON 错误 | +| `auth_error` / `invalid_request` / `context_overflow` / `quota_exceeded` | 返回终止性或单独恢复的提供方错误 | +| `success` / `slow_success` / `reasoning_success` | 流式发送完整文本响应,可选延迟或先发送 reasoning | +| `tool_call_success` / `max_tokens` | 以工具调用或 `length` 结束原因完成 | +| `wrong_content_type` | 在 `application/json` 下发送有效 SSE 正文 | +| `random` | 从加权播种随机性中选择具体请求行为 | + +`connection_refused` 只能在 CLI 中使用,且必须是第一个条目。它会延迟绑定调用方指定的非零端口,因此 `--listen-delay-ms` 期间的请求会收到真实 TCP 拒绝;其余条目在 listener 启动后开始。 + +## 随机 mode + +使用重复 `random` 条目执行开放式混合运行: + +```sh +pnpm run mock:llm -- \ + --port 8000 \ + --sequence random \ + --repeat-last \ + --seed 42 \ + --random-weights 'success=60,slow_success=10,connection_reset=5,stream_disconnect=5,partial_disconnect=10,empty=5,server_error=5' +``` + +省略 `--seed` 会生成种子,并在 `ready` 记录中打印。`--random-weights` 接受非负的相对 `behavior=weight` 条目,并要求至少一个正权重具体行为。导出默认值是一个成功占主导的压力分布,包含 reset、disconnect、部分输出、空完成、stall、429/5xx、干净截断和格式错误 JSON;它用于施加测试压力,而非估计生产事故频率。`connection_refused` 被排除,因为已绑定的请求处理器无法产生真实拒绝。 + +随机权重包含 `stall` 时,为待测客户端配置较短的流空闲超时,使场景及时结束。 + +## 时序与内容控制 + +CLI 公开 `--success-text`、`--partial-text`、`--reasoning-text`、`--chunk-size`、`--chunk-delay-ms`、`--disconnect-delay-ms`、`--retry-after-ms`、`--request-id`、`--tool-name` 和 `--tool-arguments`。毫秒延迟是 Node timer 范围内的有界整数;`retryAfterMs` 还必须为正数。库接受相同的 camel-case 选项。可选的精确 `apiKey` 验证 `Authorization: Bearer <token>`;省略时接受任何 token。 + +## 模型体验 + +无。该测试服务器替代提供方协议行为,而不调用真实模型。 + +#### KV 缓存影响 + +无;请求在本地终止,绝不会到达提供方缓存。 + +## 已知限制与待完成工作 + +- **随机权重建模测试压力,而非生产事故频率**:需要环境专用分布的调用方必须提供已测量权重,并记录发出的种子。 +- **请求脚本按到达顺序执行**:并发调用方共享一个游标,因此确定性的每会话故障分配需要独立服务器实例。 +- **真实连接拒绝是 listener 生命周期阶段**:CLI 延迟必须与客户端尝试重叠;请求级随机选择只能 reset 已接受连接。 diff --git a/packages/support/llm-mock-server/tests/server.spec.ts b/packages/support/llm-mock-server/tests/server.spec.ts index 1f15ba1a0b..34db57aa82 100644 --- a/packages/support/llm-mock-server/tests/server.spec.ts +++ b/packages/support/llm-mock-server/tests/server.spec.ts @@ -1,5 +1,5 @@ import { request } from 'node:http' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { MockLlmBehavior, MockLlmServer, MockLlmServerEvent } from '../src/index.ts' import { startMockLlmServer } from '../src/index.ts' @@ -179,9 +179,11 @@ describe('mock LLM server wire behaviors', () => { const response = await chat(server, { signal: controller.signal }) controller.abort() await expect(response.text()).rejects.toThrow() - await new Promise((resolve) => { setTimeout(resolve, 5) }) - - expect(server.requests[0]).toMatchObject({ behavior, outcome: 'client_closed' }) + // The server observes the socket close asynchronously; a fixed sleep + // raced slow runners, so poll until the outcome lands. + await vi.waitFor(() => { + expect(server.requests[0]).toMatchObject({ behavior, outcome: 'client_closed' }) + }) expect(events.filter(event => event.type === 'result')).toEqual([ expect.objectContaining({ behavior, outcome: 'client_closed' }), ]) diff --git a/packages/support/llm-replay/README.i18n.yaml b/packages/support/llm-replay/README.i18n.yaml new file mode 100644 index 0000000000..63b9979098 --- /dev/null +++ b/packages/support/llm-replay/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 901a3b7b4312fffd93e6d375c378e39064318260 +README.zh.md: b9a8068d329e28933c934e7ad352ac65641f3d23 diff --git a/packages/support/llm-replay/README.md b/packages/support/llm-replay/README.md index 534d289b19..901a3b7b43 100644 --- a/packages/support/llm-replay/README.md +++ b/packages/support/llm-replay/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-llm-replay +English | [中文](README.zh.md) + A replay LLM plugin for keyless snapshot tests. It yields model streams reconstructed from a recorded **session JSONL** fixture, so a test can boot the real agent against a fixed model transcript with no API key. With `providers` configured it registers a replay-only adapter whose catalog is available to scenarios that exercise model discovery; without `providers` it installs the catch-all `llm/stream` waterfall used by tests that do not need discovery. Its consumers are the ACP, headless `stream-json`, and TUI snapshot suites plus the web browser e2e lane. Loader-driven suites mount this plugin in place of a real LLM adapter; the web lane installs it directly to retain the teardown consumption handle. Keeping derivation and replay here places that logic under the per-file 100% coverage gate on `packages/*/src`. diff --git a/packages/support/llm-replay/README.zh.md b/packages/support/llm-replay/README.zh.md new file mode 100644 index 0000000000..b9a8068d32 --- /dev/null +++ b/packages/support/llm-replay/README.zh.md @@ -0,0 +1,70 @@ +# @deepseek-ai/dsh-llm-replay + +[English](README.md) | 中文 + +用于无密钥快照测试的回放 LLM 插件。它从已记录的**会话 JSONL** fixture 重建模型流,使测试可以在无 API 密钥的情况下使用固定模型 transcript 启动真实 agent。配置 `providers` 后,它会注册仅回放适配器,其目录可供测试模型发现的场景使用;没有 `providers` 时,它会安装不需要发现的测试所用 catch-all `llm/stream` waterfall。 + +其消费方是 ACP、headless `stream-json` 和 TUI 快照套件,以及 web 浏览器 e2e lane。Loader 驱动套件使用此插件替换真实 LLM 适配器;web lane 直接安装它,以保留拆卸消费句柄。将派生和回放逻辑保留在此处,可使其受 `packages/*/src` 上每文件 100% 覆盖率门禁约束。 + +## Fixture 的工作方式 + +Fixture 就是持久化会话日志(`<scenario>/session.jsonl`)。其 `assistant/chunk` 事件携带每个 `StreamChunk`,因此按 `(turn, step)` 对其分组可重建每次 `stream()` 调用的分片序列(每个 loop 步骤一次模型调用)。因此,录制操作是「运行一次真实 agent 并收集 `.jsonl`」,由快照 harness 完成;该插件不执行录制。Fixture 的 `request/header` 内容可能被 token 化为 `{{system}}`/`{{tools}}`(harness 在一个场景中固定该内容,并擦除其余场景);回放对此并不关心,因为派生只读取 `assistant/chunk` 事件和第 0 行会话 header。 + +有两种失败 mode 无法仅从 `assistant/chunk` 重建:在任何分片前纯抛出(例如 HTTP 401,日志只包含 `turn/end {error}` 而没有分片),以及 cancel/hang(是时序,而非分片内容)。需要这些的场景提供可选 sidecar(`<scenario>/replay.override.json`:一个 `ReplayEntry[]`),以替换派生脚本。`hang` 条目可以指定 `readyFile`;在其前缀分片到达 loop 后、等待取消前,回放会写入该空标记,使外部驱动器可以在不观察展示更新的情况下确定性取消。 + +## 嵌套 agent:每会话键控 + +父 agent 委托给进程内 subagent 的场景会记录多个日志:父级(`session.jsonl`)和每个子级各一个(`session.1.jsonl`等)。每个 agent 在同一上下文中作为自己的 `Session` 运行,因此回放必须为每个 agent 提供自己的脚本。 + +回放按调用会话 id 为每次调用建键(由 agent loop 标记的 `GenerateOptions.sessionId`)。实时会话 id 在每次运行中都是新的随机值,绝不等于已记录值,因此实时会话通过**首次调用顺序** 绑定到已记录脚本:脚本按 header `createdAt` 排序(父级在前,因为它必须先进行流式输出才能委托);第一个进行任何调用的实时会话领取第一个脚本,下一个新会话领取下一个,以此类推。然后,每个会话推进自己的游标。没有 `sessionId` 的调用是绑定到主脚本的单一匿名会话,因此单会话场景与以前完全相同。实时会话数超过已记录脚本数时快速失败。 + +## 配置 + +| 键 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `file` | string | `$DSH_SNAPSHOT_FILE` | 主(父)`session.jsonl` fixture 的路径。必需(配置或 env)。 | +| `overrideFile` | string | `$DSH_SNAPSHOT_OVERRIDE` | 替换主会话派生脚本的 `ReplayEntry[]` sidecar 可选路径。 | +| `childFiles` | string[] | `$DSH_SNAPSHOT_CHILD_FILES` (path-delimited) | 嵌套场景中已记录的 subagent 子会话日志;单会话场景为空。 | +| `providers` | `ReplayProviderConfig[]` | 无 | 可选的仅回放提供方和模型目录。每个模型可以发布 `contextWindow`;已配置路由通过回放适配器分派,绝不执行提供方 I/O。 | +| `paceMs` | number | 无(突发) | 可选的每分片毫秒延迟,使下游传输(例如真实浏览器观察的 web SSE mux)看到真正的增量传递。它只是仿真开关,测试不得依赖它保证正确性。值必须是非负整数;pace 等待期间中止会迅速取消流。 | + +```yaml +- id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek + name: DeepSeek + models: + - id: deepseek-v4-flash + contextWindow: 128000 + - id: deepseek-v4-pro + # file/overrideFile/childFiles default to $DSH_SNAPSHOT_FILE / + # $DSH_SNAPSHOT_OVERRIDE / $DSH_SNAPSHOT_CHILD_FILES, set by the snapshot + # harness per scenario. +``` + +## 导出项 + +- `installLlmReplay(ctx, config)`:安装已配置回放适配器或 catch-all `llm/stream` 监听器;返回 `ReplayHandle`(包含用于 HMR 安全的 `dispose()`,以及 `assertConsumed()` 拆卸检查;后者确保每个已记录脚本都绑定到实时会话,且每个已绑定游标都已耗尽,从而将场景静默驱动的模型调用少于记录数转换为明确诊断)。在测试中使用它,可以不通过 Loader 或 env var 驱动回放。 +- `loadSessionScripts(config)`:解析场景的有序 `SessionScript[]` (主级 + 子级),准备按首次调用顺序绑定到实时会话。 +- `loadReplayScript(config)`:只解析主会话的 `ReplayEntry[]` (如果存在则使用 sidecar override,否则从 JSONL 派生;fixture 缺失时快速失败)。 +- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)`:将已记录会话日志转换为脚本并读取其 header `id`/`createdAt` 的纯辅助工具。派生分组必须以 `finish` 分片结束;没有该分片的分组是已抛出 `stream()` 的指纹,必须改用 override sidecar 表达。 +- 类型 `ReplayEntry` / `SessionScript` / `ReplayConfig` / `ReplayProviderConfig` / `ReplayModelConfig` / `ReplayHandle` / `Config`。 + +## 插件导出形态 + +命名导出 `name` / `inject` / `Config` / `apply`,且**没有默认导出**:Cordis Loader 的 `unwrapExports` 执行 `exports.default ?? exports`,因此意外的默认导出会将模块折叠为纯函数,并丢弃 `inject` 命名空间(见 [docs/postmortem/0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md))。 + +## 模型体验 + +无。该无密钥测试适配器不向提供方模型发送请求,只将已记录 assistant 分片回放到测试 loop 中。 + +#### KV 缓存影响 + +无;该包既不组装也不发送提供方请求。 + +## 已知限制与待完成工作 + +- **首次调用顺序脚本绑定假设串行委托**:并发运行同级 subagent 的 cut(或运行中落地的压缩摘要调用)会非确定性地将实时会话绑定到已记录脚本;在这种场景出现前暂不实现更强的键控(`XXX(concurrent-subagents)`)。 +- **只有生产分片的调用可派生**:纯分片前抛出或 cancel/hang 场景需要 `replay.override.json` sidecar;override 只替换主会话的脚本。 diff --git a/packages/support/loader-smoke/README.i18n.yaml b/packages/support/loader-smoke/README.i18n.yaml new file mode 100644 index 0000000000..a4e0016620 --- /dev/null +++ b/packages/support/loader-smoke/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 8e53550608037a3c9a272db825933b7224ab24db +README.zh.md: 5310429ab59cf3cd04ac024746f5ed557e003637 diff --git a/packages/support/loader-smoke/README.md b/packages/support/loader-smoke/README.md index 450f6f6f61..8e53550608 100644 --- a/packages/support/loader-smoke/README.md +++ b/packages/support/loader-smoke/README.md @@ -1,5 +1,7 @@ # `@deepseek-ai/dsh-loader-smoke` +English | [中文](README.zh.md) + Shared subprocess harness for tests that boot an app and `cordis.yml` through the Cordis Loader. `resolveExampleLaunch` selects local `src` mode (tsx and root tsconfig paths) or CI `lib` mode (plain Node and package exports) from an explicit mode or `DSH_EXAMPLE_MODE`. `runLoaderSmoke` accepts bin and config paths, optional complete bin arguments, environment overrides, stdin, pre-run setup, and pre-cleanup inspection. It owns the isolated cwd, DSH homes, diagnostics, deadline, termination, EOF, and cleanup; it returns both streams after a zero exit and rejects with both streams on failure. diff --git a/packages/support/loader-smoke/README.zh.md b/packages/support/loader-smoke/README.zh.md new file mode 100644 index 0000000000..5310429ab5 --- /dev/null +++ b/packages/support/loader-smoke/README.zh.md @@ -0,0 +1,23 @@ +# `@deepseek-ai/dsh-loader-smoke` + +[English](README.md) | 中文 + +用于测试通过 Cordis Loader 启动应用和 `cordis.yml` 的共享子进程 harness。`resolveExampleLaunch` 选择本地 `src` mode(tsx 和根 tsconfig 路径)或 CI `lib` mode(普通 Node 和包导出);选择依据为显式 mode 或 `DSH_EXAMPLE_MODE`。 + +`runLoaderSmoke` 接受 bin 和配置路径、可选的完整 bin 参数、环境覆盖、stdin、运行前设置和清理前检查。它负责隔离 cwd、DSH 主目录、诊断、deadline、终止、EOF 和清理;在零退出后返回两个流,失败时拒绝并携带两个流。 + +这是支持层测试基础设施,而非产品 API。 + +## 模型体验 + +无。该测试专用 harness 启动示例进程并检查它们的流,不会改变已组装模型请求。 + +#### KV 缓存影响 + +无;该包既不组装也不发送提供方请求。 + +## 已知限制与待完成工作 + +- **构建 mode 需要事先构建**:配置还必须能够通过 `examples/node_modules` 向上解析每个命名包。 +- **捕获的 stdout 和 stderr 无界**:失控子进程可以消耗内存,直到 deadline 将其终止。 +- **超时只终止直接子进程**:故障 fixture 生成的进程树可以比冒烟测试存活更久,需要外部清理。 diff --git a/packages/tasks/README.i18n.yaml b/packages/tasks/README.i18n.yaml new file mode 100644 index 0000000000..0cd358369b --- /dev/null +++ b/packages/tasks/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: f1c224345c94a833c44cbafb635be7617e8c42bf +README.zh.md: 610a84a1506b4bb780297322f7827e6f04533bc1 diff --git a/packages/tasks/README.md b/packages/tasks/README.md index 71c68ea250..f1c224345c 100644 --- a/packages/tasks/README.md +++ b/packages/tasks/README.md @@ -1,5 +1,7 @@ # tasks/ — background task capability family +English | [中文](README.zh.md) + The shared home for background-task ids, owner isolation, reads, cancellation, waiting, and completion notices. Bash, subagents, and future long-running tools use one model-facing protocol. See the [background-task runtime Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md). | Package | ctx key | Role | diff --git a/packages/tasks/README.zh.md b/packages/tasks/README.zh.md new file mode 100644 index 0000000000..610a84a150 --- /dev/null +++ b/packages/tasks/README.zh.md @@ -0,0 +1,12 @@ +# tasks/:后台任务能力包族 + +[English](README.md) | 中文 + +后台 task id、拥有者隔离、读取、取消、等待和完成通知的共用归属位置。Bash、subagent 及未来的长时间运行工具共用一套面向模型的协议。参见[后台任务运行时 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)。 + +| 包(package) | ctx 键 | 角色 | +|---|---|---| +| [`tasks`](tasks/README.md)(`@deepseek-ai/dsh-tasks`) | `ctx.tasks` | 注册表服务:品牌化 `<kind>-N` id、按拥有者设防的 read/kill/wait/list、结算记账、等待完成的拥有者清理路径,以及防止 `attachSurface` 配置错误的防线 | +| [`tool-tasks`](tool-tasks/README.md)(`@deepseek-ai/dsh-tool-tasks`) | 无 | 面向模型的控制接口:`task_output`、`task_list`、`task_kill`、完成通知注入和后台工作习惯提示词段落 | + +注册表拥有跨生产方或接口重载的状态;工具包拥有呈现。生产方通过 `ctx.tasks.start` 注册执行钩子,并自行决定其配置是否公开 `run_in_background`。 diff --git a/packages/tasks/tasks/README.i18n.yaml b/packages/tasks/tasks/README.i18n.yaml new file mode 100644 index 0000000000..fc9157bddc --- /dev/null +++ b/packages/tasks/tasks/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 1a073add0fde8f2e519cc83b087af6a531a6cbb8 +README.zh.md: 795602701f072068f05bbf16ee98bdeea57548af diff --git a/packages/tasks/tasks/README.md b/packages/tasks/tasks/README.md index 1d9ce2b249..1a073add0f 100644 --- a/packages/tasks/tasks/README.md +++ b/packages/tasks/tasks/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-tasks +English | [中文](README.zh.md) + The process-local background task registry (`ctx.tasks`). It gives long-running producers shared ids, owner isolation, reads, cancellation, waiting, notices, and cleanup. Producer plugins extend `TaskKindMap` with their opaque id namespace. ## Service API diff --git a/packages/tasks/tasks/README.zh.md b/packages/tasks/tasks/README.zh.md new file mode 100644 index 0000000000..795602701f --- /dev/null +++ b/packages/tasks/tasks/README.zh.md @@ -0,0 +1,43 @@ +# @deepseek-ai/dsh-tasks + +[English](README.md) | 中文 + +进程局部的后台任务注册表(`ctx.tasks`)。它为长时间运行的生产方提供共享 id、owner 隔离、读取、取消、等待、通知和清理。生产方插件使用其不透明 id namespace 扩展 `TaskKindMap`。 + +## 服务 API + +- `start(spec): TaskId` 验证控制表层、spec、精确的存活 owner,以及可选的正 `outputLimitBytes`,然后只调用生产方的 `run()` 一次。启动方抛出异常时不注册任何内容;成功返回会直接提交,不再执行其他可能失败的步骤。 +- `get(id, caller?)` 和 `list(caller?)` 返回非消费式快照。列表只包含调用方拥有及无 owner 的任务。 +- `read(id, caller?)` 消费流任务的唯一游标;对于最终输出任务,则以幂等方式读取终止输出。 +- `kill(id, caller?, reason?)` 在更改状态前调用生产方取消。取消抛出异常时任务保持运行;成功则把状态改为 `stopping`,并将终止交付标记为已报告。 +- `wait(id, timeoutMs, caller?, signal?)` 返回终止快照,或在超时时返回存活快照。中止只会停止等待;一旦终止交付已向该等待方提交,终止结果优先。 +- `onTaskDone(listener)` 观察每条终止记录及其精确 owner。监听器抛出异常或拒绝会被封装;系统不会等待监听器工作。 +- `attachSurface(name)` 在其 effect 生命周期内声明控制表层。如果没有附加任何表层,`start()` 会在生产方执行前失败。 + +有 owner 的访问会比较任务的 `SessionId` 与调用方。`bash-1` 等 id 可预测,因此这道隔离是安全边界。无 owner 的任务向调用方开放,并持续到服务释放。 + +`outputLimitBytes` 是生产方拥有的模型呈现策略,会原样携带到快照中。控制表层在添加状态或通知元数据后应用它;注册表不会重写生产方输出,也不会为省略此字段的生产方虚构默认值。 + +## 生命周期 + +任务属于其 owner 和后端,而不是生产方工具 fiber,因此重载生产方或表层不会停止任务。某个 owner 的第一个任务会把一个受等待的 effect 附加到精确的 `Agent` scope。owner 释放会取消该对象的任务,等待生产方完全停稳,并移除其快照;复用 agent 或 Session id 无法重定向旧清理。 + +服务释放会关闭监听器、取消所有存活任务、等待其记录,并从仍存活的 owner scope 分离 effect。如果拆卸取消抛出异常,服务会强制把记录标为失败,并警告工作可能遗留,而不会死锁。取消已返回但始终不终止 `done` 时,系统无法将其与缓慢停止区分开,拆卸可能因此停滞。 + +参见[任务类型目录](../../../docs/core-data-structures/tasks.md)和[运行时 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)。 + +## 模型体验 + +通过生产方插件和 [`dsh-tool-tasks`](../tool-tasks/README.md) 间接影响;它们会渲染 task id、输出、状态、取消和完成通知。 + +#### KV Cache 影响 + +不会直接失效;请求前缀变更由命名消费方负责。 + +## 已知限制与暂缓事项 + +- **任务只存在于进程本地**:持久或跨重启执行需要独立生命周期。 +- **服务与实现没有拆分**:第二个后端必须先定义塑造该边界的生命周期。 +- **流输出只有一个消费游标**:独立观察者需要游标或快照 API。 +- **前台工作无法提升**:生产方在启动前选择前台或后台。 +- **静默无效的取消可能使拆卸停滞**:只有显式抛出异常才能安全地强制标为失败。 diff --git a/packages/tasks/tool-tasks/README.i18n.yaml b/packages/tasks/tool-tasks/README.i18n.yaml new file mode 100644 index 0000000000..af141a1a2b --- /dev/null +++ b/packages/tasks/tool-tasks/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 70c0c7da6ef17129241902d37359dd58b6d56605 +README.zh.md: 3ad8d8897ab348832b0d357436a0e78bf98c429b diff --git a/packages/tasks/tool-tasks/README.md b/packages/tasks/tool-tasks/README.md index d471e709bc..70c0c7da6e 100644 --- a/packages/tasks/tool-tasks/README.md +++ b/packages/tasks/tool-tasks/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-tool-tasks +English | [中文](README.zh.md) + The model-facing control surface for `ctx.tasks`: three kind-independent tools, completion notices, and one background-work prompt section. Loading the plugin attaches the surface required by `ctx.tasks.start()`. ## Tools diff --git a/packages/tasks/tool-tasks/README.zh.md b/packages/tasks/tool-tasks/README.zh.md new file mode 100644 index 0000000000..3ad8d8897a --- /dev/null +++ b/packages/tasks/tool-tasks/README.zh.md @@ -0,0 +1,86 @@ +# @deepseek-ai/dsh-tool-tasks + +[English](README.md) | 中文 + +`ctx.tasks` 的面向模型控制表层:三个与 kind 无关的工具、完成通知和一个后台工作提示词区段。加载该插件会附加 `ctx.tasks.start()` 所要求的表层。 + +## 工具 + +- `task_output(task_id, wait?, timeout_ms?)` 默认以非阻塞方式读取。流任务只返回下一个增量;最终输出任务在终止后返回结果。每个响应都以 `[status: ...]` 结尾。`wait: true` 最多等待到配置上限,超时时仍让运行中的任务保持存活。 +- `task_list()` 以 `<id> [<kind>] <status> — <label>` 返回调用方可见的任务。 +- `task_kill(task_id, reason?)` 立即请求取消并转发已记录的原因。终止任务返回非消费式快照。 + +三个工具都使用通用 UI 卡片:output 和 list 使用 `read`,kill 使用 `execute`。 + +它们的规范值依次为 `{ text, task }`、`PublicTaskSnapshot[]` 和 `{ outcome: 'cancellation-requested' | 'already-finished', task }`。公共快照携带 id、kind、label、status/detail 及开始/结束时间;它有意省略 `ownerSession` 和内部 `reported` 通知位。原生 renderer 保留上述状态与确认文本。 + +当生产方提供 `outputLimitBytes` 时,`task_output`、终止 `task_kill` 和完成通知会在添加状态或通知文本后,对完整的原生 UTF-8 结果施加上限。只要能够容纳,读取就会保留输出尾部与控制后缀;有界完成通知则先为 `background task <id>` 和 `task_output` 收集指令预留空间,再把剩余字节用于可变的 kind、label、status、detail 与截断标记。一个前置 pre-execute 监听器会在策略运行前捕获调用方可见任务;每个任务控制定义的 final-content 回调会把其生产方上限应用到单文本拒绝、短路、规范化工具或流水线失败、替换和阻止;结构化多块策略结果保持自身形状。已有的生产方截断标记会复用,不会重复添加。省略该字段的生产方保留现有的无界控制表层行为。 + +## 完成通知 + +一项尚未报告的完成会向精确 owner 的会话注入 `background task <id> (<kind>: <label>) finished [status: ...]. Read its output with task_output.`。应用上限时,在 PTY 支持的 64 字节下限内,稳定 id 前缀和收集命令的优先级高于可变 label/detail,因此通知仍可操作。注入是下一次请求使用的持久上下文,并非唤醒。kill 或终止性 read/wait 会把交付标为已报告,并抑制重复通知;owner 释放竞态会被封装。 + +## 配置 + +| key | 默认值 | 含义 | +|---|---|---| +| `waitTimeoutMs` | `30000` | `wait: true` 省略 `timeout_ms` 时使用的等待时间 | +| `maxWaitTimeoutMs` | `600000` | 模型所给等待时间的上限 | + +默认值高于上限时,插件会在加载时失败。 + +## 模型体验 + +### 系统提示词 + +#### 模型看到的内容 + +该插件注册 scope 中的每次请求都包含以下指引。按 agent scope 过滤工具时,可能会隐藏工具,却不会移除独立注册的提示词区段。 + +##### 后台任务指引 + +```markdown +Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. +``` + +#### Token 影响 + +激活期间,每次请求承担少量固定输入成本。 + +#### KV Cache 影响 + +只要插件 scope 与指引文本不变,前缀就保持稳定。激活或释放可能使从该提示词区段起的复用失效。 + +### 工具 schema + +#### 模型看到的内容 + +该表层可见时,会看到生成的 [`task_output`、`task_list` 和 `task_kill` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-tasks)。 + +#### Token 影响 + +工具可见的每次请求承担固定 schema 成本。 + +#### KV Cache 影响 + +只要工具定义与可见性不变,前缀就保持稳定。注册生命周期或 scope 限制可能使从第一个发生变化的 schema token 起的复用失效。 + +### 结果与通知 + +#### 模型看到的内容 + +读取会返回输出或 `(no new output)`,随后是 `[status: <status>]` 和可选 detail。空列表返回 `(no background tasks)`。kill 返回 `requested cancellation of task <id>` 或现有终止状态。尚未报告且有 owner 的完成使用上述通知。 + +#### Token 影响 + +结果与通知在压缩前保留于父级历史。流读取不会重复已消费的输出;生产方提供的 `outputLimitBytes` 会限制每次完整读取或通知。 + +#### KV Cache 影响 + +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 配置项失效。 + +## 已知限制与暂缓事项 + +- **完成通知不会唤醒空闲 agent**:需要立即获得结果的调用方必须使用 `task_output`。 +- **流读取只有单一消费方**:独立观察者需要另一套运行时 API。 +- **无 owner 的任务没有会话隔离**:外部表层必须提供调用方策略或避开这些任务。 diff --git a/packages/timeout/README.i18n.yaml b/packages/timeout/README.i18n.yaml new file mode 100644 index 0000000000..85007025aa --- /dev/null +++ b/packages/timeout/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 2a75e4d54aa1f518858d97e2f4c7d09a23a80a70 +README.zh.md: d0066fb3c1101b195f6da6798c6a1e0dad49a6c5 diff --git a/packages/timeout/README.md b/packages/timeout/README.md index 321f21373c..2a75e4d54a 100644 --- a/packages/timeout/README.md +++ b/packages/timeout/README.md @@ -1,5 +1,7 @@ # timeout/ — tool-call timeout policy +English | [中文](README.zh.md) + The tool-call timeout policy plugin. A single **product** package: it is a deployment-policy consumer of the `tools/execute` around-dispatch seam (owned by [`dsh-tools`](../core/tools)) and the pure [`dsh-timeout`](../util/timeout) library — not a swappable capability with an interface/implementation split, so it needs no seam trio. | Package | Role | ctx key | diff --git a/packages/timeout/README.zh.md b/packages/timeout/README.zh.md new file mode 100644 index 0000000000..d0066fb3c1 --- /dev/null +++ b/packages/timeout/README.zh.md @@ -0,0 +1,11 @@ +# timeout/:工具调用超时策略 + +[English](README.md) | 中文 + +工具调用超时策略插件。它是单一 **产品** 包(package):它是 `tools/execute` 环绕分发 seam(由 [`dsh-tools`](../core/tools) 拥有)和纯 [`dsh-timeout`](../util/timeout) 库的部署策略消费方,而非带接口/实现拆分的可替换能力,因此无需 seam 三包组合。 + +| 包 | 职责 | ctx 键 | +|---|---|---| +| `timeout-policy/` | `tools/execute` 包装层:对每个已配置工具,它都在 `exec.signal` 上启动单次调用截止时间,并在截止时间先到时返回结构化 `TOOL_TIMEOUT` 结果 | (注册 `tools/execute` 监听器;不注入任何内容) | + +超时被拆分为三层:[`dsh-timeout`](../util/timeout) 拥有纯计时/分类原语(`deadline`/`timeoutOf`);每种能力拥有终止操作(bash 终止其进程组,fetch 提供方关闭其 socket);本包则拥有 *作为部署策略的面向模型工具调用预算*:没有面向模型的超时参数,也没有全局默认值。它是[超时库 Agent Note](../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md) 所预见的中间件。`bash` 和钩子命令执行保留各自的 `BASH_TIMEOUT` 后端超时,不经过此策略。 diff --git a/packages/timeout/timeout-policy/README.i18n.yaml b/packages/timeout/timeout-policy/README.i18n.yaml new file mode 100644 index 0000000000..59b88721a8 --- /dev/null +++ b/packages/timeout/timeout-policy/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 3e5769e2f8b95392e9d489659c6bae634ab3ff18 +README.zh.md: d9c14af1b609b3c6edf3472664007b8119f283db diff --git a/packages/timeout/timeout-policy/README.md b/packages/timeout/timeout-policy/README.md index aea741d1fe..3e5769e2f8 100644 --- a/packages/timeout/timeout-policy/README.md +++ b/packages/timeout/timeout-policy/README.md @@ -1,5 +1,7 @@ # dsh-timeout-policy +English | [中文](README.zh.md) + Tool-call timeout enforcer: a single `tools/execute` around-dispatch listener that arms a per-call cooperative deadline on `exec.signal` for a tool declaring `timeoutMs` on its `ToolDefinition` and returns a structured `TOOL_TIMEOUT` result when that deadline wins. The budget is read from the tool's own declaration (`ToolDefinition.timeoutMs`, set by the owning tool plugin), so this plugin is **zero-config**. It is the reference `tools/execute` wrapper and the enforcement home for model-facing tool-call budgets (the timeout-library Agent Note's foreseen middleware). ## Plugin (namespace: `timeout-policy`) diff --git a/packages/timeout/timeout-policy/README.zh.md b/packages/timeout/timeout-policy/README.zh.md new file mode 100644 index 0000000000..d9c14af1b6 --- /dev/null +++ b/packages/timeout/timeout-policy/README.zh.md @@ -0,0 +1,57 @@ +# dsh-timeout-policy + +[English](README.md) | 中文 + +工具调用超时强制执行器:一个 `tools/execute` 环绕分发监听器。它会在 `exec.signal` 上启动单次调用的协作式截止时间;适用条件是工具声明了 `timeoutMs`,且声明位于其 `ToolDefinition` 上。截止时间先到时,它返回结构化 `TOOL_TIMEOUT` 结果。预算从工具自身的声明中读取(`ToolDefinition.timeoutMs`,由拥有该工具的插件设置),因此此插件是 **零配置** 的。它是 `tools/execute` 包装层的参考实现,也是面向模型工具调用预算的强制执行归属地(超时库 Agent Note 所预见的中间件)。 + +## 插件(命名空间:`timeout-policy`) + +它是函数/命名空间插件(`name`/`inject`/`apply`),而非服务。它不注册工具,也不接受配置;它消费 `ctx.tools` 的 `tools/execute` waterfall(由 `dsh-tools` 注册表始终提供),并读取每个已分发工具声明的 `timeoutMs`;该声明来自注册表(`ctx.tools.get(exec.name)`)。 + +```yaml +- id: timeout-policy + name: '@deepseek-ai/dsh-timeout-policy' +``` + +每工具预算由工具插件声明(例如 `dsh-tool-web` 的 `fetchTimeoutMs`/`searchTimeoutMs` 配置,会附加为 `ToolDefinition.timeoutMs`);此插件只负责强制执行,因此不可能拼错工具名。 + +### 行为 + +对 **声明了 `timeoutMs` 的工具**,监听器会: + +1. 从注册表中的工具自身声明(`ctx.tools.get(exec.name)?.timeoutMs`)读取预算,并启动 `deadline(exec.signal, timeoutMs, 'TOOL_TIMEOUT')`:一个将调用方中止与此插件计时器融合的信号(`@deepseek-ai/dsh-timeout`)。 +2. 将该派生信号替换到 `exec` 上用于下游分发,然后恢复调用方自身的信号(cordis `next()` 忽略传入的参数,因此包装层会原地修改共享 `exec`;恢复可使 `tools/post-execute` 看到调用方的信号)。 +3. 分发后,如果 `timeoutOf(d.signal, 'TOOL_TIMEOUT')` 匹配,即此插件自身的计时器触发,则将结果替换为结构化 `TOOL_TIMEOUT` 工具结果:`{ isError: true, error: { message, info: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' } }, content: 'Error: tool call timed out after <ms>ms' }`。 + +**未声明预算的工具** 会原样委托(不启动截止时间)。 + +基础 `next()` 是 `tools/execute` 在注册表中带规范化的分发 thunk,因此当超时信号到达抛出自身上游中止错误的提供方时,分发会先将其转换为普通错误结果,再由此包装层替换为 `TOOL_TIMEOUT`。这一顺序就是替换依据信号(`timeoutOf`)而非已分发结果形状的原因。 + +### 协作式,而非硬终止 + +派生信号只会 **通知**;终止仍属于工具以及它将 `exec.signal` 转发给的能力(`dsh-timeout` 库不拥有 kill)。**因此,声明 `timeoutMs` 意味着「与 `exec.signal` 协作」**:忽略该信号的工具不会在超时时停止。只有转发信号的工具才应声明该字段;已交付的 `web_fetch`/`web_search`(通过 `ctx.web` 转发给提供方)是参考实现。`TOOL_TIMEOUT` 无需会话事件以满足可重建性:它是最终面向模型的 `tool/result`,已由循环记录。 + +### 与其他 `tools/execute` 包装层组合 + +多个 `tools/execute` 监听器按 cordis 注册顺序组合。与未来的重试/沙箱/指标包装层一起使用时,注册顺序决定语义:「超时覆盖整个重试操作」(超时注册在外层),或「超时覆盖每次尝试」(超时注册在内层)。 + +## 模型体验 + +### 条件工具结果 + +#### 模型所见内容 + +此插件不添加提示词或 schema。如果已声明的截止时间先到,它会将提供方结果替换为 `Error: tool call timed out after <ms>ms` 与结构化 `TOOL_TIMEOUT`;否则原结果保持不变。 + +#### Token 影响 + +未超时调用为零 token。超时会添加一条短小且保留的错误结果,并可防止体积更大的延迟提供方结果进入上下文。 + +#### KV Cache 影响 + +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 + +## 已知限制与延后工作 + +- **协作式,绝不是硬终止**:截止时间只通过 `exec.signal` 通知;忽略该信号的工具不会在超时时停止(参见「协作式,而非硬终止」一节)。 +- **没有统一预算**:只有声明 `timeoutMs` 并将其放在 `ToolDefinition` 上的工具才会获得截止时间;未声明工具没有注册表级默认值(已交付的 `bash`/`read`/`write`/`edit` 有意不声明)。 diff --git a/packages/todo/README.i18n.yaml b/packages/todo/README.i18n.yaml new file mode 100644 index 0000000000..d930492e76 --- /dev/null +++ b/packages/todo/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 1e5ae9a1583b9e9d3913fcd1dca7ef11a5f391fe +README.zh.md: 0048940424f71c5709714d88346433fcfbf1554f diff --git a/packages/todo/README.md b/packages/todo/README.md index a2dfa2549d..1e5ae9a158 100644 --- a/packages/todo/README.md +++ b/packages/todo/README.md @@ -1,5 +1,7 @@ # todo/ — todo / planning capability family +English | [中文](README.zh.md) + The model-facing todo tool. A single **product** package — there is no interface/implementation seam here, because the list is single-owner session state (one agent session owns its own list), not a swappable capability. | Package | Role | ctx key | diff --git a/packages/todo/README.zh.md b/packages/todo/README.zh.md new file mode 100644 index 0000000000..0048940424 --- /dev/null +++ b/packages/todo/README.zh.md @@ -0,0 +1,11 @@ +# todo/:todo/规划能力系列 + +[English](README.md) | 中文 + +面向模型的 todo 工具。它是单一 **产品** 包(package):这里没有接口/实现 seam,因为该列表是由单一所有者管理的会话状态(每个 agent(智能体)会话拥有自己的列表),而非可替换能力。 + +| 包 | 职责 | ctx 键 | +|---|---|---| +| `tool-todo/` | 面向模型的 `todo_write` 工具;将完整列表写入会话日志(`todo/write`) | (注册到 `ctx.tools`) | + +列表存在于事件溯源会话日志中(`SessionEventMap['todo/write']`,由 [`dsh-session`](../core/session) 拥有);本包是追加快照的轻量消费方。[TUI 应用](../examples/tui-demo)等 UI 以及宿主/客户端运行时会根据会话事件渲染该持久列表。 diff --git a/packages/todo/tool-todo/README.i18n.yaml b/packages/todo/tool-todo/README.i18n.yaml new file mode 100644 index 0000000000..a941c774cf --- /dev/null +++ b/packages/todo/tool-todo/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: db6bbf6970c73767c8e9df1148f98d23047d19b7 +README.zh.md: 6a9b817cb5af2c43b8e66100333e46665359eba8 diff --git a/packages/todo/tool-todo/README.md b/packages/todo/tool-todo/README.md index 330f816027..db6bbf6970 100644 --- a/packages/todo/tool-todo/README.md +++ b/packages/todo/tool-todo/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-tool-todo +English | [中文](README.zh.md) + The model-facing `todo_write` tool: the agent's whole task list, replaced wholesale on each call. ## What it does diff --git a/packages/todo/tool-todo/README.zh.md b/packages/todo/tool-todo/README.zh.md new file mode 100644 index 0000000000..6a9b817cb5 --- /dev/null +++ b/packages/todo/tool-todo/README.zh.md @@ -0,0 +1,63 @@ +# @deepseek-ai/dsh-tool-todo + +[English](README.md) | 中文 + +面向模型的 `todo_write` 工具:agent(智能体)的完整任务列表,每次调用都会整体替换。 + +## 功能 + +注册一个工具 `todo_write(todos: [{ content, status }])` 到 `ctx.tools`。模型每次调用都会发送完整列表,不存在部分更新或单项编辑。每次调用都会向调用 agent 的会话日志追加 `todo/write` 事件(完整列表快照),具体调用 `agent.session.append('todo/write', { todos })`;当前列表是最新的该类事件(回放时后写者胜)。 + +`status` 是 `pending`、`in_progress` 或 `completed` 之一。 + +## 单一所有者 + +该列表属于调用工具的唯一 agent 会话。不存在 subagent/共享/swarm scope:非 agent 调用方(没有 `exec.agent`)无处写入列表,因此会被拒绝。这是有意设置的 scope 限制,详见 Agent Note。 + +## 验证 + +除 schema 的类型/必填/枚举检查外,`execute` 还会拒绝空或重复的 `content`,以及同时存在多个 `in_progress` 任务的情况(连贯计划最多只有一个活跃任务)。顺序与保持列表最新的纪律由模型根据工具描述负责。 + +## 渲染 + +规范结果为 `{ todos, counts: { pending, inProgress, completed } }`;其 Native 渲染器返回精简的更新确认。工具还会写入完整 `todo/write` 会话事件。UI 订阅事件流,并自行渲染该持久列表;[TUI 应用](../../examples/tui-demo)将其显示为持久计划。 + +## 导出形状 + +函数/命名空间插件:导出 `name`/`inject`/`apply`,不提供默认导出。意外的 `export default` 会通过 Loader 的 `unwrapExports` 折叠模块并丢弃 `inject`(参见 [docs/postmortem/0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md))。 + +## 模型体验 + +### 工具 schema + +#### 模型所见内容 + +模型会看到生成的 [`todo_write` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-todo)。 + +#### Token 影响 + +工具可见的每个请求都有固定 schema 成本。 + +#### KV Cache 影响 + +只要定义和可见性不变,前缀就保持稳定。插件生命周期或 scope 限制可能会使此 schema 之后的复用失效。 + +### 工具调用历史与结果 + +#### 模型所见内容 + +每个 assistant 工具调用都会在参数中保留整个替换列表。成功时精确返回 `Updated todo list: <pending> pending, <inProgress> in progress, <completed> completed.`。稳定失败文本为 ``Error: invalid todo: `content` must be a non-empty string``、`Error: invalid todos: duplicate content "<content>"`、`Error: invalid todos: at most one task may be in_progress, got <count>` 和 `Error: todo_write requires an owning agent session`。完整 `todo/write` 会话事件是 UI 与回放状态,而非第二条模型消息。 + +#### Token 影响 + +Token 增长与模型每次提交的完整列表成比例,且这些调用参数会保留到压缩(compaction)。结果本身很小,且形状固定。 + +#### KV Cache 影响 + +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 + +## 已知限制与延后工作 + +- **仅单一所有者 scope**:列表属于唯一调用 agent 会话;subagent/共享/swarm scope 是有意裁减(参见「单一所有者」一节),非 agent 调用方会被拒绝。 +- **项目形状有意保持最小**:`content` 加三态 `status`;整表替换不需要稳定 id、优先级或 active-form 字段。 +- **整表替换是唯一操作**:没有部分更新,也没有回读工具;模型每次调用都必须重新发送完整列表。 diff --git a/packages/ui/README.i18n.yaml b/packages/ui/README.i18n.yaml new file mode 100644 index 0000000000..78f6fab567 --- /dev/null +++ b/packages/ui/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: f08157d411a018141cdc21c487f81ae198f4de56 +README.zh.md: d321ee9a181eb27ccecb143d3733ee46e417b27f diff --git a/packages/ui/README.md b/packages/ui/README.md index 87b8621b32..f08157d411 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -1,5 +1,7 @@ # ui/ — human and SDK-client integration surfaces +English | [中文](README.zh.md) + Human-facing channels and the out-of-process SDK server. These are **product** packages: real interfaces that a person or SDK client drives. | Package | Role | ctx key | diff --git a/packages/ui/README.zh.md b/packages/ui/README.zh.md new file mode 100644 index 0000000000..d321ee9a18 --- /dev/null +++ b/packages/ui/README.zh.md @@ -0,0 +1,22 @@ +# ui/:面向用户和 SDK 客户端的集成接口 + +[English](README.md) | 中文 + +面向用户的交互通道和进程外 SDK 服务器。这些是**产品** 包(package):由用户或 SDK 客户端直接操作的真实接口。 + +| 包 | 职责 | ctx 键 | +|---|---|---| +| `commands/` | 用户命令注册表:共享发现元数据、作用域遮蔽、取消以及 UI 直接分派 | `ctx.commands` | +| `user-approval/` | 一次性用户审批机制、封闭的结果词汇、审计事件和逐会话审批策略 | `ctx.approval` | +| `permission/` | 面向用户的权限预设(`workspace-write`/`danger-full-access`):用一个产品级选择器组合沙箱模式与审批策略两个调节项,并写入各自的会话事件 | `ctx.permission` | +| `user-interaction/` | UI 支持的确认工具所使用的抽象用户问答 seam | `ctx.userInteraction` | +| `tool-ask-user/` | 模型侧 `ask_user_question` 工具,基于 `ctx.userInteraction` 实现 | (注册到 `ctx.tools`) | +| `tui/` | 交互式 pi-tui 终端通道:渲染会话标题、事件和工具意图,响应 `ctx.userInteraction`,并托管由 effect 持有的插件浮层 | `ctx.tui`(驱动 `ctx.agents`) | +| `jsonrpc/` | 面向进程外 SDK 客户端的 stdio JSON-RPC 服务器 | (驱动 `ctx.agents`) | +| `app-boot/` | app bin 的共享启动粘合层:加载 `.env`、Loader 快速失败保护、感知快照的配置解析,以及等待整棵树停稳的启动序列 | (供各 bin 使用的库) | + +UI 集成属于客户端驱动插件,而非对循环的修改:它使用现有的 `agent/*` 事件分类和 `dsh-agent` 工厂。[`tui`](tui/README.md) 是交互式终端入口,并提供终端本地的 `ctx.tui` 扩展服务;[`jsonrpc`](jsonrpc/README.md) 为进程外 SDK 客户端提供服务,而非交互式单次任务使用 `cli-demo`。[`commands`](commands/README.md) 是 TUI 使用的纯用户发现与分派通道;命令输入和输出不会成为模型消息。 + +`user-approval`、`user-interaction` 和 `tool-ask-user` 位于此处,因为向用户提问是由 UI 支持的产品功能,并不属于提供方无关的核心主干。`user-approval` 持有一次性的 `ctx.approval` 决策机制及其策略层级;应答方仍归拥有 agent(智能体)的通道或自动化传输层所有。`user-interaction` 保持提供方无关(`ctx.userInteraction`),`tool-ask-user` 是其模型侧消费方,而交互式 app 包提供具体实现。 + +基于 [`agent-spine-demo`](../examples/agent-spine-demo/README.md) 组合的可运行 app bundle 位于 [`examples/`](../examples/README.md)(`tui-demo`、`acp-demo`、`jsonrpc-demo`)。`acp-demo` 和 `jsonrpc-demo` 持有启动 bin;`tui-demo` bundle 则由产品 [`dsh`](../../apps/cli/README.md) CLI 启动。`ui/` 保留可复用的用户/SDK 通道插件和共享 `app-boot` 粘合层;仅供自动化使用的 ACP 传输层位于 [`acp/`](../acp/README.md)。每个入口都持有自己的 stdout 策略,叶子 `cordis.yml` 则提供后端与可选工具。 diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml new file mode 100644 index 0000000000..18051a278b --- /dev/null +++ b/packages/ui/app-boot/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 59fc4ed46f047ee8f72aa237ec3a47b21091358f +README.zh.md: 596392a8eb11469a9f3afb34046c2c37521a4ed6 diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index efc5575950..59fc4ed46f 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -1,5 +1,7 @@ # `@deepseek-ai/dsh-app-boot` +English | [中文](README.zh.md) + Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-cli-demo`](../../examples/cli-demo/README.md), [`dsh-acp-demo`](../../examples/acp-demo/README.md)): each bin is a thin self-executing composition over these helpers, parameterized by its diagnostic prefix, so the loader-failure lore lives once — under the per-file coverage gate — instead of drifting between published artifacts. | Export | Role | diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md new file mode 100644 index 0000000000..596392a8eb --- /dev/null +++ b/packages/ui/app-boot/README.zh.md @@ -0,0 +1,48 @@ +# `@deepseek-ai/dsh-app-boot` + +[English](README.md) | 中文 + +供 app bin([`dsh`](../../../apps/cli/README.md)、[`dsh-cli-demo`](../../examples/cli-demo/README.md)、[`dsh-acp-demo`](../../examples/acp-demo/README.md))共用的启动粘合层:每个 bin 都是在这些 helper 上构建的精简自执行组合,并以自身诊断前缀参数化。这样,Loader 故障处理知识只需维护一处并接受逐文件覆盖率门禁,不会在已发布产物之间逐渐分化。 + +| 导出 | 职责 | +|---|---| +| `resolveConfigPath(path, snapshotMode, cwd?)` | 生成绝对配置路径;当 `snapshotMode === 'replay'` 时,把 basename 为 `cordis.yml`/`.yaml` 的文件替换为同级 `cordis.snapshot.yml` | +| `loadEnv(binName, dir?, warn?)` | 加载已被 git 忽略的 `.env`(Node `process.loadEnvFile`);文件不存在不影响启动,文件无法加载时输出一行带标签的警告(默认写入 stderr) | +| `installFailLoud(binName, proc?)` | 将 `boot()` 之后未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;返回卸载函数(供测试使用) | +| `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目(即导入失败的插件模块),则抛出异常 | +| `loadPersonalPatches(binName, dir?)` | 解析 Harness home 中可选的 `config.yaml`(默认使用 [`resolveDshHome()`](../../util/paths/README.md):先取 `$DSH_HOME`,否则取 `~/.dsh`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 | +| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,在插件挂载前执行可选的宿主准备操作(例如 `ctx.provide(RESUME_SESSION_ID_KEY, id)`),再挂载 Loader/include 树并等待其结算,断言所有条目均已加载,最后返回根上下文 | +| `RESUME_SESSION_ID_KEY` | bin 通过 `boot` 的 `prepare` 钩子设置的上下文键,用于把要恢复的会话 id 交给已启动配置;配置以裸标识符 `resumeSessionId` 在 `!!js` 表达式中读取它,因此恢复操作无需环境变量 | +| `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)自身源代码 checkout 的磁盘路径;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 | +| `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 | + +这些保护处理两类故障。`loader.await()` 会吞掉初始化 rejection(`Promise.allSettled`);Node 仍会因随后产生的未处理 rejection 以非零状态退出,而 `installFailLoud` 会把冗长转储替换为一行带标签的消息,并确保执行 `exit(1)`。插件导入失败则只会由 Loader 记录日志(否则,即使配置存在拼写错误,进程也会以代码 0 退出),并留下没有 fiber 的条目;`assertEntriesLoaded` 会将其转换为 `boot()` rejection。 + +配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包(package))通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。bin 的子进程冒烟测试覆盖内部 loader 路径,而本包的单元测试套件会在进程内使用相对 specifier 配置驱动 `boot()`。 + +此包不包含 loader 钩子,也不提供开发模式接口:`dsh-scripts` launcher([`sdk/scripts`](../../sdk/scripts/README.md),共享项目模型见 [`sdk/helper`](../../sdk/helper/README.md))持有进程启动、tsx 注册和本地插件源代码解析,并在自身的启动序列中使用这些 helper。 + +## 个人配置 + +开发者的机器本地偏好位于所有仓库之外的 Harness home 中(默认 `~/.dsh`,可由 `$DSH_HOME` 覆盖;统一由根级 [`resolveDshHome`](../../util/paths/README.md) 解析),并由 `dsh` CLI(命令行界面)的 TUI 界面([`apps/cli`](../../../apps/cli/README.md))使用;demo bin 会原样启动仓库中提交的树。这里有两个可选文件: + +- **`.env`**:在调用目录的 `.env` 之后加载;`process.loadEnvFile` 从不覆盖已有值,因此优先级为环境中的值 > 项目 `.env` > 个人 `.env`。 +- **`config.yaml`**:在发布的默认配置上应用 Loader overlay patch,语义与 include 条目的 `patches` 相同(以仓库提交的 Code Mode overlay 为模板):按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值,因此个人 `apiKey` 可以引用个人 `.env`。如果 patch 指定的条目 id 不在已启动树中,Loader 会发出警告并跳过。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用 overlay,请使用 `[]` 或删除该文件。 + +子进程测试 launcher 会把 `DSH_HOME` 指向逐测试隔离的目录,确保开发者的个人 overlay 不会泄漏到 fixture(测试前置数据)中。 + +## 模型体验 + +模型通过此包加载的插件树间接受到影响;该树决定最终应用中的提示词、schema、消息和模型适配器。唯一贡献模型可见文本的导出 `addHarnessSourceSection`,也只有在消费方启动后调用它时才会产生影响。 + +#### KV Cache 影响 + +`boot()` 不会直接使缓存失效;消费方调用 `addHarnessSourceSection` 时,会在系统提示词靠前位置、逐请求内容之前添加一行短文本,因此不会使跨轮次缓存失效。请求前缀的其他任何变化均由相应的具名消费方持有。 + +## 已知限制与延期工作 + +- **裸包 specifier 依赖 Loader 内部机制**:生产 bin 需要 Loader 的可选原生 helper;没有该 helper 的进程内调用方必须使用可解析的相对/file specifier,或使用 tsx 路径映射。 +- **快照回放替换仅识别特定 basename**:只有以 `cordis.yml` 或 `cordis.yaml` 结尾的配置会映射到同级 `cordis.snapshot.yml`;自定义配置名称需要调用方自行选择。 +- **环境加载局限于 cwd 且为可选操作**:helper 只加载一个 `.env` 文件,并在失败时发出警告;它不会搜索父目录、合并 profile 或验证必需变量。 +- **个人配置采用 patch 形式**:按 id 定位的 patch 会替换条目的整个 `config`,而不是深度合并,因此个人覆盖必须重述需要保留的基础字段。 +- **个人 patch 只能看到已启动文件自身的条目**:如果 overlay 叶子通过嵌套 include 条目访问其基础配置(例如 Code Mode 配置),个人 patch id 只会在 overlay 的顶层条目中解析,不会进入被 include 的子树。 diff --git a/packages/ui/commands/README.i18n.yaml b/packages/ui/commands/README.i18n.yaml new file mode 100644 index 0000000000..ac4d257885 --- /dev/null +++ b/packages/ui/commands/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 8fd49723c4b0534eebd2e590c647caadd63136a7 +README.zh.md: e2ad8ad80d002d769cf6a2c9f4f09c37ce960935 diff --git a/packages/ui/commands/README.md b/packages/ui/commands/README.md index e32513a563..8fd49723c4 100644 --- a/packages/ui/commands/README.md +++ b/packages/ui/commands/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-commands +English | [中文](README.zh.md) + Plugin-owned human-command registry consumed by interactive UI adapters. The [plugin command registration Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md) owns the boundary and dispatch contract. ## Service contract diff --git a/packages/ui/commands/README.zh.md b/packages/ui/commands/README.zh.md new file mode 100644 index 0000000000..e2ad8ad80d --- /dev/null +++ b/packages/ui/commands/README.zh.md @@ -0,0 +1,41 @@ +# @deepseek-ai/dsh-commands + +[English](README.md) | 中文 + +由插件拥有、供交互式 UI 适配器使用的面向用户命令注册表。[插件命令注册 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md)定义了其边界与分发契约。 + +## 服务契约 + +`ctx.commands.register(definition)` 注册一个小写命令名称、描述、可选的非结构化输入提示,以及可中止的处理器。每个已注册命令都可供所有已组合的命令适配器使用;与某项部署不兼容的插件不会在此注册。普通上下文中的注册全局生效。在 `agent.ctx` 下挂载的命令生产插件会声明自身的 `commands` 注入,并创建精确限定到该 agent 的定义;该定义会遮蔽同名的全局定义。这种子级注入形态保留了 agent 作用域,同时不会让核心 agent loop 依赖 UI 服务。同一层中的名称重复会在注册时失败。每个 disposer 都是 Cordis effect 返回的确切 disposer;注册或移除命令时,系统会通知每个 `commands/change` 观察者,使实时适配器能够刷新发现结果。观察者失败会写入日志,既不能否决注册表变更,也不能阻止后续观察者运行。 + +`list(agent)` 在应用作用域遮蔽后,返回按名称排序的不可变描述符。`find(agent, name)` 返回相应定义。`execute(agent, line, signal)` 使用 `parseCommand()`,且只运行已知命令;语法无效或名称未知时返回 `undefined`。 + +`parseCommand()` 识别位于字节零位置的斜杠、由小写字母、数字、`_` 或 `-` 构成的名称,以及名称后紧接输入末尾或空白的形式。它将名称后的每个字节作为 `rawInput` 返回,其中包括分隔空白;消费方拥有各命令专用的语法,只能执行该语法允许的规范化。 + +处理器返回 `success` 或 `error`,并可附带 UI 文本。适配器直接渲染结果,结果绝不进入模型历史。注册表绝不会隐式地把 `rawInput` 提交给 agent;命令生产方可以通过接收命令的 `Agent` 显式安排模型可见工作,此时该生产方拥有由此产生的消息契约。注册表会让处理器完成与所提供的中止信号竞速,但不协作的处理器可能在调用方停止等待后继续产生自身的外部副作用。 + +## 组合 + +终端应用组合包会将此服务与 `dsh-tui` 一起挂载;无 UI 的 agent 主干和 ACP(Agent Client Protocol)自动化应用不会挂载它。自定义交互式组合与命令生产方会显式挂载 `@deepseek-ai/dsh-commands`。 + +## 模型体验 + +### 直接面向用户的命令 + +#### 模型看到的内容 + +注册表自身不会提交任何内容。已知斜杠命令在 UI 命令平面执行,其 `CommandResult` 文本不会作为用户消息提交。已交付的适配器会拒绝未知斜杠命令输入,而不是将其变成模型提示词。命令生产方可以显式使用接收命令的 `Agent`;例如,[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces)在选择 plan mode 后,会提交 `/plan [message]` 中的可选消息。 + +#### Token 影响 + +命令发现、执行和 UI 输出不会增加模型 token。命令生产方显式安排的 agent 工作与相应 agent 输入具有相同的 token 影响。 + +#### KV Cache 影响 + +注册表元数据、命令输入和直接输出绝不会进入模型请求,也不会影响其缓存。被命令变更的领域拥有之后产生的所有缓存影响。 + +## 已知限制与延期工作 + +- **仅支持非结构化文本输入**:表单、补全 schema 和类型化参数仍由各命令自行解析。 +- **不持久化命令输出**:适配器会实时显示结果,但通用注册表不会将结果加入会话日志,也不会在重新连接后重建结果。 +- **副作用采用协作式取消**:中止后,分发会停止等待;处理器必须遵循信号,才能停止已经进入外部系统的工作。 diff --git a/packages/ui/jsonrpc/README.i18n.yaml b/packages/ui/jsonrpc/README.i18n.yaml new file mode 100644 index 0000000000..49383b7872 --- /dev/null +++ b/packages/ui/jsonrpc/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 9297147b7a53739c46871b527ce51868eac1e244 +README.zh.md: 62596197b95729215408dfc1496c129ace6cbad4 diff --git a/packages/ui/jsonrpc/README.md b/packages/ui/jsonrpc/README.md index e4c49dfe97..9297147b7a 100644 --- a/packages/ui/jsonrpc/README.md +++ b/packages/ui/jsonrpc/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-jsonrpc +English | [中文](README.zh.md) + The `jsonrpc` plugin serves newline-delimited JSON-RPC over stdio so out-of-process SDK clients can drive harness agents. [`HarnessSdkServer`](src/server.ts) owns the protocol methods and notifications; [`jsonrpc-demo`](../../examples/jsonrpc-demo/README.md) supplies the surrounding `cordis.yml` application. ## Wiring diff --git a/packages/ui/jsonrpc/README.zh.md b/packages/ui/jsonrpc/README.zh.md new file mode 100644 index 0000000000..62596197b9 --- /dev/null +++ b/packages/ui/jsonrpc/README.zh.md @@ -0,0 +1,47 @@ +# @deepseek-ai/dsh-jsonrpc + +[English](README.md) | 中文 + +`jsonrpc` 插件通过 stdio 提供以换行符分隔的 JSON-RPC,使进程外 SDK 客户端能够驱动 harness agent(智能体)。[`HarnessSdkServer`](src/server.ts) 持有协议方法和通知;[`jsonrpc-demo`](../../examples/jsonrpc-demo/README.md) 提供外围的 `cordis.yml` 应用。 + +## 组装 + +`inject: ['agents']`。服务器按 `sessionId` 获取或创建一个 agent。只有服务建立快照时的生命周期 `local` 标志为 true,服务器才会转发 subagent 完成事件;提供方名称、子级 id 和持久化谱系均不能证明本地性。已注册的适配器优先;未被持有的 `deepseek` 路由会挂载 `dsh-llm-deepseek`,任何其他未被持有的提供方都会导致初始化失败。其他功能由外围 `cordis.yml` 提供。 + +## 配置 + +`maxTokensAsSuccess` 默认为 `false`。对于需要区分「因 token 上限而结束但可接受的 agent 结果」与「基础设施故障」的评测宿主,请将其设为 `true`。`JsonRpcConfig.input`、`output` 和 `exit` 是仅供运行时使用的传输 seam;生产环境使用进程 stdio 和 `process.exit`。 + +## stdout 即协议 + +Stdout 只承载 JSON-RPC 帧。部署不得组合 stdout logger;诊断应写入 stderr。 + +## 关闭与退出语义 + +插件响应 `shutdown`,将 SDK 持有的 agent 和订阅 dispose(资源释放)至完全停稳,关闭传输层,然后以代码 0 退出。EOF 和信号退出由 app bin 处理,后者会 dispose 根上下文。仅卸载此插件会停止服务,但不会退出进程。 + +## 协议说明 + +`initialize.serverInfo.name` 的协议稳定值为 `deepseek-harness-sdk-runtime`。一个会话只接受一个进行中的提示词;重叠请求会立即失败,其他会话保持独立,当前请求结算后该会话可再次使用。`session.finished` 报告由该提示词消息触发的轮次结果;后续注入或插件持有的零步骤轮次仍会作为 `session.event` 通知流式发出,但不能替换该提示词的状态。持久化根目录和 persona 由 `cordis.yml` 提供。 + +## 模型体验 + +### SDK 用户消息 + +#### 模型看到的内容 + +对于每个已接受的 `session/prompt`,对话模型会将调用方提供的 `contentBlocks` 原样接收为该 SDK 会话中的一条用户消息。此包(package)不会添加系统提示词文本或工具 schema;这些内容来自外围 `cordis.yml` 中的插件。 + +#### Token 影响 + +依数据而定的用户消息 token 会进入保留的会话历史,并在后续轮次中重复发送,直至另一个包将其压缩(compaction)。JSON-RPC 帧、会话通知和服务器内部记录不会增加模型上下文 token。 + +#### KV Cache 影响 + +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 + +## 已知限制与延期工作 + +- **协议没有逐会话关闭或提示词取消方法**:SDK 创建的 agent 会一直存活到进程关闭;一条已接受的提示词必须运行到 agent 空闲,该会话才能接受下一条。 +- **stdout 纯净性由部署保证**:外围配置仍可能加载 stdout logger 并破坏 JSON-RPC 通道;此插件不会检查或否决同级 logger。 +- **自动挂载适配器仅支持 DeepSeek**:`initialize` 可以复用任何预先注册的模型适配器,但唯一的回退行为是挂载 `dsh-llm-deepseek`。 diff --git a/packages/ui/permission/README.i18n.yaml b/packages/ui/permission/README.i18n.yaml new file mode 100644 index 0000000000..c29bf60910 --- /dev/null +++ b/packages/ui/permission/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 6a59ad9425bf5bfeb89e9798304a2eb90ee55bfa +README.zh.md: 0e7db1bd41a15ac4be18d33db7b9011a5bc24e7e diff --git a/packages/ui/permission/README.md b/packages/ui/permission/README.md index cdf6e0f435..6a59ad9425 100644 --- a/packages/ui/permission/README.md +++ b/packages/ui/permission/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-permission +English | [中文](README.zh.md) + User-facing permission presets through `ctx.permission` ([`PermissionService`](src/index.ts)). Each configured name bundles `sandbox/mode` with `approval/policy`; the defaults are `workspace-write` (`workspace-write` + `ask`) and `danger-full-access` (`danger-full-access` + `never`). UI adapters may expose the table as one selector, while sandbox execution and approval continue to consume their own knobs. `set(session, name)` records a changed selection in a log-only `permission/preset` event, then calls each knob's setter only when its effective value changes. The selection event precedes the knob events and preserves user intent when presets share a bundle; a net-zero selection appends nothing. `current(events)` prefers a still-matching recorded selection, then the first matching table entry, and otherwise returns `custom`. Clients may display `custom` as the current value, but cannot select it. diff --git a/packages/ui/permission/README.zh.md b/packages/ui/permission/README.zh.md new file mode 100644 index 0000000000..0e7db1bd41 --- /dev/null +++ b/packages/ui/permission/README.zh.md @@ -0,0 +1,24 @@ +# @deepseek-ai/dsh-permission + +[English](README.md) | 中文 + +通过 `ctx.permission`([`PermissionService`](src/index.ts))提供面向用户的权限 preset。每个配置名称都会将 `sandbox/mode` 与 `approval/policy` 组成一组;默认项为 `workspace-write`(`workspace-write` + `ask`)和 `danger-full-access`(`danger-full-access` + `never`)。UI 适配器可以将该表作为单个选择器公开,而沙箱执行与审批仍分别消费各自的调节项。 + +`set(session, name)` 会先在仅写日志的 `permission/preset` 事件中记录已变更的选择,再仅对实际值发生变化的调节项调用 setter。选择事件先于调节项事件,并在多个 preset 共享同一组取值时保留用户意图;净变化为零的选择不会追加任何内容。`current(events)` 优先返回仍与当前调节项匹配的已记录选择,其次返回表中第一个匹配项,否则返回 `custom`。客户端可以把 `custom` 显示为当前值,但不能选择它。 + +该服务要求存在具有约束能力的 `ctx.bash` 执行器和 `ctx.approval`。表中名为 `custom` 的条目会在加载时抛出异常;如果组合在表外指定默认值,则零事件会话会推导出 `custom`。详见[沙箱切换设计](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。 + +## 模型体验 + +间接地,通过 `dsh-user-approval` 和 `dsh-tool-bash`:二者会渲染由此服务的调节项事件所选择的审批策略提示词、切换通知和沙箱工具结果;`permission/preset` 本身只写入日志。 + +#### KV Cache 影响 + +不会直接使缓存失效;具名消费方拥有所有请求前缀变更。 + +## 已知限制与延期工作 + +- **当前没有已交付的组合挂载此服务**:在 [ACP 变为仅用于自动化](../../../.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md)之前,ACP 桥接层是唯一的选择器;preset 表为下一个公开运行时策略切换的交互式入口保留。 +- **只组合两个机制调节项**:preset 选择沙箱模式和审批策略;agent(智能体)/profile 选择尚未纳入 `PresetSpec`。 +- **`custom` 只能推导得出**:调用方可以从不匹配的调节项组合切换出去,但无法通过此服务选中或持久化一个具名 custom preset。 +- **preset 表位于进程级别**:配置在插件生命周期内固定;更改可用 preset 必须重新加载插件。 diff --git a/packages/ui/tool-ask-user/README.i18n.yaml b/packages/ui/tool-ask-user/README.i18n.yaml new file mode 100644 index 0000000000..a03a7326fa --- /dev/null +++ b/packages/ui/tool-ask-user/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 8e779f4025c20cd200344efb7cb8cd6bc09ba64d +README.zh.md: fe1dc5559882532c4f44e705cc6daa2c7f4f8905 diff --git a/packages/ui/tool-ask-user/README.md b/packages/ui/tool-ask-user/README.md index ff67395800..8e779f4025 100644 --- a/packages/ui/tool-ask-user/README.md +++ b/packages/ui/tool-ask-user/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-tool-ask-user +English | [中文](README.zh.md) + Model-facing `ask_user_question` tool over `ctx.userInteraction`. It lets the model ask the human a concise question when it needs confirmation, a choice, or missing information before continuing. ## Tool diff --git a/packages/ui/tool-ask-user/README.zh.md b/packages/ui/tool-ask-user/README.zh.md new file mode 100644 index 0000000000..fe1dc55598 --- /dev/null +++ b/packages/ui/tool-ask-user/README.zh.md @@ -0,0 +1,57 @@ +# @deepseek-ai/dsh-tool-ask-user + +[English](README.md) | 中文 + +模型侧 `ask_user_question` 工具,基于 `ctx.userInteraction` 实现。当模型需要确认、选择或缺失信息才能继续时,它可以借此向用户提出简明问题。 + +## 工具 + +`ask_user_question` 接受以下参数: + +- `questions`:必填的非空问题对象数组。 +- `id`:每个问题必填的稳定 id,会原样包含在回答中。 +- `question`:每个问题必填的问题文本。 +- `header`:可选的简短标题。 +- `options`:可选选项,包含 `label` 和 `description`。如需推荐某个选项,请将其置于首位,并在该标签末尾追加 `(Recommended)`。 +- `multi_select`:该问题是否可以返回多个选中的选项。 + +工具调用 `ctx.userInteraction.ask()`,并返回规范的 `{ answers: [{ id, selected, custom? }] }`。`selected` 包含选项标签;仅当用户自由填写回答时才会出现 `custom`,并覆盖选中的选项。Native renderer 会保留紧凑的 JSON 文本形式 `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`。 + +## 职责 + +此包(package)是用户交互 seam 的消费方。它不渲染 UI,也不了解输入的收集方式;它只将模型参数转换为 `AskUserQuestionRequest`,并把用户回答返回给 agent loop(智能体循环)。 + +## 模型体验 + +### 工具 schema + +#### 模型看到的内容 + +模型会看到生成的 [`ask_user_question` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-ask-user),其中包含问题 id、提示语、标题、选项和多选标志。 + +#### Token 影响 + +工具可见的每个请求都会产生固定的 schema 开销。 + +#### KV Cache 影响 + +只要定义和可见性保持不变,前缀即可稳定复用。插件生命周期变化或作用域限制可能从此 schema 开始使复用失效。 + +### 工具调用历史与结果 + +#### 模型看到的内容 + +模型提出的完整问题保留在 assistant 工具调用参数中。用户回答后,下一步骤会看到精确采用 `{"answers":[{"id":"<id>","selected":["<label>"],"custom":"<text>"}]}` 形式的紧凑 JSON;不使用 `custom` 时会省略该字段,`selected` 可以包含零个、一个或多个标签。调用等待期间的 UI 交互不属于模型上下文。 + +#### Token 影响 + +参数和回答 JSON 是依数据而定的保留 token;等待用户时不会产生 token 开销。 + +#### KV Cache 影响 + +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 + +## 已知限制与延期工作 + +- **待处理问题会阻塞工具调用,直至用户作答**:该工具未声明 `timeout-policy` 预算;取消仅沿用当前轮次的 `exec.signal`。 +- **Native 回答渲染为 JSON 文本**:规范值仍为结构化数据,但模型侧结果使用紧凑 JSON,而非更丰富的内容块词汇。 diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml new file mode 100644 index 0000000000..a2a17d022d --- /dev/null +++ b/packages/ui/tui/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 3d64de9f0703838cad10f8e04665ec4b985d00dc +README.zh.md: 5cf41dd76c7c28cd2d605466c7c10cfe1c1dd958 diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 616544779f..3d64de9f07 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-tui +English | [中文](README.zh.md) + The interactive terminal front door for DeepSeek Harness agents, built on [`@earendil-works/pi-tui`](https://www.npmjs.com/package/@earendil-works/pi-tui). It requires stdin and stdout TTYs; scripts and Loader pipes should use the one-shot [`@deepseek-ai/dsh-cli-demo`](../../examples/cli-demo/README.md) app instead. The implemented [TUI feature Agent Note](../../../.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md) owns the front-door decision; the [file-reference autocomplete Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.md) owns path-only `@file` behavior; the [terminal-state snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) owns its verification strategy. diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md new file mode 100644 index 0000000000..5cf41dd76c --- /dev/null +++ b/packages/ui/tui/README.zh.md @@ -0,0 +1,165 @@ +# @deepseek-ai/dsh-tui + +[English](README.md) | 中文 + +DeepSeek Harness agent(智能体)的交互式终端入口,基于 [`@earendil-works/pi-tui`](https://www.npmjs.com/package/@earendil-works/pi-tui) 构建。它要求 stdin 和 stdout 均为 TTY;脚本和 Loader pipe 应改用单次执行的 [`@deepseek-ai/dsh-cli-demo`](../../examples/cli-demo/README.md) app。 + +已实现的 [TUI 功能 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md)持有终端入口决策;[文件引用自动补全 Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.md)持有仅路径的 `@file` 行为;[终端状态快照 Agent Note](../../../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md)持有其验证策略。 + +支持 macOS、Linux 和 Windows 上的交互式终端。Windows 使用 pi-tui 原生控制台 VT 输入处理;[Windows 支持 Agent Note](../../../.agents/notes/implemented/feature/2026-07-20-windows-tui-support.md)持有平台决策与 ConPTY 进程验证。 + +本包(package)只持有交互式终端展示和输入。它注入 `agents`、[`commands`](../commands/README.md)、`llm`、`systemPrompt`、`tokenMeter`、`tools` 和 `userInteraction`,可选读取 `skills` 服务(仅在已挂载时存在),然后驱动由 app 或开发者代码创建或恢复的 agent。Agent 生命周期、持久化与模型侧 [`ask_user_question`](../tool-ask-user/README.md) 工具仍是独立组合项。 + +终端成功启动后,本包会提供终端本地的 `ctx.tui` 扩展服务。注入该服务的插件可以使用组件工厂和受限布局选项调用 `openOverlay()`;宿主会公开 viewport、语义化主题、显示文本转义、重绘、关闭和生命周期信号,但不公开 pi-tui 树、终端、焦点控制器或 overlay 句柄。插件 overlay、模型选择器和用户问题共用一个 FIFO 模态队列。每个请求都是调用方插件 fiber 的 effect,因此卸载会移除排队工作,或在清理结算前关闭可见工作;终端关闭会先卸载依赖项,再停止 pi-tui。Overlay 状态不会记录或回放。组件代码受信任,可以渲染 ANSI 样式,但必须通过 `host.display()` 处理不受信任文本。[交互式扩展 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md)持有该边界和未采用的替代方案。 + +TUI 从活跃会话表层重建已恢复历史,渲染 Markdown 响应与 reasoning,将每个工具的 `presentCall` / `presentResult` 意图应用到终端、diff 或通用卡片,把最新的 `todo/write` 计划保留在编辑器上方,并在左下方宽键盘面板中展示 `ctx.userInteraction` 问题,包含进度、编号选项和对齐说明。最新记录的会话标题成为 header 副标题;标题不存在时使用 `welcome`,终端窗口标题则变为 `<session title> — <configured title>`。持久 `llm/retry` 事件会撤回失败步骤的实时 chunk,并在 transcript(文本记录)中渲染计划重试次数、延迟和失败;成功、耗尽与取消随后通过普通会话事件结算。Footer 会对每个已记录模型步骤的用量只计一次,包括失败尝试;对于没有用量 chunk 的日志,以已提交消息的用量回退。其空闲视图会比较 token-meter 压力与当前路由的 `ctx.llm.resolveModelContext()`;适配器没有容量元数据时显示 `context unknown`,并显示工具卡片模式、当前模型和 reasoning 状态。Agent 运行时,这些摘要会替换为已经过工作时间指示器和 `esc interrupt`。表层替换事件会重建 transcript,使经过压缩(compaction)的历史不会再次出现。 + +如果逻辑工作区标签与会话宿主目录不同,嵌入方可以提供 `TuiRuntime.formatCwd`。该覆盖只改变 footer 标签;工具仍使用会话 `cwd`。 + +在模型输出、会话事件、工具 presenter、问题、配置或诊断到达 pi-tui 的 ANSI 感知 renderer 或终端标题前,TUI 会把换行之外的 C0 和 C1 控制字符渲染为可见 `\xNN` 文本。这些来源无法添加终端控制序列;终端渲染与样式仍由 TUI 和 pi-tui 持有。 + +在 token 边界输入 `@` 会搜索会话工作目录下的文件和目录。没有路径的模糊查询使用可复用的有界工作区索引;包含 `/` 的查询直接列出该目录,选择文件夹后会保持补全开启以继续深入。含空白的路径会插入为 `@"path with spaces"`。选择文件只会插入其路径和一个尾随空格:TUI 不会读取文件、附加隐藏上下文,也不会把路径替换为引用对象。注册模型侧 `read` 工具后,TUI 会添加一条固定系统提示词指令,要求模型在需要显式路径内容时读取该路径。 + +挂载可选的 `ctx.sessionReferences` 后,同一个 `@` 菜单还会提供仅含元数据的会话候选项,插入 `@[label](dsh-session:<payload>)`,并在分派前准备所选快照。会话引用保持结构化,因为模型没有类似文件系统的工具可在稍后检索会话快照。准备期间会禁止重复提交,并在失败时恢复编辑器输入。TUI 会在异步准备后根据状态选择 `agent.steer()` 或 `agent.followup()`,因此空闲 followup 仍会分派 `agent/prompt-submit`,而轮次中的 steering 会在检查点加入且不触发该 hook。 + +Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help`、`/model`、`/clear`、`/reasoning`、`/tools`、`/redraw`、`/reload`、`/resume`、`/status` 和 `/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help`,`/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具;它显示该阶段已经过时间和运行中的步骤总数,每秒刷新,并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标,每条消息排空后随即清除。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片把长主体折叠为可配置的头尾预览;Ctrl+O 在预览与完整输出之间切换所有卡片。Ctrl+R 切换 reasoning,Ctrl+L 重绘,Ctrl+D 在空闲时退出。 + +`/model` 将建议性的 `ctx.llm` catalog 打开为键盘选择器:Up/Down 移动,Enter 选择,Escape 关闭。`/model <model>` 仍可直接选择无歧义的模型 id,`/model <provider>/<model>` 则选择精确目标。已配置目标或最新记录的请求 header 会初始化选择器;由于 catalog 仅提供建议,未列出的当前模型仍会显示。选择仅对本 TUI 会话有效。提示词组装会为一个步骤建立目标快照,替换 `{{provider}}` 和 `{{model}}`,并通过 `agent/request` 应用同一组值;因此组装期间的切换会从后续步骤开始生效。请求 header 会持久记录真正到达模型的目标,未使用的选择则只存在于进程本地。 + +`/reload`(实验性,仅开发环境)会重新读取所有基于文件的 loader 配置树,并把 diff 应用到运行中 app:它手动调用 HMR(热模块替换)watcher 的配置路径;上下文中必须有 cordis Loader,否则退化为警告。它只在 agent 空闲时运行,并拒绝 reload 进行期间的再次进入。模块源代码热重载仍由 watcher 持有。挂载 `skills` 服务后,`/skill:<name> [instructions]` 会把该 skill 的指令作为一个 user 轮次加载到会话中;自动补全列出模型可调用的 skill,任何 skill(包括模型禁用的 skill)都可通过精确名称加载。 + +Footer 将会话报告的用量汇总为 `↑<uncached input> ↓<output>`;任何输入计费后,后面会显示 `cache <rate>%`,表示提供方缓存服务的已计费提示词 token 占比(未缓存输入加缓存读写),并四舍五入为百分比。它还会比较 token-meter 压力与当前路由的 `ctx.llm.resolveModelContext()`(适配器没有容量元数据时省略上下文占比),并显示当前模型和工具卡片模式;footer 过窄时,右侧会优先裁剪。 + +`/status` 会向 transcript 添加一张时间点诊断卡片,并在 agent 运行时保持可用。它报告会话 id、标题、工作目录、所选提供方/模型、reasoning 块可见性、agent 状态、事件/轮次/步骤/工具调用计数、精确输入/输出/缓存 token bucket、KV-cache 命中率、token-meter 上下文用量与容量、创建时间和最新事件时间。缺失标题、模型、缓存输入或上下文容量时会明确标记,而非推断。该卡片只存在于终端,不会重复紧凑 footer。 + +`/resume` 会针对当前工作区打开全 viewport 键盘选择器,而非居中对话框。获得焦点的搜索字段紧跟搜索 glyph 开始,并发出 pi-tui 的 cursor marker,使终端 IME 组合保持锚定在字段内。候选项按最近记录的活动排序,可按日志支持的标题或会话 id 搜索;每行报告 current/live/persisted 状态、上一轮次结果、近期提供方/模型,以及存在时的持久目标阶段。Up/Down 与 Page Up/Page Down 导航,Enter 恢复,Escape 会先清除非空搜索,再次按下才取消,Ctrl+C 则直接取消。当前会话、已在本运行时中活跃的会话、不可读日志、cwd 不匹配或日志所记提供方没有当前适配器的会话仍会显示,但不可选择。选择时会重复这些检查,并要求当前 agent 空闲,随后 flush 当前会话。TUI 接着停止终端 UI,并调用由宿主持有的可选 `TuiRuntime.handoffResume`;存在 `process.execve` 时,发布的 `dsh` 宿主会对 app 执行 dispose(资源释放)并替换自身进程。恢复操作保留相同的 `SessionId`、transcript、标题、todo 和持久目标;目标激活仍保持解除,TUI 会要求用户确认或执行 `/goal resume`。 + +`resumeCommand` 仍是部署持有的回退行为:只有当前会话已持久化后,退出才会打印它;不支持原地 handoff 的宿主会显示所选会话的命令。`{session}` 展开为会话 id。TUI 代码绝不会执行模板或任意 shell 文本。 + +## 配置 + +| 键 | 默认值 | 含义 | +|---|---|---| +| `welcome` | 未设置 | 会话出现已记录标题前使用的 banner 副标题行;未设置时,banner 进入时没有副标题 | +| `sessionId` | `main` | 由终端驱动的精确共享 agent/会话身份 | +| `showReasoning` | `true` | 渲染 reasoning 块 | +| `maxToolOutputLines` | `6` | 折叠工具卡片的头尾预览所保留的输出行数 | +| `maxQuestionOptions` | `8` | 问题面板中可见的选项数 | +| `maxModelOptions` | `8` | 模型选择器中可见的模型数 | +| `maxResumeOptions` | `8` | 恢复选择器中可见的会话数 | +| `questionDialogWidth` | `200` | 问题面板宽度(列数),以终端宽度为上限 | +| `questionDialogMaxHeight` | `20` | 问题面板最大行数 | +| `modelDialogWidth` | `72` | 模型选择器宽度(列数) | +| `modelDialogMaxHeight` | `20` | 模型选择器最大行数 | +| `fileSearchMaxResults` | `20` | 一次 `@` 查询显示的最大文件和目录候选数 | +| `fileSearchMaxEntries` | `10000` | 无路径模糊查询使用的有界工作区索引最多保留的路径数 | +| `fileSearchExcludedDirectories` | `['.git', 'node_modules']` | 遍历和直接补全时忽略的目录 basename | +| `showHardwareCursor` | `false` | 在 pi-tui 的 IME marker 处显示硬件 cursor | +| `color` | `true` | 应用内置 ANSI palette(参见[颜色](#color)) | +| `title` | `DeepSeek Harness` | 终端窗口标题的产品后缀。 | +| `resumeCommand` | 未设置 | 供退出提示和不支持原地 handoff 的宿主使用的 shell 命令模板,其中 `{session}` 会展开为会话 id | + +```yaml +- id: terminal + name: '@deepseek-ai/dsh-tui' + config: + welcome: 'Coding agent ready.' + sessionId: main-session-123 + showReasoning: true + maxToolOutputLines: 6 + fileSearchExcludedDirectories: ['.git', 'node_modules', 'dist'] +``` + +任一进程流不是 TTY 时,启动会在挂载前失败。组合 app 必须先挂载 TUI,再挂载由配置创建的 agent,使入口能够观察 `agent-loop/config-start-failed`;完全匹配会话的失败会在全屏模式启动前写出并以状态 1 退出,而不是留下空白终端。dispose 会停止接收扩展请求,卸载 `ctx.tui` 提供方及其依赖插件,中止运行中的命令,移除 TUI 定义,停止 loader,拒绝待处理问题,排空终端输入,恢复终端状态,注销事件 listener 和用户交互提供方,并且绝不会在 HMR 期间退出替换进程。 + +## 颜色 + +Palette 使用标准 16 色 ANSI 前景色和 SGR 属性,每个终端都会将其重新映射到当前配色方案,因此浅色与深色背景下都保持可读。正文使用终端默认前景色,而非固定色调。成组区域(用户提示词、工具卡片)使用彩色左侧 gutter bar,而非填充背景块;问题面板使用粗体强调色文本突出活跃行,选择器则使用反色。所有效果都只作用于前景色,因此不会与终端背景冲突。设置 `color: false` 可移除所有样式。 + +## 模型体验 + +### 交互式提示词输入 + +#### 模型看到的内容 + +每次非空普通编辑器提交都会成为一个文本块;目标 agent 空闲时通过 `agent.followup()` 发送,运行时通过 `agent.steer()` 发送。会话 mention 会变为可读的 `@label` 文本,加上由 [`dsh-session-reference`](../../context/session-reference/README.md) 定义的持久不受信任上下文;其完整 JSON 隐藏在紧凑引用卡片之后。斜杠命令和按键绑定仅用于 TUI;命令结果仍是终端通知。命令生产方可以调度单独的 agent 输入,例如 `/plan [message]` 接受的可选消息。 + +#### Token 影响 + +提交的文本会按 agent loop 的普通会话历史与压缩规则保留。Header、已记录标题、卡片、Markdown 渲染、状态行、计划和帮助文本不会增加 token。 + +#### KV Cache 影响 + +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 + +### 文件引用自动补全 + +#### 模型看到的内容 + +所选文件仍是普通 user 文本,例如 `@src/index.ts` 或 `@"docs/design notes.md"`;自动补全不会添加内容块、持久上下文或特殊引用 payload。注册 `read` 后,此 TUI agent 的每个请求还会包含下方固定系统提示词段落。模型会判断任务是否需要文件内容,并在需要时通过普通工具循环调用 `read`;只有路径不能证明文件已经过检查。 + +##### 精确系统提示词文本 + +```markdown +Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it. +``` + +#### Token 影响 + +自动补全本身不增加 token。所选路径只贡献普通 user 文本 token;`read` 可用时,固定指令会贡献系统提示词 token。只有模型选择的 `read` 调用返回文件内容后,这些内容才会占用上下文。 + +#### KV Cache 影响 + +固定指令属于稳定系统提示词前缀,可以跨轮次复用。每个所选路径都是仅追加 user 文本;后续 `read` 结果通过普通工具 transcript 追加所请求内容。 + +### 会话模型选择 + +#### 模型看到的内容 + +`/model` 命令文本和键盘选择器输入均不会记录或发送。新步骤会在提示词变量和请求路由中同时收到所选提供方/模型对。 + +#### Token 影响 + +选择器不会添加消息。更改目标可能改变插值后的系统提示词文本,并把后续请求发送给所选模型。 + +#### KV Cache 影响 + +更改提供方或模型会进入该目标的缓存域;不假定不同目标间可以复用缓存。 + +### 手动调用 skill + +#### 模型看到的内容 + +提交 `/skill:<name> [instructions]` 会加载具名 skill,并交付一个文本块:用 `<skill name="…">` 元素包装 skill 指令;提供方公开资源基准时,会先添加一行定位 skill 相对资源;最后附上用户输入的尾随指令。交付遵循普通输入同样的空闲时 followup、运行时 steer 规则。选择 skill 的是命令而非模型;模型禁用的 skill 不出现在自动补全中,但仍可按精确名称加载。 + +#### Token 影响 + +渲染后的 skill 块与尾随指令会作为一个 user 轮次保留,并遵循 agent loop 的普通会话历史和压缩规则;重复调用会再次追加正文。 + +#### KV Cache 影响 + +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 + +### 交互式用户问题回答 + +#### 模型看到的内容 + +消费方调用 `ctx.userInteraction.ask()` 时,此提供方会按顺序显示各个问题,并返回选中选项标签或 `custom` 文本。中止、取消或 UI dispose 会变为 `Error: ask_user_question was interrupted before the user answered`;该转换由 `dsh-tool-ask-user` 完成。 + +#### Token 影响 + +等待和终端 overlay 不增加 token;已解析回答或错误只会通过调用工具或插件的结果对模型可见。 + +#### KV Cache 影响 + +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 + +## 已知限制与延期工作 + +- **恢复功能没有跨进程会话锁**:选择器会拒绝本运行时中已知处于活跃状态的会话,但另一个进程可以在 handoff 之前或期间恢复同一持久 id。能够运行并发宿主的部署必须在 TUI 外协调所有权。 +- **一个已配置会话持有 transcript 和编辑器**:其他 agent 的问题仍可使用共享 overlay 提供方,但会话渲染与提示词输入仍绑定到 `sessionId`。 +- **工具卡片是文本终端展示**:终端、diff 与通用卡片使用工具持有的标题/内容,但会话内容目前没有用于内联图像渲染的图像块。 +- **有意不支持非 TTY 运行**:需要自动化的 app bundle 必须组合单次执行或服务器入口(`dsh-cli-demo`、`dsh-acp`),而不能依赖内部回退。 +- **手动 `/skill:` 调用总会重新加载完整 skill 正文**:TUI 不会检测会话中是否已存在某项 skill,因此重复调用会再次追加其指令。 +- **文件发现只发现宿主工作区**:自动补全读取 TUI 进程的会话 `cwd`,所选文本随后由已配置 `read` 工具解释。挂载远程或虚拟文件系统的部署必须对齐这些 namespace,或提供其他补全接口。 +- **文件搜索使用显式目录排除项,而非 ignore 文件**:默认排除 `.git` 和 `node_modules`,部署还可以配置更多 basename,但不会解释 `.gitignore` 和 `.ignore`。目录 symlink 不会遍历。 diff --git a/packages/ui/user-approval/README.i18n.yaml b/packages/ui/user-approval/README.i18n.yaml new file mode 100644 index 0000000000..083bc84d20 --- /dev/null +++ b/packages/ui/user-approval/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 38bcfbfe81c3ff5f16d1835259bd4c35a06dcb64 +README.zh.md: 2a3a6d08d66c70a22b3a23a2341efc0452b8a782 diff --git a/packages/ui/user-approval/README.md b/packages/ui/user-approval/README.md index f61e56b535..38bcfbfe81 100644 --- a/packages/ui/user-approval/README.md +++ b/packages/ui/user-approval/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-user-approval +English | [中文](README.zh.md) + Channel-neutral one-shot approval seam. `ctx.approval.request(req)` returns `allowed-once`, `rejected`, `cancelled`, or `unavailable`; missing or failing answerers fail closed, and a grant applies only to the requested action. Exact event signatures live in the generated [Cordis catalog](../../../docs/cordis-catalog/events.md). Each request must belong to an open agent turn. The service appends a paired `approval/asked` and `approval/decided` audit record, while the model sees only the resulting logged tool outcome. An aborted request resolves `cancelled`; an audit append that fails before commit rejects rather than returning an unlogged decision. diff --git a/packages/ui/user-approval/README.zh.md b/packages/ui/user-approval/README.zh.md new file mode 100644 index 0000000000..2a3a6d08d6 --- /dev/null +++ b/packages/ui/user-approval/README.zh.md @@ -0,0 +1,63 @@ +# @deepseek-ai/dsh-user-approval + +[English](README.md) | 中文 + +与通道无关的一次性审批 seam。`ctx.approval.request(req)` 返回 `allowed-once`、`rejected`、`cancelled` 或 `unavailable`;应答者缺失或失败时会以拒绝方式关闭,授权也只适用于所请求的操作。确切事件签名见生成的 [Cordis 目录](../../../docs/cordis-catalog/events.md)。 + +每个请求都必须属于一个打开的 agent(智能体)轮次。服务会追加一对 `approval/asked` 与 `approval/decided` 审计记录,而模型只会看到由此产生且已写入日志的工具结果。已中止的请求会 resolve 为 `cancelled`;如果审计追加在提交前失败,请求会被拒绝,而不会返回一项未记录的决定。 + +应答者是 `approval/request` waterfall(瀑布式事件)监听器。要回答所拥有 agent 的请求,请返回一个结果;否则调用 `next()` 委托。限定到 agent 的监听器只接收该 agent 的请求;每项部署应当组合一个终端应答者,因为同级监听器的顺序不是策略优先级机制。ACP(Agent Client Protocol)自动化桥接层为其拥有的会话提供一次性机器决定。 + +`ApprovalPolicy` 为 `'ask'` 或 `'never'`。实际值取最后一条 `approval/policy` 事件,并回退到配置;`setApprovalPolicy()` 是写入路径。`'never'` 会在交互式分发之前拒绝请求,也是提示词中唯一声明的策略。切换最多产生一条合并通知:如果覆盖发生在最后一个 `request/header` 之后,则归因于用户;否则归因于操作方/配置。 + +工具流水线通过此 seam 路由 `ask` 决定,并在该 seam 缺失时以拒绝方式关闭;沙箱 bash 工具也会将它用于升权重试。ACP 自动化桥接层根据客户端的机器策略,回答其自有 agent 的调用。审计事件仍只写入日志,因此模型只会看到发起请求的消费方所返回的结果。详见[审批 seam Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-06-approval-seam.md)和[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。 + +## 模型体验 + +### 系统提示词与策略通知 + +#### 模型看到的内容 + +在 `ask` 下,每个 agent 请求都会携带下方的 ask 策略提示词段。在 `never` 下,请求会携带下方的 never 策略提示词段。策略切换会在下一步骤前精确注入 `The approval policy changed from "<old>" to "<new>" (changed by the user).` 或 `The approval policy changed from "<old>" to "<new>" (changed by the operator/config).`。 + +##### Ask 策略提示词段 + +```markdown +<!-- dsh-user-approval-policy:ask --> +``` + +##### Never 策略提示词段 + +```markdown +Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). +<!-- dsh-user-approval-policy:never --> +``` + +#### Token 影响 + +每个请求有少量固定成本,`never` 下的成本更高;变更通知按条件出现,并保留在历史中。 + +#### KV Cache 影响 + +审批策略不变时,前缀保持稳定。`ask`/`never` 切换会改变系统提示词段,并从首个变化的 token 开始使复用失效;随附通知只会追加。 + +### 工具结果 + +#### 模型看到的内容 + +`approval/asked` 和 `approval/decided` 只写入日志。模型只会看到发起请求的消费方最终给出的允许、拒绝、取消或不可用工具结果;面向人类的权限 UI 不属于上下文。 + +#### Token 影响 + +不会产生重复的审计 token。拒绝可能以一条少量且保留的错误替换正常工具结果,而允许会保留消费方的普通结果。 + +#### KV Cache 影响 + +仅追加;新出现的可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 + +## 已知限制与延期工作 + +- **请求只在打开的轮次内有效**:空闲时或轮次之间的调用方会在审计前抛出异常;持久化的轮次外审批工作流仍属延期事项。 +- **仅存在一次性授权**:结果词汇包含 `allowed-once`,但不含 `allow-always`、记忆规则、撤销或授权存储;会话策略只有 `ask`/`never`。 +- **请求不携带工具参数**:应答者会看到工具名称、原因和可选调用 id;ACP 机器通道要求调用 id,并会委托不含 id 的请求。 +- **没有内置应答者**:无头或组合不完整的部署会 resolve 为 `unavailable` 并以拒绝方式关闭;服务自身绝不会提示人类。 diff --git a/packages/ui/user-interaction/README.i18n.yaml b/packages/ui/user-interaction/README.i18n.yaml new file mode 100644 index 0000000000..2a3b525012 --- /dev/null +++ b/packages/ui/user-interaction/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: d234d6677bdd772f1bbd2c979c0d41f90aef5c32 +README.zh.md: b70a61d6491e0bb0e52215cdeaeea3d728f7f153 diff --git a/packages/ui/user-interaction/README.md b/packages/ui/user-interaction/README.md index 6e74d743c2..d234d6677b 100644 --- a/packages/ui/user-interaction/README.md +++ b/packages/ui/user-interaction/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-user-interaction +English | [中文](README.zh.md) + Abstract user-interaction seam. It owns `ctx.userInteraction`, the service a model-facing tool or permission plugin uses when it needs to pause work and ask the human for a decision. ## Service: `UserInteractionService` (ctx key: `userInteraction`) diff --git a/packages/ui/user-interaction/README.zh.md b/packages/ui/user-interaction/README.zh.md new file mode 100644 index 0000000000..b70a61d649 --- /dev/null +++ b/packages/ui/user-interaction/README.zh.md @@ -0,0 +1,39 @@ +# @deepseek-ai/dsh-user-interaction + +[English](README.md) | 中文 + +抽象用户交互 seam。它拥有 `ctx.userInteraction`:当面向模型的工具或权限插件需要暂停工作并询问人类决定时所使用的服务。 + +## 服务:`UserInteractionService`(ctx 键:`userInteraction`) + +### 公开 API + +- `ctx.userInteraction.registerProvider(provider): () => void` 注册 UI 侧提供方。同一上下文中只能有一个活跃提供方;dispose(资源释放)会将其注销。 +- `ctx.userInteraction.ask(request): Promise<AskUserQuestionAnswer>` 向活跃提供方提问并等待回答。 + +### 关键类型 + +- `AskUserQuestionRequest`:`{ questions: [{ id, question, detail?, header?, options?, multiSelect? }], agent?, signal? }`;`detail` 提供辅助文本,提供方会将其随问题一起渲染,而不会将其变成选项标签。 +- `AskUserQuestionOption`:`{ label, description? }`。 +- `AskUserQuestionAnswer`:`{ answers: [{ id, selected, custom? }] }`。 +- `UserInteractionProvider`:包含 `ask(request)` 的 UI 实现。 +- `UserInteractionError`:`HarnessError` 的子类,包含 `EMPTY_QUESTIONS`、`NO_PROVIDER`、`DUPLICATE_PROVIDER` 和 `ASK_ABORTED` 等代码。 + +当回答包含 `custom` 时,`selected` 为空;自定义文本会覆盖所选选项,而不是补充它们。UI 可以把跳过的条目保留为 `{ id, selected: [] }`,既维持现有回答形态,也保留该批次中的其他回答。 + +## 职责 + +这是接口包(package)。`@deepseek-ai/dsh-tool-ask-user` 等面向模型的消费方依赖此 seam;`dsh-tui` 和宿主运行时提供交互式实现。循环保持不变:工具调用等待 Promise,工具结果随后恢复正常的 agent loop(智能体循环)。 + +## 模型体验 + +间接地,通过 `dsh-tool-ask-user`:它会将成功的提供方回答保留为紧凑 JSON,或返回以下失败之一:`Error: ask_user_question was aborted before the user answered`、`Error: ask_user_question requires at least one question`、`Error: no user-interaction provider is registered` 或 `Error: <message>`。等待人类回答不会增加 token。 + +#### KV Cache 影响 + +不会直接使缓存失效;具名消费方拥有所有请求前缀变更。 + +## 已知限制与延期工作 + +- **每个上下文只能有一个提供方**:不支持路由或扇出到多个 UI;第二次注册会抛出 `DUPLICATE_PROVIDER`,未注册任何提供方时,`ask()` 会抛出 `NO_PROVIDER`,而不会降级。 +- **词汇仅包含问题表单形态**:可选选项加可选自定义文本;更丰富的交互形态(文件选择器、diff 预览确认)尚无 seam 词汇。 diff --git a/packages/util/README.i18n.yaml b/packages/util/README.i18n.yaml new file mode 100644 index 0000000000..1fc811bc8b --- /dev/null +++ b/packages/util/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 140df90571d84320fb4eb888508c67e60aa29a22 +README.zh.md: 4c16df2a56476c0a7c965a037389fa5ba231e273 diff --git a/packages/util/README.md b/packages/util/README.md index 5a9f626de5..140df90571 100644 --- a/packages/util/README.md +++ b/packages/util/README.md @@ -1,5 +1,7 @@ # util/ — low-level shared utilities +English | [中文](README.zh.md) + Zero-dependency primitives shared across the other groups. A package lands here when it owns a tiny, foundational type or helper that several capability families need but that belongs to none of them — keeping it out of any one group avoids a capability package depending on an unrelated one just to reach a shared primitive. These are **support** packages: small, stable, and free of harness dependencies. | Package | Role | diff --git a/packages/util/README.zh.md b/packages/util/README.zh.md new file mode 100644 index 0000000000..4c16df2a56 --- /dev/null +++ b/packages/util/README.zh.md @@ -0,0 +1,20 @@ +# util/:底层共享工具 + +[English](README.md) | 中文 + +其他分组共享的零依赖原语。当某个微小的基础类型或辅助工具被多个功能家族所需,但又不属于任何一个家族时,它就位于此处。这样可避免一个功能包仅为使用共享原语而依赖不相关的功能包。这些都是**支持** 包:规模小、稳定,且不依赖 harness。 + +| 包 | 职责 | +|---|---| +| `brand/` | 仅包含类型的 `Branded<B>` 名义类型原语(无运行时代码,无 harness 依赖) | +| `paths/` | 规范的单根 `DSH_HOME` 解析,以及 harness 用户数据的共享文件系统路径常量和辅助工具(无 harness 依赖) | +| `timeout/` | 超时的时序/分类部分:`clampTimeout`/`deadline`/`timeoutOf`/`TimeoutReason`(纯函数,无 harness 依赖);终止机制保留在各个功能中 | +| `retention/` | 有界的面向模型输出:`ItemRetainer`/`TextRetainer` 加上中性通知辅助工具(纯工具,无 harness 依赖);业务语义保留在各个工具中 | + +`dsh-brand` 是规范示例:它只负责 `Branded<B>` 辅助工具,因此功能包可以为自己拥有的 id 添加品牌(`dsh-tasks` 的 `TaskId`、`dsh-session` 的 `SessionId` 等),而只需依赖 `dsh-brand`,无需仅为使用 `Branded` 而引入不相关的包。 + +`dsh-paths` 为每个包提供同一个可配置的 Harness 主目录,而不将这项横切事实归属给 bash、skill、telemetry 或组合 bundle。它优先解析显式值,其次是 `$DSH_HOME`,最后回退到 `~/.dsh`;返回绝对路径,但不缓存、创建或修改任何内容。harness 将所有用户数据保存在同一根目录下。 + +`dsh-timeout` 对超时家族采用相同结构:`dsh-bash` 和 `dsh-web-fetch-local` 都只依赖 `dsh-timeout`,便可将调用方的取消与 deadline 融合,然后区分「已超时」和「已取消」。它刻意只负责时序/分类部分,*终止*机制(对进程组发送 SIGKILL、拆除 fetch socket)保留在各个功能中,因为没有任何共享层可以负责每个功能的终止操作(见[超时库 Agent Note](../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。 + +`dsh-retention` 对有界工具输出采用同样的拆分方式:工具(`glob`/`grep`/`bash`/`web_fetch`/`web_search`)将项或文本送入 retainer,取回保留的内容以及被省略的精确内容;分组、退出码、提供方错误和恢复文案则仍由工具负责。它刻意只负责保留机制;`truncated` 是预算事实,绝不表示「检查不完整」状态(见[保留库 Agent Note](../../.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md))。 diff --git a/packages/util/brand/README.i18n.yaml b/packages/util/brand/README.i18n.yaml new file mode 100644 index 0000000000..b7f9ff0337 --- /dev/null +++ b/packages/util/brand/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 68401d95a31ed2122386794ad5256a5cd93bb2a6 +README.zh.md: c8b773937426487e67001bc133cf7fae3aea2c6e diff --git a/packages/util/brand/README.md b/packages/util/brand/README.md index e292f0bd26..68401d95a3 100644 --- a/packages/util/brand/README.md +++ b/packages/util/brand/README.md @@ -1,5 +1,7 @@ # dsh-brand +English | [中文](README.zh.md) + The `Branded<B>` nominal-typing primitive — a tiny, **type-only** package (no runtime code, no harness-package dependency) shared by every package that owns a cross-boundary id. ## What `Branded` is diff --git a/packages/util/brand/README.zh.md b/packages/util/brand/README.zh.md new file mode 100644 index 0000000000..c8b7739374 --- /dev/null +++ b/packages/util/brand/README.zh.md @@ -0,0 +1,28 @@ +# dsh-brand + +[English](README.md) | 中文 + +`Branded<B>` 名义类型原语:一个微小的**仅类型** 包(无运行时代码,无 harness 包依赖),由每个拥有跨边界 id 的包共享。 + +## `Branded` 是什么 + +品牌使 `SessionId` 和 `CallId` 这样结构相同的字符串在类型层面不可互换,尽管两者在运行时都是普通 `string`。 + +```ts +import type { Branded } from '@deepseek-ai/dsh-brand' + +export type SessionId = Branded<'SessionId'> + +/** Brand a string as a SessionId (a plain cast — zero runtime cost). */ +export function SessionId(id: string): SessionId { + return id as SessionId +} +``` + +构造操作通过所属包中针对每个 id 的工厂完成。比较、日志记录、JSON 序列化和协议格式与普通字符串表现相同;品牌会在编译时被擦除。 + +## 策略:为跨包边界的 id 添加品牌 + +包为自己拥有的 id 添加品牌:`CallId` 位于 `dsh-llm`,共享的 agent/会话 `SessionId` 位于 `dsh-session`,`TaskId` 位于 `dsh-tasks`。为可能被混淆的跨包 id 添加品牌,但无需为每个字符串都添加。 + +该包只负责原语。保持无依赖意味着,例如 `dsh-tasks` 可以为 `TaskId` 添加品牌,而无需仅为使用 `Branded` 而导入不相关的功能包。 diff --git a/packages/util/paths/README.i18n.yaml b/packages/util/paths/README.i18n.yaml new file mode 100644 index 0000000000..5fa3570408 --- /dev/null +++ b/packages/util/paths/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: b28e684f3183d739c8e229a9b341801dbf345d86 +README.zh.md: 20cc033e4d0ee22d75c7ca315c462a7846146444 diff --git a/packages/util/paths/README.md b/packages/util/paths/README.md index 3691417289..b28e684f31 100644 --- a/packages/util/paths/README.md +++ b/packages/util/paths/README.md @@ -1,5 +1,7 @@ # dsh-paths +English | [中文](README.zh.md) + Shared filesystem path helpers for DeepSeek Harness user data. ## DSH home diff --git a/packages/util/paths/README.zh.md b/packages/util/paths/README.zh.md new file mode 100644 index 0000000000..20cc033e4d --- /dev/null +++ b/packages/util/paths/README.zh.md @@ -0,0 +1,24 @@ +# dsh-paths + +[English](README.md) | 中文 + +DeepSeek Harness 用户数据的共享文件系统路径辅助工具。 + +## DSH 主目录 + +`resolveDshHome()` 解析 DeepSeek Harness 的单根主目录。优先级从高到低为:显式配置的路径、`$DSH_HOME`、`~/.dsh`。harness 将所有用户数据保存在同一根目录下。 + +`dshHomeDisplay()` 以符号方式表示当前根目录,用于面向用户的路径:默认主目录表示为 `~/.dsh`,任何已配置的主目录表示为 `$DSH_HOME`。它绝不会泄露机器的绝对路径。 + +`DSH_HOME_DIR_NAME` 定义默认用户数据目录名:`.dsh`。 + +`defaultDshHome()` 使用 Node 的平台路径规则,将操作系统主目录与 `.dsh` 拼接,并返回默认 DeepSeek Harness 主目录。 + +`expandHomePath()` 使用操作系统主目录展开 `~`、`~/...` 和 Windows 风格的 `~\...` 前缀。它会保留非波浪号路径和 `~user/...` 原样不变。 + +该包刻意保持规模小且不依赖 harness,以便产品包共享用户数据路径约定,而不必彼此依赖。 + +## 已知限制与待完成工作 + +- **展开范围刻意保持狭窄**:只有单独的 `~`、`~/...` 和 `~\...` 使用当前操作系统主目录;`~alice/...` 等指定用户的形式、环境变量和 shell 表达式保持不变。 +- **辅助工具不会操作文件系统**:调用方仍负责目录创建、存在性检查、权限,以及对结果路径应用信任策略。 diff --git a/packages/util/retention/README.i18n.yaml b/packages/util/retention/README.i18n.yaml new file mode 100644 index 0000000000..ad0fdc79f2 --- /dev/null +++ b/packages/util/retention/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: d257075a67b35e92bce53a88fc6d002f4f4d5d9b +README.zh.md: 433d512144612137fbfccf107cfe0987cec091fa diff --git a/packages/util/retention/README.md b/packages/util/retention/README.md index e3bc6affc2..d257075a67 100644 --- a/packages/util/retention/README.md +++ b/packages/util/retention/README.md @@ -1,5 +1,7 @@ # dsh-retention +English | [中文](README.zh.md) + A dependency-light **retention** library: bounded model-facing output for tools that must cap how much context they return. A caller feeds items or text chunks into a bounded object, then gets the retained content plus exact omission metadata. The library owns **only** the mechanical question *"what did we keep, and what did we omit?"*. Tool-specific code keeps its business semantics: file grouping, line numbering, exit codes, provider error states, per-line preview truncation, spill files, and the model-facing prose. This is the boundary the [Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md) draws. diff --git a/packages/util/retention/README.zh.md b/packages/util/retention/README.zh.md new file mode 100644 index 0000000000..433d512144 --- /dev/null +++ b/packages/util/retention/README.zh.md @@ -0,0 +1,97 @@ +# dsh-retention + +[English](README.md) | 中文 + +一个轻依赖的**保留** 库:为必须限制返回上下文量的工具提供有界的面向模型输出。调用方将项或文本分片送入有界对象,然后取回保留的内容和精确的省略元数据。 + +该库**只** 负责这个机制问题:*「我们保留了什么,又省略了什么?」*。工具专用代码保留其业务语义:文件分组、行号、退出码、提供方错误状态、每行预览截断、spill 文件以及面向模型的文案。这就是 [Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md) 划定的边界。 + +它是**库,而非服务或插件**:没有 `ctx`,不注册任何内容,不发出任何事件。状态只存在于每个 retainer(一次累积)中,绝不跨调用。工具包直接导入它。 + +## 对外接口 + +```ts +import { + ItemRetainer, TextRetainer, + describeOmitted, formatRetentionNotice, +} from '@deepseek-ai/dsh-retention' +import type { + Omitted, PushDecision, RetainedItems, RetainedText, + ItemRetentionStrategy, TextRetentionStrategy, RetentionNotice, +} from '@deepseek-ai/dsh-retention' +``` + +| 导出项 | 职责 | +|---|---| +| `ItemRetainer<T>` | 限制有序逻辑单元(路径、grep 匹配项、来源)。v1 只支持 `head`。`push()` → `PushDecision`;`finish()` → `RetainedItems<T>`。 | +| `TextRetainer` | 限制面向字节的文本流。`head` / `tail` / `headTail`,并在 `finish()` 时保留 UTF-8 边界。`push()` → `PushDecision`;`finish()` → `RetainedText`。 | +| `describeOmitted(omitted, unit)` | 标准化的省略子句(`exact` 输出数量;`unknown` 不输出)。 | +| `formatRetentionNotice(notice, recovery)` | 将标准化的省略子句与工具自有的恢复指引连接起来。 | +| `Omitted` | `none` / `exact` / `unknown`:省略了多少内容。 | +| `PushDecision` | `{ kept, truncated }`:每次 push 的保留结果。 | + +## 资源模式 + +两个 retainer 使用独立名称,而不是同一个通用收集器,因为它们的**资源模型** 不同。 + +- **`ItemRetainer` 限制有序逻辑单元**。搜索工具可收集完整结果集用于 spill 文件恢复,同时只为面向模型的预览保留前 `maxItems` 项。因为调用方会继续送入每个已观察到的项,所以省略数量是精确的。 +- **`TextRetainer` 限制面向字节的文本**。`head`、`tail` 和 `headTail` 在 `finish()` 时保留 UTF-8 边界;`headTail` 是 `dsh-spill-policy` 用于围绕 spill 文件通知构建有界预览的形态。 + +## `truncated` 是预算事实,绝不表示「不完整」 + +`truncated` 表示*因为预算限制,retainer 省略了本可获得的内容*。它**不** 表示上游不完整。权限失败、跳过二进制文件、提供方部分失败、不可读候选项和无效 UTF-8 保留在工具领域字段中,绝不合并到 `truncated`。将两者混为一谈是该库命名最容易诱发的缺陷;务必保持分离。 + +## 字节,而非字符 + +文本上限和 `omittedBytes` 按**字节** 计数,以保证进程/正文安全(子进程 pipe 和 HTTP 正文都是字节流)。跨越码点的分片会被正确处理:`finish()` 会修剪每个切割位置的不完整码点,使返回文本绝不在边界引入替换字符;首尾两侧会分开解码,因此绝不会跨越被省略的中间部分重建码点。按字符或行限制的预览预算属于独立的工具职责。 + +## 工具映射 + +当前每个保留消费方都按下表映射到该库。广泛迁移不属于该库首次落地的范围;下表是预期形态。 + +| 工具 | Retainer 与策略 | 说明 | +|---|---|---| +| `glob` | `ItemRetainer<FsGlobEntry>`, `head` | 收集完整的已排序路径列表用于 spill 文件,同时在内联位置保留第一页。路径映射、已跳过候选项和 `incomplete` 保留在外部。 | +| `grep` | `ItemRetainer<FlatGrepMatch>`, `head` | 收集匹配项用于 spill 文件,同时在内联位置保留第一页。每个匹配项的预览截断、分组、排序和 `incomplete` 保留在外部。 | +| `bash` | `TextRetainer`, `tail` or `headTail` | 执行器仍负责 spill 文件、退出状态、信号、超时和后台任务。 | +| `web_fetch` | `TextRetainer`, `head` or `headTail` | 提供方/资源上限保留为提供方事实;retainer 只提供保留文本和省略元数据。 | +| `web_search` | `ItemRetainer<WebSearchSource>`, `head` | 当提供方返回的来源超过面向模型的结果应包含的数量时,标准化「来源已达上限」通知。 | + +`read` **刻意不在 v1 范围内**。其 `read-render` 辅助工具负责文件专用的分页契约:`offset`/`limit`、行号、`totalLines`、偏移越界错误、每行预览截断、针对已选窗口的字节上限。这是行窗口渲染器,而非通用保留机制。单个 `Omitted` 数量无法表示行窗口两侧。 + +## 使用形态 + +```ts ignore-check +// glob: keep the first page inline while still collecting the full list for spill. +const retainer = new ItemRetainer<FsGlobEntry>({ kind: 'head', maxItems: globMaxResults }) +const allEntries: FsGlobEntry[] = [] +for await (const entry of candidates) { + allEntries.push(entry) + retainer.push(entry) +} +const { items, truncated, omitted } = retainer.finish() + +// bash: keep a head + tail, read to process exit. +const out = new TextRetainer({ kind: 'headTail', headBytes: headCap, tailBytes: tailCap }) +child.stdout.on('data', (chunk: Buffer) => { out.push(chunk) }) +const { text, omittedBytes } = out.finish() + +// A footer: the library standardizes the omission clause; the tool owns recovery words. +const footer = formatRetentionNotice( + { scope: 'grep', strategy: 'head', unit: 'items', limit: grepMaxMatches, kept: items.length, omitted }, + ({ kept }) => `Results capped at ${kept}. Narrow the pattern, path, or include to see more.`, +) +``` + +## 模型体验 + +通过渲染保留内容和省略元数据的工具消费方间接影响模型。 + +#### KV 缓存影响 + +不直接导致失效;指定的消费方负责其引起的任何请求前缀变更。 + +## 已知限制与待完成工作 + +- **项保留只支持 `head`**:tail、head/tail、分页、分组和提供方完整性语义仍由工具负责。 +- **文本保留面向字节**:`read` 分页等行窗口和字符窗口需要单独的渲染器;切割可能会丢弃部分 UTF-8 边界字节,以保持返回文本有效。 diff --git a/packages/util/timeout/README.i18n.yaml b/packages/util/timeout/README.i18n.yaml new file mode 100644 index 0000000000..89bf05b258 --- /dev/null +++ b/packages/util/timeout/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 11c55a45a1255e14fb551e42ba3965453dbd94ae +README.zh.md: 699fb62a4fa19f97d234f83b21f639f61a9f0777 diff --git a/packages/util/timeout/README.md b/packages/util/timeout/README.md index cb6f0aa558..11c55a45a1 100644 --- a/packages/util/timeout/README.md +++ b/packages/util/timeout/README.md @@ -1,5 +1,7 @@ # dsh-timeout +English | [中文](README.zh.md) + The **timing-and-classification** half of a timeout — a zero-dependency library of pure functions (no runtime harness deps) shared by every capability that clamps a caller's timeout hint, arms a deadline, and later has to tell "timed out" apart from "cancelled". It owns **no termination**. The signal it hands out only *notifies*; actually stopping the work stays in each capability, because that mechanism differs — bash SIGKILLs an OS process group, web tears down a `fetch` socket — and no shared layer can own all of them. This is the boundary the [Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md) draws: share the timing/classification, keep the hard kill local. diff --git a/packages/util/timeout/README.zh.md b/packages/util/timeout/README.zh.md new file mode 100644 index 0000000000..699fb62a4f --- /dev/null +++ b/packages/util/timeout/README.zh.md @@ -0,0 +1,70 @@ +# dsh-timeout + +[English](README.md) | 中文 + +超时的**时序与分类** 部分:一个零依赖纯函数库(无运行时 harness 依赖),由每个需要限制调用方超时提示、启动 deadline,并在之后区分「已超时」与「已取消」的功能共享。 + +它**不负责终止**。它发出的信号只会*通知*;真正停止工作仍由各功能负责,因为机制各不相同:bash 对操作系统进程组发送 SIGKILL,web 拆除 `fetch` socket,没有任何共享层能够承担全部终止机制。[Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md) 将边界划定为:共享时序/分类,将强制终止保留在本地。 + +它是**库,而非服务或插件**:没有 `ctx`,不注册任何内容,不持有状态,也不发出事件。「超时服务」必须了解如何停止每项功能的工作,这正是微内核要排除在共享层之外的知识。 + +## 对外接口 + +```ts +import { clampTimeout, deadline, idleWatchdog, MAX_TIMER_DELAY_MS, timeoutOf, TimeoutReason } from '@deepseek-ai/dsh-timeout' +``` + +| 导出项 | 职责 | +|---|---| +| `clampTimeout(requested, def, max, name?)` | 验证调用方可选的正有限提示,从 `def` 填充,并限制在 `max` 以内。如果提示不为正数或有限数,则抛出错误(包含 `name`)。 | +| `deadline(upstream, timeoutMs, code)` | 将 `upstream` 取消与超时融合为一个 `AbortSignal`(`AbortSignal.any`);超时携带 `TimeoutReason`。`[Symbol.dispose]` 清除 timer。 | +| `idleWatchdog(upstream, timeoutMs, code)` | 保持一个稳定的融合信号,并且只在受保护的异步迭代器 `next()` 尚未完成时启动。解析后取消启动;后续需求重新启动;dispose 清除;并发需求被拒绝。 | +| `MAX_TIMER_DELAY_MS` | Node 在不将延迟限制为 1 毫秒时可调度的最大延迟(`2_147_483_647`)。拥有 timer 的配置不得超过该值。 | +| `timeoutOf(signal \| { reason }, code?)` | 从已中止的信号/错误中恢复 `TimeoutReason`,否则返回 `undefined`,即超时与取消的分类器。传入 `code` 可仅匹配这个 deadline 的 timer(见下文的嵌套)。 | +| `TimeoutReason` | 印在超时中止上的内部原因(`code` + `timeoutMs`)。它不是公开错误;提供方将其转换为自己的错误/字段。 | + +## `timeoutMs <= 0` 哨兵值 + +`0` 是后端自有后台工作(bash `start()`)使用的「无超时」值,其可见范围为:**内部**。`deadline()` 不启动 timer,只转发 `upstream`;如果也没有 upstream,它将返回永不中止的信号和无操作 disposer,因此每个调用方都能保持同一种调用形态。外部请求提示会通过 `clampTimeout` 验证为**正有限数**,之后才进入 `deadline`,因此 `0` 绝不是面向模型/插件的「禁用超时」值。 + +## 使用形态 + +```ts +import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' + +declare function runWork(options: { signal: AbortSignal }): Promise<unknown> + +// Scope-lifetime consumer (foreground bash, one fetch): `using` disposes the timer. +export async function runWithDeadline(upstream: AbortSignal | undefined, timeoutMs: number): Promise<unknown> { + using d = deadline(upstream, timeoutMs, 'BASH_TIMEOUT') + const outcome = await runWork({ signal: d.signal }) // work listens on d.signal and terminates itself + const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined // classify the first abort, scoped to OUR code + const aborted = d.signal.aborted && !timedOut // mutually exclusive: timeout won, or cancel did + return { outcome, timedOut, aborted } +} +``` + +该信号只会*通知*;调用方必须连接自己的终止机制(`d.signal.addEventListener('abort', kill)`,或将 `d.signal` 传给 `fetch`)。让 promise 与 timer 竞速,会在子进程或 socket 泄漏的情况下就解析工具调用;发出信号则会强制要求存在真正的终止路径。 + +将你自己的 `code` 传给 `timeoutOf`,以便分类可在嵌套中组合:当你收到的 `upstream` *本身*就是 deadline 信号时(未来启动每次调用 deadline 的 `tools/execute` 中间件),如果外层 timer 首先触发,`AbortSignal.any` 会保留外层 `TimeoutReason`。将范围限定为你的 `code`,可将外部超时视为普通 upstream 取消,这才是你所属功能视角下的正确分类,而不会在本地 timer 尚未到期时就声称自己超时。 + +对于流式传输,创建一个 `idleWatchdog`,将其稳定的 `signal` 传入传输,并为每次提供方读取调用 `watchdog.next(iterator)`。间隔必须为正有限数,且不得超过 `MAX_TIMER_DELAY_MS`;否则 Node 会将其限制为 1 毫秒。它只测量尚未完成的需求,因此当下游代码进行渲染或在请求下一个分片前以其他方式等待时,timer 不会运行。该原语仍然只会通知,因此传输必须观察稳定信号;DeepSeek 和 pi-ai 适配器证明,超时会关闭它们的真实响应正文或 SDK 请求。 + +## 哪些操作不设置超时 + +本地文件 `read`/`write`/`edit` 不接受 `timeoutMs`:系统调用最多只能尽力中止,超时无法强制 `fsync`/`rename` 停止,而添加超时将成为违反显式优于隐式的默认值。详见 [`fs/`](../../fs/README.md)。 + +## 模型体验 + +通过 `dsh-timeout-policy` 等消费方间接影响模型;消费方可能会将提供方结果替换为已保留的超时错误,或抑制延迟结果。 + +#### KV 缓存影响 + +不直接导致失效;指定的消费方负责其引起的任何请求前缀变更。 + +## 已知限制与待完成工作 + +- **只发出通知**:deadline 无法停止忽略其信号的工作;每项功能仍需要自己的 socket/进程/任务终止路径。 +- **`timeoutMs <= 0` 是内部词汇**:只有在所属后端已解析策略后,它才会禁用本地 timer;绝不会作为面向模型/插件的公开开关。 +- **第一个中止原因决定分类**:当 upstream 取消早于本地 timer 发生时,即使自己的超时之后也会到期,该层也无法再报告。 +- **空闲 watchdog 不是总 deadline**:它针对每个尚未完成的迭代器需求重新启动,并刻意排除消费方的思考时间。 diff --git a/packages/web/README.i18n.yaml b/packages/web/README.i18n.yaml new file mode 100644 index 0000000000..8d554a4573 --- /dev/null +++ b/packages/web/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 8cd173b922ead32219ffab2b2d6b6428b3ce375c +README.zh.md: b10849bbddf6d62701d72b064a58c728ffb1e1b7 diff --git a/packages/web/README.md b/packages/web/README.md index b465925c79..8cd173b922 100644 --- a/packages/web/README.md +++ b/packages/web/README.md @@ -1,5 +1,7 @@ # web/ - web capability family +English | [中文](README.zh.md) + The web access capability seam: an abstract web interface, search/fetch provider implementations, and the model-facing web tools. All **product** packages. | Package | Role | ctx key | diff --git a/packages/web/README.zh.md b/packages/web/README.zh.md new file mode 100644 index 0000000000..b10849bbdd --- /dev/null +++ b/packages/web/README.zh.md @@ -0,0 +1,18 @@ +# web/ - web 能力家族 + +[English](README.md) | 中文 + +web 访问能力 seam:抽象 web 接口、搜索/抓取提供方实现,以及面向模型的 web 工具。这些全是**产品** 包。 + +| 包 | 职责 | ctx key | +|---|---|---| +| `web/` | 抽象 web seam(搜索/抓取提供方注册表 + 选择 + 词汇 + `WebError`) | `ctx.web` | +| `web-search-exa/` | Exa 支持的 `WebSearchProvider` | (注册到 `ctx.web`) | +| `web-search-perplexity/` | Perplexity 支持的 `WebSearchProvider` | (注册到 `ctx.web`) | +| `web-search-deepseek/` | DeepSeek 支持的 `WebSearchProvider`,通过 Anthropic 兼容 API 使用原生 `web_search` | (注册到 `ctx.web`) | +| `web-fetch-local/` | 匿名公共 HTTP(S) `WebFetchProvider` | (注册到 `ctx.web`) | +| `tool-web/` | 面向模型的 `web_search`/`web_fetch` 工具 schema | (注册到 `ctx.tools`) | + +接口位于 `web/web/`。与 bash/fs 不同,该 seam 跨越**两种能力**(搜索和抓取),每种能力都可能有多个提供方:`ctx.web` 是单一的 web 访问中间层,拥有一项提供方选择策略、一套中止/错误词汇,以及一个面向产品的「该 harness 如何访问 web」配置表层。提供方注册的是**能力** 而非工具;`tool-web` 是面向模型名称、schema、提示词指引和呈现的唯一 owner。替换搜索提供方不会改变模型提出查询的方式,替换抓取实现也不会改变模型请求 URL 的方式。 + +设计原理见 [web 能力 seam Agent Note](../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md),其中也解释了搜索与抓取为何有意合并为一个 seam,以及为何暂缓实现 `web_fetch` 的 SSRF 防护。 diff --git a/packages/web/tool-web/README.i18n.yaml b/packages/web/tool-web/README.i18n.yaml new file mode 100644 index 0000000000..eb3fa4731d --- /dev/null +++ b/packages/web/tool-web/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 5e567115c386d14b7e412ed2502e7290826a5e5e +README.zh.md: b17fe4107908381806d4029481bbf03696c4f313 diff --git a/packages/web/tool-web/README.md b/packages/web/tool-web/README.md index 27147104e9..5e567115c3 100644 --- a/packages/web/tool-web/README.md +++ b/packages/web/tool-web/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-tool-web +English | [中文](README.zh.md) + The model-facing web tool suite — `web_search` and `web_fetch` — over the [web capability seam](../web/README.md) (`ctx.web`). It owns model-facing concerns only: tool names, JSON schemas, snake_case argument names, prompt sections, the result-count bound, result formatting, HTML→markdown presentation, and `presentCall`. All web access goes through `ctx.web`; this package never imports a concrete provider. Neither tool exposes a model-facing timeout — each tool's cooperative tool-call budget is declared here via config (`fetchTimeoutMs`/`searchTimeoutMs`, attached as `ToolDefinition.timeoutMs`) and enforced by [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md) (a `tools/execute` wrapper); each tool just forwards `exec.signal` to the seam. Each tool is registered independently; a product that wants only one disables the other via config (`{ search: false }` / `{ fetch: false }`). diff --git a/packages/web/tool-web/README.zh.md b/packages/web/tool-web/README.zh.md new file mode 100644 index 0000000000..b17fe41079 --- /dev/null +++ b/packages/web/tool-web/README.zh.md @@ -0,0 +1,131 @@ +# @deepseek-ai/dsh-tool-web + +[English](README.md) | 中文 + +面向模型的 web 工具套件 `web_search` 与 `web_fetch`,构建于 [web 能力 seam](../web/README.md)(`ctx.web`)之上。它只拥有面向模型的事项:工具名称、JSON schema、snake_case 参数名称、提示词区段、结果数量上限、结果格式、HTML→markdown 呈现,以及 `presentCall`。所有 web 访问都通过 `ctx.web`;该包绝不导入具体提供方。两个工具都不公开面向模型的超时:每个工具的协作式工具调用预算通过配置在此声明(`fetchTimeoutMs`/`searchTimeoutMs`,附加为 `ToolDefinition.timeoutMs`),由 [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md)(`tools/execute` 包装层)强制执行;每个工具只把 `exec.signal` 转发给 seam。 + +每个工具独立注册;只需要其中一个工具的产品可以通过配置禁用另一个(`{ search: false }`/`{ fetch: false }`)。 + +## 工具 + +| 工具 | 参数 | 行为 | +|---|---|---| +| `web_search` | `query`(string) | 发现。返回可选答案与源 URL。`max_results` **不** 面向模型:工具设置上限(`searchMaxResults` 配置,默认 8)并传给 seam。 | +| `web_fetch` | `url`(string) | 获取特定 URL。HTML 主体渲染为近似 markdown 的文本;文本主体原样通过。非 2xx 状态会报告,而非报错。工具调用超时是部署策略(`dsh-timeout-policy`),不是模型参数。 | + +两个工具都选择并发调度,因为提供方读取会返回内容,不会修改父 agent 状态。 + +规范化 seam 结果也是规范工具值:`WebSearchResult` 与 `WebFetchResult`。原生 renderer 保留下述答案/源与抓取主体文本;提供方搜索/主体上限仍是获取限制,而非仅呈现截断。 + +## 配置 + +| Key | 默认值 | 含义 | +|---|---|---| +| `search` | `true` | 注册 `web_search`。 | +| `fetch` | `true` | 注册 `web_fetch`。 | +| `searchMaxResults` | `8` | 一次 `web_search` 调用返回的源数量上限(seam 截断更长的提供方列表并标记)。 | +| `fetchTimeoutMs` | `30000` | `web_fetch` 的协作式工具调用超时预算(ms)。 | +| `searchTimeoutMs` | `30000` | `web_search` 的协作式工具调用超时预算(ms)。 | + +`fetchTimeoutMs`/`searchTimeoutMs` 声明每个工具的协作式超时预算(附加为 `ToolDefinition.timeoutMs`),由 [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md) 强制执行;面向模型的 schema 不公开超时参数。 + +```yaml +- id: tool-web + name: '@deepseek-ai/dsh-tool-web' +``` + +## 稳定注册 + +工具注册遵循产品**启用状态**,而非后端可用性。即使选中的提供方缺失、错误配置、存在歧义或暂时不可用,工具仍保持可见;seam 在执行时解析提供方,执行以结构化 `WebError`(例如 `WEB_PROVIDER_UNAVAILABLE`、`WEB_PROVIDER_AMBIGUOUS`)失败,`ToolRegistry.execute()` 会把它转为模型可读、hook/UI 可路由的错误工具结果。这样无需把插件加载顺序、credential 状态或 HMR 时机纳入面向模型契约,也能保持模型 schema 稳定。要彻底移除 web 工具,请在此处通过配置将其禁用。 + +工具绝不会调用提供方的 `available()`,也不会枚举提供方;唯一执行路径是 `ctx.web.search()`/`ctx.web.fetch()`,提供方不可用会作为选择机制在执行时抛出的结构化 `WebError` code 到达工具。提供方选择完全留在 seam 内,只有一个 owner。 + +## 模型体验 + +### 系统提示词 + +#### 模型看到的内容 + +搜索与抓取分别贡献以下 web-search 和 web-fetch 指引。scope 工具限制不会移除这些独立注册的区段。 + +##### Web 搜索指引 + +```markdown +Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Follow up with web_fetch when you need the full content of a specific result, and cite the relevant URLs as markdown links. +``` + +##### Web 抓取指引 + +```markdown +Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL (for example a result from web_search). It returns the page content decoded to text. Cite the URL as a markdown link when you use its content. +``` + +#### Token 影响 + +每个通过配置启用的工具会为每次请求增加固定指引成本,即使限制隐藏了其 schema。 + +#### KV Cache 影响 + +只要启用工具、scope 与指引文本不变,前缀就保持稳定。配置启用状态或插件生命周期可能使从第一个变化的提示词区段起的复用失效;scope schema 限制不会移除该区段。 + +### 工具 schema + +#### 模型看到的内容 + +模型会看到生成的 [`web_search` 与 `web_fetch` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-web)。结果数量与超时预算属于部署设置,不是模型参数。 + +#### Token 影响 + +每次请求承担固定 schema 成本;通过配置禁用会同时移除 schema 与指引,scope 限制只移除 schema。 + +#### KV Cache 影响 + +只要定义与可见性不变,前缀就保持稳定。配置启用状态、插件生命周期或 scope 限制可能使从第一个变化的 schema token 起的复用失效。 + +### 搜索结果 + +#### 模型看到的内容 + +可选的提供方答案之后是 `Sources:`,再跟随数据相关、形状精确为 `- [<title-or-url>](<url>)` 的行,并可添加后缀 ` — <snippet> (<publishedAt>)`。既无答案也无源时,结果显示 `No results found.`。列表达到上限时会添加 `(Showing the first <count> sources. Refine the query for more.)`;每项结果都以 `Cite the relevant URLs above as markdown links in your answer.` 结尾。 + +#### Token 影响 + +数据相关结果会重复发送直到压缩,源数量由 `searchMaxResults` 限制。 + +#### KV Cache 影响 + +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 配置项失效。 + +### 抓取结果 + +#### 模型看到的内容 + +成功抓取的精确形状是 `Fetched <finalUrl> (HTTP <statusCode>)`、一个空行,以及提供方拥有的解码主体。发生截断时会再添加一个空行和 `(Content truncated. Fetch a more specific URL or section for the full text.)`;失败变为 `Error: <message>`。查询与 URL 保留在调用历史中。 + +#### Token 影响 + +提供方上限限制主体大小;保留的调用参数与结果会重复发送直到压缩,超时策略可以把迟到结果替换为简短错误。 + +#### KV Cache 影响 + +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 配置项失效。 + +### 参数错误 + +#### 模型看到的内容 + +空输入精确地变为 `Error: query must be a non-empty string` 或 `Error: url must be a non-empty string`。 + +#### Token 影响 + +只有失败调用会增加这些保留 token。 + +#### KV Cache 影响 + +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 配置项失效。 + +## 已知限制与暂缓事项 + +- **`htmlToMarkdown` 是最小正则转换器,不是 HTML parser**:它会移除 script/style/noscript,保留标题/项目符号/链接,并解码约十余个命名 entity;表格、图片与嵌套格式会丢失。 +- **面向模型的表层有意保持最小,提升项暂缓**:`max_results` 保持为配置上限(不是模型参数),`web_fetch` 只接受 `url`(没有 `format`/`prompt`/LLM 摘要模式);两项都列为 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md) 中的后续步骤。 +- **没有 web 专用权限策略**:两个工具都不会请求 `ctx.approval` 就直接执行;需要确认的部署必须添加 `tools/pre-execute` 策略,该包不定义持久 URL/domain 授权。 diff --git a/packages/web/web-fetch-local/README.i18n.yaml b/packages/web/web-fetch-local/README.i18n.yaml new file mode 100644 index 0000000000..7da50f07d8 --- /dev/null +++ b/packages/web/web-fetch-local/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 8cadba2de7a2708252ebc7143840825fd4fe4549 +README.zh.md: bd6257b41736153a608df93c5baa74eac38d50e3 diff --git a/packages/web/web-fetch-local/README.md b/packages/web/web-fetch-local/README.md index 160d1c6abd..8cadba2de7 100644 --- a/packages/web/web-fetch-local/README.md +++ b/packages/web/web-fetch-local/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-web-fetch-local +English | [中文](README.zh.md) + An anonymous public HTTP(S) `WebFetchProvider` for the harness [web capability seam](../web/README.md) (`ctx.web`). It retrieves a concrete URL and returns a status code plus bounded decoded content. This is an **implementation** package: it registers a provider into `ctx.web`, it does not own the key and it does not register a model-facing tool. It is a function/namespace plugin (`inject: ['web']`). diff --git a/packages/web/web-fetch-local/README.zh.md b/packages/web/web-fetch-local/README.zh.md new file mode 100644 index 0000000000..bd6257b417 --- /dev/null +++ b/packages/web/web-fetch-local/README.zh.md @@ -0,0 +1,51 @@ +# @deepseek-ai/dsh-web-fetch-local + +[English](README.md) | 中文 + +一个匿名公共 HTTP(S) `WebFetchProvider`,用于 harness [web 能力 seam](../web/README.md)(`ctx.web`)。它获取具体 URL,返回状态码与有界解码内容。 + +这是一个**实现** 包:它向 `ctx.web` 注册提供方,不拥有该 key,也不注册面向模型的工具。它是函数/namespace 插件(`inject: ['web']`)。 + +## 职责拆分 + +提供方拥有**安全资源获取**:URL 验证、HTTP 传输、重定向策略、资源兜底超时、中止传播、字节上限、charset 解码、内容类型分类与二进制拒绝。`@deepseek-ai/dsh-tool-web` 拥有**呈现**(HTML→markdown、截断格式)。非 2xx HTTP 响应是*结果*(状态码 + 解码主体),不是错误;`WebError` 只用于无法安全获取或表示资源的失败。 + +提供方的 `timeoutMs` 是直接 `ctx.web.fetch()` 调用方与错误配置部署的资源兜底,不是面向模型的工具调用预算。[`dsh-timeout-policy`](../../timeout/timeout-policy/README.md) 拥有 `web_fetch` 工具调用预算,并通过武装 `exec.signal` 强制执行该预算。 + +已交付的 web 工具部署会把提供方兜底设为高于工具预算,因此模型调用通常返回 `TOOL_TIMEOUT`。如果外层 deadline 先到达提供方,提供方报告 `WEB_ABORTED`,外层策略再将其替换为 `TOOL_TIMEOUT`。因此,`WEB_FETCH_TIMEOUT` 标识提供方预算已经耗尽的直接 seam 调用方。 + +## 传输卫生 + +- 只接受 `http:` 和 `https:` URL;拒绝 URL 中的 credential(`WEB_BLOCKED_URL`)以及过长/格式错误的 URL(`WEB_INVALID_URL`)。 +- 强制执行 URL 最大长度、响应字节上限(`WEB_FETCH_TOO_LARGE`)、解码主体字符上限、超时(`WEB_FETCH_TIMEOUT`)和重定向跳数上限。 +- 把调用方的中止信号(`WEB_ABORTED`)传播到网络请求与流式读取。 +- 只跟随**同源** 重定向;跨源重定向以 `WEB_REDIRECT_BLOCKED` 失败,要求发起新的工具调用(沿用 Claude Code 的 WebFetch 模型)。 +- 发送显式的产品 `User-Agent`,绝不伪装成浏览器。 +- 不受支持的内容类型(例如二进制)以 `WEB_UNSUPPORTED_CONTENT_TYPE` 拒绝。 + +## 配置 + +| Key | 默认值 | 含义 | +|---|---|---| +| `maxUrlLength` | `2048` | 接受的请求 URL 最大长度。 | +| `maxResponseBytes` | `5_000_000` | 响应主体最大字节数。 | +| `maxBodyChars` | `100_000` | 解码主体最大字符数。 | +| `timeoutMs` | `30_000` | Node 定时器范围内的抓取超时:直接 `ctx.web.fetch()` 调用方的资源兜底,而非面向模型的工具调用预算(后者属于 `dsh-timeout-policy`)。 | +| `maxRedirects` | `5` | 同源重定向最大跳数(`0` 表示完全不跟随)。 | +| `userAgent` | `deepseek-harness/…` | `User-Agent` 标头。 | + +数值限制会在插件构造时验证:除 `maxRedirects` 外,每个上限都必须是正的有限数;`maxRedirects` 必须是非负整数。无效值会抛出异常,不会静默构造限制荒谬的提供方。 + +## 模型体验 + +通过 [`dsh-tool-web`](../tool-web/README.md) 间接影响;该工具把此提供方经 `maxBodyChars` 限制的解码文本或 markdown 形状 HTML 置于抓取结果包装中,并保留提供方失败;重定向、标头与传输机制保持隐藏。 + +#### KV Cache 影响 + +不会直接失效;请求前缀变更由命名消费方负责。 + +## 已知限制与暂缓事项 + +- **SSRF/私有网络防护暂缓**:不会阻止私有、loopback、link-local、multicast 或其他非公开目标,也不进行 DNS 解析后验证或逐跳重新验证(见 [web 能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md))。在此功能落地前,该提供方是 SSRF 原语;能够访问敏感内部网络目标的部署**禁止启用它**。 +- **只解码文本内容**:包括 html/xhtml 与 `text/*` 加 JSON/XML 家族;缺少 `Content-Type` 或任何二进制类型都会抛出 `WEB_UNSUPPORTED_CONTENT_TYPE`,可提取文本的 PDF 解码属于明确的暂缓工作。 +- **charset 只来自 `Content-Type` 标头**(默认为 UTF-8):HTML `<meta charset>` 声明会被忽略;声明但无法识别的 charset label 会抛出异常,而非回退。 diff --git a/packages/web/web-search-deepseek/README.i18n.yaml b/packages/web/web-search-deepseek/README.i18n.yaml new file mode 100644 index 0000000000..a65feb5185 --- /dev/null +++ b/packages/web/web-search-deepseek/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 54eb7561b9d81a9e2da565e3870abe094dbe984d +README.zh.md: 5ce5f46882efb94c7d12fd137b77b167a0088741 diff --git a/packages/web/web-search-deepseek/README.md b/packages/web/web-search-deepseek/README.md index e71a769725..54eb7561b9 100644 --- a/packages/web/web-search-deepseek/README.md +++ b/packages/web/web-search-deepseek/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-web-search-deepseek +English | [中文](README.zh.md) + A [DeepSeek](https://deepseek.com)-backed `WebSearchProvider` for the harness [web capability seam](../web/README.md) (`ctx.web`). It calls DeepSeek's **Anthropic-compatible Messages API** (`POST {baseURL}/messages`) with the native `web_search_20250305` server tool enabled, and maps the structured `web_search_tool_result` blocks DeepSeek returns into the seam's normalized `WebSearchResult`. This is an **implementation** package: it registers a provider into `ctx.web`, it does not own the key and it does not register a model-facing tool. Like `@deepseek-ai/dsh-llm-deepseek`, it is a function/namespace plugin (`inject: ['web']`). The Anthropic wire shape is a provider-private detail — it does **not** make this provider depend on `ctx.llm`. diff --git a/packages/web/web-search-deepseek/README.zh.md b/packages/web/web-search-deepseek/README.zh.md new file mode 100644 index 0000000000..5ce5f46882 --- /dev/null +++ b/packages/web/web-search-deepseek/README.zh.md @@ -0,0 +1,79 @@ +# @deepseek-ai/dsh-web-search-deepseek + +[English](README.md) | 中文 + +由 [DeepSeek](https://deepseek.com) 支持的 `WebSearchProvider`,用于 harness [web 能力 seam](../web/README.md)(`ctx.web`)。它调用 DeepSeek 的 **Anthropic 兼容 Messages API**(`POST {baseURL}/messages`),启用原生 `web_search_20250305` 服务器工具,并把 DeepSeek 返回的结构化 `web_search_tool_result` 块映射为 seam 规范化的 `WebSearchResult`。 + +这是一个**实现** 包:它向 `ctx.web` 注册提供方,不拥有该 key,也不注册面向模型的工具。与 `@deepseek-ai/dsh-llm-deepseek` 一样,它是函数/namespace 插件(`inject: ['web']`)。Anthropic 协议形状是提供方私有细节,并**不** 使该提供方依赖 `ctx.llm`。 + +## 与专用搜索端点的区别 + +Exa 和 Perplexity 提供专用搜索端点,DeepSeek 则没有。该提供方改为发起一次携带 `web_search` 服务器工具的**完整 Messages 模型调用**,因此一次搜索会消耗完整模型轮次的延迟与 token,比纯检索端点更重。DeepSeek 在服务器侧执行搜索,返回**结构化** `web_search_tool_result` 块;提供方解析这些块,**绝不会从模型文本中抓取 URL**。 + +**严格模式**:如果响应不含 `web_search_tool_result` 块(未触发原生搜索),提供方会抛出 `WebError` `WEB_PROVIDER_ERROR`,而非降级为文本抓取;这种行为诚实且可诊断。 + +它复用 `$DEEPSEEK_API_KEY`(不增加 secret),但**不会** 复用 `$DEEPSEEK_BASE_URL`:搜索端点使用 Anthropic 兼容基址(`https://api.deepseek.com/anthropic/v1`),不同于 LLM 适配器使用的 chat-completions 基址(`https://api.deepseek.com`)。 + +## 配置 + +| Key | 默认值 | 含义 | +|---|---|---| +| `apiKey` | `$DEEPSEEK_API_KEY` | DeepSeek API key。为空/缺失时提供方不可用。同时作为 `x-api-key` 与 `Authorization: Bearer` 发送(官方与 Anthropic 兼容 proxy)。 | +| `baseURL` | `https://api.deepseek.com/anthropic/v1` | Anthropic 兼容端点基址;追加 `/messages`。覆盖时使用 `$DEEPSEEK_SEARCH_BASE_URL` 等独立环境变量;禁止复用属于 chat-completions LLM 适配器的 `$DEEPSEEK_BASE_URL`。无法解析时提供方不可用。 | +| `model` | `deepseek-v4-flash` | Anthropic 格式模型名称。 | +| `apiVersion` | `2023-06-01` | `anthropic-version` 标头值。 | +| `maxTokens` | `4096` | Messages 请求生成 token 的正整数上限。 | +| `maxUses` | `5` | 每次请求使用 `web_search` 服务器工具的正整数上限。 | + +```yaml +- id: web-search-deepseek + name: '@deepseek-ai/dsh-web-search-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL +``` + +## 映射 + +DeepSeek 不返回该提供方可作为 `content` 信任的提供方生成答案表层,因此省略 `content`。`sources[]` 来自 `web_search_result` 配置项,这些配置项位于 `web_search_tool_result` 块内:`url` ← `url`、`title` ← `title`、`publishedAt` ← `page_age`。`cited_text` 配置项按 URL 标识,单独位于文本块的 `citations[]` 中;提供方会将其作为 snippet 连接,没有摘录时省略 `snippet`。 + +结果按 URL 去重,因为一次请求可能在多次搜索中呈现同一页面。DeepSeek 公开 `maxUses` 而非结果数量旋钮,因此 seam 会强制执行 `maxResults`:截断 `sources[]` 并设置 `truncated`。 + +提供方失败变为 `WEB_PROVIDER_ERROR`;调用方取消变为 `WEB_ABORTED`。HTTP 重定向会在接触 `Location` 目标前被拒绝,并以 `WEB_PROVIDER_ERROR` 呈现。 + +## 模型体验 + +### 辅助 DeepSeek 搜索请求 + +#### 模型看到的内容 + +独立的 DeepSeek 模型会接收精确的 `Perform a web search for the query: <query>` 作为 user 文本,并收到一个原生 `web_search` 服务器工具定义。该请求不属于会话模型上下文。 + +#### Token 影响 + +每次搜索都会产生独立的提供方输入与输出 token;`maxTokens` 限制生成输出,`maxUses` 限制原生搜索次数。 + +#### KV Cache 影响 + +与会话请求 cache 相互独立。辅助指令与原生工具定义可以形成稳定前缀,但查询或模型路由的每次变化都会阻止从首个差异起的复用。 + +### 间接的会话工具结果 + +#### 模型看到的内容 + +通过 [`dsh-tool-web`](../tool-web/README.md),会话模型会看到结构化搜索块中去重后的 URL、标题、日期与引用 snippet;提供方文本不会作为答案受到信任。该提供方的精确失败是 `DeepSeek search aborted`、`DeepSeek search request failed: <error>`、`DeepSeek returned no web_search_tool_result blocks; the request may not have triggered native web search` 和 `DeepSeek returned an unprocessable response body: <error>`;HTTP 失败保留提供方消息。错误包装属于消费方。 + +#### Token 影响 + +注册不会直接产生会话 token。结果 token 随返回源与 snippet 增长,随后 seam 会强制执行请求的源数量上限。 + +#### KV Cache 影响 + +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 配置项失效。 + +## 已知限制与暂缓事项 + +- **一次搜索需要完整的 Messages 模型轮次**:会产生延迟与生成 token,并且最多执行 `maxUses` 次服务器侧搜索;DeepSeek 不公开专用检索端点。 +- **超量返回的源仍消耗 token**:协议没有结果数量旋钮,`maxResults` 只能由 seam 在事后截断。 +- **未引用的结果没有 `snippet`**:只有 `text` 块中的引用(`cited_text`)匹配其 URL 时,源才会获得 snippet。 +- **按错误形状分类中止**:只有 `DOMException` 且名为 `AbortError` 时才映射为 `WEB_ABORTED`;携带自定义原因的中止(例如 `dsh-timeout` 的 `TimeoutReason`)会呈现为 `WEB_PROVIDER_ERROR`。 diff --git a/packages/web/web-search-exa/README.i18n.yaml b/packages/web/web-search-exa/README.i18n.yaml new file mode 100644 index 0000000000..b8ffd244b1 --- /dev/null +++ b/packages/web/web-search-exa/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: c24f952eede90aae6fa23eec976255cb99625c19 +README.zh.md: 39b6fb9ffc0ce9ba3291c17aa88781e90ed18e08 diff --git a/packages/web/web-search-exa/README.md b/packages/web/web-search-exa/README.md index d8ab206e7a..c24f952eed 100644 --- a/packages/web/web-search-exa/README.md +++ b/packages/web/web-search-exa/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-web-search-exa +English | [中文](README.zh.md) + An [Exa](https://exa.ai)-backed `WebSearchProvider` for the harness [web capability seam](../web/README.md) (`ctx.web`). It calls Exa's `POST /search` endpoint with highlight contents and maps the flat `results[]` into the seam's normalized `WebSearchResult`. This is an **implementation** package: it registers a provider into `ctx.web`, it does not own the `ctx.web` key and it does not register a model-facing tool (that is `@deepseek-ai/dsh-tool-web`). Like `@deepseek-ai/dsh-llm-deepseek`, it is a function/namespace plugin (`inject: ['web']`) that registers its backend, not a default-export service. diff --git a/packages/web/web-search-exa/README.zh.md b/packages/web/web-search-exa/README.zh.md new file mode 100644 index 0000000000..39b6fb9ffc --- /dev/null +++ b/packages/web/web-search-exa/README.zh.md @@ -0,0 +1,42 @@ +# @deepseek-ai/dsh-web-search-exa + +[English](README.md) | 中文 + +由 [Exa](https://exa.ai) 支持的 `WebSearchProvider`,用于 harness [web 能力 seam](../web/README.md)(`ctx.web`)。它调用 Exa 的 `POST /search` 端点并请求 highlight 内容,把扁平 `results[]` 映射为 seam 规范化的 `WebSearchResult`。 + +这是一个**实现** 包:它向 `ctx.web` 注册提供方,不拥有 `ctx.web` key,也不注册面向模型的工具(后者属于 `@deepseek-ai/dsh-tool-web`)。与 `@deepseek-ai/dsh-llm-deepseek` 一样,它是函数/namespace 插件(`inject: ['web']`),负责注册后端,而非默认导出服务。 + +## 配置 + +| Key | 默认值 | 含义 | +|---|---|---| +| `apiKey` | `$EXA_API_KEY` | Exa API key。为空/缺失时提供方不可用。 | +| `baseURL` | `https://api.exa.ai` | 端点基址;追加 `/search`。无法解析时提供方不可用。 | +| `searchType` | `auto` | 以 Exa `type` 发送的检索模式:`auto`(由 Exa 决定)、`keyword` 或 `neural`。 | +| `numResults` | (未设置) | 请求不含 `maxResults` 时使用的默认结果数。未设置时不发送默认值。必须是正整数。 | +| `highlightsPerResult` | `1` | 每个结果请求的 highlight 句子数(Exa `highlightsPerUrl`)。必须是正整数。 | + +```yaml +- id: web-search-exa + name: '@deepseek-ai/dsh-web-search-exa' + config: + apiKey: !!js process.env.EXA_API_KEY +``` + +## 映射 + +Exa 返回扁平 `results[]`,不返回生成答案,因此省略 `content`。每项结果映射为 `WebSearchSource`:`url` ← `url`、`title` ← `title`、`snippet` ← 第一个非空 `highlights[]` 配置项(没有 highlight 的结果缺少可移植 snippet,会被丢弃)、`publishedAt` ← `publishedDate`。请求的 `maxResults` 优先于已配置的默认 `numResults`,并作为 Exa `numResults` 发送,以优化成本/延迟;最终边界由 seam 强制执行。提供方失败(HTTP 错误、网络失败、无法解析或形状错误的主体)以 `WebError` `WEB_PROVIDER_ERROR` 呈现;中止请求以 `WEB_ABORTED` 呈现。HTTP 重定向会在接触 `Location` 目标前被拒绝,并以 `WEB_PROVIDER_ERROR` 呈现。 + +## 模型体验 + +通过 [`dsh-tool-web`](../tool-web/README.md) 间接影响;该工具保留此提供方经 `maxResults` 限制的 URL、标题、首条 highlight 与发布日期,或将精确的 `Exa search aborted`、`Exa search request failed: <error>` 和 `Exa returned an unprocessable response body: <error>` 失败置于消费方错误包装内;生成答案与提供方私有字段不进入上下文。 + +#### KV Cache 影响 + +不会直接失效;请求前缀变更由命名消费方负责。 + +## 已知限制与暂缓事项 + +- **没有非空 highlight 的结果会被整个丢弃**:没有可映射的可移植 snippet,因此返回源可能少于请求数量。 +- **只公开 `searchType`/`numResults`/`highlightsPerResult`**:Exa 的其他控制项(livecrawl、category、domain/date filter、全文内容)等待提供方无关 seam 字段(见 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md))。 +- **按错误形状分类中止**:只有 `DOMException` 且名为 `AbortError` 时才映射为 `WEB_ABORTED`;携带自定义原因的中止(例如 `dsh-timeout` 的 `TimeoutReason`)会呈现为 `WEB_PROVIDER_ERROR`。 diff --git a/packages/web/web-search-perplexity/README.i18n.yaml b/packages/web/web-search-perplexity/README.i18n.yaml new file mode 100644 index 0000000000..f69bb5d31e --- /dev/null +++ b/packages/web/web-search-perplexity/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 80f6d34d63ddf0cc7d1f0d4f6744d64c91f9a269 +README.zh.md: 2e631c11f99d9b24ba44fe1fe5f5238916ecc175 diff --git a/packages/web/web-search-perplexity/README.md b/packages/web/web-search-perplexity/README.md index a3728e0197..80f6d34d63 100644 --- a/packages/web/web-search-perplexity/README.md +++ b/packages/web/web-search-perplexity/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-web-search-perplexity +English | [中文](README.zh.md) + A [Perplexity](https://perplexity.ai)-backed `WebSearchProvider` for the harness [web capability seam](../web/README.md) (`ctx.web`). It calls Perplexity's OpenAI-compatible `POST /chat/completions` endpoint and maps the generated answer plus citations into the seam's normalized `WebSearchResult`. This is an **implementation** package: it registers a provider into `ctx.web`, it does not own the key and it does not register a model-facing tool. Like `@deepseek-ai/dsh-llm-deepseek`, it is a function/namespace plugin (`inject: ['web']`). The OpenAI-compatible wire shape is a provider-private detail — it does **not** make this provider depend on `ctx.llm`. diff --git a/packages/web/web-search-perplexity/README.zh.md b/packages/web/web-search-perplexity/README.zh.md new file mode 100644 index 0000000000..2e631c11f9 --- /dev/null +++ b/packages/web/web-search-perplexity/README.zh.md @@ -0,0 +1,65 @@ +# @deepseek-ai/dsh-web-search-perplexity + +[English](README.md) | 中文 + +由 [Perplexity](https://perplexity.ai) 支持的 `WebSearchProvider`,用于 harness [web 能力 seam](../web/README.md)(`ctx.web`)。它调用 Perplexity 的 OpenAI 兼容 `POST /chat/completions` 端点,把生成答案与引用映射为 seam 规范化的 `WebSearchResult`。 + +这是一个**实现** 包:它向 `ctx.web` 注册提供方,不拥有该 key,也不注册面向模型的工具。与 `@deepseek-ai/dsh-llm-deepseek` 一样,它是函数/namespace 插件(`inject: ['web']`)。OpenAI 兼容协议形状是提供方私有细节,并**不** 使该提供方依赖 `ctx.llm`。 + +## 配置 + +| Key | 默认值 | 含义 | +|---|---|---| +| `apiKey` | `$PERPLEXITY_API_KEY` | Perplexity API key。为空/缺失时提供方不可用。 | +| `baseURL` | `https://api.perplexity.ai` | 端点基址;追加 `/chat/completions`。无法解析时提供方不可用。 | +| `model` | `sonar` | 搜索模型名称。 | +| `maxTokens` | `1024` | 生成答案 token 上限(`max_tokens`)。必须是正整数。 | +| `searchRecency` | (未设置) | 以 `search_recency_filter` 发送的新近程度窗口:`day`、`week`、`month` 或 `year`。未设置时不发送 filter。 | + +```yaml +- id: web-search-perplexity + name: '@deepseek-ai/dsh-web-search-perplexity' + config: + apiKey: !!js process.env.PERPLEXITY_API_KEY +``` + +## 映射 + +`content` ← `choices[0].message.content`(生成答案)。`sources[]` 优先使用结构化 `search_results[]`(`url`、`title`、`snippet`、`publishedAt` ← `date`),否则回退到只含 URL 的 `citations[]` 数组;仅当不存在 `search_results` 时才采取这条回退路径。这些源只携带 `url`,因此 seam 上的 `title`/`snippet`/`publishedAt` 是可选字段。提供方失败以 `WebError` `WEB_PROVIDER_ERROR` 呈现;中止请求以 `WEB_ABORTED` 呈现。HTTP 重定向会在接触 `Location` 目标前被拒绝,并以 `WEB_PROVIDER_ERROR` 呈现。Perplexity 没有结果数量控制,因此 seam 会强制执行 `maxResults`(截断 `sources[]` 并设置 `truncated`)。 + +## 模型体验 + +### 辅助 Perplexity 请求 + +#### 模型看到的内容 + +独立的 Perplexity 模型通过 chat-completions 端点接收逐字的 `<query>` 作为唯一 user 消息。该请求不属于会话模型上下文。 + +#### Token 影响 + +每次搜索会产生独立的提供方 token;`maxTokens` 限制生成答案。 + +#### KV Cache 影响 + +与会话请求 cache 相互独立。同一模型路由下的相同查询可能复用提供方 cache;查询或路由改变会建立不同前缀。 + +### 间接的会话工具结果 + +#### 模型看到的内容 + +通过 [`dsh-tool-web`](../tool-web/README.md),会话模型会看到生成答案及结构化结果元数据,或只含 URL 的引用。该提供方的精确失败是 `Perplexity search aborted`、`Perplexity search request failed: <error>` 和 `Perplexity returned an unprocessable response body: <error>`;HTTP 失败保留提供方消息。错误包装属于消费方。 + +#### Token 影响 + +注册不会直接产生会话 token。答案与源 token 取决于数据,源数量受 seam 限制;保留的结果或错误会重复发送直到压缩。 + +#### KV Cache 影响 + +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 配置项失效。 + +## 已知限制与暂缓事项 + +- **引用回退源只含 URL**:Perplexity 省略结构化 `search_results[]` 时,源不含 `title`/`snippet`/`publishedAt`,因此工具只渲染裸 hostname label。 +- **超量返回的源仍消耗 token 与延迟**:协议没有结果数量控制,`maxResults` 只能由 seam 在事后截断。 +- **只公开 `model`/`maxTokens`/`searchRecency`**:Perplexity 的其他搜索控制项(domain filter、`web_search_options` 上下文大小、图片)等待提供方无关 seam 字段(见 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md))。 +- **按错误形状分类中止**:只有 `DOMException` 且名为 `AbortError` 时才映射为 `WEB_ABORTED`;携带自定义原因的中止(例如 `dsh-timeout` 的 `TimeoutReason`)会呈现为 `WEB_PROVIDER_ERROR`。 diff --git a/packages/web/web/README.i18n.yaml b/packages/web/web/README.i18n.yaml new file mode 100644 index 0000000000..ec7f1ccd0e --- /dev/null +++ b/packages/web/web/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 471725f7368f480cfb255767376e3b1918bd68cf +README.zh.md: 2ed9c80682b2ff430ddd39662ea73cdf06374805 diff --git a/packages/web/web/README.md b/packages/web/web/README.md index 507dd1772e..471725f736 100644 --- a/packages/web/web/README.md +++ b/packages/web/web/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-web +English | [中文](README.zh.md) + The **web access seam**: an abstract `WebService` (`ctx.web`) defining WHAT web access the harness has — search the web, fetch a URL — over multiple providers, without binding the model contract to one vendor's API shape. This package is the interface third of the web capability. Unlike bash/fs it spans two capabilities (search and fetch) on one seam, with potentially multiple providers each: diff --git a/packages/web/web/README.zh.md b/packages/web/web/README.zh.md new file mode 100644 index 0000000000..2ed9c80682 --- /dev/null +++ b/packages/web/web/README.zh.md @@ -0,0 +1,61 @@ +# @deepseek-ai/dsh-web + +[English](README.md) | 中文 + +**web 访问 seam**:抽象 `WebService`(`ctx.web`)定义 harness 具备哪些 web 访问能力(搜索 web、抓取 URL),并通过多个提供方实现,不把模型契约绑定到某个厂商的 API 形状。 + +该包是 web 能力中负责接口的三分之一。与 bash/fs 不同,它在一个 seam 上跨越搜索与抓取两种能力,每种能力都可能有多个提供方: + +| 包 | 职责 | +|---|---| +| `@deepseek-ai/dsh-web`(本包) | 接口:服务、提供方注册表、选择策略、请求/结果词汇、`WebError` 分类体系 | +| `@deepseek-ai/dsh-web-search-exa` | 搜索实现:Exa | +| `@deepseek-ai/dsh-web-search-perplexity` | 搜索实现:Perplexity | +| `@deepseek-ai/dsh-web-fetch-local` | 抓取实现:匿名公共 HTTP(S) | +| `@deepseek-ai/dsh-tool-web` | 面向模型的 `web_search`/`web_fetch` 工具 schema,构建于 `ctx.web` 之上 | + +搜索与抓取没有共享请求 schema 或业务逻辑,但有意共用一个 seam:`ctx.web` 是单一 web 访问中间层,拥有一项提供方选择策略、一套中止/错误词汇和一个面向产品的「该 harness 如何访问 web」配置表层。代价是成对的并行 `Search`/`Fetch` 方法;这种并行是有意设计,不是遗漏提取。 + +## 服务 API(`ctx.web`) + +| 成员 | 语义 | +|---|---| +| `registerSearchProvider(provider)`/`registerFetchProvider(provider)` | 注册后端。同一能力 kind 下 id 重复时抛出 `WebError` `WEB_DUPLICATE_PROVIDER`。返回 disposer。随调用 fiber 释放。 | +| `search(request, signal?)` | 解析搜索提供方并运行一次搜索。在结果上强制执行 `request.maxResults`(截断 `sources[]`,设置 `truncated`)。能力无法运行时抛出 `WebError`。 | +| `fetch(request, signal?)` | 解析抓取提供方并获取一个 URL。非 2xx 响应是结果,不会抛出异常。无法安全获取或表示资源时抛出 `WebError`。 | + +提供方注册的是**能力** 而非工具。`dsh-tool-web` 是面向模型名称、描述、提示词指引、JSON schema 和呈现的唯一 owner。 + +## 选择 + +选择绝不依赖注册、配置或 HMR 顺序。能力要么具有显式提供方 id(配置 `searchProvider`/`fetchProvider`,或由环境变量 `$DSH_WEB_SEARCH_PROVIDER`/`$DSH_WEB_FETCH_PROVIDER` 提供相同字段),要么在恰好只注册一个可用提供方时自动选择。`search()`/`fetch()` 会在执行时解析提供方: + +| 情况 | 执行 | +|---|---| +| 已配置 id 已注册且 `available()` | 运行该提供方 | +| 已配置 id 未注册 | `WEB_PROVIDER_CONFIGURED_MISSING` | +| 已配置 id 已注册但不可用 | `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` | +| 无 id,恰好一个已注册的可用提供方 | 运行该提供方 | +| 无 id,没有可用提供方 | `WEB_PROVIDER_UNAVAILABLE` | +| 无 id,多个可用提供方 | `WEB_PROVIDER_AMBIGUOUS` | + +失败分支会抛出 `WebError`;调用方按其结构化 code(加消息细节:缺失 id、歧义候选集合)路由。提供方自身的 `available()` 是便宜的局部检查(credential 是否存在、配置是否可解析),供执行时选择使用,且**禁止发起网络调用**;`dsh-tool-web` 永远不会调用它。工具通过 `ctx.web.search()`/`fetch()` 执行,并按抛出的 code 路由,因此提供方选择只有一个 owner。 + +## 词汇 + +`WebSearchRequest`(`query`、`maxResults?`)→ `WebSearchResult`(`content?`、`sources[]`、`truncated`);每个 `WebSearchSource` 都有必填 `url` 与可选 `title`/`snippet`/`publishedAt`(Perplexity 引用可能只含 URL)。`WebFetchRequest`(`url`)→ `WebFetchResult`(最终 `url`、`statusCode`、`body`、`truncated`);取消作为可选的直接 `AbortSignal` 参数传给 `search()`/`fetch()`。`WebFetchBody` 是这里拥有的封闭判别联合(`html` | `text`);消费方使用 `switch` 实现穷尽检查,因此新增 kind 会破坏编译,直到处理完毕。完整契约见 `src/types.ts`,其中也包含 `WebError` code 分类体系。 + +## 模型体验 + +通过 `dsh-tool-web` 间接影响;该工具保留有界的规范化提供方数据,或精确的已配置提供方、提供方不可用、无提供方、多提供方及 `Error: <message>` 失败,本注册表自身不贡献提示词或 schema。 + +#### KV Cache 影响 + +不会直接失效;请求前缀变更由命名消费方负责。 + +## 已知限制与暂缓事项 + +- **没有观测表层**:没有提供方变更事件或能力状态查询;可用性只能通过执行 `search()`/`fetch()` 并按抛出的 `WebError` code 路由来观测,无提供方失败是通用的 `WEB_PROVIDER_UNAVAILABLE`,不会枚举逐提供方原因(见 [Agent Note](../../../.agents/notes/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md))。 +- **`WebSearchRequest` 只携带 `query` + `maxResults`**:提供方无关的控制项(新近程度、domain filter、区域提示、搜索深度)暂缓至 Exa 与 Perplexity 都能诚实支持时(见 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md))。 +- **`WebFetchBody` 没有 `pdf` 分支**:可提取文本的 PDF 支持属于明确的暂缓工作;封闭联合会使新增该分支成为三个 web 包中由编译强制执行的变更。 +- **提供方支持的页面提取不属于 `fetch()` 范围**:Firecrawl/Tavily 风格的 `web_extract` 能力暂缓,而不会扩宽抓取 seam。 diff --git a/packages/workflow/README.i18n.yaml b/packages/workflow/README.i18n.yaml new file mode 100644 index 0000000000..5b143480d5 --- /dev/null +++ b/packages/workflow/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 17da3397e4fa64837b271d89e9062e3621434338 +README.zh.md: 056d005de69a493a24b360583651bf313548b3fb diff --git a/packages/workflow/README.md b/packages/workflow/README.md index 5c0d51724b..17da3397e4 100644 --- a/packages/workflow/README.md +++ b/packages/workflow/README.md @@ -1,5 +1,7 @@ # workflow/ — dynamic-workflow capability family +English | [中文](README.zh.md) + The workflow seam: a model-written JavaScript orchestration script that fans out subagents at scale (phases, structured per-agent results, concurrency caps), modeled on Claude Code's dynamic workflows. A capability seam (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)) in the bash shape: ONE engine implementation per context registers as `ctx.workflows`; the model-facing tool consumes it. | Package | Role | ctx key | diff --git a/packages/workflow/README.zh.md b/packages/workflow/README.zh.md new file mode 100644 index 0000000000..056d005de6 --- /dev/null +++ b/packages/workflow/README.zh.md @@ -0,0 +1,16 @@ +# workflow/:动态工作流能力族 + +[English](README.md) | 中文 + +workflow seam:由模型编写 JavaScript 编排脚本,大规模扇出 subagent(分阶段、每个 agent(智能体)的结构化结果、并发上限),其设计参考 Claude Code 动态工作流。这是 bash 形态的能力 seam(见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):每个上下文只有一个引擎实现注册为 `ctx.workflows`;面向模型的工具消费它。 + +| 包 | 角色 | ctx 键 | +|---|---|---| +| `workflow/` | 抽象 workflow seam:服务基类、运行词汇和 `workflow/*` 事件 | `ctx.workflows` | +| `workflow-workerthread/` | `node:worker_threads` 引擎:每次运行使用一个 worker;脚本的 vm 上下文位于 worker 内,`agent()` 通过消息端口桥接到 `ctx.subagents` | (提供 `ctx.workflows`) | +| `tool-workflow/` | 面向模型的 `workflow` 工具,基于 `ctx.workflows` | (注册到 `ctx.tools`) | +| `tool-ralph/` | 基于 `ctx.workflows` 和全新结构化输出 subagent 提供方的固定全新 agent Ralph 策略 | (注册到 `ctx.tools`) | + +接口位于 `workflow/workflow/`。引擎的 `agent()` 钩子使用 [subagent seam](../subagent/README.md)(任何已注册提供方;随产品交付的示例使用 `spawn`),`agent({ schema })` 则使用进程内后端实现的结构化输出支持。worker thread 隔离的是脚本:宿主绝不会被它阻塞,已取消运行经过宽限时间后的终止也会实际生效;但它不是安全边界。如果将来确有需要,可以在同一接口后替换为 isolated-vm/独立进程引擎,以实现真正的沙箱隔离。 + +通用脚本引擎的决策和延期工作见[动态工作流 Agent Note](../../.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md)。独立的 [Ralph 消费方](../../.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md)会固定脚本和全新提供方策略,而不是再添加一个引擎或 agent loop(智能体循环)模式。 diff --git a/packages/workflow/tool-ralph/README.i18n.yaml b/packages/workflow/tool-ralph/README.i18n.yaml new file mode 100644 index 0000000000..85df85d098 --- /dev/null +++ b/packages/workflow/tool-ralph/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: daf242364d1093cff30cd1dc95823e1ecb89a9c7 +README.zh.md: cc71630c097157182823342a873d673f6420f44e diff --git a/packages/workflow/tool-ralph/README.md b/packages/workflow/tool-ralph/README.md index 8f54c101f0..daf242364d 100644 --- a/packages/workflow/tool-ralph/README.md +++ b/packages/workflow/tool-ralph/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-tool-ralph +English | [中文](README.zh.md) + The model-facing `ralph` tool runs a fixed foreground workflow that gives one immutable objective to a sequence of fresh child agents. It demonstrates a specialized orchestration policy as an ordinary plugin over [`ctx.workflows`](../workflow/README.md) and [`ctx.subagents`](../../subagent/subagent/README.md): no Ralph mode or fresh-agent loop is added to `agent-loop`, and the same-session [goal domain](../../goal/goal/README.md) remains independent. The [Ralph Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md) owns the policy and deferred work. ## Contract diff --git a/packages/workflow/tool-ralph/README.zh.md b/packages/workflow/tool-ralph/README.zh.md new file mode 100644 index 0000000000..cc71630c09 --- /dev/null +++ b/packages/workflow/tool-ralph/README.zh.md @@ -0,0 +1,93 @@ +# @deepseek-ai/dsh-tool-ralph + +[English](README.md) | 中文 + +面向模型的 `ralph` 工具运行固定的前台工作流,把一个不可变目标依次交给多个全新子 agent(智能体)。它展示如何把专用编排策略实现为基于 [`ctx.workflows`](../workflow/README.md) 和 [`ctx.subagents`](../../subagent/subagent/README.md) 的普通插件:不会向 `agent-loop` 添加 Ralph 模式或全新 agent 循环,同会话的[目标领域](../../goal/goal/README.md)也保持独立。政策和延期工作由 [Ralph Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md)负责。 + +## 契约 + +`ralph({ objective, maxRounds? })` 会等待整个运行完成。部署配置中的 `maxRounds` 既是默认值,也是调用覆盖值的上限。每个 Ralph Round 通过 `subagentProvider` 启动一个子 agent;该提供方必须存在、支持结构化输出,并报告 `inheritsParentContext: false`。已配置的提供方以 `WorkflowStartRequest.subagentProvider` 传递,使固定脚本无法检查或更改路由,普通的模型编写 `workflow` 工具也不会因此获得提供方选择器。解析后的 Round 上限还会作为 `WorkflowStartRequest.maxTotalAgents` 传递,使固定循环与引擎的子 agent 总数后备上限协同;Ralph 上限超过引擎部署上限时,引擎会在发布运行前拒绝。 + +每个子 agent 只接收不可变目标、当前 Ralph Round 及其上限、一条「共享工作区是权威状态」指令,以及上一个结构化交接内容。工作区是长期记忆;不会把父级对话或先前子 agent 会话作为初始内容。报告包含 `status: continue | complete | blocked`、非空摘要、证据、后续步骤和阻塞文本。固定工作流内部及消费方边界都会校验特定状态的语义和序列化后的 `maxHandoffChars` 上限。无效、缺失或过大的报告会使工作流失败,而不会被截断或误认为上限耗尽。 + +成功的终态工具结果为 `complete`、`blocked` 或 `budget-limited`,并包含最后一份有界报告和已启动的 Round 数量。规范包络为 `{ runId, agentsStarted, result }`;Native 渲染器中的完成与阻塞标签会明确说明结果由 worker 报告,而非独立认证。`maxResultChars` 只限制包含截断标记的渲染文本,不会改变规范值中经过校验的报告或跨 Round 交接内容。 + +普通子 agent 失败会产生错误,其中标明失败的 Round;如果已有上一次成功交接,也会保留它。Ralph 不会重试该 Round。致命的提供方启动、传输、worker 或工作流失败仍是工作流错误,并可能在固定脚本返回交接内容前结算。取消同样属于错误;局部输出绝不会视为成功。 + +## 生命周期与取消 + +调用方 agent 是每个全新子 agent 的父级,因此会保留 cwd 和谱系,但不会复制其对话。`exec.signal` 进入工作流引擎,同时也桥接到 `run.cancel()`,确保实现相互独立。工具等待 `run.result` 并调用 `run.dispose()`,后一个调用位于 `finally` 中,因此取消的父级步骤会等到引擎完成有界终止且子 agent 完全停稳后才返回。 + +## 渲染意图 + +待处理调用使用 `generic` 卡片,标题为 `ralph`;不可变目标作为其 `rawInput`。结果继续使用 generic 卡片。两个呈现函数都只依赖工具参数和已结算的工具包络。 + +## 配置 + +| 键 | 默认值 | 含义 | +|---|---|---| +| `subagentProvider` | `spawn` | 每个 Round 使用的全新结构化输出提供方。 | +| `maxRounds` | `256` | 一次 Ralph 运行的默认值和部署上限。 | +| `maxHandoffChars` | `16384` | 一份 Round 报告序列化后的最大字符数。 | +| `maxResultChars` | `16384` | 返回给父级的完整成功结果最大字符数。 | + +插件应用时会规范化并校验所有配置值;直接应用、未经过 Loader schema 规范化的情况也包括在内。每次调用前都会立即解析提供方能力,因为提供方注册可能随插件生命周期和 HMR(热模块替换)变化。 + +## 模型体验 + +### 系统提示词 + +#### 模型看到的内容 + +在该插件的注册作用域内,每个父级请求都会收到下方的固定路由指导。 + +##### Ralph 指导 + +```markdown +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. +``` + +#### Token 影响 + +插件启用期间,每个请求支付少量固定指导成本。 + +#### KV Cache 影响 + +只要插件作用域和指导文本不变,前缀就保持稳定。启用或 dispose(资源释放)可能从该提示词段开始使复用失效。 + +### 工具 schema + +#### 模型看到的内容 + +已生成的 [`ralph` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-ralph)公开一个必填 `objective` 字符串和一个可选 `maxRounds` 数字。提供方选择、交接大小、报告 schema、工作流脚本和编排行为均由部署拥有,不在调用接口中。 + +#### Token 影响 + +工具可见的每个请求都会支付少量固定 schema 成本。 + +#### KV Cache 影响 + +只要定义和可见性不变,前缀就保持稳定。 + +### 子 agent 请求与父级结果 + +#### 模型看到的内容 + +每个子 agent 都会看到独立的固定 Round 提示词和结构化输出捕获契约。父级只看到原始调用和一个终态结果,其中包含 worker 报告的状态、Round 数量及经过美化打印的最终报告;中间子 agent 消息和报告不会进入父级对话。普通子 agent 失败时会改为产生错误,其中包含对应 Round 编号;从第二个 Round 起,还会包含上一次成功交接。 + +#### Token 影响 + +每个 Round 都会支付全新子 agent 上下文的成本。`maxHandoffChars` 限制跨 Round 状态,`maxResultChars` 独立限制完整的父级成功文本;子 agent 工作留在父级上下文之外。 + +#### KV Cache 影响 + +每个全新子 agent 都有独立的请求缓存。父级结果追加在可复用请求前缀之后。 + +## 已知限制与延期工作 + +- **完成由 worker 自行声明**:没有独立的评估器或验证器判断目标是否实际完成;评估器政策及评估器驱动的延续均延期处理。 +- **仅支持前台**:没有 task id、后台收集、进程恢复检查点、调度器或基于墙上时钟的启动政策。 +- **工作区是唯一的跨 Round 长期记忆**:一份有界报告作为显式交接内容,每个子 agent 结束后,未提交的对话推理都会消失。 +- **一个 Round 对应一个全新子 agent**:Round 内没有扇出、模型/提供方切换、fork 上下文或由模型调用选择的提供方。 +- **普通子 agent 失败会终止运行**:固定脚本报告失败的 Round 和上一次成功交接,但不会重试;致命的工作流基础设施失败可能在该状态返回前结束。 +- **聚合工作量仅受 Round 数量限制**:token、价格和已用时间预算均延期处理。 diff --git a/packages/workflow/tool-workflow/README.i18n.yaml b/packages/workflow/tool-workflow/README.i18n.yaml new file mode 100644 index 0000000000..33d3461568 --- /dev/null +++ b/packages/workflow/tool-workflow/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 5afa68764bfe339377954f8912b6f9b795435be1 +README.zh.md: d24442f2dcf12dae11980ab6ab08d1a63c2b1432 diff --git a/packages/workflow/tool-workflow/README.md b/packages/workflow/tool-workflow/README.md index e7cfceea9a..5afa68764b 100644 --- a/packages/workflow/tool-workflow/README.md +++ b/packages/workflow/tool-workflow/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-tool-workflow +English | [中文](README.zh.md) + The model-facing **`workflow` tool**: run a JavaScript orchestration script that fans out subagents, and return the script's final value. This package owns schema and lifecycle shaping over [`ctx.workflows`](../workflow/README.md); script parsing, execution, caps, and cancellation live behind the seam, while the consumer retains ownership of the parent-facing schema and result envelope. ## What the model sees diff --git a/packages/workflow/tool-workflow/README.zh.md b/packages/workflow/tool-workflow/README.zh.md new file mode 100644 index 0000000000..d24442f2dc --- /dev/null +++ b/packages/workflow/tool-workflow/README.zh.md @@ -0,0 +1,80 @@ +# @deepseek-ai/dsh-tool-workflow + +[English](README.md) | 中文 + +面向模型的 **`workflow` 工具**:运行一段扇出 subagent 的 JavaScript 编排脚本,并返回脚本的最终值。本包负责基于 [`ctx.workflows`](../workflow/README.md) 塑造 schema 和生命周期;脚本解析、执行、上限与取消位于 seam 之后,消费方继续拥有面向父级的 schema 和结果包络。 + +## 模型看到的内容 + +工具有三个参数:`meta`(必需的身份数据:`name`、`description` 和可选的进度注解)、`script`(必需的纯 JavaScript 函数体,不含 `export const meta` 语句;工具描述包含完整的编写契约)以及 `args`(可选 JSON 对象,作为全局变量 `args` 向脚本公开;裸列表应包装到字段中,使协议 schema 如实表达形态)。插件还会贡献一个 `tool:<toolName>` 系统提示词段,其中包含使用政策:只有用户明确要求工作流/大型编排时才使用该工具;一两项委派优先使用普通 subagent 调用。这遵循工具指导随工具插件交付、绝不放入部署 persona 的约定。 + +## 生命周期 + +当前版本采用同步收集(类似 [`dsh-tool-subagent`](../../subagent/tool-subagent/README.md)):`execute` 启动运行并等待 `run.result`;这些操作位于 `try/finally` 中,该结构总会 dispose 运行,使脚本及其子 agent(智能体)在每条路径上完全停稳。`exec.signal` 会桥接到 `run.cancel()`,包括启动前已经中止的情况。非 `completed` 结束原因会映射为报告原因的 `isError` 结果,绝不会把局部输出当作成功;`start()` 同步抛出的解析/meta 失败会变成模型可据以修正的 `isError`。完成时返回规范值 `{ runId, agentsStarted, result }`;Native 渲染器保留 meta 名称、agent 数量和 JSON 值,只会在 `maxResultChars` 处截断该投影。 + +## 渲染意图 + +渲染意图预先确定(见[渲染意图 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md)):使用一个 `generic` 卡片,标题为 `workflow: <meta.name>`,直接从 `args.meta.name` 读取(呈现是参数的纯函数,不要求引擎解析);脚本文本作为 `rawInput` 携带。结果继续使用 generic 卡片。 + +## 配置 + +| 键 | 默认值 | 含义 | +|---|---|---| +| `toolName` | `workflow` | 要注册的面向模型工具名称。 | +| `maxResultChars` | `50000` | 渲染结果上限;更长的 JSON 会连同提示一起截断。 | + +## 模型体验 + +### 系统提示词 + +#### 模型看到的内容 + +在该插件的注册作用域内,每个父级请求都会收到下方的 workflow 指导。作用域工具限制可以隐藏 schema,而不移除这段独立注册的指导。 + +##### Workflow 指导 + +```markdown +Use the <toolName> tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. +``` + +#### Token 影响 + +插件启用期间,每个请求支付少量固定指导成本。 + +#### KV Cache 影响 + +只要插件作用域和指导文本不变,前缀就保持稳定。启用或 dispose(资源释放)可能从该提示词段开始使复用失效。 + +### 工具 schema + +#### 模型看到的内容 + +工具可见时,已生成的默认 [`workflow` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-workflow)包含完整的 JavaScript 钩子与元数据契约;`toolName` 可以重命名该定义,模型会提交脚本、元数据和可选 args。 + +#### Token 影响 + +工具可见的每个请求都会支付较大的固定 schema 成本。 + +#### KV Cache 影响 + +只要 `toolName`、定义和可见性不变,前缀就保持稳定。重命名、插件生命周期或作用域限制可能从该 schema 开始使复用失效。 + +### 工具调用历史与结果 + +#### 模型看到的内容 + +由模型编写的完整脚本、元数据和 args 会保留在 assistant 工具调用中。成功结果精确为 `workflow "<name>" completed (<count> agent<optional-s>).`、换行、`Return value:`、换行,以及经过美化打印且依赖数据的 JSON;达到上限时,会在新行添加 `… [truncated: <omitted> more characters]`。失败结果精确为 `Error: workflow run was cancelled`(可以追加后缀 ` (<error>)`)、`Error: workflow run failed: <error-or-unknown error>` 或防御性的 `Error: workflow run ended abnormally (<reason>)`;没有所属 agent 的调用变为 `Error: workflow tool requires a calling agent (exec.agent was undefined)`。中间子 agent 消息会被省略。 + +#### Token 影响 + +调用 token 可能很多,并会保留到上下文压缩(compaction)为止。结果渲染受 `maxResultChars` 限制;子模型 token 与父级保留的上下文相互独立。 + +#### KV Cache 影响 + +仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 + +## 已知限制与延期工作 + +- **父级轮次会阻塞到整个工作流结算**:没有后台启动/轮询接口,取消会把局部输出作为错误丢弃。 +- **`args` 必须是对象,Native 结果文本有界**:调用方把顶层数组/标量包装到字段中;规范工作流结果保持完整,超过 `maxResultChars` 的 JSON 会在面向模型的投影中截断,而不是存入检索句柄之后。 +- **每次工具注册的工作流政策固定**:提供方选择、上限和工具名称属于部署配置,不是模型调用参数。 diff --git a/packages/workflow/workflow-workerthread/README.i18n.yaml b/packages/workflow/workflow-workerthread/README.i18n.yaml new file mode 100644 index 0000000000..1c183d526a --- /dev/null +++ b/packages/workflow/workflow-workerthread/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 9da420bdd67ac5b0a4bfffa312c6fe7b2dabf8f5 +README.zh.md: e280562b95e9dbebb243aad0f765771f4737b312 diff --git a/packages/workflow/workflow-workerthread/README.md b/packages/workflow/workflow-workerthread/README.md index 74eacc5fd8..9da420bdd6 100644 --- a/packages/workflow/workflow-workerthread/README.md +++ b/packages/workflow/workflow-workerthread/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-workflow-workerthread +English | [中文](README.zh.md) + This package implements `WorkflowService` with one Node worker thread per run. The worker executes the orchestration script; child agents remain on the host and are reached through `ctx.subagents` over a typed host/worker protocol. The package root exports the default engine plugin and its `Config`; the worker protocol, runtime, and session modules stay private to the implementation. The operational `./worker` entry remains the engine's spawn target. diff --git a/packages/workflow/workflow-workerthread/README.zh.md b/packages/workflow/workflow-workerthread/README.zh.md new file mode 100644 index 0000000000..e280562b95 --- /dev/null +++ b/packages/workflow/workflow-workerthread/README.zh.md @@ -0,0 +1,124 @@ +# @deepseek-ai/dsh-workflow-workerthread + +[English](README.md) | 中文 + +本包为 `WorkflowService` 提供实现,每次运行使用一个 Node worker thread。worker 执行编排脚本;子 agent(智能体)留在宿主上,通过带类型的宿主/worker 协议访问 `ctx.subagents`。 + +包根目录默认导出引擎插件及其 `Config`;worker 协议、运行时和会话模块均为实现私有。操作入口 `./worker` 仍是引擎的派生目标。 + +这种拆分只有一个主要目的:同步脚本循环不能阻塞 harness 事件循环,忽略取消的脚本可以连同其 worker 一起终止。它不是安全沙箱。 + +## 信任与隔离边界 + +工作流脚本由模型编写,信任前提与模型已有的 bash 访问相同。worker 内的 `node:vm` 是塑造 API 的机制,不是安全边界:逃逸的脚本可以用宿主进程权限重新取得 Node 能力。 + +worker 仍提供实用的隔离: + +- 脚本 CPU 工作和同步自旋不会占用宿主事件循环; +- `worker.terminate()` 为 dispose(资源释放)提供真实的最终停止手段; +- 除未构建 loader 的管道变量外,worker 以空环境启动,因此环境凭据不会通过 `process.env` 跨越边界; +- 宿主/worker 消息使用结构化克隆数据,并在脚本边界执行普通 JSON 校验。 + +真正的不可信脚本沙箱需要在同一 workflow seam 后采用不同引擎。 + +## 脚本契约 + +工作流的 `meta` 是宿主提供的数据,而不是待求值的脚本文本。引擎会校验必需的 `name` 和 `description`、拒绝未知字段,并在返回运行前检查函数体能否解析。 + +在 worker 内,脚本会收到 `args` 以及以下钩子: + +- `agent(prompt, { label, phase, schema, model })` 启动一个宿主侧 subagent。提供 schema 时返回结构化值,否则返回最终文本。普通子 agent 失败会产生 `null`; +- `parallel(thunks)` 在已配置的并发限制下运行 thunk; +- `pipeline(items, ...stages)` 在没有跨阶段屏障的情况下传递 `(previous, item, index)`; +- `phase(title)` 和 `log(message)` 发出观察器叙述。 + +未知选项、格式错误的参数、不支持的 schema、触发的上限、提供方启动失败和基础设施结果失败都属于致命工作流错误。有意不注入 timer、文件系统 API 或 Node 全局变量,但上述信任注意事项仍然适用。 + +## 运行顺序 + +`start()` 会校验 meta、解析函数体、解析一个已注册且规范化的提供方路由,并解析每次运行的子 agent 总数上限,然后才创建 worker 或发布 `workflow/start`。请求的 `maxTotalAgents` 必须是正安全整数,且不能超过引擎配置的部署上限。源代码模式通过 data URL bootstrap 安装 TypeScript 转换;构建模式把同级 `lib/worker.cjs` 作为文件系统路径传入,因为 pkg 的 VFS 钩子要求 CommonJS。两者都能在普通 Node 下运行。ready/go 握手可以避免启动信号取消与 worker 启动发生竞态,导致脚本最初的同步片段被执行。 + +对于每次 `agent()` 调用: + +1. worker 发送 `child-start`,其中包含普通数据提示词和选项。 +2. 宿主通过异步 `SubagentService.start` 调用启动请求中的提供方覆盖值,否则调用已配置提供方;调用会传入工作流父级和每次运行唯一的规范中止信号。提供方选择应用于该次运行的每个子 agent,对脚本不可见。 +3. 如果启动被拒绝,宿主会发送 `child-start-error`;提供方启动已经完全停稳,不会发出子 agent 生命周期事件。 +4. 如果启动兑现时工作流仍接纳工作,宿主会记录该运行、观察 `result`,然后发送 `child-started`。即使结果已经结算,也只会随后转发,以保持先启动、后结果的顺序。 +5. worker 发出成对的 `workflow/agent-start` 和 `workflow/agent-end` 叙述,并在收集后请求 dispose 子 agent。 + +提供方启动与已发布子 agent 分开跟踪。如果启动仍在等待,而取消、worker 死亡或正常工作流结算关闭了接纳,共享信号会中止该启动。即便提供方随后兑现,宿主也会 dispose 它,且绝不向 worker 通知。 + +## 值边界 + +离开脚本的值会经过 `materializeFromRealm`;该函数接受普通的无损 JSON 数据,并拒绝特殊原型、函数、symbol、循环、稀疏数组、非有限数和嵌套 `undefined`。遍历在 worker 内执行,并把对象键定义为数据属性,使 `__proto__` 无法改变原型。 + +子 agent 结果从宿主跨越到 worker 之前,会先投影并制作快照。这是真正近似进程的序列化边界;它有意不同于可信的同进程工作流和 subagent 事件 payload,后者以不可变方式借用值。 + +## 取消与 dispose + +`WorkflowRun.cancel()` 会记录第一个原因、通知 worker 取消、中止每个待处理及已发布子 agent 共享的唯一信号,并启动 `disposeGraceMs` timer。worker 钩子会在下次 await 时抛出 `CANCELLED`。如果运行到期限仍未结算,宿主会将其以已取消状态兑现、为悬空的子 agent 生命周期事件配对,并终止 worker。 + +subagent seam 只有一个取消通道:请求信号。不存在单独的子 agent 取消 RPC。已发布子 agent 使用 `run.dispose()` 清理;待处理提供方启动在其 promise 拒绝或兑现前仍由提供方拥有。 + +正常结算也会中止待处理启动,并在结果对外结算前开始 dispose 所有已发布但无需等待的子 agent。宿主的完全停稳条件同时包括待处理启动和已发布子 agent 的 dispose,因此清理不会遗漏异步启动事务。 + +`dispose()` 是幂等的。它会取消运行、立即启动宿主驱动的 dispose、在同一宽限时间内等待结果和子 agent 完全停稳、无条件终止 worker,并执行最后一次幸存项扫描。每个子 agent 的 dispose 都会记忆化,使 worker RPC、宿主取消、死亡清理和公开 dispose 都汇入同一操作。 + +## 结果与事件保证 + +在宿主主张点,终态结果遵循先到者胜。已接受的外部取消会覆盖后到的非取消 worker 结果;先完成主张的结果或 worker 死亡不能被可重入清理回调改写。 + +worker 错误、消息失败或提前退出会在清理前关闭消息接纳,然后以 `error` 兑现;如果取消已经拥有该运行,则不覆盖取消。后到的排队消息无法在该逻辑边界后创建子 agent 或发出叙述。 + +宿主会维护已转发子 agent 启动的台账。优雅退出的 worker 会提供对应的结束事件;死亡或强制终止会把缺失的结束事件合成为已取消。因此,每个已转发的 `workflow/agent-start` 都会且只会配对一次,不过已经到达的工作流结果之后的清理可能稍后才完成。 + +## 配置 + +| 键 | 默认值 | 含义 | +|---|---|---| +| `provider` | `spawn` | `agent()` 使用的宿主侧 subagent 提供方。 | +| `maxConcurrentAgents` | `0` | 并发 `agent()` 上限;`0` 会根据可用 CPU 并行度解析。 | +| `maxTotalAgents` | `1000` | 一次运行中的 `agent()` 调用总数。 | +| `maxItemsPerCall` | `4096` | 一次 `parallel()` 或 `pipeline()` 调用接受的条目数。 | +| `syncTimeoutMs` | `5000` | 脚本最初同步片段的 VM 超时时间。 | +| `disposeGraceMs` | `5000` | 强制结算/终止之前的期限,也是公开 dispose 的期限。 | + +所属消费方可以为一次运行设置 `WorkflowStartRequest.subagentProvider` 和 `WorkflowStartRequest.maxTotalAgents`。它们属于引擎级政策,不是脚本钩子或面向模型的选项;普通 `workflow` 工具不会设置两者。每次运行的子 agent 总数上限可以降低、但绝不能提高已配置的 `maxTotalAgents` 上限。 + +## 模型体验 + +### 子 agent 请求 + +#### 模型看到的内容 + +脚本每次调用 `agent()`,都会把提示词逐字发送给 subagent 提供方,并附带可选模型或结构化输出 schema。每个子 agent 看到该提供方自己的上下文;phase 和 log 叙述只留在观察器事件中。 + +#### Token 影响 + +可能需要为许多独立子 agent 上下文支付 token 成本,数量受 `maxConcurrentAgents`、`maxTotalAgents` 和 `maxItemsPerCall` 限制;这些上下文绝不会直接加入父级历史。 + +#### KV Cache 影响 + +与父级请求缓存和同级子 agent 缓存相互独立。每个子 agent 只能在其自身提供方、模型、提示词和 schema 下复用逐字节相同的前缀;其后续历史仅追加增长。 + +### 父级工具结果(间接) + +#### 模型看到的内容 + +通过 [`dsh-tool-workflow`](../tool-workflow/README.md),成功结果只会在该消费方的包装层中公开实体化的最终 JSON 值和子 agent 数量。本引擎提供稳定错误,包括 `workflow script does not parse: <error>`、`invalid meta: <violations>`、`agent() requires a non-empty prompt string`、`agent() could not start a child: <error>`、`child agent run failed: <error>`,以及其精确的 `parallel()`、`pipeline()`、`phase()`、选项、schema 和 JSON 边界校验消息。中间子 agent 输出可供脚本使用,但不提供给父模型。 + +#### Token 影响 + +本引擎不会直接向父级添加 token。最终结果大小由工具消费方限制,并保留到上下文压缩(compaction)为止。 + +#### KV Cache 影响 + +仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 + +## 已知限制与延期工作 + +- **worker/vm 不是安全边界**:模型编写的代码可以逃逸 `node:vm` 并取得 worker 的进程权限;不可信代码部署需要独立进程或容器引擎。 +- **每次运行都要支付一个 worker thread 的成本**:没有池、预热运行时或跨运行脚本缓存。 +- **不注入环境 timer、文件系统或网络,但逃逸代码仍可访问 Node**:缺失的全局变量用于保证 API 可移植性,而非隔离。 +- **终止只能报告宿主观察到的启动**:`agentsStarted` 不包括仍在 worker 侧排队等待并发、且在强制终止后无法得知的调用。 +- **跨 realm 错误在脚本内无法通过 `instanceof Error`**:工作流作者必须根据 `name` 和 `code` 等稳定字段分支。 diff --git a/packages/workflow/workflow/README.i18n.yaml b/packages/workflow/workflow/README.i18n.yaml new file mode 100644 index 0000000000..33cc6468d9 --- /dev/null +++ b/packages/workflow/workflow/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 9d01cfd3d2504d6b5af5a1af6cba735a4abb5795 +README.zh.md: 85dc6bb1897678f72eb715514ab85cad7f7b9589 diff --git a/packages/workflow/workflow/README.md b/packages/workflow/workflow/README.md index 9283f22cc7..9d01cfd3d2 100644 --- a/packages/workflow/workflow/README.md +++ b/packages/workflow/workflow/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-workflow +English | [中文](README.zh.md) + The workflow seam (`ctx.workflows`) executes a model-written orchestration script that can fan out subagents. The seam defines the script, run, result, error, and event contracts; an engine decides how to isolate and execute the script. `@deepseek-ai/dsh-workflow-workerthread` is the current engine and `@deepseek-ai/dsh-tool-workflow` is the model-facing consumer. A future process or sandbox engine can replace the implementation without changing the tool. diff --git a/packages/workflow/workflow/README.zh.md b/packages/workflow/workflow/README.zh.md new file mode 100644 index 0000000000..85dc6bb189 --- /dev/null +++ b/packages/workflow/workflow/README.zh.md @@ -0,0 +1,59 @@ +# @deepseek-ai/dsh-workflow + +[English](README.md) | 中文 + +workflow seam(`ctx.workflows`)执行由模型编写、可扇出 subagent 的编排脚本。该 seam 定义脚本、运行、结果、错误和事件契约;引擎负责决定如何隔离并执行脚本。 + +`@deepseek-ai/dsh-workflow-workerthread` 是当前引擎,`@deepseek-ai/dsh-tool-workflow` 是面向模型的消费方。未来的进程或沙箱引擎可以替换实现,而无需更改工具。 + +## 服务与运行契约 + +`WorkflowService.start(request): WorkflowRun` 会同步完成足够多的校验,在运行存在前拒绝格式错误的 meta 块、无法解析的脚本、不可用的提供方路由或不受支持的单次运行限制。返回后,`WorkflowRun.result` 绝不拒绝:执行失败以 `stopReason: 'error'` 兑现,取消则在引擎有限的宽限时间内以 `cancelled` 兑现。 + +运行由持有方拥有。引擎插件卸载会阻止新的启动,但不会撤销已接受的运行。持有方必须在每条路径上调用 `dispose()`;dispose 会取消剩余工作,并在文档规定的期限内达到或放弃完全停稳。 + +`WorkflowStartRequest` 包含 `{ meta, script, args?, subagentProvider?, maxTotalAgents?, parent, signal? }`。`parent` 把每个子 agent(智能体)归属于调用 agent。`subagentProvider` 可以为该次运行的所有子 agent 指定路由,同时不向脚本公开提供方选择;省略时使用引擎配置的提供方。`maxTotalAgents` 可以为一次运行降低引擎的部署上限,同样对脚本不可见。实现会同步拒绝无效路由和限制。`meta` 与 `args` 是普通数据,不是脚本片段。 + +`WorkflowRun` 公开 `{ id, meta, result, cancel(reason?), dispose() }`。`WorkflowResult` 包含 `{ value, stopReason, error?, agentsStarted }`;`value` 是普通 JSON 数据或 `null`。 + +## 事件 + +工作流事件只供观察。它们携带 `WorkflowRunInfo`(`id` 加 `meta`),而不是实时运行,因此监听器无法取得取消或 dispose(资源释放)权限。 + +- `workflow/start` / `workflow/end` 为运行配对; +- `workflow/phase` 和 `workflow/log` 公开脚本叙述; +- `workflow/agent-start` / `workflow/agent-end` 按 `seq` 为每次子 agent 调用配对;异步提供方启动被拒绝的子 agent 不会发出其中任何一个事件。 + +同进程事件 payload 是以不可变方式借用的值。每个监听器都独立隔离:同步抛出或返回的 promise 被拒绝时,只会记录日志,不会阻塞同级监听器或改变执行。 + +## 失败纪律 + +`WorkflowError` 携带一个代码和 `fatal` 标志。致命错误总会逸出 `parallel()` 和 `pipeline()`,而不会变成普通的逐项 `null`: + +- `SCRIPT_PARSE` / `META_INVALID`:工作流无法启动; +- `INVALID_ARGUMENT` / `UNSUPPORTED_OPTION` / `UNSUPPORTED_SCHEMA`:钩子调用违反引擎契约; +- `AGENT_CAP` / `ITEM_CAP`:超过已配置的安全上限; +- `AGENT_START`:提供方异步启动被拒绝; +- `AGENT_RESULT`:已就绪子 agent 的结果因基础设施故障而拒绝; +- `RESULT_UNSERIALIZABLE`:脚本/worker 值不是普通 JSON 数据; +- `CANCELLED`:取消拥有该运行,待处理和未来的钩子都会拒绝。 + +子 agent 若以非完成的结束原因正常兑现,并不属于基础设施异常:`agent()` 返回 `null`,使脚本可以处理普通的子 agent 失败。 + +## 模型体验 + +通过 `dsh-tool-workflow` 和工作流引擎间接产生影响;两者创建子 agent 请求,并返回保留在父级的工具结果。 + +#### KV Cache 影响 + +不会直接使缓存失效;具名消费方负责请求前缀的任何变化。 + +## 已知限制与延期工作 + +- **仅支持前台收集**:调用方拥有一个实时运行并等待它;后台启动/轮询、spill 句柄和分离收集均延期处理。 +- **没有日志记录或恢复**:脚本、子 agent 进度和中间值均不设检查点,因此进程重启后无法继续运行。 +- **没有已保存或嵌套工作流**:该 seam 只启动调用方提供的脚本,工作流脚本不会收到用于递归编排的 `workflow()` 钩子。 +- **没有 token 预算词汇**:引擎会限制并发、条目和子 agent,但请求与结果都不会统计跨子 agent 的模型 token。 +- **运行由持有方拥有,不由服务跟踪**:卸载引擎不会发现独立的实时句柄;每个消费方都必须 dispose 自己启动的运行。 + +延期的工作流接口见[动态工作流 Agent Note](../../../.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md)。 diff --git a/packages/workspace/README.i18n.yaml b/packages/workspace/README.i18n.yaml new file mode 100644 index 0000000000..a5400bc218 --- /dev/null +++ b/packages/workspace/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 0d5ebabfbbb2922a369adb3a5d67ea4aafbe700f +README.zh.md: b82e8e6138f3e97c3c047cf1812cee8e558ea29b diff --git a/packages/workspace/README.md b/packages/workspace/README.md index 4658080be9..0d5ebabfbb 100644 --- a/packages/workspace/README.md +++ b/packages/workspace/README.md @@ -1,5 +1,7 @@ # workspace/ — the workspace entity +English | [中文](README.zh.md) + The workspace family owns the persistent workspace concept: a directory the user works in, with a title and the ordered list of sessions that belong to it. Design record: [domain KV storage Agent Note](../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md). | Package | Role | ctx key | diff --git a/packages/workspace/README.zh.md b/packages/workspace/README.zh.md new file mode 100644 index 0000000000..b82e8e6138 --- /dev/null +++ b/packages/workspace/README.zh.md @@ -0,0 +1,11 @@ +# workspace/:Workspace 实体 + +[English](README.md) | 中文 + +Workspace 系列拥有持久 workspace 概念:用户工作所在的目录,包含标题以及属于它的有序会话列表。设计记录:[领域 KV 存储 Agent Note](../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md)。 + +| 包 | 职责 | ctx 键 | +|---|---|---| +| `workspace/` | 位于存储领域形式之上的 `WorkspaceRegistry` 服务:按 realpath 唯一的路径、会话所有权计数、实体缓存 | `ctx.workspace` | + +所有权真相存在 workspace 记录的 `sessionIds`(有序)中,绝不从会话 cwd 派生;`attachSession` 会验证会话头的 cwd 解析到 workspace 路径,因此一个会话在结构上最多属于一个 workspace。本阶段有意不提供删除(workspace 与会话级联);该功能将与会话侧原语一起交付。 diff --git a/packages/workspace/workspace/README.i18n.yaml b/packages/workspace/workspace/README.i18n.yaml new file mode 100644 index 0000000000..b3e0df9280 --- /dev/null +++ b/packages/workspace/workspace/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 0d395ecc58fc5e3362cb5f3c565a0539bb09c4dd +README.zh.md: 017e1e4d3aae9f8708ead3565f8b5b59d9b249ca diff --git a/packages/workspace/workspace/README.md b/packages/workspace/workspace/README.md index b333d90ad0..0d395ecc58 100644 --- a/packages/workspace/workspace/README.md +++ b/packages/workspace/workspace/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-workspace +English | [中文](README.zh.md) + Workspace entity registry (`ctx.workspace`) for the DeepSeek Harness: durable workspace records, stable workspace order, and a newest-first candidate session index stored through the domain data form. Consumers see the `Workspace` interface; the entity implementation stays package-private. The entity/storage rationale lives in the [domain Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md); header-only bootstrap and GUI ordering live in the [Workspace UI product-flow Agent Note](../../../.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md). diff --git a/packages/workspace/workspace/README.zh.md b/packages/workspace/workspace/README.zh.md new file mode 100644 index 0000000000..017e1e4d3a --- /dev/null +++ b/packages/workspace/workspace/README.zh.md @@ -0,0 +1,39 @@ +# @deepseek-ai/dsh-workspace + +[English](README.md) | 中文 + +DeepSeek Harness 的 Workspace 实体注册表(`ctx.workspace`):通过领域数据形式存储持久 workspace 记录、稳定 workspace 顺序和按新到旧排列的候选会话索引。消费方看到 `Workspace` 接口;实体实现保持包(package)私有。 + +实体/存储理由见[领域 Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md);仅头部启动和 GUI 排序见 [Workspace UI 产品流 Agent Note](../../../.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md)。 + +## 形状 + +- `ctx.workspace.create(path, title?)`:规范化 `path` 时使用 `fs.realpath`,拒绝不存在或非目录的路径,每个规范路径最多创建一条记录,并将新记录前置到持久 workspace 顺序。对同一路径重复调用会返回现有 workspace,且不改变其标题;不同路径不能创建重复标题。 +- `ctx.workspace.get(id)`/`list()`/`resolveByPath(path)`:由缓存提供的查找。`list()` 为同步操作,并遵循持久注册表顺序;`resolveByPath` 为异步操作,因为它应用同一 `realpath` 规范,并会拒绝缺失路径,而不是创建路径。 +- `Workspace.attachSession(id)`:对照 workspace 路径验证实时或已持久化的会话头 cwd,并将新 id 前置。未知会话、缺失/无法解析/非目录的 cwd 值和不匹配情况都会在不写入的前提下被拒绝。`detachSession` 只移除候选索引条目。 +- `ctx.workspace.touchSession(id)`:仅将已验证、已记账的会话移到最前。未分组或被过滤的会话为空操作,workspace 顺序绝不改变。 +- `Workspace.sessionIds`:按持久候选顺序提供同步 id 加规范 cwd 成员投影。缺失头部、无效 cwd 值和不匹配情况都被过滤;下一次 workspace 变更会剪除它们。如果同一存储介质将一个会话索引到两个 workspace 下、从两条记录声明同一路径,或偏离持久 workspace 顺序,启动会被拒绝。 +- `Workspace.status()`:未缓存的目录检查,返回 `'ok' | 'missing-dir'`;目录缺失绝不会改动记录。 + +`storageDomain` 和 `sessionPersistence` 是启动必需依赖。对等服务不可用时,插件保持待处理,且不能提交空的已初始化标记。首次成功启动时,注册表调用 `SessionPersistence.list()`,仅使用头部 `id`、`cwd` 和 `createdAt` 对有效历史目录分组并持久化初始顺序;它绝不读取事件正文。已初始化标记最后写入,因此重启后可安全复用部分启动写入。后续仅有 cwd 的会话仍属于 Ungrouped。 + +## 模型体验 + +### Workspace 记录与会话记账 + +#### 模型所见内容 + +没有。`ctx.workspace` 只向宿主侧消费方提供 workspace 记录:此包不注册工具、不注入提示词、不写入会话事件,因此没有请求字段会携带此包数据。 + +#### Token 影响 + +每个请求的直接 token 为零。 + +#### KV Cache 影响 + +与实时请求无关:此包绝不触及请求前缀,因此无法使提供方缓存复用失效。 + +## 已知限制与延后工作 + +- 本阶段没有删除入口:workspace 删除将与会话删除原语和级联编排一起作为完整语义交付(参见 Agent Note 的未来工作一节);系统有意不公开「删除记录、保留会话」的半成品操作。 +- 头部索引会在启动时刷新,也会在 attach 必须解析未缓存持久 id 时刷新;另一进程执行的删除或 cwd 破坏会在下次刷新或重启后被观测。 diff --git a/packages/workspace/workspace/src/entity.ts b/packages/workspace/workspace/src/entity.ts index 7f213db0b8..1df3eacb3d 100644 --- a/packages/workspace/workspace/src/entity.ts +++ b/packages/workspace/workspace/src/entity.ts @@ -15,6 +15,17 @@ import type { WorkspaceRecord } from './spec.ts' import type { Workspace, WorkspaceId } from './types.ts' import { realpathNormalize } from './paths.ts' +/** An insertSessionBefore request named a session or anchor not on the account (storage failures stay plain errors). */ +export class WorkspaceMoveInvalidError extends Error { + /** + * @param message - Which id was unaccounted and where. + */ + constructor(message: string) { + super(message) + this.name = 'WorkspaceMoveInvalidError' + } +} + /** * The registry-owned machinery an entity mutates through. Entities never see * the registry itself — only the open table, the canonical session-path @@ -137,30 +148,27 @@ export class WorkspaceEntity implements Workspace { : { ...record, sessionIds: [sessionId, ...record.sessionIds] }) } - /** - * Test the durable candidate account without applying header projection. - * @param sessionId - Candidate session id. - * @returns whether this workspace's stored account contains the id. - */ - hasSession(sessionId: SessionId): boolean { - return this.record.sessionIds.includes(sessionId) - } - - /** - * Move one validated accounted session to the front without touching peers. - * @param sessionId - Accounted session whose activity was observed. - */ - async touchSession(sessionId: SessionId): Promise<void> { - if ( - this.host.sessionPath(sessionId) !== this.record.path - || this.record.sessionIds[0] === sessionId - ) return - await this.mutate(record => !record.sessionIds.includes(sessionId) || record.sessionIds[0] === sessionId - ? record - : { - ...record, - sessionIds: [sessionId, ...record.sessionIds.filter(id => id !== sessionId)], - }) + async insertSessionBefore(sessionId: SessionId, beforeSessionId?: SessionId): Promise<void> { + await this.mutate((record) => { + if (!record.sessionIds.includes(sessionId)) { + throw new WorkspaceMoveInvalidError( + `cannot move session '${sessionId}' in workspace '${record.path}': the session is not accounted`, + ) + } + if (beforeSessionId !== undefined && !record.sessionIds.includes(beforeSessionId)) { + throw new WorkspaceMoveInvalidError( + `cannot move session '${sessionId}' before '${beforeSessionId}' in workspace '${record.path}': ` + + 'the anchor session is not accounted', + ) + } + if (beforeSessionId === sessionId) return record + const without = record.sessionIds.filter(id => id !== sessionId) + const at = beforeSessionId === undefined ? without.length : without.indexOf(beforeSessionId) + const sessionIds = [...without.slice(0, at), sessionId, ...without.slice(at)] + return sessionIds.every((id, index) => id === record.sessionIds[index]) + ? record + : { ...record, sessionIds } + }) } async detachSession(sessionId: SessionId): Promise<void> { diff --git a/packages/workspace/workspace/src/index.ts b/packages/workspace/workspace/src/index.ts index c20c2143c6..0f849e7374 100644 --- a/packages/workspace/workspace/src/index.ts +++ b/packages/workspace/workspace/src/index.ts @@ -14,6 +14,8 @@ import type {} from '@deepseek-ai/dsh-session-persistence' import type { DomainGlobal, KvTable } from '@deepseek-ai/dsh-storage-domain' import { WorkspaceEntity } from './entity.ts' import type { WorkspaceEntityHost } from './entity.ts' + +export { WorkspaceMoveInvalidError } from './entity.ts' import { realpathNormalize } from './paths.ts' import { workspaceDomainSpec } from './spec.ts' import type { WorkspaceDomainState, WorkspaceRecord } from './spec.ts' @@ -47,6 +49,7 @@ export class WorkspaceNameConflictError extends Error { } } + declare module 'cordis' { interface Context { workspace: WorkspaceRegistry @@ -82,7 +85,6 @@ export class WorkspaceRegistry extends Service { private readonly headers = new Map<SessionId, SessionHeader>() private readonly sessionPaths = new Map<SessionId, string>() private readonly invalidSessionPaths = new Map<SessionId, string>() - private readonly pendingTouches = new Map<SessionId, Promise<void>>() private operationTail: Promise<void> = Promise.resolve() private readonly host: WorkspaceEntityHost = { @@ -120,13 +122,6 @@ export class WorkspaceRegistry extends Service { this.validateStoredState(this.requireState()) this.rebuildEntities() this.reportFilteredCandidates() - // Session activity is authoritative even when no RPC/SSE consumer is - // connected. This service-owned listener is disposed with the registry. - this.ctx.on('session/event', (session) => { - void this.touchSession(session.id).catch((error: unknown) => { - this.ctx.logger.warn(`workspace activity touch failed for session '${session.id}': ${String(error)}`) - }) - }) } /** @@ -173,32 +168,6 @@ export class WorkspaceRegistry extends Service { }) } - /** - * Move one accounted, cwd-validated session to the front of its workspace. - * Ungrouped sessions and candidates filtered by the header check are - * no-ops. The owning workspace's relative position never changes. - * @param sessionId - Session whose activity was observed. - * @returns resolution after the possible record write. - */ - async touchSession(sessionId: SessionId): Promise<void> { - const pending = this.pendingTouches.get(sessionId) - if (pending !== undefined) { - await pending - return - } - for (const entity of this.entities.values()) { - if (!entity.hasSession(sessionId)) continue - const touch = entity.touchSession(sessionId) - this.pendingTouches.set(sessionId, touch) - try { - await touch - } finally { - this.pendingTouches.delete(sessionId) - } - return - } - } - /** * Resolve by canonical directory path without creating or mutating a * workspace. A missing path rejects during `realpath`; an existing unowned diff --git a/packages/workspace/workspace/src/types.ts b/packages/workspace/workspace/src/types.ts index ca254ca2cb..09d37213cc 100644 --- a/packages/workspace/workspace/src/types.ts +++ b/packages/workspace/workspace/src/types.ts @@ -41,10 +41,12 @@ export interface Workspace { readonly updatedAt: string /** - * Header-validated sessions in newest-first display order. The durable - * candidate account is filtered synchronously: missing headers, invalid - * cwd values, and canonical cwd mismatches are never returned. A subsequent - * workspace mutation prunes those filtered candidates durably. + * Header-validated sessions in manually owned order: a new session is + * prepended at attach, explicit reordering goes through + * `insertSessionBefore`, and activity never reorders. The durable candidate + * account is filtered synchronously: missing headers, invalid cwd values, + * and canonical cwd mismatches are never returned. A subsequent workspace + * mutation prunes those filtered candidates durably. */ readonly sessionIds: readonly SessionId[] @@ -57,8 +59,7 @@ export interface Workspace { /** * Prepend a session to this workspace's candidate account. An already - * accounted id resolves without writing; activity-driven reordering uses - * `WorkspaceRegistry.touchSession` instead. A new id's live or persisted + * accounted id resolves without writing. A new id's live or persisted * header cwd must resolve to an existing directory equal to {@link path}; * unknown ids, missing or invalid cwd values, and mismatches reject without * writing. @@ -67,6 +68,18 @@ export interface Workspace { */ attachSession(sessionId: SessionId): Promise<void> + /** + * Move an accounted session within the manual order, DOM-insertBefore-like: + * with an anchor the session lands before it, without one it appends to the + * end. Only the moved id changes position. A session or anchor absent from + * the account rejects without writing; a move to the current position + * resolves without writing (decided on the domain write chain). + * @param sessionId - The accounted session to move. + * @param beforeSessionId - Accounted anchor to insert before; omitted appends. + * @returns resolution after durability. + */ + insertSessionBefore(sessionId: SessionId, beforeSessionId?: SessionId): Promise<void> + /** * Remove a session from this workspace's account. Idempotent: an id not on * the account resolves without writing (decided on the domain write chain, diff --git a/packages/workspace/workspace/tests/workspace.spec.ts b/packages/workspace/workspace/tests/workspace.spec.ts index cde0b35098..8ce70cc7d5 100644 --- a/packages/workspace/workspace/tests/workspace.spec.ts +++ b/packages/workspace/workspace/tests/workspace.spec.ts @@ -10,8 +10,7 @@ import type { DomainChanged } from '@deepseek-ai/dsh-storage-domain' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionHeader } from '@deepseek-ai/dsh-session' import { MemoryMediaPool, MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts' -import { WorkspaceEntity } from '../src/entity.ts' -import WorkspaceRegistry, { WorkspaceId, WorkspaceNameConflictError } from '../src/index.ts' +import WorkspaceRegistry, { WorkspaceId, WorkspaceMoveInvalidError, WorkspaceNameConflictError } from '../src/index.ts' import type { WorkspaceDomainState, WorkspaceRecord } from '../src/index.ts' const DOMAIN_VERSION = 2 @@ -434,13 +433,12 @@ describe('WorkspaceRegistry create and lookup', () => { }) describe('Workspace session ordering', () => { - it('prepends new attaches, keeps repeat attach idempotent, and touches one id only', async () => { + it('prepends new attaches and keeps repeat attach idempotent', async () => { const dir = await makeDir('attach-order') const result = await harness() result.setSessions([ header('s1', dir, 1), header('s2', dir, 2), - header('ungrouped', dir, 3), ]) const workspace = await result.registry.create(dir) await workspace.attachSession(SessionId('s1')) @@ -448,59 +446,57 @@ describe('Workspace session ordering', () => { expect(workspace.sessionIds).toEqual(['s2', 's1']) await workspace.attachSession(SessionId('s1')) expect(workspace.sessionIds).toEqual(['s2', 's1']) - - const beforeTouch = result.changes.length - await Promise.all([ - result.registry.touchSession(SessionId('s1')), - result.registry.touchSession(SessionId('s1')), - ]) - expect(workspace.sessionIds).toEqual(['s1', 's2']) - expect(result.changes).toHaveLength(beforeTouch + 1) - await result.registry.touchSession(SessionId('s1')) - expect(result.changes).toHaveLength(beforeTouch + 1) - await result.registry.touchSession(SessionId('ungrouped')) - expect(result.changes).toHaveLength(beforeTouch + 1) - expect(storedRecord(result.pool, workspace.id).sessionIds).toEqual(['s1', 's2']) + expect(storedRecord(result.pool, workspace.id).sessionIds).toEqual(['s2', 's1']) }) - it('does not resurrect a session detached before its queued touch', async () => { - const dir = await makeDir('detach-touch-race') - const result = await harness({ sessions: [header('s1', dir), header('s2', dir)] }) + it('moves one id before an anchor or to the end, durably', async () => { + const dir = await makeDir('insert-before') + const result = await harness() + result.setSessions([header('s1', dir, 1), header('s2', dir, 2), header('s3', dir, 3)]) + const workspace = await result.registry.create(dir) + await workspace.attachSession(SessionId('s1')) + await workspace.attachSession(SessionId('s2')) + await workspace.attachSession(SessionId('s3')) + expect(workspace.sessionIds).toEqual(['s3', 's2', 's1']) + + await workspace.insertSessionBefore(SessionId('s1'), SessionId('s2')) + expect(workspace.sessionIds).toEqual(['s3', 's1', 's2']) + await workspace.insertSessionBefore(SessionId('s3')) + expect(workspace.sessionIds).toEqual(['s1', 's2', 's3']) + expect(storedRecord(result.pool, workspace.id).sessionIds).toEqual(['s1', 's2', 's3']) + }) + + it('treats self-anchored and already-in-place moves as no-ops without writing', async () => { + const dir = await makeDir('insert-noop') + const result = await harness() + result.setSessions([header('s1', dir, 1), header('s2', dir, 2)]) const workspace = await result.registry.create(dir) await workspace.attachSession(SessionId('s1')) await workspace.attachSession(SessionId('s2')) - await Promise.all([ - workspace.detachSession(SessionId('s1')), - result.registry.touchSession(SessionId('s1')), - ]) const written = result.changes.length + + await workspace.insertSessionBefore(SessionId('s1'), SessionId('s1')) + await workspace.insertSessionBefore(SessionId('s2'), SessionId('s1')) + await workspace.insertSessionBefore(SessionId('s1')) await workspace.detachSession(SessionId('absent')) expect(result.changes).toHaveLength(written) - expect(workspace.sessionIds).toEqual(['s2']) + expect(workspace.sessionIds).toEqual(['s2', 's1']) }) - it('does not reinsert a candidate absent at the durable touch slot', async () => { - const dir = await makeDir('stale-touch') - const id = WorkspaceId('00000000-0000-4000-8000-000000000030') - let durable = record(dir, ['s2', 's1']) - const table = { - update: async ( - _id: WorkspaceId, - update: (current: WorkspaceRecord) => WorkspaceRecord, - ): Promise<WorkspaceRecord> => { - durable = { ...durable, sessionIds: [SessionId('s2')] } - durable = update(durable) - return durable - }, - } - const entity = new WorkspaceEntity({ - table: () => table as never, - sessionPath: () => dir, - readSessionHeader: async () => header('s1', dir), - rememberSessionPath: () => {}, - }, id, record(dir, ['s2', 's1'])) - await entity.touchSession(SessionId('s1')) - expect(durable.sessionIds).toEqual(['s2']) + it('rejects moves naming an unaccounted session or anchor', async () => { + const dir = await makeDir('insert-invalid') + const result = await harness() + result.setSessions([header('s1', dir, 1)]) + const workspace = await result.registry.create(dir) + await workspace.attachSession(SessionId('s1')) + const written = result.changes.length + + await expect(workspace.insertSessionBefore(SessionId('ghost'))) + .rejects.toBeInstanceOf(WorkspaceMoveInvalidError) + await expect(workspace.insertSessionBefore(SessionId('s1'), SessionId('ghost'))) + .rejects.toThrow(/anchor session is not accounted/) + expect(result.changes).toHaveLength(written) + expect(workspace.sessionIds).toEqual(['s1']) }) it('validates a lazy live session without requiring it in persistence.list()', async () => { @@ -546,66 +542,6 @@ describe('Workspace session ordering', () => { expect(workspace.sessionIds).toEqual(['s1']) }) - it('keeps workspace order stable while touch order survives reload', async () => { - const older = await makeDir('stable-older') - const newer = await makeDir('stable-newer') - const sessions = [ - header('old-1', older, 100), - header('old-2', older, 200), - header('new-1', newer, 300), - ] - const pool = new MemoryMediaPool() - const first = await harness({ pool, sessions }) - const originalWorkspaceIds = first.registry.list().map(workspace => workspace.id) - const oldWorkspace = first.registry.list().find(workspace => workspace.path === older)! - expect(oldWorkspace.sessionIds).toEqual(['old-2', 'old-1']) - await first.registry.touchSession(SessionId('old-1')) - expect(oldWorkspace.sessionIds).toEqual(['old-1', 'old-2']) - expect(first.registry.list().map(workspace => workspace.id)).toEqual(originalWorkspaceIds) - await first.fiber.dispose() - - const reloaded = await harness({ pool, sessions }) - expect(reloaded.registry.list().map(workspace => workspace.id)).toEqual(originalWorkspaceIds) - expect(reloaded.registry.list().find(workspace => workspace.path === older)!.sessionIds) - .toEqual(['old-1', 'old-2']) - }) - - it('persists activity order from session/event without any stream consumer', async () => { - const dir = await makeDir('event-touch') - const result = await harness({ sessionStore: true }) - const workspace = await result.registry.create(dir) - const first = result.ctx.sessions.create(SessionId('event-first'), { meta: { cwd: dir } }) - result.ctx.sessions.create(SessionId('event-second'), { meta: { cwd: dir } }) - await workspace.attachSession(SessionId('event-first')) - await workspace.attachSession(SessionId('event-second')) - expect(workspace.sessionIds).toEqual(['event-second', 'event-first']) - - first.append('turn/start', { - turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, - }) - await vi.waitFor(() => { expect(workspace.sessionIds).toEqual(['event-first', 'event-second']) }) - expect(storedRecord(result.pool, workspace.id).sessionIds) - .toEqual(['event-first', 'event-second']) - }) - - it('contains a background activity write failure at the service listener', async () => { - const dir = await makeDir('event-touch-failure') - const result = await harness({ sessionStore: true }) - const workspace = await result.registry.create(dir) - const first = result.ctx.sessions.create(SessionId('failed-first'), { meta: { cwd: dir } }) - result.ctx.sessions.create(SessionId('failed-second'), { meta: { cwd: dir } }) - await workspace.attachSession(SessionId('failed-first')) - await workspace.attachSession(SessionId('failed-second')) - const warn = vi.spyOn(result.ctx.logger, 'warn') - result.pool.failNextWrites = 1 - first.append('turn/start', { - turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, - }) - await vi.waitFor(() => { expect(warn).toHaveBeenCalledWith(expect.stringContaining('touch failed')) }) - expect(workspace.sessionIds).toEqual(['failed-second', 'failed-first']) - }) }) describe('header-validated membership projection', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24164d7fb5..76f8f81321 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -125,9 +125,9 @@ importers: '@deepseek-ai/dsh-client-hmr': specifier: workspace:^ version: link:../../packages/client/hmr - '@deepseek-ai/dsh-client-i18n': + '@deepseek-ai/dsh-client-locale': specifier: workspace:^ - version: link:../../packages/client/i18n + version: link:../../packages/client/locale '@deepseek-ai/dsh-client-modules': specifier: workspace:^ version: link:../../packages/client/modules @@ -140,9 +140,18 @@ importers: '@deepseek-ai/dsh-client-ui-layout': specifier: workspace:^ version: link:../../packages/client/ui-layout + '@deepseek-ai/dsh-client-ui-models': + specifier: workspace:^ + version: link:../../packages/client/ui-models '@deepseek-ai/dsh-client-ui-question': specifier: workspace:^ version: link:../../packages/client/ui-question + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../../packages/client/ui-settings + '@deepseek-ai/dsh-client-ui-settings-general': + specifier: workspace:^ + version: link:../../packages/client/ui-settings-general '@deepseek-ai/dsh-client-ui-sidebar': specifier: workspace:^ version: link:../../packages/client/ui-sidebar @@ -749,18 +758,29 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) - packages/client/i18n: - dependencies: + packages/client/locale: + devDependencies: '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime - devDependencies: + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + react: + specifier: ^18.2.0 + version: 18.3.1 packages/client/modules: devDependencies: @@ -847,12 +867,45 @@ importers: packages/client/ui-layout: devDependencies: + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-client-ui-theme': + specifier: workspace:^ + version: link:../ui-theme + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + react: + specifier: ^18.2.0 + version: 18.3.1 + + packages/client/ui-models: + devDependencies: + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../ui-settings + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -946,6 +999,67 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/client/ui-settings: + dependencies: + clsx: + specifier: ^2.0.0 + version: 2.1.1 + devDependencies: + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-sidebar': + specifier: workspace:^ + version: link:../ui-sidebar + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + react: + specifier: ^18.2.0 + version: 18.3.1 + + packages/client/ui-settings-general: + devDependencies: + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../ui-settings + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + react: + specifier: ^18.2.0 + version: 18.3.1 + packages/client/ui-sidebar: dependencies: clsx: @@ -990,13 +1104,35 @@ importers: version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/client/ui-theme: + dependencies: + clsx: + specifier: ^2.0.0 + version: 2.1.1 devDependencies: + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + react: + specifier: ^18.2.0 + version: 18.3.1 packages/client/ui-trajectory: devDependencies: @@ -1023,6 +1159,10 @@ importers: version: 18.3.1 packages/client/ui-workspace: + dependencies: + clsx: + specifier: ^2.0.0 + version: 2.1.1 devDependencies: '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ diff --git a/scripts/clean.spec.ts b/scripts/clean.spec.ts new file mode 100644 index 0000000000..0d3aced888 --- /dev/null +++ b/scripts/clean.spec.ts @@ -0,0 +1,62 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' + +import { RepositoryCleaner } from './clean.ts' + +const roots: string[] = [] + +function fixture(): string { + const root = mkdtempSync(join(tmpdir(), 'dsh-clean-')) + roots.push(root) + return root +} + +function write(path: string, content = ''): void { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, content) +} + +function addProject(root: string, path: string): void { + write(join(root, 'tsconfig.json'), JSON.stringify({ files: [], references: [{ path }] })) + write(join(root, path, 'tsconfig.json'), JSON.stringify({ + compilerOptions: { composite: true, outDir: 'lib/types' }, + include: ['src'], + })) + write(join(root, path, 'src/index.ts'), 'export {}\n') +} + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +describe('RepositoryCleaner', () => { + it('derives live build outputs from project references and removes safe stale package residue', async () => { + const root = fixture() + addProject(root, 'products/shell') + write(join(root, 'products/shell/lib/types/index.js')) + write(join(root, 'products/shell/lib/index.js')) + write(join(root, '.typecheck/legacy.tsbuildinfo')) + write(join(root, 'root.tsbuildinfo')) + write(join(root, 'packages/removed/ghost/node_modules/.bin/tool')) + + await new RepositoryCleaner(root).clean() + + expect(existsSync(join(root, 'products/shell/lib'))).toBe(false) + expect(existsSync(join(root, 'products/shell/src/index.ts'))).toBe(true) + expect(existsSync(join(root, '.typecheck'))).toBe(false) + expect(existsSync(join(root, 'root.tsbuildinfo'))).toBe(false) + expect(existsSync(join(root, 'packages/removed/ghost'))).toBe(false) + }) + + it('does not delete any target when a manifest-less package contains an unknown file', async () => { + const root = fixture() + addProject(root, 'products/shell') + write(join(root, 'products/shell/lib/types/index.js')) + write(join(root, 'packages/removed/ghost/notes.txt')) + + await expect(new RepositoryCleaner(root).clean()).rejects.toThrow('packages/removed/ghost/notes.txt') + expect(existsSync(join(root, 'products/shell/lib'))).toBe(true) + }) +}) diff --git a/scripts/clean.ts b/scripts/clean.ts new file mode 100644 index 0000000000..3cb3820db7 --- /dev/null +++ b/scripts/clean.ts @@ -0,0 +1,165 @@ +import { lstat, readdir, rm } from 'node:fs/promises' +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' +import { fileURLToPath } from 'node:url' +import ts from 'typescript' +import { repositoryConfigHost } from './ts-project.ts' + +const knownOrphanEntries = new Set(['node_modules', 'lib', '.typecheck']) + +function isMissing(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' +} + +async function exists(path: string): Promise<boolean> { + try { + await lstat(path) + return true + } catch (error) { + if (isMissing(error)) return false + throw error + } +} + +async function childDirectories(path: string): Promise<string[]> { + try { + const entries = await readdir(path, { withFileTypes: true }) + return entries.filter(entry => entry.isDirectory()).map(entry => join(path, entry.name)) + } catch (error) { + if (isMissing(error)) return [] + throw error + } +} + +function repositoryPath(root: string, path: string): string { + return relative(root, path).split(sep).join('/') +} + +function parseConfig(configPath: string): ts.ParsedCommandLine { + const parsed = ts.getParsedCommandLineOfConfigFile(configPath, {}, repositoryConfigHost) + if (!parsed) throw new Error(`clean: cannot parse TypeScript config ${configPath}`) + if (parsed.errors.length > 0) { + throw new Error(parsed.errors.map(error => ts.flattenDiagnosticMessageText(error.messageText, '\n')).join('\n')) + } + return parsed +} + +/** Plans and removes repository-owned build output without crossing the repository boundary. */ +export class RepositoryCleaner { + constructor(private readonly root: string) {} + + /** + * Remove generated build state and package directories containing only known residue. + * @returns Repository-relative paths that were removed. + */ + async clean(): Promise<string[]> { + const targets = await this.plan() + // Planning validates every target first, so an unsafe orphan prevents all deletion. + for (const target of targets) await rm(target, { recursive: true, force: true }) + return targets.map(target => repositoryPath(this.root, target)) + } + + private async plan(): Promise<string[]> { + const targets = new Set<string>() + const unsafeOrphans: string[] = [] + + // These checks cover legacy root-level incremental state emitted by older configs. + await this.addIfPresent(targets, join(this.root, '.typecheck')) + for (const entry of await readdir(this.root, { withFileTypes: true })) { + if (entry.isFile() && entry.name.endsWith('.tsbuildinfo')) targets.add(join(this.root, entry.name)) + } + + // The root project-reference graph is the source of truth for live build targets. + // Each emitting project declares lib/types as outDir; its parent lib also owns + // the sibling runtime bundles, so the complete build output root is removed. + for (const outputDirectory of this.buildOutputDirectories()) { + await this.addIfPresent(targets, outputDirectory) + } + + for (const groupDirectory of await childDirectories(join(this.root, 'packages'))) { + for (const packageDirectory of await childDirectories(groupDirectory)) { + // A package.json marks a live package; its output was discovered from the + // project graph above, and its package-local node_modules must be preserved. + if (await exists(join(packageDirectory, 'package.json'))) { + continue + } + + // A manifest-less package directory is stale only when every remaining + // entry is known generated residue; unknown files make the whole clean fail. + const entries = await readdir(packageDirectory) + const unknown = entries.filter(entry => !knownOrphanEntries.has(entry) && !entry.endsWith('.tsbuildinfo')) + if (unknown.length > 0) { + unsafeOrphans.push(...unknown.map(entry => repositoryPath(this.root, join(packageDirectory, entry)))) + } else { + targets.add(packageDirectory) + } + } + } + + if (unsafeOrphans.length > 0) { + throw new Error([ + 'clean: refusing to remove package directories without package.json; unknown entries remain:', + ...unsafeOrphans.sort().map(path => ` ${path}`), + ].join('\n')) + } + + return [...targets].sort() + } + + private buildOutputDirectories(): string[] { + const outputs = new Set<string>() + const pending = [join(this.root, 'tsconfig.json')] + const visited = new Set<string>() + + while (pending.length > 0) { + const nextConfigPath = pending.pop() + if (nextConfigPath === undefined) break + const configPath = resolve(nextConfigPath) + if (visited.has(configPath)) continue + visited.add(configPath) + + const parsed = parseConfig(configPath) + if (parsed.options.outDir !== undefined) { + const typesDirectory = resolve(parsed.options.outDir) + if (basename(typesDirectory) !== 'types') { + throw new Error(`clean: expected TypeScript outDir to end in /types: ${repositoryPath(this.root, typesDirectory)}`) + } + const outputDirectory = dirname(typesDirectory) + this.assertRepositoryTarget(outputDirectory) + outputs.add(outputDirectory) + } + + for (const reference of parsed.projectReferences ?? []) { + pending.push(ts.resolveProjectReferencePath(reference)) + } + } + + return [...outputs] + } + + private assertRepositoryTarget(path: string): void { + const repositoryRelative = relative(this.root, path) + if (repositoryRelative === '' || repositoryRelative === '..' || repositoryRelative.startsWith(`..${sep}`) || isAbsolute(repositoryRelative)) { + throw new Error(`clean: refusing build output outside repository: ${path}`) + } + } + + private async addIfPresent(targets: Set<string>, path: string): Promise<void> { + // Missing outputs are normal on a clean checkout; only existing paths become deletion targets. + if (await exists(path)) targets.add(path) + } +} + +const scriptPath = fileURLToPath(import.meta.url) +if (process.argv[1] !== undefined && resolve(process.argv[1]) === scriptPath) { + try { + const removed = await new RepositoryCleaner(resolve(dirname(scriptPath), '..')).clean() + if (removed.length === 0) { + console.log('clean: already clean') + } else { + console.log(`clean: removed ${removed.length} paths`) + } + } catch (error) { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 + } +} diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index c2fee63c70..3d0ce17051 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -7,5 +7,5 @@ "docs/testing.md": 1100, "examples/AGENTS.md": 310, "packages/AGENTS.md": 660, - "packages/README.md": 790 + "packages/README.md": 835 } diff --git a/scripts/doc-typecheck.ts b/scripts/doc-typecheck.ts index 9a9e007758..69b9d67411 100644 --- a/scripts/doc-typecheck.ts +++ b/scripts/doc-typecheck.ts @@ -1,7 +1,8 @@ /** * Typecheck Markdown `ts` fences against the workspace API. `ignore-check` fences are reported as * opt-outs; generated catalog fragments and source-equivalence blocks are skipped here because their - * owning gates verify them. A build-coordinated mode consumes existing declarations without emit. + * owning gates verify them. Byte-identical `.zh.md` copies reuse their unsuffixed sibling's check. A + * build-coordinated mode consumes existing declarations without emit. */ import { execFileSync } from 'node:child_process' @@ -10,6 +11,7 @@ import { join, relative, resolve } from 'node:path' import ts from 'typescript' import { builtDeclarationPath } from './doc-typecheck-paths.ts' import { extractFences } from './md-fences.ts' +import { partitionPairedMarkdownDerivatives } from './paired-markdown-derivatives.ts' const root = resolve(import.meta.dirname, '..') @@ -206,7 +208,12 @@ for (const pattern of markdownGlobs) { } files.sort() -const all = files.flatMap(extractBlocks) +const extracted = files.flatMap(extractBlocks) +const { primary: all, derivatives } = partitionPairedMarkdownDerivatives( + extracted, + block => block.file, + block => `${block.kind}\0${block.code}`, +) const checked = all.filter(b => b.kind === 'check') const ignored = all.filter(b => b.kind === 'ignore') // Only compile-eligible fences belong in the opt-out ratio; every other skipped @@ -233,7 +240,7 @@ if (compilationError !== undefined) { const ratio = ignored.length / ratioDenominator const skipped = all.length - ratioDenominator -console.log(`doc-typecheck: ${checked.length} block(s) compiled, ${ignored.length} ignored (${(ratio * 100).toFixed(0)}% opt-out), ${skipped} type-equiv/catalog (checked elsewhere).`) +console.log(`doc-typecheck: ${checked.length} block(s) compiled, ${ignored.length} ignored (${(ratio * 100).toFixed(0)}% opt-out), ${skipped} type-equiv/catalog (checked elsewhere), ${derivatives.length} paired derivative(s).`) // Guard against the escape hatch becoming the norm. if (ratioDenominator >= 4 && ratio > 0.5) { console.error(`doc-typecheck: too many blocks opt out of checking (${ignored.length}/${ratioDenominator}). Make them compile or delete them.`) diff --git a/scripts/paired-markdown-derivatives.spec.ts b/scripts/paired-markdown-derivatives.spec.ts new file mode 100644 index 0000000000..f5882e007f --- /dev/null +++ b/scripts/paired-markdown-derivatives.spec.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { partitionPairedMarkdownDerivatives } from './paired-markdown-derivatives.ts' + +interface Block { + doc: string + kind: string + code: string +} + +const partition = (blocks: Block[]) => partitionPairedMarkdownDerivatives( + blocks, + block => block.doc, + block => `${block.kind}\0${block.code}`, +) + +describe('partitionPairedMarkdownDerivatives', () => { + it('treats a complete byte-identical Chinese sequence as derivative', () => { + const english = [ + { doc: 'docs/example.md', kind: 'ts', code: 'const one = 1' }, + { doc: 'docs/example.md', kind: 'type-equiv', code: 'interface Example {}' }, + ] + const chinese = english.map(block => ({ ...block, doc: 'docs/example.zh.md' })) + const unrelated = { doc: 'docs/other.md', kind: 'ts', code: 'const other = 2' } + + expect(partition([...english, ...chinese, unrelated])).toEqual({ + primary: [...english, unrelated], + derivatives: chinese, + }) + }) + + it('keeps reordered, changed, partial, and orphan Chinese sequences primary', () => { + const sequence = (doc: string) => [ + { doc, kind: 'ts', code: 'const one = 1' }, + { doc, kind: 'ts', code: 'const two = 2' }, + ] + const english = sequence('docs/example.md') + const changed = english.map((block, index) => ({ + ...block, + doc: 'docs/example.zh.md', + code: index === 0 ? 'const one = 0' : block.code, + })) + const reorderedEnglish = sequence('docs/reordered.md') + const reordered = [...reorderedEnglish].reverse().map(block => ({ ...block, doc: 'docs/reordered.zh.md' })) + const partialEnglish = sequence('docs/partial.md') + const partial = [{ ...partialEnglish[0]!, doc: 'docs/partial.zh.md' }] + const orphan = [{ doc: 'docs/orphan.zh.md', kind: 'ts', code: 'const orphan = true' }] + const blocks = [ + ...english, + ...changed, + ...reorderedEnglish, + ...reordered, + ...partialEnglish, + ...partial, + ...orphan, + ] + + expect(partition(blocks)).toEqual({ primary: blocks, derivatives: [] }) + }) + + it('requires the fence kind to match as well as the body', () => { + const english = { doc: 'docs/example.md', kind: 'type-equiv', code: 'interface Example {}' } + const chinese = { ...english, doc: 'docs/example.zh.md', kind: 'public-api' } + + expect(partition([english, chinese])).toEqual({ primary: [english, chinese], derivatives: [] }) + }) +}) diff --git a/scripts/paired-markdown-derivatives.ts b/scripts/paired-markdown-derivatives.ts new file mode 100644 index 0000000000..ead0c06923 --- /dev/null +++ b/scripts/paired-markdown-derivatives.ts @@ -0,0 +1,63 @@ +/** + * Separate byte-identical Chinese Markdown code blocks from the primary checks + * performed on their unsuffixed English siblings. The bilingual pairing gate + * owns cross-language identity; source-oriented gates consume one copy. + */ + +/** The result of separating canonical blocks from paired Chinese derivatives. */ +export interface MarkdownDerivativePartition<T> { + /** Blocks that still require the caller's owning check. */ + primary: T[] + /** Chinese blocks covered by the byte-identical unsuffixed sequence. */ + derivatives: T[] +} + +/** Return the unsuffixed sibling of a Chinese Markdown path. */ +function unsuffixedSibling(doc: string): string | null { + return doc.endsWith('.zh.md') ? `${doc.slice(0, -'.zh.md'.length)}.md` : null +} + +/** + * Partition complete byte-identical `.zh.md` block sequences from primary + * blocks. A partial or reordered match stays primary so the caller fails + * closed; the translation-pairing gate reports the cross-language mismatch. + * + * @param blocks - Blocks in repository scan order. + * @param docOf - Repository-relative Markdown path owning a block. + * @param fingerprintOf - Block kind/info string plus byte-exact body. + * @returns Primary blocks and paired Chinese derivatives, preserving order. + */ +export function partitionPairedMarkdownDerivatives<T>( + blocks: readonly T[], + docOf: (block: T) => string, + fingerprintOf: (block: T) => string, +): MarkdownDerivativePartition<T> { + const byDoc = new Map<string, T[]>() + for (const block of blocks) { + const doc = docOf(block) + const group = byDoc.get(doc) + if (group) group.push(block) + else byDoc.set(doc, [block]) + } + + const derivativeDocs = new Set<string>() + for (const [doc, candidates] of byDoc) { + const sibling = unsuffixedSibling(doc) + if (sibling === null) continue + const originals = byDoc.get(sibling) + if (originals === undefined || originals.length !== candidates.length) continue + if (candidates.every((candidate, index) => { + const original = originals[index] + return original !== undefined && fingerprintOf(candidate) === fingerprintOf(original) + })) { + derivativeDocs.add(doc) + } + } + + const primary: T[] = [] + const derivatives: T[] = [] + for (const block of blocks) { + (derivativeDocs.has(docOf(block)) ? derivatives : primary).push(block) + } + return { primary, derivatives } +} diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index fd9e0bec36..9410fd3767 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -4,7 +4,7 @@ "messages": [ { "role": "system", - "content": "# Translation Prompt\n\nYou are a senior technical translator specializing in LLM and agent development documentation. Your task is to translate the given source document from English to Chinese, producing natural, professional technical prose.\n\n## Quality Requirements\n\n### Structure and Format Preservation\n- Output a complete translated document that maintains exactly the same structure as the source: heading hierarchy, list shape, table columns, link targets, and code blocks.\n- Fenced code blocks must be byte-identical to the source, including ALL comments inside them. Do NOT translate comments inside code blocks. This is a hard rule with no exceptions.\n- Inline code spans (commands, flags, paths, API names, version numbers) must be kept verbatim. Never translate or reformat them.\n- Every relative link must point to the same target as in the source. Link text is translated; link targets are not.\n- Language switcher line: when translating into Chinese, write `[English](source-filename.md) | 中文`. When translating into English, write `English | [中文](source-filename.zh.md)`. Do NOT copy the switcher line from the source file unchanged — you must flip the link direction.\n- After a closing bold marker `**`, insert a space before the next character when that character is a Latin letter, digit, or CJK ideograph. Never insert a space before any punctuation (full-width or half-width).\n\n### Tone and Style\n- The translation must read as if originally written in the target language by a native speaker. If an expression sounds like a word-for-word rendering from the source language, rephrase it.\n- Write in a professional, formal tone appropriate for developer documentation. Never use colloquial or casual expressions.\n- Use polite imperative forms where the text instructs the reader to do something.\n- Keep the author's register: concise stays concise, detailed stays detailed.\n\n### Sentence Structure\n- Break long sentences with commas or semicolons. Avoid run-on sentences.\n- Prefer active voice. Convert passive constructions to active if it reads more naturally.\n- Translate meaning, not words. Restructure sentences where the target language grammar requires it.\n- Do not invent words or expressions that do not exist in natural technical writing of the target language.\n\n### Word Choice\n- Prefer precise, formal vocabulary over casual or colloquial alternatives.\n- When multiple synonyms exist, choose the one most commonly used in professional technical documentation of the target language.\n- Avoid slang, internal jargon, or overly literal translations that would not be recognized by the general developer audience.\n- Do not use the same word to translate two different source-language terms that carry distinct meanings.\n- Avoid repeating the same verb in close proximity; vary word choice for readability.\n\n#### When translating into Chinese\n- When a number modifies a noun, always include a Chinese classifier or measure word (量词). For example: \"three-package seam\" → \"由三个包构成的 seam\", not \"三包 seam\".\n\n### Punctuation\n\n#### When translating into Chinese\n- Use full-width Chinese punctuation in prose: `,。:;?!()「」`.\n- Strongly prefer replacing all em-dashes (——) with colons, periods, commas, or parentheses. Keep an em-dash only if no other punctuation works at all.\n- Use enumeration commas (、) between parallel items, not regular commas.\n- List item endings: use semicolons or no punctuation. Do not end list items with commas.\n- Put one half-width space between Chinese text and Latin words/numbers.\n- For RFC 2119 keywords (MUST, MUST NOT, SHOULD, MAY), translate to the corresponding Chinese term (必须、禁止、应当、可以) and keep the SOURCE emphasis marker: plain source stays plain (必须), italic source stays italic (*必须*), and bold source stays bold (**必须**).\n\n#### When translating into English\n(To be added.)\n\n## Terminology\n\nA terminology table is provided below. Follow it strictly:\n- Render every listed term exactly as specified.\n- When the target language is Chinese, use the \"中文\" column. On first occurrence, write the \"首次出现\" value with its parenthetical gloss; on subsequent occurrences, write only the part before the parentheses.\n- When the target language is English, use the \"English\" column without a Chinese gloss; do not copy the \"中文\" or \"首次出现\" value into English prose.\n- If a term has already been glossed as part of a compound term, do not gloss it again when it appears alone later.\n- NEVER use translations listed in the \"不要译作\" column.\n- For technical terms not in the table, follow the target language: for a Chinese target, use an established Chinese rendering from a major Chinese-language OSS or vendor source, or keep the source term and flag it as pending when no such precedent exists; for an English target, use the established English technical term, or preserve an ambiguous source term with a short English gloss and flag it as pending. Do not invent a translation. This rule applies to terminology only; for general prose, freely restructure and paraphrase for natural expression.\n\n# Terminology\n\n本表约定本仓库的中英术语统一译法。\n\n**通用规则:**\n- \"中文\"列为中文译文的正文默认用词。若该列为英文,则中文译文的正文中保留英文不翻译。\n- 首次出现按\"首次出现\"列书写(带括号注释);后续出现只写括号前的部分(可能为中文,也可能为英文),不出现括号内的注释。\n- \"不要译作\"列为严格禁止的译法。\n- 如果某术语已经作为另一个术语的组成部分被括注过(如 `agent loop(智能体循环)` 中已包含 `agent` 的括注),则该术语后续单独出现时无需再次括注。\n\n## 缩写类(中英文文本中均使用缩写)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| ACP | ACP | ACP(Agent Client Protocol) | | |\n| AI | AI | AI(人工智能) | | |\n| API | API | | | |\n| CI | CI | | | |\n| CLI | CLI | CLI(命令行界面) | | |\n| e2e | e2e | | | |\n| HMR | HMR | HMR(热模块替换) | | |\n| JSON Schema | JSON Schema | | | |\n| JSONL | JSONL | | | |\n| LLM | LLM | LLM(大语言模型) | | |\n| MCP | MCP | | | |\n| PR | PR | PR(Pull Request) | | |\n| RAG | RAG | RAG(检索增强生成) | | |\n| SDK | SDK | | | |\n| SSE | SSE | SSE(Server-Sent Events) | | |\n\n## 英文类(中英文文本中均使用英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| agent | agent | agent(智能体) | | |\n| Agent Note | Agent Note | Agent Note(agent 决策记录) | 智能体注记、智能体笔记 | 本仓库中由 agent 撰写的提案与决策记录 |\n| agent harness | agent harness | agent harness(智能体框架) | | agent 组合词(agent harness/workflow/loop/skill 等)整体保留英文;未括注过 agent 时首现按对应组合词或 agent 行处理 |\n| agent loop | agent loop | agent loop(智能体循环) | | |\n| backlog | backlog | backlog(待翻清单) | | 仅在双语翻译语境里括注`待翻清单` |\n| blob hash | blob hash | | | `git hash-object` 的结果 |\n| Cordis | Cordis | | | |\n| dispose | dispose | dispose(资源释放) | | |\n| doc-sync | doc-sync | doc-sync(文档同步门禁) | | |\n| fiber | fiber | | | |\n| fixture | fixture | fixture(测试前置数据) | | |\n| fork | fork | | | |\n| Function Calling | Function Calling | Function Calling(函数调用) | | |\n| harness | harness | | | |\n| harness engineering | harness engineering | | | |\n| lint | lint | | | |\n| mock | mock | | | 保留英文;指测试替身 |\n| loader | loader | | | |\n| manifest | manifest | manifest(元数据清单) | | |\n| monorepo | monorepo | | | |\n| Round | Round | | 回合、目标回合、Ralph 回合 | 外层策略使用 Round 时,领域层级为 Session > Round > Turn(轮次) > Step(步骤);Round 是可选的外层策略迭代,并非每个会话轮次都具有的通用层级。Goal Round 与 Ralph Round 均保留英文。一个 Round 承载一个轮次,步骤隶属于该轮次;明确的零步骤轮次仍保持原义。 |\n| schema | schema | | | |\n| schema DSL | schema DSL | | | |\n| seam | seam | | | 与 `extension point` 是不同概念;根据具体语境,可译为`服务边界`或`可替换点` |\n| skill | skill | skill(技能) | | |\n| spawn | spawn | | | |\n| steering | steering | steering(中途引导) | | |\n| task id | task id | | 任务 id | 保留英文 |\n| subagent | subagent | | | |\n| thinking | thinking | | | API 字段保留英文;描述模型模式时译为`思考` |\n| transcript | transcript | transcript(文本记录) | | 指会话渲染给用户或编辑器的完整文本,区别于事件日志 |\n| waterfall | waterfall | waterfall(瀑布式事件) | | |\n| wheel | wheel 包 | | | Python 打包格式 |\n| worktree | worktree | | | git 工作区概念 |\n| Zstandard | Zstandard | | | RFC 8878 compression format; `zstd` remains a code value. |\n\n## 双语类(中英文文本各自使用中英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| adapter | 适配器 | | | |\n| adapter contract | 适配器契约 | 适配器契约(adapter contract) | | |\n| append-only | 仅追加 | | | |\n| artifact | 产物 | | | |\n| backend | 后端 | | | |\n| background task | 后台任务 | | | |\n| block | 块 | | | |\n| build target | 构建目标 | | | |\n| cancel | 取消 | | | |\n| feature | 功能 | | 能力 | SDK 产品与工程模型中的可管理产品单元 |\n| feature option | 功能选项 | | variant | 一项 SDK 功能内有限、可选择的实现或配置 |\n| checkpoint | 检查点 | | | |\n| chunk | 分片 | | | |\n| compaction | 压缩 | 压缩(compaction) | | |\n| companion tool | 配套工具 | | | |\n| Cordis plugin config | Cordis 插件配置 | | | Cordis 插件公开的 `Config` 对象或配置结构 |\n| config key | 配置键 | | | Cordis 插件配置中的单个字段 |\n| consumer | 消费方 | | | |\n| content block | 内容块 | | | |\n| Cookbook | 实操手册 | | | 文档标题用语 |\n| context | 上下文 | | | |\n| counterpart | 对侧文件 | | 对应物、配对物 | 双语配对语境;泛指\"另一侧\"时可写「另一侧」 |\n| context compaction | 上下文压缩 | 上下文压缩(context compaction) | | |\n| contract | 契约 | | | 如:`pairing contract` →`配对契约` |\n| Cordis config entry | Cordis 配置项 | | | 指 `cordis.yml` 插件列表中的一项;插件实现本身写`Cordis 插件` |\n| Cordis plugin | Cordis 插件 | | | Cordis 加载的插件实现,不指 `cordis.yml` 中的一项配置 |\n| coverage | 覆盖率 | | | |\n| crash recovery | 崩溃恢复 | | | |\n| deploy root | 部署根目录 | | | |\n| durability | 持久性 | | | |\n| feature requirement | 功能依赖 | | | 功能或功能选项通过 `requires` 声明的关系 |\n| enforcement frontier | 执行红线 | | 强制边界 | i18n 配对机制用语:manifest `required` 清单所划的门禁生效范围;与金标样例(style-samples ⑦)一致 |\n| ergonomics | 易用性 / 开发体验 | | 人体工学 | API 或面向模型的接口用「易用性」;工具链或开发者工作流用「开发体验」 |\n| event | 事件 | | | |\n| event log | 事件日志 | | | |\n| event stream | 事件流 | | | |\n| event-sourced | 事件溯源 | | | 沿用 DDD 社区通行译法 |\n| Executive summary | 摘要 | | | 事故复盘标题用语 |\n| executor | 执行器 | | | |\n| expected output | 预期输出 | | 金标 | 指 snapshot 比较产物;翻译语料的人工校准样例不在此列 |\n| extension | 扩展 | | | |\n| extension point | 扩展点 | | | 注意与 `seam` 区分 |\n| fail-fast | 快速失败 | | | |\n| fenced code block | 围栏代码块 | | | 沿用 MDN 中文翻译 |\n| fingerprint | 指纹 | | | 通用内容指纹;双语配对机制使用 sidecar record 记录两侧 blob hash |\n| finish reason | 结束原因 | | | |\n| foreground run | 前台运行 | | | |\n| freshness | 新鲜度 | | | 沿用 MDN 中文翻译;在本项目中指译文相对源文的同步状态 |\n| hook | 钩子 | | | |\n| implementation | 实现 | | | |\n| inference | 推理 | 推理(inference) | | 需要和 `reasoning` 区分时保留英文括注 |\n| info string | 信息字符串 | | | 沿用 CommonMark 中文翻译;指代码围栏 ``` 之后的语言标注 |\n| injection | 注入 | | | |\n| integration | 集成 | | | |\n| interface | 接口 | | | |\n| language switcher | 语言切换行 | | | i18n 配对机制用语:双语配对文件顶部的互链行 |\n| memory | 记忆 / 内存 | | | 与 `agent` 搭配时译为`记忆`(如 `agent memory` →`智能体记忆`);指系统资源时译为`内存` |\n| merge | 合并 | | | |\n| message | 消息 | | | |\n| mod | 模组 | | | |\n| model provider | 模型提供方 | | | |\n| module | 模块 | | | |\n| non-escalation | 非升权 | | 非升级、不可升级 | 仅用于安全与权限语境,指主体不得获得超出既有授权的权限;普通升级不适用此行 |\n| npm dependency | NPM 依赖 | | | `package.json` 中的包关系;`dependencies`、`devDependencies` 等字段保持原样 |\n| opt-out ratio | opt-out 比例 | | 退出检查比例 | |\n| orphan | 遗留 | | 孤儿、孤立 | 指英文源已不存在的 `.zh.md`(如「遗留译文」);进程语境按 OS 惯用语译「孤儿进程」 |\n| orphan branch | 孤立分支 | | 孤儿分支 | 沿用 git 官方中文翻译 |\n| package | 包 | 包(package) | | 指 npm 包(`@deepseek-ai/dsh-*`);`package.json` 等代码标识保持原样 |\n| pairing | 配对 | | | |\n| parent-subset grants | 父级子集授权 | | 父集合授权 | 指授权范围仅限于父级所持授权的子集 |\n| peer dependency | 对等依赖 | 对等依赖(peer dependency) | | |\n| permission | 权限 | | | |\n| persistence | 持久化 | | | |\n| pipeline | 流水线 | | | |\n| plugin | 插件 | | | |\n| prompt | 提示词 | | | |\n| provider | 提供方 | | | |\n| provider-neutral | 提供方无关 | | | |\n| quality gate | 质量门禁 | | | |\n| quiescence | 完全停稳 | | 静默、静止状态 | 指生命周期工作全部结算后的状态 |\n| reasoning | 推理 | 推理(reasoning) | | 需要和 `inference` 区分时保留英文括注 |\n| reasoning_content | 思考内容 | | | |\n| registry | 注册表 | | | |\n| replay | 回放 | | | |\n| resume | 恢复 | | | |\n| runtime | 运行时 | | | |\n| same-world subprocess | 与宿主共享文件系统和内核的子进程 | | 同世界子进程 | |\n| sandbox | 沙箱 | | | |\n| service | 服务 | | | |\n| serving surface | 对外服务接口 | | | |\n| session | 会话 | | | |\n| session event | 会话事件 | | | |\n| sidecar record | 伴随记录 | | 旁挂记录 | 指与文档同目录的伴随记录文件 |\n| smoke test | 冒烟测试 | | | |\n| snapshot | 快照 | | | |\n| source of truth | 真源 | | | |\n| spine | 主干 | | | |\n| staged | 暂存 | | | 沿用 git 官方中文翻译 |\n| stale | 陈旧 | | 过期 | 与 `fresh`(`新鲜`)成对;门禁输出中保留英文 `stale` 不翻译;`expired` 才译为`过期` |\n| step | 步骤 | | | |\n| stream | 流 | | | |\n| streaming | 流式输出 | | | |\n| structural signature | 结构签名 | | | i18n 配对机制用语:门禁比对两侧文件时提取的有序结构序列(标题层级、代码块、列表等) |\n| Summary | 概述 | | | 事故复盘标题用语 |\n| system prompt | 系统提示词 | | | |\n| taxonomy | 分类体系 | | | |\n| token usage | token 用量 | | | |\n| tool | 工具 | | | |\n| tool call | 工具调用 | | | |\n| tool result | 工具结果 | | | |\n| tool schema | 工具 schema | | | |\n| toolkit | 工具包 | | | |\n| turn | 轮次 | | | |\n| VFS | VFS | 虚拟文件系统(VFS) | | |\n| typecheck | 类型检查 | | | |\n| vocabulary | 词汇 | | | |\n| wire format | 协议格式 | 协议格式(wire format) | | |\n| workflow | 工作流 | | | |\n| wrapper | 包装层 | | | 软件层或 SDK 包装层 |\n| wrapper script | 包装脚本 | | | 可执行脚本包装层 |\n\n\n## Output Format\n\nProduce your output in three XML sections:\n\nThe outer section tags are framing. If Markdown inside any section body contains a line consisting only of `<translation>`, `</translation>`, `<review>`, `</review>`, `<final>`, or `</final>`, prefix that line with `\\`. If the original line already has one or more backslashes immediately before the tag, add one more. The parser removes exactly one framing escape; tags mentioned inline need no escaping.\n\n```xml\n<translation>\n(Complete translation of the source document)\n</translation>\n\n<review>\n(Self-review notes, one correction per line with category tag, e.g.)\n- [Tone] \"旁挂记录\" → \"伴随记录\"(生造词)\n- [Sentence] 第 3 段补充逗号断句\n- [Punctuation] 两处破折号替换为冒号\n- 无修正\n</review>\n\n<final>\n(Final translation after corrections)\n</final>\n```\n\n## Self-Review Instructions\n\nAfter writing `<translation>`, re-read it in the target language only, without looking at the source. Check by category:\n\n**Structure**\n- Is the heading hierarchy, list shape, and code block content identical to the source?\n- Are ALL comments inside code blocks left untranslated (byte-identical to source)?\n- Is the language switcher line correctly flipped (not copied from source)?\n- Are link targets preserved, and are spaces after bold markers present only before Latin letters, digits, or CJK ideographs?\n- Are wrapper-tag lines inside section bodies escaped with one additional backslash?\n\n**Tone & Style**\n- Does every sentence read as if originally written by a native speaker?\n- Is there any colloquial, casual, or overly informal phrasing?\n\n**Sentence Structure**\n- Are there run-on sentences that need breaking?\n- Are there stiff passive constructions that should be converted to active voice?\n\n**Word Choice**\n- Are there overly literal translations that sound unnatural?\n- Is the same target-language word used to translate two distinct source concepts?\n- Is any slang or internal jargon present?\n\n**Terminology**\n- For a Chinese target, are first-occurrence glosses correctly applied (not missing, not repeated)? For an English target, are Chinese glosses absent?\n- Are any \"不要译作\" forbidden translations present?\n- For unlisted terms, does a Chinese target use established Chinese precedent or retain the source term as pending, and does an English target use established English terminology or preserve only an ambiguous source term with a short English gloss?\n\n**Punctuation** (when target is Chinese)\n- Are there em-dashes that should be replaced with colons, periods, or commas?\n- Are list items ending with commas instead of semicolons?\n- Do RFC 2119 keywords preserve the source emphasis exactly?\n\nRecord corrections in `<review>` with category tags. Then output the corrected version in `<final>`. If no corrections are needed, write \"无修正\" in `<review>` and copy the translation unchanged into `<final>`.\n\n## Examples\n\nBelow are representative examples of common problems and their corrections. Follow the \"Good\" versions.\n\n### Colloquial verb → Professional verb\n- Source: `The repo pins pnpm@11.7.0 in package.json`\n- Bad: `仓库在 package.json 中钉住 pnpm@11.7.0`\n- Good: `该仓库在 package.json 中固定使用 pnpm@11.7.0`\n\n### Run-on sentence → Natural phrasing with pause\n- Source: `Read docs/architecture.md before changing anything under packages/.`\n- Bad: `改动 packages/ 下的任何东西之前先读 docs/architecture.md。`\n- Good: `在修改 packages/ 目录下的任何内容之前,请先阅读 docs/architecture.md。`\n\n### Stiff passive voice → Active and natural\n- Source: `a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.`\n- Bad: `门禁绿意味着这对文档曾在当前内容上被确认一致,不意味着这次确认本身是对的。`\n- Good: `门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。`\n\n### Invented word → Natural expression\n- Source: `A sidecar record of both blob hashes makes consistency checkable`\n- Bad: `旁挂记录两侧 blob hash,使一致性可检查`\n- Good: `伴随记录保存两侧 blob hash,使一致性可检查`\n\n### Em-dash → Colon/period\n- Source: `FIXME — an issue that should block a new release. A release should not ship with an open FIXME unless reviewers explicitly agree the change can be merged anyway.`\n- Bad: `FIXME——应当阻塞新版本发布的问题。除非评审者明确同意可以照常合入,发布不应带着未解决的 FIXME 出门。`\n- Good: `FIXME:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 FIXME。`\n\n### Overly literal → Meaningful rendering\n- Source: `awkward phrasing is easier to hear without the source anchoring you`\n- Bad: `没有源文锚着,别扭的表述更容易被听出来`\n- Good: `不对照原文时,更容易察觉别扭的表达`\n\n### Terminology — do not translate what should be kept in English\n- Source: `typed service seams, and explicit extension points`\n- Bad: `类型化的服务 seam(扩展点)与显式扩展点`\n- Good: `类型化的服务 seam 与显式扩展点`\n\n### Slang/jargon → Professional phrasing\n- Source: `The committed agent workflow lives in .agents/skills/dsh-translate-docs`\n- Bad: `进仓的 agent 工作流见 .agents/skills/dsh-translate-docs`\n- Good: `仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs`\n\n### \"For humans\" — translate the intent, not the word\n- Source: `For humans, start with the development guide`\n- Bad: `对于人工读者,请先从开发指南开始`(\"人工读者\"生硬)\n- Good: `面向开发者:请先阅读开发指南`(\"开发者\"自然,且中文里冒号在此处更自然)\n\n### Code block comments — NEVER translate\n- Source code block contains: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)`\n- Bad: `# 全屏 TUI coding agent(需要 DEEPSEEK_API_KEY)`\n- Good: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)` (keep exactly as-is, byte-for-byte)\n\n### Language switcher — flip direction\n- Source file (English) has: `English | [中文](README.zh.md)`\n- Bad (copying source unchanged): `English | [中文](README.zh.md)`\n- Good (flipped for Chinese file): `[English](README.md) | 中文`\n\n---\n\nNow translate the following document:" + "content": "# Translation Prompt\n\nYou are a senior technical translator specializing in LLM and agent development documentation. Your task is to translate the given source document from English to Chinese, producing natural, professional technical prose.\n\n## Quality Requirements\n\n### Structure and Format Preservation\n- Output a complete translated document that maintains exactly the same structure as the source: heading hierarchy, list shape, table columns, link targets, and code blocks.\n- Fenced code blocks must be byte-identical to the source, including ALL comments inside them. Do NOT translate comments inside code blocks. This is a hard rule with no exceptions.\n- Inline code spans (commands, flags, paths, API names, version numbers) must be kept verbatim. Never translate or reformat them.\n- Every relative link must point to the same target as in the source. Link text is translated; link targets are not.\n- Language switcher line: when translating into Chinese, write `[English](source-filename.md) | 中文`. When translating into English, write `English | [中文](source-filename.zh.md)`. Do NOT copy the switcher line from the source file unchanged — you must flip the link direction.\n- After a closing bold marker `**`, insert a space before the next character when that character is a Latin letter, digit, or CJK ideograph. Never insert a space before any punctuation (full-width or half-width).\n\n### Tone and Style\n- The translation must read as if originally written in the target language by a native speaker. If an expression sounds like a word-for-word rendering from the source language, rephrase it.\n- Write in a professional, formal tone appropriate for developer documentation. Never use colloquial or casual expressions.\n- Use polite imperative forms where the text instructs the reader to do something.\n- Keep the author's register: concise stays concise, detailed stays detailed.\n\n### Sentence Structure\n- Break long sentences with commas or semicolons. Avoid run-on sentences.\n- Prefer active voice. Convert passive constructions to active if it reads more naturally.\n- Translate meaning, not words. Restructure sentences where the target language grammar requires it.\n- Do not invent words or expressions that do not exist in natural technical writing of the target language.\n\n### Word Choice\n- Prefer precise, formal vocabulary over casual or colloquial alternatives.\n- When multiple synonyms exist, choose the one most commonly used in professional technical documentation of the target language.\n- Avoid slang, internal jargon, or overly literal translations that would not be recognized by the general developer audience.\n- Do not use the same word to translate two different source-language terms that carry distinct meanings.\n- Avoid repeating the same verb in close proximity; vary word choice for readability.\n\n#### When translating into Chinese\n- When a number modifies a noun, always include a Chinese classifier or measure word (量词). For example: \"three-package seam\" → \"由三个包构成的 seam\", not \"三包 seam\".\n\n### Punctuation\n\n#### When translating into Chinese\n- Use full-width Chinese punctuation in prose: `,。:;?!()「」`.\n- Strongly prefer replacing all em-dashes (——) with colons, periods, commas, or parentheses. Keep an em-dash only if no other punctuation works at all.\n- Use enumeration commas (、) between parallel items, not regular commas.\n- List item endings: use semicolons or no punctuation. Do not end list items with commas.\n- Put one half-width space between Chinese text and Latin words/numbers.\n- For RFC 2119 keywords (MUST, MUST NOT, SHOULD, MAY), translate to the corresponding Chinese term (必须、禁止、应当、可以) and keep the SOURCE emphasis marker: plain source stays plain (必须), italic source stays italic (*必须*), and bold source stays bold (**必须**).\n\n#### When translating into English\n(To be added.)\n\n## Terminology\n\nA terminology table is provided below. Follow it strictly:\n- Render every listed term exactly as specified.\n- When the target language is Chinese, use the \"中文\" column. On first occurrence, write the \"首次出现\" value with its parenthetical gloss; on subsequent occurrences, write only the part before the parentheses.\n- When the target language is English, use the \"English\" column without a Chinese gloss; do not copy the \"中文\" or \"首次出现\" value into English prose.\n- If a term has already been glossed as part of a compound term, do not gloss it again when it appears alone later.\n- NEVER use translations listed in the \"不要译作\" column.\n- For technical terms not in the table, follow the target language: for a Chinese target, use an established Chinese rendering from a major Chinese-language OSS or vendor source, or keep the source term and flag it as pending when no such precedent exists; for an English target, use the established English technical term, or preserve an ambiguous source term with a short English gloss and flag it as pending. Do not invent a translation. This rule applies to terminology only; for general prose, freely restructure and paraphrase for natural expression.\n\n# Terminology\n\n本表约定本仓库的中英术语统一译法。\n\n**通用规则:**\n- \"中文\"列为中文译文的正文默认用词。若该列为英文,则中文译文的正文中保留英文不翻译。\n- 首次出现按\"首次出现\"列书写(带括号注释);后续出现只写括号前的部分(可能为中文,也可能为英文),不出现括号内的注释。\n- \"不要译作\"列为严格禁止的译法。\n- 如果某术语已经作为另一个术语的组成部分被括注过(如 `agent loop(智能体循环)` 中已包含 `agent` 的括注),则该术语后续单独出现时无需再次括注。\n\n## 缩写类(中英文文本中均使用缩写)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| ACP | ACP | ACP(Agent Client Protocol) | | |\n| AI | AI | AI(人工智能) | | |\n| API | API | | | |\n| CI | CI | | | |\n| CLI | CLI | CLI(命令行界面) | | |\n| e2e | e2e | | | |\n| HMR | HMR | HMR(热模块替换) | | |\n| JSON Schema | JSON Schema | | | |\n| JSONL | JSONL | | | |\n| LLM | LLM | LLM(大语言模型) | | |\n| MCP | MCP | | | |\n| PR | PR | PR(Pull Request) | | |\n| RAG | RAG | RAG(检索增强生成) | | |\n| SDK | SDK | | | |\n| SSE | SSE | SSE(Server-Sent Events) | | |\n\n## 英文类(中英文文本中均使用英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| agent | agent | agent(智能体) | | |\n| Agent Note | Agent Note | Agent Note(agent 决策记录) | 智能体注记、智能体笔记 | 本仓库中由 agent 撰写的提案与决策记录 |\n| agent harness | agent harness | agent harness(智能体框架) | | agent 组合词(agent harness/workflow/loop/skill 等)整体保留英文;未括注过 agent 时首现按对应组合词或 agent 行处理 |\n| agent loop | agent loop | agent loop(智能体循环) | | |\n| blob hash | blob hash | | | `git hash-object` 的结果 |\n| Cordis | Cordis | | | |\n| dispose | dispose | dispose(资源释放) | | |\n| doc-sync | doc-sync | doc-sync(文档同步门禁) | | |\n| fiber | fiber | | | |\n| fixture | fixture | fixture(测试前置数据) | | |\n| fork | fork | | | |\n| Function Calling | Function Calling | Function Calling(函数调用) | | |\n| harness | harness | | | |\n| harness engineering | harness engineering | | | |\n| lint | lint | | | |\n| mock | mock | | | 保留英文;指测试替身 |\n| loader | loader | | | |\n| manifest | manifest | manifest(元数据清单) | | |\n| monorepo | monorepo | | | |\n| Round | Round | | 回合、目标回合、Ralph 回合 | 外层策略使用 Round 时,领域层级为 Session > Round > Turn(轮次) > Step(步骤);Round 是可选的外层策略迭代,并非每个会话轮次都具有的通用层级。Goal Round 与 Ralph Round 均保留英文。一个 Round 承载一个轮次,步骤隶属于该轮次;明确的零步骤轮次仍保持原义。 |\n| schema | schema | | | |\n| schema DSL | schema DSL | | | |\n| seam | seam | | | 与 `extension point` 是不同概念;根据具体语境,可译为`服务边界`或`可替换点` |\n| skill | skill | skill(技能) | | |\n| spawn | spawn | | | |\n| steering | steering | steering(中途引导) | | |\n| task id | task id | | 任务 id | 保留英文 |\n| subagent | subagent | | | |\n| thinking | thinking | | | API 字段保留英文;描述模型模式时译为`思考` |\n| transcript | transcript | transcript(文本记录) | | 指会话渲染给用户或编辑器的完整文本,区别于事件日志 |\n| waterfall | waterfall | waterfall(瀑布式事件) | | |\n| wheel | wheel 包 | | | Python 打包格式 |\n| worktree | worktree | | | git 工作区概念 |\n| Zstandard | Zstandard | | | RFC 8878 compression format; `zstd` remains a code value. |\n\n## 双语类(中英文文本各自使用中英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| adapter | 适配器 | | | |\n| adapter contract | 适配器契约 | 适配器契约(adapter contract) | | |\n| append-only | 仅追加 | | | |\n| artifact | 产物 | | | |\n| backend | 后端 | | | |\n| background task | 后台任务 | | | |\n| block | 块 | | | |\n| build target | 构建目标 | | | |\n| cancel | 取消 | | | |\n| feature | 功能 | | 能力 | SDK 产品与工程模型中的可管理产品单元 |\n| feature option | 功能选项 | | variant | 一项 SDK 功能内有限、可选择的实现或配置 |\n| checkpoint | 检查点 | | | |\n| chunk | 分片 | | | |\n| compaction | 压缩 | 压缩(compaction) | | |\n| companion tool | 配套工具 | | | |\n| Cordis plugin config | Cordis 插件配置 | | | Cordis 插件公开的 `Config` 对象或配置结构 |\n| config key | 配置键 | | | Cordis 插件配置中的单个字段 |\n| consumer | 消费方 | | | |\n| content block | 内容块 | | | |\n| Cookbook | 实操手册 | | | 文档标题用语 |\n| context | 上下文 | | | |\n| counterpart | 对侧文件 | | 对应物、配对物 | 双语配对语境;泛指\"另一侧\"时可写「另一侧」 |\n| context compaction | 上下文压缩 | 上下文压缩(context compaction) | | |\n| contract | 契约 | | | 如:`pairing contract` →`配对契约` |\n| Cordis config entry | Cordis 配置项 | | | 指 `cordis.yml` 插件列表中的一项;插件实现本身写`Cordis 插件` |\n| Cordis plugin | Cordis 插件 | | | Cordis 加载的插件实现,不指 `cordis.yml` 中的一项配置 |\n| coverage | 覆盖率 | | | |\n| crash recovery | 崩溃恢复 | | | |\n| deploy root | 部署根目录 | | | |\n| durability | 持久性 | | | |\n| feature requirement | 功能依赖 | | | 功能或功能选项通过 `requires` 声明的关系 |\n| ergonomics | 易用性 / 开发体验 | | 人体工学 | API 或面向模型的接口用「易用性」;工具链或开发者工作流用「开发体验」 |\n| event | 事件 | | | |\n| event log | 事件日志 | | | |\n| event stream | 事件流 | | | |\n| event-sourced | 事件溯源 | | | 沿用 DDD 社区通行译法 |\n| Executive summary | 摘要 | | | 事故复盘标题用语 |\n| executor | 执行器 | | | |\n| expected output | 预期输出 | | 金标 | 指 snapshot 比较产物;翻译语料的人工校准样例不在此列 |\n| extension | 扩展 | | | |\n| extension point | 扩展点 | | | 注意与 `seam` 区分 |\n| fail-fast | 快速失败 | | | |\n| fenced code block | 围栏代码块 | | | 沿用 MDN 中文翻译 |\n| fingerprint | 指纹 | | | 通用内容指纹;双语配对机制使用 sidecar record 记录两侧 blob hash |\n| finish reason | 结束原因 | | | |\n| foreground run | 前台运行 | | | |\n| freshness | 新鲜度 | | | 沿用 MDN 中文翻译;在本项目中指译文相对源文的同步状态 |\n| hook | 钩子 | | | |\n| implementation | 实现 | | | |\n| inference | 推理 | 推理(inference) | | 需要和 `reasoning` 区分时保留英文括注 |\n| info string | 信息字符串 | | | 沿用 CommonMark 中文翻译;指代码围栏 ``` 之后的语言标注 |\n| injection | 注入 | | | |\n| integration | 集成 | | | |\n| interface | 接口 | | | |\n| language switcher | 语言切换行 | | | i18n 配对机制用语:双语配对文件顶部的互链行 |\n| memory | 记忆 / 内存 | | | 与 `agent` 搭配时译为`记忆`(如 `agent memory` →`智能体记忆`);指系统资源时译为`内存` |\n| merge | 合并 | | | |\n| message | 消息 | | | |\n| mod | 模组 | | | |\n| model provider | 模型提供方 | | | |\n| module | 模块 | | | |\n| non-escalation | 非升权 | | 非升级、不可升级 | 仅用于安全与权限语境,指主体不得获得超出既有授权的权限;普通升级不适用此行 |\n| npm dependency | NPM 依赖 | | | `package.json` 中的包关系;`dependencies`、`devDependencies` 等字段保持原样 |\n| opt-out ratio | opt-out 比例 | | 退出检查比例 | |\n| orphan | 遗留 | | 孤儿、孤立 | 指英文源已不存在的 `.zh.md`(如「遗留译文」);进程语境按 OS 惯用语译「孤儿进程」 |\n| orphan branch | 孤立分支 | | 孤儿分支 | 沿用 git 官方中文翻译 |\n| package | 包 | 包(package) | | 指 npm 包(`@deepseek-ai/dsh-*`);`package.json` 等代码标识保持原样 |\n| pairing | 配对 | | | |\n| parent-subset grants | 父级子集授权 | | 父集合授权 | 指授权范围仅限于父级所持授权的子集 |\n| peer dependency | 对等依赖 | 对等依赖(peer dependency) | | |\n| permission | 权限 | | | |\n| persistence | 持久化 | | | |\n| pipeline | 流水线 | | | |\n| plugin | 插件 | | | |\n| prompt | 提示词 | | | |\n| provider | 提供方 | | | |\n| provider-neutral | 提供方无关 | | | |\n| quality gate | 质量门禁 | | | |\n| quiescence | 完全停稳 | | 静默、静止状态 | 指生命周期工作全部结算后的状态 |\n| reasoning | 推理 | 推理(reasoning) | | 需要和 `inference` 区分时保留英文括注 |\n| reasoning_content | 思考内容 | | | |\n| registry | 注册表 | | | |\n| replay | 回放 | | | |\n| resume | 恢复 | | | |\n| runtime | 运行时 | | | |\n| same-world subprocess | 与宿主共享文件系统和内核的子进程 | | 同世界子进程 | |\n| sandbox | 沙箱 | | | |\n| service | 服务 | | | |\n| serving surface | 对外服务接口 | | | |\n| session | 会话 | | | |\n| session event | 会话事件 | | | |\n| sidecar record | 伴随记录 | | 旁挂记录 | 指与文档同目录的伴随记录文件 |\n| smoke test | 冒烟测试 | | | |\n| snapshot | 快照 | | | |\n| source of truth | 真源 | | | |\n| spine | 主干 | | | |\n| staged | 暂存 | | | 沿用 git 官方中文翻译 |\n| stale | 陈旧 | | 过期 | 与 `fresh`(`新鲜`)成对;门禁输出中保留英文 `stale` 不翻译;`expired` 才译为`过期` |\n| step | 步骤 | | | |\n| stream | 流 | | | |\n| streaming | 流式输出 | | | |\n| structural signature | 结构签名 | | | i18n 配对机制用语:门禁比对两侧文件时提取的有序结构序列(标题层级、代码块、列表等) |\n| Summary | 概述 | | | 事故复盘标题用语 |\n| system prompt | 系统提示词 | | | |\n| taxonomy | 分类体系 | | | |\n| token usage | token 用量 | | | |\n| tool | 工具 | | | |\n| tool call | 工具调用 | | | |\n| tool result | 工具结果 | | | |\n| tool schema | 工具 schema | | | |\n| toolkit | 工具包 | | | |\n| turn | 轮次 | | | |\n| VFS | VFS | 虚拟文件系统(VFS) | | |\n| typecheck | 类型检查 | | | |\n| vocabulary | 词汇 | | | |\n| wire format | 协议格式 | 协议格式(wire format) | | |\n| workflow | 工作流 | | | |\n| wrapper | 包装层 | | | 软件层或 SDK 包装层 |\n| wrapper script | 包装脚本 | | | 可执行脚本包装层 |\n\n\n## Output Format\n\nProduce your output in three XML sections:\n\nThe outer section tags are framing. If Markdown inside any section body contains a line consisting only of `<translation>`, `</translation>`, `<review>`, `</review>`, `<final>`, or `</final>`, prefix that line with `\\`. If the original line already has one or more backslashes immediately before the tag, add one more. The parser removes exactly one framing escape; tags mentioned inline need no escaping.\n\n```xml\n<translation>\n(Complete translation of the source document)\n</translation>\n\n<review>\n(Self-review notes, one correction per line with category tag, e.g.)\n- [Tone] \"旁挂记录\" → \"伴随记录\"(生造词)\n- [Sentence] 第 3 段补充逗号断句\n- [Punctuation] 两处破折号替换为冒号\n- 无修正\n</review>\n\n<final>\n(Final translation after corrections)\n</final>\n```\n\n## Self-Review Instructions\n\nAfter writing `<translation>`, re-read it in the target language only, without looking at the source. Check by category:\n\n**Structure**\n- Is the heading hierarchy, list shape, and code block content identical to the source?\n- Are ALL comments inside code blocks left untranslated (byte-identical to source)?\n- Is the language switcher line correctly flipped (not copied from source)?\n- Are link targets preserved, and are spaces after bold markers present only before Latin letters, digits, or CJK ideographs?\n- Are wrapper-tag lines inside section bodies escaped with one additional backslash?\n\n**Tone & Style**\n- Does every sentence read as if originally written by a native speaker?\n- Is there any colloquial, casual, or overly informal phrasing?\n\n**Sentence Structure**\n- Are there run-on sentences that need breaking?\n- Are there stiff passive constructions that should be converted to active voice?\n\n**Word Choice**\n- Are there overly literal translations that sound unnatural?\n- Is the same target-language word used to translate two distinct source concepts?\n- Is any slang or internal jargon present?\n\n**Terminology**\n- For a Chinese target, are first-occurrence glosses correctly applied (not missing, not repeated)? For an English target, are Chinese glosses absent?\n- Are any \"不要译作\" forbidden translations present?\n- For unlisted terms, does a Chinese target use established Chinese precedent or retain the source term as pending, and does an English target use established English terminology or preserve only an ambiguous source term with a short English gloss?\n\n**Punctuation** (when target is Chinese)\n- Are there em-dashes that should be replaced with colons, periods, or commas?\n- Are list items ending with commas instead of semicolons?\n- Do RFC 2119 keywords preserve the source emphasis exactly?\n\nRecord corrections in `<review>` with category tags. Then output the corrected version in `<final>`. If no corrections are needed, write \"无修正\" in `<review>` and copy the translation unchanged into `<final>`.\n\n## Examples\n\nBelow are representative examples of common problems and their corrections. Follow the \"Good\" versions.\n\n### Colloquial verb → Professional verb\n- Source: `The repo pins pnpm@11.7.0 in package.json`\n- Bad: `仓库在 package.json 中钉住 pnpm@11.7.0`\n- Good: `该仓库在 package.json 中固定使用 pnpm@11.7.0`\n\n### Run-on sentence → Natural phrasing with pause\n- Source: `Read docs/architecture.md before changing anything under packages/.`\n- Bad: `改动 packages/ 下的任何东西之前先读 docs/architecture.md。`\n- Good: `在修改 packages/ 目录下的任何内容之前,请先阅读 docs/architecture.md。`\n\n### Stiff passive voice → Active and natural\n- Source: `a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.`\n- Bad: `门禁绿意味着这对文档曾在当前内容上被确认一致,不意味着这次确认本身是对的。`\n- Good: `门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。`\n\n### Invented word → Natural expression\n- Source: `A sidecar record of both blob hashes makes consistency checkable`\n- Bad: `旁挂记录两侧 blob hash,使一致性可检查`\n- Good: `伴随记录保存两侧 blob hash,使一致性可检查`\n\n### Em-dash → Colon/period\n- Source: `FIXME — an issue that should block a new release. A release should not ship with an open FIXME unless reviewers explicitly agree the change can be merged anyway.`\n- Bad: `FIXME——应当阻塞新版本发布的问题。除非评审者明确同意可以照常合入,发布不应带着未解决的 FIXME 出门。`\n- Good: `FIXME:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 FIXME。`\n\n### Overly literal → Meaningful rendering\n- Source: `awkward phrasing is easier to hear without the source anchoring you`\n- Bad: `没有源文锚着,别扭的表述更容易被听出来`\n- Good: `不对照原文时,更容易察觉别扭的表达`\n\n### Terminology — do not translate what should be kept in English\n- Source: `typed service seams, and explicit extension points`\n- Bad: `类型化的服务 seam(扩展点)与显式扩展点`\n- Good: `类型化的服务 seam 与显式扩展点`\n\n### Slang/jargon → Professional phrasing\n- Source: `The committed agent workflow lives in .agents/skills/dsh-translate-docs`\n- Bad: `进仓的 agent 工作流见 .agents/skills/dsh-translate-docs`\n- Good: `仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs`\n\n### \"For humans\" — translate the intent, not the word\n- Source: `For humans, start with the development guide`\n- Bad: `对于人工读者,请先从开发指南开始`(\"人工读者\"生硬)\n- Good: `面向开发者:请先阅读开发指南`(\"开发者\"自然,且中文里冒号在此处更自然)\n\n### Code block comments — NEVER translate\n- Source code block contains: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)`\n- Bad: `# 全屏 TUI coding agent(需要 DEEPSEEK_API_KEY)`\n- Good: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)` (keep exactly as-is, byte-for-byte)\n\n### Language switcher — flip direction\n- Source file (English) has: `English | [中文](README.zh.md)`\n- Bad (copying source unchanged): `English | [中文](README.zh.md)`\n- Good (flipped for Chinese file): `[English](README.md) | 中文`\n\n---\n\nNow translate the following document:" }, { "role": "user", @@ -16,19 +16,19 @@ }, { "role": "user", - "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThis onboarding guide helps project contributors get started with the local environment, daily workflow, and CI flow; see the Agent Notes for design rationale and technical trade-offs.\n\n## Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git.\n- Optional: a DeepSeek API key for the TUI, headless, and ACP automation demos and real-API e2e tests.\n\n## First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`; the wrapper script uses lefthook's reviewed `--force` mode so linked worktrees with an existing `core.hooksPath` do not fail normal `pnpm run …` commands.\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\npnpm exec lefthook install --force\n```\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nThat first typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through the two no-emit aggregates described below.\n\n## TypeScript project layout\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md).\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n## Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n## Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` runs staged-file ESLint fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n## CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n## Daily commands\n\nUse these from the repo root:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\nWhen changing package public behavior, update the relevant README or JSDoc in the same change. `pnpm run doc-sync` catches checked TypeScript snippets, generated doc freshness, markdown wrap/link drift, type equivalence, translation pairing, Mermaid syntax, and doc budgets, but broader prose/API sync still needs review.\n\n## Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe full-screen interactive coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:tui\n```\n\nThe self-referential cordis-agent demo can inspect and modify its live plugin runtime and needs the same credentials:\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n## Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate also enforces a 1:1 correspondence by document, symbol, and projection, so a block can't go silently unchecked and a stale entry can't linger. `doc-typecheck` skips both fence kinds (they aren't standalone-compilable) and excludes them from its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a block, update the manifest in the same change.\n\n## Architecture context\n\nRead `docs/architecture.md` before changing anything under `packages/`. The codebase is built around Cordis plugins, event-sourced sessions, typed service seams, and explicit extension points.\n" + "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThis onboarding guide helps project contributors get started with the local environment, daily workflow, and CI flow; see the Agent Notes for design rationale and technical trade-offs.\n\n## Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git.\n- Optional: a DeepSeek API key for the TUI, headless, and ACP automation demos and real-API e2e tests.\n\n## First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`; the wrapper script uses lefthook's reviewed `--force` mode so linked worktrees with an existing `core.hooksPath` do not fail normal `pnpm run …` commands.\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\npnpm exec lefthook install --force\n```\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nThat first typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through the two no-emit aggregates described below.\n\n## TypeScript project layout\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md).\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n## Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n## Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` runs staged-file ESLint fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n## CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n## Daily commands\n\nUse these from the repo root:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\nWhen changing package public behavior, update the relevant README or JSDoc in the same change. `pnpm run doc-sync` catches checked TypeScript snippets, generated doc freshness, markdown wrap/link drift, type equivalence, translation pairing, Mermaid syntax, and doc budgets, but broader prose/API sync still needs review.\n\n## Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe full-screen interactive coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:tui\n```\n\nThe self-referential cordis-agent demo can inspect and modify its live plugin runtime and needs the same credentials:\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n## Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n\n## Architecture context\n\nRead `docs/architecture.md` before changing anything under `packages/`. The codebase is built around Cordis plugins, event-sourced sessions, typed service seams, and explicit extension points.\n" }, { "role": "assistant", - "content": "# 开发指南\n\n[English](development.md) | 中文\n\n本指南覆盖参与 DeepSeek Harness 开发所需的本地环境搭建、日常工作流与 CI 流程;设计动机与技术权衡请查阅相应 Agent Note。\n\n## 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git。\n- 可选:一个 DeepSeek API key,用于 TUI、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n## 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。包装脚本使用 lefthook 经过评审的 `--force` 模式,确保已存在 `core.hooksPath` 的关联 worktree 不会导致正常的 `pnpm run …` 命令失败。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\npnpm exec lefthook install --force\n```\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n首次类型检查会执行全仓 `tsc -b tsconfig.json` 图:发射每个 package/vendor 的 `lib/types`,并通过下述两个 no-emit 聚合检查示例、测试和脚本。\n\n## TypeScript 项目布局\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n## 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n## Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 运行对暂存文件的 ESLint 修复,检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n## CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n## 日常命令\n\n在仓库根目录使用:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\n修改 package 的公开行为时,请在同一个变更中更新相关 README 或 JSDoc。`pnpm run doc-sync` 能检测到被检查的 TypeScript 片段、生成文档的新鲜度、Markdown 换行/链接漂移、type-equiv、翻译配对、Mermaid 语法和文档预算,但更广泛的行文/API 同步仍需评审把关。\n\n## 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n全屏交互式 coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:tui\n```\n\n自指的 cordis-agent 演示可以检查并修改其实时插件运行时,并需要相同的凭证:\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n## 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁还按文档、符号和投影强制 1:1 对应,因此不会有块被静默漏检,也不会有陈旧条目滞留。`doc-typecheck` 跳过两种围栏(它们不能独立编译),并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个块时,请在同一个变更里更新 manifest。\n\n## 架构上下文\n\n在修改 `packages/` 目录下的任何内容之前,请先阅读 `docs/architecture.md`。这套代码围绕 Cordis 插件、事件溯源的会话、类型化的服务 seam 与显式扩展点构建。\n" + "content": "# 开发指南\n\n[English](development.md) | 中文\n\n本指南覆盖参与 DeepSeek Harness 开发所需的本地环境搭建、日常工作流与 CI 流程;设计动机与技术权衡请查阅相应 Agent Note。\n\n## 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git。\n- 可选:一个 DeepSeek API key,用于 TUI、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n## 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。包装脚本使用 lefthook 经过评审的 `--force` 模式,确保已存在 `core.hooksPath` 的关联 worktree 不会导致正常的 `pnpm run …` 命令失败。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\npnpm exec lefthook install --force\n```\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n首次类型检查会执行全仓 `tsc -b tsconfig.json` 图:发射每个 package/vendor 的 `lib/types`,并通过下述两个 no-emit 聚合检查示例、测试和脚本。\n\n## TypeScript 项目布局\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n## 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n## Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 运行对暂存文件的 ESLint 修复,检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n## CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n## 日常命令\n\n在仓库根目录使用:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\n修改 package 的公开行为时,请在同一个变更中更新相关 README 或 JSDoc。`pnpm run doc-sync` 能检测到被检查的 TypeScript 片段、生成文档的新鲜度、Markdown 换行/链接漂移、type-equiv、翻译配对、Mermaid 语法和文档预算,但更广泛的行文/API 同步仍需评审把关。\n\n## 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n全屏交互式 coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:tui\n```\n\n自指的 cordis-agent 演示可以检查并修改其实时插件运行时,并需要相同的凭证:\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n## 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n\n## 架构上下文\n\n在修改 `packages/` 目录下的任何内容之前,请先阅读 `docs/architecture.md`。这套代码围绕 Cordis 插件、事件溯源的会话、类型化的服务 seam 与显式扩展点构建。\n" }, { "role": "user", - "content": "# Bilingual documentation\n\nEnglish | [中文](README.zh.md)\n\nThis repo's documentation is read by people and agents both inside and outside the company, so the README, Agent Notes, and docs tree are maintained in English and Simplified Chinese. This page defines the pairing contract, the enforcement gate, and the rollout policy; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. The committed agent workflow lives in [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md).\n\n## The pairing contract\n\n- **Both languages carry equal authority.** A document may be authored and reviewed in either language first — a Chinese-first Agent Note is as legitimate as an English-first one — and the counterpart is translated from it. Neither file outranks the other; what binds them is that they must say the same thing.\n- **A pair is three sibling files.** The English `foo.md`, the Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`, all in the same directory. No locale directories, no separate translation repo, no interleaved bilingual files. Pairs merge whole: a PR never lands one language without the other two files.\n- **The consistency record.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last time the two were confirmed to say the same thing:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. The recorded hash also recovers the exact last-confirmed text of either side (`git cat-file -p <hash>`), so an out-of-sync pair is updated by diffing the edited side against its last-confirmed state and patching the counterpart minimally — never by re-translating whole files. After bringing the pair back in line, `pnpm run verify-translation-pairing --write` re-records both hashes; that yaml diff is the reviewable act of confirming consistency.\n- **Language switcher.** Both files link to each other immediately after their H1 heading: the English file carries `English | [中文](foo.zh.md)` and the Chinese file carries `[English](foo.md) | 中文`.\n- **Structure mirrors the counterpart.** Heading depths and order, list kinds, ordered-list starts, list item counts, table row and column counts, link targets, and verbatim code blocks match one to one across the pair — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`).\n\n## The gate: verify-translation-pairing\n\n`pnpm run verify-translation-pairing` (part of `doc-sync`, which contributors run locally for documentation changes and CI runs exhaustively) enforces the contract mechanically:\n\n1. Every file listed as `required` in [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) has a complete pair.\n2. Every pair that exists at all — required or not — is complete and consistent: all three files present, each side's current blob hash equals the recorded one (editing either side without re-confirming the pair goes red), both sides carry the language switcher, and the structural signatures match in order — heading depths, verbatim code blocks (info string and content), table row and column counts, list kinds, ordered-list starts, item counts, and every link target apart from the switcher.\n3. Files listed as `excluded` have no `.zh.md` and no `.i18n.yaml` at all.\n4. Every date-named document (`yyyy-mm-dd-*.md`) dated on or after the manifest's `requiredSince` cutoff has a complete pair — new date-named Agent Notes merge bilingual from birth.\n\n`pnpm run verify-translation-pairing --list` prints the current pairing state of every document in scope — missing, out-of-sync, or ok — and is the work list for translation batches. It never fails; it reports.\n\nThe practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart and re-records the pair** (run the [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill, then `--write`), exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI.\n\nThe gate's limit, stated plainly: **a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.** It checks hashes and shape; it cannot judge whether the two sides actually say the same thing, or whether the wording is accurate, well-termed, and natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review.\n\n## Scope, exclusions, and rollout\n\n**Scope**: the root `README.md`, everything under `.agents/notes/**`, `docs/**`, and `python/**`. Package READMEs (`packages/**`) join the scope in a later batch.\n\n**Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them):\n\n- `docs/cordis-catalog/`, `docs/tool-catalog/`, `docs/config-catalog.md`, `docs/persistence-catalog.md`, `docs/module-graph.md`, `docs/agent-lifecycle.md`, `docs/capability-seams.md`, `docs/event-producer-consumer.md`, `docs/graph-atlas.md`, and `docs/tool-execution-pipeline.md` — generated files; their generators emit English only today, so a hand-written translation would go stale on every regeneration. The planned follow-up is to teach the generators to emit Chinese alongside English, at which point these leave the exclusion list.\n- `docs/AGENTS.md` and `.agents/notes/**/AGENTS.md` — agent instructions, maintained in English only like the root `AGENTS.md`.\n- `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction.\n- [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior.\n\n**Rollout**: a date-named document (`yyyy-mm-dd-*.md`, i.e. an Agent Note) dated on or after the manifest's `requiredSince` cutoff must merge with its pair. Earlier dates are backlog, including files created on the cutoff's eve. An Agent Note filename records its first-proposed date, so backdating past the cutoff is a review-visible violation. The manifest's `required` list is the current enforcement frontier, not the goal of full coverage. Translation batches add paths to `required`, ratcheting the gate forward. Unlisted documents remain visible in `--list`, while every existing pair is governed by the full contract. Because later edits must update both sides, expand `required` only as fast as translation review can support.\n\n## Division of labor\n\nCounterparts here are produced by an agent running [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) and reviewed by a human — inference is cheap here, review attention is the scarce resource. The gate checks pair completeness, recorded hashes, switchers, and its documented structural signature. Review still owns translation quality, terminology, and structural requirements that the signature does not encode. The prompt contract is executable: [scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) renders the committed template (terminology injected; the template carries its own calibrated rules) into either direction and parses the three-section response, while `verify-translation-prompt` exercises both render directions and the checked-in example in `doc-sync`.\n" + "content": "# Bilingual documentation\n\nEnglish | [中文](README.zh.md)\n\nThis repo's documentation is read by people and agents both inside and outside the company, so every document in scope is maintained in English and Simplified Chinese. This page defines the pairing contract, enforcement gate, scope, and exclusions; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. The committed agent workflow lives in [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md).\n\n## The pairing contract\n\n- **Both languages carry equal authority.** A document may be authored and reviewed in either language first — a Chinese-first Agent Note is as legitimate as an English-first one — and the counterpart is translated from it. Neither file outranks the other; what binds them is that they must say the same thing.\n- **A pair is three sibling files.** The English `foo.md`, the Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`, all in the same directory. No locale directories, no separate translation repo, no interleaved bilingual files. Pairs merge whole: a PR never lands one language without the other two files.\n- **The consistency record.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last time the two were confirmed to say the same thing:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. The recorded hash also recovers the exact last-confirmed text of either side (`git cat-file -p <hash>`), so an out-of-sync pair is updated by diffing the edited side against its last-confirmed state and patching the counterpart minimally — never by re-translating whole files. After bringing the pair back in line, `pnpm run verify-translation-pairing --write` re-records both hashes; that yaml diff is the reviewable act of confirming consistency.\n- **Language switcher.** Both files link to each other immediately after their H1 heading: the English file carries `English | [中文](foo.zh.md)` and the Chinese file carries `[English](foo.md) | 中文`.\n- **Structure mirrors the counterpart.** Heading depths and order, list kinds, ordered-list starts, list item counts, table row and column counts, link targets, and verbatim code blocks match one to one across the pair — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`).\n\n## The gate: verify-translation-pairing\n\n`pnpm run verify-translation-pairing` (part of `doc-sync`, which contributors run locally for documentation changes and CI runs exhaustively) enforces the contract mechanically:\n\n1. Every document in scope has a complete pair. README discovery is case-insensitive on the basename, so `missions/readme.md` is in scope alongside the other documentation roots.\n2. Every pair artifact that exists at all is complete and consistent: all three files present, each side's current blob hash equals the recorded one (editing either side without re-confirming the pair goes red), both sides carry the language switcher, and the structural signatures match in order — heading depths, verbatim code blocks (info string and content), table row and column counts, list kinds, ordered-list starts, item counts, and every link target apart from the switcher.\n3. Files listed as `excluded` have no `.zh.md` and no `.i18n.yaml` at all.\n\nSource-oriented code gates consume an exact `.zh.md` fence sequence as a derivative of its unsuffixed sibling instead of compiling or manifesting the same code twice. The sequence must match in length, order, fence kind, and byte-exact body; otherwise both copies remain independently checked and the pairing gate reports the structural mismatch.\n\n`pnpm run verify-translation-pairing --list` prints the current pairing state of every document in scope — missing, out-of-sync, or ok. It never fails; `missing` and `out-of-sync` rows identify violations that the normal check rejects.\n\nThe practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart and re-records the pair** (run the [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill, then `--write`), exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI.\n\nThe gate's limit, stated plainly: **a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.** It checks hashes and shape; it cannot judge whether the two sides actually say the same thing, or whether the wording is accurate, well-termed, and natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review.\n\n## Scope and exclusions\n\n**Scope**: every non-vendor README, plus every document under `.agents/notes/**`, `docs/**`, and `python/**`. README matching is case-insensitive on the basename and covers future directories without another manifest edit. Dependency and ignored build-output trees are discovery exclusions, not source documentation.\n\n**Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them):\n\n- `docs/cordis-catalog/`, `docs/tool-catalog/`, `docs/config-catalog.md`, `docs/persistence-catalog.md`, `docs/module-graph.md`, `docs/agent-lifecycle.md`, `docs/capability-seams.md`, `docs/event-producer-consumer.md`, `docs/graph-atlas.md`, and `docs/tool-execution-pipeline.md` — generated files; their generators emit English only today, so a hand-written translation would go stale on every regeneration. The planned follow-up is to teach the generators to emit Chinese alongside English, at which point these leave the exclusion list.\n- `docs/AGENTS.md`, `.agents/notes/**/AGENTS.md`, and their `CLAUDE.md` instruction symlinks — agent instructions, maintained in English only like the root `AGENTS.md`.\n- `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction.\n- [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior.\n\n**Universal requirement**: every current or future document in scope must merge as a complete bilingual pair. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) contains only explicit exclusions; there is no per-file rollout list, date cutoff, or README-specific policy class.\n\n## Division of labor\n\nCounterparts here are produced by an agent running [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) and reviewed by a human — inference is cheap here, review attention is the scarce resource. The gate checks pair completeness, recorded hashes, switchers, and its documented structural signature. Review still owns translation quality, terminology, and structural requirements that the signature does not encode. The prompt contract is executable: [scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) renders the committed template (terminology injected; the template carries its own calibrated rules) into either direction and parses the three-section response, while `verify-translation-prompt` exercises both render directions and the checked-in example in `doc-sync`.\n" }, { "role": "assistant", - "content": "# 双语文档\n\n[English](README.md) | 中文\n\n本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此 README、Agent Note(agent 决策记录)与 docs 目录树以英文和简体中文双语维护。本页定义配对契约、强制门禁与推进策略;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。仓库内置的 agent 工作流见 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md)。\n\n## 配对契约\n\n- **两种语言同权。** 一篇文档可以先用任一语言撰写和评审(先写中文的 Agent Note 与先写英文的一样正当),另一侧由它翻译而来。两个文件谁也不高于谁;约束它们的是二者必须说同样的话。\n- **一对文档是三个同目录文件。** 英文 `foo.md`、中文 `foo.zh.md`,加一份一致性记录 `foo.i18n.yaml`,都在同一目录。不用语言目录,不用独立翻译仓库,不用中英混排的单文件。配对必须整体合并:PR(Pull Request)永远不会只带一种语言而缺其余两个文件。\n- **一致性记录。**`foo.i18n.yaml` 保存两侧文件在上一次被确认「说同样的话」时各自的完整 git blob hash:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。记录的 hash 还能还原任一侧上次确认时的确切文本(`git cat-file -p <hash>`),所以失去同步的配对是「把被改的一侧与其上次确认状态做 diff、再最小化地修补另一侧」,从不整篇重译。两侧对齐后,`pnpm run verify-translation-pairing --write` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审。\n- **语言切换行。** 两个文件在各自 H1 标题之后立即互链:英文文件带 `English | [中文](foo.zh.md)`,中文文件带 `[English](foo.md) | 中文`。\n- **结构与另一侧一一对应。** 标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、链接目标与逐字节一致的代码块在配对两侧一一对应;完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。\n\n## 门禁:verify-translation-pairing\n\n`pnpm run verify-translation-pairing`(`doc-sync`(文档同步门禁)的一环,贡献者会针对文档变更在本地运行,CI 则会完整运行)机械地强制执行这份契约:\n\n1. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 中 `required` 列出的每个文件都有完整配对。\n2. 任何已存在的配对(无论是否 required)都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、双方都带语言切换行、结构签名按序一致:标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。\n3. 列为 `excluded` 的文件完全没有 `.zh.md`,也没有 `.i18n.yaml`。\n4. 凡文件名符合 `yyyy-mm-dd-*.md` 且日期不早于 manifest(元数据清单)中 `requiredSince` 分界日期的文档,都必须有完整配对;新建的日期命名 Agent Note 从创建起便须配齐中英文。\n\n`pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前配对状态(missing、out-of-sync 或 ok),是翻译批次的工作清单。它从不失败;它只报告。\n\n这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 更新另一侧并重新记录配对**(运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill(技能),再 `--write`),与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。\n\n把门禁的边界说白:**门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。** 它检查记录的 hash 与结构签名;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然;这部分契约由评审者把关,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。\n\n## 范围、排除与推进\n\n**范围**:根 `README.md`,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部内容。包(package)README(`packages/**`)在后续批次加入范围。\n\n**排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`):\n\n- `docs/cordis-catalog/`、`docs/tool-catalog/`、`docs/config-catalog.md`、`docs/persistence-catalog.md`、`docs/module-graph.md`、`docs/agent-lifecycle.md`、`docs/capability-seams.md`、`docs/event-producer-consumer.md`、`docs/graph-atlas.md` 与 `docs/tool-execution-pipeline.md`:生成文件;生成器目前只输出英文,手写译文在每次重新生成时必然陈旧。计划中的后续工作是让生成器同时输出中文,届时这些文件移出排除清单。\n- `docs/AGENTS.md` 与 `.agents/notes/**/AGENTS.md`:agent 指令,与根 `AGENTS.md` 一样只以英文维护。\n- `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。\n- [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。\n\n**推进**:以日期命名的文档(`yyyy-mm-dd-*.md`,即 Agent Note),只要标注日期等于或晚于 manifest 的 `requiredSince` 分界日期,合并时就必须配齐双语文件。更早日期的文件属于 backlog(待翻清单),包括分界前夜创建的文件。Agent Note 文件名记录首次提出日期,因此倒填日期绕过分界属于评审可见的违规。manifest 中的 `required` 列表是当前执行红线,并非全量覆盖这一最终目标。翻译批次将路径加入 `required`,使门禁只向前收紧。未列入的文档仍可通过 `--list` 查看,而任何已存在的配对都受完整契约约束。后续修改必须同步更新两侧,因此 `required` 的扩展速度不能超过翻译评审的承载能力。\n\n## 分工\n\n这里的对侧文件由运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 的 agent 生成,再由人评审:在这里推理(inference)很便宜,评审注意力才是稀缺资源。门禁负责检查配对是否完整、记录的 hash、语言切换行以及本文列出的结构签名;翻译质量、术语和签名未涵盖的结构要求仍由评审把关。提示词契约也有可执行实现:[scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) 会把仓库内置的模板(注入术语表;模板自带经人工校准的规则)渲染为英译中或中译英两个方向的提示词,并解析三段式响应;`doc-sync` 中的 `verify-translation-prompt` 会检查两个渲染方向与仓库内示例。\n" + "content": "# 双语文档\n\n[English](README.md) | 中文\n\n本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此范围内的每篇文档都以英文和简体中文维护。本页定义配对契约、强制门禁、范围与排除规则;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。仓库内置的 agent 工作流见 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md)。\n\n## 配对契约\n\n- **两种语言同权。** 一篇文档可以先用任一语言撰写和评审(先写中文的 Agent Note 与先写英文的一样正当),另一侧由它翻译而来。两个文件谁也不高于谁;约束它们的是二者必须说同样的话。\n- **一对文档是三个同目录文件。** 英文 `foo.md`、中文 `foo.zh.md`,加一份一致性记录 `foo.i18n.yaml`,都在同一目录。不用语言目录,不用独立翻译仓库,不用中英混排的单文件。配对必须整体合并:PR(Pull Request)永远不会只带一种语言而缺其余两个文件。\n- **一致性记录。**`foo.i18n.yaml` 保存两侧文件在上一次被确认「说同样的话」时各自的完整 git blob hash:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。记录的 hash 还能还原任一侧上次确认时的确切文本(`git cat-file -p <hash>`),所以失去同步的配对是「把被改的一侧与其上次确认状态做 diff、再最小化地修补另一侧」,从不整篇重译。两侧对齐后,`pnpm run verify-translation-pairing --write` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审。\n- **语言切换行。** 两个文件在各自 H1 标题之后立即互链:英文文件带 `English | [中文](foo.zh.md)`,中文文件带 `[English](foo.md) | 中文`。\n- **结构与另一侧一一对应。** 标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、链接目标与逐字节一致的代码块在配对两侧一一对应;完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。\n\n## 门禁:verify-translation-pairing\n\n`pnpm run verify-translation-pairing`(`doc-sync`(文档同步门禁)的一环,贡献者会针对文档变更在本地运行,CI 则会完整运行)机械地强制执行这份契约:\n\n1. 范围内的每篇文档都有完整配对。发现 README 时,basename 不区分大小写,因此 `missions/readme.md` 与其他文档根一样属于范围。\n2. 任何已存在的配对产物都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、双方都带语言切换行、结构签名按序一致:标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。\n3. 列为 `excluded` 的文件完全没有 `.zh.md`,也没有 `.i18n.yaml`。\n\n面向源码的代码门禁会把精确的 `.zh.md` 围栏序列视为其无后缀兄弟文件的派生内容,而不会再次编译相同代码或在 manifest 中重复登记。该序列必须在长度、顺序、围栏类型和按字节精确的正文上一致;否则两份副本仍会独立受检,配对门禁也会报告结构不匹配。\n\n`pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前配对状态(missing、out-of-sync 或 ok)。它从不失败;其中 missing 与 out-of-sync 行指出普通检查会拒绝的违规。\n\n这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 更新另一侧并重新记录配对**(运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill(技能),再 `--write`),与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。\n\n把门禁的边界说白:**门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。** 它检查记录的 hash 与结构签名;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然;这部分契约由评审者把关,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。\n\n## 范围与排除\n\n**范围**:除 vendor 源码外的全部 README,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部文档。匹配 README 时只看文件名且不区分大小写,因此今后新增的目录无需再修改 manifest。依赖目录和被忽略的构建产物目录只在发现阶段排除,并非源文档。\n\n**排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`):\n\n- `docs/cordis-catalog/`、`docs/tool-catalog/`、`docs/config-catalog.md`、`docs/persistence-catalog.md`、`docs/module-graph.md`、`docs/agent-lifecycle.md`、`docs/capability-seams.md`、`docs/event-producer-consumer.md`、`docs/graph-atlas.md` 与 `docs/tool-execution-pipeline.md`:生成文件;生成器目前只输出英文,手写译文在每次重新生成时必然陈旧。计划中的后续工作是让生成器同时输出中文,届时这些文件移出排除清单。\n- `docs/AGENTS.md`、`.agents/notes/**/AGENTS.md` 以及指向它们的 `CLAUDE.md` 指令符号链接:agent 指令,与根 `AGENTS.md` 一样只以英文维护。\n- `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。\n- [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。\n\n**统一要求**:当前及今后纳入范围的每篇文档,合并时都必须构成完整的双语配对。[scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 只包含显式排除项;不存在逐文件推进清单、日期分界或 README 专用政策类别。\n\n## 分工\n\n这里的对侧文件由运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 的 agent 生成,再由人评审:在这里推理(inference)很便宜,评审注意力才是稀缺资源。门禁负责检查配对是否完整、记录的 hash、语言切换行以及本文列出的结构签名;翻译质量、术语和签名未涵盖的结构要求仍由评审把关。提示词契约也有可执行实现:[scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) 会把仓库内置的模板(注入术语表;模板自带经人工校准的规则)渲染为英译中或中译英两个方向的提示词,并解析三段式响应;`doc-sync` 中的 `verify-translation-prompt` 会检查两个渲染方向与仓库内示例。\n" }, { "role": "user", @@ -40,11 +40,11 @@ }, { "role": "user", - "content": "# Agent Note: Bilingual documentation via paired sibling files and a pairing gate\n\nStatus: implemented\n\nEnglish | [中文](2026-07-02-bilingual-docs-and-pairing-gate.zh.md)\n\n## Problem\n\nThis repo's README and docs tree are read by people and agents inside and outside the company, in both English and Chinese. Maintaining a second language by hand, with no mechanism, is how translations rot: one side moves on, the other silently lies, and no gate notices. The repo's standing answer to invariants of this kind is to encode them as a mechanical check (see [quality gates](2026-06-11-quality-gates.md) and [doc-sync enforcement](2026-06-11-doc-sync-enforcement.md)), so the bilingual policy ships with one.\n\n## Decision\n\n- **Paired sibling files with equal authority.** A documentation pair is three sibling files: English `foo.md`, Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`. Neither language is canonical — a document may be authored and reviewed Chinese-first and translated to English afterwards, or the reverse; what binds the pair is that both sides must say the same thing, and pairs merge whole (both languages plus the record, never one alone). Policy: [docs/i18n/README.md](../../../../docs/i18n/README.md); translation rules: [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md); terminology source of truth: [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md).\n- **A sidecar record of both blob hashes makes consistency checkable.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last confirmed-consistent state. An edit to either side without re-confirming the pair is then mechanically detectable as a pure content comparison — no history lookup — and the hashes are computable for files edited in the same PR, which a commit-hash record is not. Re-recording (`verify-translation-pairing --write`) produces a reviewable yaml diff: confirming consistency is an explicit, visible act in the PR.\n- **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: required pairs exist, every existing pair is complete (all three files) and consistent (both hashes match, switcher links both ways, structural signatures identical), excluded (generated or bilingual-by-construction) files stay unpaired, and date-named documents on or after the manifest's `requiredSince` cutoff have complete pairs. The `required` list in [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) is a ratchet: each merged translation batch adds its files, so coverage only grows.\n- **The enforcement frontier advances in coherent review batches.** A related set enters `required` only when reviewers can evaluate it as a unit. The core frontier groups [architecture](../../../../docs/architecture.md), the [Cordis primer](../../../../docs/cordis-primer.md), [defensive patterns](../../../../docs/defensive-patterns.md), the [glossary](../../../../docs/glossary.md), and [testing](../../../../docs/testing.md) because their terminology, links, and contributor contracts inform one another; admitting only part would leave the enforced corpus internally inconsistent. A site-published pair uses `pairedPages()` so the root locale projects `.zh.md` and `/en/` projects `.md`; creating a counterpart alone does not publish it.\n- **Pairing records are metadata, not Cordis Loader configuration.** Cordis configuration discovery accepts actual `.cordis.yml` and `.cordis.yaml` files while excluding `*.i18n.yaml`, even when the document name contains `cordis`. This preserves validation of executable Loader entries without parsing translation hashes as configuration.\n- **Translation is agent work with human review.** The committed workflow is [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md), following the same pattern as [dsh-code-review](../../../skills/dsh-code-review/SKILL.md): the skill carries the workflow and defers to the docs as sources of truth. The skill directs the orchestrating agent to delegate translation writing to a subagent.\n\n## Verification\n\nThe verification contract covers each boundary independently. `verify-translation-pairing` pins pair completeness, hashes, switchers, and structure; [`project-doc-site.spec.ts`](../../../../scripts/project-doc-site.spec.ts) pins locale-specific source selection for published pairs; [`cordis-config-files.spec.ts`](../../../../scripts/cordis-config-files.spec.ts) pins discovery of Loader YAML and exclusion of translation records; and the [translation-prompt runnable snapshot](../../../../scripts/translation-prompt.snapshot.ts) pins the rendered system message, five reviewed example pairs, source request, and consumed response. Together these checks make pair drift, publication drift, configuration misclassification, and model-visible prompt drift review-visible.\n\n## Alternatives considered\n\n- **English as the canonical source with a fingerprint inside the translation** — the design first proposed for this Agent Note: `.zh.md` files carried an HTML comment recording the English source's blob hash, and translation flowed EN → ZH only. Revised in review: the team wants Chinese-first authoring (write and review a Chinese Agent Note, then translate to English) with the two languages holding equal authority, which a one-directional canonical model cannot express. The sidecar record covering BOTH sides replaced the in-file one-directional fingerprint; the blob-hash mechanics survived unchanged.\n- **Locale directories (`docs/en/` + `docs/zh/`, the Kubernetes/ECharts model)** — rejected: this repo has no docs-site framework to map locales to routes, moving every English file would churn every existing cross-reference, and `verify-md-links`/`verify-doc-refs` would need path-mapping logic instead of working unchanged.\n- **A separate translation repo (the PingCAP `docs`/`docs-cn` model)** — rejected: right for a docs product with independent release trains, overkill for a monorepo's own documentation; it also puts the translation outside the reach of this repo's gates.\n- **Interleaved bilingual files (single file, both languages)** — rejected: doubles every diff, breaks the one-line-per-paragraph convention's diff ergonomics, and makes partial inconsistency invisible.\n- **Commit-hash records (the MDN `l10n.sourceCommit` model)** — rejected in favor of blob hashes: a same-PR edit has no commit hash yet, so the MDN model cannot express \"consistent as of the state this PR introduces\", and verifying it requires git history instead of file content.\n- **Comparing git timestamps of the pair (no record)** — rejected: formatting-only edits would false-positive, and a counterpart committed after an unrelated edit would false-negative; content identity is the only signal that means what the gate claims.\n\n## Industry precedent\n\nPaired sibling files with locale suffixes are the dominant Chinese big-tech convention (ant-design `index.zh-CN.md`/`index.en-US.md`; arco-design `README.zh-CN.md` with a top-of-file switcher; Apache ShardingSphere's 387 `.cn.md`/`.en.md` pairs) — but none of those repos *enforce* pairing or consistency in CI; the convention holds by review alone. Consistency automation exists outside China: MDN's `l10n.sourceCommit` front-matter fingerprint, Vue's Ryu-Cho action (upstream-commit watcher that opens issues/PRs for stale translations), Kubernetes' localization drift scripts, and Microsoft's Azure co-op-translator (source-hash-driven LLM re-translation in CI). This design combines the two: the Chinese-ecosystem file layout with a hash-pair gate, plus a committed agent skill in place of a bot service.\n\n## Consequences\n\n- Editing either side of a paired document obligates the same PR to update the counterpart and re-record the pair — the gate makes the doc-sync rule bilingual, and CI (not reviewer memory) carries the invariant.\n- Every pair adds a third file to the tree. The record is machine-written (`--write`), so the cost is directory noise, not maintenance effort; in exchange, \"who confirmed these consistent, and when\" is answerable from git blame on the yaml.\n- When the two sides disagree, no mechanical rule picks a winner — the PR review does. That is the price of equal authority, accepted deliberately: the alternative (a canonical language) forbids Chinese-first authoring.\n- Generated docs (`cordis-catalog/`, `tool-catalog/`, `module-graph.md`) are excluded for now; the planned follow-up is to teach their generators to emit Chinese alongside English, at which point they leave the exclusion list.\n- Rollout is incremental by design: documents outside `required` are visible backlog (`--list`), not red CI, so pairs land in reviewable batches without a big-bang PR. A date-named document dated on or after the manifest's `requiredSince` cutoff merges bilingual or not at all, so new date-named Agent Notes do not enlarge that backlog.\n- The recorded hashes double as the update tool (`git cat-file -p <hash>` recovers either side's last-confirmed text for a minimal diff-based update), so re-translation of whole files is never forced by the mechanism.\n" + "content": "# Agent Note: Bilingual documentation via paired sibling files and a pairing gate\n\nStatus: implemented\n\nEnglish | [中文](2026-07-02-bilingual-docs-and-pairing-gate.zh.md)\n\n## Problem\n\nThis repo's documentation corpus is read by people and agents inside and outside the company, in both English and Chinese. Maintaining a second language by hand, with no mechanism, is how translations rot: one side moves on, the other silently lies, and no gate notices. The repo's standing answer to invariants of this kind is to encode them as a mechanical check (see [quality gates](2026-06-11-quality-gates.md) and [doc-sync enforcement](2026-06-11-doc-sync-enforcement.md)), so the bilingual policy ships with one.\n\n## Decision\n\n- **Paired sibling files with equal authority.** A documentation pair is three sibling files: English `foo.md`, Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`. Neither language is canonical — a document may be authored and reviewed Chinese-first and translated to English afterwards, or the reverse; what binds the pair is that both sides must say the same thing, and pairs merge whole (both languages plus the record, never one alone). Policy: [docs/i18n/README.md](../../../../docs/i18n/README.md); translation rules: [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md); terminology source of truth: [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md).\n- **A sidecar record of both blob hashes makes consistency checkable.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last confirmed-consistent state. An edit to either side without re-confirming the pair is then mechanically detectable as a pure content comparison — no history lookup — and the hashes are computable for files edited in the same PR, which a commit-hash record is not. Re-recording (`verify-translation-pairing --write`) produces a reviewable yaml diff: confirming consistency is an explicit, visible act in the PR.\n- **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: every discovered, non-excluded source has a complete pair; every existing pair is complete (all three files) and consistent (both hashes match, switcher links both ways, structural signatures identical); and excluded generated, instruction, or bilingual-by-construction files stay unpaired. [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) contains only explicit exclusions, so no requirement can bypass discovery and receive a weaker check. Source-oriented code gates consume a `.zh.md` fence sequence as a derivative only when its unsuffixed sibling has the same tracked fences in the same order with byte-identical bodies; an incomplete, reordered, reclassified, or changed sequence stays independent, so the owning code gate or pairing gate reports the mismatch.\n- **One corpus-wide requirement.** Every document in scope requires a complete pair from creation; the policy has no per-file rollout state, date cutoff, or README-specific class. README discovery covers every case-insensitive README basename outside vendored, dependency, and ignored build-output trees, including future top-level directories. A site-published pair uses `pairedPages()` so the root locale projects `.zh.md` and `/en/` projects `.md`; creating a counterpart alone does not publish it.\n- **Pairing records are metadata, not Cordis Loader configuration.** Cordis configuration discovery accepts actual `.cordis.yml` and `.cordis.yaml` files while excluding `*.i18n.yaml`, even when the document name contains `cordis`. This preserves validation of executable Loader entries without parsing translation hashes as configuration.\n- **Translation is agent work with human review.** The committed workflow is [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md), following the same pattern as [dsh-code-review](../../../skills/dsh-code-review/SKILL.md): the skill carries the workflow and defers to the docs as sources of truth. The skill directs the orchestrating agent to delegate translation writing to a subagent.\n\n## Verification\n\nThe verification contract covers each boundary independently. `verify-translation-pairing` pins pair completeness, hashes, switchers, and structure; [`project-doc-site.spec.ts`](../../../../scripts/project-doc-site.spec.ts) pins locale-specific source selection for published pairs; [`cordis-config-files.spec.ts`](../../../../scripts/cordis-config-files.spec.ts) pins discovery of Loader YAML and exclusion of translation records; and the [translation-prompt runnable snapshot](../../../../scripts/translation-prompt.snapshot.ts) pins the rendered system message, five reviewed example pairs, source request, and consumed response. Together these checks make pair drift, publication drift, configuration misclassification, and model-visible prompt drift review-visible.\n\n## Alternatives considered\n\n- **English as the canonical source with a fingerprint inside the translation** — the design first proposed for this Agent Note: `.zh.md` files carried an HTML comment recording the English source's blob hash, and translation flowed EN → ZH only. Revised in review: the team wants Chinese-first authoring (write and review a Chinese Agent Note, then translate to English) with the two languages holding equal authority, which a one-directional canonical model cannot express. The sidecar record covering BOTH sides replaced the in-file one-directional fingerprint; the blob-hash mechanics survived unchanged.\n- **Locale directories (`docs/en/` + `docs/zh/`, the Kubernetes/ECharts model)** — rejected: this repo has no docs-site framework to map locales to routes, moving every English file would churn every existing cross-reference, and `verify-md-links`/`verify-doc-refs` would need path-mapping logic instead of working unchanged.\n- **A separate translation repo (the PingCAP `docs`/`docs-cn` model)** — rejected: right for a docs product with independent release trains, overkill for a monorepo's own documentation; it also puts the translation outside the reach of this repo's gates.\n- **Interleaved bilingual files (single file, both languages)** — rejected: doubles every diff, breaks the one-line-per-paragraph convention's diff ergonomics, and makes partial inconsistency invisible.\n- **Commit-hash records (the MDN `l10n.sourceCommit` model)** — rejected in favor of blob hashes: a same-PR edit has no commit hash yet, so the MDN model cannot express \"consistent as of the state this PR introduces\", and verifying it requires git history instead of file content.\n- **Comparing git timestamps of the pair (no record)** — rejected: formatting-only edits would false-positive, and a counterpart committed after an unrelated edit would false-negative; content identity is the only signal that means what the gate claims.\n\n## Industry precedent\n\nPaired sibling files with locale suffixes are the dominant Chinese big-tech convention (ant-design `index.zh-CN.md`/`index.en-US.md`; arco-design `README.zh-CN.md` with a top-of-file switcher; Apache ShardingSphere's 387 `.cn.md`/`.en.md` pairs) — but none of those repos *enforce* pairing or consistency in CI; the convention holds by review alone. Consistency automation exists outside China: MDN's `l10n.sourceCommit` front-matter fingerprint, Vue's Ryu-Cho action (upstream-commit watcher that opens issues/PRs for stale translations), Kubernetes' localization drift scripts, and Microsoft's Azure co-op-translator (source-hash-driven LLM re-translation in CI). This design combines the two: the Chinese-ecosystem file layout with a hash-pair gate, plus a committed agent skill in place of a bot service.\n\n## Consequences\n\n- Editing either side of a paired document obligates the same PR to update the counterpart and re-record the pair — the gate makes the doc-sync rule bilingual, and CI (not reviewer memory) carries the invariant.\n- Every pair adds a third file to the tree. The record is machine-written (`--write`), so the cost is directory noise, not maintenance effort; in exchange, \"who confirmed these consistent, and when\" is answerable from git blame on the yaml.\n- When the two sides disagree, no mechanical rule picks a winner — the PR review does. That is the price of equal authority, accepted deliberately: the alternative (a canonical language) forbids Chinese-first authoring.\n- Generated docs (`cordis-catalog/`, `tool-catalog/`, `module-graph.md`) are excluded for now; the planned follow-up is to teach their generators to emit Chinese alongside English, at which point they leave the exclusion list.\n- The exclusions-only manifest makes every current and future in-scope document mandatory through the same path. There is no explicit requirement, cutoff, or class entry that can fall outside discovery while appearing enforced.\n- The recorded hashes double as the update tool (`git cat-file -p <hash>` recovers either side's last-confirmed text for a minimal diff-based update), so re-translation of whole files is never forced by the mechanism.\n" }, { "role": "assistant", - "content": "# Agent Note:通过配对兄弟文件与配对门禁实现双语文档\n\nStatus: implemented\n\n[English](2026-07-02-bilingual-docs-and-pairing-gate.md) | 中文\n\n## 问题\n\n本仓库的 README 与 docs 目录树会被公司内外的人和 agent(智能体)以中英两种语言阅读。在没有机制的情况下纯靠手工维护第二语言,正是译文腐烂的根源:一侧持续演进,另一侧默默失实,而没有门禁会注意到。对于这类不变式,本仓库一贯的做法是将其编码为机械检查(见[质量门禁](2026-06-11-quality-gates.md)与 [doc-sync 强制](2026-06-11-doc-sync-enforcement.md)),因此双语政策随附一道门禁一起交付。\n\n## 决策\n\n- **配对兄弟文件,两种语言同权。** 一对文档由三个兄弟文件组成:英文 `foo.md`、中文 `foo.zh.md`,以及一份一致性记录 `foo.i18n.yaml`。没有哪种语言是正典:一篇文档可以先用中文撰写和评审、之后再译成英文,反之亦可;约束配对的是:两侧必须表达相同的内容,且配对整体合并(两种语言加记录,绝不单独落一侧)。政策见 [docs/i18n/README.md](../../../../docs/i18n/README.md);翻译规则见 [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md);术语真源见 [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md)。\n- **伴随记录保存两侧 blob hash,使一致性可检查。** `foo.i18n.yaml` 保存两侧文件在上一次确认一致时各自的完整 git blob hash。此后修改了任一侧而未重新确认配对,都能被机械检测出来(纯内容比较,无需查询历史),而且同一个 PR(Pull Request)内改动的文件也能计算出 hash,commit hash 式的记录做不到这一点。重新记录(`verify-translation-pairing --write`)会产生一份可评审的 yaml diff:确认一致在 PR 中是一个显式、可见的动作。\n- **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:required 的配对必须存在;任何已存在的配对必须完整(三个文件齐全)且一致(两个 hash 匹配、切换行双向互链、结构签名一致);被排除的文件(生成物或本身即双语的)不得配对;凡文件名以日期开头且日期不早于 manifest(元数据清单)中 `requiredSince` 分界日期的文档,也必须有完整配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 中的 `required` 清单只进不退:每个合并的翻译批次将自己的文件加入其中,覆盖面只增不减。\n- **执行红线按连贯的评审批次推进。** 一组相关文档只有在评审者能够将其作为整体评估时,才进入 `required`。核心红线将[架构](../../../../docs/architecture.md)、[Cordis 入门](../../../../docs/cordis-primer.md)、[防御性模式](../../../../docs/defensive-patterns.md)、[术语表](../../../../docs/glossary.md)和[测试](../../../../docs/testing.md)归为一组,因为它们的术语、链接和贡献者契约相互关联;只纳入其中一部分会使受门禁约束的文档集合内部不一致。发布到文档站的配对使用 `pairedPages()`,由根 locale 投影 `.zh.md`,由 `/en/` 投影 `.md`;仅创建对侧文件并不会发布它。\n- **配对记录是元数据,而不是 Cordis Loader 配置。** Cordis 配置发现会接受实际的 `.cordis.yml` 和 `.cordis.yaml` 文件,同时排除 `*.i18n.yaml`,即使文档名中包含 `cordis` 也不例外。这样既能继续校验可执行的 Loader 配置项,又不会把翻译 hash 当作配置来解析。\n- **翻译是 agent 的工作,由人评审。** 仓库内置的工作流是 [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md),与 [dsh-code-review](../../../skills/dsh-code-review/SKILL.md) 模式相同:skill(技能)承载工作流,并将文档作为真源。该 skill 要求编排 agent 把翻译写作委派给 subagent。\n\n## 验证\n\n验证契约分别覆盖每个边界。`verify-translation-pairing` 固定配对完整性、hash、切换行和结构;[`project-doc-site.spec.ts`](../../../../scripts/project-doc-site.spec.ts) 固定已发布配对按 locale 选择对应源文件;[`cordis-config-files.spec.ts`](../../../../scripts/cordis-config-files.spec.ts) 固定 Loader YAML 的发现以及翻译记录的排除;[翻译提示词可运行快照](../../../../scripts/translation-prompt.snapshot.ts)则固定渲染后的系统消息、五对经评审的示例、源请求和响应消费结果。这些检查共同使配对漂移、发布漂移、配置误分类和模型可见提示词漂移都可在评审中看见。\n\n## 曾考虑的替代方案\n\n- **英文为正典源、指纹放在译文内**:本 Agent Note 最初提出的设计:`.zh.md` 文件携带一条 HTML 注释记录英文源的 blob hash,翻译只沿 EN → ZH 单向流动。评审中修订:团队需要中文先行的撰写方式(先写、先审中文 Agent Note,再译英文),两种语言同权,而单向正典模型无法表达这一点。覆盖**两侧**的伴随记录取代了文件内的单向指纹;blob hash 的机制本身保持不变。\n- **语言目录(`docs/en/` + `docs/zh/`,Kubernetes/ECharts 模式)**:否决。本仓库没有将 locale 映射到路由的文档站框架;如果移动所有英文文件,所有既有交叉引用都要随之修改;且 `verify-md-links`/`verify-doc-refs` 将需要路径映射逻辑,而非原样工作。\n- **独立翻译仓库(PingCAP `docs`/`docs-cn` 模式)**:否决。适合有独立发布节奏的文档产品,对 monorepo 自身的文档而言过重;还会把译文置于本仓库门禁触及不到的地方。\n- **中英混排单文件(一个文件、两种语言)**:否决。每个 diff 都翻倍,破坏一段一行约定的 diff 易读性,且局部不一致不可见。\n- **Commit hash 式记录(MDN `l10n.sourceCommit` 模式)**:否决,改用 blob hash。同一个 PR 内的改动还没有 commit hash,MDN 模式无法表达「与本 PR 引入的状态一致」,且校验它需要 git 历史而非文件内容。\n- **比较配对两侧的 git 时间戳(无记录)**:否决。纯格式化的改动会误报,一次无关改动之后提交的对侧文件会漏报;只有内容同一性这个信号才与门禁的承诺名实相符。\n\n## 业界先例\n\n带语言后缀的配对兄弟文件是中国大厂的主流约定(ant-design 的 `index.zh-CN.md`/`index.en-US.md`;arco-design 的 `README.zh-CN.md` 加顶部切换行;Apache ShardingSphere 的 387 对 `.cn.md`/`.en.md`),但这些仓库都没有在 CI 中**强制**配对或一致性检查;约定纯靠评审维系。一致性自动化存在于中国以外:MDN 的 `l10n.sourceCommit` front-matter 指纹、Vue 的 Ryu-Cho action(监视上游 commit,为陈旧译文自动开 issue/PR)、Kubernetes 的本地化漂移脚本、微软 Azure co-op-translator(CI 中由源 hash 驱动的 LLM 重译)。本设计将两者结合:中文生态的文件布局,加上 hash 配对门禁,再加一个仓库内置的 agent skill 替代 bot 服务。\n\n## 后果\n\n- 修改已配对文档的任一侧,同一个 PR 就有义务更新对侧并重新记录配对。门禁将 doc-sync 规则双语化,不变式由 CI(而非评审者的记忆)承载。\n- 每个配对给目录树多添一个文件。记录由机器写入(`--write`),代价是目录噪音而非维护负担;换来的是「谁在何时确认过这对文档一致」可以从 yaml 的 git blame 直接回答。\n- 两侧说法冲突时,没有机械规则裁决谁赢,由 PR 评审裁决。这是同权的代价,且是有意接受的:另一个选项(正典语言)会禁止中文先行撰写。\n- 生成文档(`cordis-catalog/`、`tool-catalog/`、`module-graph.md`)暂被排除;计划中的后续工作是让生成器在输出英文的同时输出中文,届时将这些文件移出排除清单。\n- 推进天然是渐进的:`required` 之外的文档是可见的 backlog(待翻清单,`--list`),而非红色的 CI;因此配对按可评审的批次落地,无需一个巨型 PR。凡文件名以日期开头且日期不早于 manifest 中 `requiredSince` 分界日期的文档,都必须配齐双语文件,因此新建的日期命名 Agent Note 不会增加这份 backlog。\n- 记录的 hash 兼作更新工具(`git cat-file -p <hash>` 能还原任一侧上次确认的文本,用于基于 diff 的最小更新),因此这套机制从不强迫整篇重译。\n" + "content": "# Agent Note:通过配对兄弟文件与配对门禁实现双语文档\n\nStatus: implemented\n\n[English](2026-07-02-bilingual-docs-and-pairing-gate.md) | 中文\n\n## 问题\n\n本仓库的文档语料会被公司内外的人和 agent(智能体)以中英两种语言阅读。在没有机制的情况下纯靠手工维护第二语言,正是译文腐烂的根源:一侧持续演进,另一侧默默失实,而没有门禁会注意到。对于这类不变式,本仓库一贯的做法是将其编码为机械检查(见[质量门禁](2026-06-11-quality-gates.md)与 [doc-sync 强制](2026-06-11-doc-sync-enforcement.md)),因此双语政策随附一道门禁一起交付。\n\n## 决策\n\n- **配对兄弟文件,两种语言同权。** 一对文档由三个兄弟文件组成:英文 `foo.md`、中文 `foo.zh.md`,以及一份一致性记录 `foo.i18n.yaml`。没有哪种语言是正典:一篇文档可以先用中文撰写和评审、之后再译成英文,反之亦可;约束配对的是:两侧必须表达相同的内容,且配对整体合并(两种语言加记录,绝不单独落一侧)。政策见 [docs/i18n/README.md](../../../../docs/i18n/README.md);翻译规则见 [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md);术语真源见 [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md)。\n- **伴随记录保存两侧 blob hash,使一致性可检查。** `foo.i18n.yaml` 保存两侧文件在上一次确认一致时各自的完整 git blob hash。此后修改了任一侧而未重新确认配对,都能被机械检测出来(纯内容比较,无需查询历史),而且同一个 PR(Pull Request)内改动的文件也能计算出 hash,commit hash 式的记录做不到这一点。重新记录(`verify-translation-pairing --write`)会产生一份可评审的 yaml diff:确认一致在 PR 中是一个显式、可见的动作。\n- **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:每个已发现且未排除的源文档都有完整配对;每个现有配对都完整(三个文件齐全)且一致(两个 hash 匹配、切换行双向互链、结构签名一致);被排除的生成文档、指令文档或本身即双语的文档不得配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 只包含显式排除项,因此任何要求都无法绕过发现流程而接受较弱的检查。只有当 `.zh.md` 围栏序列与其无后缀兄弟文件拥有顺序相同、正文按字节一致的同一组受跟踪围栏时,面向源码的代码门禁才会将其作为派生内容消费;不完整、顺序变更、重分类或已改动的序列仍会独立受检,因此由其所属的代码门禁或配对门禁报告不匹配。\n- **全语料统一要求。** 范围内的每篇文档从创建起就必须有完整配对;政策没有逐文件推进状态、日期分界或 README 专用类别。README 发现会覆盖 vendor 源码、依赖目录与被忽略的构建产物目录之外所有文件名不区分大小写匹配 README 的文件,包括今后新增的顶层目录。发布到文档站的配对使用 `pairedPages()`,由根 locale 投影 `.zh.md`,由 `/en/` 投影 `.md`;仅创建对侧文件并不会发布它。\n- **配对记录是元数据,而不是 Cordis Loader 配置。** Cordis 配置发现会接受实际的 `.cordis.yml` 和 `.cordis.yaml` 文件,同时排除 `*.i18n.yaml`,即使文档名中包含 `cordis` 也不例外。这样既能继续校验可执行的 Loader 配置项,又不会把翻译 hash 当作配置来解析。\n- **翻译是 agent 的工作,由人评审。** 仓库内置的工作流是 [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md),与 [dsh-code-review](../../../skills/dsh-code-review/SKILL.md) 模式相同:skill(技能)承载工作流,并将文档作为真源。该 skill 要求编排 agent 把翻译写作委派给 subagent。\n\n## 验证\n\n验证契约分别覆盖每个边界。`verify-translation-pairing` 固定配对完整性、hash、切换行和结构;[`project-doc-site.spec.ts`](../../../../scripts/project-doc-site.spec.ts) 固定已发布配对按 locale 选择对应源文件;[`cordis-config-files.spec.ts`](../../../../scripts/cordis-config-files.spec.ts) 固定 Loader YAML 的发现以及翻译记录的排除;[翻译提示词可运行快照](../../../../scripts/translation-prompt.snapshot.ts)则固定渲染后的系统消息、五对经评审的示例、源请求和响应消费结果。这些检查共同使配对漂移、发布漂移、配置误分类和模型可见提示词漂移都可在评审中看见。\n\n## 曾考虑的替代方案\n\n- **英文为正典源、指纹放在译文内**:本 Agent Note 最初提出的设计:`.zh.md` 文件携带一条 HTML 注释记录英文源的 blob hash,翻译只沿 EN → ZH 单向流动。评审中修订:团队需要中文先行的撰写方式(先写、先审中文 Agent Note,再译英文),两种语言同权,而单向正典模型无法表达这一点。覆盖**两侧**的伴随记录取代了文件内的单向指纹;blob hash 的机制本身保持不变。\n- **语言目录(`docs/en/` + `docs/zh/`,Kubernetes/ECharts 模式)**:否决。本仓库没有将 locale 映射到路由的文档站框架;如果移动所有英文文件,所有既有交叉引用都要随之修改;且 `verify-md-links`/`verify-doc-refs` 将需要路径映射逻辑,而非原样工作。\n- **独立翻译仓库(PingCAP `docs`/`docs-cn` 模式)**:否决。适合有独立发布节奏的文档产品,对 monorepo 自身的文档而言过重;还会把译文置于本仓库门禁触及不到的地方。\n- **中英混排单文件(一个文件、两种语言)**:否决。每个 diff 都翻倍,破坏一段一行约定的 diff 易读性,且局部不一致不可见。\n- **Commit hash 式记录(MDN `l10n.sourceCommit` 模式)**:否决,改用 blob hash。同一个 PR 内的改动还没有 commit hash,MDN 模式无法表达「与本 PR 引入的状态一致」,且校验它需要 git 历史而非文件内容。\n- **比较配对两侧的 git 时间戳(无记录)**:否决。纯格式化的改动会误报,一次无关改动之后提交的对侧文件会漏报;只有内容同一性这个信号才与门禁的承诺名实相符。\n\n## 业界先例\n\n带语言后缀的配对兄弟文件是中国大厂的主流约定(ant-design 的 `index.zh-CN.md`/`index.en-US.md`;arco-design 的 `README.zh-CN.md` 加顶部切换行;Apache ShardingSphere 的 387 对 `.cn.md`/`.en.md`),但这些仓库都没有在 CI 中**强制**配对或一致性检查;约定纯靠评审维系。一致性自动化存在于中国以外:MDN 的 `l10n.sourceCommit` front-matter 指纹、Vue 的 Ryu-Cho action(监视上游 commit,为陈旧译文自动开 issue/PR)、Kubernetes 的本地化漂移脚本、微软 Azure co-op-translator(CI 中由源 hash 驱动的 LLM 重译)。本设计将两者结合:中文生态的文件布局,加上 hash 配对门禁,再加一个仓库内置的 agent skill 替代 bot 服务。\n\n## 后果\n\n- 修改已配对文档的任一侧,同一个 PR 就有义务更新对侧并重新记录配对。门禁将 doc-sync 规则双语化,不变式由 CI(而非评审者的记忆)承载。\n- 每个配对给目录树多添一个文件。记录由机器写入(`--write`),代价是目录噪音而非维护负担;换来的是「谁在何时确认过这对文档一致」可以从 yaml 的 git blame 直接回答。\n- 两侧说法冲突时,没有机械规则裁决谁赢,由 PR 评审裁决。这是同权的代价,且是有意接受的:另一个选项(正典语言)会禁止中文先行撰写。\n- 生成文档(`cordis-catalog/`、`tool-catalog/`、`module-graph.md`)暂被排除;计划中的后续工作是让生成器在输出英文的同时输出中文,届时将这些文件移出排除清单。\n- 只含排除项的 manifest 通过同一路径,要求当前及今后纳入范围的每篇文档都必须配对。不存在显式要求、分界或类别条目可以落在发现范围之外,却看似已经强制执行。\n- 记录的 hash 兼作更新工具(`git cat-file -p <hash>` 能还原任一侧上次确认的文本,用于基于 diff 的最小更新),因此这套机制从不强迫整篇重译。\n" }, { "role": "user", diff --git a/scripts/translation-pairing.manifest.json b/scripts/translation-pairing.manifest.json index 4e08844dc9..7c77929ba3 100644 --- a/scripts/translation-pairing.manifest.json +++ b/scripts/translation-pairing.manifest.json @@ -1,220 +1,8 @@ { - "requiredSince": "2026-07-14", - "required": [ - ".agents/notes/README.md", - ".agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md", - ".agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.md", - ".agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md", - ".agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.md", - ".agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md", - ".agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.md", - ".agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.md", - ".agents/notes/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.md", - ".agents/notes/implemented/architecture/2026-06-13-capability-seams.md", - ".agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md", - ".agents/notes/implemented/architecture/2026-06-14-session-persistence.md", - ".agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md", - ".agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md", - ".agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md", - ".agents/notes/implemented/architecture/2026-06-18-session-surface.md", - ".agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md", - ".agents/notes/implemented/architecture/2026-06-20-branded-ids.md", - ".agents/notes/implemented/architecture/2026-06-20-extract-example-app-packages.md", - ".agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md", - ".agents/notes/implemented/architecture/2026-06-20-package-hierarchy.md", - ".agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md", - ".agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md", - ".agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md", - ".agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md", - ".agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.md", - ".agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md", - ".agents/notes/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md", - ".agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md", - ".agents/notes/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md", - ".agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md", - ".agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md", - ".agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md", - ".agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md", - ".agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md", - ".agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md", - ".agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md", - ".agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md", - ".agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md", - ".agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md", - ".agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md", - ".agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md", - ".agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md", - ".agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.md", - ".agents/notes/implemented/feature/2026-06-14-acp-multi-session.md", - ".agents/notes/implemented/feature/2026-06-15-code-mode.md", - ".agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md", - ".agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md", - ".agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md", - ".agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md", - ".agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md", - ".agents/notes/implemented/feature/2026-06-25-ask-user-question.md", - ".agents/notes/implemented/feature/2026-06-29-todo-write-tool.md", - ".agents/notes/implemented/feature/2026-06-30-hook-bridges.md", - ".agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md", - ".agents/notes/implemented/feature/2026-06-30-interception-seams.md", - ".agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md", - ".agents/notes/implemented/feature/2026-06-30-subagent-observe-enrich.md", - ".agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md", - ".agents/notes/implemented/feature/2026-07-05-skill-system.md", - ".agents/notes/implemented/feature/2026-07-06-approval-seam.md", - ".agents/notes/implemented/feature/2026-07-06-explicit-tool-order.md", - ".agents/notes/implemented/feature/2026-07-06-sandbox.md", - ".agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md", - ".agents/notes/implemented/feature/2026-07-07-session-prefix.md", - ".agents/notes/implemented/feature/2026-07-08-repeat-tool-guard.md", - ".agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md", - ".agents/notes/implemented/feature/2026-07-10-session-query-service.md", - ".agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md", - ".agents/notes/implemented/process/2026-06-11-doc-sync-enforcement.md", - ".agents/notes/implemented/process/2026-06-11-quality-gates.md", - ".agents/notes/implemented/process/2026-06-11-tsdown-over-dumble.md", - ".agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.md", - ".agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.md", - ".agents/notes/implemented/process/2026-06-17-ts-build-config.md", - ".agents/notes/implemented/process/2026-06-18-markdown-cross-link-lint.md", - ".agents/notes/implemented/process/2026-06-20-agent-note-classification.md", - ".agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.md", - ".agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.md", - ".agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md", - ".agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md", - ".agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.md", - ".agents/notes/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md", - ".agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.md", - ".agents/notes/implemented/process/2026-07-04-persistence-log-catalog.md", - ".agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.md", - ".agents/notes/implemented/process/2026-07-06-export-surface-jsdoc-gate.md", - ".agents/notes/implemented/process/2026-07-06-generated-config-catalog.md", - ".agents/notes/implemented/process/2026-07-06-node-engine-floor.md", - ".agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md", - ".agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md", - ".agents/notes/implemented/process/2026-07-10-readme-known-limitations-gate.md", - ".agents/notes/implemented/process/2026-07-12-package-model-experience-contract.md", - ".agents/notes/implemented/process/2026-07-19-web-styling-system.md", - ".agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.md", - ".agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.md", - ".agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md", - ".agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md", - ".agents/notes/implemented/simplification/2026-06-20-prune-dead-seam-methods.md", - ".agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.md", - ".agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md", - ".agents/notes/implemented/simplification/2026-06-20-unify-agent-and-session-id.md", - ".agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md", - ".agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md", - ".agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.md", - ".agents/notes/implemented/simplification/2026-07-04-drop-inert-request-knobs.md", - ".agents/notes/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md", - ".agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md", - ".agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md", - ".agents/notes/implemented/simplification/2026-07-04-prune-write-only-fs-surface.md", - ".agents/notes/implemented/simplification/2026-07-04-remove-agent-steering-mirror.md", - ".agents/notes/implemented/simplification/2026-07-04-share-app-bin-boot-glue.md", - ".agents/notes/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md", - ".agents/notes/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md", - ".agents/notes/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.md", - ".agents/notes/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.md", - ".agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.md", - ".agents/notes/implemented/testing/2026-06-11-property-based-testing.md", - ".agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md", - ".agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md", - ".agents/notes/implemented/testing/2026-06-20-remove-redundant-snapshot-log-expected-output.md", - ".agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md", - ".agents/notes/implemented/testing/2026-06-22-fork-snapshot-scenarios.md", - ".agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.md", - ".agents/notes/implemented/testing/2026-07-04-hook-snapshot-matrix.md", - ".agents/notes/implemented/testing/2026-07-04-single-source-acp-replay-config.md", - ".agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md", - ".agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.md", - ".agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.md", - ".agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md", - ".agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md", - ".agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.md", - ".agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md", - ".agents/notes/rejected/feature/2026-07-13-stream-workflow-progress-through-tool-calls.md", - ".agents/notes/proposed/process/2026-06-11-api-extractor-reports.md", - ".agents/notes/proposed/process/2026-06-11-architectural-conformance.md", - ".agents/notes/proposed/process/2026-06-11-supply-chain-and-vendor-drift.md", - ".agents/notes/proposed/process/2026-06-20-discover-package-inventory.md", - ".agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md", - ".agents/notes/proposed/testing/2026-06-11-deterministic-and-stress-testing.md", - ".agents/notes/proposed/testing/2026-06-11-mutation-testing.md", - ".agents/notes/rejected/architecture/2026-06-11-immutable-public-surfaces.md", - ".agents/notes/rejected/architecture/2026-06-20-providerless-example-base.md", - ".agents/notes/rejected/process/2026-07-04-generate-agent-note-index-tables.md", - ".agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.md", - ".agents/notes/rejected/simplification/2026-06-20-drop-acp-session-load.md", - ".agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.md", - ".agents/notes/rejected/simplification/2026-06-20-drop-bash-output-spill-files.md", - ".agents/notes/rejected/simplification/2026-06-20-drop-durable-step-boundaries.md", - ".agents/notes/rejected/simplification/2026-06-20-drop-unused-session-lineage.md", - ".agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.md", - ".agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.md", - ".agents/notes/rejected/simplification/2026-06-20-retire-mid-turn-steering.md", - ".agents/notes/rejected/simplification/2026-06-20-single-session-acp-bridge.md", - ".agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.md", - ".agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md", - ".agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md", - ".agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.md", - "README.md", - "docs/architecture.md", - "docs/cookbook/adding-a-package.md", - "docs/cookbook/adding-a-tool.md", - "docs/cookbook/adding-a-vendored-package.md", - "docs/cookbook/adding-an-llm-adapter.md", - "docs/cookbook/extension-cookbook.md", - "docs/cookbook/responding-to-pr-review-on-a-stack.md", - "docs/cordis-primer.md", - "docs/core-data-structures/approval.md", - "docs/core-data-structures/bash.md", - "docs/core-data-structures/code-runtime.md", - "docs/core-data-structures/compaction.md", - "docs/core-data-structures/core.md", - "docs/core-data-structures/filesystem.md", - "docs/core-data-structures/llm-streaming.md", - "docs/core-data-structures/persistence.md", - "docs/core-data-structures/sandbox.md", - "docs/core-data-structures/scope.md", - "docs/core-data-structures/session-query.md", - "docs/core-data-structures/session.md", - "docs/core-data-structures/skills.md", - "docs/core-data-structures/subagent.md", - "docs/core-data-structures/system-prompt.md", - "docs/core-data-structures/tools.md", - "docs/core-data-structures/user-interaction.md", - "docs/core-data-structures/web.md", - "docs/core-data-structures/workflow.md", - "docs/defensive-patterns.md", - "docs/development.md", - "docs/glossary.md", - "docs/i18n/README.md", - "docs/i18n/translation-rules.md", - "docs/postmortem/0001-acp-default-export-drops-inject.md", - "docs/postmortem/0002-js-expression-disabled-filesystem-tools.md", - "docs/postmortem/README.md", - "docs/testing.md", - "docs/user/develop/basic/config.md", - "docs/user/develop/basic/index.md", - "docs/user/develop/basic/tool.md", - "docs/user/develop/framework/events.md", - "docs/user/develop/framework/index.md", - "docs/user/develop/framework/service.md", - "docs/user/develop/practice/index.md", - "docs/user/develop/practice/llm-adapter.md", - "docs/user/guide/config.md", - "docs/user/guide/index.md", - "docs/user/guide/quickstart.md", - "docs/user/index.md", - "python/README.md", - "python/sdk-runtime/README.md", - "python/sdk/README.md" - ], "excluded": [ ".agents/notes/AGENTS.md", ".agents/notes/implemented/AGENTS.md", + ".agents/notes/implemented/CLAUDE.md", "docs/AGENTS.md", "docs/agent-lifecycle.md", "docs/capability-seams.md", @@ -228,7 +16,6 @@ "docs/module-graph.md", "docs/persistence-catalog.md", "docs/tool-catalog.md", - "docs/tool-execution-pipeline.md", - "python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/" + "docs/tool-execution-pipeline.md" ] } diff --git a/scripts/translation-pairing.spec.ts b/scripts/translation-pairing.spec.ts index 2bdc4f4e15..c158b3020a 100644 --- a/scripts/translation-pairing.spec.ts +++ b/scripts/translation-pairing.spec.ts @@ -1,12 +1,10 @@ -/** Regression tests for the bilingual cutoff and structural signature. */ +/** Regression tests for the bilingual corpus scope and structural signature. */ import { describe, expect, it } from 'vitest' import { - datedDocumentDate, - isIsoDate, + isTranslationScopeFile, parseTranslationMarkdown, parseTranslationPairingManifest, - requiresPairByDate, translationStructureDiff, translationStructureSignature, } from './translation-pairing.ts' @@ -16,49 +14,60 @@ function signature(markdown: string) { } describe('translation pairing manifest', () => { - it('accepts a real ISO cutoff and string-array fields', () => { + it('accepts an exclusions-only manifest', () => { expect(parseTranslationPairingManifest(JSON.stringify({ - requiredSince: '2026-07-14', - required: ['README.md'], excluded: ['docs/generated/'], }))).toEqual({ - requiredSince: '2026-07-14', - required: ['README.md'], excluded: ['docs/generated/'], }) }) - it.each(['2026-7-14', '2026-02-29', '2026-13-01', 'not-a-date'])('rejects invalid cutoff %s', (cutoff) => { - expect(isIsoDate(cutoff)).toBe(false) + it.each([ + ['required', ['packages/README.md']], + ['requiredClasses', ['readme']], + ['requiredSince', '2026-07-14'], + ] as const)('rejects obsolete policy field %s instead of accepting an inert requirement', (field, value) => { expect(() => parseTranslationPairingManifest(JSON.stringify({ - requiredSince: cutoff, - required: [], excluded: [], - }))).toThrow('requiredSince must be a valid YYYY-MM-DD date') + [field]: value, + }))).toThrow(`unsupported field(s): ${field}; every in-scope document is required`) }) - it('rejects non-string manifest arrays', () => { + it('rejects a missing or non-string exclusion list', () => { + expect(() => parseTranslationPairingManifest('{}')).toThrow('excluded must be an array of strings') expect(() => parseTranslationPairingManifest(JSON.stringify({ - requiredSince: '2026-07-14', - required: [42], - excluded: [], - }))).toThrow('required must be an array of strings') + excluded: [42], + }))).toThrow('excluded must be an array of strings') }) }) -describe('date-based pairing frontier', () => { - const cutoff = '2026-07-14' - - it('enforces the cutoff day and every later day, but not the preceding day', () => { - expect(requiresPairByDate('.agents/notes/2026-07-13-before.md', cutoff)).toBe(false) - expect(requiresPairByDate('.agents/notes/2026-07-14-at-cutoff.md', cutoff)).toBe(true) - expect(requiresPairByDate('.agents/notes/2026-07-15-after.md', cutoff)).toBe(true) +describe('translation scope discovery', () => { + it.each([ + 'README.md', + 'apps/cli/README.md', + 'future/subtree/readme.md', + 'packages/example/README.zh.md', + 'native/example/README.i18n.yaml', + '.agents/notes/proposed/feature.md', + 'docs/guide.md', + 'python/guide.md', + ])('includes %s', (file) => { + expect(isTranslationScopeFile(file)).toBe(true) }) - it('matches only a date at the start of the basename', () => { - expect(datedDocumentDate('.agents/notes/2026-07-14-proposal.md')).toBe('2026-07-14') - expect(datedDocumentDate('docs/release-notes-2026-07-14-alpha.md')).toBeUndefined() - expect(requiresPairByDate('docs/release-notes-2026-07-14-alpha.md', cutoff)).toBe(false) + it.each([ + 'packages/example/guide.md', + 'examples/tutorial.md', + 'website/reference.md', + 'packages/example/README.txt', + 'vendor/example/README.md', + 'packages/example/node_modules/dependency/README.md', + 'packages/example/lib/README.md', + 'coverage/report/README.md', + 'python/sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-macos-arm64/README.md', + 'python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/README.md', + ])('excludes non-source or non-README path %s', (file) => { + expect(isTranslationScopeFile(file)).toBe(false) }) }) diff --git a/scripts/translation-pairing.ts b/scripts/translation-pairing.ts index b7238fa3aa..a7c626dddd 100644 --- a/scripts/translation-pairing.ts +++ b/scripts/translation-pairing.ts @@ -1,7 +1,7 @@ /** * Pure parsing and structural helpers for the bilingual-document pairing - * gate. Kept separate from the CLI so cutoff and signature behavior can be - * regression-tested without reading or mutating the repository tree. + * gate. Kept separate from the CLI so corpus discovery and signature behavior + * can be regression-tested without reading or mutating the repository tree. */ import { fromMarkdown } from 'mdast-util-from-markdown' @@ -11,31 +11,77 @@ import type { Nodes } from 'mdast' /** Validated shape of `scripts/translation-pairing.manifest.json`. */ export interface TranslationPairingManifest { - required: string[] + /** Source documents exempt from pairing because they are generated, instructional, or bilingual by construction. */ excluded: string[] - /** Date-named documents on or after this day must merge bilingual. */ - requiredSince: string } -const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/ -const DATED_DOCUMENT = /(?:^|\/)(\d{4}-\d{2}-\d{2})-[^/]*\.md$/ +const README_ARTIFACT = /(?:^|\/)readme(?:\.md|\.zh\.md|\.i18n\.yaml)$/i +const NON_SOURCE_DIRECTORIES = new Set([ + 'node_modules', + 'lib', + '.pnpm-store', + '.cache', + 'coverage', + '.sessions', + '.storages', + 'tmp', + 'dist-exe', + '__pycache__', + '.pytest_cache', + '.artifacts', + 'vendor', +]) -/** Whether a string names one real calendar day in canonical ISO form. */ -export function isIsoDate(value: string): boolean { - if (!ISO_DATE.test(value)) return false - const date = new Date(`${value}T00:00:00.000Z`) - return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value +/** Glob traversal exclusions corresponding to the non-source path predicate. */ +export const TRANSLATION_SCOPE_GLOB_EXCLUDES = [ + '**/node_modules/**', + '**/lib/**', + '**/.pnpm-store/**', + '**/.cache/**', + '**/coverage/**', + '**/.doc-typecheck-*/**', + '**/.node-next-types-*/**', + '**/.sessions/**', + '**/.storages/**', + '**/tmp/**', + '**/dist-exe/**', + '**/__pycache__/**', + '**/.pytest_cache/**', + 'apps/web/dist/**', + '.artifacts/**', + 'python/sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-*/**', + 'python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/**', + 'vendor/**', +] + +/** Whether a repository-relative path belongs to a dependency or generated tree. */ +function isTranslationSourceExcluded(file: string): boolean { + const segments = file.split('/') + return segments.some(segment => NON_SOURCE_DIRECTORIES.has(segment) + || segment.startsWith('.doc-typecheck-') + || segment.startsWith('.node-next-types-')) + || file.startsWith('apps/web/dist/') + || file.startsWith('python/sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-') + || file.startsWith('python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/') } -/** Read one manifest string-array field or fail before enforcement starts. */ -function stringArrayField(record: Record<string, unknown>, field: 'required' | 'excluded'): string[] { - const value = record[field] +/** Whether one discovered Markdown or sidecar path belongs to the bilingual source corpus. */ +export function isTranslationScopeFile(file: string): boolean { + return !isTranslationSourceExcluded(file) && (README_ARTIFACT.test(file) + || file.startsWith('.agents/notes/') + || file.startsWith('docs/') + || file.startsWith('python/')) +} + +/** Read the manifest exclusion list or fail before enforcement starts. */ +function excludedField(record: Record<string, unknown>): string[] { + const value = record.excluded if (!Array.isArray(value)) { - throw new Error(`translation-pairing.manifest.json: ${field} must be an array of strings`) + throw new Error('translation-pairing.manifest.json: excluded must be an array of strings') } const entries: unknown[] = value if (!entries.every((entry): entry is string => typeof entry === 'string')) { - throw new Error(`translation-pairing.manifest.json: ${field} must be an array of strings`) + throw new Error('translation-pairing.manifest.json: excluded must be an array of strings') } return entries } @@ -47,26 +93,11 @@ export function parseTranslationPairingManifest(content: string): TranslationPai throw new Error('translation-pairing.manifest.json: expected an object') } const record = value as Record<string, unknown> - const requiredSince = record.requiredSince - if (typeof requiredSince !== 'string' || !isIsoDate(requiredSince)) { - throw new Error(`translation-pairing.manifest.json: requiredSince must be a valid YYYY-MM-DD date; got ${JSON.stringify(requiredSince)}`) + const unsupported = Object.keys(record).filter(field => field !== 'excluded') + if (unsupported.length > 0) { + throw new Error(`translation-pairing.manifest.json: unsupported field(s): ${unsupported.join(', ')}; every in-scope document is required`) } - return { - required: stringArrayField(record, 'required'), - excluded: stringArrayField(record, 'excluded'), - requiredSince, - } -} - -/** Return the leading date of a `yyyy-mm-dd-*.md` basename, if present. */ -export function datedDocumentDate(file: string): string | undefined { - return DATED_DOCUMENT.exec(file)?.[1] -} - -/** Whether a date-named document falls on or after the pairing cutoff. */ -export function requiresPairByDate(file: string, requiredSince: string): boolean { - const date = datedDocumentDate(file) - return date !== undefined && date >= requiredSince + return { excluded: excludedField(record) } } /** The structural surface compared between the two sides of a pair. */ diff --git a/scripts/ts-project.ts b/scripts/ts-project.ts index 930c055d4f..9a0400a39d 100644 --- a/scripts/ts-project.ts +++ b/scripts/ts-project.ts @@ -11,7 +11,8 @@ interface ProjectGraph { options: ts.CompilerOptions } -const configHost: ts.ParseConfigFileHost = { +/** TypeScript config host shared by repository scripts. */ +export const repositoryConfigHost: ts.ParseConfigFileHost = { useCaseSensitiveFileNames: ts.sys.useCaseSensitiveFileNames, readDirectory: (...args) => ts.sys.readDirectory(...args), fileExists: fileName => ts.sys.fileExists(fileName), @@ -52,7 +53,7 @@ function loadProjectGraph(projectRoot: string): ProjectGraph { /** Parse one config file and fail loud on any config diagnostic. */ function parseConfig(configPath: string): ts.ParsedCommandLine { - const parsed = ts.getParsedCommandLineOfConfigFile(configPath, {}, configHost) + const parsed = ts.getParsedCommandLineOfConfigFile(configPath, {}, repositoryConfigHost) if (!parsed) throw new Error(`cannot parse TypeScript config ${configPath}`) if (parsed.errors.length > 0) { throw new Error(parsed.errors.map(error => ts.flattenDiagnosticMessageText(error.messageText, '\n')).join('\n')) diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 506b01f5ec..02665b6dce 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1,5 +1,5 @@ { - "comment": "Maps each ` ```ts type-equiv ` or ` ```ts public-api ` block (by doc + declared symbol + projection) to the source declaration and original JSDoc it must match. Omit projection for the complete declaration; use public-api with a ` ```ts public-api ` block for a body-stripped public class declaration. verify-type-equiv.ts enforces a 1:1 correspondence: every source-equivalence block has exactly one entry here, and every entry resolves to exactly one block. Add an entry when you add a source-equivalence block; remove it when you remove the block.", + "comment": "Maps each primary ` ```ts type-equiv ` or ` ```ts public-api ` block (by doc + declared symbol + projection) to the source declaration and original JSDoc it must match. Paired `.zh.md` blocks are byte-identical derivatives checked through their unsuffixed sibling and have no duplicate entry. Omit projection for the complete declaration; use public-api with a ` ```ts public-api ` block for a body-stripped public class declaration. verify-type-equiv.ts enforces a 1:1 correspondence between primary blocks and entries. Add an entry when you add a primary source-equivalence block; remove it when you remove the block.", "entries": [ { "doc": "docs/core-data-structures/core.md", @@ -1253,954 +1253,6 @@ "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchHit", "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "Branded", - "source": "packages/util/brand/src/index.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "ContentBlockMap", - "source": "packages/llm/llm/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "AssistantProvenance", - "source": "packages/llm/llm/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "Message", - "source": "packages/llm/llm/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "MessageSourceMap", - "source": "packages/llm/llm/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "FinishReasonMap", - "source": "packages/llm/llm/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "LlmProviderInfo", - "source": "packages/llm/llm/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "LlmModelInfo", - "source": "packages/llm/llm/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "LlmModelContext", - "source": "packages/llm/llm/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "GenerateOptions", - "source": "packages/llm/llm/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "ToolSchema", - "source": "packages/llm/llm/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "LlmCallConfig", - "source": "packages/llm/llm/src/call-config.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "SessionEvent", - "source": "packages/core/session/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "SendOptions", - "source": "packages/core/agent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "AgentCancelCause", - "source": "packages/core/agent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "InjectOptions", - "source": "packages/core/agent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "ResolvedAgentInput", - "source": "packages/core/agent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "AgentMessageId", - "source": "packages/core/agent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "AgentMessage", - "source": "packages/core/agent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "CancelOptions", - "source": "packages/core/agent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "Agent", - "source": "packages/core/agent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "HookContext", - "source": "packages/core/agent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "PromptDecision", - "source": "packages/core/agent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "ContinuationDecision", - "source": "packages/core/agent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "RequestError", - "source": "packages/core/agent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "RequestErrorDecision", - "source": "packages/core/agent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "ContinuationStop", - "source": "packages/core/agent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.zh.md", - "symbol": "SessionStartSource", - "source": "packages/core/agent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/scope.zh.md", - "symbol": "ScopeKey", - "source": "packages/core/scope/src/index.ts" - }, - { - "doc": "docs/core-data-structures/scope.zh.md", - "symbol": "Scoped", - "source": "packages/core/scope/src/index.ts" - }, - { - "doc": "docs/core-data-structures/scope.zh.md", - "symbol": "Scope", - "source": "packages/core/scope/src/index.ts" - }, - { - "doc": "docs/core-data-structures/scope.zh.md", - "symbol": "ScopeLayer", - "source": "packages/core/scope/src/store.ts" - }, - { - "doc": "docs/core-data-structures/system-prompt.zh.md", - "symbol": "AssembleContext", - "source": "packages/core/system-prompt/src/index.ts" - }, - { - "doc": "docs/core-data-structures/system-prompt.zh.md", - "symbol": "PromptSection", - "source": "packages/core/system-prompt/src/index.ts" - }, - { - "doc": "docs/core-data-structures/system-prompt.zh.md", - "symbol": "ToolProviderResult", - "source": "packages/core/system-prompt/src/index.ts" - }, - { - "doc": "docs/core-data-structures/llm-streaming.zh.md", - "symbol": "StreamChunk", - "source": "packages/llm/llm/src/types.ts" - }, - { - "doc": "docs/core-data-structures/llm-streaming.zh.md", - "symbol": "LlmFailure", - "source": "packages/llm/llm/src/types.ts" - }, - { - "doc": "docs/core-data-structures/llm-streaming.zh.md", - "symbol": "TokenUsage", - "source": "packages/llm/llm/src/types.ts" - }, - { - "doc": "docs/core-data-structures/llm-streaming.zh.md", - "symbol": "ContentBlockMap", - "source": "packages/llm/llm/src/types.ts" - }, - { - "doc": "docs/core-data-structures/llm-streaming.zh.md", - "symbol": "AppIdentity", - "source": "packages/llm/llm/src/attribution.ts" - }, - { - "doc": "docs/core-data-structures/llm-streaming.zh.md", - "symbol": "BlockAssembler", - "source": "packages/llm/llm/src/assembler.ts", - "projection": "public-api" - }, - { - "doc": "docs/core-data-structures/llm-streaming.zh.md", - "symbol": "LlmAdapter", - "source": "packages/llm/llm/src/index.ts", - "projection": "public-api" - }, - { - "doc": "docs/core-data-structures/session.zh.md", - "symbol": "PromptMessageData", - "source": "packages/core/session/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session.zh.md", - "symbol": "SessionEventMap", - "source": "packages/core/session/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session.zh.md", - "symbol": "OutOfBandSessionEventMap", - "source": "packages/core/session/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session.zh.md", - "symbol": "EpochHeader", - "source": "packages/core/session/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session.zh.md", - "symbol": "TodoItem", - "source": "packages/core/session/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session.zh.md", - "symbol": "SessionEvent", - "source": "packages/core/session/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session.zh.md", - "symbol": "TurnTriggerMap", - "source": "packages/core/session/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session.zh.md", - "symbol": "TurnEndReasonMap", - "source": "packages/core/session/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session.zh.md", - "symbol": "SurfaceEventType", - "source": "packages/core/session/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session.zh.md", - "symbol": "SurfaceOp", - "source": "packages/core/session/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session.zh.md", - "symbol": "SurfaceIntent", - "source": "packages/core/session/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session.zh.md", - "symbol": "SessionSurface", - "source": "packages/core/session/src/surface.ts" - }, - { - "doc": "docs/core-data-structures/session.zh.md", - "symbol": "SurfaceFoldReplacement", - "source": "packages/core/session/src/surface.ts" - }, - { - "doc": "docs/core-data-structures/session.zh.md", - "symbol": "SurfaceFoldResult", - "source": "packages/core/session/src/surface.ts" - }, - { - "doc": "docs/core-data-structures/session.zh.md", - "symbol": "Session", - "source": "packages/core/session/src/index.ts", - "projection": "public-api" - }, - { - "doc": "docs/core-data-structures/persistence.zh.md", - "symbol": "SessionHeader", - "source": "packages/core/session/src/types.ts" - }, - { - "doc": "docs/core-data-structures/persistence.zh.md", - "symbol": "CreateSessionOptions", - "source": "packages/core/session/src/types.ts" - }, - { - "doc": "docs/core-data-structures/persistence.zh.md", - "symbol": "SessionLocation", - "source": "packages/session-persistence/session-persistence/src/index.ts" - }, - { - "doc": "docs/core-data-structures/persistence.zh.md", - "symbol": "SessionPersistenceRevision", - "source": "packages/session-persistence/session-persistence/src/revision.ts" - }, - { - "doc": "docs/core-data-structures/persistence.zh.md", - "symbol": "SessionPersistenceSnapshot", - "source": "packages/session-persistence/session-persistence/src/index.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionEventSurface", - "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionRecord", - "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionLogSnapshot", - "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionSurfaceSnapshot", - "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionTitleObservation", - "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionTitleObservationResult", - "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionEventRecord", - "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionResultFilter", - "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionEventResultFilter", - "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionEventSearchDocument", - "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionSearchCursor", - "source": "packages/session-query/session-query/src/cursor.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionSearchRequest", - "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionEventSearchRequest", - "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionSearchPage", - "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionEventSearchPage", - "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionEventSearchHit", - "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionSearchHit", - "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionLineageNode", - "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionLineageTrace", - "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionQueryErrorCode", - "source": "packages/session-query/session-query/src/config.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionEventReadRequest", - "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionEventWindow", - "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionEventTraceRequest", - "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionEventTrace", - "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/session-query.zh.md", - "symbol": "SessionEventTraceObservation", - "source": "packages/session-query/session-query/src/types.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "ToolOutputDefinition", - "source": "packages/core/tools/src/index.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "ToolDefinition", - "source": "packages/core/tools/src/index.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "ValueSchemaSpec", - "source": "packages/core/tools/src/schema.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "ParameterPropertySpec", - "source": "packages/core/tools/src/schema.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "ParameterSchemaSpec", - "source": "packages/core/tools/src/schema.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "InferValue", - "source": "packages/core/tools/src/schema.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "InferArgs", - "source": "packages/core/tools/src/schema.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "ToolExecutionToken", - "source": "packages/core/tools/src/index.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "ToolExecutionInput", - "source": "packages/core/tools/src/index.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "ToolExecution", - "source": "packages/core/tools/src/index.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "ToolDispatchExecution", - "source": "packages/core/tools/src/index.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "ToolExecutionMode", - "source": "packages/core/tools/src/index.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "CodeDispatchLog", - "source": "packages/core/tools/src/index.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "ToolRunContext", - "source": "packages/core/tools/src/index.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "ToolGuard", - "source": "packages/core/tools/src/index.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "ToolRestriction", - "source": "packages/core/tools/src/index.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "ToolFailure", - "source": "packages/core/tools/src/index.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "ToolExecutionSuccess", - "source": "packages/core/tools/src/index.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "ToolExecutionFailure", - "source": "packages/core/tools/src/index.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "ToolExecutionResult", - "source": "packages/core/tools/src/index.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "PreToolDecision", - "source": "packages/core/tools/src/index.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "PostToolDecision", - "source": "packages/core/tools/src/index.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "JsonSchemaScalar", - "source": "packages/core/tools/src/json-schema.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "JsonSchemaType", - "source": "packages/core/tools/src/json-schema.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "JsonSchemaNode", - "source": "packages/core/tools/src/json-schema.ts" - }, - { - "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "ObjectJsonSchema", - "source": "packages/core/tools/src/json-schema.ts" - }, - { - "doc": "docs/core-data-structures/user-interaction.zh.md", - "symbol": "AskUserQuestionOption", - "source": "packages/ui/user-interaction/src/types.ts" - }, - { - "doc": "docs/core-data-structures/user-interaction.zh.md", - "symbol": "AskUserQuestionItem", - "source": "packages/ui/user-interaction/src/types.ts" - }, - { - "doc": "docs/core-data-structures/user-interaction.zh.md", - "symbol": "AskUserQuestionRequest", - "source": "packages/ui/user-interaction/src/index.ts" - }, - { - "doc": "docs/core-data-structures/user-interaction.zh.md", - "symbol": "AskUserQuestionAnswerItem", - "source": "packages/ui/user-interaction/src/types.ts" - }, - { - "doc": "docs/core-data-structures/user-interaction.zh.md", - "symbol": "AskUserQuestionAnswer", - "source": "packages/ui/user-interaction/src/types.ts" - }, - { - "doc": "docs/core-data-structures/user-interaction.zh.md", - "symbol": "UserInteractionProvider", - "source": "packages/ui/user-interaction/src/index.ts" - }, - { - "doc": "docs/core-data-structures/user-interaction.zh.md", - "symbol": "UserInteractionError", - "source": "packages/ui/user-interaction/src/index.ts" - }, - { - "doc": "docs/core-data-structures/approval.zh.md", - "symbol": "ApprovalRequestId", - "source": "packages/ui/user-approval/src/types.ts" - }, - { - "doc": "docs/core-data-structures/approval.zh.md", - "symbol": "ApprovalOutcome", - "source": "packages/ui/user-approval/src/types.ts" - }, - { - "doc": "docs/core-data-structures/approval.zh.md", - "symbol": "ApprovalPolicy", - "source": "packages/ui/user-approval/src/index.ts" - }, - { - "doc": "docs/core-data-structures/approval.zh.md", - "symbol": "ApprovalRequest", - "source": "packages/ui/user-approval/src/index.ts" - }, - { - "doc": "docs/core-data-structures/bash.zh.md", - "symbol": "DshEnvironmentKey", - "source": "packages/bash/bash/src/types.ts" - }, - { - "doc": "docs/core-data-structures/bash.zh.md", - "symbol": "DshEnvironment", - "source": "packages/bash/bash/src/types.ts" - }, - { - "doc": "docs/core-data-structures/bash.zh.md", - "symbol": "BashExecRequest", - "source": "packages/bash/bash/src/types.ts" - }, - { - "doc": "docs/core-data-structures/bash.zh.md", - "symbol": "BashExecSpec", - "source": "packages/bash/bash/src/types.ts" - }, - { - "doc": "docs/core-data-structures/bash.zh.md", - "symbol": "BashRunResult", - "source": "packages/bash/bash/src/types.ts" - }, - { - "doc": "docs/core-data-structures/bash.zh.md", - "symbol": "BashSandboxInfo", - "source": "packages/bash/bash/src/types.ts" - }, - { - "doc": "docs/core-data-structures/bash.zh.md", - "symbol": "CollectedOutput", - "source": "packages/bash/bash/src/types.ts" - }, - { - "doc": "docs/core-data-structures/bash.zh.md", - "symbol": "BashProcess", - "source": "packages/bash/bash/src/types.ts" - }, - { - "doc": "docs/core-data-structures/bash.zh.md", - "symbol": "BashProcessRead", - "source": "packages/bash/bash/src/types.ts" - }, - { - "doc": "docs/core-data-structures/sandbox.zh.md", - "symbol": "SandboxMode", - "source": "packages/sandbox/sandbox/src/index.ts" - }, - { - "doc": "docs/core-data-structures/sandbox.zh.md", - "symbol": "ConfinedSandboxMode", - "source": "packages/sandbox/sandbox/src/index.ts" - }, - { - "doc": "docs/core-data-structures/sandbox.zh.md", - "symbol": "SandboxExecutionPolicy", - "source": "packages/sandbox/sandbox/src/index.ts" - }, - { - "doc": "docs/core-data-structures/sandbox.zh.md", - "symbol": "SandboxEnforcement", - "source": "packages/sandbox/sandbox/src/index.ts" - }, - { - "doc": "docs/core-data-structures/sandbox.zh.md", - "symbol": "SandboxPolicy", - "source": "packages/sandbox/sandbox/src/index.ts" - }, - { - "doc": "docs/core-data-structures/sandbox.zh.md", - "symbol": "SandboxPolicyRequest", - "source": "packages/sandbox/sandbox-policy/src/index.ts" - }, - { - "doc": "docs/core-data-structures/sandbox.zh.md", - "symbol": "ConfinedArgv", - "source": "packages/sandbox/sandbox/src/index.ts" - }, - { - "doc": "docs/core-data-structures/code-runtime.zh.md", - "symbol": "CodeJsonValue", - "source": "packages/code-runtime/code-runtime/src/types.ts" - }, - { - "doc": "docs/core-data-structures/code-runtime.zh.md", - "symbol": "CodeRunRequest", - "source": "packages/code-runtime/code-runtime/src/types.ts" - }, - { - "doc": "docs/core-data-structures/code-runtime.zh.md", - "symbol": "CodeRunResult", - "source": "packages/code-runtime/code-runtime/src/types.ts" - }, - { - "doc": "docs/core-data-structures/code-runtime.zh.md", - "symbol": "CodeBindingNamespace", - "source": "packages/code-runtime/code-runtime/src/types.ts" - }, - { - "doc": "docs/core-data-structures/code-runtime.zh.md", - "symbol": "CodeBindingErrorClass", - "source": "packages/code-runtime/code-runtime/src/types.ts" - }, - { - "doc": "docs/core-data-structures/code-runtime.zh.md", - "symbol": "CodeBindingFunction", - "source": "packages/code-runtime/code-runtime/src/types.ts" - }, - { - "doc": "docs/core-data-structures/code-runtime.zh.md", - "symbol": "CodeRunFailure", - "source": "packages/code-runtime/code-runtime/src/types.ts" - }, - { - "doc": "docs/core-data-structures/filesystem.zh.md", - "symbol": "FsTarget", - "source": "packages/fs/fs/src/types.ts" - }, - { - "doc": "docs/core-data-structures/filesystem.zh.md", - "symbol": "FsTargetKey", - "source": "packages/fs/fs/src/types.ts" - }, - { - "doc": "docs/core-data-structures/filesystem.zh.md", - "symbol": "FsVersion", - "source": "packages/fs/fs/src/types.ts" - }, - { - "doc": "docs/core-data-structures/filesystem.zh.md", - "symbol": "FsInfo", - "source": "packages/fs/fs/src/types.ts" - }, - { - "doc": "docs/core-data-structures/filesystem.zh.md", - "symbol": "FsPathInfo", - "source": "packages/fs/fs/src/types.ts" - }, - { - "doc": "docs/core-data-structures/filesystem.zh.md", - "symbol": "FsDirEntry", - "source": "packages/fs/fs/src/types.ts" - }, - { - "doc": "docs/core-data-structures/filesystem.zh.md", - "symbol": "FsWriteIntent", - "source": "packages/fs/fs/src/types.ts" - }, - { - "doc": "docs/core-data-structures/filesystem.zh.md", - "symbol": "FsWriteOutcome", - "source": "packages/fs/fs/src/types.ts" - }, - { - "doc": "docs/core-data-structures/filesystem.zh.md", - "symbol": "FsEditRequest", - "source": "packages/fs/fs/src/types.ts" - }, - { - "doc": "docs/core-data-structures/filesystem.zh.md", - "symbol": "FsEditOutcome", - "source": "packages/fs/fs/src/types.ts" - }, - { - "doc": "docs/core-data-structures/filesystem.zh.md", - "symbol": "FsErrorCode", - "source": "packages/fs/fs/src/types.ts" - }, - { - "doc": "docs/core-data-structures/filesystem.zh.md", - "symbol": "FsPolicyExec", - "source": "packages/fs/fs-policy/src/types.ts" - }, - { - "doc": "docs/core-data-structures/filesystem.zh.md", - "symbol": "FileReadOutcome", - "source": "packages/fs/tool-fs/src/read-render.ts" - }, - { - "doc": "docs/core-data-structures/skills.zh.md", - "symbol": "SkillSource", - "source": "packages/skill/skill/src/index.ts" - }, - { - "doc": "docs/core-data-structures/skills.zh.md", - "symbol": "SkillResourceBase", - "source": "packages/skill/skill/src/index.ts" - }, - { - "doc": "docs/core-data-structures/skills.zh.md", - "symbol": "SkillSummary", - "source": "packages/skill/skill/src/index.ts" - }, - { - "doc": "docs/core-data-structures/skills.zh.md", - "symbol": "SkillCandidate", - "source": "packages/skill/skill/src/index.ts" - }, - { - "doc": "docs/core-data-structures/skills.zh.md", - "symbol": "SkillDefinition", - "source": "packages/skill/skill/src/index.ts" - }, - { - "doc": "docs/core-data-structures/skills.zh.md", - "symbol": "SkillRegistration", - "source": "packages/skill/skill/src/index.ts" - }, - { - "doc": "docs/core-data-structures/skills.zh.md", - "symbol": "SkillLookupOptions", - "source": "packages/skill/skill/src/index.ts" - }, - { - "doc": "docs/core-data-structures/skills.zh.md", - "symbol": "SkillProvider", - "source": "packages/skill/skill/src/index.ts" - }, - { - "doc": "docs/core-data-structures/skills.zh.md", - "symbol": "Config", - "source": "packages/skill/skill/src/index.ts" - }, - { - "doc": "docs/core-data-structures/compaction.zh.md", - "symbol": "CompactionResult", - "source": "packages/compact/compact/src/types.ts" - }, - { - "doc": "docs/core-data-structures/compaction.zh.md", - "symbol": "CompactionTrigger", - "source": "packages/compact/compact/src/index.ts" - }, - { - "doc": "docs/core-data-structures/compaction.zh.md", - "symbol": "PrunedEntry", - "source": "packages/compact/compact-tool-result-prune/src/types.ts" - }, - { - "doc": "docs/core-data-structures/compaction.zh.md", - "symbol": "PruneResult", - "source": "packages/compact/compact-tool-result-prune/src/types.ts" - }, - { - "doc": "docs/core-data-structures/subagent.zh.md", - "symbol": "SubagentCapabilities", - "source": "packages/subagent/subagent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/subagent.zh.md", - "symbol": "SubagentStartRequest", - "source": "packages/subagent/subagent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/subagent.zh.md", - "symbol": "SubagentResult", - "source": "packages/subagent/subagent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/subagent.zh.md", - "symbol": "SubagentStopReasonMap", - "source": "packages/subagent/subagent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/subagent.zh.md", - "symbol": "SubagentRun", - "source": "packages/subagent/subagent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/subagent.zh.md", - "symbol": "SubagentProvider", - "source": "packages/subagent/subagent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/web.zh.md", - "symbol": "WebSearchRequest", - "source": "packages/web/web/src/types.ts" - }, - { - "doc": "docs/core-data-structures/web.zh.md", - "symbol": "WebSearchResult", - "source": "packages/web/web/src/types.ts" - }, - { - "doc": "docs/core-data-structures/web.zh.md", - "symbol": "WebSearchSource", - "source": "packages/web/web/src/types.ts" - }, - { - "doc": "docs/core-data-structures/web.zh.md", - "symbol": "WebFetchRequest", - "source": "packages/web/web/src/types.ts" - }, - { - "doc": "docs/core-data-structures/web.zh.md", - "symbol": "WebFetchResult", - "source": "packages/web/web/src/types.ts" - }, - { - "doc": "docs/core-data-structures/web.zh.md", - "symbol": "WebFetchBody", - "source": "packages/web/web/src/types.ts" - }, - { - "doc": "docs/core-data-structures/workflow.zh.md", - "symbol": "WorkflowStartRequest", - "source": "packages/workflow/workflow/src/types.ts" - }, - { - "doc": "docs/core-data-structures/workflow.zh.md", - "symbol": "WorkflowMeta", - "source": "packages/workflow/workflow/src/types.ts" - }, - { - "doc": "docs/core-data-structures/workflow.zh.md", - "symbol": "WorkflowResult", - "source": "packages/workflow/workflow/src/types.ts" - }, - { - "doc": "docs/core-data-structures/workflow.zh.md", - "symbol": "WorkflowRun", - "source": "packages/workflow/workflow/src/types.ts" } ] } diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index d68df7adcd..4824138f83 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -59,7 +59,10 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = { 'packages/client/ui-trajectory': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-workspace': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-theme': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, - 'packages/client/i18n': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, + 'packages/client/ui-settings': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, + 'packages/client/ui-settings-general': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, + 'packages/client/ui-models': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, + 'packages/client/locale': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/web': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/examples/agent-spine-demo': { kind: 'indirect', reason: 'The bundle only mounts model-facing child plugins.' }, 'packages/fs/fs': { kind: 'indirect', reason: 'The service interface delegates model rendering to dsh-tool-fs.' }, diff --git a/scripts/verify-translation-pairing.ts b/scripts/verify-translation-pairing.ts index 860e193b4f..d1211c3dd4 100644 --- a/scripts/verify-translation-pairing.ts +++ b/scripts/verify-translation-pairing.ts @@ -1,9 +1,9 @@ /** * Enforce complete English/Chinese pairs, matching structure, and recorded git - * blob hashes under the bilingual manifest. Required files and date-named docs - * at or after `requiredSince` must be paired; excluded docs may have neither a - * counterpart nor sidecar. `--list` reports state and `--write` records both - * sides after human review. Translation quality remains a review responsibility. + * blob hashes for every in-scope document. The manifest contains only explicit + * exclusions, which may have neither a counterpart nor a sidecar. + * `--list` reports state and `--write` records both sides after human review. + * Translation quality remains a review responsibility. * See `docs/i18n/README.md` for the owning contract. */ @@ -11,11 +11,11 @@ import { createHash } from 'node:crypto' import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs' import { basename, join, resolve, sep } from 'node:path' import { - datedDocumentDate, linksTo, parseTranslationMarkdown, parseTranslationPairingManifest, - requiresPairByDate, + isTranslationScopeFile, + TRANSLATION_SCOPE_GLOB_EXCLUDES, translationStructureDiff, translationStructureSignature, } from './translation-pairing.ts' @@ -24,17 +24,12 @@ const root = resolve(import.meta.dirname, '..') const listMode = process.argv.includes('--list') const writeMode = process.argv.includes('--write') -/** Scope of the bilingual contract: root docs, Agent Notes, the docs tree, and the Python SDK tree. */ +/** Discover source Markdown and pairing sidecars before applying the corpus predicate. */ const SCOPE_PATTERNS = [ - 'README.md', - 'README.zh.md', - 'README.i18n.yaml', + '**/*.md', + '**/*.i18n.yaml', '.agents/notes/**/*.md', '.agents/notes/**/*.i18n.yaml', - 'docs/**/*.md', - 'docs/**/*.i18n.yaml', - 'python/**/*.md', - 'python/**/*.i18n.yaml', ] const manifest = parseTranslationPairingManifest(readFileSync(join(root, 'scripts/translation-pairing.manifest.json'), 'utf8')) @@ -92,7 +87,10 @@ function renderMeta(source: string, sourceHash: string, zh: string, zhHash: stri // Enumerate the scope once. const files = new Set<string>() for (const pattern of SCOPE_PATTERNS) { - for (const match of globSync(pattern, { cwd: root })) files.add(match.split(sep).join('/')) + for (const match of globSync(pattern, { cwd: root, exclude: TRANSLATION_SCOPE_GLOB_EXCLUDES })) { + const normalized = match.split(sep).join('/') + if (isTranslationScopeFile(normalized)) files.add(normalized) + } } const translations = [...files].filter(f => f.endsWith('.zh.md')).sort() const metas = [...files].filter(f => f.endsWith('.i18n.yaml')).sort() @@ -118,34 +116,17 @@ if (writeMode) { const errors: string[] = [] const state = new Map<string, 'ok' | 'out-of-sync' | 'missing'>() -// 1. Required pairs exist. -for (const req of manifest.required) { - if (!existsSync(join(root, req))) { - errors.push(`${req}: listed in translation-pairing.manifest.json \`required\` but the file does not exist`) - continue - } - const { zh } = pairPaths(req) - if (!existsSync(join(root, zh))) { - errors.push(`${req}: required to have a translation, but ${zh} does not exist`) - state.set(req, 'missing') - } -} - -// 2. Date-named documents (Agent Notes) dated on/after the requiredSince cutoff merge -// bilingual: a new Agent Note lands with its pair or not at all. Deterministic from -// the filename alone — no git history, so it holds on shallow CI checkouts. +// 1. Every discovered, non-excluded source merges bilingual. for (const source of sources) { if (isExcluded(source)) continue - const date = datedDocumentDate(source) - if (!requiresPairByDate(source, manifest.requiredSince) || date === undefined) continue const { zh } = pairPaths(source) if (!existsSync(join(root, zh))) { - errors.push(`${source}: dated ${date} — documents dated on/after ${manifest.requiredSince} merge bilingual (docs/i18n/README.md); add the counterpart and record the pair`) + errors.push(`${source}: in-scope documentation must merge bilingual (docs/i18n/README.md); add the counterpart and record the pair`) state.set(source, 'missing') } } -// 3. Every pair that exists at all is complete and consistent. Anchor on the +// 2. Every pair that exists at all is complete and consistent. Anchor on the // union of .zh.md files and .i18n.yaml records so a half-deleted pair is // caught from either remnant. const pairAnchors = new Set<string>() @@ -205,7 +186,7 @@ for (const source of [...pairAnchors].sort()) { if (!state.has(source)) state.set(source, 'ok') } -// Complete the state map for --list: any in-scope, non-excluded document with no pair yet is backlog. +// Complete the state map for --list: any in-scope, non-excluded document with no pair is missing. for (const source of sources) { if (!isExcluded(source) && !state.has(source)) state.set(source, 'missing') } @@ -214,9 +195,7 @@ if (listMode) { const order = { 'out-of-sync': 0, missing: 1, ok: 2 } as const const rows = [...state.entries()].sort((a, b) => order[a[1]] - order[b[1]] || a[0].localeCompare(b[0])) for (const [file, status] of rows) { - const required = manifest.required.includes(file) - const tag = required ? ' (required)' : requiresPairByDate(file, manifest.requiredSince) ? ' (required by date)' : ' (backlog)' - console.log(`${status.padEnd(11)} ${file}${status === 'missing' ? tag : ''}`) + console.log(`${status.padEnd(11)} ${file}${status === 'missing' ? ' (required)' : ''}`) } const counts = { 'ok': 0, 'out-of-sync': 0, 'missing': 0 } for (const status of state.values()) counts[status]++ @@ -225,7 +204,7 @@ if (listMode) { } if (errors.length === 0) { - console.log(`verify-translation-pairing: ${pairAnchors.size} pair(s) checked against ${manifest.required.length} required, all consistent.`) + console.log(`verify-translation-pairing: ${pairAnchors.size} pair(s) checked across all in-scope documentation, all consistent.`) process.exit(0) } diff --git a/scripts/verify-type-equiv.ts b/scripts/verify-type-equiv.ts index 7a3d2305d7..58cfea238d 100644 --- a/scripts/verify-type-equiv.ts +++ b/scripts/verify-type-equiv.ts @@ -4,12 +4,14 @@ * declaration; `public-api` entries preserve a class's body-stripped public * declaration. Blocks and entries have a one-to-one relationship; comparison * ignores whitespace and non-JSDoc comments but preserves declaration - * structure and every original JSDoc comment. + * structure and every original JSDoc comment. Byte-identical `.zh.md` blocks + * reuse the manifest-backed check of their unsuffixed sibling. */ import { globSync, readFileSync, existsSync } from 'node:fs' import { resolve, sep } from 'node:path' import ts from 'typescript' +import { partitionPairedMarkdownDerivatives } from './paired-markdown-derivatives.ts' const root = resolve(import.meta.dirname, '..') @@ -223,7 +225,12 @@ const docSet = new Set<string>() for (const pattern of MARKDOWN_GLOBS) { for (const match of globSync(pattern, { cwd: root })) docSet.add(match.split(sep).join('/')) } -const blocks: EquivBlock[] = [...docSet].sort().flatMap(extractEquivBlocks) +const extractedBlocks: EquivBlock[] = [...docSet].sort().flatMap(extractEquivBlocks) +const { primary: blocks, derivatives } = partitionPairedMarkdownDerivatives( + extractedBlocks, + block => block.doc, + block => `${block.projection ?? 'declaration'}\0${block.code}`, +) const errors: string[] = [] // A manifest entry naming a doc that does not exist (or is outside the scanned @@ -299,11 +306,11 @@ for (const e of entries) { } if (errors.length === 0) { - console.log(`verify-type-equiv: ${verified} type-equiv block(s) match source structure and JSDoc (1:1 with manifest).`) + console.log(`verify-type-equiv: ${verified} type-equiv block(s) match source structure and JSDoc (1:1 with manifest); ${derivatives.length} paired derivative(s).`) process.exit(0) } console.error('verify-type-equiv: type-equiv verification failed:') for (const e of errors) console.error(` ${e}`) -console.error(`\n(checked ${blocks.length} block(s) across ${new Set(blocks.map(b => b.doc)).size} doc(s); manifest at scripts/type-equiv.manifest.json)`) +console.error(`\n(checked ${blocks.length} primary block(s) across ${new Set(blocks.map(b => b.doc)).size} doc(s), ${derivatives.length} paired derivative(s); manifest at scripts/type-equiv.manifest.json)`) process.exit(1) diff --git a/tsconfig.base.json b/tsconfig.base.json index a449a34c4e..378b5fb437 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -115,7 +115,10 @@ "@deepseek-ai/dsh-client-ui-trajectory": ["./packages/client/ui-trajectory/src"], "@deepseek-ai/dsh-client-ui-workspace": ["./packages/client/ui-workspace/src"], "@deepseek-ai/dsh-client-ui-theme": ["./packages/client/ui-theme/src"], - "@deepseek-ai/dsh-client-i18n": ["./packages/client/i18n/src"], + "@deepseek-ai/dsh-client-ui-settings": ["./packages/client/ui-settings/src"], + "@deepseek-ai/dsh-client-ui-settings-general": ["./packages/client/ui-settings-general/src"], + "@deepseek-ai/dsh-client-ui-models": ["./packages/client/ui-models/src"], + "@deepseek-ai/dsh-client-locale": ["./packages/client/locale/src"], "@deepseek-ai/dsh-client-web": ["./packages/client/web/src"], "@deepseek-ai/dsh-*": [ "./packages/core/*/src", diff --git a/tsconfig.client.json b/tsconfig.client.json index 17f34b601f..afadb08b14 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -38,7 +38,10 @@ { "path": "./packages/client/ui-question" }, { "path": "./packages/client/ui-trajectory" }, { "path": "./packages/client/ui-theme" }, - { "path": "./packages/client/i18n" }, + { "path": "./packages/client/ui-settings" }, + { "path": "./packages/client/ui-settings-general" }, + { "path": "./packages/client/ui-models" }, + { "path": "./packages/client/locale" }, { "path": "./packages/client/web" }, { "path": "./apps/web" } ]