diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml index bca3fb39ad..2efe235e28 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md -2026-07-30-client-locale-full-rollout.md: a357f20734d1aa8df60efbf28fd5b8a1a814d63e -2026-07-30-client-locale-full-rollout.zh.md: d22b743f0597405e7f42374ec2caed5523e85595 +2026-07-30-client-locale-full-rollout.md: 09baf5876029295f7a80b6a0fe6a6395d98f406c +2026-07-30-client-locale-full-rollout.zh.md: 806916aea15a21fd24fdfc4654976b3c4577a675 diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md index a357f20734..09baf58760 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md @@ -14,7 +14,7 @@ After the typed locale standard seat landed (`locale:` on register → framework **Component copy rides the standard `t` seat; deep children take `t` as a plain prop** typed `XxxProps['t']`. The dictionary canon is unchanged: `zh satisfies Record` is the key source and `en satisfies Record` locks bilingual balance. -**Zero-cordis atoms (ui-primitives) take copy as props**: `labels` on `TerminalBlock`/`JsonTree`, `copyLabel`/`copiedLabel` on `CodeBlock`, `codeLabels` on `MarkdownText`, `truncatedLabel` on `JsonBlock`, `label` on `ConnectionBanner`, `closeLabel` on `Modal` — defaults are the previous hardcoded strings, so a consumer passing nothing renders byte-identical output. Localized plugins pass dictionary-driven labels from their own `t` seat; call sites passing object props memoize them on the `t` identity (`MarkdownText` caches its component table on the `codeLabels` identity). +**Zero-cordis atoms (ui-primitives) take copy as props**: `copyLabel`/`copiedLabel` on `HoverCard`, `labels` on `TerminalBlock`/`JsonTree`, `copyLabel`/`copiedLabel` on `CodeBlock`, `codeLabels` on `MarkdownText`, `truncatedLabel` on `JsonBlock`, `label` on `ConnectionBanner`, `closeLabel` on `Modal` — defaults are the previous hardcoded strings, so a consumer passing nothing renders byte-identical output. Localized plugins pass dictionary-driven labels from their own `t` seat; call sites passing object props memoize them on the `t` identity (`MarkdownText` caches its component table on the `codeLabels` identity). **The non-translation boundary (deliberate decisions, not debt):** diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md index d22b743f05..806916aea1 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md @@ -14,7 +14,7 @@ typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t` **组件文案走标准 `t` 席位;深层子组件用 prop 下传**,类型写 `XxxProps['t']`。字典规范形态不变:`zh satisfies Record` 为 key 源、`en satisfies Record` 锁双语平衡。 -**zero-cordis 原子组件(ui-primitives)文案 props 化**:`TerminalBlock`/`JsonTree` 的 `labels`、`CodeBlock` 的 `copyLabel`/`copiedLabel`、`MarkdownText` 的 `codeLabels`、`JsonBlock` 的 `truncatedLabel`、`ConnectionBanner` 的 `label`、`Modal` 的 `closeLabel`——默认值即原硬编码字符串,不传 props 的消费者渲染逐字节不变。已本地化的插件从自己的 `t` 席位传字典驱动的 label;传对象 props 的调用点按 `t` 身份 memo(`MarkdownText` 的组件表按 `codeLabels` 身份缓存)。 +**zero-cordis 原子组件(ui-primitives)文案 props 化**:`HoverCard` 的 `copyLabel`/`copiedLabel`、`TerminalBlock`/`JsonTree` 的 `labels`、`CodeBlock` 的 `copyLabel`/`copiedLabel`、`MarkdownText` 的 `codeLabels`、`JsonBlock` 的 `truncatedLabel`、`ConnectionBanner` 的 `label`、`Modal` 的 `closeLabel`——默认值即原硬编码字符串,不传 props 的消费者渲染逐字节不变。已本地化的插件从自己的 `t` 席位传字典驱动的 label;传对象 props 的调用点按 `t` 身份 memo(`MarkdownText` 的组件表按 `codeLabels` 身份缓存)。 **不翻译边界(刻意决定,不是欠账):** diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.i18n.yaml new file mode 100644 index 0000000000..ed2f7646bc --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.md +2026-07-30-hover-popup-pointer-grace.md: 3f60c98ec6453b633feebe408cbc0c0c49eedea1 +2026-07-30-hover-popup-pointer-grace.zh.md: db10e156103284383f911684b2c92977a0315275 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.md b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.md new file mode 100644 index 0000000000..3f60c98ec6 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.md @@ -0,0 +1,35 @@ +# Agent Note: Hover popup pointer grace + +Status: implemented + +English | [中文](2026-07-30-hover-popup-pointer-grace.zh.md) + +## Problem + +Both popups the workspace browser rows raise floated out of reach of the pointer. `HoverCard` closed on the first `pointerleave` from its anchor and rendered its card `pointer-events: none`, but the card sits 8px off the anchor's right edge, so every path to it crossed ground belonging to neither and killed the card before it arrived — the full workspace path and session title it exists to show could be read only in passing. The row action menus passed `closeOnPointerLeave`, whose handler sat on the portaled list: aiming back at the `...` trigger that opened the list closed it, and so did any overshoot past a list edge, with no window to come back. + +## Decision + +`usePointerGrace` ([packages/client/ui-primitives/src/pointer-grace.ts](../../../../packages/client/ui-primitives/src/pointer-grace.ts)) owns one cancelable delayed close, shared by both atoms, with `POINTER_GRACE_MS` at 200. Leaving arms the close; coming back cancels it. Transit through an anchor-to-popup gap is therefore survivable, while a pointer that has genuinely moved on still dismisses the popup. + +`HoverCard` arms the grace on leave instead of closing, and its card no longer sets `pointer-events: none`, so resting on the card holds it open. Re-entering while already open cancels the pending close without restarting the dwell, which keeps the card from blinking when the pointer crosses the gap. A press on the card starts a selection instead of dismissing it; only anchor-region presses and an owner flipping `disabled` dismiss immediately, ahead of the grace. + +`Menu` moves pointer-leave dismissal from the portaled list to the wrapper span. React's enter/leave traversal runs over the React tree, so the trigger and the portaled list are one region there: crossing the 4px gap between them, or aiming back at the trigger, no longer counts as leaving. Leaving is only armed while the list is open, and an owner-driven close (selection, Escape, outside click) disarms a pending grace close in an effect keyed on `open` alone — folding that into the outside-click effect would cancel the grace on every re-render, since owners pass a fresh `onClose` closure each time. + +## Alternatives considered + +**Close the popups only on outside click and Escape.** Rejected because both popups are hover-raised and unlabeled as dismissible; leaving them up after the pointer has moved to another row would strand a card over unrelated content. + +**Widen the anchor's hit area to abut the popup.** Rejected because the 8px and 4px offsets are the design's, and an invisible bridge element would have to track every reposition the fixed-positioned popups already do on scroll and resize. + +**Keep the hover card `pointer-events: none` and only add the grace.** Rejected because the pointer resting on the card would then hit whatever is behind it, so the grace would expire and close the card the user had just reached. + +**Give each atom its own timer.** Rejected because the two closes are the same behavior with the same tuning; a shared hook keeps them from drifting apart. + +## Consequences + +The hover card is now hit-testable and covers 244px of whatever it overlays while shown, which is the price of being reachable; it still lives only as long as the pointer is on the row or the card. Row menus survive the round trip between trigger and list, and a menu that closes for its own reason cannot be reopened into a stale pending close. Menus without `closeOnPointerLeave` are untouched — the wrapper handlers are only attached when it is set. + +## Testing + +`packages/client/ui-primitives/tests/hover-card.spec.tsx` and `tests/atoms.spec.tsx` pin the grace boundary, cancel-on-return, no-second-dwell, disarm-on-owner-close, and the no-arming-while-closed case. The reachability gestures themselves — hovering onto the card, and moving between an open list and its trigger — are pinned in the real browser by `apps/web/tests/workspace-management.e2e.ts`, since they depend on hit testing and layout that jsdom does not model. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.zh.md new file mode 100644 index 0000000000..db10e15610 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 悬浮弹层的指针宽限期 + +Status: implemented + +[English](2026-07-30-hover-popup-pointer-grace.md) | 中文 + +## 问题 + +工作区浏览器行弹出的两种弹层都处于指针无法抵达的位置。`HoverCard` 在指针离开锚点的第一个 `pointerleave` 上就关闭,其卡片还设置了 `pointer-events: none`;但卡片位于锚点右边缘外 8px 处,因此通往卡片的每条路径都要穿过既不属于锚点也不属于卡片的区域,卡片在指针抵达之前就已被销毁——它本应展示的完整工作区路径和会话标题只能匆匆一瞥。行操作菜单传入了 `closeOnPointerLeave`,而其处理器挂在传送后的列表上:把指针移回打开该列表的 `...` 触发按钮会关闭列表,越过列表边缘的任何一次抖动同样如此,且没有任何折返窗口。 + +## 决策 + +`usePointerGrace`([packages/client/ui-primitives/src/pointer-grace.ts](../../../../packages/client/ui-primitives/src/pointer-grace.ts))持有唯一一个可取消的延迟关闭,由两个原子组件共享,`POINTER_GRACE_MS` 为 200。离开会启动关闭,折返则取消它。因此指针可以安全穿越锚点与弹层之间的间隙,而真正移开的指针仍会关闭弹层。 + +`HoverCard` 在离开时启动宽限期而不再立即关闭,其卡片也不再设置 `pointer-events: none`,因此指针停在卡片上即可让它保持打开。在已打开状态下重新进入只取消待执行的关闭,而不重启停留计时,从而避免指针穿越间隙时卡片闪烁。在卡片上按下指针用于开始文本选择,不会关闭卡片;只有锚点区域内的按下和所有者将 `disabled` 置真,才会抢在宽限期之前立即关闭卡片。 + +`Menu` 把指针离开关闭的处理从传送后的列表移到包裹 span 上。React 的 enter/leave 遍历基于 React 树进行,因此触发按钮与传送后的列表在这里属于同一区域:穿越两者之间 4px 的间隙、或把指针移回触发按钮,都不再算作离开。只有在列表打开时才会启动离开关闭;由所有者驱动的关闭(选择、Escape、外部点击)会在一个仅以 `open` 为依赖的 effect 中解除待执行的宽限关闭——若把它折叠进外部点击的 effect,则每次重新渲染都会取消宽限期,因为所有者每次都传入新的 `onClose` 闭包。 + +## 考虑过的替代方案 + +**仅通过外部点击和 Escape 关闭这两种弹层。** 之所以否决:两者都由悬停唤起,且没有可见的关闭标识;在指针已移到其他行之后仍让它们停留,会把卡片遗留在无关内容之上。 + +**扩大锚点的命中区域,使其与弹层相接。** 之所以否决:8px 与 4px 的偏移来自设计稿,而一个不可见的桥接元素还必须跟随这两个固定定位弹层已经在滚动和缩放时执行的每一次重新定位。 + +**保留悬浮卡片的 `pointer-events: none`,只加入宽限期。** 之所以否决:那样指针停在卡片上时命中的是卡片背后的元素,宽限期仍会到期,并关闭用户刚刚够到的卡片。 + +**让两个原子组件各自持有计时器。** 之所以否决:这两处关闭是同一种行为、同一套调参;共享 hook 可以防止它们各自漂移。 + +## 后果 + +悬浮卡片现在可被命中,显示期间会遮挡其覆盖区域的 244px——这是可抵达性的代价;它依然只在指针位于行或卡片上时存在。行菜单现在能承受触发按钮与列表之间的往返,而因自身原因关闭的菜单也不会被残留的待执行关闭重新关掉。未设置 `closeOnPointerLeave` 的菜单不受影响——只有设置该属性时才会挂上包裹层处理器。 + +## 测试 + +`packages/client/ui-primitives/tests/hover-card.spec.tsx` 与 `tests/atoms.spec.tsx` 固定验证宽限期边界、折返取消、不重启停留计时、所有者关闭时解除待执行关闭,以及列表关闭时不启动关闭。可抵达性手势本身——把指针移到卡片上,以及在打开的列表与其触发按钮之间移动——由 `apps/web/tests/workspace-management.e2e.ts` 在真实浏览器中固定验证,因为它们依赖 jsdom 无法建模的命中测试与布局。 diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml index a16f07a63c..5fc6eacf97 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md -2026-07-29-persistent-bash-str-replace-editor.md: a97af750bdd80ddf38dc2d126e70c245ed035f35 -2026-07-29-persistent-bash-str-replace-editor.zh.md: 0c2ab26693d90c91d5c41a128ebb77a3c6cc2e7f +2026-07-29-persistent-bash-str-replace-editor.md: 22851078c1cc8fa9d5716afa41c8a2e2b7e7725c +2026-07-29-persistent-bash-str-replace-editor.zh.md: cf4d18f26d11380a637d573e8ea98cb3ccfcdb59 diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md index a97af750bd..22851078c1 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md @@ -18,6 +18,8 @@ Some deployments need a one-call Bash schema whose shell state survives across m Both plugins are included in the Python runtime closure. The persistent Bash closure also includes the PTY service/local backend and the sandbox services required by that backend. Because `node-pty` executes a native `spawn-helper` on macOS, each packaged macOS runtime executable ships with a `-spawn-helper` sibling; Linux uses `forkpty` directly. A pinned `node-pty` patch checks `DSH_NODE_PTY_SPAWN_HELPER` first, so it remains a true override for a current external consumer that supplies a non-sibling helper. When the override is unset, the patch resolves the packaged executable sibling if present and otherwise preserves upstream lookup in ordinary Node runs. The macOS builders fail before publication when the helper is absent or not executable. +The shipped [`core-web.cordis.yml`](../../../../apps/cli/config/core-web.cordis.yml) overlay composes both plugins over the ordinary Web surface, disables its other model-facing consumers, and leaves the Web host, browser, Workspace, persistence, sandbox, and permission stack in place. The local PTY backend resolves the effective session sandbox mode when it creates the shell. While that owner has an open shell or a spawn in progress, a different permission mode is rejected before its session event commits; the editor continues through the Web filesystem sandbox. + ## Alternatives considered **One combined compatibility plugin.** Rejected because neither tool requires the other and the combined name would tie reusable capabilities to one benchmark. @@ -30,4 +32,4 @@ Both plugins are included in the Python runtime closure. The persistent Bash clo ## Consequences -Profiles can reproduce an external agent by configuring persona and descriptions while the underlying packages remain general. Persistent Bash requires an owning Agent and real PTY backend. Shell exit, timeout, or cancellation loses state. The editor delegates security and mutation policy to the mounted filesystem stack. Runtime-wheel consumers still need no Node installation; Linux wheels contain one executable, while macOS wheels also contain its private native helper. +Profiles can reproduce an external agent by configuring persona and descriptions while the underlying packages remain general. Persistent Bash requires an owning Agent and real PTY backend. Shell exit, timeout, or cancellation loses state. The editor delegates security and mutation policy to the mounted filesystem stack. The Core Web profile retains Web permissions but must close its persistent shell before changing modes. Runtime-wheel consumers still need no Node installation; Linux wheels contain one executable, while macOS wheels also contain its private native helper. diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md index 0c2ab26693..cf4d18f26d 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md @@ -18,6 +18,8 @@ 两个插件都进入 Python runtime 闭包。持久 Bash 的闭包还包含 PTY 服务/本地后端,以及该后端要求的沙箱服务。由于 `node-pty` 在 macOS 上会执行原生 `spawn-helper`,每个打包后的 macOS 运行时可执行文件都会携带一个 `-spawn-helper` 伴随文件;Linux 直接使用 `forkpty`。固定版本的 `node-pty` 补丁会先检查 `DSH_NODE_PTY_SPAWN_HELPER`,因此对当前提供非伴随 helper 的外部消费方而言,该变量仍是真正的覆盖项。未设置该覆盖时,补丁会在打包可执行文件的伴随文件存在时解析它,否则在普通 Node 运行中保留上游查找方式。若 helper 缺失或不可执行,macOS 构建器会在发布前失败。 +已交付的 [`core-web.cordis.yml`](../../../../apps/cli/config/core-web.cordis.yml) 覆盖层在常规 Web 界面之上组合这两个插件,禁用该界面的其他面向模型的消费方,并保留 Web 宿主、浏览器、Workspace、持久化、沙箱与权限栈。本地 PTY 后端会在创建 shell 时解析会话的有效沙箱模式。只要该所有者仍有打开的 shell 或仍在进行中的 spawn,另一种权限模式就会在对应的会话事件提交前遭到拒绝;编辑器则继续经由 Web 文件系统沙箱运行。 + ## 考虑过的替代方案 **单一组合兼容插件。** 被拒绝,因为两个工具互不依赖,组合命名还会把可复用能力绑定到某个基准。 @@ -30,4 +32,4 @@ ## 后果 -Profile 可以通过配置 persona 和描述复现外部 Agent,而底层包保持通用。持久 Bash 需要拥有它的 Agent 与真实 PTY 后端;shell 退出、超时或取消会丢失状态。编辑器把安全与变更策略委托给挂载的文件系统栈。运行时 wheel 包的消费方仍无需安装 Node;Linux wheel 包包含一个可执行文件,macOS wheel 包还包含其私有原生 helper。 +Profile 可以通过配置 persona 和描述复现外部 Agent,而底层包保持通用。持久 Bash 需要拥有它的 Agent 与真实 PTY 后端;shell 退出、超时或取消会丢失状态。编辑器把安全与变更策略委托给挂载的文件系统栈。Core Web profile 保留 Web 权限,但必须先关闭持久 shell 才能更改权限模式。运行时 wheel 包的消费方仍无需安装 Node;Linux wheel 包包含一个可执行文件,macOS wheel 包还包含其私有原生 helper。 diff --git a/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.i18n.yaml new file mode 100644 index 0000000000..d4f7f72fe2 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md +2026-07-31-hover-card-click-copy.md: c87734fe328fa2adb396d6685495faa82bc1fff2 +2026-07-31-hover-card-click-copy.zh.md: a57b5238b095de293605d4e309dcc2da3516e904 diff --git a/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md b/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md new file mode 100644 index 0000000000..c87734fe32 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md @@ -0,0 +1,29 @@ +# Agent Note: Hover cards copy their primary value on activation + +Status: implemented + +English | [中文](2026-07-31-hover-card-click-copy.zh.md) + +## Problem + +Workspace and Session rows clip the two values their hover cards expose in full: the Workspace directory path and Session title. The [reachable card](../bug-fix/2026-07-30-hover-popup-pointer-grace.md) permits text selection, but selecting and copying a single known value is a needlessly precise gesture, and the card gives no confirmation that the clipboard accepted it. + +## Decision + +`HoverCard` accepts an optional `copyText` plus `copyLabel` and `copiedLabel`. With `copyText`, the whole card has button semantics for pointer and keyboard activation; its accessible name combines the localized action prefix with the exact value, it writes that value through the shared clipboard helper, and it replaces its content with the success label for up to one second only after the host accepts the write. The feedback retains the pre-copy card height and clears with the card. Without `copyText`, the atom retains its read/select-only behavior. + +The Workspace browser chooses the payload rather than making the primitive infer it from rendered text: a Workspace card passes the full directory path, and a non-blank Session card passes the full display title. A provisional blank New Session card remains read-only because its localized label is a placeholder, not session content. The browser's locale seat supplies `Copy`/`复制` and the success state `Copied`/`已复制`. + +Press and activation remain separate contracts. A pointer press inside the card keeps it mounted so text selection can begin; a completed non-collapsed selection intersecting the card suppresses pointer-click activation, while a plain click or button key activates copy. Anchor-region presses still dismiss immediately, and clipboard rejection leaves the original content visible without claiming success. + +## Alternatives considered + +**Copy the card's rendered `textContent`.** That would concatenate the primary value with creation time or running status, making the clipboard payload depend on presentation and localization. + +**Implement clipboard state in both Workspace card bodies.** The two consumers would duplicate host fallback, keyboard behavior, timer ownership, and success rendering even though the card owns the activation surface. + +**Change the common Chinese `copied` label from `复制成功` to `已复制`.** That would alter every existing copy control to satisfy one card interaction. The Workspace dictionary owns the card-specific wording instead. + +## Consequences + +Both non-placeholder hover-card variants gain the same click and keyboard affordance while retaining consumer-owned payload semantics and localized feedback. The generic atom adds one optional behavior path and a one-second timer; it clears copied state on close, ignores completion after close or unmount, and never reports a rejected write as success. Focused component coverage pins pointer selection precedence, activation, failure, feedback geometry and expiry, and cleanup, while the real-browser Workspace scenario verifies the English label, stable feedback height, and browser clipboard. diff --git a/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.zh.md b/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.zh.md new file mode 100644 index 0000000000..a57b5238b0 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.zh.md @@ -0,0 +1,29 @@ +# Agent Note(agent 决策记录):悬浮卡片激活时复制主要值 + +Status: implemented + +[English](2026-07-31-hover-card-click-copy.md) | 中文 + +## 问题 + +Workspace 与 Session 行会截断对应悬浮卡片完整展示的两个值:Workspace 目录路径和 Session 标题。这张[可抵达的卡片](../bug-fix/2026-07-30-hover-popup-pointer-grace.md)支持文本选择,但复制单个已知值仍需精确选择,操作没有必要地繁琐;卡片也不会确认剪贴板是否接受了写入。 + +## 决策 + +`HoverCard` 接收可选的 `copyText`,以及 `copyLabel` 和 `copiedLabel`。传入 `copyText` 后,整个卡片都会为指针与键盘激活提供按钮语义;其无障碍名称由本地化操作前缀和原值组成,卡片通过共享剪贴板辅助函数写入该值,并且只有宿主接受写入后,才会用成功标签替换内容最长一秒。反馈保持复制前的卡片高度,并会随卡片关闭一同清除。未传入 `copyText` 时,该原子组件维持只读且可选择文本的行为。 + +Workspace 浏览器选择复制载荷,不让基础组件从渲染文本中推断:Workspace 卡片传入完整目录路径,非空白 Session 卡片传入完整显示标题。临时的空白「新会话」卡片保持只读,因为其本地化标签是占位文案,并非会话内容。浏览器的 locale 席位提供 `Copy`/`复制`,成功状态则使用 `Copied`/`已复制`。 + +按下与激活仍是两份独立契约。卡片内发生指针按下时,卡片保持挂载,以便用户开始选择文本;文本选择完成后,若非折叠选区与卡片相交,就会阻止指针点击激活,而普通点击或按钮激活键会激活复制。锚点区域内发生指针按下时,卡片仍会立即消失;剪贴板拒绝写入时,卡片继续显示原内容,不会声称复制成功。 + +## 备选方案 + +**复制卡片渲染后的 `textContent`。** 这会把主要值与创建时间或运行状态拼接起来,使剪贴板载荷依赖表现形式和本地化结果。 + +**在两个 Workspace 卡片主体中分别实现剪贴板状态。** 两个消费方会重复实现宿主回退、键盘行为、计时器所有权和成功状态渲染,尽管激活表层由卡片持有。 + +**将通用中文 `copied` 标签从 `复制成功` 改为 `已复制`。** 这样会为了满足一种卡片交互而改变所有现有复制控件。卡片专用文案应由 Workspace 字典持有。 + +## 后果 + +两种非占位悬浮卡片都获得相同的点击与键盘操作能力,同时保留由消费方决定载荷的语义和本地化反馈。通用原子组件增加一条可选行为路径和一个一秒计时器;卡片关闭时会清除已复制状态,关闭或卸载后到达的完成结果会被忽略,写入被拒绝时绝不会报告成功。聚焦组件测试会固定指针选择文本的优先级、激活、失败、反馈尺寸与到期清除以及清理行为,真实浏览器中的 Workspace 场景则验证英文标签、反馈期间高度稳定和浏览器剪贴板。 diff --git a/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.i18n.yaml new file mode 100644 index 0000000000..0eddfa1740 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.md +2026-07-31-third-party-memory-mcp-examples.md: e82d65a3a5a60cafcc47df5a6873cf5768cd8b8f +2026-07-31-third-party-memory-mcp-examples.zh.md: ee0e9f2e1378f9787e09e32cc05dd13a3648254c diff --git a/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.md b/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.md new file mode 100644 index 0000000000..e82d65a3a5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.md @@ -0,0 +1,78 @@ +# Agent Note: Third-party memory MCP examples + +Status: implemented + +English | [中文](2026-07-31-third-party-memory-mcp-examples.zh.md) + +## Problem + +A direct vendor integration made one provider's API, configuration, health behavior, and tool semantics part of DSH. That was too much product surface for a capability already expressible through MCP, and it would require repeating the same adaptation for every memory system. Users instead need a small, inspectable way to opt into one external memory server while preserving the generic MCP boundary. + +The acceptance bar is stronger than "the socket connects": each reference must support a real DSH write in session A, recall from the provider in a fresh DSH session B, and use of the recalled value. At the same time, provider downloads, accounts, models, embeddings, storage initialization, and separate HTTP processes must remain upstream responsibilities. + +## Decision + +Ship three default-off Cordis overlay examples under `examples/mcp-memory`: Memorix, MCP Reference Memory, and Engram. Every file inserts exactly one `@deepseek-ai/dsh-mcp-client` row. None is referenced by the shipped composition, and the CLI declares the generic bridge only so an explicitly selected overlay can resolve it. + +These third-party configurations are provided as interoperability examples only. Their inclusion does not imply endorsement, recommendation, partnership, or ongoing support by DeepSeek. There is no memory preset registry, vendor-specific DSH plugin, universal memory service, installation UI, migration layer, health checker, or reconnect controller. Another memory MCP server uses the same documented stdio or Streamable HTTP row. + +## Responsibility boundary + +| Concern | DSH | Upstream provider or user | +|---|---|---| +| Parse selected overlay | Yes | Select one file | +| Start stdio command and stop it on plugin disposal | Yes | Install the pinned executable | +| Connect to Streamable HTTP and discover tools | Yes | Run and supervise the HTTP service | +| Register tools as `mcp____` | Yes | Define tool schemas and behavior | +| Account, auth, model, embedding, storage initialization | No | Yes | +| Vendor data migration, retry, crash recovery | No | Yes | + +The generic stdio transport scrubs ambient credential-shaped and `DSH_*` variables while inheriting other ambient variables. Baseline examples add only required overrides; optional provider secrets must be added to `config.env` or configured in the provider's own files. + +## Pins, storage, and identity + +| Provider | Tested contract | +|---|---| +| Memorix | npm `1.3.0`, tag commit `500792cad3144142293bfbb20acb4841c9f7fcfa` | +| MCP Reference Memory | npm `2026.7.4`, package commit `6dd0a683e198783e30feabf7abaf42f925bd18b1` | +| Engram | tag `v1.20.0`, commit `ba9e46ced152c37a7cb9e576153c41995873e2fc` | + +Storage remains provider-owned. Memorix uses `~/.memorix/data` and Engram uses `~/.engram` by default. The Reference Memory example sets a stable `$HOME/.dsh-mcp-reference-memory.jsonl` path instead of writing into the installed npm package directory. Each provider's own environment variable can override these locations before DSH starts. + +Project identity remains provider-owned: Memorix and Engram use the DSH working directory's Git project, with Engram optionally accepting `ENGRAM_PROJECT`. + +## Model guidance + +The examples do not patch `@deepseek-ai/dsh-system-prompt`: a config patch replaces a row's complete config and could erase an existing persona. The README instead offers one optional additive instruction: + +> When the user asks you to remember something, call a memory write tool. When historical information may be relevant, search memory and use relevant results. + +Provider tool descriptions remain authoritative. + +## Validation contract + +Remote CI never contacts third-party services or consumes secrets. The keyless suite parses all three overlay files, checks their generic bridge and secret boundary, replaces the upstream endpoint with the package-owned MCP fixture server, boots the real Cordis Loader, and proves tool discovery. + +Before merge, manual evidence for every pinned provider must separately show: + +1. DSH session A calls a write tool and receives success for a unique value. +2. Fresh DSH session B, under the same provider storage scope, calls search or recall and returns that value without session A's transcript. +3. Session B uses the recalled value in a subsequent answer. + +"Fresh session" means a new DSH session in the same Host. No Host restart is required. The generic MCP client discovers asynchronously and has no automatic reconnect after a child or HTTP transport closes; validation waits for tools before the first turn and uses HMR or a Host restart only after a crash. + +## Alternatives considered + +**One DSH plugin per provider.** Rejected because it repeats auth, configuration, lifecycle, and tool wrappers that MCP already standardizes and expands ownership for every added provider. + +**A memory-provider preset registry.** Rejected because a registry would make third-party versions and recommendations look like a supported DSH product surface. Copyable overlays keep ownership and drift visible. + +**Run `npx` or `go run` inside the MCP row.** Rejected after probes showed first-run npm downloads can exceed the MCP initialization timeout and an interrupted `npx` cache can become unusable. DSH starts a server process; it is not the provider package manager. Pinned installation commands are explicit prerequisites. + +**Inject the common instruction from the generic MCP client.** Rejected because the bridge serves non-memory MCP servers too, and generic prompt mutation would reintroduce provider semantics into shared runtime code. + +## Consequences + +Selecting a file gives the model the provider's complete discovered MCP tool surface, with schema/token cost determined by that provider. Removing `--config` removes the memory server. Users accept each upstream license, data policy, cloud cost, and operational model directly. + +The earlier vendor-specific change is superseded by this generic path. Future provider drift is handled by updating and revalidating a small example pin rather than adding runtime branches to DSH. diff --git a/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.zh.md b/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.zh.md new file mode 100644 index 0000000000..ee0e9f2e13 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.zh.md @@ -0,0 +1,78 @@ +# Agent Note: 第三方记忆 MCP 示例 + +Status: implemented + +[English](2026-07-31-third-party-memory-mcp-examples.md) | 中文 + +## 问题 + +直接集成某个提供方会使该提供方的 API、配置、健康状态行为和工具语义成为 DSH 的一部分。对于已经可以通过 MCP 表达的功能,这会让产品接口过于庞大,而且每接入一个记忆系统都需要重复同样的适配工作。用户需要的是一种精简、可检查的方式,在保留通用 MCP 边界的同时,选择启用一个外部记忆服务器。 + +验收标准不止于「套接字可以连接」:每份参考配置都必须支持 DSH 在会话 A 中实际写入,在新的 DSH 会话 B 中从提供方召回,并使用召回的值。与此同时,提供方下载、账户、模型、embedding、存储初始化和独立 HTTP 进程仍由上游负责。 + +## 决策 + +在 `examples/mcp-memory` 下交付三份默认关闭的 Cordis overlay 示例:Memorix、MCP Reference Memory 和 Engram。每个文件只插入一个 `@deepseek-ai/dsh-mcp-client` 配置项。交付组合不会引用这些文件;CLI(命令行界面)仅声明通用桥接器,使用户显式选择 overlay 时可以解析它。 + +这些第三方配置仅作为互操作参考;收录不代表 DeepSeek 的认可、推荐、合作关系或持续支持承诺。系统没有记忆预设注册表、提供方专属 DSH 插件、通用记忆服务、安装 UI、迁移层、健康检查器或重连控制器。其他记忆 MCP 服务器可以使用同一份文档中的 stdio 或 Streamable HTTP 配置项。 + +## 职责边界 + +| 事项 | DSH | 上游提供方或用户 | +|---|---|---| +| 解析选中的 overlay | 是 | 选择一个文件 | +| 启动 stdio 命令,并在插件 dispose(资源释放)时将其停止 | 是 | 安装固定版本的可执行文件 | +| 连接 Streamable HTTP 并发现工具 | 是 | 运行并监管 HTTP 服务 | +| 以 `mcp____` 注册工具 | 是 | 定义工具 schema 和行为 | +| 账户、认证、模型、embedding、存储初始化 | 否 | 是 | +| 提供方数据迁移、重试、崩溃恢复 | 否 | 是 | + +通用 stdio 传输会清除环境中名称类似凭据的变量和 `DSH_*` 变量,同时继承其他环境变量。基线示例仅添加必需的覆盖项;可选的提供方密钥必须添加到 `config.env`,或配置在提供方自己的文件中。 + +## 版本固定、存储与身份 + +| 提供方 | 已测试契约 | +|---|---| +| Memorix | npm `1.3.0`,tag commit `500792cad3144142293bfbb20acb4841c9f7fcfa` | +| MCP Reference Memory | npm `2026.7.4`,package commit `6dd0a683e198783e30feabf7abaf42f925bd18b1` | +| Engram | tag `v1.20.0`,commit `ba9e46ced152c37a7cb9e576153c41995873e2fc` | + +存储仍由提供方负责。Memorix 默认使用 `~/.memorix/data`,Engram 默认使用 `~/.engram`。Reference Memory 示例设置稳定的 `$HOME/.dsh-mcp-reference-memory.jsonl` 路径,而不是写入已安装的 npm 包(package)目录。每个提供方自己的环境变量都可以在 DSH 启动前覆盖这些位置。 + +项目身份仍由提供方负责:Memorix 和 Engram 使用 DSH 工作目录中的 Git 项目,其中 Engram 还可以选择接受 `ENGRAM_PROJECT`。 + +## 模型指导 + +示例不会修改 `@deepseek-ai/dsh-system-prompt`:配置 patch 会替换某个配置项的完整配置,可能抹除已有 persona。README 改为提供一条可选的附加指令: + +> 用户要求记住时调用写入工具;涉及历史信息时,主动检索并使用相关记忆。 + +提供方的工具描述仍然是权威定义。 + +## 验证契约 + +远程 CI 不会访问第三方服务或消耗密钥。无密钥套件解析全部三份 overlay 文件,检查其通用桥接器和密钥边界,将上游端点替换为包自带的 MCP fixture(测试前置数据)服务器,通过真实 Cordis Loader 启动,并验证工具发现。 + +合并前,每个固定版本的提供方都必须分别提供以下人工证据: + +1. DSH 会话 A 调用写入工具,为一个唯一值写入记忆,并收到成功结果。 +2. 新的 DSH 会话 B 在相同的提供方存储范围下调用搜索或召回,不借助会话 A 的 transcript(文本记录)便可返回该值。 +3. 会话 B 在后续回答中使用该召回值。 + +「新会话」是指同一个 Host 中新建的 DSH 会话,不需要重启 Host。通用 MCP 客户端以异步方式发现工具,子进程或 HTTP 传输关闭后不会自动重连;验证会在第一轮之前等待工具出现,并且只在崩溃后使用 HMR 或重启 Host。 + +## 考虑过的替代方案 + +**每个提供方使用一个 DSH 插件。** 不予采纳,因为这会重复 MCP 已经标准化的认证、配置、生命周期和工具包装层,并随着每增加一个提供方而扩大维护范围。 + +**记忆提供方预设注册表。** 不予采纳,因为注册表会让第三方版本和推荐看起来像受支持的 DSH 产品接口。可复制的 overlay 让所有权和版本偏移保持可见。 + +**在 MCP 配置项内运行 `npx` 或 `go run`。** 不予采纳,因为探测表明首次 npm 下载可能超过 MCP 初始化超时,而中断的 `npx` 缓存可能变得不可用。DSH 负责启动服务器进程,不是提供方的包管理器。固定版本的安装命令属于显式前置条件。 + +**由通用 MCP 客户端注入共用指令。** 不予采纳,因为该桥接器也服务于非记忆类 MCP 服务器,而且通用提示词变更会把提供方语义重新带入共享运行时代码。 + +## 后果 + +选择一个文件后,模型可以使用提供方发现到的完整 MCP 工具接口;工具 schema 和 token 成本由提供方决定。移除 `--config` 就会移除记忆服务器。用户直接接受各上游的许可证、数据政策、云服务费用和运维模式。 + +通用方案取代了早期针对特定提供方的改动。未来出现提供方版本偏移时,只需更新并重新验证一份小型示例的固定版本,不必向 DSH 添加运行时分支。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 33fed58119..e7a4161df1 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/README.md -README.md: cf038ad19c631721c7b3182ffe83e75e3837d9ba -README.zh.md: c790f973a9ab0071253ab161bd8bc7835ebb2e02 +README.md: aee08225febe34bb10ee1001c798db9d7daa8905 +README.zh.md: 8f38fd342a0433ca5f7645f896b81aa5392033ae diff --git a/apps/cli/README.md b/apps/cli/README.md index cf038ad19c..aee08225fe 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -27,6 +27,14 @@ The Web/headless composition also registers only `web_search`. Search uses DeepS `DSH_TOOLS_MODE` selects the tool presentation mode for the whole Web/headless process: `native` (the schema default when unset), `code` (the `run_code`-only Code Mode wire), or `both`; any other value fails loud at boot through the `dsh-tools` config schema. It is a TEMPORARY seam — process-wide because Loader composition is static — and is removed once the web UI owns per-session tool-mode selection; the TUI surface ignores it (its config tree pins its own mode). +[`core-web.cordis.yml`](config/core-web.cordis.yml) is an opt-in `dsh web --config` overlay that keeps the shipped Web host, browser, Workspace, persistence, and permission composition while reducing the default native model surface to owner-scoped persistent `bash` and `str_replace_editor`. The PTY backend and editor consume the existing Web sandbox and filesystem providers. An open persistent shell prevents changing that session's permission mode until the shell closes, so a shell created under wider access cannot survive a downgrade. `DSH_TOOLS_MODE` still controls native/Code Mode presentation for the resulting two-tool registry. + +From a source checkout, start this minimal Web profile with: + +```sh +pnpm run dsh web --config apps/cli/config/core-web.cordis.yml +``` + Every `dsh` surface — TUI, Web, and headless — reports session telemetry by default (the row lives in the shared `base.cordis.yml`): every session-log event streams as OTLP/HTTP log records to `https://harness-telemetry.deepseeksvc.com/v1/logs` on a 10-second batch cadence. `DSH_TELEMETRY_OTLP_URL` points the exporter at a different collector; setting `DSH_TELEMETRY_DISABLED` to ANY non-empty value — including `0` or `false` — disables the row before it loads (a privacy switch prefers off-by-mistake over on-by-mistake). No redaction rule is mounted in this composition yet: exported records are the raw captured copy, including message text, tool arguments and results, and the session's working-directory path. The deployment rulings live in the [web-telemetry-default-mount Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md). ## Install (developer machine) diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index c790f973a9..8f38fd342a 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -27,6 +27,14 @@ Web/无头组合还只会注册 `web_search`。搜索使用 DeepSeek 的 Anthr `DSH_TOOLS_MODE` 为整个 Web/无头进程选择工具呈现模式:可选值为 `native`(未设置时的 schema 默认值)、`code`(仅含 `run_code` 的 Code Mode 协议接口)或 `both`;任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seam:Loader 组合是静态的,因此该设置作用于整个进程;待 Web UI 负责逐会话工具模式选择后便会移除。TUI 界面会忽略该变量(其配置树固定了自身模式)。 +[`core-web.cordis.yml`](config/core-web.cordis.yml) 是一个可选启用的 `dsh web --config` 覆盖层:它保留已交付的 Web 宿主、浏览器、Workspace、持久化与权限组合,同时将默认的原生模型界面精简为以所有者为作用域的持久 `bash` 以及 `str_replace_editor`。PTY 后端和编辑器分别消费现有的 Web 沙箱与文件系统提供方。持久 shell 处于打开状态时,会阻止所属会话更改权限模式;因此,在较宽权限下创建的 shell 无法在降权后继续存活。`DSH_TOOLS_MODE` 仍控制由此得到的双工具注册表采用原生/Code Mode 呈现。 + +在源码 checkout 中,用以下命令启动这个精简 Web profile: + +```sh +pnpm run dsh web --config apps/cli/config/core-web.cordis.yml +``` + 每个 `dsh` 界面——TUI、Web 与无头——都默认上报会话遥测(该行位于共享的 `base.cordis.yml`):每条会话日志事件以 OTLP/HTTP 日志记录的形式、按 10 秒批处理节奏流向 `https://harness-telemetry.deepseeksvc.com/v1/logs`。`DSH_TELEMETRY_OTLP_URL` 可将 exporter 指向其他 collector;将 `DSH_TELEMETRY_DISABLED` 设为**任意非空值**——包括 `0` 或 `false`——都会在该行加载前将其关停(隐私开关取「宁可误关、不可误开」)。该组合当前未挂载任何脱敏规则:导出记录即原始捕获副本,包含消息正文、工具参数与结果、以及会话工作目录路径。部署口径见 [web-telemetry-default-mount Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md)。 ## 安装(开发机) diff --git a/apps/cli/config/core-web.cordis.yml b/apps/cli/config/core-web.cordis.yml new file mode 100644 index 0000000000..347a5bfbe6 --- /dev/null +++ b/apps/cli/config/core-web.cordis.yml @@ -0,0 +1,72 @@ +# Opt-in two-tool profile over the shipped Web composition. The default native +# model surface is exactly persistent `bash` plus `str_replace_editor`; the +# Web host, browser shell, workspace, persistence, and permission stack remain. + +# Disable every model-facing consumer in the base/Web tree. plan-mode owns the +# always-registered exit_plan_mode tool even while the session is not planning. +- id: tool-bash + disabled: true + +- id: tool-tasks + disabled: true + +- id: tool-fs + disabled: true + +- id: tool-fs-search + disabled: true + +- id: tool-web + disabled: true + +- id: tool-skill + disabled: true + +- id: plan-mode + disabled: true + +- id: tool-subagent + disabled: true + +- id: tool-subagent-fork + disabled: true + +- id: tool-workflow + disabled: true + +- id: tool-todo + disabled: true + +# The matching browser controls must not offer host tools that this profile +# omits. ui-question's host half owns the ask_user_question registration. +- id: ui-plan + disabled: true + +- id: ui-question + disabled: true + +- insert: + - id: pty + name: '@deepseek-ai/dsh-pty' + + # This backend consumes the existing Web sandbox and permission policy. + # It loads only on Linux/macOS; Windows and other platforms fail at boot. + # Its 300s send wait matches the persistent Bash command timeout instead of + # pty-local's 30s default. An open persistent shell fences permission-mode + # changes until it closes. + - id: pty-local + name: '@deepseek-ai/dsh-pty-local' + config: + timeoutMs: 300000 + + - id: persistent-bash + name: '@deepseek-ai/dsh-tool-bash-persistent' + config: + timeoutMs: 300000 + + # The editor consumes the Web fs-sandbox provider and therefore retains + # the selected session permission mode. + - id: str-replace-editor + name: '@deepseek-ai/dsh-tool-str-replace-editor' + config: + maxOutputChars: 16000 diff --git a/apps/cli/package.json b/apps/cli/package.json index 5556411e15..997cea8e5f 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -68,9 +68,12 @@ "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", + "@deepseek-ai/dsh-mcp-client": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-permission": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", + "@deepseek-ai/dsh-pty": "workspace:^", + "@deepseek-ai/dsh-pty-local": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", @@ -103,12 +106,14 @@ "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", + "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tool-fs-search": "workspace:^", "@deepseek-ai/dsh-tool-goal": "workspace:^", "@deepseek-ai/dsh-tool-ralph": "workspace:^", "@deepseek-ai/dsh-tool-skill": "workspace:^", + "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", "@deepseek-ai/dsh-tool-tasks": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", diff --git a/apps/cli/tests/fixtures/memory-mcp-base.cordis.yml b/apps/cli/tests/fixtures/memory-mcp-base.cordis.yml new file mode 100644 index 0000000000..00878d6c98 --- /dev/null +++ b/apps/cli/tests/fixtures/memory-mcp-base.cordis.yml @@ -0,0 +1,8 @@ +# Minimal keyless composition for loading example MCP overlays against the +# package-owned fixture server in memory-mcp-configs.spec.ts. Source builtins +# keep this unit test independent of prebuilt workspace artifacts. +- id: system-prompt + name: cordis:memory-test-system-prompt + +- id: tools + name: cordis:memory-test-tools diff --git a/apps/cli/tests/memory-mcp-configs.spec.ts b/apps/cli/tests/memory-mcp-configs.spec.ts new file mode 100644 index 0000000000..7069b22738 --- /dev/null +++ b/apps/cli/tests/memory-mcp-configs.spec.ts @@ -0,0 +1,132 @@ +/** + * The third-party memory examples stay config-only. This suite parses every + * checked-in overlay, verifies its pin/transport/secret boundary, then replaces + * only the upstream endpoint with the package-owned keyless MCP fixture and + * proves the real Cordis Loader discovers a tool through the generic bridge. + */ + +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { Context } from 'cordis' +import type { PatchOptions } from '@cordisjs/plugin-include' +import { boot, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import * as McpClient from '@deepseek-ai/dsh-mcp-client/src/index.ts' + +interface ExampleContract { + file: string + id: string + serverName: string + transport: 'stdio' | 'streamable-http' + pin: string +} + +interface InsertedRow { + id?: string + name?: string + config?: Record +} + +const root = resolve(import.meta.dirname, '../../..') +const exampleDir = resolve(root, 'examples/mcp-memory') +const baseConfig = resolve(import.meta.dirname, 'fixtures/memory-mcp-base.cordis.yml') +const fixtureServer = resolve(root, 'packages/mcp/mcp-client/tests/fixture-server.ts') + +const examples: ExampleContract[] = [ + { + file: 'memorix.cordis.yml', + id: 'memory-memorix', + serverName: 'memorix', + transport: 'stdio', + pin: '1.3.0', + }, + { + file: 'mcp-reference-memory.cordis.yml', + id: 'memory-mcp-reference', + serverName: 'reference_memory', + transport: 'stdio', + pin: '2026.7.4', + }, + { + file: 'engram.cordis.yml', + id: 'memory-engram', + serverName: 'engram', + transport: 'stdio', + pin: '1.20.0', + }, +] + +const liveContexts = new Set() + +afterEach(async () => { + await Promise.all([...liveContexts].map(async ctx => ctx.fiber.dispose())) + liveContexts.clear() +}) + +function insertedRow(patches: PatchOptions[]): InsertedRow { + expect(patches).toHaveLength(1) + const insert = patches[0]?.insert + expect(insert).toHaveLength(1) + return insert?.[0] as InsertedRow +} + +async function waitForTool(ctx: Context, name: string): Promise { + const deadline = Date.now() + 10_000 + while (!ctx.tools.schemas().some(schema => schema.name === name)) { + if (Date.now() >= deadline) throw new Error(`timed out waiting for ${name}`) + await new Promise(resolveWait => setTimeout(resolveWait, 25)) + } +} + +describe('third-party memory MCP example overlays', () => { + it.each(examples)('parses $file with the documented generic boundary', (contract) => { + const file = resolve(exampleDir, contract.file) + const source = readFileSync(file, 'utf8') + const row = insertedRow(loadOverlayPatches('memory-mcp-config-test', file)) + + expect(row.id).toBe(contract.id) + expect(row.name).toBe('@deepseek-ai/dsh-mcp-client') + expect(row.config?.serverName).toBe(contract.serverName) + expect(row.config?.transport).toBe(contract.transport) + expect(source.split('\n', 1)[0]).toContain(contract.pin) + expect(source).not.toMatch(/\bsk-[A-Za-z0-9_-]{8,}\b/) + expect(source).not.toContain('DEEPSEEK_API_KEY') + }) + + it.each(examples)('loads $file and discovers a keyless fixture tool', async (contract) => { + const patches = loadOverlayPatches( + 'memory-mcp-config-test', + resolve(exampleDir, contract.file), + ) + // The static config gate verifies the checked-in bare package specifier. + // The unit test maps it to the source module so a clean checkout needs no + // prebuilt `lib/` artifacts before proving the Loader/MCP behavior. + insertedRow(patches).name = 'cordis:memory-test-mcp-client' + const fixturePatch: PatchOptions = { + id: contract.id, + config: { + serverName: contract.serverName, + transport: 'stdio', + command: process.execPath, + args: [fixtureServer], + env: {}, + cwd: root, + toolCallTimeoutMs: 5_000, + }, + } + const ctx = await boot( + 'memory-mcp-config-test', + baseConfig, + [...patches, fixturePatch], + (ctx) => { + liveContexts.add(ctx) + ctx.loader.builtins['memory-test-system-prompt'] = SystemPrompt + ctx.loader.builtins['memory-test-tools'] = ToolRegistry + ctx.loader.builtins['memory-test-mcp-client'] = McpClient + }, + ) + await waitForTool(ctx, `mcp__${contract.serverName}__greet`) + }, 15_000) +}) diff --git a/apps/web/tests/core-web-profile.snapshot.ts b/apps/web/tests/core-web-profile.snapshot.ts new file mode 100644 index 0000000000..58f2a34858 --- /dev/null +++ b/apps/web/tests/core-web-profile.snapshot.ts @@ -0,0 +1,84 @@ +import { writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import type { AgentHandle } from '@deepseek-ai/dsh-agent' +import { CallId } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import { launchWebScaffold, type WebScaffold } from './scaffold.ts' + +const CORE_WEB_OVERLAY = fileURLToPath(new URL('../../cli/config/core-web.cordis.yml', import.meta.url)) + +describe('core Web profile', () => { + let scaffold: WebScaffold + let agentHandle: AgentHandle + + beforeAll(async () => { + scaffold = await launchWebScaffold({ + extraOverlayPath: CORE_WEB_OVERLAY, + toolsMode: 'native', + }) + agentHandle = await scaffold.ctx.agents.create({ + sessionId: SessionId('core-web-profile-smoke'), + meta: { cwd: scaffold.workspaceCwd }, + agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + }) + }) + + afterAll(async () => { + const failures: unknown[] = [] + await agentHandle?.dispose().catch((error: unknown) => failures.push(error)) + await scaffold?.close().catch((error: unknown) => failures.push(error)) + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'core Web profile smoke teardown failed') + }) + + it('boots and executes both tools through the shipped Web composition', async () => { + const seedPath = join(scaffold.workspaceCwd, 'profile-smoke.txt') + await writeFile(seedPath, 'CORE_WEB_EDITOR_OK\n') + const signal = new AbortController().signal + const bash = await scaffold.ctx.tools.execute({ + signal, + callId: CallId('core-web-bash-smoke'), + name: 'bash', + arguments: { command: "printf 'CORE_WEB_BASH_OK\\n'" }, + agent: agentHandle.agent, + }) + const editor = await scaffold.ctx.tools.execute({ + signal, + callId: CallId('core-web-editor-smoke'), + name: 'str_replace_editor', + arguments: { command: 'view', path: seedPath }, + agent: agentHandle.agent, + }) + + const text = (result: typeof bash): string => result.content + .filter(block => block.type === 'text') + .map(block => block.text) + .join('') + .replaceAll(scaffold.workspaceCwd, '{{cwd}}') + .trimEnd() + + expect({ + tools: scaffold.ctx.tools.schemas().map(tool => tool.name), + bash: text(bash), + editor: text(editor), + }).toMatchInlineSnapshot(` + { + "bash": "CORE_WEB_BASH_OK", + "editor": "Here's the content of {{cwd}}/profile-smoke.txt with line numbers (which has a total of 2 lines): + 1 CORE_WEB_EDITOR_OK + 2", + "tools": [ + "bash", + "str_replace_editor", + ], + } + `) + + const entries = [...scaffold.ctx.loader.entries()] + expect(entries.find(entry => entry.options.id === 'persistent-bash')?.fiber).toBeDefined() + expect(entries.find(entry => entry.options.id === 'pty-local')?.fiber).toBeDefined() + expect(entries.find(entry => entry.options.id === 'str-replace-editor')?.fiber).toBeDefined() + }) +}) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index cb508bc7c3..ea39e2d29b 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -106,6 +106,12 @@ export interface WebScaffold { /** Options for {@link launchWebScaffold}. */ export interface LaunchOptions { + /** + * Optional product overlay applied after the shipped Web surface and before + * the scaffold's hermetic test patches, matching AppCLIEntry's `--config` + * ordering. + */ + extraOverlayPath?: string /** * Replay fixture (session.jsonl) served by the inserted dsh-llm-replay row * in replay/refresh modes; ignored in record mode (the real adapter @@ -215,8 +221,12 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise