dsh-next-actions 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,25 @@
1
+ # 版本记录
2
+
3
+ ## 0.1.1 — 2026-11-11
4
+
5
+ 修复 DSH 升级后引导提示丢失:`AgentRegistry.roots()` 在 DSH 0.2.1-rc.3 起被移除,
6
+ 原来的根 agent 判定抛错后被静默忽略,常驻提示语因此从未注册,模型只看到单独的工具描述。
7
+
8
+ - 改用会话 header 的 `origin: 'subagent'` 区分普通会话与子代理;子代理仍不注册工具与提示。
9
+ - 静默降级改为可诊断:引导安装失败、既有 agent 列表不可读、会话 header 无法判定都写 Host 日志;
10
+ Host logger 缺失时回退 `console.warn`。
11
+ - 引导语明确说明工程/代码工作同样适用:卡片在回答下方,不把回答变成 UI 回答。
12
+ - 工具描述自述触发时机,即使提示语丢失也能说明用途。
13
+ - 客户端光标插入被拒绝或抛错时回退为追加,并写 `console.warn` 说明降级。
14
+ - 测试改用当前 `AgentRegistry.list()` 接口,并覆盖子代理排除、各静默降级的告警。
15
+
16
+ ## 0.1.0 — 2026-11-11
17
+
18
+ 首个公开发布。
19
+
20
+ - 回复下方显示 1–3 条下一步建议:一两句上下文,加至多 3 个建议按钮;推荐项置顶、带 ✦ 与品牌色描边。
21
+ - 点击只填入:草稿为空或仍是未改动的填入文字时整段替换;草稿里有内容时在光标处插入空行 + 指令,保留 `@` 引用,⌘Z 可撤销;永不自动发送。
22
+ - 「已使用」从会话日志推断:下一条用户消息里有整行原样发送 → ✓,改后发送 → 铅笔;判定按选项各自进行。
23
+ - 危险建议带 ⚠,同样只填入;风险来自模型字段或命令形状模式。
24
+ - 卡片随会话保存,刷新与回放后仍在;工具过程组里一行记录。
25
+ - 兼容 PTC(`run_code`)会话;子代理不注册工具;`enabled: false` 时不注册工具与提示词,零常驻 token。
package/DESIGN.md ADDED
@@ -0,0 +1,75 @@
1
+ # 设计:下一步建议
2
+
3
+ ## 要解决的问题
4
+
5
+ 一轮工作结束后,用户常常要自己想清楚下一句该说什么,再把它打出来。模型此时最了解刚发生了什么,
6
+ 所以由它在收尾时顺手给出几步,用户点一下就能把完整指令放进输入框,改完自己发送。
7
+
8
+ 整个产品服务一条路径:**看一眼 → 点一下 → 在输入框里改**。新增任何机制前,先确认它服务这条路径。
9
+
10
+ ## 理念
11
+
12
+ 1. **回答的下一句,而不是新模块。** 卡片紧跟回答,先用一两句话承接刚才的结果,再给出动作;
13
+ 建议不会凭空出现,也不进入回答正文。
14
+ 2. **交给人,不替人做。** 点击只填入,永不发送;输入框里已有的内容永不覆盖;危险操作同样只填入。
15
+ 用户永远在发送前有最后一次修改的机会。
16
+ 3. **会话日志是唯一事实。** 卡片来自工具调用的记录,"已使用"来自用户真正发出的下一条消息;
17
+ 插件不另存状态,刷新、回放、换设备看到的都一样。"使用"指做了这一步,而不是按了这个按钮。
18
+ 4. **安静,可以忽略。** 最多三条;状态不占文字,每个按钮至多一个前置图标,辅以描边和字色的轻微变化,
19
+ 不加徽标、文字标签或弹层。不需要时继续对话即可,不弹窗、不设置、不打扰。
20
+ 没有真实后续工作的回合不出卡片。
21
+ 5. **长在 DSH 上。** 外观沿用官方动作卡与过程行,只用主题 token;用户不应看出这是另一套界面。
22
+ 6. **少即是多。** 能由已有事实推出来的就不存,能由用户自己完成的就不做;
23
+ 每一处代码都要能回答它服务哪条理念。
24
+
25
+ ## 体验
26
+
27
+ **浅色主题**
28
+
29
+ ![浅色主题的下一步建议卡片示意:一行上下文,加推荐、已使用、需确认三个按钮](assets/card-preview.svg)
30
+
31
+ **深色主题**
32
+
33
+ ![深色主题的下一步建议卡片示意:同一张卡片在深色主题下的颜色](assets/card-preview-dark.svg)
34
+
35
+ 示意图采用各主题的 token 值;实际外观随 DSH 明暗主题变化。
36
+
37
+ - **上下文**:卡片第一行是 1–2 句话,说明刚才做了什么、为什么是这几步。
38
+ - **建议**:最多 3 条,按模型给出的顺序排列;悬停显示完整指令(原生提示),不改变布局。
39
+ - **推荐**:模型至多标 1 条,固定排第一;按钮描边带品牌蓝,前置 ✦。
40
+ - **危险**:模型标记或命令形状的风险模式命中时前置 ⚠,可访问名带「需确认」。
41
+ - **已使用**:这是从用户发出的文本推断出来的,不是点击记录;手动输入相近的内容也会被标记。
42
+ 判定依据是这张卡片之后、第一个由用户消息开启的回合里的第一条消息:中间没有用户消息的回合(注入上下文、唤醒)跳过,
43
+ 但如果那一轮自己出了卡片,这条消息就归新卡片;第一条消息没有文字(例如只回复图片)时不做判定,也不向后借。
44
+ 对每个选项各自判定:
45
+ - 消息中有连续的整行与该指令一致(忽略空白)→ ✓。点击填入时指令总是成行出现,所以藏在一句话里的短词或「不要 + 指令」不算原样发送。
46
+ - 某一行或某一段按原顺序保留了指令的大部分字符,并且没有被大量新内容稀释(基于最长公共子序列的 Dice 相似度 ≥ 0.6)→ 铅笔,表示「改后发送」。
47
+ 否定句同样算改后发送;去掉空白后不足 4 个字的指令只认原样发送。
48
+ - 其他情况不标。
49
+
50
+ 已使用的按钮文字转为二级色。每个按钮只放一个前置图标:✓ 或铅笔 > ⚠ > ✦;所有状态都写进可访问名。
51
+ - **点击 = 填入**:草稿为空,或仍是卡片刚填入且未改动的文字 → 整段替换;草稿里有用户自己的内容 →
52
+ 在光标处插入「空行 + 指令」,作为一次编辑器编辑,保留 `@` 引用,⌘Z 可撤销。
53
+ - **随会话保存**:每一轮的卡片都在,历史卡片仍可点击填入;只有回合刚结束时出现的卡片做展开动画。
54
+ - **过程行**:工具过程组里一行「下一步建议 · 正在生成 / 已生成 · N / 未显示」。
55
+
56
+ 不做:自动发送、确认弹层、设置页、单轮忽略按钮、额外的模型请求。全局关闭用 Host 配置
57
+ `enabled: false`,此时不注册工具与提示词,零 token。
58
+
59
+ ## 机制
60
+
61
+ | 部分 | 文件 | 职责 |
62
+ | --- | --- | --- |
63
+ | Host | `index.js` | 为根 agent 注册 `suggest_next_actions` 与一段常驻提示;子代理不注册(按会话 header 的 `origin` 区分);安装与判定失败写日志;`enabled:false` 时什么都不注册 |
64
+ | 规范化 | `lib/normalize.js` | 参数 → `{ shown, context, suggestions[≤3] }`:折叠空白、截断、按指令去重、推荐至多一条且置顶;危险标记来自模型字段或命令形状模式。结果作为 `tool/result.meta` 写入会话日志 |
65
+ | Client | `client.js`(手写单文件,含 CSS) | 两个 Conversation Definition:`next-actions` 把本工具结果上的 meta(native 与 PTC `run_code` 两条通路)发布为该轮卡片;`next-actions-sent` 把用户在该轮发出的第一条 `user/message` 发布为 `sent` 文本。`conversation.chat.turnTail` 渲染卡片;`tool.call.toolview` 渲染过程行 |
66
+
67
+ 会话日志只追加,压缩不删除原 `tool/result`,所以客户端直接读 meta,不重算参数,也不需要构建步骤;
68
+ 压缩产生的替换副本一律忽略,避免同一张卡片发布两次。第 N 轮的卡片通过 `useChat` 的 `timeline.turnOrder` / `timeline.turns` 读第 N+1 轮的 `sent` 文本来判定
69
+ "已使用"。
70
+
71
+ 事件归到哪一轮由引擎决定。同一个 Definition 服务页面里所有会话的 assembler,翻页加载旧历史(`prepend`)时还会对更早的事件重跑 `match`,
72
+ 所以 `match` 只看当前事件、不持有状态:卡片按调用 id(PTC 按根调用 id)、消息按消息 id 建 Context,
73
+ 轮次在 `start` 里从引擎解析好的 `match.location` 读取。引擎规定每轮的同一个 Location key 只能有一个 owner,
74
+ 因此同一轮里第一个产出卡片的调用拥有卡片,第一条用户消息拥有 `sent`;后来者通过 `reader.previous` 看到前驱已占用就不再发布。外部插件不能 require Harness Client 包,所以卡片样式按官方动作卡重写,类名用 `dshna-` 前缀;
75
+ 刚结束的回合用 CSS `grid-template-rows` 展开卡片,避免回答被一帧顶起,`prefers-reduced-motion` 下不做动画。
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 LitiaEeloo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/NOTICE ADDED
@@ -0,0 +1,38 @@
1
+ dsh-next-actions
2
+ =================
3
+
4
+ Third-party notices for portions adapted from other works.
5
+
6
+
7
+ DeepSeek Harness official action card
8
+ -------------------------------------
9
+
10
+ The suggestion card and process row follow the DeepSeek Harness official
11
+ action card. Portions of client.js — the card and process-row CSS, and the
12
+ markup structure they style — are adapted from that work, with renamed
13
+ `dshna-` class names and DSH theme-token references.
14
+
15
+ Copyright (c) 2026 DeepSeek
16
+ Licensed under the MIT License, reproduced below.
17
+
18
+ MIT License
19
+
20
+ Copyright (c) 2026 DeepSeek
21
+
22
+ Permission is hereby granted, free of charge, to any person obtaining a copy
23
+ of this software and associated documentation files (the "Software"), to deal
24
+ in the Software without restriction, including without limitation the rights
25
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
26
+ copies of the Software, and to permit persons to whom the Software is
27
+ furnished to do so, subject to the following conditions:
28
+
29
+ The above copyright notice and this permission notice shall be included in all
30
+ copies or substantial portions of the Software.
31
+
32
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
33
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
34
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
35
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
36
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
37
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
38
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,79 @@
1
- # Temporary Holding Version
1
+ # dsh-next-actions
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ > Suggested next actions under DeepSeek Harness replies — clicking one fills the input, never sends.
4
+ > DSH Web/Desktop 插件:在回复下方显示 1–3 条「下一步建议」,点击只填入输入框。
5
+
6
+ **状态:v0.1.1(npm 包:[dsh-next-actions](https://www.npmjs.com/package/dsh-next-actions))。** 设计与理念见 [`DESIGN.md`](DESIGN.md),版本记录见 [`CHANGELOG.md`](CHANGELOG.md)。
7
+
8
+ ## 安装
9
+
10
+ 要求 DSH 0.2.2-rc.1 或更高版本;Host 需要支持 Web Client(Desktop 与 Web 均可)。
11
+
12
+ ```sh
13
+ dsh plugin --profile desktop add dsh-next-actions
14
+ ```
15
+
16
+ 也可以让 agent 用 `plugin_manager` 的 `install_bundle` 安装同名 bundle,或在 Web 侧栏的 Plugins 页面里选中它。装好后重启 DSH。
17
+
18
+ 本地开发时用 `link:` 安装本目录:
19
+
20
+ ```sh
21
+ dsh plugin --profile desktop add link:/path/to/dsh-next-actions
22
+ ```
23
+
24
+ ## 功能
25
+
26
+ - 一两句上下文 + 最多 3 个建议按钮;卡片随会话保存,刷新与回放后仍在。
27
+ - 推荐项置顶并带 ✦ 与品牌色描边;下一条消息里有整行与某条建议一致 → 实勾,改后发送 → 铅笔(从会话日志里的文本推断,手动输入相近内容也会被标记)。
28
+ - 空草稿直接填入;已有草稿时在光标处插入(保留 `@` 引用,⌘Z 撤销);永不自动发送。
29
+ - 危险建议带 ⚠ 标记,同样只填入。
30
+ - 工具过程组里一行记录;子代理不注册工具;Host 配置 `enabled: false` 时零常驻 token。
31
+ - 兼容 PTC(`run_code`)会话。
32
+
33
+ ## 结构
34
+
35
+ ```text
36
+ index.js Host:工具 + 根 agent 常驻提示 + enabled 门控
37
+ lib/normalize.js 参数规范化,结果写入 tool/result.meta
38
+ client.js Client(手写单文件):Definition、卡片、过程行与 CSS
39
+ cordis.patch.yml 插件行 patch
40
+ test/ node --test
41
+ assets/promo/ 宣传短片:源文件、封面与渲染脚本(成片不入库,不随 npm 发布)
42
+ ```
43
+
44
+ ## 开发
45
+
46
+ ```sh
47
+ node --test # 测试
48
+ npm pack --dry-run # 打包检查
49
+ ```
50
+
51
+ 安装/卸载用 `plugin_manager` 的 `install_bundle` / `remove_bundle`;验证使用假 `DSH_HOME`。
52
+ Host 改动需重启 DSH,Client 改动需刷新页面。
53
+
54
+ ## 发布
55
+
56
+ 改 `package.json` 版本与 `CHANGELOG.md` 后:
57
+
58
+ ```sh
59
+ node --test
60
+ npm pack --dry-run
61
+ git tag -a v<version> -m "v<version>"
62
+ git push origin main --tags
63
+ npm publish --access public
64
+ npm view dsh-next-actions version # 确认 latest 指向新版本
65
+ ```
66
+
67
+ 发布后用假 `DSH_HOME` 从 registry 装一次;本机 `link:` 安装不需要切换。
68
+
69
+ 关闭功能:在 profile 的 `cordis.patch.yml` 写
70
+
71
+ ```yaml
72
+ - id: dsh-next-actions
73
+ name: 'dsh-next-actions'
74
+ config: { enabled: false }
75
+ ```
76
+
77
+ ## 许可
78
+
79
+ MIT(见 [LICENSE](LICENSE))。卡片外观参照 MIT 许可的 DSH 官方动作卡,归属说明见 [NOTICE](NOTICE)。非 DeepSeek 官方插件。
@@ -0,0 +1,46 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="720" height="196" viewBox="0 0 720 196" role="img" aria-label="深色主题下的下一步建议卡片示意:一行上下文和三个按钮,分别带推荐、已使用、需确认标记">
2
+ <style>
3
+ text { font-family: system-ui, -apple-system, "PingFang SC", "Noto Sans CJK SC", "Helvetica Neue", sans-serif; }
4
+ .muted { fill: #adb2b8; font-size: 12px; }
5
+ .reply { fill: #cfd3d6; font-size: 13px; }
6
+ .context { fill: #cfd3d6; font-size: 13px; }
7
+ .label { fill: #f9fafb; font-size: 13px; }
8
+ .label-used { fill: #cfd3d6; font-size: 13px; }
9
+ </style>
10
+ <rect x="0.5" y="0.5" width="719" height="195" rx="14" fill="#17181a" stroke="#ffffff0f" stroke-width="0.75"/>
11
+ <text class="reply" x="28" y="38">助手回答……</text>
12
+ <text class="muted" x="28" y="64">▸ 下一步建议 · 已生成 · 3</text>
13
+ <rect x="28" y="76" width="664" height="96" rx="12" fill="#232324" stroke="#ffffff1f" stroke-width="0.75"/>
14
+ <g transform="translate(48 92) scale(0.4444)">
15
+ <g transform="translate(-1.5 0.5)">
16
+ <rect x="5" y="7.6" width="21" height="5.4" rx="2.7" fill="#5E9DF5"/>
17
+ <rect x="5" y="15.3" width="16" height="5.4" rx="2.7" fill="#7AAEFF"/>
18
+ <rect x="5" y="23" width="11" height="5.4" rx="2.7" fill="#9CC3FF"/>
19
+ <path d="M29.6 6.6C29.88 8.65 31.25 10.02 33.3 10.3C31.25 10.58 29.88 11.95 29.6 14C29.32 11.95 27.95 10.58 25.9 10.3C27.95 10.02 29.32 8.65 29.6 6.6Z" fill="#41E1AC"/>
20
+ </g>
21
+ </g>
22
+ <text class="context" x="70" y="104">修复已完成,改动集中在 src/api;目前只跑了类型检查。</text>
23
+ <g>
24
+ <rect x="48" y="118" width="118" height="32" rx="6" fill="#232324" stroke="#7aaaff" stroke-opacity="0.6" stroke-width="0.75"/>
25
+ <g transform="translate(60 128) scale(0.75)">
26
+ <path d="M8 2.5C8.4 5.6 10.4 7.6 13.5 8 10.4 8.4 8.4 10.4 8 13.5 7.6 10.4 5.6 8.4 2.5 8 5.6 7.6 7.6 5.6 8 2.5Z" fill="#7aaaff"/>
27
+ </g>
28
+ <text class="label" x="76" y="139">跑一下测试</text>
29
+ </g>
30
+ <g>
31
+ <rect x="174" y="118" width="118" height="32" rx="6" fill="#232324" stroke="#ffffff29" stroke-width="0.75"/>
32
+ <g transform="translate(186 128) scale(0.75)">
33
+ <path d="M3.5 8.5 6.5 11.5 12.5 4.5" fill="none" stroke="#6dd795" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
34
+ </g>
35
+ <text class="label-used" x="202" y="139">补回归用例</text>
36
+ </g>
37
+ <g>
38
+ <rect x="300" y="118" width="118" height="32" rx="6" fill="#232324" stroke="#ffffff29" stroke-width="0.75"/>
39
+ <g transform="translate(312 128) scale(0.75)">
40
+ <path d="M8 1.8 14.6 13.2H1.4L8 1.8Z" fill="none" stroke="#e7ae73" stroke-width="1.2" stroke-linejoin="round"/>
41
+ <path d="M8 6v3.4" stroke="#e7ae73" stroke-width="1.2" stroke-linecap="round"/>
42
+ <circle cx="8" cy="11.4" r="0.7" fill="#e7ae73"/>
43
+ </g>
44
+ <text class="label" x="328" y="139">推送到远端</text>
45
+ </g>
46
+ </svg>
@@ -0,0 +1,46 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="720" height="196" viewBox="0 0 720 196" role="img" aria-label="下一步建议卡片示意:一行上下文和三个按钮,分别带推荐、已使用、需确认标记">
2
+ <style>
3
+ text { font-family: system-ui, -apple-system, "PingFang SC", "Noto Sans CJK SC", "Helvetica Neue", sans-serif; }
4
+ .muted { fill: #8f959e; font-size: 12px; }
5
+ .reply { fill: #5f6673; font-size: 13px; }
6
+ .context { fill: #5f6673; font-size: 13px; }
7
+ .label { fill: #1f2329; font-size: 13px; }
8
+ .label-used { fill: #5f6673; font-size: 13px; }
9
+ </style>
10
+ <rect x="0.5" y="0.5" width="719" height="195" rx="14" fill="#f6f7f9" stroke="#e4e6ea" stroke-width="0.75"/>
11
+ <text class="reply" x="28" y="38">助手回答……</text>
12
+ <text class="muted" x="28" y="64">▸ 下一步建议 · 已生成 · 3</text>
13
+ <rect x="28" y="76" width="664" height="96" rx="12" fill="#ffffff" stroke="#e0e2e6" stroke-width="0.75"/>
14
+ <g transform="translate(48 92) scale(0.4444)">
15
+ <g transform="translate(-1.5 0.5)">
16
+ <rect x="5" y="7.6" width="21" height="5.4" rx="2.7" fill="#5E9DF5"/>
17
+ <rect x="5" y="15.3" width="16" height="5.4" rx="2.7" fill="#7AAEFF"/>
18
+ <rect x="5" y="23" width="11" height="5.4" rx="2.7" fill="#9CC3FF"/>
19
+ <path d="M29.6 6.6C29.88 8.65 31.25 10.02 33.3 10.3C31.25 10.58 29.88 11.95 29.6 14C29.32 11.95 27.95 10.58 25.9 10.3C27.95 10.02 29.32 8.65 29.6 6.6Z" fill="#41E1AC"/>
20
+ </g>
21
+ </g>
22
+ <text class="context" x="70" y="104">修复已完成,改动集中在 src/api;目前只跑了类型检查。</text>
23
+ <g>
24
+ <rect x="48" y="118" width="118" height="32" rx="6" fill="#ffffff" stroke="#7fa7ff" stroke-width="0.75"/>
25
+ <g transform="translate(60 128) scale(0.75)">
26
+ <path d="M8 2.5C8.4 5.6 10.4 7.6 13.5 8 10.4 8.4 8.4 10.4 8 13.5 7.6 10.4 5.6 8.4 2.5 8 5.6 7.6 7.6 5.6 8 2.5Z" fill="#3370ff"/>
27
+ </g>
28
+ <text class="label" x="76" y="139">跑一下测试</text>
29
+ </g>
30
+ <g>
31
+ <rect x="174" y="118" width="118" height="32" rx="6" fill="#ffffff" stroke="#d9dbe0" stroke-width="0.75"/>
32
+ <g transform="translate(186 128) scale(0.75)">
33
+ <path d="M3.5 8.5 6.5 11.5 12.5 4.5" fill="none" stroke="#2ea121" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
34
+ </g>
35
+ <text class="label-used" x="202" y="139">补回归用例</text>
36
+ </g>
37
+ <g>
38
+ <rect x="300" y="118" width="118" height="32" rx="6" fill="#ffffff" stroke="#d9dbe0" stroke-width="0.75"/>
39
+ <g transform="translate(312 128) scale(0.75)">
40
+ <path d="M8 1.8 14.6 13.2H1.4L8 1.8Z" fill="none" stroke="#ff8800" stroke-width="1.2" stroke-linejoin="round"/>
41
+ <path d="M8 6v3.4" stroke="#ff8800" stroke-width="1.2" stroke-linecap="round"/>
42
+ <circle cx="8" cy="11.4" r="0.7" fill="#ff8800"/>
43
+ </g>
44
+ <text class="label" x="328" y="139">推送到远端</text>
45
+ </g>
46
+ </svg>
package/client.js ADDED
@@ -0,0 +1,524 @@
1
+ /**
2
+ * Browser half of dsh-next-actions.
3
+ *
4
+ * - Two Conversation Definitions publish turn data. `next-actions` holds the
5
+ * card from this tool's `tool/result.meta` (native) or `tool/ptc-dispatch`
6
+ * meta (PTC `run_code`); `next-actions-sent` holds the text of the turn's
7
+ * first human `user/message`. `match()` reads only the current event; the
8
+ * turn always comes from the Engine-resolved `match.location`, because one
9
+ * Definition serves every session's assembler and `prepend` re-matches
10
+ * older pages.
11
+ * - A `conversation.chat.turnTail` entry renders the card under every turn
12
+ * that produced one; clicking a suggestion fills the composer and never
13
+ * sends. A chip whose prompt the next user message sent as-is gets a check;
14
+ * one sent with edits gets a pencil. Both come from the session log.
15
+ * - A `tool.call.toolview` entry renders the one-line process row.
16
+ *
17
+ * External plugins must not require Harness Client packages, so the markup and
18
+ * CSS follow the official action card with renamed `dshna-` classes and theme
19
+ * tokens only. DSH serves this file as one classic script; it is hand-written
20
+ * source, not a build output.
21
+ */
22
+ window.__ModuleLoader__.load({
23
+ id: 'dsh-next-actions',
24
+ factory(require) {
25
+ const React = require('react')
26
+ const h = React.createElement
27
+
28
+ // Card and process-row CSS: values follow the official action card and
29
+ // propose_action row; only theme tokens are referenced.
30
+ const STYLES = `
31
+ .dshna-reveal {
32
+ display: grid;
33
+ grid-template-rows: 1fr;
34
+ margin: 16px 0;
35
+ }
36
+ .dshna-reveal > * { min-height: 0; }
37
+ .dshna-reveal[data-fresh] { animation: dshna-reveal .22s cubic-bezier(.4, 0, .2, 1) both; }
38
+ @keyframes dshna-reveal { from { grid-template-rows: 0fr; opacity: 0; } }
39
+ @media (prefers-reduced-motion: reduce) { .dshna-reveal[data-fresh] { animation: none; } }
40
+ .dshna-card {
41
+ overflow: hidden;
42
+ padding: 14px 20px 16px;
43
+ border: .5px solid var(--dsw-alias-border-l2);
44
+ border-radius: var(--dsw-radius-lg);
45
+ background: var(--dsw-alias-bg-layer-1);
46
+ color: var(--dsw-alias-label-primary);
47
+ font-family: var(--dsw-font-family);
48
+ }
49
+ .dshna-context {
50
+ margin: 0;
51
+ color: var(--dsw-alias-label-secondary);
52
+ font-size: 13px;
53
+ line-height: 1.6;
54
+ overflow-wrap: anywhere;
55
+ }
56
+ .dshna-mark { display: inline-block; vertical-align: -3px; margin-right: 6px; }
57
+ .dshna-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
58
+ .dshna-chip {
59
+ box-sizing: border-box;
60
+ display: inline-flex;
61
+ align-items: center;
62
+ gap: 4px;
63
+ max-width: 100%;
64
+ height: 32px;
65
+ padding: 0 12px;
66
+ border: .5px solid var(--dsw-alias-border-l3);
67
+ border-radius: var(--dsw-radius-sm);
68
+ background: transparent;
69
+ color: var(--dsw-alias-label-primary);
70
+ font: inherit;
71
+ font-size: 13px;
72
+ line-height: 20px;
73
+ cursor: pointer;
74
+ }
75
+ .dshna-chip:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); }
76
+ .dshna-chip:active:not(:disabled) { background: var(--dsw-alias-interactive-bg-active); }
77
+ .dshna-chip:disabled { cursor: not-allowed; opacity: .4; }
78
+ .dshna-chip:focus-visible {
79
+ outline: var(--dsw-focus-ring-width, 2px) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
80
+ outline-offset: 2px;
81
+ }
82
+ .dshna-chip-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
83
+ /* Recommended: tinted border + spark. Used (next message): check or pencil in success color; quieter label. */
84
+ .dshna-chip[data-recommended] { border-color: color-mix(in srgb, var(--dsw-alias-state-business-primary) 60%, transparent); }
85
+ .dshna-spark { flex: none; color: var(--dsw-alias-state-business-primary); }
86
+ .dshna-chip[data-used] { color: var(--dsw-alias-label-secondary); }
87
+ .dshna-used { flex: none; color: color-mix(in srgb, var(--dsw-alias-state-success-primary) 65%, var(--dsw-alias-label-primary)); }
88
+ .dshna-warn { flex: none; color: color-mix(in srgb, var(--dsw-alias-state-warn-label) 65%, var(--dsw-alias-label-primary)); }
89
+ .dshna-row {
90
+ display: flex;
91
+ align-items: center;
92
+ gap: 6px;
93
+ min-width: 0;
94
+ height: calc(24px + var(--dsh-content-font-delta, 0px));
95
+ color: var(--dsw-alias-label-tertiary);
96
+ }
97
+ .dshna-row > svg { flex: none; }
98
+ .dshna-row-title { flex: none; font-size: var(--dsh-content-font-size-secondary, 13px); }
99
+ .dshna-row-summary { min-width: 0; margin-left: 2px; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
100
+ `
101
+
102
+ const PKG = 'dsh-next-actions'
103
+ const NS = 'nextActions'
104
+ const TOOL_NAME = 'suggest_next_actions'
105
+ const KEY = 'next-actions'
106
+ const SENT_KEY = 'next-actions-sent'
107
+
108
+ const EN = Object.freeze({
109
+ 'card.title': 'Suggested next actions',
110
+ 'chip.recommended': 'Recommended',
111
+ 'chip.danger': 'Needs confirmation',
112
+ 'chip.sent': 'Sent',
113
+ 'chip.edited': 'Sent with edits',
114
+ 'chip.aria': '{label}: {prompt}',
115
+ 'chip.ariaMarked': '{label} ({marks}): {prompt}',
116
+ 'chip.markSeparator': ', ',
117
+ 'row.title': 'Next actions',
118
+ 'row.running': 'Writing',
119
+ 'row.ok': 'Shown',
120
+ 'row.error': 'Not shown',
121
+ })
122
+ const ZH = Object.freeze({
123
+ 'card.title': '下一步建议',
124
+ 'chip.recommended': '推荐',
125
+ 'chip.danger': '需确认',
126
+ 'chip.sent': '已发送',
127
+ 'chip.edited': '改后发送',
128
+ 'chip.aria': '{label}:{prompt}',
129
+ 'chip.ariaMarked': '{label}({marks}):{prompt}',
130
+ 'chip.markSeparator': ',',
131
+ 'row.title': '下一步建议',
132
+ 'row.running': '正在生成',
133
+ 'row.ok': '已生成',
134
+ 'row.error': '未显示',
135
+ })
136
+
137
+ // -----------------------------------------------------------------------
138
+ // Data: fold this tool's events into turn data
139
+ // -----------------------------------------------------------------------
140
+
141
+ /**
142
+ * The card model is the Host-normalized `meta` on the original tool result.
143
+ * Compaction retains the original event; replacement copies are ignored.
144
+ * Older metas may carry up to six suggestions.
145
+ */
146
+ function modelOf(meta) {
147
+ if (meta?.shown !== true || typeof meta.context !== 'string' || !Array.isArray(meta.suggestions)) return null
148
+ const suggestions = meta.suggestions
149
+ .filter((suggestion) => typeof suggestion?.label === 'string' && typeof suggestion.prompt === 'string')
150
+ .slice(0, 3)
151
+ return suggestions.length === 0 ? null : { context: meta.context, suggestions }
152
+ }
153
+
154
+ const isTurn = (turn) => Number.isSafeInteger(turn) && turn >= 0
155
+ const isId = (value) => (typeof value === 'string' && value !== '') || Number.isSafeInteger(value)
156
+
157
+ /**
158
+ * Turn of a Match as the Engine resolved it for this session's window. Only
159
+ * the Engine knows which session and position an event has, so `match()`
160
+ * never guesses a turn from earlier events.
161
+ */
162
+ function turnOf(match) {
163
+ const location = match.location
164
+ const turn = location?.kind === 'turn' || location?.kind === 'step' ? location.turn?.turn : undefined
165
+ return isTurn(turn) ? turn : isTurn(match.event?.data?.turn) ? match.event.data.turn : null
166
+ }
167
+
168
+ /**
169
+ * A turn's Location key has one owner. Contexts of one kind form a chain by
170
+ * start order; `taken` says an earlier Context of the same turn already
171
+ * owns the key. The Engine replays a Context when its predecessor changes.
172
+ */
173
+ function takenBefore(reader, kind, turn, owns) {
174
+ const previous = reader.previous(kind)?.state
175
+ return previous !== undefined && previous.turn === turn && (previous.taken || owns(previous))
176
+ }
177
+
178
+ /** Plain text of a message's content blocks. */
179
+ function textOf(content) {
180
+ if (typeof content === 'string') return content
181
+ if (!Array.isArray(content)) return ''
182
+ return content.map((block) => (block?.type === 'text' && typeof block.text === 'string' ? block.text : '')).join('')
183
+ }
184
+
185
+ /**
186
+ * Card: one Context per call, keyed like the official tool Definition.
187
+ * Native calls by `callId`; PTC (`run_code`) dispatches by their root call.
188
+ * The first call of a turn that produced a card owns the turn's card.
189
+ */
190
+ const hasCard = (state) => state.model !== null
191
+ const cardDefinition = {
192
+ kind: KEY,
193
+ match(event) {
194
+ const data = event.data
195
+ switch (event.type) {
196
+ case 'tool/call':
197
+ return data.name === TOOL_NAME && isId(data.callId) ? { id: String(data.callId), role: 'start' } : null
198
+ case 'tool/result': {
199
+ // Replacement copies of a result must not re-publish the card.
200
+ const callId = data.message?.source?.callId ?? data.message?.toolCallId
201
+ return event.surfaceOp === 'append' && isId(callId) ? { id: String(callId), role: 'update' } : null
202
+ }
203
+ case 'tool/ptc-dispatch-start':
204
+ case 'tool/ptc-dispatch':
205
+ return data.name === TOOL_NAME && isId(data.rootCallId)
206
+ ? { id: `ptc:${data.rootCallId}`, role: event.type === 'tool/ptc-dispatch-start' ? 'start' : 'update' }
207
+ : null
208
+ default:
209
+ return null
210
+ }
211
+ },
212
+ start(_context, match, reader) {
213
+ const turn = turnOf(match)
214
+ const state = { turn, model: null, taken: turn !== null && takenBefore(reader, KEY, turn, hasCard) }
215
+ return cardDefinition.update({ state }, match)
216
+ },
217
+ update({ state }, { event }) {
218
+ const data = event.data
219
+ let model = null
220
+ if (event.type === 'tool/result' && event.surfaceOp === 'append') {
221
+ if (data.message === undefined || data.message.isError === true) return state
222
+ model = modelOf(data.meta)
223
+ } else if (event.type === 'tool/ptc-dispatch' && data.isError !== true) {
224
+ model = modelOf(data.meta)
225
+ }
226
+ return model === null ? state : { ...state, model }
227
+ },
228
+ buildLocationData({ state }, scope, previous) {
229
+ if (scope !== 'turn' || state === undefined || state.turn === null || state.model === null || state.taken) return null
230
+ if (previous?.key === KEY && previous.turn === state.turn && previous.value.model === state.model) return previous
231
+ return { kind: 'turn', turn: state.turn, key: KEY, value: { model: state.model } }
232
+ },
233
+ }
234
+
235
+ /**
236
+ * Sent text: one Context per human message. The first message of a turn owns
237
+ * the turn's `sent`, text or not: a textless reply publishes an empty value
238
+ * so the card before it is not judged by a later message.
239
+ */
240
+ const hasSent = () => true
241
+ const sentDefinition = {
242
+ kind: SENT_KEY,
243
+ match(event) {
244
+ const data = event.data
245
+ return event.type === 'user/message' && event.surfaceOp === 'append' && data.source?.kind === 'user' && isId(data.id)
246
+ ? { id: String(data.id), role: 'start' }
247
+ : null
248
+ },
249
+ start(_context, match, reader) {
250
+ const turn = turnOf(match)
251
+ return { turn, text: textOf(match.event.data.content), taken: turn !== null && takenBefore(reader, SENT_KEY, turn, hasSent) }
252
+ },
253
+ update({ state }) {
254
+ return state
255
+ },
256
+ buildLocationData({ state }, scope, previous) {
257
+ if (scope !== 'turn' || state === undefined || state.turn === null || state.taken) return null
258
+ if (previous?.key === SENT_KEY && previous.turn === state.turn && previous.value.text === state.text) return previous
259
+ return { kind: 'turn', turn: state.turn, key: SENT_KEY, value: { text: state.text } }
260
+ },
261
+ }
262
+
263
+ // -----------------------------------------------------------------------
264
+ // Card
265
+ // -----------------------------------------------------------------------
266
+
267
+ /** Empty drafts and drafts the card wrote itself are replaced; anything else gets the action added. */
268
+ function fillMode(draft, owned) {
269
+ return draft.trim() === '' || draft === owned ? 'replace' : 'insert'
270
+ }
271
+
272
+ let sharedCtx = null
273
+
274
+ function fill(actions, draft, owned, prompt, sessionId) {
275
+ let next = prompt
276
+ if (fillMode(draft, owned) === 'insert') {
277
+ next = null
278
+ try {
279
+ // One editor edit at the caret: keeps @ references, undoable with ⌘Z.
280
+ if (actions.insertText(`\n\n${prompt}`, actions.captureInsertion()) !== true) {
281
+ console.warn('[dsh-next-actions] insertText declined; appending the action instead')
282
+ next = `${draft.trimEnd()}\n\n${prompt}`
283
+ }
284
+ } catch (error) {
285
+ console.warn('[dsh-next-actions] insert at caret failed; appending the action instead', error)
286
+ next = `${draft.trimEnd()}\n\n${prompt}`
287
+ }
288
+ }
289
+ if (next !== null) actions.setDraft(next)
290
+ try {
291
+ sharedCtx?.get?.('conversation')?.input?.shell?.(sessionId)?.focus?.()
292
+ } catch {
293
+ // Focus is a convenience; the draft is already filled.
294
+ }
295
+ return next === prompt ? prompt : null
296
+ }
297
+
298
+ function Mark() {
299
+ return h('svg', { width: 16, height: 16, viewBox: '0 0 36 36', fill: 'none', className: 'dshna-mark', 'aria-hidden': 'true' },
300
+ h('g', { transform: 'translate(-1.5 0.5)' },
301
+ h('rect', { x: 5, y: 7.6, width: 21, height: 5.4, rx: 2.7, fill: '#5E9DF5' }),
302
+ h('rect', { x: 5, y: 15.3, width: 16, height: 5.4, rx: 2.7, fill: '#7AAEFF' }),
303
+ h('rect', { x: 5, y: 23, width: 11, height: 5.4, rx: 2.7, fill: '#9CC3FF' }),
304
+ h('path', { d: 'M29.6 6.6C29.88 8.65 31.25 10.02 33.3 10.3C31.25 10.58 29.88 11.95 29.6 14C29.32 11.95 27.95 10.58 25.9 10.3C27.95 10.02 29.32 8.65 29.6 6.6Z', fill: '#41E1AC' })))
305
+ }
306
+
307
+ function WarnIcon() {
308
+ return h('svg', { width: 12, height: 12, viewBox: '0 0 16 16', fill: 'none', className: 'dshna-warn', 'aria-hidden': 'true' },
309
+ h('path', { d: 'M8 1.8 14.6 13.2H1.4L8 1.8Z', stroke: 'currentColor', strokeWidth: 1.2, strokeLinejoin: 'round' }),
310
+ h('path', { d: 'M8 6v3.4', stroke: 'currentColor', strokeWidth: 1.2, strokeLinecap: 'round' }),
311
+ h('circle', { cx: 8, cy: 11.4, r: 0.7, fill: 'currentColor' }))
312
+ }
313
+
314
+ function SparkIcon() {
315
+ return h('svg', { width: 12, height: 12, viewBox: '0 0 16 16', className: 'dshna-spark', 'aria-hidden': 'true' },
316
+ h('path', { d: 'M8 2.5C8.4 5.6 10.4 7.6 13.5 8 10.4 8.4 8.4 10.4 8 13.5 7.6 10.4 5.6 8.4 2.5 8 5.6 7.6 7.6 5.6 8 2.5Z', fill: 'currentColor' }))
317
+ }
318
+
319
+ /** "Sent as-is". */
320
+ function CheckIcon() {
321
+ return h('svg', { width: 12, height: 12, viewBox: '0 0 16 16', fill: 'none', className: 'dshna-used', 'aria-hidden': 'true' },
322
+ h('path', { d: 'M3.5 8.5 6.5 11.5 12.5 4.5', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round' }))
323
+ }
324
+
325
+ /** "Sent with edits". */
326
+ function EditedIcon() {
327
+ return h('svg', { width: 12, height: 12, viewBox: '0 0 16 16', fill: 'none', className: 'dshna-used', 'aria-hidden': 'true' },
328
+ h('path', { d: 'M10.5 3 13 5.5 6 12.5 3 13l.5-3L10.5 3Z', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round' }))
329
+ }
330
+
331
+ // -----------------------------------------------------------------------
332
+ // Usage: which suggestions the next human message used
333
+ //
334
+ // An inference from the sent text, not a click record: typing similar text
335
+ // by hand is marked too. Clicking fills the prompt as whole lines (the
336
+ // empty draft replaced, or "\n\n" + prompt inserted), so "sent as-is"
337
+ // means whole lines equal to the prompt.
338
+ // -----------------------------------------------------------------------
339
+
340
+ /** Prompts shorter than this (whitespace removed) only count when sent as-is. */
341
+ const EDITED_MIN_LENGTH = 4
342
+ /** Order-aware similarity (Dice over the longest common subsequence) that counts as "sent with edits". */
343
+ const EDITED_SIMILARITY = 0.6
344
+
345
+ const squash = (value) => value.replace(/\s+/g, '').toLowerCase()
346
+
347
+ /** Characters of `left` kept in order in `right`. */
348
+ function commonLength(left, right) {
349
+ let row = new Array(right.length + 1).fill(0)
350
+ for (const character of left) {
351
+ const next = [0]
352
+ for (let index = 0; index < right.length; index += 1) {
353
+ next.push(character === right[index] ? row[index] + 1 : Math.max(row[index + 1], next[index]))
354
+ }
355
+ row = next
356
+ }
357
+ return row[right.length]
358
+ }
359
+
360
+ function similarity(target, text) {
361
+ // Dice cannot reach the threshold when lengths differ too much; skip the quadratic work.
362
+ if ((2 * Math.min(target.length, text.length)) / (target.length + text.length) < EDITED_SIMILARITY) return 0
363
+ return (2 * commonLength(target, text)) / (target.length + text.length)
364
+ }
365
+
366
+ /**
367
+ * 'full' when whole consecutive lines of the sent text equal the prompt;
368
+ * 'partial' when one line or paragraph keeps most of the prompt in order.
369
+ */
370
+ function usageOf(prompt, sent) {
371
+ const target = Array.from(squash(prompt))
372
+ if (target.length === 0) return null
373
+ const lines = sent.split('\n').map(squash).filter((line) => line !== '')
374
+ const joined = target.join('')
375
+ for (let first = 0; first < lines.length; first += 1) {
376
+ let run = ''
377
+ for (let last = first; last < lines.length && run.length < joined.length; last += 1) {
378
+ run += lines[last]
379
+ if (run === joined) return 'full'
380
+ }
381
+ }
382
+ if (target.length < EDITED_MIN_LENGTH) return null
383
+ const paragraphs = sent.split(/\n\s*\n/).map(squash).filter((paragraph) => paragraph !== '')
384
+ for (const candidate of new Set([...lines, ...paragraphs])) {
385
+ if (similarity(target, Array.from(candidate)) >= EDITED_SIMILARITY) return 'partial'
386
+ }
387
+ return null
388
+ }
389
+
390
+ /**
391
+ * Text the user answered the card of `turn` with: the first message of the
392
+ * next turn opened by a human. Turns without a human message (injected
393
+ * context, wake-ups) are skipped, and a later turn showing its own card
394
+ * claims the next message instead. A human turn whose message has no text
395
+ * (an image-only reply) ends the search, so no later message is borrowed.
396
+ */
397
+ function nextSent(snapshot, turn) {
398
+ const order = snapshot?.timeline?.turnOrder
399
+ const index = Array.isArray(order) ? order.indexOf(turn) : -1
400
+ if (index < 0) return ''
401
+ for (const later of order.slice(index + 1)) {
402
+ const data = snapshot.timeline.turns?.get(later)?.data
403
+ const sent = data?.get(SENT_KEY)
404
+ if (sent !== undefined) return typeof sent.text === 'string' ? sent.text : ''
405
+ if (data?.get(KEY)?.model) return ''
406
+ }
407
+ return ''
408
+ }
409
+
410
+ /** Only a card mounted right after its turn ends animates in; history mounts instantly. */
411
+ function isFresh(turn) {
412
+ const time = turn?.end?.time
413
+ const value = typeof time === 'number' ? time : Date.parse(time)
414
+ return Number.isFinite(value) && Date.now() - value < 5000
415
+ }
416
+
417
+ function NextActionsCard({ turn, inputActions, useInput, useChat, useSession, sessionId, t }) {
418
+ const ownedRef = React.useRef(null)
419
+ const draft = useInput?.((state) => state?.draft ?? '') ?? ''
420
+ const sent = useChat?.((snapshot) => nextSent(snapshot, turn?.turn)) ?? ''
421
+ const subagent = useSession?.((state) => state?.subagent ?? null)
422
+
423
+ const model = turn?.end === undefined ? undefined : turn.data?.get(KEY)?.model
424
+ const closing = turn?.data?.get('turn-tail')?.closing
425
+ const suggestions = closing && !subagent ? model?.suggestions : undefined
426
+ const usages = React.useMemo(
427
+ () => suggestions?.map(({ prompt }) => usageOf(prompt, sent)),
428
+ [suggestions, sent],
429
+ )
430
+ if (!suggestions) return null
431
+
432
+ const canWrite = typeof inputActions?.setDraft === 'function'
433
+ const chip = (suggestion, index) => {
434
+ const usage = usages[index]
435
+ const marks = [
436
+ suggestion.recommended && t('chip.recommended'),
437
+ suggestion.danger && t('chip.danger'),
438
+ usage === 'full' && t('chip.sent'),
439
+ usage === 'partial' && t('chip.edited'),
440
+ ].filter(Boolean)
441
+ // One leading icon: what the user did outranks what the model said.
442
+ const icon = usage !== null
443
+ ? h(usage === 'full' ? CheckIcon : EditedIcon)
444
+ : suggestion.danger ? h(WarnIcon) : suggestion.recommended ? h(SparkIcon) : null
445
+ return h('button', {
446
+ key: index,
447
+ type: 'button',
448
+ className: 'dshna-chip',
449
+ 'data-recommended': suggestion.recommended ? '' : undefined,
450
+ 'data-used': usage ?? undefined,
451
+ disabled: !canWrite,
452
+ title: suggestion.prompt,
453
+ 'aria-label': marks.length > 0
454
+ ? t('chip.ariaMarked', { label: suggestion.label, marks: marks.join(t('chip.markSeparator')), prompt: suggestion.prompt })
455
+ : t('chip.aria', { label: suggestion.label, prompt: suggestion.prompt }),
456
+ onClick: () => {
457
+ ownedRef.current = fill(inputActions, draft, ownedRef.current, suggestion.prompt, sessionId)
458
+ },
459
+ },
460
+ icon,
461
+ h('span', { className: 'dshna-chip-label' }, suggestion.label))
462
+ }
463
+
464
+ return h('div', { className: 'dshna-reveal', 'data-fresh': isFresh(turn) ? '' : undefined },
465
+ h('section', { className: 'dshna-card', 'aria-label': t('card.title') },
466
+ h('p', { className: 'dshna-context' }, h(Mark), model.context),
467
+ h('div', { className: 'dshna-chips' }, suggestions.map(chip))))
468
+ }
469
+
470
+ // -----------------------------------------------------------------------
471
+ // Process row (one line in the tool group, like the official propose_action row)
472
+ // -----------------------------------------------------------------------
473
+
474
+ function rowState(phase, block) {
475
+ if (phase === 'preparing' || phase === 'start') return 'running'
476
+ if (block?.isError === true || block?.error !== undefined) return 'error'
477
+ return block?.meta?.shown === false ? 'error' : 'ok'
478
+ }
479
+
480
+ function ProcessRow({ phase, block, t }) {
481
+ const state = rowState(phase, block)
482
+ const count = state === 'ok' && Array.isArray(block?.meta?.suggestions) ? block.meta.suggestions.length : 0
483
+ return h('div', { className: 'dshna-row', 'data-tool': TOOL_NAME, 'data-state': state, title: block?.meta?.note },
484
+ h('svg', { width: 14, height: 14, viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor', strokeWidth: 1, 'aria-hidden': 'true' },
485
+ h('rect', { x: 2.25, y: 2.25, width: 4, height: 4, rx: 1 }),
486
+ h('rect', { x: 2.25, y: 9.75, width: 4, height: 4, rx: 1 }),
487
+ h('rect', { x: 9.75, y: 9.75, width: 4, height: 4, rx: 1 }),
488
+ h('path', { d: 'M11.75 1.75v5M9.25 4.25h5' })),
489
+ h('span', { className: 'dshna-row-title' }, t('row.title')),
490
+ h('span', { className: 'dshna-row-summary' }, count > 0 ? `${t(`row.${state}`)} · ${count}` : t(`row.${state}`)))
491
+ }
492
+
493
+ // -----------------------------------------------------------------------
494
+ // Plugin entry
495
+ // -----------------------------------------------------------------------
496
+
497
+ function apply(ctx) {
498
+ sharedCtx = ctx
499
+ ctx.effect(() => ctx.locale.register(NS, { en: EN, zh: ZH }), 'dsh-next-actions: locale')
500
+ ctx.effect(() => {
501
+ const element = document.createElement('style')
502
+ element.dataset.plugin = PKG
503
+ element.textContent = STYLES
504
+ document.head.appendChild(element)
505
+ return () => element.remove()
506
+ }, 'dsh-next-actions: styles')
507
+ ctx.effect(() => ctx.uiConversation.events.register(cardDefinition), 'dsh-next-actions: card definition')
508
+ ctx.effect(() => ctx.uiConversation.events.register(sentDefinition), 'dsh-next-actions: sent definition')
509
+ ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({
510
+ name: 'conversation.chat.turnTail', id: PKG, order: -10, locale: NS,
511
+ }, NextActionsCard))
512
+ ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({
513
+ name: 'tool.call.toolview', key: TOOL_NAME, locale: NS,
514
+ }, ProcessRow))
515
+ }
516
+
517
+ return {
518
+ // `uiConversation` is injected so the definition registers on a cold boot.
519
+ inject: ['slots', 'locale', 'uiConversation'],
520
+ apply,
521
+ __test: { cardDefinition, sentDefinition, modelOf, fillMode, fill, rowState, usageOf, nextSent },
522
+ }
523
+ },
524
+ })
@@ -0,0 +1,7 @@
1
+ # Activates the package: the Host `apply` registers the tool; the Client half is
2
+ # discovered through package.json `dsh.client`. `enabled: false` registers nothing.
3
+ - insert:
4
+ - id: dsh-next-actions
5
+ name: 'dsh-next-actions'
6
+ config:
7
+ enabled: true
package/icon.svg ADDED
@@ -0,0 +1,14 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36" fill="none">
2
+ <defs>
3
+ <linearGradient id="next-actions-fill" x1="5" y1="8" x2="26" y2="20" gradientUnits="userSpaceOnUse">
4
+ <stop stop-color="#67C7FE"/>
5
+ <stop offset="1" stop-color="#3F77D8"/>
6
+ </linearGradient>
7
+ </defs>
8
+ <g transform="translate(-1.5 0.5)">
9
+ <rect x="5" y="7.6" width="21" height="5.4" rx="2.7" fill="url(#next-actions-fill)"/>
10
+ <rect x="5" y="15.3" width="16" height="5.4" rx="2.7" fill="#7AAEFF"/>
11
+ <rect x="5" y="23" width="11" height="5.4" rx="2.7" fill="#9CC3FF"/>
12
+ <path d="M29.6 6.6C29.88 8.65 31.25 10.02 33.3 10.3C31.25 10.58 29.88 11.95 29.6 14C29.32 11.95 27.95 10.58 25.9 10.3C27.95 10.02 29.32 8.65 29.6 6.6Z" fill="#41E1AC"/>
13
+ </g>
14
+ </svg>
package/index.js ADDED
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Host half of `dsh-next-actions`.
3
+ *
4
+ * Registers the `suggest_next_actions` tool for root agents plus one standing
5
+ * prompt section that tells the model when to call it. Zero imports: the
6
+ * package is installed with `link:` and cannot resolve `@deepseek-ai/*`.
7
+ * Row config `enabled: false` registers nothing, so the feature costs no tokens.
8
+ */
9
+
10
+ import { buildMeta, LIMITS } from './lib/normalize.js'
11
+
12
+ export const name = 'dsh-next-actions'
13
+ export const inject = ['tools', 'systemPrompt', 'agents']
14
+
15
+ const TOOL_NAME = 'suggest_next_actions'
16
+
17
+ const GUIDANCE = [
18
+ 'Before your final answer, call suggest_next_actions once when this turn leaves real follow-up work:',
19
+ 'a task finished or advanced, tools ran, files changed, review or approval is due,',
20
+ 'or you were about to end with a list of next actions.',
21
+ 'Use it for code and engineering work too; the card sits under the reply and the reply keeps its normal plain-text format.',
22
+ 'Put those actions only in the card; the reply gets at most one lead-in sentence and never numbers or bullets them.',
23
+ 'Skip greetings, acknowledgements, pure knowledge answers, repeated cards, and turns that already asked a structured question.',
24
+ ].join(' ')
25
+
26
+ const PROMPT_SECTION = 'tool:suggest-next-actions'
27
+
28
+ const TOOL = {
29
+ name: TOOL_NAME,
30
+ description: [
31
+ 'Show 1–3 clickable next actions as a card under your reply; clicking one fills the message input, never sends.',
32
+ 'Call it once before the final answer when this turn leaves real follow-up work.',
33
+ "Write the actions in the user's language and never repeat them in your prose.",
34
+ ].join(' '),
35
+ parameters: {
36
+ type: 'object',
37
+ additionalProperties: false,
38
+ required: ['context', 'suggestions'],
39
+ properties: {
40
+ context: {
41
+ type: 'string',
42
+ description: 'One or two sentences (≤240 chars): what was just done and why these actions follow. Never restate the answer.',
43
+ },
44
+ suggestions: {
45
+ type: 'array',
46
+ description: '1–3 distinct next actions, most useful first.',
47
+ items: {
48
+ type: 'object',
49
+ additionalProperties: false,
50
+ required: ['label', 'prompt'],
51
+ properties: {
52
+ label: { type: 'string', description: `Short imperative button text, ≤${LIMITS.label} chars, no ending punctuation.` },
53
+ prompt: { type: 'string', description: "Message the user could send as-is (≤500 chars), citing this turn's files, commands or decisions." },
54
+ recommended: { type: 'boolean', description: 'At most one: your pick when one action is clearly best.' },
55
+ danger: { type: 'boolean', description: 'True only for destructive, irreversible, paid or permission-changing actions, e.g. 删除/清空/重置/发布/部署/回滚/付费/授权/卸载.' },
56
+ },
57
+ },
58
+ },
59
+ },
60
+ },
61
+ output: {
62
+ schema: {
63
+ type: 'object',
64
+ additionalProperties: false,
65
+ required: ['shown', 'count'],
66
+ properties: { shown: { type: 'boolean' }, count: { type: 'integer' } },
67
+ },
68
+ render: (_args, value) => [{
69
+ type: 'text',
70
+ text: value?.shown
71
+ ? `Next actions shown below your answer (${value.count}). Now write the final answer: do not list them, keep at most one lead-in sentence, and do not call this tool again this turn.`
72
+ : 'No card shown. Answer normally; do not mention this tool.',
73
+ }],
74
+ // Persisted as tool/result.meta; the browser renders the card from it.
75
+ presentationMeta: (args) => buildMeta(args),
76
+ },
77
+ execute(args) {
78
+ const meta = buildMeta(args)
79
+ return { shown: meta.shown, count: meta.shown ? meta.suggestions.length : 0 }
80
+ },
81
+ }
82
+
83
+ /** Only a literal `enabled: false` turns the feature off. */
84
+ export function normalizeConfig(raw) {
85
+ return { enabled: !(raw !== null && typeof raw === 'object' && raw.enabled === false) }
86
+ }
87
+
88
+ /**
89
+ * Ordinary session agents only. Delegated agents carry the durable
90
+ * `origin: 'subagent'` header; `AgentRegistry.roots()` no longer exists since
91
+ * DSH 0.2.1-rc.3, so the header is the supported discriminator. Fail open so a
92
+ * Harness change never silently hides the tool, and report the drift once.
93
+ */
94
+ function isRoot(agent, onUnreadable) {
95
+ if (agent === undefined || agent === null) return true
96
+ try {
97
+ return agent.session.header.origin !== 'subagent'
98
+ } catch (error) {
99
+ onUnreadable?.(error)
100
+ return true
101
+ }
102
+ }
103
+
104
+ export function apply(ctx, config) {
105
+ if (!normalizeConfig(config).enabled) return
106
+
107
+ /** Host log, with a console fallback so a missing logger cannot hide the report. */
108
+ const warn = (message) => {
109
+ const line = `dsh-next-actions: ${message}`
110
+ try {
111
+ if (typeof ctx.logger?.warn === 'function') {
112
+ ctx.logger.warn(line)
113
+ return
114
+ }
115
+ } catch {
116
+ // Fall through: the warning must not depend on a working logger.
117
+ }
118
+ console.warn(line)
119
+ }
120
+
121
+ /** Report a header-shape change once; `when` runs on every tool resolution. */
122
+ let headerFailureReported = false
123
+ const isRootAgent = (agent) => isRoot(agent, (error) => {
124
+ if (headerFailureReported) return
125
+ headerFailureReported = true
126
+ warn(`agent session header unreadable; treating the agent as a root: ${String(error)}`)
127
+ })
128
+
129
+ ctx.effect(
130
+ () => ctx.tools.register(TOOL, { when: ({ agent }) => isRootAgent(agent) }),
131
+ 'dsh-next-actions: tool',
132
+ )
133
+
134
+ /** agent → disposer of its prompt section. */
135
+ const installed = new Map()
136
+ const install = (agent) => {
137
+ if (installed.has(agent) || !isRootAgent(agent)) return
138
+ try {
139
+ const prompt = agent.ctx.systemPrompt
140
+ const base = prompt.getSectionOrder?.('TOOL_REPORT')
141
+ installed.set(agent, agent.ctx.effect(() => prompt.section({
142
+ name: PROMPT_SECTION,
143
+ order: typeof base === 'number' ? base + 50 : 2950,
144
+ text: GUIDANCE,
145
+ }), 'dsh-next-actions: guidance'))
146
+ } catch (error) {
147
+ // The tool still works without the guidance; surface the degradation
148
+ // instead of silently losing the standing instruction.
149
+ warn(`guidance not installed: ${String(error)}`)
150
+ }
151
+ }
152
+
153
+ ctx.effect(() => {
154
+ const offCreated = ctx.on('agent/created', ({ agent }) => install(agent))
155
+ const offDisposed = ctx.on('agent/disposed', ({ agent }) => installed.delete(agent))
156
+ try {
157
+ for (const agent of ctx.agents.list()) install(agent)
158
+ } catch (error) {
159
+ // Agents created later still arrive through agent/created, but agents
160
+ // that were already live at apply time keep missing the guidance.
161
+ warn(`could not install guidance on existing agents: ${String(error)}`)
162
+ }
163
+ return () => {
164
+ offCreated()
165
+ offDisposed()
166
+ for (const dispose of installed.values()) {
167
+ try {
168
+ dispose()
169
+ } catch {
170
+ // The agent may already be gone.
171
+ }
172
+ }
173
+ installed.clear()
174
+ }
175
+ }, 'dsh-next-actions: guidance')
176
+ }
177
+
178
+ /** Test seams. */
179
+ export const __definition = TOOL
180
+ export const __guidance = { section: PROMPT_SECTION, text: GUIDANCE }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Normalize `suggest_next_actions` arguments into the card model.
3
+ *
4
+ * Host only: the result is persisted as `tool/result.meta`, which the browser
5
+ * half renders as-is. Import-free and side-effect-free.
6
+ */
7
+
8
+ export const LIMITS = Object.freeze({ suggestions: 3, context: 240, label: 24, prompt: 500 })
9
+
10
+ /**
11
+ * Command-shaped risk patterns: a hit marks a suggestion as needing confirmation.
12
+ * Clicking still only fills the input, so the mark never blocks the user.
13
+ */
14
+ export const RISK = /\brm\s+-rf\b|\bforce[- ]?push\b|--force\b|\bdrop\s+(table|database)\b|\btruncate\s+table\b|\bsudo\b|\bformat\s+(disk|drive|volume)\b/i
15
+
16
+ const text = (value) => (typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '')
17
+
18
+ function clip(value, max) {
19
+ const characters = Array.from(value)
20
+ return characters.length <= max ? value : `${characters.slice(0, max - 1).join('')}…`
21
+ }
22
+
23
+ /**
24
+ * @param {unknown} args - tool arguments as parsed by the Host.
25
+ * @returns {{ shown: true, context: string, suggestions: { label: string, prompt: string, danger?: true }[] } | { shown: false, note: string }}
26
+ */
27
+ export function buildMeta(args) {
28
+ if (args === null || typeof args !== 'object' || Array.isArray(args)) return { shown: false, note: 'invalid-arguments' }
29
+
30
+ const context = clip(text(args.context), LIMITS.context)
31
+ if (context === '') return { shown: false, note: 'missing-context' }
32
+
33
+ const seen = new Set()
34
+ const entries = []
35
+ let recommended = null
36
+ for (const item of Array.isArray(args.suggestions) ? args.suggestions : []) {
37
+ if (item === null || typeof item !== 'object') continue
38
+ const prompt = clip(text(item.prompt) || text(item.label), LIMITS.prompt)
39
+ const key = prompt.toLowerCase()
40
+ if (prompt === '' || seen.has(key)) continue
41
+ seen.add(key)
42
+ const label = clip(text(item.label) || prompt, LIMITS.label)
43
+ const entry = { label, prompt }
44
+ if (item.danger === true || RISK.test(`${label}\n${prompt}`)) entry.danger = true
45
+ // At most one recommendation; it always leads the card.
46
+ if (item.recommended === true && recommended === null) {
47
+ entry.recommended = true
48
+ recommended = entry
49
+ } else {
50
+ entries.push(entry)
51
+ }
52
+ }
53
+ const suggestions = recommended === null ? entries : [recommended, ...entries]
54
+ if (suggestions.length === 0) return { shown: false, note: 'no-usable-suggestions' }
55
+ return { shown: true, context, suggestions: suggestions.slice(0, LIMITS.suggestions) }
56
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Suggested next actions",
4
+ "description": "Shows 1–3 clickable next actions under replies, with one or two sentences of context. Clicking one fills the input; nothing is sent automatically."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "下一步建议",
4
+ "description": "在回复下方显示一两句上下文和 1–3 条可点击的下一步建议;点击只会填入输入框,不会自动发送。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,68 @@
1
1
  {
2
2
  "name": "dsh-next-actions",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "license": "MIT",
5
+ "description": "Shows 1–3 clickable next actions under replies; clicking one fills the input draft, never sends.",
6
+ "icon": "./icon.svg",
7
+ "author": "LighghtEeloo",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/LighghtEeloo/dsh-next-actions.git"
11
+ },
12
+ "homepage": "https://github.com/LighghtEeloo/dsh-next-actions#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/LighghtEeloo/dsh-next-actions/issues"
15
+ },
16
+ "type": "module",
17
+ "keywords": [
18
+ "deepseek-harness",
19
+ "dsh",
20
+ "dsh-plugin",
21
+ "next-actions",
22
+ "suggestions"
23
+ ],
24
+ "engines": {
25
+ "dsh": ">=0.2.2-rc.1"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "exports": {
31
+ ".": "./index.js",
32
+ "./client": "./client.js",
33
+ "./locale/*.json": "./locale/*.json",
34
+ "./icon": "./icon.svg"
35
+ },
36
+ "files": [
37
+ "index.js",
38
+ "client.js",
39
+ "cordis.patch.yml",
40
+ "lib/normalize.js",
41
+ "locale/*.json",
42
+ "icon.svg",
43
+ "DESIGN.md",
44
+ "assets/*.svg",
45
+ "README.md",
46
+ "CHANGELOG.md",
47
+ "LICENSE",
48
+ "NOTICE"
49
+ ],
50
+ "dsh": {
51
+ "bundle": {
52
+ "patch": "./cordis.patch.yml"
53
+ },
54
+ "client": {
55
+ "platform": "web",
56
+ "immediately": true,
57
+ "inject": [
58
+ "@deepseek-ai/dsh-client-locale",
59
+ "@deepseek-ai/dsh-client-ui-chat",
60
+ "@deepseek-ai/dsh-client-ui-conversation",
61
+ "@deepseek-ai/dsh-client-ui-tool"
62
+ ]
63
+ }
64
+ },
65
+ "scripts": {
66
+ "test": "node --test"
67
+ }
68
+ }