Merge branch 'master' into codex/ignore-archived-agent-notes-in-rg

This commit is contained in:
Turtle
2026-08-10 15:47:05 +08:00
committed by GitHub
234 changed files with 6394 additions and 558 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md
2026-07-05-reconstructable-requests.md: c13e40b5b22e5d6fe19f6b5bd137db3a48350390
2026-07-05-reconstructable-requests.zh.md: 746bcd37dabada949873d394776c1ca43eff4ea2
2026-07-05-reconstructable-requests.md: d7b867d6e17c2dfe0e49b70bd0f5e7ff173fb572
2026-07-05-reconstructable-requests.zh.md: c41e5f89558a9c7b09ccd2bf4fd5b7d9a6cde419

View File

@@ -14,7 +14,7 @@ The reference shape for the happy path is MiniCode's `LLMClient`: a stateful con
### The principle
**Model-visible ⟺ logged.** Anything that reaches a model request must be recorded in the session log. The checkable consequence: **every conversation request the loop sends is a pure function of the session log** — anyone holding the log reconstructs it byte-for-byte. Scope, stated precisely: the guarantee covers the loop-built `GenerateOptions`; provider wire bytes follow from it because both adapters' serialization is a pure per-message function at a pinned code version; direct one-shots (compaction's summarize call) log their envelope scalars (`compact/summary.{provider, model, maxTokens}`) and their input is deterministic code over the logged region — reconstructable from log + code, outside the invariant because only the loop marks request ownership.
**Model-visible ⟺ durably referenced.** Anything that reaches a model request must be reconstructable from the session log and the immutable content-addressed objects it references. The checkable consequence: anyone holding the log, its referenced attachment objects, and the pinned code version reconstructs every loop request byte-for-byte. Text-only `GenerateOptions` remain a pure function of the log; image-bearing requests additionally resolve `ImageAttachmentRef` bytes through `ctx.attachments` during adapter serialization, where digest and recorded metadata verification make the object lookup deterministic and fail loud on missing or corrupt data. Direct one-shots (compaction's summarize call) log their envelope scalars (`compact/summary.{provider, model, maxTokens}`), and their input is deterministic code over the logged region plus those referenced objects — outside the invariant because only the loop marks request ownership.
Prefix-cache stability is corollary #1, not the headline: an append-only log projected by a per-node pure function yields requests that are append-extensions of their predecessors whenever the header is unchanged — stability is emergent, not managed. Byte-exact audit/replay is corollary #2; resume and fork with *attributable* drift is corollary #3.

View File

@@ -14,7 +14,7 @@ Status: implemented
### 原则
**模型可见 ⟺ 已记录** 凡到达模型请求的内容都必须记录在会话日志中。可检查的推论:**循环发出的每个对话请求都是会话日志的纯函数**——任何人持有日志即可逐字节重建请求。精确的范围声明:该保证覆盖循环构建的 `GenerateOptions`提供方协议格式wire format字节由此推导而来因为两个适配器的序列化在固定代码版本下都是逐消息的纯函数直接的一次性调用(压缩的 summarize 调用)记录其信封标量(`compact/summary.{provider, model, maxTokens}`),其输入是对日志区域的确定性代码运算——可从日志加代码重建,因为只有循环会标记请求归属,所以它们不在不变式内。
**模型可见 ⟺ 已持久引用** 凡到达模型请求的内容都必须能从会话日志及其引用的不可变内容寻址对象中重建。可检查的推论:任何人持有日志、日志引用的附件对象和固定代码版本,即可逐字节重建循环的每个请求。纯文本 `GenerateOptions` 仍是日志的纯函数;含图片请求还会在适配器序列化期间通过 `ctx.attachments` 解析 `ImageAttachmentRef` 字节,其中对内容摘要及已记录元数据的校验使对象查找具有确定性,并在数据缺失或损坏时明确失败。直接的一次性调用(压缩的 summarize 调用)记录其信封标量(`compact/summary.{provider, model, maxTokens}`),其输入是对日志区域及这些引用对象的确定性代码运算——由于只有循环会标记请求归属,因此它们不在不变式内。
前缀缓存稳定性是推论 #1,而非标题:一个仅追加的日志经逐节点纯函数投影,在 header 不变时自然产出前一请求的追加扩展——稳定性是涌现的,不是管理出来的。字节精确的审计/回放是推论 #2;带*可归因*漂移的恢复与 fork 是推论 #3

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.md
2026-07-29-atomic-web-image-admission.md: c09d376f101a41994df3a10c22c06da4e59f06f6
2026-07-29-atomic-web-image-admission.zh.md: 8785f7489b0c433cba43a1747533b1d38aada3d3

View File

@@ -0,0 +1,29 @@
# Agent Note: Atomic Web image admission
Status: implemented
English | [中文](2026-07-29-atomic-web-image-admission.zh.md)
## Problem
Image prompt admission and `session.selectModel` each read session modality state across asynchronous model and attachment lookups. Without one ordering boundary, an image prompt could validate an image-capable target while a concurrent selection installed a text-only target, or selection could miss a prompt after inbox dequeue but before its durable message event. Scanning the immutable event log avoided the second race but permanently blocked a text-only selection even after compaction removed the image from current model history.
## Decision
Each live Web agent has one private promise chain shared by image-bearing prompt admission and model selection. A failed operation settles its caller normally and leaves the chain usable. Text-only prompts bypass the chain because they cannot change the modality constraint.
The pending-publication set records a queued occurrence at dequeue and a steering occurrence already at enqueue (steering items never enter the queued UI mirror), and retains each until its matching `user/message` or `steering/message` event publishes. If admission ends without publishing, the transition to idle retires the entries; inbox discard retires the listed work, and session disposal retires every remaining entry. Model selection checks that set, the queued UI mirror, and `Session.deriveMessages()`, which is the current model-visible history after compaction.
Provider adapters remain the final enforcement boundary. The host ordering only prevents its mutable route and pending image state from contradicting each other before request assembly.
## Alternatives considered
**Scan every immutable session event.** This catches published images but treats compacted-away content as permanently model-visible, preventing a valid later switch to a text-only route.
**Retire the pending mirror at inbox dequeue.** Dequeue precedes the durable message append and leaves the exact interval in which model selection can miss both pending and published state.
**Serialize every prompt and session mutation.** Text-only prompts and unrelated session operations cannot introduce an image requirement. A broader lock would add latency and ownership without closing another modality race.
## Consequences
An image prompt and a concurrent model selection have deterministic order, and a text-only target cannot strand an image that has been admitted but not yet published. Selection may wait for an in-flight image admission, while unrelated prompts retain their existing concurrency. Compaction can make a text-only target valid once no pending or derived image remains.

View File

@@ -0,0 +1,29 @@
# Agent Note: Web 图片准入的原子性
Status: implemented
[English](2026-07-29-atomic-web-image-admission.md) | 中文
## 问题
包含图片的提示词准入与 `session.selectModel` 都会在跨越异步模型查询与附件查询的过程中读取会话模态状态。如果没有统一的顺序边界,包含图片的提示词可能在支持图片的目标上通过校验,并发的选择操作却设置了纯文本目标;选择操作也可能在提示词已从 inbox 出队、但其持久消息事件尚未发布时漏掉该提示词。扫描不可变事件日志可以避免第二种竞态但即使压缩compaction已经从当前模型历史中移除图片仍会永久阻止选择纯文本目标。
## 决策
每个活跃 Web agent智能体都有一条私有 promise 链,由包含图片的提示词准入与模型选择共享。操作失败会照常传递给调用方,且不会使该链失效。纯文本提示词绕过该链,因为它们不会改变模态约束。
待发布集合会在排队条目出队时记录它,而 steering 条目在入队时即被记录steering 条目从不进入排队 UI 镜像),并各自保留到匹配的 `user/message``steering/message` 事件发布。若准入结束时未发布事件转为空闲状态会移除这些条目inbox 丢弃会移除列出的工作项,会话 dispose资源释放则会移除所有剩余条目。模型选择会检查该集合、排队 UI 镜像以及 `Session.deriveMessages()`;后者表示压缩后模型当前可见的历史。
提供方适配器仍是最终的强制检查边界。宿主的顺序控制仅用于避免其可变路由与待发布图片状态在请求组装前彼此矛盾。
## 曾考虑的替代方案
**扫描每个不可变会话事件。** 这能捕获已发布的图片,但会把经压缩移除的内容视为永久对模型可见,从而阻止之后合法切换到纯文本路由。
**在 inbox 出队时退役待处理镜像。** 出队早于持久消息追加,因此恰好会留下一个时间区间,让模型选择既看不到待处理状态,也看不到已发布状态。
**序列化每个提示词和会话变更。** 纯文本提示词和无关的会话操作无法引入图片要求。更宽的锁会增加延迟与所有权复杂度,却不会再消除任何模态竞态。
## 后果
包含图片的提示词准入与并发模型选择之间具有确定的先后顺序,纯文本目标无法使已获准入但尚未发布的图片搁浅。模型选择可能等待正在进行的图片准入完成,而无关提示词仍按现有方式并发处理。当没有图片等待发布,且派生历史经过压缩后也不再含图片时,纯文本目标可以变得有效。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-10-session-row-identity-covers-the-preset.md
2026-08-10-session-row-identity-covers-the-preset.md: 7a89dcb4e4ae292a06a1743842d2e9cf6bd96282
2026-08-10-session-row-identity-covers-the-preset.zh.md: 7ffa3423818bcc867c942651540db1975737e073

View File

@@ -0,0 +1,37 @@
# Agent Note: The session-row identity guard covers the preset
Status: implemented
English | [中文](2026-08-10-session-row-identity-covers-the-preset.zh.md)
## Problem
`SessionManager.buildListSnapshot` memoizes list rows by value: a wire refresh mints all-new summary objects, so an entry equal to the cached one is replaced by the cached instance, and every `SessionListItem` memo downstream keeps hitting. The stated contract is "reuse the cached object when every field matches"; the comparison enumerated the fields by hand and did not enumerate `agentPreset`.
A confirmed preset switch moves exactly that one field. `noteAgentPreset` upserts it and `applyMutation` merges it in — the merge deliberately does not take the mutation's `updatedAt`, so a switched row differs from its cached twin in the preset and in nothing else. The guard therefore judged the row unchanged and served the stale instance, permanently: the manager's own summaries said `minimal` while every reader of the projected snapshot went on reading `standard`.
The hero chip is one of those readers, and it compares the pick against that row before sending anything. Switching back to the preset the session was created under looked to it like "already on that preset", so it dropped the stage and sent no RPC at all — the chip label moved while the composition did not. A session could be switched away from its creation-time preset once and never back.
## Decision
The identity guard compares `agentPreset` alongside the other summary fields, which is what "every field matches" already claimed. Nothing else changes: the memoization, the merge, and the chip's no-op check all stay as they are, because each is correct once the row it reads is.
## Alternatives considered
**Have the chip re-read the host instead of the list row.** It would route around the stale row, but the row is also what the session header labels itself from, so the staleness would survive in the surface where it is most visible — and any future reader of `SessionSummary.agentPreset` would inherit the same trap.
**Drop the entry-identity memoization and rebuild rows every snapshot.** It removes the whole class of missing-field bugs, at the cost the memo exists to avoid: a wire refresh mints new objects for every row, so each refresh would re-render the entire session list.
**Compare summaries structurally rather than field by field.** A generic deep comparison cannot be added blind: the row carries `projectionValues`, whose reference identity is the deliberate signal that the projection store republished, and folding it into a value comparison would either re-render on every projection tick or mask a real one.
## Consequences
Every field a session row carries now participates in row identity, so a surface reading `SessionSummary.agentPreset` sees a switch as soon as the host confirms it — the header label included. The guard is still a hand-written enumeration, so a field added to `SessionSummary` later must be added here too; the `sessions-service` projection test names the failure mode for the next such field rather than only pinning this one.
## Testing
`sessions-service.spec.ts` feeds a blank row, notes a switch, and asserts the projected snapshot reports the new preset — it fails on the old guard because the row differs in nothing else. The `agent-preset-selection` web e2e switches down and back up, asserting the host honors the second switch and the `/` catalog returns with it; without this fix the second switch never reaches the host at all.
## Related
The same e2e covers [the catalog-invalidation fix](2026-08-10-slash-catalog-follows-preset-switch.md), which is what makes the menu follow either switch once the switch itself lands.

View File

@@ -0,0 +1,37 @@
# Agent Note会话行的标识判定纳入 preset
Status: implemented
[English](2026-08-10-session-row-identity-covers-the-preset.md) | 中文
## Problem
`SessionManager.buildListSnapshot` 按值对列表行做记忆化:一次 wire 刷新会铸造全新的 summary 对象,因此与缓存项相等的行会被替换为缓存实例,下游每一个 `SessionListItem` memo 才能持续命中。它声明的约定是「每个字段都相同就复用缓存对象」,而那段比较是手写枚举字段的,其中没有 `agentPreset`
一次已确认的 preset 切换恰好只移动这一个字段。`noteAgentPreset` 把它 upsert 进去,`applyMutation` 合并它——该合并有意不采用 mutation 的 `updatedAt`,因此切换后的行与它的缓存孪生只在 preset 上不同别处一致。于是标识判定认为这一行没变永久地提供了过期实例manager 自己的 summaries 是 `minimal`,而所有读取投影快照的一方继续读到 `standard`
hero 上的 chip 正是其中一个读取方,而且它在发出任何请求之前会拿这次选择和那一行比较。切回会话创建时的那个 preset在它看来就是「已经是这个 preset 了」,于是丢弃 stage、根本不发 RPC——chip 的标签变了,组成没变。一个会话可以从创建时的 preset 切走一次,然后再也切不回来。
## Decision
标识判定把 `agentPreset` 与其余 summary 字段一起比较这本就是「每个字段都相同」所声称的内容。其他一概不动记忆化、合并、chip 的 no-op 检查各自都是对的——只要它们读到的那一行是对的。
## Alternatives considered
**让 chip 改为直接读宿主,而不是读列表行。** 这样能绕开过期的行,但会话头部的标签同样以这一行为准,过期状态会在最显眼的界面里留下来;而且将来任何 `SessionSummary.agentPreset` 的读取方都会继承同一个陷阱。
**去掉行标识记忆化,每次快照都重建行。** 这能整类消除「漏字段」缺陷,代价却正是这个 memo 存在的理由:一次 wire 刷新会为每一行铸造新对象,于是每次刷新都要重渲染整个会话列表。
**改成结构化比较,而不是逐字段枚举。** 通用的深比较不能盲目加:行上带有 `projectionValues`,它的引用标识本身就是「投影 store 重新发布了」这一有意为之的信号,把它折进值比较,要么每个投影 tick 都重渲染,要么把一次真实变化掩盖掉。
## Consequences
会话行携带的每个字段现在都参与行标识,因此读取 `SessionSummary.agentPreset` 的界面会在宿主确认后立刻看到切换,会话头部标签也包含在内。该判定仍是手写枚举,所以将来给 `SessionSummary` 新增字段时必须同步加进来;`sessions-service` 的投影测试为下一个这样的字段点明了失效形态,而不只是钉住这一次。
## Testing
`sessions-service.spec.ts` 喂入一行空会话、记录一次切换,并断言投影快照报告的是新 preset——在旧判定下它会失败因为这一行别处都没变。`agent-preset-selection` web e2e 先向下切再向上切,断言宿主认可第二次切换、`/` 目录随之回来;没有这次修复,第二次切换根本到不了宿主。
## Related
同一条 e2e 也覆盖[目录失效的修复](2026-08-10-slash-catalog-follows-preset-switch.md)——正是它让菜单在切换真正落地之后跟随任一方向的切换。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.md
2026-08-10-slash-catalog-follows-preset-switch.md: 85bd5b2134fd20c86fdeb13f3ce5b007449105b5
2026-08-10-slash-catalog-follows-preset-switch.zh.md: 97c8f08a7b3dfec7c17fbb00bef626e28505c500

View File

@@ -0,0 +1,43 @@
# Agent Note: The slash catalog follows a blank session's preset switch
Status: implemented
English | [中文](2026-08-10-slash-catalog-follows-preset-switch.zh.md)
## Problem
Presets moved the rows that decide what a session's `/` menu contains. The Web composition disables host-plane `skill-local`, `tool-skill`, `plan-mode`, and `command-compact`; a preset supplies them, so which commands and skills exist is a property of the session's composition rather than of the deployment.
Both browser catalogs cache per session — `CommandDirectory` in `dsh-client-ui-command`, the single-flight fetch map in `dsh-client-ui-skill` — and the composer warms both at scope birth, under whatever preset the session was created with. The hero chip then lets the user recompose the still-blank session, and neither cache had an invalidation edge for that: `commands/changed` is registry-wide and `connection/reset` needs a reconnect. `agentPresets.recompose` re-parents the agent's scope onto a standing mount that may already exist, so it registers nothing and the registry-wide signal never fires for it.
The menu therefore kept serving the composition the session no longer ran. Switching down left `compact`, `plan`, and every project skill listed; switching up left the narrower catalog — the four host-plane rows and the client's own `model` contribution — with no skills at all, which is what the bug report described. The catalog only healed when an unrelated registry change or a reconnect happened to invalidate it.
## Decision
The switch's commit point is the logged `agent-preset/selected` event. The host stream frames it as `host/session-preset-changed { sessionId, agentPreset }`, the browser runtime bridges that frame to the typed `session/preset-changed` ctx event beside the registry-invalidation bridges it already owns, and each catalog owner drops its own entry for that session: `ui-command` soft-refreshes the key (the old snapshot keeps serving the open menu until the new one lands), `ui-skill` invalidates it (aborting an in-flight prewarm, so a warm racing the switch cannot publish the stale catalog).
The frame is per session and carries no catalog, only the preset id — which the manager folds into the session row, because the `agentPresets.select` echo reaches only the client that issued the switch and the row is what the session header labels itself from (and what the hero chip compares the next pick against).
Deriving the frame from the logged event rather than from the RPC handler's return keeps one authority for "this session's composition changed": every connected client observes the switch, not only the tab that issued it, and a client that is not the switcher never has to infer it from a registry signal that will not come.
## Alternatives considered
**Invalidate in the client's own `agentPresets.select` callback.** Smallest change, and the preset is locked after the first turn, so the hero chip is the only place a switch can originate. Rejected because the invalidation would then live in the surface that happens to issue the RPC rather than at the commit point: a second tab on the same blank session keeps a stale menu, and any future host-side recomposition has no signal at all.
**Derive the client event from the existing `session/event` mux frame.** The logged event already reaches every subscribed client, so no new wire type would be needed. Rejected on face separation: narrowing `event.type` to `agent-preset/selected` requires the `SessionEventMap` augmentation, and the only ways to load it in the Client program are a project reference to `dsh-agent-presets` — which drags the host `ctx.sessions` merge into a program that publishes its own — or a cast that defeats the discriminant.
**Reuse `host/commands-changed`.** It is the existing catalog-invalidation frame, but it is registry-wide, carries no session, and says nothing about skills; a client would repull every session's commands and still never refresh a skill catalog.
## Consequences
The wire gains one frame and the Client one typed event, and every catalog a preset decides now has one place to subscribe: a future per-session surface derived from the composition invalidates on the same signal instead of inventing another. The cost is that the frame is a second reader of a logged fact — the host stream must keep deriving it from `agent-preset/selected`, so a future switch path that recomposes without logging would go unannounced. `ui-command` stays soft (the open menu never blanks) while `ui-skill` drops its entry outright, because a skill catalog has no partial-serve mode; a menu opened inside the refetch window shows no skills for that instant rather than the wrong ones.
## Testing
`api-proxy-agent-preset.spec.ts` asserts the committed switch frames once with the session and its new preset; `wire-events.spec.ts` asserts the frame-to-event bridge; the `ui-command` and `ui-skill` specs assert that the event repulls the recomposed session and leaves every other session's cache serving. The `agent-preset-selection` web e2e seeds a project skill and, after the hero chip applies `minimal`, asserts the `/` menu drops `compact`, `plan`, and the skill while keeping the host-plane rows — the assembled-application evidence that the panel follows the composition.
That e2e also stopped reading its staged-pick assertion off the serialized session list: the seeded session records `minimal` too, so the substring answered before the switch had landed. It now addresses the live session by id.
## Related
Reaching the host on a SECOND switch is a separate defect with its own cause and fix: [the session-row identity guard](2026-08-10-session-row-identity-covers-the-preset.md). Until it landed, `agent-preset-selection.e2e.ts` could only exercise the first switch — the invalidation edge here is direction-blind, but the switch it reacts to has to happen.

View File

@@ -0,0 +1,43 @@
# Agent Note斜杠目录跟随空会话的 preset 切换
Status: implemented
[English](2026-08-10-slash-catalog-follows-preset-switch.md) | 中文
## Problem
preset 把决定 `/` 菜单内容的那些行搬走了。Web 组装禁用了宿主面的 `skill-local``tool-skill``plan-mode``command-compact`,改由 preset 提供,因此一个会话有哪些命令和技能,是它自身组成的属性,而不是部署的属性。
浏览器侧两份目录都按会话缓存——`dsh-client-ui-command``CommandDirectory``dsh-client-ui-skill` 的 single-flight 拉取表——并且 composer 在 scope 出生时就按会话创建时的 preset 预热了它们。随后 hero 上的 chip 允许用户重组这个仍为空的会话,而两份缓存都没有对应的失效边:`commands/changed` 是注册表级的,`connection/reset` 需要重连。`agentPresets.recompose` 只是把 agent 的 scope 重新挂接到一个可能已经存在的常驻挂载上,不产生任何注册,注册表级信号因此永远不会为它触发。
于是菜单继续提供会话已经不再运行的那套组成。向下切换后 `compact``plan` 和全部项目技能仍列在菜单里;向上切换后留在原地的是更窄的目录——四条宿主面行加客户端自己的 `model` 贡献——而且完全没有技能,这正是 bug 报告描述的现象。只有当某个无关的注册表变化或一次重连恰好使其失效时,目录才会自愈。
## Decision
这次切换的提交点是落账的 `agent-preset/selected` 事件。宿主流把它成帧为 `host/session-preset-changed { sessionId, agentPreset }`,浏览器运行时在它已经拥有的那组注册表失效桥接旁,把该帧桥接为类型化的 `session/preset-changed` ctx 事件,两份目录各自丢弃该会话的那一项:`ui-command` 软刷新该键(新快照落地前,旧快照继续服务已打开的菜单),`ui-skill` 让它失效(并中止在途的预热,使一次与切换赛跑的 warm 无法发布过期目录)。
该帧按会话粒度,不携带目录,只带 preset id——manager 会把它折进会话行,因为 `agentPresets.select` 的回执只会到达发起切换的那个客户端而会话头部标签正是以这一行为准hero chip 比较下一次选择时读的也是它)。
从落账事件而不是 RPC 处理器的返回值派生该帧,使「这个会话的组成变了」只有一个权威来源:每个已连接的客户端都能观察到这次切换,而不只是发起它的那个标签页;不是发起方的客户端也无需从一个根本不会到来的注册表信号里去推断。
## Alternatives considered
**在客户端自己的 `agentPresets.select` 回调里就地失效。** 改动最小,而且第一轮之后 preset 就锁定hero 上的 chip 是切换唯一可能的发起处。否决理由是失效逻辑会落在恰好发起 RPC 的那个界面上,而不是提交点:同一个空会话在第二个标签页里仍是过期菜单,将来任何宿主侧的重组也完全没有信号。
**从既有的 `session/event` mux 帧派生客户端事件。** 落账事件本来就会送达每个已订阅的客户端不需要新增协议类型。因面face分离而否决`event.type` 收窄到 `agent-preset/selected` 需要 `SessionEventMap` 增补,而在 Client 程序里加载它只有两条路——引用 `dsh-agent-presets` 工程,那会把宿主的 `ctx.sessions` 合并拖进一个自己也发布同名服务的程序;或者用一次类型断言绕过判别式。
**复用 `host/commands-changed`。** 它是既有的目录失效帧,但它是注册表级的、不带会话、也与技能无关;客户端会把每个会话的命令都重拉一遍,却依然永远刷不新技能目录。
## Consequences
协议多了一个帧Client 多了一个类型化事件,而每一份由 preset 决定的目录从此有了统一的订阅点:将来任何从组成派生的按会话界面,都在同一个信号上失效,而不必再发明一个。代价是该帧成为一项落账事实的第二个读者——宿主流必须持续从 `agent-preset/selected` 派生它,因此将来若出现一条不落账就重组的切换路径,它将无人宣告。`ui-command` 保持软失效(已打开的菜单不会变空),而 `ui-skill` 直接丢弃该项,因为技能目录没有「部分可服务」的状态;在重拉窗口内打开的菜单,那一瞬间显示的是没有技能,而不是错误的技能。
## Testing
`api-proxy-agent-preset.spec.ts` 断言已提交的切换恰好成帧一次,并带上会话与新 preset`wire-events.spec.ts` 断言帧到事件的桥接;`ui-command``ui-skill` 的 spec 断言该事件只重拉被重组的会话,其他会话的缓存继续服务。`agent-preset-selection` web e2e 播种一个项目技能,并在 hero chip 应用 `minimal` 之后断言 `/` 菜单丢掉了 `compact``plan` 和该技能,同时保留宿主面的那几行——这是面板跟随组成的整装应用证据。
同一条 e2e 也不再从序列化后的会话列表里读它的 staged-pick 断言:被播种的会话同样记录着 `minimal`,子串匹配在切换落地之前就会通过。现在它按 id 寻址那个活跃会话。
## Related
第二次切换能否到达宿主是另一个缺陷,有各自的成因与修复:[会话行的标识判定](2026-08-10-session-row-identity-covers-the-preset.md)。在它落地之前,`agent-preset-selection.e2e.ts` 只能演练第一次切换——这里的失效边对方向无感,但它所响应的那次切换必须真的发生。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md
2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 2a3925d8b779fed7f8b3330d079fdb3059685fd5
2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: be478ece73039201a820d804435610faea4cb3fe

View File

@@ -0,0 +1,214 @@
# Agent Note: Web multimodal image input and durable attachments
Status: implemented
English | [中文](2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md)
## Problem
Before this change, the Web composer accepted only text: `InputBar` received a string draft, `ConversationService.send()` created text content, and the host forwarded that content to the agent. Users could not paste an image, inspect it before sending, submit an image-only prompt, or recover sent images from history.
This is not only a composer gap. Core needs a durable image content block, providers need explicit modality handling, and the session log must reconstruct everything visible to a model. [The previous image-block removal](../../implemented/simplification/2026-07-04-drop-image-content-block.md) rejected a partial design that could silently lose or flatten images. A browser object URL, local path, provider URL, or base64 payload cannot be canonical session content.
The [Web client architecture](../../implemented/architecture/2026-07-19-gui-web-client-architecture.md) keeps components pure and per-session composer state in `ctx.conversation`; the [GUI layering and RPC protocol](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) makes durable events the source of truth for both live rendering and history replay. Image intake, persistence, provider conversion, and rendering therefore need one explicit lifecycle.
Peer products converge on an attachment rail above the editor, but their storage choices differ. Codex-style paths such as `/var/folders/.../codex-clipboard-*.png` are reasonable intake staging locations, not durable message identities: the operating system may delete them, another host cannot read them, and a resumed session cannot rely on them.
## Decision
Pasted or dropped raster images are the Web composer's first consumer of a durable attachment capability. Unsent files remain temporary client-owned draft state. The host validates and durably commits every accepted user image before appending its message event. A provider adapter that produces structured image output must durably commit the output before appending its assistant block. Canonical user and assistant content contains only role-neutral `ImageBlock` references.
Version one supports PNG, JPEG, WebP, and GIF paste and drag-and-drop, image-only or mixed prompts, historical user and assistant image rendering, and original-image preview on double-click. File picking, generic files, PDF, audio, video, image copying, and a custom context menu remain separate follow-ups.
### Product behavior
- Pasting or dropping one or more supported images adds ordered thumbnails above the textarea without inserting placeholder text. Dragging files over the composer highlights the drop target.
- The same resident `InputBar` renders the rail in both blank-session Hero and active-session layouts. The rail is hidden when empty and scrolls horizontally instead of widening the composer.
- Each approximately 72-by-72-pixel thumbnail has a remove action and opens its original draft image on double-click.
- A prompt may contain text and images or images only. Pure text paste remains native browser behavior; mixed clipboard content inserts its text normally while adding its files to the rail, and file-only paste prevents default browser handling. File drops on the composer always prevent browser navigation and report unsupported files locally.
- A failed send restores the complete text and image draft without clobbering text or images added while the request was in flight. Removal, successful send, session-scope disposal, rendered-history disposal, and application disposal revoke the object URLs they own.
- Historical user and assistant images use one `MessageImage` control. Inline images preserve intrinsic aspect ratio, do not upscale, and stay within a 240-by-240-pixel box.
- Double-clicking a message image opens the stored original in a viewport-bounded modal. Escape, the close control, and backdrop activation close it and restore focus.
- Version one does not override the browser context menu and provides no explicit image-copy action.
### Storage lifecycle and ownership
The persistence boundary is message acceptance, not paste:
| State | Allowed representation | Durability and ordering |
| --- | --- | --- |
| Unsent user draft | Browser `File` plus object URL; a native client may use an OS temporary file such as `/var/...` | Temporary and client-owned. It may disappear on reload or process exit and never appears in a session event. |
| Accepted user image | Immutable object below `DSH_HOME` plus `ImageAttachmentRef` | The host commits every image before `agent.send()` or `agent.steer()` can append the owning user event. |
| Structured model image output | Immutable object below `DSH_HOME` plus `ImageAttachmentRef` | The provider adapter commits the bytes before it emits a completed image block or assistant message event. Temporary URLs, paths, and base64 are forbidden in the event. |
Each session's `InputMachine` state keeps the ordered runtime-only attachment identifiers alongside the live draft. The framework-owned chat store receives only the draft's plain-text persistence mirror, while `ConversationService` owns the corresponding browser-only `File` and object-URL registry:
```ts
import type { Branded } from '@deepseek-ai/dsh-brand'
type DraftAttachmentId = Branded<'DraftAttachmentId'>
interface ChatStoreState {
selection: object | null
draft: string
view: string | null
}
interface InputState {
draft: string
imageIds: readonly DraftAttachmentId[]
}
interface ComposerAttachment {
kind: 'image'
id: DraftAttachmentId
file: File
previewUrl: string
}
```
This split uses the session provide channel's input hook and actions as the single subscription path for live composer state while keeping non-serializable browser objects out of persisted JSON. Only the plain-text draft mirror uses `localStorage`; attachment identifiers, browser `File` objects, and object URLs remain scoped to the live session input shell. Unsent images therefore do not survive reload or session-scope disposal. A Workspace switch moves a mixed text-and-image draft only when the destination shell accepts the complete image batch; refusal leaves both parts with the source. A native client may stage input in an OS temporary directory, but it must treat that path exactly like the browser object URL: delete it when no longer needed and copy the bytes into the durable store before message acceptance.
The local attachment backend resolves an explicit `dshHome`, then `$DSH_HOME`, then `~/.dsh`. It stores content-addressed objects below `$DSH_HOME/attachments/v1/objects/<prefix>/<sha256>` with owner-only directory and file permissions. On each process's first save for one home, it creates that home and synchronizes every ancestor entry to the filesystem root; existence is not treated as durability because another process may still be between `mkdir` and parent `fsync`. A temporary file is then written, synchronized, atomically published, and made durable with directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) before the service returns a reference. The content digest is encoded in the opaque `sha256:<digest>` identifier. Admission and reads fully decode supported rasters before accepting their format and dimensions, and every read also verifies the digest, byte length, and logged metadata.
The store performs no automatic deletion in version one. Sent user images and model-generated images remain reachable for history, resume, and fork. Reference-aware garbage collection needs a separate design because an age-only rule can delete data still referenced by a durable session. Deployment byte and pixel limits are admission policy on writes; reads verify the digest and recorded metadata without reapplying current admission limits, so lowering policy does not invalidate older history.
### Durable content and prompt wire
The attachment seam exposes immutable image write and verified read operations. The canonical metadata is deliberately narrower than a generic file record:
```ts
import type { Branded } from '@deepseek-ai/dsh-brand'
type AttachmentId = Branded<'AttachmentId'>
interface ImageAttachmentRef {
attachmentId: AttachmentId
mediaType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif'
bytes: number
width: number
height: number
name?: string
}
interface ImageBlock {
type: 'image'
attachment: ImageAttachmentRef
}
```
`ImageBlock` joins the merge-extensible core `ContentBlockMap` and is valid in either user or assistant content. It never carries base64, an object URL, a filesystem path, or a provider-owned locator. This keeps the session event plus immutable object store sufficient to reconstruct the exact model-visible image. The LLM vocabulary therefore has a type-only dependency on the attachment seam; provider runtime dependencies remain adapter-specific.
The browser cannot mint a durable reference, so `session.prompt` accepts a narrow intake union rather than canonical `ContentBlock[]`:
```ts
export {}
type PromptInputPart =
| { type: 'text'; text: string }
| {
type: 'image'
mediaType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif'
data: string
name?: string
}
```
Base64 crosses JSON-RPC once and is discarded after persistence. The host validates canonical base64, image count, aggregate bytes, individual bytes, the declared MIME against a fully decoded raster, intrinsic dimensions, and decoded-pixel count. It awaits the seam's storage-free `validateImage` for every batch member before saving any member, so one malformed image cannot strand the batch's valid members as unreferenced objects. Storage commits then run in submission order to bound full-raster decoder memory. If a later storage I/O operation fails, the host appends no user event, but an earlier immutable content-addressed object may remain unreferenced; version one leaves cleanup to future reference-aware garbage collection instead of adding destructive rollback to the deduplicated store. Only after every image succeeds does it call the agent with normalized text and durable image blocks in the submitted order. A failure exposes no attachment path or raw bytes.
`session.attachment` is a read-only, session-scoped endpoint. The host serves bytes only when a durable event in that session references the requested attachment identifier. The client deduplicates loads by session and attachment identifier while that session is rendered, revokes resolved URLs on rendered-session disposal, and rejects invalidated late loads before allocating an object URL so an unmounted session or disposed service cannot repopulate the cache.
### Model capabilities and provider behavior
Model catalog entries gain optional merge-extensible input modality declarations. A missing declaration means unknown; a present list without `image` is an explicit negative capability.
The host is the authoritative preflight boundary. It resolves the session's latest routed provider/model, falling back through agent options to host defaults; if that model explicitly excludes image input, it rejects the prompt before writing any attachment or event, and the client restores the draft. Image-bearing prompt admission and model selection share one per-agent serial boundary, and a dequeued prompt remains pending until its durable message event publishes ([ordering decision](../bug-fix/2026-07-29-atomic-web-image-admission.md)); a steering carrier gates from its enqueue until its `steering/message` event publishes, closing the outbox hop that never enters the queued mirror. Selection rejects a text-only target while an image is pending publication or remains in the session's current derived history. Compaction can remove old images and make a later text-only selection valid; idle without publication releases a claimed queued carrier, while steering retained in the outbox stays gated until publication or discard. `session.updateQueue` edits accept text content only, so a queue edit cannot inject an image past this admission boundary. Unknown capability proceeds to the adapter guard so uncatalogued model identifiers remain usable. The browser rejects unsupported declared image media types before allocating preview URLs, but it does not snapshot deployment limits or model capability: a handshake snapshot cannot represent a session's current target after `session.selectModel`, and deployment policy may change independently. The host validates the complete batch against current byte, count, aggregate, media, dimension, pixel, and routed-model policy before writing any attachment or event; its rejection renders through the composer error strip.
The Pi-AI adapter is the first visual-input route: it resolves `ctx.attachments` at request time, recursively converts each durable image reference including references nested inside tool results, and emits native image content only for models that declare image input. The shipped composition registers Pi-AI OpenAI and Anthropic routes alongside the text-only default DeepSeek route; selecting the active provider/model remains a host composition or profile concern rather than an image-input CLI feature. Request-time service resolution keeps Cordis load order from freezing optional attachment availability. The hand-written DeepSeek adapter throws typed `UNSUPPORTED_CONTENT` for an image anywhere in the request, including nested tool results. No adapter may flatten or skip an image.
Core supports structured assistant image blocks, but no current production provider route is certified for image output. Any future output-capable adapter must retrieve provider bytes under bounded size and time policy, validate them through the same attachment service, persist them, and only then publish the atomic `ImageBlock`. A URL in assistant Markdown remains text and is never downloaded automatically.
Provider-neutral token estimation does not guess visual pricing from image dimensions; provider-reported usage remains authoritative. ACP renders an explicit image marker until that protocol surface gains native image support rather than silently omitting the block.
Compaction replays the selected conversation prefix, including image references, into the configured summarization route. A visual-capable route resolves those references through its adapter; a text-only route fails explicitly instead of silently dropping the visual context. The synthesized checkpoint remains text-only, and `compact-basic` rejects image summary output with `UNSUPPORTED_CONTENT`.
### History rendering and original preview
History folding preserves `ImageBlock` in both user and assistant messages. User images align to the trailing edge above their text; assistant images remain in their original content-block position in the leading narration flow. `MessageImage` derives a stable inline box from recorded dimensions, resolves bytes through the session-authorized loader, uses `object-fit: contain`, and turns a missing or corrupt object into a retryable error control.
Composer thumbnails and each `MessageImage` own ephemeral original-preview state and invoke the same pure `ImageLightbox`. The modal uses the already resolved original object URL, constrains only display size, focuses its close control, and restores the previous focus target when closed.
### Limits and trust boundaries
Version one accepts PNG, JPEG, WebP, and GIF only. SVG and remote URLs are excluded. Default limits are 5 MiB per image, 10 images and 20 MiB aggregate image bytes per message, and 40 million intrinsic pixels per image. These deployment-varying limits are validated backend configuration and enforced by the host before persistence. The client connection carrier has an independent configurable `maxRequestBodyBytes` cap (32 MiB by default) for every API request and fails load if it cannot hold the attachment service's aggregate image limit after base64 and envelope expansion; lowering image policy therefore never silently lowers the carrier limit for valid text or other RPCs. A body without a declared length is rejected the moment it crosses the cap rather than drained to its end.
Malformed base64, unsupported or mismatched media, truncated image payloads, excess bytes, excess image count, excess pixels, missing objects, and integrity mismatches return stable structured failures. Original filenames are reduced to a display basename, control characters are removed, and no local path is logged or returned to the browser.
### Package and surface changes
| Surface | Responsibility |
| --- | --- |
| `packages/attachment/attachment` | Opaque attachment identifier, image reference, limits, failures, and `ctx.attachments` service. |
| `packages/attachment/attachment-local` | Private content-addressed storage, complete raster decoding, integrity verification, and configuration. |
| `packages/llm/llm` | Role-neutral `ImageBlock` and input-modality metadata. |
| `packages/llm/llm-pi-ai` | Resolve durable supported image input into native provider content. |
| `packages/llm/llm-deepseek` | Reject image content explicitly. |
| `packages/compact/compact-basic` | Preserve images in summary input and reject non-text checkpoint output explicitly. |
| `packages/host/apiproxy` and `packages/bundle/base` | Narrow upload wire, persist-before-event ordering, session-authorized reads, limits and model preflight, plus default profile composition. |
| `packages/client/connection` and `packages/client/runtime` | Bounded request buffering, wire types, fixture images, prompt uploads, attachment reads, and durable-reference folding. |
| `packages/client/ui-conversation` | Per-session draft images, attachment rail, user and assistant image controls, and original preview. |
| `packages/acp/acp` | Explicit fallback rendering for image blocks. |
The attachment packages form the interface/implementation side of one capability seam. Composer behavior stays in the conversation object layer, provider conversion stays in adapters, and no change is required in `agent-loop`.
### Implementation
The implemented slice includes the attachment seam, role-neutral image block, Pi-AI input conversion, DeepSeek rejection, durable host ordering, Web upload/read protocol, current image-limit enforcement, bounded Web request bodies, in-memory draft images, paste/drop rail, user and assistant history rendering, double-click preview, compaction handling, and keyless assembled Web coverage.
No compatibility shim is required for the pre-release prompt wire; all call sites and fixtures change with the introducing slice.
## Alternatives considered
### Keep every intake image in `/var` or another temporary directory
Temporary storage is appropriate before send, including for a native client that receives clipboard files through the operating system. It is not appropriate after acceptance: cleanup is outside the harness's control, paths are host-specific, and resume or fork can outlive the file. The proposal permits temporary staging but copies accepted bytes into `DSH_HOME` before the event.
### Persist immediately on paste or drop
Immediate persistence makes drafts reload-resistant but creates durable objects before a session or message owns them, which requires quota, orphan lifetime, and cleanup policy. Version one keeps the unsent draft temporary and makes send acceptance the durability boundary.
### Inline base64 in messages and session logs
This duplicates binary data across RPC, events, history pages, forks, compaction, and browser storage, and invites token accounting to treat encoding text as model text. One immutable object plus small references keeps the durable representation bounded.
### Use browser object URLs, local paths, or provider URLs as canonical content
Object URLs expire with the document, local paths are not portable, and provider URLs may expire, track viewers, or expose credentials. They remain temporary transport or preview details only.
### Use one generic `AttachmentBlock` for images, files, audio, and video
Composer presentation can use a generic attachment rail, but provider semantics are modality-specific. Images are native multimodal input; PDFs may be provider files or extracted text; video may be native, sampled, or unsupported. A specific `ImageBlock` forces every consumer to handle or reject the modality explicitly.
### Rely on UI capability checks or silently filter images
UI state can be stale and does not protect direct SDK, ACP, replay, or uncatalogued model paths. Silent filtering changes user intent. Provider enforcement remains mandatory, while UI checks are optional earlier feedback.
## Testing
- Storage tests cover content-addressed deduplication, private permissions, admission failures, corruption/missing-object failures, and reading history after deployment limits are lowered.
- Host and protocol tests cover persist-before-event ordering, absence of base64 in logs, session-scoped authorization, capability rejection, upload limits, bounded HTTP request bodies, image-admission/model-selection races (queued and steering placements), pending publication, idle release without publication, text-only queue edits, and selection against current derived history after compaction.
- Client unit tests cover paste and drop, mixed clipboard text, image-only send, draft restoration, ordering, draft/session-scope/application object-URL cleanup, and a deferred historical read that completes after disposal; the keyless assembled built-client lane (`apps/web/tests/image-display.snapshot.ts`, `DSH_EXAMPLE_MODE=lib pnpm run test:snapshot`) covers the historical user and assistant galleries over the authorized attachment route, the original-size lightbox, and the composer paste rail.
- Adapter and compaction tests cover native Pi-AI image conversion, late attachment-service composition, text-only rejection, recursively nested tool-result images, preserved summary input, and explicit image-output rejection.
- A credentialed real-API test sends a PNG through the Anthropic `claude-opus-4-8` route and requires the model to identify its QR code.
- The current production adapter set has no certified image-output route; output-provider certification remains outside version one.
## Consequences
- Durable storage grows without garbage collection. Version one chooses replay safety over premature deletion.
- A missing or corrupt object makes exact model reconstruction fail. Failing loud preserves integrity but may prevent that session from continuing until repaired.
- JSON-RPC base64 adds upload memory and roughly one-third encoding overhead. Version-one limits bound it; larger media needs streaming or a binary transport.
- Unsent images do not survive reload. Durable drafts need quota and orphan cleanup rather than reusing message storage implicitly.
- Original preview decodes more pixels than the inline control displays. Pixel limits, one clicked preview, and object-URL disposal bound but do not eliminate transient browser memory.
- Capability metadata may be missing or stale. Host preflight improves feedback, while adapter enforcement remains authoritative.
- A future output provider may require authenticated retrieval before an assistant image can complete, adding latency and a new failure point. Persist-before-event ordering favors replay integrity.
- File picking, generic files/PDF, audio/video, durable draft staging, image copying, custom context menus, output-provider certification, and reference-aware garbage collection remain independent designs.

View File

@@ -0,0 +1,214 @@
# Agent Note: Web 多模态图片输入与持久附件
Status: implemented
[English](2026-07-22-web-multimodal-image-input-and-durable-attachments.md) | 中文
## 问题
在此变更之前Web 输入区仅接受文本:`InputBar` 接收字符串草稿,`ConversationService.send()` 创建文本内容,宿主再把该内容转发给 agent智能体。用户无法粘贴图片、在发送前查看图片、提交仅含图片的提示词也无法从历史记录中恢复已发送图片。
这不只是输入区功能缺失。核心层需要持久图片内容块,提供方需要明确处理模态,会话日志则必须重建模型可见的全部内容。[此前移除图片块的决策](../../implemented/simplification/2026-07-04-drop-image-content-block.md)否决了可能静默丢失图片或将其展平的不完整设计。浏览器对象 URL、本地路径、提供方 URL 或 base64 数据都不能成为规范会话内容。
[Web 客户端架构](../../implemented/architecture/2026-07-19-gui-web-client-architecture.md)要求组件保持纯粹,并将每个会话的输入区状态放在 `ctx.conversation` 中;[GUI 分层与 RPC 协议](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)则要求持久事件成为实时渲染与历史回放的共同真源。因此,图片接收、持久化、提供方转换和渲染需要遵循同一个明确的生命周期。
同类产品普遍在编辑器上方设置附件栏,但存储方案各不相同。诸如 `/var/folders/.../codex-clipboard-*.png` 的 Codex 式路径适合作为接收输入时的暂存位置,却不能作为持久消息身份:操作系统可能删除文件,另一台宿主无法读取文件,恢复后的会话也不能依赖文件仍然存在。
## 决策
粘贴或拖放的光栅图片是 Web 输入区对持久附件能力的首个应用场景。未发送文件仍是由客户端持有的临时草稿状态。宿主在追加相应消息事件前,校验并持久提交每张已接受的用户图片。生成结构化图片输出的提供方适配器在追加相应助手块前,也必须持久提交输出。规范用户内容与助手内容只包含角色无关的 `ImageBlock` 引用。
第一版支持粘贴和拖放 PNG、JPEG、WebP 与 GIF支持仅图片或混合提示词支持渲染历史用户图片与助手图片并支持双击预览原图。文件选择、通用文件、PDF、音频、视频、图片复制和自定义上下文菜单仍分别作为后续工作。
### 产品行为
- 粘贴或拖放一张或多张受支持的图片后,文本框上方会按顺序显示缩略图,但不会插入占位文本。文件拖入输入区时会高亮放置目标。
- 同一个常驻 `InputBar` 会在空白会话 Hero 和活跃会话布局中渲染附件栏。附件栏为空时隐藏,通过横向滚动避免撑宽输入区。
- 每个缩略图约为 72 × 72 像素,带有移除操作;双击时打开草稿原图。
- 提示词可同时包含文本与图片,也可仅包含图片。粘贴纯文本时保持浏览器原生行为;粘贴混合的剪贴板内容时,文本会正常插入,文件则同时添加到附件栏;仅粘贴文件时才阻止浏览器的默认处理。在输入区放置文件时总会阻止浏览器导航,并在本地报告不受支持的文件。
- 发送失败时恢复完整的文本与图片草稿,但不会覆盖请求飞行期间新增的文本或图片。移除、发送成功、会话 scope 释放、已渲染历史记录释放和应用释放都会撤销各自持有的对象 URL。
- 历史用户图片与助手图片共用一个 `MessageImage` 控件。行内图片保持固有宽高比、不放大,并限制在 240 × 240 像素的边界框内。
- 双击消息图片会在不超出视口的模态框中打开存储的原图。按 Escape、激活关闭控件或激活背景区域都会关闭模态框并恢复焦点。
- 第一版不覆盖浏览器上下文菜单,也不提供明确的图片复制操作。
### 存储生命周期与归属
持久化边界是消息被接受,而不是图片被粘贴:
| 状态 | 允许的表示 | 持久性与顺序 |
| --- | --- | --- |
| 未发送的用户草稿 | 浏览器 `File` 加对象 URL原生客户端可以使用 `/var/...` 等操作系统临时文件 | 临时且由客户端持有。它可能在重载或进程退出后消失,绝不出现在会话事件中。 |
| 已接受的用户图片 | `DSH_HOME` 下的不可变对象加 `ImageAttachmentRef` | 在 `agent.send()``agent.steer()` 能够追加所属用户事件前,宿主提交每张图片。 |
| 结构化模型图片输出 | `DSH_HOME` 下的不可变对象加 `ImageAttachmentRef` | 提供方适配器在发出已完成的图片块或助手消息事件前提交字节。事件中禁止出现临时 URL、路径和 base64。 |
每个会话的 `InputMachine` 状态在实时草稿旁保存仅限运行时的有序附件标识符。框架持有的 chat store 只接收草稿的纯文本持久化镜像,`ConversationService` 则持有相应的浏览器专用 `File` 与对象 URL 注册表:
```ts
import type { Branded } from '@deepseek-ai/dsh-brand'
type DraftAttachmentId = Branded<'DraftAttachmentId'>
interface ChatStoreState {
selection: object | null
draft: string
view: string | null
}
interface InputState {
draft: string
imageIds: readonly DraftAttachmentId[]
}
interface ComposerAttachment {
kind: 'image'
id: DraftAttachmentId
file: File
previewUrl: string
}
```
这一拆分把会话 provide 通道的输入 hook 与 actions 用作实时输入区状态的唯一订阅路径,同时避免把不可序列化的浏览器对象写进持久 JSON。只有纯文本草稿镜像使用 `localStorage`;附件标识符、浏览器 `File` 对象和对象 URL 都限定在实时会话输入外壳的 scope 内。未发送图片因此无法跨重载或会话 scope 释放保留。切换 Workspace 时,只有目标外壳接受完整图片批次,图文混合草稿才会移动;拒绝时,文本和图片都留在来源外壳。原生客户端可以在操作系统临时目录中暂存输入,但必须像对待浏览器对象 URL 一样对待该路径:不再需要时删除,并在消息被接受前把字节复制进持久存储。
本地附件后端依次解析显式 `dshHome``$DSH_HOME``~/.dsh`。它把内容寻址对象存储在 `$DSH_HOME/attachments/v1/objects/<prefix>/<sha256>` 下,并为目录和文件设置仅所有者可访问的权限。每个进程首次为某个 home 保存对象时,都会创建该 home并逐级同步每个祖先目录项直至文件系统根目录不能把存在视为持久性因为另一个进程可能仍处于 `mkdir` 与父目录 `fsync` 之间。随后服务写入并同步临时文件再以原子方式发布并对发布路径执行目录同步使其持久POSIXWindows 依赖文件系统元数据日志),之后才返回引用。内容摘要编码在不透明的 `sha256:<digest>` 标识符中。写入准入与读取都会完整解码受支持的光栅图片,之后才接受其格式和尺寸;每次读取还会校验摘要、字节长度和已记录的元数据。
第一版不对存储执行自动删除。已发送的用户图片和模型生成图片会一直保留,以供历史记录、恢复和 fork 使用。按引用感知的垃圾回收需要单独设计,因为仅按时间清理可能删除仍被持久会话引用的数据。部署的字节和像素限制是写入时的准入策略;读取时会校验摘要和已记录的元数据,但不重新应用当前准入限制,因此收紧策略不会导致旧历史记录失效。
### 持久内容与提示词协议
附件服务边界公开不可变图片写入和经过校验的读取操作。规范元数据刻意比通用文件记录更窄:
```ts
import type { Branded } from '@deepseek-ai/dsh-brand'
type AttachmentId = Branded<'AttachmentId'>
interface ImageAttachmentRef {
attachmentId: AttachmentId
mediaType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif'
bytes: number
width: number
height: number
name?: string
}
interface ImageBlock {
type: 'image'
attachment: ImageAttachmentRef
}
```
`ImageBlock` 加入可合并扩展的核心 `ContentBlockMap`,在用户内容和助手内容中都有效。它绝不携带 base64、对象 URL、文件系统路径或提供方持有的定位符。因此会话事件与不可变对象存储足以共同重建模型可见的确切图片。LLM 词汇由此仅在类型层面依赖附件服务边界;提供方运行时依赖仍由各适配器持有。
浏览器无法生成持久引用,因此 `session.prompt` 接受范围狭窄的接收联合类型,而不是规范 `ContentBlock[]`
```ts
export {}
type PromptInputPart =
| { type: 'text'; text: string }
| {
type: 'image'
mediaType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif'
data: string
name?: string
}
```
Base64 只跨越一次 JSON-RPC并在持久化后丢弃。宿主会校验规范 base64、图片数量、总字节数、单张图片字节数、声明的 MIME 与完整解码后的光栅图片是否一致、固有尺寸和解码像素数。它会在保存任何成员之前,等待服务边界上不触碰存储的 `validateImage` 完成对每个批次成员的校验,因此一张畸形图片不会把批次中的有效成员留成无引用对象。随后按提交顺序执行存储提交,以限制完整光栅解码器的内存占用。如果后续存储 I/O 操作失败,宿主不会追加用户事件,但先前的不可变内容寻址对象可能保持无引用状态;第一版将清理留给未来按引用感知的垃圾回收,而不向去重存储添加破坏性回滚。只有每张图片都成功后,宿主才会用规范化文本和按提交顺序排列的持久图片块调用 agent。失败时不公开任何附件路径或原始字节。
`session.attachment` 是只读且限定于会话作用域的端点。只有该会话中的持久事件引用了所请求的附件标识符,宿主才提供字节。会话处于渲染状态时,客户端会按会话和附件标识符对加载操作去重;已渲染会话释放时会撤销已解析的 URL并在分配对象 URL 前拒绝已失效的延迟加载,以免已卸载的会话或已释放的服务重新写入缓存。
### 模型能力与提供方行为
模型目录项增加可选且可合并扩展的输入模态声明。缺少声明表示未知;声明存在但不含 `image`,则明确表示不支持图片。
宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果该模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。包含图片的提示词准入与模型选择共用一个逐 agent 的串行边界,而且已经出队的提示词在其持久消息事件发布前仍保持待发布状态([顺序决策](../bug-fix/2026-07-29-atomic-web-image-admission.md)steering 载体则从入队起就参与门槛,直到其 `steering/message` 事件发布为止,堵住了从不进入排队镜像的 outbox 窗口。当图片正等待发布或仍存在于会话当前的派生历史中时模型选择会拒绝纯文本目标。压缩compaction可以移除旧图片使之后选择纯文本目标变得有效未发布任何事件即转入空闲时已认领的 queued 载体会被释放,而保留在 outbox 中的 steering 在发布或丢弃前始终受门槛约束。`session.updateQueue` 的编辑只接受文本内容,因此队列编辑无法绕过该准入边界注入图片。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。浏览器会在分配预览 URL 前拒绝声明不支持的图片媒体类型,但不会为部署限制或模型能力保留快照:握手快照无法表达 `session.selectModel` 之后会话的当前目标,部署策略也可能独立变化。宿主会根据当前的单张字节数、图片数量、总字节数、媒体类型、尺寸、像素数和路由模型策略校验整个批次,再写入任何附件或事件;其拒绝通过 composer 错误条呈现。
Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachments`,递归转换每个持久图片引用,包括嵌套在工具结果中的引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。交付的组合会同时注册 Pi-AI OpenAI、Anthropic 路由和仅支持文本的默认 DeepSeek 路由;选择当前提供方/模型仍由宿主组合或配置承担,而不是图片输入 CLI命令行界面的功能。在请求时解析服务可避免 Cordis 加载顺序将可选附件服务的可用性固化。手写 DeepSeek 适配器遇到请求中任何位置的图片时都会抛出类型化的 `UNSUPPORTED_CONTENT` 错误,包括嵌套工具结果中的图片。任何适配器都不得将图片展平或跳过。
核心层支持结构化助手图片块,但当前没有任何生产提供方路径通过图片输出认证。未来任何支持输出的适配器都必须在有界的大小和时间策略下获取提供方字节,通过同一个附件服务校验并持久化字节,之后才能以原子方式发布 `ImageBlock`。助手 Markdown 中的 URL 仍是文本,绝不自动下载。
提供方无关的 token 估算不会根据图片尺寸猜测视觉定价;提供方返回的用量仍是权威值。在 ACPAgent Client Protocol接口原生支持图片前ACP 会渲染明确的图片标记,而不是静默省略该块。
压缩会把选定的会话前缀(包含图片引用)回放到已配置的摘要生成路径中。支持视觉的路径会通过适配器解析这些引用;仅文本路径会明确失败,而不是静默丢弃视觉上下文。合成的检查点仍仅包含文本,`compact-basic` 会以 `UNSUPPORTED_CONTENT` 拒绝包含图片的摘要输出。
### 历史渲染与原图预览
历史记录折叠会在用户消息和助手消息中保留 `ImageBlock`。用户图片在文本上方靠尾端对齐;助手图片则保留在靠前端叙述流中的原内容块位置。`MessageImage` 根据记录的尺寸派生稳定的行内边界框,通过会话授权加载器解析字节,使用 `object-fit: contain`,并将对象缺失或损坏转换为可重试的错误控件。
输入区缩略图和每个 `MessageImage` 各自持有临时原图预览状态,并调用同一个纯 `ImageLightbox`。模态框使用已经解析的原始对象 URL只限制显示尺寸它会聚焦关闭控件并在关闭时把焦点恢复到先前的目标。
### 限制与信任边界
第一版仅接受 PNG、JPEG、WebP 和 GIF。不接受 SVG 和远程 URL。默认限制为每张图片 5 MiB、每条消息 10 张图片和 20 MiB 图片总字节数,以及每张图片 4,000 万个固有像素。这些随部署变化的限制属于经过校验的后端配置,并由宿主在持久化前强制执行。客户端连接载体为每个 API 请求设置独立且可配置的 `maxRequestBodyBytes` 上限(默认 32 MiB如果该上限无法容纳附件服务的图片总量限制经 base64 和请求封装膨胀后的大小,加载就会失败。因此,降低图片策略绝不会静默降低有效文本或其他 RPC 的载体上限。未声明长度的请求体在越过上限的瞬间即被拒绝,而不是先读完再拒。
格式错误的 base64、不支持或不匹配的媒体、截断的图片数据、超出字节限制、超出图片数量、超出像素限制、对象缺失和完整性不匹配都会返回稳定的结构化错误。原始文件名只保留用于显示的末段控制字符会被移除并且任何本地路径都不会写入日志或返回浏览器。
### 包与接口变更
| 接口 | 职责 |
| --- | --- |
| `packages/attachment/attachment` | 不透明附件标识符、图片引用、限制、错误和 `ctx.attachments` 服务。 |
| `packages/attachment/attachment-local` | 私有内容寻址存储、完整光栅解码、完整性校验和配置。 |
| `packages/llm/llm` | 角色无关的 `ImageBlock` 和输入模态元数据。 |
| `packages/llm/llm-pi-ai` | 将持久且受支持的图片输入解析为提供方原生内容。 |
| `packages/llm/llm-deepseek` | 明确拒绝图片内容。 |
| `packages/compact/compact-basic` | 在摘要输入中保留图片,并明确拒绝非文本检查点输出。 |
| `packages/host/apiproxy``packages/bundle/base` | 范围狭窄的上传协议、先持久化再追加事件的顺序、会话授权读取、限制和模型前置检查,以及默认 profile 组合。 |
| `packages/client/connection``packages/client/runtime` | 有界请求缓冲、协议类型、fixture测试前置数据图片、提示词上传、附件读取和持久引用折叠。 |
| `packages/client/ui-conversation` | 每个会话的草稿图片、附件栏、用户与助手图片控件和原图预览。 |
| `packages/acp/acp` | 图片块的明确兜底渲染。 |
附件包package构成一个能力服务边界的接口与实现侧。输入区行为留在会话对象层提供方转换留在适配器中无需修改 `agent-loop`
### 实现
已实现的范围包括附件服务边界、角色无关的图片块、Pi-AI 输入转换、DeepSeek 拒绝、宿主持久化顺序、Web 上传与读取协议、当前图片限制执行、大小受限的 Web 请求体、内存草稿图片、粘贴与拖放附件栏、用户与助手历史图片渲染、双击预览、压缩处理,以及组装后无需密钥的 Web 覆盖。
预发布提示词协议不需要兼容包装层;引入相应切片时会同时修改所有调用点和 fixture。
## 曾考虑的替代方案
### 将每张粘贴图片保留在 `/var` 或其他临时目录中
临时存储适合在发送前使用,也适合通过操作系统接收剪贴板文件的原生客户端。但它不适合在消息被接受后继续使用:清理不在 harness 的控制范围内,路径因宿主而异,恢复或 fork 后的会话也可能比文件存在得更久。提案允许临时暂存,但会在追加事件前将已接受的字节复制进 `DSH_HOME`
### 粘贴或拖放后立即持久化
立即持久化可以让草稿在重载后继续存在,但会在会话或消息持有对象前就创建持久对象,因此必须定义配额、遗留对象生命周期和清理策略。第一版保持未发送草稿为临时状态,并把发送被接受作为持久性边界。
### 在消息与会话日志中内联 base64
这种方式会在 RPC、事件、历史分页、fork、压缩和浏览器存储中复制二进制数据还会诱使 token 计量把编码文本当成模型文本。单一不可变对象配合小型引用,可以让持久表示保持有界。
### 使用浏览器对象 URL、本地路径或提供方 URL 作为规范内容
对象 URL 会随文档失效,本地路径不可移植,提供方 URL 则可能过期、跟踪查看者或暴露凭据。它们只能作为临时传输或预览细节存在。
### 用一个通用 `AttachmentBlock` 表示图片、文件、音频和视频
输入区展示可以使用通用附件栏但提供方语义取决于具体模态。图片是原生多模态输入PDF 可能是提供方文件或提取后的文本;视频可能由模型原生支持、抽帧处理或不受支持。特定的 `ImageBlock` 会迫使每个消费方明确处理或拒绝该模态。
### 依赖 UI 能力检查或静默过滤图片
UI 状态可能陈旧,也无法保护直接 SDK、ACP、回放或未收录模型的路径。静默过滤会改变用户意图。提供方强制检查仍是必需项UI 检查则是可选的提前反馈。
## 测试
- 存储测试覆盖内容寻址去重、私有权限、准入失败、对象损坏或缺失时的失败,以及收紧部署限制后读取历史数据。
- 宿主与协议测试覆盖先持久化再追加事件的顺序、日志中不含 base64、会话作用域授权、能力拒绝、上传限制、大小受限的 HTTP 请求体、图片准入与模型选择的竞态(排队与 steering 两种放置)、待发布状态、未发布即空闲时的门槛释放、仅文本的队列编辑,以及压缩后依据当前派生历史进行的选择。
- 客户端单元测试覆盖粘贴与拖放、混合剪贴板文本、仅图片发送、草稿恢复、顺序、草稿、会话作用域和应用层级的对象 URL 清理以及一项在释放后才完成的延迟历史读取keyless 的组装后构建产物通道(`apps/web/tests/image-display.snapshot.ts``DSH_EXAMPLE_MODE=lib pnpm run test:snapshot`)覆盖经授权附件路由渲染的历史用户与助手图片画廊、原图 lightbox以及 composer 粘贴缩略图条。
- 适配器与压缩测试覆盖 Pi-AI 原生图片转换、后置附件服务组合、仅文本拒绝、递归嵌套在工具结果中的图片、保留摘要输入,以及明确拒绝图片输出。
- 需要凭据的实际 API 测试会通过 Anthropic `claude-opus-4-8` 路径发送一张 PNG并要求模型识别其中的二维码。
- 当前生产适配器集合没有经过认证的图片输出路由;输出提供方认证仍不在第一版范围内。
## 后果
- 持久存储会在没有垃圾回收时持续增长。第一版选择回放安全,而不是过早删除。
- 对象缺失或损坏会让模型请求无法精确重建。明确失败可以保持完整性,但在修复前可能阻止该会话继续运行。
- JSON-RPC base64 会增加上传内存,并带来约三分之一的编码开销。第一版的限制可以约束开销;更大的媒体需要流式传输或二进制传输协议。
- 未发送图片无法跨重载保留。持久草稿需要配额和遗留对象清理,而不是隐式复用消息存储。
- 原图预览解码的像素多于行内控件显示的像素。像素限制、一次只打开一个预览和对象 URL 释放可以约束但无法消除浏览器瞬时内存占用。
- 能力元数据可能缺失或陈旧。宿主前置检查可以改善反馈,适配器强制检查仍是权威结果。
- 未来输出提供方可能需要经过身份认证的下载,助手图片才能完成,这会增加延迟与新的故障点。先持久化再追加事件的顺序优先保障回放完整性。
- 文件选择、通用文件与 PDF、音频与视频、持久草稿暂存、图片复制、自定义上下文菜单、输出提供方认证和按引用感知的垃圾回收仍是相互独立的设计。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-29-simplify-web-image-input-v1.md
2026-07-29-simplify-web-image-input-v1.md: e7847dc2ae18f48146cb2b686dcda64beebf2350
2026-07-29-simplify-web-image-input-v1.zh.md: c9e0a3cc5dc07b70be4b0c1244921ccac567d9af

View File

@@ -0,0 +1,37 @@
# Agent Note: Simplify Web image input version one
Status: implemented
English | [中文](2026-07-29-simplify-web-image-input-v1.zh.md)
## Problem
The first durable Web image-input slice introduced required ordered multi-image intake alongside speculative surfaces for arbitrary CLI provider mounting, output-modality discovery, alternative text, provider-neutral visual token pricing, and browser lifecycle APIs with no cross-package consumer. Keeping the speculative surfaces would turn unchosen future behavior into public contracts and make the initial capability harder to review and maintain.
## Decision
Version one accepts ordered image batches bounded by configurable per-message count and aggregate-byte limits plus per-image byte and pixel limits. The browser rejects unsupported declared formats before preview allocation, while the host authoritatively decodes the complete batch, checks current deployment bounds, validates every image without storage writes, and only then saves every image while preserving submitted order in the resulting durable blocks. Request buffering derives directly from the attachment service's aggregate image-byte limit. This preserves validation atomicity without adding a batch transaction, rollback protocol, or policy snapshot in `host.describe`.
Provider/model selection remains configuration and profile state. The boot composition registers the shipped DeepSeek, OpenAI, and Anthropic routes; the CLI does not add image-specific selection flags, inspect the yml provider roster, or dynamically mount an adapter.
Exact-model metadata carries only the input modalities that current admission decisions consume. `ImageBlock` carries the durable attachment reference; its optional display name supplies accessible UI text, so the core block has no separate alternative-text field. Provider-neutral token estimation does not apply one provider's visual pricing formula to other routes.
The attachment seam exposes its limits plus storage-free `validateImage`, `saveImage`, and `readImage`. The host depends on that seam rather than implementation re-exports. Browser draft and historical-image operations remain concrete conversation-plugin internals; the public `IConversation` face contains only the input registry and the scoped send, cancel, and history verbs used across package boundaries.
## Alternatives considered
**Accept only one image.** Comparing or combining several images is a current product requirement. Count and aggregate-byte bounds keep that path finite without reducing it to a single image.
**Add a storage transaction or rollback protocol.** Storage-free validation prevents malformed later members from leaving earlier valid members unreferenced. Stronger all-or-nothing storage across independent content-addressed objects would require ownership or reclamation semantics that the current product path does not need.
**Keep future-facing fields and methods as placeholders.** Output modalities, block alternative text, and active-model handshake data had no current decision consumer. Adding them later with their first consumer preserves freedom to choose the correct contract.
**Estimate every image with one tile formula.** Visual pricing varies by provider, model, detail mode, and preprocessing. A hard-coded provider-neutral estimate would look authoritative while being wrong; provider usage is the authoritative accounting source.
**Add CLI provider/model selection or dynamic mounting.** Configuration already owns route selection, plugin composition, and credentials. Duplicating those choices in image-input flags would require parsing or mutating the config tree outside the loader.
## Consequences
The feature retains the two batch limits and one storage-free validation method required by multi-image prompts, while removing unrelated public fields, lifecycle operations, policy snapshots, and route-assembly branches. Provider/model selection remains composition or profile configuration. Pre-request token pressure may undercount visual input until a provider-aware estimator is designed, while reported usage remains exact.
Reintroducing any removed surface requires a concrete consumer and its failure, lifecycle, replay, and testing contract rather than compatibility with this pre-release shape.

View File

@@ -0,0 +1,37 @@
# Agent Note: 简化 Web 图片输入第一版
Status: implemented
[English](2026-07-29-simplify-web-image-input-v1.md) | 中文
## 问题
首个持久化 Web 图片输入切片在引入按顺序接收多张图片的必需能力时,也引入了由 CLI命令行界面挂载任意提供方、输出模态发现、替代文本、提供方无关的视觉 token 定价以及没有跨包package消费方的浏览器生命周期 API 等推测性表面。保留这些推测性表面会把尚未选择的未来行为变成公共契约,并使初始能力更难评审和维护。
## 决策
第一版接受有序图片批次,并以可配置的每条消息图片数量与图片总字节数上限,以及单张图片字节数与像素上限约束批次。浏览器会在分配预览前拒绝声明不支持的格式,而宿主会以权威方式解码完整批次、检查当前部署上限、在不写入存储的情况下校验每张图片,之后才保存每张图片,并在生成的持久图片块中保留提交顺序。请求缓冲上限直接由附件服务的图片总字节数上限派生。这样无需添加批次事务、回滚协议或 `host.describe` 中的策略快照,就能保持校验的原子性。
提供方/模型选择仍属于配置和 profile 状态。启动组合会注册交付的 DeepSeek、OpenAI 和 Anthropic 路由CLI 不会添加图片专用选择标志、检查 yml 提供方清单或动态挂载适配器。
确切模型元数据只携带当前准入决策会消费的输入模态。`ImageBlock` 携带持久附件引用;其可选显示名称提供无障碍 UI 文本,因此核心块没有单独的替代文本字段。与提供方无关的 token 估算不会把某一提供方的视觉定价公式应用于其他路由。
附件服务边界公开其限制、不触碰存储的 `validateImage`,以及 `saveImage``readImage`。宿主依赖该服务边界,而不是实现层重新导出的内容。浏览器草稿和历史图片操作仍是具体会话插件的内部实现;公开的 `IConversation` 表面只包含输入注册表,以及跨包边界使用的按作用域发送、取消和历史记录操作。
## 曾考虑的替代方案
**只接受一张图片。** 比较或组合多张图片是当前产品要求。图片数量和总字节数上限使这条路径保持有界,而无需将其缩减为单张图片。
**添加存储事务或回滚协议。** 不触碰存储的校验能防止后面的畸形成员使前面有效的成员成为无引用对象。若要在独立的内容寻址对象之间实现更强的全有或全无存储保证,就需要当前产品路径并不需要的所有权或回收语义。
**将面向未来的字段和方法保留为占位符。** 输出模态、块替代文本和活跃模型握手数据目前都没有决策消费方。等到第一个消费方出现时再加入这些内容,可以保留选择正确契约的自由。
**使用一种图块公式估算每张图片。** 视觉定价因提供方、模型、细节模式和预处理而异。一项硬编码且与提供方无关的估算会看似权威,实际却是错误的;提供方用量才是权威核算来源。
**添加 CLI 提供方/模型选择或动态挂载。** 配置已经负责路由选择、插件组合和凭据。在图片输入标志中重复这些选择,会要求在 loader 之外解析或修改配置树。
## 后果
该功能保留了多图片提示词所需的两个批次上限和一个不触碰存储的校验方法,同时移除了无关的公开字段、生命周期操作、策略快照和路由组装分支。提供方/模型选择仍属于组合或 profile 配置。在设计出提供方感知型估算器之前,请求前的 token 压力计算可能少计视觉输入,而上报的用量仍保持精确。
重新引入任何已移除表面时,都必须有具体消费方,并为其定义失败、生命周期、回放和测试契约,而不是为了兼容这一预发布形态。

View File

@@ -83,6 +83,7 @@ External packages that a workspace package resolves at runtime. `scripts/install
| [`pnpm`](https://github.com/pnpm/pnpm) | MIT |
| [`react`](https://github.com/facebook/react) | MIT |
| [`react-dom`](https://github.com/facebook/react) | MIT |
| [`sharp`](https://github.com/lovell/sharp) | Apache-2.0 |
| [`shiki`](https://github.com/shikijs/shiki) | MIT |
| [`supports-color`](https://github.com/chalk/supports-color) | MIT |
| [`tsx`](https://github.com/privatenumber/tsx) | MIT |

View File

@@ -46,6 +46,8 @@ flowchart LR
cfg --> plugin_dsh_base_llm_pi_ai
plugin_dsh_base_session_persistence_jsonl["session-persistence-jsonl<br/>@deepseek-ai/dsh-session-persistence-jsonl"]
cfg --> plugin_dsh_base_session_persistence_jsonl
plugin_dsh_base_attachment_local["attachment-local<br/>@deepseek-ai/dsh-attachment-local"]
cfg --> plugin_dsh_base_attachment_local
plugin_dsh_base_session_query_sqlite["session-query-sqlite<br/>@deepseek-ai/dsh-session-query-sqlite"]
cfg --> plugin_dsh_base_session_query_sqlite
plugin_dsh_base_session_projection["session-projection<br/>@deepseek-ai/dsh-session-projection"]
@@ -183,6 +185,7 @@ flowchart LR
| `credentials` | `@deepseek-ai/dsh-credentials-local` |
| `llm-pi-ai` | `@deepseek-ai/dsh-llm-pi-ai` |
| `session-persistence-jsonl` | `@deepseek-ai/dsh-session-persistence-jsonl` |
| `attachment-local` | `@deepseek-ai/dsh-attachment-local` |
| `session-query-sqlite` | `@deepseek-ai/dsh-session-query-sqlite` |
| `session-projection` | `@deepseek-ai/dsh-session-projection` |
| `telemetry-otel` | `@deepseek-ai/dsh-session-telemetry-otel` |

View File

@@ -44,9 +44,15 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
return page.getByRole('dialog', { name: '设置' })
}
/** Tokenize the lane-owned preset root the way the scaffold tokenizes cwd. */
/** Tokenize the lane-owned preset root after general aria normalization. */
function withPresetRoot(snapshot: string): string {
return snapshot.split(userRoot).join('{{presetRoot}}')
const rootSuffix = `/${userRoot.split('/').pop()!}`
return snapshot.split('\n').map((line) => {
const rootStart = line.indexOf(rootSuffix)
if (rootStart === -1) return line
const pathStart = line.lastIndexOf(' ', rootStart) + 1
return `${line.slice(0, pathStart)}{{presetRoot}}${line.slice(rootStart + rootSuffix.length)}`
}).join('\n')
}
beforeAll(async () => {

View File

@@ -11,6 +11,7 @@
//
// Zero model calls: no replay fixture mounts, so a stray stream fails loud.
import { fileURLToPath } from 'node:url'
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
@@ -29,6 +30,30 @@ const HEADER_EXPECTED = join(SNAPSHOT_DIR, 'header.expected.md')
const SHIPPED_PRESETS = fileURLToPath(new URL('../../cli/config/agent-presets', import.meta.url))
const MODE = webSnapshotMode()
const SEED_ID = 'agent-preset-selection-web-e2e'
/** A project skill only a preset that mounts `skill-local` can discover. */
const SKILL_NAME = 'preset-catalog-demo'
/**
* Seed one project skill under the connected workspace.
*
* Local skill discovery is a PRESET row, so this file is visible through
* `standard` and invisible through `minimal` — which makes the '/' menu's
* skill group a statement about the session's composition.
* @param workspaceCwd - the scaffold's temp project parent.
*/
async function seedWorkspaceSkill(workspaceCwd: string): Promise<void> {
const directory = join(workspaceCwd, 'workspace', '.agents', 'skills', SKILL_NAME)
await mkdir(directory, { recursive: true })
await writeFile(join(directory, 'SKILL.md'), [
'---',
`name: ${SKILL_NAME}`,
'description: Prove the slash catalog follows the session composition',
'---',
'',
'Body.',
'',
].join('\n'))
}
/**
* A settled one-turn session with no model content: this lane asserts chrome
@@ -53,6 +78,35 @@ function seedLog(): string {
].join('\n')
}
/**
* The preset the host reports for the blank session the workspace connect
* produced. Addressed by id rather than by scanning the serialized list: the
* seeded session records `minimal` too, so a substring match over the whole
* list answers before the switch has landed.
* @param baseUrl - the scaffold's origin.
* @returns the live session's preset, or undefined before it is listed.
*/
async function livePreset(baseUrl: string): Promise<string | undefined> {
const response = await fetch(`${baseUrl}/api/session.list`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
type: 'client-request', rpcId: 'agent-preset-live', method: 'session.list', payload: {},
}),
})
const body = await response.json() as {
result: { value?: { items: { sessionId: string; agentPreset?: string }[] } }
}
return body.result.value?.items.find(item => item.sessionId !== SEED_ID)?.agentPreset
}
/** Every option label the trigger menu currently lists. */
async function menuOptions(page: Page): Promise<string[]> {
const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
await menu.waitFor({ timeout: 10_000 })
return await menu.getByRole('option').allTextContents()
}
describe('web e2e: agent-preset selection', () => {
let scaffold: WebScaffold
let browser: Browser
@@ -67,6 +121,7 @@ describe('web e2e: agent-preset selection', () => {
// records `minimal` is what makes the header label a claim about the
// session rather than an echo of the current default.
await seedSession(scaffold, seedLog(), SEED_ID, 'minimal')
await seedWorkspaceSkill(scaffold.workspaceCwd)
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
@@ -114,21 +169,47 @@ describe('web e2e: agent-preset selection', () => {
// The chip stages; the blank session the workspace connect produced is
// what the stage lands on. The host's own answer is what comes back.
await expect.poll(async () => {
const response = await fetch(`${scaffold.baseUrl}/api/session.list`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
type: 'client-request', rpcId: 'agent-preset-stage', method: 'session.list', payload: {},
}),
})
const body = await response.json() as {
result: { value?: { sessions: { blank: boolean; agentPreset?: string }[] } }
}
return JSON.stringify(body.result.value?.sessions ?? body.result)
}, { timeout: 15_000 }).toContain('minimal')
await expect.poll(() => livePreset(scaffold.baseUrl), { timeout: 15_000 }).toBe('minimal')
})
it('re-reads the slash catalog through the composition the switch installed', async () => {
// Continues the previous case: the chip has already applied `minimal` to
// the blank session, and this one reads the menu that switch left behind.
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-slash-catalog'))
const composer = page.locator('textarea:enabled').last()
// `minimal` mounts neither the compaction group nor plan mode nor local
// skill discovery, so the catalog the composer warmed under the
// deployment default must not survive the switch.
await composer.fill('/')
await expect.poll(() => menuOptions(page), { timeout: 15_000 })
.not.toEqual(expect.arrayContaining([expect.stringContaining(SKILL_NAME)]))
const onMinimal = await menuOptions(page)
expect(onMinimal.some(option => option.startsWith('compact'))).toBe(false)
expect(onMinimal.some(option => option.startsWith('plan'))).toBe(false)
// The host-plane commands and the client's own contribution are the
// floor: they belong to no preset and never move.
expect(onMinimal.some(option => option.startsWith('goal'))).toBe(true)
expect(onMinimal.some(option => option.startsWith('model'))).toBe(true)
await composer.fill('')
// Switching back up reaches the host at all — the chip compares the pick
// against its list row, so a row that never reprojected the first switch
// answers "already standard" and sends nothing — and restores the catalog
// instead of leaving the session reading the narrower composition.
await page.getByRole('button', { name: '极简模式' }).click()
await page.getByRole('menuitem', { name: /^标准模式/ }).first().click()
await expect.poll(() => livePreset(scaffold.baseUrl), { timeout: 15_000 }).toBe('standard')
await composer.fill('/')
await expect.poll(() => menuOptions(page), { timeout: 15_000 })
.toEqual(expect.arrayContaining([expect.stringContaining(SKILL_NAME)]))
const onStandard = await menuOptions(page)
expect(onStandard.some(option => option.startsWith('compact'))).toBe(true)
expect(onStandard.some(option => option.startsWith('plan'))).toBe(true)
await composer.fill('')
}, 90_000)
it('labels a resumed session with the preset it was created under', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-header'))
// The seeded session's cwd is the scaffold root rather than the connected

View File

@@ -0,0 +1,138 @@
// @vitest-environment jsdom
// Multimodal image surfaces over the BUILT client graph (the code-mode-fixture
// idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport).
// Opens the fixture history session whose turn 72 carries an image in BOTH a
// user message and an assistant message, and pins the product surfaces: the
// history ImageGallery loading real fixture bytes through the authorized
// sessions.attachment route, the double-click ImageLightbox, and the composer
// intake chain (paste → ordered thumbnail rail → image-only send enablement → remove).
import { fireEvent, screen, waitFor, within } from '@testing-library/react'
import { expect, it } from 'vitest'
import { installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts'
installAssembledBootEnv()
/** Open the fixture history session (the alpha log carrying the turn-72 image pair) and wait for its gallery. */
async function openFixtureSession(): Promise<void> {
const tree = await screen.findByRole('tree', { name: '会话' }, { timeout: 10_000 })
const group = (await within(tree).findAllByText('fixture'))
.map(el => el.closest<HTMLElement>('[role="treeitem"]'))
.find(el => el?.getAttribute('aria-expanded') !== null)
if (group === null || group === undefined) throw new Error('fixture Workspace group missing')
if (group.getAttribute('aria-expanded') === 'false') {
fireEvent.click(within(group).getByText('fixture'))
await waitFor(() => {
expect(group.getAttribute('aria-expanded')).toBe('true')
})
}
const session = await within(tree).findByText('Fixture 历史会话')
fireEvent.click(session)
await waitFor(() => {
expect(document.querySelectorAll('[data-align] img').length).toBeGreaterThan(0)
}, { timeout: 10_000 })
}
it('renders the history image pair through the authorized attachment route and opens the lightbox', async () => {
localStorage.setItem('dsh.locale', 'zh')
mountAssembledApp()
await openFixtureSession()
// Both the user-side (align=end) and assistant-side (align=start) galleries
// load real fixture bytes over sessions.attachment. jsdom provides
// createObjectURL, so this environment MUST take the object-URL path — a
// data: src here would mean the fallback ran where it should not.
await waitFor(() => {
if (document.querySelector('[data-align="end"] img') === null
|| document.querySelector('[data-align="start"] img') === null) {
throw new Error('history image galleries missing')
}
}, { timeout: 10_000 })
const galleryShape = (align: string) => [...document.querySelectorAll(`[data-align="${align}"] img`)]
.map(img => ({ alt: img.getAttribute('alt'), scheme: img.getAttribute('src')?.split(':')[0] }))
expect({ user: galleryShape('end'), assistant: galleryShape('start') }).toMatchInlineSnapshot(`
{
"assistant": [
{
"alt": "fixture-image.png",
"scheme": "blob",
},
],
"user": [
{
"alt": "fixture-image.png",
"scheme": "blob",
},
],
}
`)
const userImage = document.querySelector<HTMLElement>('[data-align="end"] img')!
// Double-click opens the original-size lightbox; Escape/close dismisses it.
const frame = userImage.closest('button')
if (frame === null) throw new Error('image frame button missing')
fireEvent.doubleClick(frame)
const lightbox = await screen.findByRole('dialog')
expect(within(lightbox).getByRole('img').getAttribute('src')?.split(':')[0]).toBe('blob')
fireEvent.click(within(lightbox).getByRole('button', { name: /关闭/ }))
await waitFor(() => {
expect(screen.queryByRole('dialog')).toBeNull()
})
})
it('accepts pasted images into the composer rail in order and removes them', async () => {
localStorage.setItem('dsh.locale', 'zh')
mountAssembledApp()
const tree = await screen.findByRole('tree', { name: '会话' }, { timeout: 10_000 })
const start = tree.querySelector<HTMLButtonElement>('button[aria-label="在“fixture”中新建会话"]')
if (start === null) throw new Error('fixture Workspace new-session action missing')
fireEvent.click(start)
// Image-only send arming is pinned at package level (input-bar.spec.tsx);
// this assembled lane pins the intake chain over the built graph.
const textarea = await screen.findByPlaceholderText('描述你想要构建的内容', {}, { timeout: 10_000 })
const image = new File([new Uint8Array([137, 80, 78, 71])], 'pasted.png', { type: 'image/png' })
fireEvent.paste(textarea, {
clipboardData: {
items: [{ kind: 'file', type: 'image/png', getAsFile: () => image }],
getData: () => '',
},
})
// The rail is an accessible group holding the draft thumbnail (queried via
// DOM: jsdom's a11y-visibility computation hides the composer subtree).
const rail = await waitFor(() => {
const el = document.querySelector('[role="group"][aria-label="待发送图片"]')
if (el === null) throw new Error('attachment rail missing')
return el
}, { timeout: 5_000 })
expect([...rail.querySelectorAll('img')].map(img => ({
alt: img.getAttribute('alt'), scheme: img.getAttribute('src')?.split(':')[0],
}))).toMatchInlineSnapshot(`
[
{
"alt": "pasted.png",
"scheme": "blob",
},
]
`)
const second = new File([new Uint8Array([137, 80, 78, 71])], 'second.png', { type: 'image/png' })
fireEvent.paste(textarea, {
clipboardData: {
items: [{ kind: 'file', type: 'image/png', getAsFile: () => second }],
getData: () => '',
},
})
await waitFor(() => {
expect([...rail.querySelectorAll('img')].map(img => img.getAttribute('alt')))
.toEqual(['pasted.png', 'second.png'])
})
const remove = [...rail.querySelectorAll('button[aria-label^="移除图片"]')]
if (remove.length !== 2) throw new Error('remove buttons missing')
for (const button of remove) fireEvent.click(button)
await waitFor(() => {
expect(document.querySelector('[role="group"][aria-label="待发送图片"]')).toBeNull()
})
})

View File

@@ -1,6 +1,8 @@
- banner:
- navigation "Session hierarchy":
- button "Use the ask_user_question tool to" [disabled]
- img
- text: 标准模式
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,6 +1,8 @@
- banner:
- navigation "Session hierarchy":
- button "Use the ask_user_question tool to" [disabled]
- img
- text: 标准模式
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -314,6 +314,7 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => {
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
await page.getByText('标准模式', { exact: true }).waitFor({ timeout: 10_000 })
}, 120_000)
afterAll(async () => {

View File

@@ -446,7 +446,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
).toBe(3)
expect(await page.getByText('Ungrouped', { exact: true }).count()).toBe(0)
const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
expect(await hierarchy.getByRole('button').count()).toBe(1)
await expect.poll(() => hierarchy.getByRole('button').count()).toBe(1)
await compareOrRefreshGolden(
FORK_EXPECTED,
await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd),

View File

@@ -2,7 +2,7 @@
// Assembled todo snapshot: boots the real built `packages/client/*/lib/
// client.js` bundles through AppWebEntry's ModuleLoader path against the
// keyless FixtureApiClient transport, opens the fixture session, and pins the
// two surfaces the fixture's parallel plan (turn 72, two items `in_progress`)
// two surfaces the fixture's parallel plan (turn 73, two items `in_progress`)
// reaches — the `todo_write` tool row and the dock's plan strip.
//
// The row is pinned as three separate fields on purpose. `summary=` is the

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/capability-seams.md
capability-seams.md: 345e17c8c28bbe3465abd20639770e6331a70d11
capability-seams.zh.md: 472aaabf4c992fdcd54fbe0e9a14cf9a82e6b803
capability-seams.md: d5610b6bf6bcb39bfa146ada53ce0734a978b8cd
capability-seams.zh.md: 8a1b38d3600d673a8e0a99941e51d0973101a1c1

View File

@@ -7,10 +7,14 @@ A service can be a core spine service, a swappable capability seam, or a bundle/
```mermaid
flowchart LR
pkg_attachment["attachment"]
svc_attachments["ctx.attachments<br/>Durable binary attachment storage"]
pkg_attachment_local["attachment-local"]
pkg_host_runtime["host-runtime"]
pkg_llm_pi_ai["llm-pi-ai"]
pkg_llm["llm"]
svc_llm["ctx.llm<br/>LLM adapter registry"]
pkg_llm_deepseek["llm-deepseek"]
pkg_llm_pi_ai["llm-pi-ai"]
pkg_llm_replay["llm-replay"]
pkg_agent_loop["agent-loop"]
pkg_compact_basic["compact-basic"]
@@ -186,6 +190,8 @@ flowchart LR
pkg_agent_presets --> svc_agentPresets
pkg_api_gateway --> svc_typertGateway
pkg_approval --> svc_approval
pkg_attachment --> svc_attachments
pkg_attachment_local --> svc_attachments
pkg_bash --> svc_bash
pkg_bash_env --> svc_bashEnv
pkg_bash_local --> svc_bash
@@ -280,6 +286,8 @@ flowchart LR
svc_agents --> pkg_subagent_inprocess
svc_approval --> pkg_tool_bash
svc_approval --> pkg_tools
svc_attachments --> pkg_host_runtime
svc_attachments --> pkg_llm_pi_ai
svc_bash --> pkg_hooks_claude
svc_bash --> pkg_hooks_codex
svc_bash --> pkg_tool_bash
@@ -380,6 +388,7 @@ flowchart LR
| ctx key | Role | Owner | Implementations | Direct consumers | Companion plugins | Note |
| --- | --- | --- | --- | --- | --- | --- |
| `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | `host-runtime`, [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | The host commits accepted images before session events; provider adapters resolve authorized durable references into provider-native content. |
| `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compact-basic`](../packages/compact/compact-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. |
| `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements. |
| `ctx.toolResultPrune` | `core` | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Rewrites oversized current tool results through replayable single-node surface replacements before summary compaction. |

View File

@@ -9,10 +9,14 @@
```mermaid
flowchart LR
pkg_attachment["attachment"]
svc_attachments["ctx.attachments<br/>Durable binary attachment storage"]
pkg_attachment_local["attachment-local"]
pkg_host_runtime["host-runtime"]
pkg_llm_pi_ai["llm-pi-ai"]
pkg_llm["llm"]
svc_llm["ctx.llm<br/>LLM adapter registry"]
pkg_llm_deepseek["llm-deepseek"]
pkg_llm_pi_ai["llm-pi-ai"]
pkg_llm_replay["llm-replay"]
pkg_agent_loop["agent-loop"]
pkg_compact_basic["compact-basic"]
@@ -188,6 +192,8 @@ flowchart LR
pkg_agent_presets --> svc_agentPresets
pkg_api_gateway --> svc_typertGateway
pkg_approval --> svc_approval
pkg_attachment --> svc_attachments
pkg_attachment_local --> svc_attachments
pkg_bash --> svc_bash
pkg_bash_env --> svc_bashEnv
pkg_bash_local --> svc_bash
@@ -282,6 +288,8 @@ flowchart LR
svc_agents --> pkg_subagent_inprocess
svc_approval --> pkg_tool_bash
svc_approval --> pkg_tools
svc_attachments --> pkg_host_runtime
svc_attachments --> pkg_llm_pi_ai
svc_bash --> pkg_hooks_claude
svc_bash --> pkg_hooks_codex
svc_bash --> pkg_tool_bash
@@ -382,6 +390,7 @@ flowchart LR
| ctx 键 | 角色 | 所属包 | 实现 | 直接消费方 | 配套插件 | 说明 |
| --- | --- | --- | --- | --- | --- | --- |
| `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | `host-runtime`、[`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | 宿主会在会话事件之前提交已接受的图片;提供方适配器将已授权的持久引用解析为提供方原生内容。 |
| `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek)、[`llm-pi-ai`](../packages/llm/llm-pi-ai)、[`llm-replay`](../packages/support/llm-replay) | [`agent-loop`](../packages/core/agent-loop)、[`compact-basic`](../packages/compact/compact-basic) | - | 适配器注册提供方实现agent loop智能体循环与压缩功能调用提供方无关的流服务。 |
| `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compact-basic`](../packages/compact/compact-basic) | - | 拥有按会话隔离的回放折叠区;压力消费方共享不可变且带修订版本的测量结果。 |
| `ctx.toolResultPrune` | `core` | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | - | [`compact-basic`](../packages/compact/compact-basic) | - | 在摘要压缩前,通过可回放的单节点表层替换来改写过大的当前工具结果。 |

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/config-catalog.md
config-catalog.md: 18980d22c694647374b9fa4e6dfbf245ff2416c4
config-catalog.zh.md: a43c561806498ca53a95af815d0cd7686a0100ca
config-catalog.md: 234e07eafdc4a01681ccff4b7ac8290905cd677b
config-catalog.zh.md: d092947d31cfe4b24cae5d0ee8570dda39d7a287

View File

@@ -261,6 +261,26 @@ Depends on: [`ToolPresentationMode`](subsystems/tools.md)
Source: [`packages/core/agent-tool-mode/src/index.ts:36`](../packages/core/agent-tool-mode/src/index.ts)
## `@deepseek-ai/dsh-attachment-local`
```ts config-catalog
/** Local attachment backend configuration. */
export interface Config {
/** Explicit harness home; omitted follows `DSH_HOME`, then `~/.dsh`. */
dshHome?: string
/** Maximum encoded bytes accepted for one image. */
maxImageBytes?: number
/** Maximum image count accepted in one submitted message. */
maxImagesPerMessage?: number
/** Maximum aggregate encoded image bytes accepted in one submitted message. */
maxMessageImageBytes?: number
/** Maximum intrinsic width multiplied by height accepted for one image. */
maxImagePixels?: number
}
```
Source: [`packages/attachment/attachment-local/src/index.ts:24`](../packages/attachment/attachment-local/src/index.ts)
## `@deepseek-ai/dsh-bash-env`
```ts config-catalog
@@ -332,10 +352,12 @@ export interface ConnectionConfig {
* that is not a bare, canonical authority fails the plugin load.
*/
trustedHosts?: string[]
/** Maximum buffered JSON body for every `/api` request. */
maxRequestBodyBytes?: number
}
```
Source: [`packages/client/connection/src/index.ts:32`](../packages/client/connection/src/index.ts)
Source: [`packages/client/connection/src/index.ts:52`](../packages/client/connection/src/index.ts)
## `@deepseek-ai/dsh-client-hmr`
@@ -617,7 +639,7 @@ Source: [`packages/hooks/hooks-codex/src/index.ts:44`](../packages/hooks/hooks-c
## `@deepseek-ai/dsh-host-apiproxy`
Requires: `agentDefaultModel` · `agents` · `directoryPicker` · `llm` · `sessions` · `subagents` · `sessionQuery` · `tools` · `userInteraction` · `workspace`
Requires: `agentDefaultModel` · `agents` · `attachments` · `directoryPicker` · `llm` · `sessions` · `subagents` · `sessionQuery` · `tools` · `userInteraction` · `workspace`
```ts config-catalog
/** Gateway plugin config: the Host-only Workspace creation root. */
@@ -2736,6 +2758,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
Abstract service classes — a deployment loads a concrete implementation package instead ([capability seams](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)).
- `@deepseek-ai/dsh-attachment` — abstract `AttachmentStore` ([`packages/attachment/attachment/src/index.ts`](../packages/attachment/attachment/src/index.ts))
- `@deepseek-ai/dsh-bash` — abstract `BashExecutor` ([`packages/bash/bash/src/index.ts`](../packages/bash/bash/src/index.ts))
- `@deepseek-ai/dsh-code-runtime` — abstract `CodeRuntime` ([`packages/code-runtime/code-runtime/src/index.ts`](../packages/code-runtime/code-runtime/src/index.ts))
- `@deepseek-ai/dsh-compact` — abstract `CompactService` ([`packages/compact/compact/src/index.ts`](../packages/compact/compact/src/index.ts))

View File

@@ -263,6 +263,26 @@ export interface Config {
来源:[`packages/core/agent-tool-mode/src/index.ts:36`](../packages/core/agent-tool-mode/src/index.ts)
## `@deepseek-ai/dsh-attachment-local`
```ts config-catalog
/** Local attachment backend configuration. */
export interface Config {
/** Explicit harness home; omitted follows `DSH_HOME`, then `~/.dsh`. */
dshHome?: string
/** Maximum encoded bytes accepted for one image. */
maxImageBytes?: number
/** Maximum image count accepted in one submitted message. */
maxImagesPerMessage?: number
/** Maximum aggregate encoded image bytes accepted in one submitted message. */
maxMessageImageBytes?: number
/** Maximum intrinsic width multiplied by height accepted for one image. */
maxImagePixels?: number
}
```
来源:[`packages/attachment/attachment-local/src/index.ts:24`](../packages/attachment/attachment-local/src/index.ts)
## `@deepseek-ai/dsh-bash-env`
```ts config-catalog
@@ -334,10 +354,12 @@ export interface ConnectionConfig {
* that is not a bare, canonical authority fails the plugin load.
*/
trustedHosts?: string[]
/** Maximum buffered JSON body for every `/api` request. */
maxRequestBodyBytes?: number
}
```
来源:[`packages/client/connection/src/index.ts:32`](../packages/client/connection/src/index.ts)
来源:[`packages/client/connection/src/index.ts:52`](../packages/client/connection/src/index.ts)
## `@deepseek-ai/dsh-client-hmr`
@@ -619,7 +641,7 @@ export interface Config {
## `@deepseek-ai/dsh-host-apiproxy`
需要:`agentDefaultModel` · `agents` · `directoryPicker` · `llm` · `sessions` · `subagents` · `sessionQuery` · `tools` · `userInteraction` · `workspace`
需要:`agentDefaultModel` · `agents` · `attachments` · `directoryPicker` · `llm` · `sessions` · `subagents` · `sessionQuery` · `tools` · `userInteraction` · `workspace`
```ts config-catalog
/** Gateway plugin config: the Host-only Workspace creation root. */
@@ -2737,6 +2759,7 @@ export interface Config {
抽象服务类——部署时应改为加载具体的实现包(参见[能力 seam](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md))。
- `@deepseek-ai/dsh-attachment` — 抽象 `AttachmentStore`[`packages/attachment/attachment/src/index.ts`](../packages/attachment/attachment/src/index.ts)
- `@deepseek-ai/dsh-bash` — 抽象 `BashExecutor`[`packages/bash/bash/src/index.ts`](../packages/bash/bash/src/index.ts)
- `@deepseek-ai/dsh-code-runtime` — 抽象 `CodeRuntime`[`packages/code-runtime/code-runtime/src/index.ts`](../packages/code-runtime/code-runtime/src/index.ts)
- `@deepseek-ai/dsh-compact` — 抽象 `CompactService`[`packages/compact/compact/src/index.ts`](../packages/compact/compact/src/index.ts)

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
event-producer-consumer.md: 3b8a6b1dd155fd1350b164f1dd2d2bf0ec26a4a5
event-producer-consumer.zh.md: 12de167fcd1217f00a8ae719ef3191a4873a2799
event-producer-consumer.md: 4b7a8794808cc02daac7031f4981a765c97ca81a
event-producer-consumer.zh.md: 976f41d7798e9182b60366d77e546d79c4a66a13

View File

@@ -28,8 +28,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:76`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-policy`](../packages/fs/fs-policy), [`skill-local`](../packages/skill/skill-local) |
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:73`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:62`](../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/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:64`](../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/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:74`](../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), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:96`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-context`](../packages/context/workspace-context) |
@@ -70,6 +70,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `internal/status` | - | [`agent`](../packages/core/agent) |
| `locale/change` | `locale` (`emit`) | `locale` |
| `models/changed` | `runtime` (`emit`) | `ui-models` |
| `session/preset-changed` | `runtime` (`emit`) | `ui-command` |
| `settings/changed` | `runtime` (`emit`) | `ui-models`, `ui-permission`, `ui-settings-general` |
| `slash/input-begin-command` | - | `ui-conversation` |
| `slash/input-consume-token` | - | `ui-conversation` |

View File

@@ -30,8 +30,8 @@
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:76`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-policy`](../packages/fs/fs-policy), [`skill-local`](../packages/skill/skill-local) |
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:73`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:62`](../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/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:64`](../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/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:74`](../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), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:96`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-context`](../packages/context/workspace-context) |
@@ -72,6 +72,7 @@
| `internal/status` | - | [`agent`](../packages/core/agent) |
| `locale/change` | `locale` (`emit`) | `locale` |
| `models/changed` | `runtime` (`emit`) | `ui-models` |
| `session/preset-changed` | `runtime` (`emit`) | `ui-command` |
| `settings/changed` | `runtime` (`emit`) | `ui-models`, `ui-permission`, `ui-settings-general` |
| `slash/input-begin-command` | - | `ui-conversation` |
| `slash/input-consume-token` | - | `ui-conversation` |

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/module-graph.md
module-graph.md: c41db02165740b19a9ef751e6f50316a76df28c3
module-graph.zh.md: 9071dbc0f2e6df8ec7edd1f3e14cd7ff063123fa
module-graph.md: 9df5eabe1c39fd8bb3e14b635e43b782c816d369
module-graph.zh.md: ee187222fa1ed727941e7820ac8c33ed532497e3

View File

@@ -127,6 +127,10 @@ flowchart TD
pkg_api_gateway["api-gateway"]
pkg_api_remotes["api-remotes"]
end
subgraph group_attachment["packages/attachment"]
pkg_attachment["attachment"]
pkg_attachment_local["attachment-local"]
end
subgraph group_boot["packages/boot"]
pkg_app_boot["app-boot"]
end
@@ -327,9 +331,8 @@ flowchart TD
pkg_type_meta --> pkg_invariants
pkg_typert_generator --> pkg_invariants
pkg_typert_registry --> pkg_invariants
pkg_llm --> pkg_brand
pkg_llm --> pkg_invariants
pkg_llm --> pkg_timeout
pkg_attachment --> pkg_brand
pkg_attachment --> pkg_invariants
pkg_client_connection --> pkg_host_webserver
pkg_client_connection --> pkg_invariants
pkg_client_hmr --> pkg_client_modules
@@ -365,34 +368,16 @@ flowchart TD
pkg_subprocess_local --> pkg_timeout
pkg_typert_loader --> pkg_invariants
pkg_typert_loader --> pkg_typert_registry
pkg_llm_deepseek --> pkg_credentials
pkg_llm_deepseek --> pkg_environment
pkg_llm_deepseek --> pkg_invariants
pkg_llm_deepseek --> pkg_llm
pkg_llm_deepseek --> pkg_settings
pkg_llm_deepseek --> pkg_timeout
pkg_llm_pi_ai --> pkg_credentials
pkg_llm_pi_ai --> pkg_environment
pkg_llm_pi_ai --> pkg_invariants
pkg_llm_pi_ai --> pkg_llm
pkg_llm_pi_ai --> pkg_settings
pkg_llm_pi_ai --> pkg_timeout
pkg_session --> pkg_brand
pkg_session --> pkg_invariants
pkg_session --> pkg_llm
pkg_session --> pkg_scope
pkg_session --> pkg_type_meta
pkg_system_prompt --> pkg_invariants
pkg_system_prompt --> pkg_llm
pkg_system_prompt --> pkg_scope
pkg_skill --> pkg_invariants
pkg_skill --> pkg_llm
pkg_skill --> pkg_scope
pkg_web --> pkg_invariants
pkg_web --> pkg_llm
pkg_llm --> pkg_attachment
pkg_llm --> pkg_brand
pkg_llm --> pkg_invariants
pkg_llm --> pkg_timeout
pkg_api_gateway --> pkg_client_connection
pkg_api_gateway --> pkg_invariants
pkg_api_gateway --> pkg_typert_registry
pkg_attachment_local --> pkg_attachment
pkg_attachment_local --> pkg_invariants
pkg_attachment_local --> pkg_paths
pkg_client_locale --> pkg_client_runtime
pkg_client_locale --> pkg_client_ui_primitives
pkg_client_locale --> pkg_client_ui_slots
@@ -416,40 +401,36 @@ flowchart TD
pkg_credentials_local --> pkg_environment
pkg_credentials_local --> pkg_invariants
pkg_credentials_local --> pkg_paths
pkg_lsp --> pkg_brand
pkg_lsp --> pkg_invariants
pkg_lsp --> pkg_llm
pkg_settings_local --> pkg_atomic_write
pkg_settings_local --> pkg_invariants
pkg_settings_local --> pkg_paths
pkg_settings_local --> pkg_settings
pkg_agent --> pkg_invariants
pkg_agent --> pkg_llm
pkg_agent --> pkg_scope
pkg_agent --> pkg_session
pkg_agent --> pkg_system_prompt
pkg_agent --> pkg_type_meta
pkg_skill_badge --> pkg_invariants
pkg_skill_badge --> pkg_skill
pkg_web_fetch_local --> pkg_invariants
pkg_web_fetch_local --> pkg_timeout
pkg_web_fetch_local --> pkg_web
pkg_web_search_exa --> pkg_environment
pkg_web_search_exa --> pkg_invariants
pkg_web_search_exa --> pkg_web
pkg_web_search_perplexity --> pkg_environment
pkg_web_search_perplexity --> pkg_invariants
pkg_web_search_perplexity --> pkg_web
pkg_spill --> pkg_brand
pkg_spill --> pkg_invariants
pkg_spill --> pkg_llm
pkg_spill --> pkg_session
pkg_acp_snapshot --> pkg_invariants
pkg_acp_snapshot --> pkg_session
pkg_app_boot --> pkg_environment
pkg_app_boot --> pkg_invariants
pkg_app_boot --> pkg_paths
pkg_app_boot --> pkg_system_prompt
pkg_llm_deepseek --> pkg_credentials
pkg_llm_deepseek --> pkg_environment
pkg_llm_deepseek --> pkg_invariants
pkg_llm_deepseek --> pkg_llm
pkg_llm_deepseek --> pkg_settings
pkg_llm_deepseek --> pkg_timeout
pkg_llm_pi_ai --> pkg_attachment
pkg_llm_pi_ai --> pkg_credentials
pkg_llm_pi_ai --> pkg_environment
pkg_llm_pi_ai --> pkg_invariants
pkg_llm_pi_ai --> pkg_llm
pkg_llm_pi_ai --> pkg_settings
pkg_llm_pi_ai --> pkg_timeout
pkg_session --> pkg_brand
pkg_session --> pkg_invariants
pkg_session --> pkg_llm
pkg_session --> pkg_scope
pkg_session --> pkg_type_meta
pkg_system_prompt --> pkg_invariants
pkg_system_prompt --> pkg_llm
pkg_system_prompt --> pkg_scope
pkg_skill --> pkg_invariants
pkg_skill --> pkg_llm
pkg_skill --> pkg_scope
pkg_web --> pkg_invariants
pkg_web --> pkg_llm
pkg_client_ui_question --> pkg_client_locale
pkg_client_ui_question --> pkg_invariants
pkg_client_ui_settings_general --> pkg_client_connection
@@ -480,10 +461,54 @@ flowchart TD
pkg_client_ui_workspace --> pkg_client_ui_primitives
pkg_client_ui_workspace --> pkg_client_ui_slots
pkg_client_ui_workspace --> pkg_invariants
pkg_lsp --> pkg_brand
pkg_lsp --> pkg_invariants
pkg_lsp --> pkg_llm
pkg_agent --> pkg_invariants
pkg_agent --> pkg_llm
pkg_agent --> pkg_scope
pkg_agent --> pkg_session
pkg_agent --> pkg_system_prompt
pkg_agent --> pkg_type_meta
pkg_skill_badge --> pkg_invariants
pkg_skill_badge --> pkg_skill
pkg_web_fetch_local --> pkg_invariants
pkg_web_fetch_local --> pkg_timeout
pkg_web_fetch_local --> pkg_web
pkg_web_search_exa --> pkg_environment
pkg_web_search_exa --> pkg_invariants
pkg_web_search_exa --> pkg_web
pkg_web_search_perplexity --> pkg_environment
pkg_web_search_perplexity --> pkg_invariants
pkg_web_search_perplexity --> pkg_web
pkg_spill --> pkg_brand
pkg_spill --> pkg_invariants
pkg_spill --> pkg_llm
pkg_spill --> pkg_session
pkg_acp_snapshot --> pkg_invariants
pkg_acp_snapshot --> pkg_session
pkg_app_boot --> pkg_environment
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
pkg_code_runtime_worker --> pkg_timeout
pkg_host_directory_picker_browse --> pkg_client_locale
pkg_host_directory_picker_browse --> pkg_client_runtime
pkg_host_directory_picker_browse --> pkg_client_ui_primitives
pkg_host_directory_picker_browse --> pkg_client_ui_slots
pkg_host_directory_picker_browse --> pkg_client_ui_workspace
pkg_host_directory_picker_browse --> pkg_invariants
pkg_host_directory_picker_native --> pkg_client_runtime
pkg_host_directory_picker_native --> pkg_client_ui_slots
pkg_host_directory_picker_native --> pkg_client_ui_workspace
pkg_host_directory_picker_native --> pkg_invariants
pkg_agent_presets --> pkg_atomic_write
pkg_agent_presets --> pkg_invariants
pkg_agent_presets --> pkg_paths
@@ -538,25 +563,15 @@ flowchart TD
pkg_loader_smoke --> pkg_invariants
pkg_loader_smoke --> pkg_llm
pkg_loader_smoke --> pkg_session
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_time_context --> pkg_agent
pkg_time_context --> pkg_invariants
pkg_time_context --> pkg_session
pkg_host_apiproxy --> pkg_agent_presets
pkg_host_apiproxy --> pkg_invariants
pkg_host_directory_picker_browse --> pkg_client_locale
pkg_host_directory_picker_browse --> pkg_client_runtime
pkg_host_directory_picker_browse --> pkg_client_ui_primitives
pkg_host_directory_picker_browse --> pkg_client_ui_slots
pkg_host_directory_picker_browse --> pkg_client_ui_workspace
pkg_host_directory_picker_browse --> pkg_invariants
pkg_host_directory_picker_native --> pkg_client_runtime
pkg_host_directory_picker_native --> pkg_client_ui_slots
pkg_host_directory_picker_native --> pkg_client_ui_workspace
pkg_host_directory_picker_native --> pkg_invariants
pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse
pkg_host_directory_picker_auto --> pkg_host_directory_picker_native
pkg_host_directory_picker_auto --> pkg_host_webserver
pkg_host_directory_picker_auto --> pkg_invariants
pkg_commands --> pkg_agent
pkg_commands --> pkg_brand
pkg_commands --> pkg_invariants
@@ -696,10 +711,6 @@ flowchart TD
pkg_command_feedback --> pkg_commands
pkg_command_feedback --> pkg_invariants
pkg_command_feedback --> pkg_session
pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse
pkg_host_directory_picker_auto --> pkg_host_directory_picker_native
pkg_host_directory_picker_auto --> pkg_host_webserver
pkg_host_directory_picker_auto --> pkg_invariants
pkg_permission --> pkg_bash
pkg_permission --> pkg_commands
pkg_permission --> pkg_invariants
@@ -1032,6 +1043,8 @@ flowchart TD
pkg_web_app --> pkg_invariants
pkg_web_app --> pkg_system_prompt
pkg_client_ui_conversation --> pkg_agent
pkg_client_ui_conversation --> pkg_attachment
pkg_client_ui_conversation --> pkg_brand
pkg_client_ui_conversation --> pkg_client_locale
pkg_client_ui_conversation --> pkg_client_runtime
pkg_client_ui_conversation --> pkg_client_ui_primitives
@@ -1252,7 +1265,7 @@ flowchart TD
| [`type-meta`](../packages/typert/type-meta) | `typert` | [`invariants`](../packages/support/invariants) |
| [`typert-generator`](../packages/typert/generator) | `typert` | [`invariants`](../packages/support/invariants) |
| [`typert-registry`](../packages/typert/registry) | `typert` | [`invariants`](../packages/support/invariants) |
| [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) |
| [`attachment`](../packages/attachment/attachment) | `attachment` | [`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-runtime`](../packages/client/runtime) | `client` | [`invariants`](../packages/support/invariants), [`type-meta`](../packages/typert/type-meta), [`typert-registry`](../packages/typert/registry) |
@@ -1267,20 +1280,28 @@ flowchart TD
| [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) |
| [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`typert-loader`](../packages/typert/loader) | `typert` | [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) |
| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`type-meta`](../packages/typert/type-meta) |
| [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
| [`skill`](../packages/skill/skill) | `skill` | [`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) |
| [`llm`](../packages/llm/llm) | `llm` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) |
| [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) |
| [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) |
| [`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-models`](../packages/client/ui-models) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`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-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) |
| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) |
| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
| [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) |
| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`type-meta`](../packages/typert/type-meta) |
| [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
| [`skill`](../packages/skill/skill) | `skill` | [`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-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) |
| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-connection`](../packages/client/connection), [`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), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) |
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `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) |
| [`client-ui-slash`](../packages/client/ui-slash) | `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) |
| [`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) |
| [`client-ui-workspace`](../packages/client/ui-workspace) | `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) |
| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`type-meta`](../packages/typert/type-meta) |
| [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/support/invariants), [`skill`](../packages/skill/skill) |
| [`web-fetch-local`](../packages/web/web-fetch-local) | `web` | [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) |
@@ -1289,13 +1310,10 @@ flowchart TD
| [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`app-boot`](../packages/boot/app-boot) | `boot` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) |
| [`client-ui-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) |
| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-connection`](../packages/client/connection), [`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), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) |
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `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) |
| [`client-ui-slash`](../packages/client/ui-slash) | `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) |
| [`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) |
| [`client-ui-workspace`](../packages/client/ui-workspace) | `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) |
| [`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), [`timeout`](../packages/util/timeout) |
| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) |
| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) |
| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings) |
| [`persona`](../packages/preset/persona) | `preset` | [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) |
| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
@@ -1309,11 +1327,9 @@ flowchart TD
| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`web`](../packages/web/web) |
| [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) |
| [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`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) |
| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/support/invariants) |
| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) |
| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) |
| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) |
| [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) |
| [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
| [`user-interaction`](../packages/interaction/user-interaction) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
@@ -1347,7 +1363,6 @@ flowchart TD
| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) |
| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) |
| [`permission`](../packages/interaction/permission) | `interaction` | [`bash`](../packages/bash/bash), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval) |
| [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) |
@@ -1403,7 +1418,7 @@ flowchart TD
| [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`web-app`](../packages/bundle/web-app) | `bundle` | [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) |
| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) |
| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) |
| [`sdk-protocol`](../packages/scaffold/protocol) | `scaffold` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
| [`repository-plugin`](../packages/self-modification/repository-plugin) | `self-modification` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`paths`](../packages/util/paths), [`skill-local`](../packages/skill/skill-local) |
| [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |

View File

@@ -129,6 +129,10 @@ flowchart TD
pkg_api_gateway["api-gateway"]
pkg_api_remotes["api-remotes"]
end
subgraph group_attachment["packages/attachment"]
pkg_attachment["attachment"]
pkg_attachment_local["attachment-local"]
end
subgraph group_boot["packages/boot"]
pkg_app_boot["app-boot"]
end
@@ -329,9 +333,8 @@ flowchart TD
pkg_type_meta --> pkg_invariants
pkg_typert_generator --> pkg_invariants
pkg_typert_registry --> pkg_invariants
pkg_llm --> pkg_brand
pkg_llm --> pkg_invariants
pkg_llm --> pkg_timeout
pkg_attachment --> pkg_brand
pkg_attachment --> pkg_invariants
pkg_client_connection --> pkg_host_webserver
pkg_client_connection --> pkg_invariants
pkg_client_hmr --> pkg_client_modules
@@ -367,34 +370,16 @@ flowchart TD
pkg_subprocess_local --> pkg_timeout
pkg_typert_loader --> pkg_invariants
pkg_typert_loader --> pkg_typert_registry
pkg_llm_deepseek --> pkg_credentials
pkg_llm_deepseek --> pkg_environment
pkg_llm_deepseek --> pkg_invariants
pkg_llm_deepseek --> pkg_llm
pkg_llm_deepseek --> pkg_settings
pkg_llm_deepseek --> pkg_timeout
pkg_llm_pi_ai --> pkg_credentials
pkg_llm_pi_ai --> pkg_environment
pkg_llm_pi_ai --> pkg_invariants
pkg_llm_pi_ai --> pkg_llm
pkg_llm_pi_ai --> pkg_settings
pkg_llm_pi_ai --> pkg_timeout
pkg_session --> pkg_brand
pkg_session --> pkg_invariants
pkg_session --> pkg_llm
pkg_session --> pkg_scope
pkg_session --> pkg_type_meta
pkg_system_prompt --> pkg_invariants
pkg_system_prompt --> pkg_llm
pkg_system_prompt --> pkg_scope
pkg_skill --> pkg_invariants
pkg_skill --> pkg_llm
pkg_skill --> pkg_scope
pkg_web --> pkg_invariants
pkg_web --> pkg_llm
pkg_llm --> pkg_attachment
pkg_llm --> pkg_brand
pkg_llm --> pkg_invariants
pkg_llm --> pkg_timeout
pkg_api_gateway --> pkg_client_connection
pkg_api_gateway --> pkg_invariants
pkg_api_gateway --> pkg_typert_registry
pkg_attachment_local --> pkg_attachment
pkg_attachment_local --> pkg_invariants
pkg_attachment_local --> pkg_paths
pkg_client_locale --> pkg_client_runtime
pkg_client_locale --> pkg_client_ui_primitives
pkg_client_locale --> pkg_client_ui_slots
@@ -418,40 +403,36 @@ flowchart TD
pkg_credentials_local --> pkg_environment
pkg_credentials_local --> pkg_invariants
pkg_credentials_local --> pkg_paths
pkg_lsp --> pkg_brand
pkg_lsp --> pkg_invariants
pkg_lsp --> pkg_llm
pkg_settings_local --> pkg_atomic_write
pkg_settings_local --> pkg_invariants
pkg_settings_local --> pkg_paths
pkg_settings_local --> pkg_settings
pkg_agent --> pkg_invariants
pkg_agent --> pkg_llm
pkg_agent --> pkg_scope
pkg_agent --> pkg_session
pkg_agent --> pkg_system_prompt
pkg_agent --> pkg_type_meta
pkg_skill_badge --> pkg_invariants
pkg_skill_badge --> pkg_skill
pkg_web_fetch_local --> pkg_invariants
pkg_web_fetch_local --> pkg_timeout
pkg_web_fetch_local --> pkg_web
pkg_web_search_exa --> pkg_environment
pkg_web_search_exa --> pkg_invariants
pkg_web_search_exa --> pkg_web
pkg_web_search_perplexity --> pkg_environment
pkg_web_search_perplexity --> pkg_invariants
pkg_web_search_perplexity --> pkg_web
pkg_spill --> pkg_brand
pkg_spill --> pkg_invariants
pkg_spill --> pkg_llm
pkg_spill --> pkg_session
pkg_acp_snapshot --> pkg_invariants
pkg_acp_snapshot --> pkg_session
pkg_app_boot --> pkg_environment
pkg_app_boot --> pkg_invariants
pkg_app_boot --> pkg_paths
pkg_app_boot --> pkg_system_prompt
pkg_llm_deepseek --> pkg_credentials
pkg_llm_deepseek --> pkg_environment
pkg_llm_deepseek --> pkg_invariants
pkg_llm_deepseek --> pkg_llm
pkg_llm_deepseek --> pkg_settings
pkg_llm_deepseek --> pkg_timeout
pkg_llm_pi_ai --> pkg_attachment
pkg_llm_pi_ai --> pkg_credentials
pkg_llm_pi_ai --> pkg_environment
pkg_llm_pi_ai --> pkg_invariants
pkg_llm_pi_ai --> pkg_llm
pkg_llm_pi_ai --> pkg_settings
pkg_llm_pi_ai --> pkg_timeout
pkg_session --> pkg_brand
pkg_session --> pkg_invariants
pkg_session --> pkg_llm
pkg_session --> pkg_scope
pkg_session --> pkg_type_meta
pkg_system_prompt --> pkg_invariants
pkg_system_prompt --> pkg_llm
pkg_system_prompt --> pkg_scope
pkg_skill --> pkg_invariants
pkg_skill --> pkg_llm
pkg_skill --> pkg_scope
pkg_web --> pkg_invariants
pkg_web --> pkg_llm
pkg_client_ui_question --> pkg_client_locale
pkg_client_ui_question --> pkg_invariants
pkg_client_ui_settings_general --> pkg_client_connection
@@ -482,10 +463,54 @@ flowchart TD
pkg_client_ui_workspace --> pkg_client_ui_primitives
pkg_client_ui_workspace --> pkg_client_ui_slots
pkg_client_ui_workspace --> pkg_invariants
pkg_lsp --> pkg_brand
pkg_lsp --> pkg_invariants
pkg_lsp --> pkg_llm
pkg_agent --> pkg_invariants
pkg_agent --> pkg_llm
pkg_agent --> pkg_scope
pkg_agent --> pkg_session
pkg_agent --> pkg_system_prompt
pkg_agent --> pkg_type_meta
pkg_skill_badge --> pkg_invariants
pkg_skill_badge --> pkg_skill
pkg_web_fetch_local --> pkg_invariants
pkg_web_fetch_local --> pkg_timeout
pkg_web_fetch_local --> pkg_web
pkg_web_search_exa --> pkg_environment
pkg_web_search_exa --> pkg_invariants
pkg_web_search_exa --> pkg_web
pkg_web_search_perplexity --> pkg_environment
pkg_web_search_perplexity --> pkg_invariants
pkg_web_search_perplexity --> pkg_web
pkg_spill --> pkg_brand
pkg_spill --> pkg_invariants
pkg_spill --> pkg_llm
pkg_spill --> pkg_session
pkg_acp_snapshot --> pkg_invariants
pkg_acp_snapshot --> pkg_session
pkg_app_boot --> pkg_environment
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
pkg_code_runtime_worker --> pkg_timeout
pkg_host_directory_picker_browse --> pkg_client_locale
pkg_host_directory_picker_browse --> pkg_client_runtime
pkg_host_directory_picker_browse --> pkg_client_ui_primitives
pkg_host_directory_picker_browse --> pkg_client_ui_slots
pkg_host_directory_picker_browse --> pkg_client_ui_workspace
pkg_host_directory_picker_browse --> pkg_invariants
pkg_host_directory_picker_native --> pkg_client_runtime
pkg_host_directory_picker_native --> pkg_client_ui_slots
pkg_host_directory_picker_native --> pkg_client_ui_workspace
pkg_host_directory_picker_native --> pkg_invariants
pkg_agent_presets --> pkg_atomic_write
pkg_agent_presets --> pkg_invariants
pkg_agent_presets --> pkg_paths
@@ -540,25 +565,15 @@ flowchart TD
pkg_loader_smoke --> pkg_invariants
pkg_loader_smoke --> pkg_llm
pkg_loader_smoke --> pkg_session
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_time_context --> pkg_agent
pkg_time_context --> pkg_invariants
pkg_time_context --> pkg_session
pkg_host_apiproxy --> pkg_agent_presets
pkg_host_apiproxy --> pkg_invariants
pkg_host_directory_picker_browse --> pkg_client_locale
pkg_host_directory_picker_browse --> pkg_client_runtime
pkg_host_directory_picker_browse --> pkg_client_ui_primitives
pkg_host_directory_picker_browse --> pkg_client_ui_slots
pkg_host_directory_picker_browse --> pkg_client_ui_workspace
pkg_host_directory_picker_browse --> pkg_invariants
pkg_host_directory_picker_native --> pkg_client_runtime
pkg_host_directory_picker_native --> pkg_client_ui_slots
pkg_host_directory_picker_native --> pkg_client_ui_workspace
pkg_host_directory_picker_native --> pkg_invariants
pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse
pkg_host_directory_picker_auto --> pkg_host_directory_picker_native
pkg_host_directory_picker_auto --> pkg_host_webserver
pkg_host_directory_picker_auto --> pkg_invariants
pkg_commands --> pkg_agent
pkg_commands --> pkg_brand
pkg_commands --> pkg_invariants
@@ -698,10 +713,6 @@ flowchart TD
pkg_command_feedback --> pkg_commands
pkg_command_feedback --> pkg_invariants
pkg_command_feedback --> pkg_session
pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse
pkg_host_directory_picker_auto --> pkg_host_directory_picker_native
pkg_host_directory_picker_auto --> pkg_host_webserver
pkg_host_directory_picker_auto --> pkg_invariants
pkg_permission --> pkg_bash
pkg_permission --> pkg_commands
pkg_permission --> pkg_invariants
@@ -1034,6 +1045,8 @@ flowchart TD
pkg_web_app --> pkg_invariants
pkg_web_app --> pkg_system_prompt
pkg_client_ui_conversation --> pkg_agent
pkg_client_ui_conversation --> pkg_attachment
pkg_client_ui_conversation --> pkg_brand
pkg_client_ui_conversation --> pkg_client_locale
pkg_client_ui_conversation --> pkg_client_runtime
pkg_client_ui_conversation --> pkg_client_ui_primitives
@@ -1224,7 +1237,7 @@ flowchart TD
pkg_acp_demo --> pkg_workspace_context
```
| Package | Group | Depends on |
| 包 | 分组 | 依赖项 |
| --- | --- | --- |
| [`invariants`](../packages/support/invariants) | `support` | — |
| [`atomic-write`](../packages/util/atomic-write) | `util` | [`invariants`](../packages/support/invariants) |
@@ -1254,7 +1267,7 @@ flowchart TD
| [`type-meta`](../packages/typert/type-meta) | `typert` | [`invariants`](../packages/support/invariants) |
| [`typert-generator`](../packages/typert/generator) | `typert` | [`invariants`](../packages/support/invariants) |
| [`typert-registry`](../packages/typert/registry) | `typert` | [`invariants`](../packages/support/invariants) |
| [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) |
| [`attachment`](../packages/attachment/attachment) | `attachment` | [`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-runtime`](../packages/client/runtime) | `client` | [`invariants`](../packages/support/invariants), [`type-meta`](../packages/typert/type-meta), [`typert-registry`](../packages/typert/registry) |
@@ -1269,20 +1282,28 @@ flowchart TD
| [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) |
| [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`typert-loader`](../packages/typert/loader) | `typert` | [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) |
| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`type-meta`](../packages/typert/type-meta) |
| [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
| [`skill`](../packages/skill/skill) | `skill` | [`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) |
| [`llm`](../packages/llm/llm) | `llm` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) |
| [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) |
| [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) |
| [`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-models`](../packages/client/ui-models) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`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-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) |
| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) |
| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
| [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) |
| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`type-meta`](../packages/typert/type-meta) |
| [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
| [`skill`](../packages/skill/skill) | `skill` | [`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-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) |
| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-connection`](../packages/client/connection), [`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), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) |
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `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) |
| [`client-ui-slash`](../packages/client/ui-slash) | `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) |
| [`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) |
| [`client-ui-workspace`](../packages/client/ui-workspace) | `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) |
| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`type-meta`](../packages/typert/type-meta) |
| [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/support/invariants), [`skill`](../packages/skill/skill) |
| [`web-fetch-local`](../packages/web/web-fetch-local) | `web` | [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) |
@@ -1291,13 +1312,10 @@ flowchart TD
| [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`app-boot`](../packages/boot/app-boot) | `boot` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) |
| [`client-ui-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) |
| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-connection`](../packages/client/connection), [`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), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) |
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `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) |
| [`client-ui-slash`](../packages/client/ui-slash) | `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) |
| [`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) |
| [`client-ui-workspace`](../packages/client/ui-workspace) | `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) |
| [`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), [`timeout`](../packages/util/timeout) |
| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) |
| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) |
| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings) |
| [`persona`](../packages/preset/persona) | `preset` | [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) |
| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
@@ -1311,11 +1329,9 @@ flowchart TD
| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`web`](../packages/web/web) |
| [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) |
| [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`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) |
| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/support/invariants) |
| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) |
| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) |
| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) |
| [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) |
| [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
| [`user-interaction`](../packages/interaction/user-interaction) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
@@ -1349,7 +1365,6 @@ flowchart TD
| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) |
| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) |
| [`permission`](../packages/interaction/permission) | `interaction` | [`bash`](../packages/bash/bash), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval) |
| [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) |
@@ -1405,7 +1420,7 @@ flowchart TD
| [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`web-app`](../packages/bundle/web-app) | `bundle` | [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) |
| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) |
| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) |
| [`sdk-protocol`](../packages/scaffold/protocol) | `scaffold` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
| [`repository-plugin`](../packages/self-modification/repository-plugin) | `self-modification` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`paths`](../packages/util/paths), [`skill-local`](../packages/skill/skill-local) |
| [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/README.md
README.md: 096fd7de4d2644dac664fac940b6487052115258
README.zh.md: 6b6758db8f7118a09f6b0998231d3944f44f5052
README.md: b1b57466feeee7625b0b5de78e5f9ff220ddef32
README.zh.md: 492051f013def0f196902d1105f84a5644057f3a

View File

@@ -24,6 +24,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures
| [tools.md](tools.md) | `ToolDefinition` full fields, the schema DSL, `ToolExecution`/`ToolResult`, tool-presentation UI types, and the guarded execution pipeline |
| [user-interaction.md](user-interaction.md) | the UI-backed human question/answer seam: `AskUserQuestionRequest`, answer/options vocabulary, provider API, error taxonomy |
| [approval.md](approval.md) | the one-shot user-approval seam: `ApprovalRequest`, `ApprovalOutcome`, per-session policy, audit and answerer contracts |
| [attachment.md](attachment.md) | durable image identity and metadata, validation inputs, verified reads, and the `AttachmentStore` seam |
| [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, background `BashProcess` handles |
| [subprocess.md](subprocess.md) | the subprocess seam: fully-explicit `SubprocessSpawnSpec`, offset-based output readers, unclassified `SubprocessOutcome`, and the managed `DSH_*` environment vocabulary |
| [pty.md](pty.md) | persistent terminal ids, backend/session contracts, send readiness, bounded reads, and owner-visible snapshots |

View File

@@ -24,6 +24,7 @@
| [tools.md](tools.md) | `ToolDefinition` 完整字段、schema DSL、`ToolExecution`/`ToolResult`、工具展示 UI 类型,以及受保护的执行流水线 |
| [user-interaction.md](user-interaction.md) | UI 支持的人工问答 seam`AskUserQuestionRequest`、answer/options 词汇、提供方 API、错误分类体系 |
| [approval.md](approval.md) | 一次性用户审批 seam`ApprovalRequest``ApprovalOutcome`、逐会话策略、审计与 answerer 约定 |
| [attachment.md](attachment.md) | 持久图片标识与元数据、校验输入、经校验读取,以及 `AttachmentStore` seam |
| [bash.md](bash.md) | bash 执行器 seam`BashExecRequest`/`Spec``BashRunResult`、后台 `BashProcess` 句柄 |
| [subprocess.md](subprocess.md) | 子进程 seam完全显式的 `SubprocessSpawnSpec`、基于偏移的输出读取器、不含分类的 `SubprocessOutcome`,以及受管 `DSH_*` 环境词汇 |
| [pty.md](pty.md) | 持久化终端 ID、后端/会话约定、发送就绪状态、有界读取与 owner 可见快照 |

View File

@@ -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 docs/subsystems/attachment.md
attachment.md: 51556c3a0391a571656f407ed22edb8b93eb5326
attachment.zh.md: 2a6079845ae6f19884b9fb3312e32187161d57d1

View File

@@ -0,0 +1,113 @@
# Durable Image Attachments
English | [中文](attachment.zh.md)
The attachment seam separates binary image ownership from the session log. A producer gives validated encoded bytes to [`ctx.attachments`](#ctxattachments--attachmentstore-abstract-seam); the service publishes an immutable content-addressed reference only after the object is durable. Session events and model-visible `ImageBlock`s contain that reference and metadata, never a browser object URL, host temporary path, provider URL, or base64 payload.
Unsent browser drafts may stay in memory and native clients may stage them in operating-system temporary storage. Once the host accepts a user message, its images move below `<DSH_HOME>/attachments/v1` before the user event is appended. Structured model image output follows the same persist-before-event rule.
Source: [`packages/attachment/attachment/src/types.ts`](../../packages/attachment/attachment/src/types.ts)
## Identity and verified metadata
`AttachmentId` is a branded opaque string. The local backend currently emits `sha256:<digest>`, but consumers must neither parse that representation nor derive a filesystem path from it.
```ts type-equiv
/** Raster image formats accepted by the version-one attachment path. */
type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif'
```
```ts type-equiv
/** Durable, serializable metadata for one immutable image object. */
interface ImageAttachmentRef {
/** Opaque storage identifier; never a filesystem path or bearer URL. */
attachmentId: AttachmentId
/** Media type verified from the stored bytes. */
mediaType: ImageMediaType
/** Exact encoded byte length. */
bytes: number
/** Intrinsic encoded width in pixels. */
width: number
/** Intrinsic encoded height in pixels. */
height: number
/** Optional display name stripped of local path information. */
name?: string
}
```
```ts type-equiv
/** Deployment-resolved limits used by upload admission and request buffering. */
interface ImageAttachmentLimits {
maxImageBytes: number
maxImagesPerMessage: number
maxMessageImageBytes: number
maxImagePixels: number
mediaTypes: readonly ImageMediaType[]
}
```
The reference records intrinsic dimensions and encoded length so clients can lay out history without decoding first, while every authoritative read still re-checks digest, media signature, dimensions, and metadata against the object.
## Commit and verified-read payloads
```ts type-equiv
/** Request to validate and durably commit one image. */
interface SaveImageAttachment {
data: Uint8Array
/** Caller-declared media type, checked against fully decoded bytes. */
mediaType: ImageMediaType
/** Optional browser/provider display name; it is never interpreted as a path. */
name?: string
}
```
```ts type-equiv
/** Stored image bytes returned after reference and digest verification. */
interface StoredImageAttachment {
ref: ImageAttachmentRef
data: Uint8Array
}
```
`saveImage()` validates bytes and atomically commits one object before returning its reference. `validateImage()` runs the same admission checks without persisting anything; batch callers validate every member through it before saving any member, so validation rejection leaves no partial objects behind. `readImage()` accepts a reference from an authorized session path and returns bytes only after integrity verification. The service is deliberately retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to any one session's deletion.
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
<a id="cordis-surface"></a>
## Cordis surface
Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — this section is byte-identical in both language sides of the page. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` surface lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
<a id="ctxattachments--attachmentstore-abstract-seam"></a>
### `ctx.attachments` — `AttachmentStore` (abstract seam)
Immutable binary attachment service. Implementations validate bytes before publishing a reference.
```ts cordis-catalog
/**
* Validate one image without persisting it.
* Batch callers validate every member before saving any member.
* @param input - encoded bytes, declared media type, and optional display name.
* @returns completion after the encoded raster has been fully decoded.
*/
abstract validateImage(input: SaveImageAttachment): Promise<void>
/**
* Validate and durably commit one image before its owning session event is appended.
* @param input - encoded bytes, declared media type, and optional display name.
* @returns a durable content-addressed reference.
*/
abstract saveImage(input: SaveImageAttachment): Promise<ImageAttachmentRef>
/**
* Read one image and verify that bytes still match the recorded reference.
* @param ref - durable reference from the session log.
* @returns the verified bytes and canonical reference.
*/
abstract readImage(ref: ImageAttachmentRef): Promise<StoredImageAttachment>
```
Source: [`packages/attachment/attachment/src/index.ts:29`](../../packages/attachment/attachment/src/index.ts)
<!-- END GENERATED cordis-surface -->

View File

@@ -0,0 +1,113 @@
# 持久图片附件
[English](attachment.md) | 中文
附件 seam 将二进制图片的所有权与会话日志分离。生成方把经过校验的编码字节交给 [`ctx.attachments`](#ctxattachments--attachmentstore-abstract-seam);只有对象完成持久化后,该服务才会发布不可变的内容寻址引用。会话事件和模型可见的 `ImageBlock` 包含该引用及其元数据,绝不包含浏览器对象 URL、宿主临时路径、提供方 URL 或 base64 数据。
未发送的浏览器草稿可以保留在内存中,原生客户端也可以将其暂存于操作系统临时存储。宿主接受用户消息后,会先把消息中的图片移到 `<DSH_HOME>/attachments/v1` 下,再追加用户事件。结构化模型图片输出遵循同样的先持久化、后追加事件规则。
来源:[`packages/attachment/attachment/src/types.ts`](../../packages/attachment/attachment/src/types.ts)
## 标识与经过校验的元数据
`AttachmentId` 是带类型标记的不透明字符串。本地后端目前生成 `sha256:<digest>`,但消费方既不能解析这种表示,也不能据此派生文件系统路径。
```ts type-equiv
/** Raster image formats accepted by the version-one attachment path. */
type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif'
```
```ts type-equiv
/** Durable, serializable metadata for one immutable image object. */
interface ImageAttachmentRef {
/** Opaque storage identifier; never a filesystem path or bearer URL. */
attachmentId: AttachmentId
/** Media type verified from the stored bytes. */
mediaType: ImageMediaType
/** Exact encoded byte length. */
bytes: number
/** Intrinsic encoded width in pixels. */
width: number
/** Intrinsic encoded height in pixels. */
height: number
/** Optional display name stripped of local path information. */
name?: string
}
```
```ts type-equiv
/** Deployment-resolved limits used by upload admission and request buffering. */
interface ImageAttachmentLimits {
maxImageBytes: number
maxImagesPerMessage: number
maxMessageImageBytes: number
maxImagePixels: number
mediaTypes: readonly ImageMediaType[]
}
```
引用记录固有尺寸和编码长度,使客户端无需先解码即可排布历史记录;每次权威读取仍会根据对象重新校验摘要、媒体签名、尺寸和元数据。
## 提交与校验读取的数据
```ts type-equiv
/** Request to validate and durably commit one image. */
interface SaveImageAttachment {
data: Uint8Array
/** Caller-declared media type, checked against fully decoded bytes. */
mediaType: ImageMediaType
/** Optional browser/provider display name; it is never interpreted as a path. */
name?: string
}
```
```ts type-equiv
/** Stored image bytes returned after reference and digest verification. */
interface StoredImageAttachment {
ref: ImageAttachmentRef
data: Uint8Array
}
```
`saveImage()` 校验字节并以原子方式提交一个对象,之后才返回其引用。`validateImage()` 执行相同的准入检查,但不持久化任何内容;批量调用方会在保存任何成员前通过它校验所有成员,因此校验拒绝不会留下部分对象。`readImage()` 接受来自已授权会话路径的引用,只在完整性校验通过后返回字节。该服务刻意不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,而不是与任何一个会话的删除绑定。
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
<a id="cordis-surface"></a>
## Cordis surface
Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — this section is byte-identical in both language sides of the page. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` surface lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
<a id="ctxattachments--attachmentstore-abstract-seam"></a>
### `ctx.attachments` — `AttachmentStore` (abstract seam)
Immutable binary attachment service. Implementations validate bytes before publishing a reference.
```ts cordis-catalog
/**
* Validate one image without persisting it.
* Batch callers validate every member before saving any member.
* @param input - encoded bytes, declared media type, and optional display name.
* @returns completion after the encoded raster has been fully decoded.
*/
abstract validateImage(input: SaveImageAttachment): Promise<void>
/**
* Validate and durably commit one image before its owning session event is appended.
* @param input - encoded bytes, declared media type, and optional display name.
* @returns a durable content-addressed reference.
*/
abstract saveImage(input: SaveImageAttachment): Promise<ImageAttachmentRef>
/**
* Read one image and verify that bytes still match the recorded reference.
* @param ref - durable reference from the session log.
* @returns the verified bytes and canonical reference.
*/
abstract readImage(ref: ImageAttachmentRef): Promise<StoredImageAttachment>
```
Source: [`packages/attachment/attachment/src/index.ts:29`](../../packages/attachment/attachment/src/index.ts)
<!-- END GENERATED cordis-surface -->

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/llm-streaming.md
llm-streaming.md: 4d450c19ec2bbfacabcefc83466e67c8a6c82bd6
llm-streaming.zh.md: 777c44eaff2b1ee6e5939e04e580d6ad34b1ad1a
llm-streaming.md: 9f92052d411e3bd4256db63df54eba7f4e313b18
llm-streaming.zh.md: ab5540c9e7adce70f0462fb478e8f674d1f6bba4

View File

@@ -22,12 +22,13 @@ Source: [`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts)
interface ContentBlockMap {
'text': TextBlock
'reasoning': ReasoningBlock
'image': ImageBlock
'tool-call': ToolCallBlock
'tool-result': ToolResultBlock
}
```
The block interfaces (full fields in source): `TextBlock` (`text`), `ReasoningBlock` (thinking, distinct from visible text), `ToolCallBlock` (`id: CallId`, `name`, raw-JSON `arguments`), `ToolResultBlock` (`toolCallId`, nested `content: ContentBlock[]`, `isError?`). `ContentBlock = ContentBlockMap[ContentBlockType]`. The core set is limited to blocks every shipping path honors — multimodal content (images, audio, …) has no core block type; a feature that needs one adds it via the merge-extensible map together with the adapter/UI/compaction support that honors it.
The block interfaces (full fields in source): `TextBlock` (`text`), `ReasoningBlock` (thinking, distinct from visible text), `ImageBlock` (a durable [image attachment](attachment.md)), `ToolCallBlock` (`id: CallId`, `name`, raw-JSON `arguments`), and `ToolResultBlock` (`toolCallId`, nested `content: ContentBlock[]`, `isError?`). `ContentBlock = ContentBlockMap[ContentBlockType]`. A new modality belongs in the merge-extensible map only when its adapter, UI, compaction, and durable replay paths honor it.
Source: [`packages/llm/llm/src/message.ts`](../../packages/llm/llm/src/message.ts)
@@ -396,6 +397,8 @@ interface LlmModelInfo {
name: string
/** Optional user-facing distinction from otherwise similar models. */
description?: string
/** Accepted request modalities; absent means unknown, while an explicit omission is negative capability. */
inputModalities?: readonly ModelModality[]
}
```
@@ -830,7 +833,7 @@ async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<Prepared
stream(options: GenerateOptions): AsyncIterable<StreamChunk>
```
Source: [`packages/llm/llm/src/index.ts:292`](../../packages/llm/llm/src/index.ts)
Source: [`packages/llm/llm/src/index.ts:294`](../../packages/llm/llm/src/index.ts)
<a id="llm-events"></a>
@@ -855,7 +858,7 @@ The provider topology changed: an adapter registered or unregistered routes, or
'llm/adapters-updated'(): void
```
Source: [`packages/llm/llm/src/index.ts:73`](../../packages/llm/llm/src/index.ts)
Source: [`packages/llm/llm/src/index.ts:75`](../../packages/llm/llm/src/index.ts)
<a id="llmstream--waterfall"></a>
@@ -879,5 +882,5 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t
'llm/stream'(this: LlmService, options: GenerateOptions, next: () => AsyncIterable<StreamChunk>): AsyncIterable<StreamChunk>
```
Source: [`packages/llm/llm/src/index.ts:62`](../../packages/llm/llm/src/index.ts)
Source: [`packages/llm/llm/src/index.ts:64`](../../packages/llm/llm/src/index.ts)
<!-- END GENERATED cordis-surface -->

View File

@@ -22,12 +22,13 @@
interface ContentBlockMap {
'text': TextBlock
'reasoning': ReasoningBlock
'image': ImageBlock
'tool-call': ToolCallBlock
'tool-result': ToolResultBlock
}
```
各块接口(完整字段见源码):`TextBlock``text`)、`ReasoningBlock`thinking区别于可见文本、`ToolCallBlock``id: CallId`、`name`、原始 JSON `arguments``ToolResultBlock``toolCallId`、嵌套 `content: ContentBlock[]`、`isError?`)。`ContentBlock = ContentBlockMap[ContentBlockType]`。核心集仅限于每条交付路径都尊重的块——多模态内容(图像、音频等)没有核心块类型;需要的功能通过可合并扩展的 map 添加,同时提供适配器/UI/压缩支持
各块接口(完整字段见源码):`TextBlock``text`)、`ReasoningBlock`thinking区别于可见文本、`ImageBlock`(一个持久的[图片附件](attachment.md))、`ToolCallBlock``id: CallId`、`name`、原始 JSON `arguments`,以及 `ToolResultBlock``toolCallId`、嵌套 `content: ContentBlock[]`、`isError?`)。`ContentBlock = ContentBlockMap[ContentBlockType]`。仅当适配器、UI、压缩和持久回放路径均支持某种新模态时才将其纳入可合并扩展的 map
源码:[`packages/llm/llm/src/message.ts`](../../packages/llm/llm/src/message.ts)
@@ -404,6 +405,8 @@ interface LlmModelInfo {
name: string
/** Optional user-facing distinction from otherwise similar models. */
description?: string
/** Accepted request modalities; absent means unknown, while an explicit omission is negative capability. */
inputModalities?: readonly ModelModality[]
}
```
@@ -838,7 +841,7 @@ async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<Prepared
stream(options: GenerateOptions): AsyncIterable<StreamChunk>
```
Source: [`packages/llm/llm/src/index.ts:292`](../../packages/llm/llm/src/index.ts)
Source: [`packages/llm/llm/src/index.ts:294`](../../packages/llm/llm/src/index.ts)
<a id="llm-events"></a>
@@ -863,7 +866,7 @@ The provider topology changed: an adapter registered or unregistered routes, or
'llm/adapters-updated'(): void
```
Source: [`packages/llm/llm/src/index.ts:73`](../../packages/llm/llm/src/index.ts)
Source: [`packages/llm/llm/src/index.ts:75`](../../packages/llm/llm/src/index.ts)
<a id="llmstream--waterfall"></a>
@@ -887,5 +890,5 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t
'llm/stream'(this: LlmService, options: GenerateOptions, next: () => AsyncIterable<StreamChunk>): AsyncIterable<StreamChunk>
```
Source: [`packages/llm/llm/src/index.ts:62`](../../packages/llm/llm/src/index.ts)
Source: [`packages/llm/llm/src/index.ts:64`](../../packages/llm/llm/src/index.ts)
<!-- END GENERATED cordis-surface -->

File diff suppressed because one or more lines are too long

View File

@@ -310,6 +310,11 @@
"src/**/*.ts"
]
},
"packages/attachment/attachment": {
"project": [
"src/**/*.ts"
]
},
"packages/util/timeout": {
"entry": [
"tests/**/*.spec.ts"

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/README.md
README.md: c18a46b7131f7782be68f3c96fa99b89615de471
README.zh.md: 3cd766ed70b7847bef8229a48b65873540365851
README.md: c246534a26cd7297f2ba8885099c8b517a5dc2b0
README.zh.md: 4139874d3ebbf82fad8680a3721a1cf35a553706

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Packages use the `@deepseek-ai/dsh-*` scope. Cordis `Service` subclasses and function plugins contribute through `ctx.effect()`, `ctx.on()`, or `ctx.waterfall()`. Authoring rules: [package](AGENTS.md) and [root](../AGENTS.md#conventions).
npm scope: `@deepseek-ai/dsh-*`; Cordis `Service` subclasses and function plugins contribute through `ctx.effect()`, `ctx.on()`, or `ctx.waterfall()`. Rules: [package](AGENTS.md), [root](../AGENTS.md#conventions).
## Hierarchy
@@ -31,13 +31,14 @@ Groups hold `packages/<group>/<pkg>/`; names stay `@deepseek-ai/dsh-<pkg>`. **Gr
| [`tasks/`](tasks/README.md) | Generic background-task runtime and model-facing `task_*` control tools | Product — stable surface |
| [`workflow/`](workflow/README.md) | Workflow seam, worker-thread engine, and model-facing `workflow`/`ralph` tools | Product — stable surface |
| [`web/`](web/README.md) | Web capability family: seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface |
| [`attachment/`](attachment/README.md) | Durable attachment identity, validation, local content-addressed storage | Product — stable surface |
| [`spill/`](spill/README.md) | Spill capability family: storage seam, local impl, tool-result spill policy | Product — stable surface |
| [`todo/`](todo/README.md) | The model-facing `todo_write` tool | Product — stable surface |
| [`plan/`](plan/README.md) | Plan collaboration state with a direct entry command and reviewed exit | Product — stable surface |
| [`preset/`](preset/README.md) | Per-session agent composition from preset `cordis.yml` files | Product — stable surface |
| [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders + the `tools/execute` deadline enforcer | Product — stable surface |
| [`bundle/`](bundle/README.md) | Installable `dsh --profile` patch layers | Product — stable surface |
| [`self-modification/`](self-modification/README.md) | The agent modifies its own runtime: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) and restricted repository Plugin loading | Product — stable surface |
| [`self-modification/`](self-modification/README.md) | Agent runtime self-modification: live plugin/service inspection, model-written plugin mount/unmount ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)), restricted repository Plugin loading | Product — stable surface |
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
| [`session/`](session/README.md) | Durable session data plane: persistence seam + JSONL/SQLite backends, projection seam, log-backed titles, session reporting | Product — stable surface |
| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable surface |

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
所有包都使用 `@deepseek-ai/dsh-*` scope。Cordis `Service` 子类和函数插件的贡献通过 `ctx.effect()``ctx.on()``ctx.waterfall()` 注册。编写规则见[](AGENTS.md)与[根规则](../AGENTS.md#conventions)。
npm scope 为 `@deepseek-ai/dsh-*`Cordis `Service` 子类和函数插件通过 `ctx.effect()``ctx.on()``ctx.waterfall()` 注册。规则见[](AGENTS.md)与[根规则](../AGENTS.md#conventions)。
## 层级结构
@@ -31,13 +31,14 @@
| [`tasks/`](tasks/README.md) | 通用后台任务运行时和面向模型的 `task_*` 控制工具 | 产品:稳定接口 |
| [`workflow/`](workflow/README.md) | 工作流 seam、worker 线程引擎和面向模型的 `workflow`/`ralph` 工具 | 产品:稳定接口 |
| [`web/`](web/README.md) | Web 能力系列seam、搜索获取提供方实现和面向模型的 Web 工具 | 产品:稳定接口 |
| [`attachment/`](attachment/README.md) | 持久附件标识、校验、本地内容寻址存储 | 产品:稳定接口 |
| [`spill/`](spill/README.md) | spill 能力系列:存储 seam、本地实现、工具结果 spill 策略 | 产品:稳定接口 |
| [`todo/`](todo/README.md) | 面向模型的 `todo_write` 工具 | 产品:稳定接口 |
| [`plan/`](plan/README.md) | Plan 协作状态,提供直接进入命令与经评审的退出 | 产品:稳定接口 |
| [`preset/`](preset/README.md) | 由 preset `cordis.yml` 按会话组装 agent | 产品:稳定接口 |
| [`guard/`](guard/README.md) | 循环卫生守卫:建议性重复调用提醒 + `tools/execute` 截止时间强制执行器 | 产品:稳定接口 |
| [`bundle/`](bundle/README.md) | 可安装的 `dsh --profile` 补丁层 | 产品:稳定接口 |
| [`self-modification/`](self-modification/README.md) | agent 修改自身运行时:检查实时运行时的插件服务,挂载/卸载模型所写插件([设计](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md),以及受限仓库插件加载 | 产品:稳定接口 |
| [`self-modification/`](self-modification/README.md) | agent 运行时自修改:实时插件服务检查、模型所写插件挂载/卸载[设计](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)受限仓库插件加载 | 产品:稳定接口 |
| [`hooks/`](hooks/README.md) | 钩子桥接 + 共享 Claude CodeCodex 协议格式库 | 产品:稳定接口 |
| [`session/`](session/README.md) | 持久会话数据平面:持久化 seam + JSONL/SQLite 后端、投影 seam、日志支持的标题、会话上报 | 产品:稳定接口 |
| [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定接口 |

View File

@@ -166,6 +166,17 @@ export function apply(ctx: Context, config: AcpConfig): void {
content: { type: 'text', text: block.text },
},
})
} else if (block.type === 'image') {
notify({
sessionId: record.agent.session.id,
update: {
sessionUpdate: 'agent_message_chunk',
content: {
type: 'text',
text: `[image attachment ${block.attachment.attachmentId}]`,
},
},
})
}
}
}

View File

@@ -41,6 +41,35 @@ describe('ACP prompt lifecycle', () => {
await vi.waitFor(() => { expect(messageText(harness!)).toBe('cut off') })
})
it('renders an assistant image as an explicit attachment placeholder', async () => {
const attachmentId = `sha256:${'a'.repeat(64)}` as never
harness = await makeBridgeHarness({
script: [[
{ type: 'block-start', index: 0, blockType: 'image' },
{
type: 'block-end',
index: 0,
block: {
type: 'image',
attachment: {
attachmentId,
mediaType: 'image/png',
bytes: 1,
width: 1,
height: 1,
},
},
},
{ type: 'finish', reason: { kind: 'stop' } },
]],
})
const sessionId = await newSession(harness)
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'show it' }] })
await vi.waitFor(() => {
expect(messageText(harness!)).toBe(`[image attachment ${String(attachmentId)}]`)
})
})
it('rejects a failed turn and never publishes its partial chunks', async () => {
harness = await makeBridgeHarness({ script: [errorResponse('provider boom')] })
const sessionId = await newSession(harness)

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/attachment/README.md
README.md: 61b4e5c602f475f85bbe859b8483e30b518e06c8
README.zh.md: b4a80917e166a3b0836e6e9c79d536d9dd8e1a41

View File

@@ -0,0 +1,12 @@
# attachment/ - durable attachment capability family
English | [中文](README.zh.md)
The durable binary attachment seam and its local filesystem implementation. Both are product packages.
| Package | Role | ctx key |
|---|---|---|
| `attachment/` | Immutable attachment references, image limits, and storage service | `ctx.attachments` |
| `attachment-local/` | Content-addressed private storage below `DSH_HOME` | (registers on `ctx.attachments`) |
Unsent browser drafts are intentionally outside this capability. Bytes enter durable storage only when a user prompt is submitted or when a provider adapter commits structured model output.

View File

@@ -0,0 +1,12 @@
# attachment/:持久附件能力族
[English](README.md) | 中文
持久二进制附件服务边界及其本地文件系统实现。两者均为产品包package
| 包 | 角色 | ctx 键 |
|---|---|---|
| `attachment/` | 不可变附件引用、图片限制和存储服务 | `ctx.attachments` |
| `attachment-local/` | `DSH_HOME` 下的私有内容寻址存储 | (注册至 `ctx.attachments` |
未发送的浏览器草稿刻意位于这项能力之外。只有用户提交提示词,或提供方适配器提交结构化模型输出时,字节才进入持久存储。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/attachment/attachment-local/README.md
README.md: 80001b29b392fe1c8b663f46d47f1ec0726e6d0f
README.zh.md: c3b95ace06b9f5ada156f20f33a1740a235400aa

View File

@@ -0,0 +1,21 @@
# @deepseek-ai/dsh-attachment-local
English | [中文](README.zh.md)
The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `<DSH_HOME>/attachments/v1/objects/<sha256-prefix>/<sha256>` and are addressed by an opaque `sha256:` id. Each process proves a home durable once by syncing every ancestor entry to the filesystem root, so a directory another process created but has not yet synced is never mistaken for a safe boundary. Writes then use a private staging directory, owner-only files, a synced temporary file, an atomic exclusive hard-link publish, and directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash. Write admission and reads fully decode the raster before accepting its format and dimensions; reads also re-check the digest and logged metadata. Byte and pixel limits are write-time admission policy, so a later policy reduction does not make already-admitted history unreadable.
`DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata, never this host path.
## Model Experience
Indirectly, through durable replay of historical user images and structured model image output after restart and fork.
#### KV Cache effect
None beyond the image block owned by the requesting adapter.
## Known Limitations and Deferred Work
- Objects are retained indefinitely; reference-aware garbage collection is deferred.
- The local backend assumes the host and provider adapter share this filesystem service.
- Animated GIF metadata is validated from the logical screen; frame-level decoding policy is provider-owned.

View File

@@ -0,0 +1,21 @@
# @deepseek-ai/dsh-attachment-local
[English](README.md) | 中文
这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `<DSH_HOME>/attachments/v1/objects/<sha256-prefix>/<sha256>`,并通过不透明的 `sha256:` 标识符寻址。每个进程都会通过将每个祖先目录项逐级同步到文件系统根目录,为某个 home 一次性证明其持久性,因此绝不会把另一个进程已经创建但尚未同步的目录误认为安全边界。随后,写入过程使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布路径执行目录同步(适用于 POSIXWindows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。写入准入与读取都会完整解码光栅图片,之后才接受其格式和尺寸;读取还会重新校验摘要和已记录的元数据。字节和像素限制属于写入时的准入策略,因此后续收紧限制不会导致已经接纳的历史记录变得不可读。
`DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据,绝不包含这个宿主路径。
## 模型体验
该包通过重启和 fork 后对历史用户图片与结构化模型图片输出的持久回放间接影响模型。
#### KV 缓存影响
除发起请求的适配器所持有的图片块外,不产生其他影响。
## 已知限制与待完成工作
- 对象会无限期保留;基于引用的垃圾回收尚未实现。
- 本地后端假定宿主与提供方适配器共享同一个文件系统服务。
- 动态 GIF 的元数据根据逻辑屏幕进行校验;逐帧解码策略由提供方持有。

View File

@@ -0,0 +1,33 @@
{
"name": "@deepseek-ai/dsh-attachment-local",
"description": "Private content-addressed DSH_HOME attachment storage",
"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" },
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": ["lib/index.js", "lib/invariant.js", "lib/types/**/*.d.ts"],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-attachment": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-paths": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0",
"sharp": "^0.35.3"
},
"devDependencies": {
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,66 @@
/** Raster inspection: full decode at admission, header-only probe on verified reads. */
import sharp, { type Sharp } from 'sharp'
import { AttachmentError } from '@deepseek-ai/dsh-attachment'
import type { ImageMediaType } from '@deepseek-ai/dsh-attachment'
/** Decoded metadata from a supported image. */
export interface DetectedImage {
mediaType: ImageMediaType
width: number
height: number
}
const MEDIA_TYPES: Readonly<Record<string, ImageMediaType>> = {
png: 'image/png',
jpeg: 'image/jpeg',
webp: 'image/webp',
gif: 'image/gif',
}
async function imageMetadata(image: Sharp): Promise<DetectedImage> {
const metadata = await image.metadata()
const mediaType = MEDIA_TYPES[metadata.format as string]
if (mediaType === undefined) {
throw new AttachmentError('Unsupported or malformed image data.', 'INVALID_IMAGE')
}
return { mediaType, width: metadata.width, height: metadata.height }
}
/**
* Parse a supported raster's header and return its intrinsic metadata without
* decoding pixels. Digest-verified reads use this: admission already proved
* that these exact bytes decode completely, so the read path only re-derives
* the reference fields instead of paying the full-raster decode again.
* @param data - complete encoded image bytes.
* @returns verified format and dimensions.
*/
export async function probeImage(data: Uint8Array): Promise<DetectedImage> {
try {
return await imageMetadata(sharp(data, { failOn: 'error', limitInputPixels: false }))
} catch (error) {
if (error instanceof AttachmentError) throw error
throw new AttachmentError('Unsupported or malformed image data.', 'INVALID_IMAGE', { cause: error })
}
}
/**
* Fully decode a supported raster and return its intrinsic metadata.
* @param data - complete encoded image bytes.
* @param maxPixels - decoded-pixel admission limit.
* @returns verified format and dimensions.
*/
export async function detectImage(data: Uint8Array, maxPixels?: number): Promise<DetectedImage> {
try {
const image = sharp(data, { failOn: 'error', limitInputPixels: false })
const detected = await imageMetadata(image)
if (maxPixels !== undefined && detected.width * detected.height > maxPixels) {
throw new AttachmentError('Image exceeds the configured decoded-pixel limit.', 'IMAGE_TOO_MANY_PIXELS')
}
await image.raw().toBuffer()
return detected
} catch (error) {
if (error instanceof AttachmentError) throw error
throw new AttachmentError('Unsupported or malformed image data.', 'INVALID_IMAGE', { cause: error })
}
}

View File

@@ -0,0 +1,76 @@
/** Local durable attachment backend rooted below `DSH_HOME`. @module @deepseek-ai/dsh-attachment-local */
import { join, resolve } from 'node:path'
import { Context } from 'cordis'
import z from 'schemastery'
import { AttachmentStore } from '@deepseek-ai/dsh-attachment'
import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import { readImageFile, saveImageFile, validateImageFile } from './store.ts'
export { detectImage } from './image.ts'
export { readImageFile, saveImageFile, validateImageFile } from './store.ts'
/** Default maximum encoded bytes for one image. */
export const DEFAULT_MAX_IMAGE_BYTES = 5 * 1024 * 1024
/** Default maximum images in one prompt. */
export const DEFAULT_MAX_IMAGES_PER_MESSAGE = 10
/** Default maximum aggregate image bytes in one prompt. */
export const DEFAULT_MAX_MESSAGE_IMAGE_BYTES = 20 * 1024 * 1024
/** Default maximum intrinsic pixels for one image. */
export const DEFAULT_MAX_IMAGE_PIXELS = 40_000_000
/** Local attachment backend configuration. */
export interface Config {
/** Explicit harness home; omitted follows `DSH_HOME`, then `~/.dsh`. */
dshHome?: string
/** Maximum encoded bytes accepted for one image. */
maxImageBytes?: number
/** Maximum image count accepted in one submitted message. */
maxImagesPerMessage?: number
/** Maximum aggregate encoded image bytes accepted in one submitted message. */
maxMessageImageBytes?: number
/** Maximum intrinsic width multiplied by height accepted for one image. */
maxImagePixels?: number
}
/** Persistent content-addressed local attachment store. */
export class LocalAttachmentStore extends AttachmentStore {
static Config: z<Config> = z.object({
dshHome: z.string(),
maxImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGE_BYTES),
maxImagesPerMessage: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGES_PER_MESSAGE),
maxMessageImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_MESSAGE_IMAGE_BYTES),
maxImagePixels: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGE_PIXELS),
})
/** Absolute versioned storage root. */
readonly root: string
readonly imageLimits: ImageAttachmentLimits
constructor(ctx: Context, config: Config) {
super(ctx)
this.root = resolve(join(resolveDshHome(config.dshHome), 'attachments', 'v1'))
this.imageLimits = Object.freeze({
maxImageBytes: config.maxImageBytes ?? DEFAULT_MAX_IMAGE_BYTES,
maxImagesPerMessage: config.maxImagesPerMessage ?? DEFAULT_MAX_IMAGES_PER_MESSAGE,
maxMessageImageBytes: config.maxMessageImageBytes ?? DEFAULT_MAX_MESSAGE_IMAGE_BYTES,
maxImagePixels: config.maxImagePixels ?? DEFAULT_MAX_IMAGE_PIXELS,
mediaTypes: Object.freeze(['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const),
})
}
async validateImage(input: SaveImageAttachment): Promise<void> {
await validateImageFile(input, this.imageLimits)
}
async saveImage(input: SaveImageAttachment): Promise<ImageAttachmentRef> {
return saveImageFile(this.root, input, this.imageLimits)
}
async readImage(ref: ImageAttachmentRef): Promise<StoredImageAttachment> {
return readImageFile(this.root, ref)
}
}
export default LocalAttachmentStore

View File

@@ -0,0 +1,20 @@
/** Package-owned invariant companion for `@deepseek-ai/dsh-attachment-local`. @module @deepseek-ai/dsh-attachment-local/invariant */
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-attachment-local'
/** Cordis companion plugin name. */
export const name = 'attachment-local-invariant'
/** Services required before package ownership can be reserved. */
export const inject = ['invariants', 'attachments']
/** No runtime invariant: immutable writes and verified reads are enforced directly at the backend boundary. */
const install: InvariantInstaller = () => {}
/**
* Register the package invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the registration disposer.
*/
export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,221 @@
/** Content-addressed, owner-private local attachment storage. */
import { createHash, randomUUID } from 'node:crypto'
import { constants } from 'node:fs'
import { chmod, link, mkdir, open, readFile, unlink } from 'node:fs/promises'
import { dirname, join, parse, resolve } from 'node:path'
import {
AttachmentError,
AttachmentId,
} from '@deepseek-ai/dsh-attachment'
import type {
ImageAttachmentLimits,
ImageAttachmentRef,
SaveImageAttachment,
StoredImageAttachment,
} from '@deepseek-ai/dsh-attachment'
import { detectImage, probeImage } from './image.ts'
const ID_PATTERN = /^sha256:([a-f0-9]{64})$/
const durableHomes = new Set<string>()
function digest(data: Uint8Array): string {
return createHash('sha256').update(data).digest('hex')
}
function displayName(value: string | undefined): string | undefined {
if (value === undefined) return undefined
// Strip both separator styles by hand: a POSIX host treats `\` as an
// ordinary character, so path.basename would keep a Windows client's full
// local path and leak it into the reference and the session log.
const leaf = value.slice(Math.max(value.lastIndexOf('/'), value.lastIndexOf('\\')) + 1)
const clean = leaf.replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 255)
return clean === '' ? undefined : clean
}
function objectPath(root: string, sha256: string): string {
return join(root, 'objects', sha256.slice(0, 2), sha256)
}
function ensureReference(ref: ImageAttachmentRef): string {
const match = ID_PATTERN.exec(String(ref.attachmentId))
if (match?.[1] === undefined) throw new AttachmentError('Attachment reference is invalid.', 'INVALID_ATTACHMENT_REF')
return match[1]
}
async function inspectMetadata(
data: Uint8Array,
declaredMediaType: ImageAttachmentRef['mediaType'],
maxPixels?: number,
): Promise<Omit<ImageAttachmentRef, 'attachmentId' | 'name'>> {
if (data.byteLength === 0) throw new AttachmentError('Image is empty.', 'INVALID_IMAGE')
const detected = await detectImage(data, maxPixels)
if (detected.mediaType !== declaredMediaType) throw new AttachmentError('Declared image type does not match its bytes.', 'IMAGE_TYPE_MISMATCH')
return { ...detected, bytes: data.byteLength }
}
/**
* Run the full admission policy for one image without touching storage.
* @param input - encoded bytes and declared metadata.
* @param limits - resolved storage policy.
* @returns completion after the encoded raster has been fully decoded.
*/
export async function validateImageFile(input: SaveImageAttachment, limits: ImageAttachmentLimits): Promise<void> {
if (input.data.byteLength > limits.maxImageBytes) {
throw new AttachmentError('Image exceeds the configured byte limit.', 'IMAGE_TOO_LARGE')
}
await inspectMetadata(input.data, input.mediaType, limits.maxImagePixels)
}
/**
* Make a directory's entries durable (fsync on a read-only directory handle).
* A synced file alone does not survive a crash when its directory entry never
* reached storage, so the publication directory is synced before a durable
* reference is reported.
*/
async function syncDirectory(path: string): Promise<void> {
/* v8 ignore next -- Windows cannot open directory handles; NTFS metadata journaling owns entry durability there. */
if (process.platform === 'win32') return
/* v8 ignore start -- Windows cannot exercise directory fsync; POSIX behavior tests enforce this peer. */
const handle = await open(path, constants.O_RDONLY)
try {
await handle.sync()
} finally {
await handle.close()
}
/* v8 ignore stop */
}
/**
* Create one private directory tree and persist every ancestor entry up to a
* caller-vouched durable boundary. The walk deliberately ignores what mkdir
* reports as newly created: a concurrent first save can create a level this
* process then merely observes, so "already existed" is not "already durable"
* — the entry may still be unsynced in the creator, and a crash would drop a
* directory the session checkpoint already references. Re-syncing a durable
* entry is harmless; skipping an unsynced one is not.
* @param path - absolute directory to create.
* @param boundary - absolute ancestor the caller vouches is already durable.
*/
async function ensureDurableDirectory(path: string, boundary: string): Promise<void> {
const target = resolve(path)
const stop = resolve(boundary)
await mkdir(target, { recursive: true, mode: 0o700 })
await chmod(target, 0o700)
let level = target
while (level !== stop) {
const parent = dirname(level)
await syncDirectory(parent)
/* v8 ignore next -- filesystem-root guard: callers pass a boundary that is an ancestor of path, so the walk reaches it first. */
if (parent === level) return
level = parent
}
}
/**
* Establish this process's proof that one DSH_HOME entry and every ancestor
* below the filesystem root are durable. Mere existence is insufficient: a
* concurrent process may have created the directory but not synced its parent.
*/
async function ensureDurableHome(path: string): Promise<string> {
const home = resolve(path)
if (!durableHomes.has(home)) {
await ensureDurableDirectory(home, parse(home).root)
durableHomes.add(home)
}
return home
}
/**
* Save and verify immutable image bytes below a versioned attachment root.
* @param root - absolute `DSH_HOME/attachments/v1` root.
* @param input - encoded bytes and declared metadata.
* @param limits - resolved storage policy.
* @returns durable content-addressed reference.
*/
export async function saveImageFile(root: string, input: SaveImageAttachment, limits: ImageAttachmentLimits): Promise<ImageAttachmentRef> {
if (input.data.byteLength > limits.maxImageBytes) throw new AttachmentError('Image exceeds the configured byte limit.', 'IMAGE_TOO_LARGE')
const metadata = await inspectMetadata(input.data, input.mediaType, limits.maxImagePixels)
const sha256 = digest(input.data)
const bucket = join(root, 'objects', sha256.slice(0, 2))
const staging = join(root, 'tmp')
// Establish DSH_HOME itself against the filesystem root once per process.
// Every process performs that proof independently, so observing a directory
// another process created can never be mistaken for durable publication.
const boundary = await ensureDurableHome(dirname(dirname(resolve(root))))
await ensureDurableDirectory(bucket, boundary)
await ensureDurableDirectory(staging, boundary)
const temporary = join(staging, randomUUID())
const target = objectPath(root, sha256)
let handle
try {
handle = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600)
await handle.writeFile(input.data)
await handle.sync()
await handle.close()
handle = undefined
try {
await link(temporary, target)
} catch (error) {
/* v8 ignore next -- Private same-filesystem directories make EEXIST the only recoverable link race. */
if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) throw error
const existing = new Uint8Array(await readFile(target))
if (digest(existing) !== sha256) throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT')
}
// Persist the target entry and close a concurrent bucket-creation window
// before the reference can reach a session checkpoint. The dedup path
// repeats both syncs because it may observe another writer's link before
// that writer reaches its own durability boundary.
await syncDirectory(bucket)
await syncDirectory(join(root, 'objects'))
await unlink(temporary)
} catch (error) {
/* v8 ignore next -- A descriptor can remain open only when the underlying write/sync/close operation fails. */
if (handle !== undefined) await handle.close().catch(
/* v8 ignore next -- Close failure is superseded by the storage operation that entered cleanup. */
() => {},
)
await unlink(temporary).catch(
/* v8 ignore next -- The callback requires a second independent staging-unlink failure. */
(cleanupError: unknown) => {
/* v8 ignore next -- Cleanup is best-effort only for a staging file already removed by a failed operation. */
if (!(cleanupError instanceof Error && 'code' in cleanupError && cleanupError.code === 'ENOENT')) throw cleanupError
},
)
if (error instanceof AttachmentError) throw error
throw new AttachmentError('Unable to persist image attachment.', 'ATTACHMENT_WRITE_FAILED', { cause: error })
}
const name = displayName(input.name)
return {
attachmentId: AttachmentId(`sha256:${sha256}`),
...metadata,
...(name !== undefined ? { name } : {}),
}
}
/**
* Read and verify one content-addressed image.
* @param root - absolute `DSH_HOME/attachments/v1` root.
* @param ref - reference recorded in the session log.
* @returns verified bytes and reference.
*/
export async function readImageFile(root: string, ref: ImageAttachmentRef): Promise<StoredImageAttachment> {
const sha256 = ensureReference(ref)
let data: Uint8Array
try {
data = new Uint8Array(await readFile(objectPath(root, sha256)))
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') throw new AttachmentError('Attachment object is missing.', 'ATTACHMENT_NOT_FOUND')
throw new AttachmentError('Unable to read image attachment.', 'ATTACHMENT_READ_FAILED', { cause: error })
}
if (digest(data) !== sha256) throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT')
// The digest proves these are the exact bytes admission fully decoded, so
// the read path only re-derives the header fields (no raster decode, no
// per-request pixel amplification on history replay).
const metadata = await probeImage(data)
if (metadata.mediaType !== ref.mediaType || data.byteLength !== ref.bytes
|| metadata.width !== ref.width || metadata.height !== ref.height) {
throw new AttachmentError('Stored attachment metadata does not match its reference.', 'ATTACHMENT_CORRUPT')
}
return { ref, data }
}

View File

@@ -0,0 +1,51 @@
import sharp from 'sharp'
import { describe, expect, it } from 'vitest'
import { detectImage, probeImage } from '../src/image.ts'
async function raster(format: 'png' | 'jpeg' | 'webp' | 'gif'): Promise<Uint8Array> {
const image = sharp({
create: { width: 3, height: 2, channels: 4, background: { r: 1, g: 2, b: 3, alpha: 1 } },
})
return new Uint8Array(await image.toFormat(format).toBuffer())
}
describe('raster decoding', () => {
it('decodes every supported format and its intrinsic dimensions', async () => {
for (const [format, mediaType] of [
['png', 'image/png'],
['jpeg', 'image/jpeg'],
['webp', 'image/webp'],
['gif', 'image/gif'],
] as const) {
await expect(detectImage(await raster(format)))
.resolves.toEqual({ mediaType, width: 3, height: 2 })
}
})
it('rejects excess decoded pixels before decoding', async () => {
await expect(detectImage(await raster('png'), 5))
.rejects.toMatchObject({ code: 'IMAGE_TOO_MANY_PIXELS' })
})
it('rejects malformed bytes and truncated payloads with readable headers', async () => {
await expect(detectImage(Uint8Array.of(1, 2, 3)))
.rejects.toMatchObject({ code: 'INVALID_IMAGE' })
const unsupported = await sharp({
create: { width: 1, height: 1, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 1 } },
}).tiff().toBuffer()
await expect(detectImage(unsupported)).rejects.toMatchObject({ code: 'INVALID_IMAGE' })
const complete = await raster('png')
const truncated = complete.subarray(0, 62)
await expect(sharp(truncated).metadata()).resolves.toMatchObject({ width: 3, height: 2 })
await expect(detectImage(truncated)).rejects.toMatchObject({ code: 'INVALID_IMAGE' })
})
it('probes malformed bytes and unsupported formats into the same stable error', async () => {
await expect(probeImage(Uint8Array.of(1, 2, 3)))
.rejects.toMatchObject({ code: 'INVALID_IMAGE' })
const unsupported = await sharp({
create: { width: 1, height: 1, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 1 } },
}).tiff().toBuffer()
await expect(probeImage(unsupported)).rejects.toMatchObject({ code: 'INVALID_IMAGE' })
})
})

View File

@@ -0,0 +1,60 @@
import { Context } from 'cordis'
import { existsSync } from 'node:fs'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import LocalAttachmentStore, {
DEFAULT_MAX_IMAGE_BYTES,
DEFAULT_MAX_IMAGE_PIXELS,
DEFAULT_MAX_IMAGES_PER_MESSAGE,
DEFAULT_MAX_MESSAGE_IMAGE_BYTES,
} from '../src/index.ts'
describe('local attachment service', () => {
it('resolves every omitted admission limit explicitly', () => {
const service = new LocalAttachmentStore(new Context(), {})
expect(service.imageLimits).toEqual({
maxImageBytes: DEFAULT_MAX_IMAGE_BYTES,
maxImagesPerMessage: DEFAULT_MAX_IMAGES_PER_MESSAGE,
maxMessageImageBytes: DEFAULT_MAX_MESSAGE_IMAGE_BYTES,
maxImagePixels: DEFAULT_MAX_IMAGE_PIXELS,
mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],
})
})
it('saves and reads through the service boundary', async () => {
const dshHome = await mkdtemp(join(tmpdir(), 'dsh-attachment-service-'))
try {
const service = new LocalAttachmentStore(new Context(), { dshHome })
const data = Uint8Array.from(Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64',
))
const ref = await service.saveImage({ data, mediaType: 'image/png' })
await expect(service.readImage(ref)).resolves.toEqual({ ref, data })
} finally {
await rm(dshHome, { recursive: true, force: true })
}
})
it('validates without persisting: a rejected image leaves no storage root behind', async () => {
const dshHome = await mkdtemp(join(tmpdir(), 'dsh-attachment-validate-'))
try {
const service = new LocalAttachmentStore(new Context(), { dshHome })
await expect(service.validateImage({ data: Uint8Array.of(1, 2, 3), mediaType: 'image/png' }))
.rejects.toThrow(/Unsupported or malformed image data/)
const valid = Uint8Array.from(Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64',
))
const limited = new LocalAttachmentStore(new Context(), { dshHome, maxImageBytes: 1 })
await expect(limited.validateImage({ data: valid, mediaType: 'image/png' }))
.rejects.toMatchObject({ code: 'IMAGE_TOO_LARGE' })
await expect(service.validateImage({ data: valid, mediaType: 'image/png' })).resolves.toBeUndefined()
expect(existsSync(service.root)).toBe(false)
} finally {
await rm(dshHome, { recursive: true, force: true })
}
})
})

View File

@@ -0,0 +1,208 @@
import { createHash } from 'node:crypto'
import { constants } from 'node:fs'
import { chmod, mkdir, readFile, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join, parse, resolve } from 'node:path'
import { mkdtemp, rm } from 'node:fs/promises'
import { afterEach, describe, expect, it, vi } from 'vitest'
import sharp from 'sharp'
import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment'
import { readImageFile, saveImageFile } from '../src/store.ts'
const fsControl = vi.hoisted(() => ({ syncedDirectories: [] as string[] }))
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>()
return {
...actual,
async open(...args: Parameters<typeof actual.open>): ReturnType<typeof actual.open> {
if (args[1] === constants.O_RDONLY) fsControl.syncedDirectories.push(String(args[0]))
return actual.open(...args)
},
}
})
const PNG = Uint8Array.from(Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64',
))
const LIMITS: ImageAttachmentLimits = {
maxImageBytes: 1024,
maxImagesPerMessage: 2,
maxMessageImageBytes: 2048,
maxImagePixels: 16,
mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],
}
const roots: string[] = []
async function root(): Promise<string> {
const value = await mkdtemp(join(tmpdir(), 'dsh-attachment-'))
roots.push(value)
return join(value, 'attachments', 'v1')
}
function parentChainToRoot(path: string): string[] {
const parents: string[] = []
let level = resolve(path)
const root = parse(level).root
while (level !== root) {
level = dirname(level)
parents.push(level)
}
return parents
}
afterEach(async () => {
await Promise.all(roots.splice(0).map(path => rm(path, { recursive: true, force: true })))
})
describe('local attachment store', () => {
it.skipIf(process.platform === 'win32')('syncs every object ancestor up to the durable boundary before returning', async () => {
const storageRoot = await root()
const base = join(storageRoot, '..', '..')
const sha256 = createHash('sha256').update(PNG).digest('hex')
const objects = join(storageRoot, 'objects')
const bucket = join(objects, sha256.slice(0, 2))
fsControl.syncedDirectories.length = 0
await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)
// Each process first proves DSH_HOME durable all the way to the filesystem
// root; existence alone cannot vouch for a concurrent creator's fsync.
// Later directory creation can then stop at that process-proven boundary.
expect(fsControl.syncedDirectories).toEqual([
...parentChainToRoot(base),
// bucket chain: every parent entry between the bucket and the boundary.
objects,
storageRoot,
join(storageRoot, '..'),
base,
// staging chain re-walks the shared ancestors after creating tmp.
storageRoot,
join(storageRoot, '..'),
base,
// publication: the settled object's bucket and its parent for the rename.
bucket,
objects,
])
})
it('creates and persists a missing nested home directory against the filesystem root', async () => {
const storageRoot = join(await root(), 'home', 'attachments', 'v1')
const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)
await expect(readImageFile(storageRoot, ref)).resolves.toEqual({ ref, data: PNG })
})
it('publishes one private content-addressed object and deduplicates equal bytes', async () => {
const storageRoot = await root()
const first = await saveImageFile(storageRoot, {
data: PNG, mediaType: 'image/png', name: '/private/tmp/pixel.png',
}, LIMITS)
const second = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)
const sha256 = createHash('sha256').update(PNG).digest('hex')
const object = join(storageRoot, 'objects', sha256.slice(0, 2), sha256)
expect(first).toEqual({
attachmentId: `sha256:${sha256}`,
mediaType: 'image/png',
bytes: PNG.byteLength,
width: 1,
height: 1,
name: 'pixel.png',
})
expect(second.attachmentId).toBe(first.attachmentId)
expect(new Uint8Array(await readFile(object))).toEqual(PNG)
if (process.platform !== 'win32') {
expect((await stat(object)).mode & 0o777).toBe(0o600)
expect((await stat(join(storageRoot, 'objects', sha256.slice(0, 2)))).mode & 0o777).toBe(0o700)
}
await expect(readImageFile(storageRoot, first)).resolves.toEqual({ ref: first, data: PNG })
})
it('keeps admitted history readable after deployment limits become stricter', async () => {
const storageRoot = await root()
const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)
await expect(readImageFile(storageRoot, ref)).resolves.toEqual({ ref, data: PNG })
})
it('rejects malformed bytes, mismatched declarations, byte limits, and decoded-pixel limits', async () => {
const storageRoot = await root()
await expect(saveImageFile(storageRoot, {
data: new Uint8Array(0), mediaType: 'image/png',
}, LIMITS)).rejects.toMatchObject({ code: 'INVALID_IMAGE' })
await expect(saveImageFile(storageRoot, {
data: Uint8Array.of(1, 2, 3), mediaType: 'image/png',
}, LIMITS)).rejects.toMatchObject({ code: 'INVALID_IMAGE' })
await expect(saveImageFile(storageRoot, {
data: PNG, mediaType: 'image/jpeg',
}, LIMITS)).rejects.toMatchObject({ code: 'IMAGE_TYPE_MISMATCH' })
await expect(saveImageFile(storageRoot, {
data: PNG, mediaType: 'image/png',
}, { ...LIMITS, maxImageBytes: 1 })).rejects.toMatchObject({ code: 'IMAGE_TOO_LARGE' })
const wide = new Uint8Array(await sharp({
create: { width: 5, height: 5, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 1 } },
}).png().toBuffer())
await expect(saveImageFile(storageRoot, {
data: wide, mediaType: 'image/png',
}, LIMITS)).rejects.toMatchObject({ code: 'IMAGE_TOO_MANY_PIXELS' })
const unnamed = await saveImageFile(storageRoot, {
data: PNG, mediaType: 'image/png', name: '\u0000',
}, LIMITS)
expect(unnamed).not.toHaveProperty('name')
})
it('fails closed when an object is missing, corrupted, or addressed by an invalid reference', async () => {
const storageRoot = await root()
const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)
const sha256 = String(ref.attachmentId).slice('sha256:'.length)
const object = join(storageRoot, 'objects', sha256.slice(0, 2), sha256)
await chmod(object, 0o600)
await writeFile(object, Uint8Array.of(1, 2, 3))
await expect(readImageFile(storageRoot, ref))
.rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' })
await expect(readImageFile(storageRoot, { ...ref, attachmentId: 'bad' as never }))
.rejects.toMatchObject({ code: 'INVALID_ATTACHMENT_REF' })
const missingRoot = await root()
await mkdir(missingRoot, { recursive: true })
await expect(readImageFile(missingRoot, ref))
.rejects.toMatchObject({ code: 'ATTACHMENT_NOT_FOUND' })
const unreadableRoot = await root()
const target = join(unreadableRoot, 'objects', sha256.slice(0, 2), sha256)
await mkdir(target, { recursive: true })
await expect(readImageFile(unreadableRoot, ref))
.rejects.toMatchObject({ code: 'ATTACHMENT_READ_FAILED' })
})
it('rejects conflicting existing objects and reference metadata mismatches', async () => {
const storageRoot = await root()
const sha256 = createHash('sha256').update(PNG).digest('hex')
const target = join(storageRoot, 'objects', sha256.slice(0, 2), sha256)
await mkdir(join(storageRoot, 'objects', sha256.slice(0, 2)), { recursive: true })
await writeFile(target, Uint8Array.of(1, 2, 3))
await expect(saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS))
.rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' })
await writeFile(target, PNG)
const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)
await expect(readImageFile(storageRoot, { ...ref, width: ref.width + 1 }))
.rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' })
})
it('maps unexpected publication failures to a stable storage error', async () => {
const storageRoot = await root()
const sha256 = createHash('sha256').update(PNG).digest('hex')
const target = join(storageRoot, 'objects', sha256.slice(0, 2), sha256)
await mkdir(target, { recursive: true })
await expect(saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS))
.rejects.toMatchObject({ code: 'ATTACHMENT_WRITE_FAILED' })
})
})

View File

@@ -0,0 +1,12 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": { "rootDir": "src", "outDir": "lib/types" },
"include": ["src"],
"references": [
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../attachment" },
{ "path": "../../util/paths" },
{ "path": "../../support/invariants" }
]
}

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/attachment/attachment/README.md
README.md: 4f450316294e554396adb9a8454051a08d9befd3
README.zh.md: fe51b0003cdf1659c7c56106b97c6f3139ebe890

View File

@@ -0,0 +1,21 @@
# @deepseek-ai/dsh-attachment
English | [中文](README.zh.md)
The durable attachment seam. `ctx.attachments` validates and atomically commits immutable image bytes, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, or base64 in session events.
Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the same admission policy without persisting; batch writers validate every member first so a malformed member cannot strand earlier members as unreferenced objects. `saveImage` commits each accepted image before any model-visible session event is published, and `readImage` verifies the content-addressed object against its logged metadata.
## Model Experience
Indirectly, through the role-neutral core `ImageBlock` and provider adapters that resolve its durable reference.
#### KV Cache effect
Adding an image changes the provider request and therefore invalidates the affected request suffix.
## Known Limitations and Deferred Work
- Version one accepts PNG, JPEG, WebP, and GIF only.
- Retention and garbage collection are deferred because resumed and forked sessions may share immutable objects.
- Generic files, audio, video, and persistent unsent drafts require separate lifecycle and provider contracts.

View File

@@ -0,0 +1,21 @@
# @deepseek-ai/dsh-attachment
[English](README.md) | 中文
持久附件服务边界。`ctx.attachments` 校验并以原子方式提交不可变图片字节,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL 或 base64。
未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行相同的准入策略,但不执行持久化;批量写入方会先校验每个成员,避免某个格式错误的成员使较早的成员成为无引用对象。`saveImage` 会在发布任何模型可见的会话事件前提交每张已接受的图片,`readImage` 则根据已记录的元数据校验内容寻址对象。
## 模型体验
该包通过角色无关的核心 `ImageBlock`,以及解析其持久引用的提供方适配器,间接影响模型。
#### KV 缓存影响
添加图片会改变提供方请求,因此会使受影响的请求后缀失效。
## 已知限制与待完成工作
- 第一版仅接受 PNG、JPEG、WebP 和 GIF。
- 保留策略与垃圾回收尚未实现,因为恢复和 fork 后的会话可能共享不可变对象。
- 通用文件、音频、视频和持久的未发送草稿需要单独的生命周期与提供方契约。

View File

@@ -0,0 +1,27 @@
{
"name": "@deepseek-ai/dsh-attachment",
"description": "Durable immutable attachment storage seam for the DeepSeek Harness",
"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" },
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": ["lib/index.js", "lib/invariant.js", "lib/types/**/*.d.ts"],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,15 @@
/** Attachment identifier brand. @module @deepseek-ai/dsh-attachment/brand */
import type { Branded } from '@deepseek-ai/dsh-brand'
/** Opaque content-addressed identifier for one immutable attachment object. */
export type AttachmentId = Branded<'AttachmentId'>
/**
* Brand a validated storage identifier.
* @param value - backend-produced opaque identifier.
* @returns the branded identifier.
*/
export function AttachmentId(value: string): AttachmentId {
return value as AttachmentId
}

View File

@@ -0,0 +1,26 @@
/** Attachment failure class. @module @deepseek-ai/dsh-attachment/error */
/**
* Stable failures suitable for host RPC error mapping.
*
* Deliberately re-implements the `HarnessError` shape instead of extending it:
* the base lives in `@deepseek-ai/dsh-llm`, which itself depends on this
* package (`ImageBlock` references `ImageAttachmentRef`), so sharing the base
* would create a dependency cycle. Consumers route on `code`, never on the
* prototype chain, so the shapes stay interchangeable at the wire boundary.
*/
export class AttachmentError extends Error {
/** Stable machine-routing failure code. */
readonly code: string
/**
* @param message - human-readable failure description without raw bytes or host paths.
* @param code - stable machine-routing code.
* @param options - optional chained cause.
*/
constructor(message: string, code: string, options?: ErrorOptions) {
super(message, options)
this.name = 'AttachmentError'
this.code = code
}
}

View File

@@ -0,0 +1,60 @@
/** Durable attachment storage seam (`ctx.attachments`). @module @deepseek-ai/dsh-attachment */
import { Context, Service } from 'cordis'
import type {
ImageAttachmentLimits,
ImageAttachmentRef,
SaveImageAttachment,
StoredImageAttachment,
} from './types.ts'
export { AttachmentId } from './brand.ts'
export { AttachmentError } from './error.ts'
export type {
AttachmentId as AttachmentIdType,
ImageAttachmentLimits,
ImageAttachmentRef,
ImageMediaType,
SaveImageAttachment,
StoredImageAttachment,
} from './types.ts'
declare module 'cordis' {
interface Context {
attachments: AttachmentStore
}
}
/** Immutable binary attachment service. Implementations validate bytes before publishing a reference. */
export abstract class AttachmentStore extends Service {
constructor(ctx: Context) {
super(ctx, 'attachments')
}
/** Deployment-resolved image policy used by authoritative and fast-path validation. */
abstract readonly imageLimits: ImageAttachmentLimits
/**
* Validate one image without persisting it.
* Batch callers validate every member before saving any member.
* @param input - encoded bytes, declared media type, and optional display name.
* @returns completion after the encoded raster has been fully decoded.
*/
abstract validateImage(input: SaveImageAttachment): Promise<void>
/**
* Validate and durably commit one image before its owning session event is appended.
* @param input - encoded bytes, declared media type, and optional display name.
* @returns a durable content-addressed reference.
*/
abstract saveImage(input: SaveImageAttachment): Promise<ImageAttachmentRef>
/**
* Read one image and verify that bytes still match the recorded reference.
* @param ref - durable reference from the session log.
* @returns the verified bytes and canonical reference.
*/
abstract readImage(ref: ImageAttachmentRef): Promise<StoredImageAttachment>
}
export default AttachmentStore

View File

@@ -0,0 +1,20 @@
/** Package-owned invariant companion for `@deepseek-ai/dsh-attachment`. @module @deepseek-ai/dsh-attachment/invariant */
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-attachment'
/** Cordis companion plugin name. */
export const name = 'attachment-invariant'
/** Service required before package ownership can be reserved. */
export const inject = ['invariants']
/** No runtime invariant: this stateless seam owns types while implementations enforce immutable-store checks. */
const install: InvariantInstaller = () => {}
/**
* Register the package invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the registration disposer.
*/
export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,48 @@
/** Durable attachment vocabulary. @module @deepseek-ai/dsh-attachment/types */
import type { AttachmentId } from './brand.ts'
export type { AttachmentId } from './brand.ts'
/** Raster image formats accepted by the version-one attachment path. */
export type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif'
/** Durable, serializable metadata for one immutable image object. */
export interface ImageAttachmentRef {
/** Opaque storage identifier; never a filesystem path or bearer URL. */
attachmentId: AttachmentId
/** Media type verified from the stored bytes. */
mediaType: ImageMediaType
/** Exact encoded byte length. */
bytes: number
/** Intrinsic encoded width in pixels. */
width: number
/** Intrinsic encoded height in pixels. */
height: number
/** Optional display name stripped of local path information. */
name?: string
}
/** Deployment-resolved limits used by upload admission and request buffering. */
export interface ImageAttachmentLimits {
maxImageBytes: number
maxImagesPerMessage: number
maxMessageImageBytes: number
maxImagePixels: number
mediaTypes: readonly ImageMediaType[]
}
/** Request to validate and durably commit one image. */
export interface SaveImageAttachment {
data: Uint8Array
/** Caller-declared media type, checked against fully decoded bytes. */
mediaType: ImageMediaType
/** Optional browser/provider display name; it is never interpreted as a path. */
name?: string
}
/** Stored image bytes returned after reference and digest verification. */
export interface StoredImageAttachment {
ref: ImageAttachmentRef
data: Uint8Array
}

View File

@@ -0,0 +1,11 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": { "rootDir": "src", "outDir": "lib/types" },
"include": ["src"],
"references": [
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../util/brand" },
{ "path": "../../support/invariants" }
]
}

View File

@@ -107,6 +107,12 @@
config:
root: !!js dshHomePath('sessions')
# Durable image bytes live outside the append-only session log. Messages
# keep content-addressed references that this shared backend resolves for
# provider requests and authorized history reads.
- id: attachment-local
name: '@deepseek-ai/dsh-attachment-local'
# Raw configs can supply a process-local path or disable this shared session
# capability. The neutral default is process-local and opens only when used.
- id: session-query-sqlite

View File

@@ -39,6 +39,7 @@
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-default-model": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-attachment-local": "workspace:^",
"@deepseek-ai/dsh-bash-env": "workspace:^",
"@deepseek-ai/dsh-bash-sandbox": "workspace:^",
"@deepseek-ai/dsh-command-compact": "workspace:^",

View File

@@ -29,6 +29,7 @@
},
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",

View File

@@ -6,10 +6,10 @@
// The ./api and ./client subpath exports are the browser-safe channels.
export type {
ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, PromptContentPart, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
DirectoryEntry, DirectoryListing,
WorkspaceApi, WorkspaceId, WorkspaceView,
ResponseValue, WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelSelection, QueueAction, QueuedInboxItem, SessionModels,
@@ -37,6 +37,9 @@ export type { SessionId, SessionEvent } from '@deepseek-ai/dsh-session/types'
export type { MessageId } from '@deepseek-ai/dsh-llm/brand'
export type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm/types'
/** Successful value returned by the connection-generation host handshake. */
export type HostDescription = import('@deepseek-ai/dsh-host-apiproxy/api').ResponseValue<'host.describe'>
import type { RpcResponse, RpcResult } from '@deepseek-ai/dsh-host-apiproxy/api'
/**

View File

@@ -131,11 +131,15 @@ export class ConnectionController {
// subscribed baseline. The timeout guards against a carrier that never fires onOpen
// (see ConnectionConfig.streamOpenTimeoutMs).
const timeout = new AbortController()
await Promise.all([
const [description] = await Promise.all([
this.api.host.describe({}),
Promise.race([streamsOpen, sleep(this.config.streamOpenTimeoutMs, timeout.signal)]),
])
timeout.abort()
const descriptionResult = description.result
if (!descriptionResult.ok) {
throw new Error(`host.describe failed: ${descriptionResult.error.code}: ${descriptionResult.error.message}`)
}
if (ac.signal.aborted) throw new Error('generation aborted during readiness handshake')
this.attempt = 0
this.emitState('connected')

View File

@@ -1,7 +1,7 @@
// FixtureApi: standalone UI development without a server. Real contract shape: unary takes
// RpcRequest<P> and returns RpcResponse<T> (echoing the rpcId); streams yield RpcRequest<frame>
// (the fixture IS the fake server, so it mints frame rpcIds); root respond takes ClientResponse
// and returns RpcReceipt. fx-alpha carries a hand-built history script (60 turns, pageable);
// and returns RpcReceipt. fx-alpha carries a hand-built history script (74 turns, pageable);
// prompt triggers a chunked streaming replay; cancel stops the replay; resident pending
// approval/question requests exercise replay and composer takeover with stable rpcIds.
@@ -19,6 +19,7 @@ import type {
ToolResultMessage,
UserMessage,
} from '@deepseek-ai/dsh-llm'
import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type {
SessionEvent,
SessionId,
@@ -51,10 +52,10 @@ function userMessage(content: ContentBlock[], source: MessageSource = { kind: 'u
return createUserMessage({ content, source })
}
function assistantMessage(content: ContentBlock[]): AssistantMessage {
function assistantMessage(content: ContentBlock[], model = 'fx-1'): AssistantMessage {
return createAssistantMessage({
content,
source: { provider: 'fixture', model: 'fx-1' },
source: { provider: 'fixture', model },
})
}
@@ -330,6 +331,16 @@ function sid(id: string): SessionId {
return id as SessionId
}
const FIXTURE_IMAGE_DATA = 'iVBORw0KGgoAAAANSUhEUgAAAKAAAABaCAYAAAA/xl1SAAAAvklEQVR42u3SMQ0AAAjAMIyhELM4AAe8PD1qYFlk9cCXEAEDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGxIBCYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIAYEAyIAcGAGBAMiAHBgBgQDIgBwYAYEAyIAcGAGBAMiAHBgBgQDIgB4bYWLb6pnOb1xAAAAABJRU5ErkJggg=='
const FIXTURE_IMAGE_REF: ImageAttachmentRef = {
attachmentId: 'fixture:image' as AttachmentIdType,
mediaType: 'image/png',
bytes: 247,
width: 160,
height: 90,
name: 'fixture-image.png',
}
/** Deterministic provider billing attached to fixture assistant messages. */
function fixtureUsage(turn: number, step: number): TokenUsage {
return {
@@ -340,7 +351,7 @@ function fixtureUsage(turn: number, step: number): TokenUsage {
}
}
/** fx-alpha history script: 60 turns (~130+ messages -> 3 pages at PAGE_MESSAGES=50),
/** fx-alpha history script: 74 turns (~150+ messages -> 4 pages at PAGE_MESSAGES=50),
* mixing reasoning blocks / tool call+result / context. */
function buildAlphaLog(): SessionEvent[] {
const events: Record<string, unknown>[] = []
@@ -476,7 +487,7 @@ function buildAlphaLog(): SessionEvent[] {
push({ type: 'step/end', data: { turn, step: 0 } })
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
}
// Turn 72: todo_write sample — the TodoRow toolview in the flow plus the
// Turn 73: todo_write sample — the TodoRow toolview in the flow plus the
// todo/write snapshot event feeding the TodoPanel plan strip. Two items are
// in_progress: this fixture chooses the parallel policy, so both surfaces
// must render a parallel plan rather than the first active item alone.
@@ -535,8 +546,32 @@ function buildAlphaLog(): SessionEvent[] {
toolTurn(70, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.')
toolTurn(71, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.')
// Turn 72: user and assistant images share one durable fixture object.
// The todo turn remains last so its standing projection stays visible.
push({ type: 'turn/start', data: { turn: 72 } })
push({
type: 'user/message',
surfaceOp: 'append',
data: userMessage([{ type: 'image', attachment: FIXTURE_IMAGE_REF }, ...text('历史用户图片')]),
})
push({ type: 'step/start', data: { turn: 72, step: 0 } })
push({
type: 'assistant/message',
surfaceOp: 'append',
data: {
turn: 72,
step: 0,
message: assistantMessage(
[...text('结构化模型图片:'), { type: 'image', attachment: FIXTURE_IMAGE_REF }],
'fx-vision',
),
},
})
push({ type: 'step/end', data: { turn: 72, step: 0 } })
push({ type: 'turn/end', data: { turn: 72, reason: { kind: 'completed' } } })
const todoArgs = JSON.stringify({ todos: fixtureTodos })
toolTurn(72, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 2 in progress, 1 completed.')
toolTurn(73, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 2 in progress, 1 completed.')
// The real tool appends the snapshot mid-execution — between tool/call and
// tool/result — so the fixture reproduces that exact ordering (the last
// toolTurn events run ... tool/call, tool/result, step/end, turn/end).
@@ -855,7 +890,6 @@ function estimateFixtureContent(blocks: readonly ContentBlock[]): number {
// ContentBlockMap is merge-extensible: this client graph sees only the
// base four members, but fixture turns do carry extended blocks at
// runtime, so the structural JSON fallback below is live code.
// oxlint-disable-next-line typescript/no-unnecessary-condition -- the type collapses without the out-of-graph merges (see above).
if (block.type === 'tool-result') {
return tokens + estimateFixtureContent(block.content) + BLOCK_OVERHEAD
}
@@ -1057,6 +1091,18 @@ function pageOf(
return { events, hasMore: start > 0 }
}
/** Fixture mirror of host session-scoped attachment authorization. */
function logReferencesAttachment(log: readonly SessionEvent[], attachmentId: string): boolean {
const visit = (value: unknown): boolean => {
if (Array.isArray(value)) return value.some(visit)
if (typeof value !== 'object' || value === null) return false
const record = value as Record<string, unknown>
if (record.attachmentId === attachmentId) return true
return Object.values(record).some(visit)
}
return log.some(event => visit(event.data))
}
/** Fixture mirror of first-party message extraction used by session-query. */
function searchBlockText(block: ContentBlock): string[] {
switch (block.type) {
@@ -1351,6 +1397,10 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
session.sessionId,
{ provider: 'deepseek-official', model: 'deepseek-v4-flash' },
]))
const attachments = new Map<string, { attachment: ImageAttachmentRef; data: string }>([[
String(FIXTURE_IMAGE_REF.attachmentId),
{ attachment: FIXTURE_IMAGE_REF, data: FIXTURE_IMAGE_DATA },
]])
/** Credential store double: set/unset flip the describe badge, values never read back. */
const fixtureCredentials = new Map<string, true>([
// The assembled fixture represents an already-configured shipped
@@ -1368,7 +1418,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
['my-agent', { trust: 'user', content: "- id: tool-read\n name: '@deepseek-ai/dsh-tool-read'\n" }],
])
let fixtureDefaultPreset = 'standard'
const nextTurn = new Map<SessionId, number>([[sid('fx-alpha'), 60]])
const nextTurn = new Map<SessionId, number>([[sid('fx-alpha'), 74]])
let nextSession = 1
let nextRpc = 1
let attachedSessions = options.empty ? 0 : 1
@@ -2145,9 +2195,26 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
// First accepted prompt appends events: the summary stops being blank.
summary.blank = false
const userText = content.map(b => (b.type === 'text' ? b.text : '')).join('')
const durable: ContentBlock[] = content.map((block) => {
if (block.type === 'text') return block
const attachment: ImageAttachmentRef = {
attachmentId: `fixture:${randomUuid()}` as AttachmentIdType,
mediaType: block.mediaType,
bytes: Math.max(
1,
Math.floor(block.data.length * 3 / 4)
- (block.data.endsWith('==') ? 2 : block.data.endsWith('=') ? 1 : 0),
),
width: 160,
height: 90,
...block.name === undefined ? {} : { name: block.name },
}
attachments.set(String(attachment.attachmentId), { attachment, data: block.data })
return { type: 'image', attachment }
})
if (mode === 'steer' && replays.has(id)) {
// Steering: the durable user/message lands inside the current turn; the replay continues.
append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(content) })
append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable) })
return ok(request, { accepted: true as const })
}
const turn = nextTurn.get(id) ?? 0
@@ -2160,7 +2227,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
if (plan.wanted !== null && plan.wanted !== plan.active) {
append(id, { type: 'plan/mode', data: { active: plan.wanted } })
}
append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(content) })
append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable) })
// Capacity parallel of the host token-meter's request/context record:
// log-only, appended inside the open turn, and deduplicated against the
// route already recorded (the fixture never varies contextWindow).
@@ -2186,6 +2253,27 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
)
return ok(request, { accepted: true as const })
},
attachment: (request) => {
const stored = attachments.get(String(request.payload.attachmentId))
if (stored === undefined) {
return err(request, {
code: 'attachment-error',
message: 'fixture attachment missing',
details: { reason: 'ATTACHMENT_NOT_FOUND' },
})
}
if (!logReferencesAttachment(
logs.get(request.payload.sessionId) ?? [],
String(request.payload.attachmentId),
)) {
return err(request, {
code: 'attachment-error',
message: 'fixture attachment is not referenced by this session',
details: { reason: 'ATTACHMENT_NOT_REFERENCED' },
})
}
return ok(request, stored)
},
updateQueue: request => err(request, {
code: 'queue-item-not-found',
message: 'fixture has no pending queue item',
@@ -2837,6 +2925,7 @@ export class FixtureApiClient extends AbstractApiClient {
case 'session.rename': return this.api.sessions.rename(request)
case 'session.fork': return this.api.sessions.fork(request)
case 'session.prompt': return this.api.sessions.prompt(request)
case 'session.attachment': return this.api.sessions.attachment(request)
case 'session.updateQueue': return this.api.sessions.updateQueue(request)
case 'session.cancel': return this.api.sessions.cancel(request)
case 'subagent.list': return this.api.subagents.list(request)

View File

@@ -14,7 +14,7 @@ import type { ClientConnectionRpc } from '../rpc.ts'
// ---- Contract re-exports (browser-safe apiproxy channels + core types) ----
export type {
ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, PromptContentPart, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
DirectoryEntry, DirectoryListing,
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
@@ -24,7 +24,7 @@ export type {
SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
HostDescription, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
GoalsApi, GoalRef,
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
CredentialsApi, CredentialView, ConfigurableProviderView, DiscoveredModelView, LlmApi,

View File

@@ -21,8 +21,14 @@ export interface FetchHandler {
* @param req - incoming node:http request (fully read before dispatch).
* @param res - node:http response the bridge writes and owns to completion.
* @param apiHandler - fetch-shaped API carrier the request is dispatched to.
* @param maxRequestBodyBytes - maximum body bytes buffered before dispatch.
*/
export async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: FetchHandler): Promise<void> {
export async function bridge(
req: IncomingMessage,
res: ServerResponse,
apiHandler: FetchHandler,
maxRequestBodyBytes = 32 * 1024 * 1024,
): Promise<void> {
const abort = new AbortController()
// Client-disconnect detection MUST hang off the response, not the request:
// since Node 16, IncomingMessage 'close' fires as soon as the request body is
@@ -32,8 +38,26 @@ export async function bridge(req: IncomingMessage, res: ServerResponse, apiHandl
res.on('close', () => {
if (!res.writableEnded) abort.abort()
})
const declaredLength = req.headers['content-length']
if (declaredLength !== undefined && Number(declaredLength) > maxRequestBodyBytes) {
res.writeHead(413, { connection: 'close' })
res.end()
req.destroy()
return
}
const chunks: Buffer[] = []
for await (const chunk of req) chunks.push(chunk as Buffer)
let received = 0
for await (const chunk of req) {
const buffer = chunk as Buffer
received += buffer.byteLength
if (received > maxRequestBodyBytes) {
res.writeHead(413, { connection: 'close' })
res.end()
req.destroy()
return
}
chunks.push(buffer)
}
/* v8 ignore next 3 -- `??` arms: node:http always sets url/method on server
requests; the fields are only optional on the client-side IncomingMessage type */
const request = new Request(new URL(req.url ?? '/', 'http://dsh.internal'), {

View File

@@ -1,6 +1,7 @@
/** Host HTTP bridge for browser-client RPC. */
import type { Context } from 'cordis'
import z from 'schemastery'
import type {} from '@deepseek-ai/dsh-attachment'
// Activates the httpServer Context merge used below.
import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
@@ -25,6 +26,25 @@ export { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts'
/** Stable Cordis plugin name. */
export const name = 'client-connection'
/** Headroom for RPC JSON fields around aggregate base64 image payloads. */
const REQUEST_ENVELOPE_HEADROOM_BYTES = 1024 * 1024
function assertImageBodyCapacity(ctx: Context, maxRequestBodyBytes: number): void {
const attachments = ctx.get('attachments')
if (attachments === undefined) return
const requiredImageBodyBytes = Math.ceil(
attachments.imageLimits.maxMessageImageBytes * 4 / 3,
) + REQUEST_ENVELOPE_HEADROOM_BYTES
if (maxRequestBodyBytes < requiredImageBodyBytes) {
throw new Error(
`client-connection maxRequestBodyBytes (${String(maxRequestBodyBytes)}) must be at least `
+ `${String(requiredImageBodyBytes)} for the configured aggregate image limit`,
)
}
}
/** Default carrier cap for all HTTP RPC bodies. */
const DEFAULT_MAX_REQUEST_BODY_BYTES = 32 * 1024 * 1024
/** Services required before providing Connection; API Proxy is an optional `/api` fallback. */
export const inject = ['httpServer']
@@ -39,10 +59,13 @@ export interface ConnectionConfig {
* that is not a bare, canonical authority fails the plugin load.
*/
trustedHosts?: string[]
/** Maximum buffered JSON body for every `/api` request. */
maxRequestBodyBytes?: number
}
export const Config: z<ConnectionConfig> = z.object({
trustedHosts: z.array(String).default([]),
maxRequestBodyBytes: z.natural().min(1).default(DEFAULT_MAX_REQUEST_BODY_BYTES),
})
/**
@@ -109,9 +132,11 @@ const PRIVILEGED_METHODS = new Set([
export function apply(ctx: Context, config?: ConnectionConfig): void {
// The Loader resolves schema defaults; hand-built test contexts may pass none.
const trustedHosts = config?.trustedHosts ?? []
const maxRequestBodyBytes = config?.maxRequestBodyBytes ?? DEFAULT_MAX_REQUEST_BODY_BYTES
// Config boundary: a malformed entry fails the load loudly here rather than
// silently authorizing its hostname prefix at request time.
for (const entry of trustedHosts) assertTrustedAuthority(entry)
if (ctx.get('apiProxy') !== undefined) assertImageBodyCapacity(ctx, maxRequestBodyBytes)
const connection = new HostConnectionService(ctx, trustedHosts)
const fetchHandler = connection.createSharedFetchHandler(API_PATH, {
async fetch(request) {
@@ -144,11 +169,12 @@ export function apply(ctx: Context, config?: ConnectionConfig): void {
res.end('forbidden')
return
}
await bridge(req, res, fetchHandler)
await bridge(req, res, fetchHandler, maxRequestBodyBytes)
},
}
ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route')
ctx.inject(['apiProxy'], (apiCtx) => {
assertImageBodyCapacity(apiCtx, maxRequestBodyBytes)
const downlinks = new WebSocketDownlinks(apiCtx.apiProxy)
const registerDownlink = (
path: string,

View File

@@ -83,6 +83,35 @@ describe('connection lifecycle', () => {
}
})
it('treats a host.describe business error as generation failure', async () => {
const api = new FakeApiClient()
let describeCalls = 0
api.onDescribe = () => {
describeCalls += 1
if (describeCalls === 1) {
return Promise.resolve({
rpcId: 'bad-describe' as never,
result: {
ok: false as const,
error: { code: 'internal' as const, message: 'not ready', details: {} },
},
})
}
return Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0 }))
}
let connected = 0
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST)
controller.start()
try {
await vi.waitFor(() => { expect(describeCalls).toBe(2) })
await vi.waitFor(() => { expect(connected).toBe(1) })
} finally {
controller.stop()
warnSpy.mockRestore()
}
})
it('converges stream/error frames into reconnect instead of dispatching them', async () => {
const api = new FakeApiClient()
const muxSeen: string[] = []

View File

@@ -67,6 +67,8 @@ export class FakeApiClient implements IApiClient {
=> Promise<RpcResponse<{ selected: ModelSelection }>> =
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onAttachment: (payload: unknown) => Promise<RpcResponse<{ attachment: { attachmentId: never; mediaType: 'image/png'; bytes: number; width: number; height: number }; data: string }>> =
() => Promise.resolve(ok({ attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==' }))
onUpdateQueue: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
@@ -110,6 +112,7 @@ export class FakeApiClient implements IApiClient {
rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
fork: (payload: unknown) => this.record('session.fork', payload, this.onFork(payload)),
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
attachment: (payload: unknown) => this.record('session.attachment', payload, this.onAttachment(payload)),
updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)),
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
}

View File

@@ -5,6 +5,34 @@ import { describe, expect, it } from 'vitest'
import { bridge } from '../src/http-bridge.ts'
describe('HTTP bridge abort', () => {
it('destroys a declared-oversize request instead of draining it', async () => {
const destroyed: true[] = []
const request = Readable.from([]) as unknown as IncomingMessage
Object.assign(request, {
url: '/api/session.prompt',
method: 'POST',
headers: { 'content-type': 'application/json', 'content-length': '999999' },
destroy: () => { destroyed.push(true) },
})
let status: number | undefined
let headers: unknown
const response = Object.assign(new EventEmitter(), {
writableEnded: false,
writeHead(code: number, values?: unknown) { status = code; headers = values; return this },
write() { return true },
end(this: { writableEnded: boolean }) { this.writableEnded = true; return this },
}) as unknown as ServerResponse
await bridge(request, response, {
fetch: () => { throw new Error('a rejected request must never reach the handler') },
}, 1000)
// The socket must not stay parked draining a body the client can trickle
// at will after the rejection — same discipline as the chunked overrun.
expect(status).toBe(413)
expect(headers).toMatchObject({ connection: 'close' })
expect(destroyed).toHaveLength(1)
})
it('aborts a pending native picker request when the browser disconnects', async () => {
const body = JSON.stringify({
type: 'client-request', rpcId: 'picker-1', method: 'host.pickDirectory', payload: {},
@@ -38,7 +66,7 @@ describe('HTTP bridge abort', () => {
}
return Response.json({ aborted: fetchRequest.signal.aborted })
},
})
}, Number.MAX_SAFE_INTEGER)
await started
response.emit('close')
await pending

View File

@@ -7,6 +7,7 @@ import { describe, expect, it } from 'vitest'
import type { AddressInfo } from 'node:net'
import type { IncomingMessage, ServerResponse } from 'node:http'
import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { AttachmentStore } from '@deepseek-ai/dsh-attachment'
import { RpcId, type ClientRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { HttpServerService, WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
import { API_PATH, apply, HOST_EVENTS_PATH, inject, MUX_EVENTS_PATH, type HostConnectionHandle } from '../src/index.ts'
@@ -89,6 +90,19 @@ async function mounted(config?: { trustedHosts?: string[] }): Promise<{
}
describe('connection node half', () => {
it('fails loud when the carrier cap cannot hold the configured image batch', () => {
const ctx = new Context()
const routes: WebRoute[] = []
ctx.provide('httpServer', fakeHttpServer(routes, []) as HttpServerService)
ctx.provide('attachments', {
imageLimits: { maxMessageImageBytes: 20 * 1024 * 1024 },
} as AttachmentStore)
ctx.provide('apiProxy', {} as ApiProxy)
expect(() => { apply(ctx, { maxRequestBodyBytes: 1024 }) })
.toThrow(/must be at least .* aggregate image limit/)
expect(routes).toHaveLength(0)
})
it('fails the load on a trustedHosts entry that is not a bare authority', async () => {
const routes: WebRoute[] = []
const upgrades: WebUpgradeRoute[] = []

View File

@@ -9,6 +9,9 @@
"src"
],
"references": [
{
"path": "../../attachment/attachment"
},
{
"path": "../../llm/llm"
},

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
README.md: 0a7d9975093da558af623ee9940f4be398526821
README.zh.md: 41388e4ba564baa61cfdaaacb74f4f5ea053d41a
README.md: 753d1de796ba8ff20217d423555710429e9b7a75
README.zh.md: 9b5b8ba7ce42875afd4b9b83b9c2f64e95298ca5

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers, loading the current tail first and prepending one older page only when its consumer requests it. Each history snapshot exposes the raw window's absolute base sequence so a consumer detects a prepend even when the page adds no surface-visible node. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions.
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers, loading the current tail first and prepending one older page only when its consumer requests it. Each history snapshot exposes the raw window's absolute base sequence so a consumer detects a prepend even when the page adds no surface-visible node. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `session/preset-changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. `host/session-preset-changed` also folds its preset into the session row, because the switch's RPC echo reaches only the client that issued it. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions.
## Slot declaration injection

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本,先加载当前尾部,并仅在消费方请求时向前补入一页更早历史。每份历史快照都会公开原始窗口的绝对基准序号,因此即使该页没有新增任何 surface 可见节点消费方仍能检测到向前补页。WorkspacesService 依赖 SessionsService拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed``settings/changed``credentials/changed``models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent智能体和 cwd客户端不持有任何实体化之前的会话状态——agent scopehost dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf``useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本,先加载当前尾部,并仅在消费方请求时向前补入一页更早历史。每份历史快照都会公开原始窗口的绝对基准序号,因此即使该页没有新增任何 surface 可见节点消费方仍能检测到向前补页。WorkspacesService 依赖 SessionsService拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed``session/preset-changed``settings/changed``credentials/changed``models/changed`),使各表面缓存无需触碰流即可重拉。`host/session-preset-changed` 还会把其中的 preset 折进会话行,因为这次切换的 RPC 回执只会到达发起它的那个客户端。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent智能体和 cwd客户端不持有任何实体化之前的会话状态——agent scopehost dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf``useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
## Slot 声明注入

View File

@@ -33,6 +33,7 @@
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-compact": "workspace:^",

View File

@@ -7,9 +7,9 @@
* must stub); runtime-internal entry points (history staging, wire-frame
* dispatch) stay on the class, invisible out here.
*/
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type {
MessageId, QueueAction, RpcResult, SessionId,
MessageId, PromptContentPart, QueueAction, RpcResult, SessionId,
} from '@deepseek-ai/dsh-client-connection/client'
import type { ConversationSnapshot } from '../sessions/conversation.ts'
import type { ObservableSnapshot } from './store.ts'
@@ -33,11 +33,19 @@ export interface ISession {
readonly projections: ProjectionsFace
/**
* Send a prompt into the session.
* @param content - model-facing content blocks.
* @param content - text plus browser-owned temporary image uploads.
* @param mode - 'queue' appends a turn; 'steer' interrupts the running one.
* @returns acceptance, or the business error (also mirrored into snapshot.promptError).
*/
prompt(content: ContentBlock[], mode: 'queue' | 'steer'): Promise<RpcResult<{ accepted: true }>>
prompt(content: PromptContentPart[], mode: 'queue' | 'steer'): Promise<RpcResult<{ accepted: true }>>
/**
* Resolve one durable image referenced by this session.
* @param attachmentId - opaque id found in the folded session log.
* @returns the authenticated reference and decoded bytes.
*/
readAttachment(
attachmentId: AttachmentIdType,
): Promise<RpcResult<{ attachment: ImageAttachmentRef; data: Uint8Array }>>
/**
* Apply one edit, remove, or strict steer action to a still-pending queue occurrence.
* @param itemId - agent-owned inbox occurrence identity.

Some files were not shown because too many files have changed in this diff Show More