zen-gitsync 2.17.24 → 2.17.26
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/README.md +6 -0
- package/package.json +6 -3
- package/scripts/release.js +8 -10
- package/scripts/run-tests.cjs +5 -0
- package/src/cli/ai/agent.js +20 -0
- package/src/cli/ai/termui.js +73 -1
- package/src/cli/ai/termui.test.js +51 -0
- package/src/cli/ai/tools.js +116 -1
- package/src/cli/ai/tools.test.js +65 -1
- package/src/cli/ai/turn.js +3 -0
- package/src/memory/library.js +273 -0
- package/src/memory/library.test.js +187 -0
- package/src/memory/store.js +199 -0
- package/src/memory/store.test.js +137 -0
- package/src/memory/templates.js +113 -0
- package/src/paths.js +18 -0
- package/src/ui/public/assets/{AgentEngineSelector-CvIoGCLD.js → AgentEngineSelector-CEayBStS.js} +1 -1
- package/src/ui/public/assets/{AgentView-DnN-vCMi.css → AgentView-B3LhhRsy.css} +1 -1
- package/src/ui/public/assets/{AgentView-BrqS78Rt.js → AgentView-BhLlAvng.js} +1 -1
- package/src/ui/public/assets/{AppVersionBadge-BHxZm7-h.js → AppVersionBadge-btxeyJpH.js} +2 -2
- package/src/ui/public/assets/{BranchSelector-w5ybmWVv.js → BranchSelector-ERybqfLW.js} +1 -1
- package/src/ui/public/assets/{CommitForm-pRCnAfz6.js → CommitForm-DcXdYwKn.js} +1 -1
- package/src/ui/public/assets/{CommonDialog-qXAsFhNS.js → CommonDialog-5bwugMhs.js} +1 -1
- package/src/ui/public/assets/EditorView-BzxNDG8A.js +1 -0
- package/src/ui/public/assets/{EditorView-C6HFYeEM.css → EditorView-DqxBkn6c.css} +1 -1
- package/src/ui/public/assets/{FileDiffViewer-Bc8j9cQn.js → FileDiffViewer-BBOtL3bR.js} +1 -1
- package/src/ui/public/assets/{FlowExecutionViewer-CF4HkGu6.js → FlowExecutionViewer-BPMDn3Md.js} +1 -1
- package/src/ui/public/assets/{FlowOrchestrationWorkspace-a3Zk4BMK.js → FlowOrchestrationWorkspace-PtCM4LB1.js} +1 -1
- package/src/ui/public/assets/{IconButton-BjIHmE4p.js → IconButton-BELiW6cx.js} +1 -1
- package/src/ui/public/assets/{LogList-CqpSAW5T.js → LogList-BAD5dV_N.js} +1 -1
- package/src/ui/public/assets/{MindmapView-CgptM54l.js → MindmapView-BAwb7Gs1.js} +1 -1
- package/src/ui/public/assets/{MonitorView-qb0EPvQW.js → MonitorView-DIqKQfiM.js} +1 -1
- package/src/ui/public/assets/{ProjectStartupButton-CmankQtk.js → ProjectStartupButton-DnQyvZOG.js} +1 -1
- package/src/ui/public/assets/{RecentDirectoriesChat-CsHHckPP.js → RecentDirectoriesChat-BMZA0DgP.js} +1 -1
- package/src/ui/public/assets/RecentDirectoriesChat-o4xK1Koc.css +1 -0
- package/src/ui/public/assets/{RemoteManagerDialog-Cpg4g5KW.js → RemoteManagerDialog-BmdcIfwA.js} +1 -1
- package/src/ui/public/assets/{RemoteRepoCard-DCc-adLb.js → RemoteRepoCard-DqKIIcGh.js} +1 -1
- package/src/ui/public/assets/{SourceMapView-I41XvvZr.js → SourceMapView-Sd_871DD.js} +1 -1
- package/src/ui/public/assets/{SvgIcon-BB80ERhO.js → SvgIcon-DyQoHzNw.js} +1 -1
- package/src/ui/public/assets/{TemplateManager-BB_bEiBB.js → TemplateManager-BI1EXn7d.js} +1 -1
- package/src/ui/public/assets/{UserInputNode-y9gz0EKH.js → UserInputNode-3qtC_swg.js} +1 -1
- package/src/ui/public/assets/{WorkbenchView-Bkzbu5Sp.css → WorkbenchView-BNxybEqF.css} +1 -1
- package/src/ui/public/assets/WorkbenchView-wAdnj8Kl.js +6 -0
- package/src/ui/public/assets/_plugin-vue_export-helper-CGArUtKL.js +19 -0
- package/src/ui/public/assets/agentConversations-cmS8iux5.js +5 -0
- package/src/ui/public/assets/{configStore-BR8vEJCp.js → configStore-Vh8LZWlL.js} +1 -1
- package/src/ui/public/assets/{gitStore-Bz77W-S0.js → gitStore-B9gZQf2q.js} +1 -1
- package/src/ui/public/assets/index-BZFKDSGv.css +1 -0
- package/src/ui/public/assets/index-DE-31lcL.js +29 -0
- package/src/ui/public/assets/{previewSandbox-luPexzF4.js → previewSandbox-DmecmT9g.js} +1 -1
- package/src/ui/public/assets/{toolsStore-CeI7jM5o.js → toolsStore-DgCBDdhm.js} +1 -1
- package/src/ui/public/assets/vendor-BY5ZDlW1.css +1 -0
- package/src/ui/public/assets/{vendor-CyWsJd6S.js → vendor-BjomZKSS.js} +28 -120
- package/src/ui/public/index.html +14 -14
- package/src/ui/server/index.js +4 -0
- package/src/ui/server/routes/fileOpen.js +43 -7
- package/src/ui/server/routes/memory.js +125 -0
- package/src/ui/server/routes/workbench/agentChat.js +35 -3
- package/src/ui/server/routes/workbench/agentChatShared.test.js +49 -0
- package/src/ui/server/routes/workbench/dispatchInstruction.js +8 -6
- package/src/ui/server/routes/workbench/dispatchInstruction.test.js +29 -2
- package/src/ui/server/routes/workbench/index.js +41 -17
- package/src/ui/server/routes/workbench/memoryContext.js +125 -0
- package/src/ui/server/routes/workbench/memoryContext.test.js +79 -0
- package/src/ui/server/routes/workbench/orchestratorPrompt.test.js +47 -2
- package/src/ui/server/routes/workbench/orchestratorStore.js +29 -1
- package/src/ui/server/routes/workbench/runExternalTurn.js +6 -1
- package/src/ui/server/routes/workbench/shared.js +28 -2
- package/src/ui/server/routes/workbench/taskRunner.js +48 -5
- package/src/utils/selfUpdatePolicy.js +26 -4
- package/src/utils/selfUpdatePolicy.test.js +21 -2
- package/src/ui/public/assets/EditorView-DnuGnX3k.js +0 -1
- package/src/ui/public/assets/RecentDirectoriesChat-vHT8jMxW.css +0 -1
- package/src/ui/public/assets/WorkbenchView-DKqriLBP.js +0 -6
- package/src/ui/public/assets/_plugin-vue_export-helper-Dd_YmKEO.js +0 -19
- package/src/ui/public/assets/agentConversations-CT9E8eQr.js +0 -5
- package/src/ui/public/assets/index-CsLyZsXy.js +0 -29
- package/src/ui/public/assets/index-D2rf9QKB.css +0 -1
- package/src/ui/public/assets/vendor-BzMymUBi.css +0 -1
package/README.md
CHANGED
|
@@ -389,6 +389,8 @@ A dedicated view for running coding agents across one or many projects. Every ta
|
|
|
389
389
|
| AI prompt generation | The "New / Edit preset" dialog carries an **AI Generate project architecture** button plus an **Edit instruction** button: the server recursively finds every sub-project (a directory holding `.git` or one of 9 manifests), reads each one's key files on its own (manifest 20 KB / README 8 KB / a 2-level tree), calls the LLM concurrently to produce a per-sub-project architecture description, and merges them into one when there are several. **Edit instruction** customises the prompt used for generation (persisted to `~/.zen-gitsync/ai-instruction.json`) |
|
|
390
390
|
| Pipe-mode launcher | Spawns the selected executor as a detached process with stdout/stderr piped to the server — no external terminal window is opened, so output streams directly into the UI. Claude Code runs as `claude -p - --output-format stream-json --verbose --permission-mode bypassPermissions --dangerously-skip-permissions` (the prompt goes in over stdin to dodge Windows' 32 K command-line limit); OpenCode runs as `opencode run --format json --auto --thinking`; Codex runs as `codex exec --json --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox -`, both following whatever default model their own CLI is configured with |
|
|
391
391
|
| Isolated processes | Every run is its own detached process with fresh context, so memory and conversation state never accumulate across tasks |
|
|
392
|
+
| **Cross-run memory** | Every dispatched task is told about a local memory library at `~/.zen-gitsync/memory/` — the lessons earlier agents in that same repository left behind. The library is deliberately **two-tier**: only `INDEX.md` (one ≤100-character line per lesson) ever enters the prompt, while the lesson bodies live in `lessons/*.md` and are read **on demand, only when an index line actually matches the task**. Nothing is preloaded and `archive/` is never read — which is the whole point, since pasting the index into every prompt would make each dispatch pay for every unrelated historical lesson. When the workbench starts, the library is seeded if it does not exist yet (existing files are never overwritten). Before finishing, each agent self-checks four questions — stuck ≥3 min or a repeated wrong path, a wrong tool / wrong file / a grep drowned in build output, a correction from you, or a verified non-obvious shortcut — and writes **one** lesson if any of them is yes, always appending the matching `INDEX.md` line (a lesson without an index line is a lesson nobody will ever find). Set `memoryContext: false` on a task to opt out of the whole thing |
|
|
393
|
+
| Memory library panel | **Settings → Memory** browses the same library: a scope picker (the two global files plus every repository the agents have worked in, labelled by its real path), the lesson list for the selected scope, and click-to-expand for the raw text. Deleting a lesson **also removes its line from the index** — leaving a line behind would point agents at a file that no longer exists. Lessons that were written without an index line are badged as **Not indexed**, since the agent can never find those. `GLOBAL.md` and `INDEX.md` themselves cannot be deleted (they are the accounting). Nothing here is saved through the dialog's Save button: every action takes effect immediately |
|
|
392
394
|
| Live log | The "执行日志 / Execution log" panel **opens by default** and auto-scrolls, showing accumulated `stdout` + `stderr` (last 64 KB rendered client-side; the server keeps up to 100 MB per job) |
|
|
393
395
|
| Live status | Task status (pending / running / done / error / cancelled) and PID stream in real time over SSE |
|
|
394
396
|
| Tool-call stream | The model's tool calls are rendered inline in the conversation flow, so you can follow what the agent actually did |
|
|
@@ -413,6 +415,7 @@ A dedicated view (robot icon in the activity bar) for chatting with the built-in
|
|
|
413
415
|
| Live session entry | Sending the first message of a new session makes it show up in the list **immediately** with a "Generating..." badge, instead of waiting for the whole turn to finish; once the reply ends and the server persists the session, the entry is replaced by the real timestamp and message count |
|
|
414
416
|
| Streaming chat | SSE-based real-time streaming with thinking process, content, tool calls, and tool results rendered inline |
|
|
415
417
|
| Tool call display | Each tool invocation (run_command, read_file, edit_file, list_files, search_text, write_file) is shown as a collapsible card with arguments preview and execution result |
|
|
418
|
+
| Task plan | Multi-step work gets a visible plan: the agent calls the built-in `update_plan` tool to split the task into 3-8 verifiable steps before touching anything, then updates each step's status as it goes. Steps render as a checklist with completed / in-progress / pending states and a `2/5` progress header — in the terminal as a `✓ / ▶ / ○` list, in the Web panel as a card that **stays visible even when the tool group is collapsed** (collapsing hides other tool calls, never the current plan) |
|
|
416
419
|
| Recent-projects awareness | Ask "which of my projects need a pull?" and the agent calls its built-in `list_projects` tool instead of scanning the disk: it returns exactly the list behind the GUI's **Recent projects** panel (recent directories plus any directory a task was created in, with branch / ahead / behind / uncommitted counts and task progress), so the agent's answer and the UI agree. Ahead/behind reads local refs, so the agent can pass `refresh=true` to run a `git fetch` pass first when the question is about pulling |
|
|
417
420
|
| Session persistence | All conversations are saved to `~/.zen-gitsync/agent-sessions/` as JSON files; the CLI agent (`g ai`) writes to the same directory so Web and CLI sessions are unified |
|
|
418
421
|
| Skill / MCP plaza | The **Skill plaza** and **MCP plaza** tabs list skills and MCP servers from several sources, each with its description, weekly downloads / usage count and install state. Install one into the **current project** or into the **`g ai` agent** — entries already installed can be uninstalled from the same card, and ones that still need environment variables are flagged. From a terminal, `g ai` lists what is installed with `/skills` (`/mcp` is an alias) |
|
|
@@ -1051,6 +1054,8 @@ Activity Bar 第四个视图,在 GUI 内直接浏览并编辑项目文件:
|
|
|
1051
1054
|
| AI 生成预置 | 「新建 / 编辑预置」对话框内置 **AI 生成项目架构说明** 按钮 + **编辑指令** 按钮:服务端递归识别当前项目里的所有子项目(含 `.git` 或 9 种 manifest 之一的目录),为每个子项目独立读取关键文件(manifest 20 KB / README 8 KB / 2 层目录树),并发调 LLM 产出各子项目架构说明,多子项目场景再合并成一份整体说明;用户可点「编辑指令」自定义生成策略(持久化到 `~/.zen-gitsync/ai-instruction.json`);`max_tokens=4000`,单次请求最多 20 分钟 |
|
|
1052
1055
|
| 管道模式启动 | 选定执行器以 detached 进程拉起,stdout / stderr 通过管道回传服务端,不再弹外部终端窗口。Claude Code 走 `claude -p - --output-format stream-json --verbose --permission-mode bypassPermissions --dangerously-skip-permissions`(prompt 从 stdin 喂入,避开 Windows 32K 命令行上限);OpenCode 走 `opencode run --format json --auto --thinking`;Codex 走 `codex exec --json --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox -`,后两者的模型都跟随各自 CLI 配置的默认值 |
|
|
1053
1056
|
| 独立进程 | 每次执行都是独立的 detached 进程,上下文与状态不会跨任务累积 |
|
|
1057
|
+
| **跨轮记忆** | 每次派发都会告诉智能体本机有一份记忆库 `~/.zen-gitsync/memory/` —— 同一仓库里之前的智能体踩过的坑。记忆库刻意做成**两级**:只有 `INDEX.md`(每条经验 ≤100 字)会进 prompt,经验正文放在 `lessons/*.md` 里,**命中关键词才按需读取**。不预加载、`archive/` 永不读 —— 这正是重点:把索引整份塞进每次派发,等于每跑一次任务都要为所有不相关的历史经验付 token。工作台启动时若目录不存在会铺一份种子(**已存在的文件永不覆盖**)。收尾时智能体自检四问:卡住 ≥3 分钟或走了反复路径 / 用错工具写错文件或 grep 被构建产物淹没 / 你纠正过它的方向 / 发现"看似绕路但更快"或"反直觉但正确"且被验证的做法 —— 任一为是就记**一条**,并且必须在对应 `INDEX.md` 补一行索引(**没索引的经验等于不存在**)。单条任务可设 `memoryContext: false` 整块关掉 |
|
|
1058
|
+
| 记忆库面板 | **设置 → 记忆库** 浏览同一份库:范围选择器(全局两篇 + 智能体干过活的所有仓库,按真实路径标注)→ 该范围的条目列表 → 点标题展开看**原始文本**。删除一条会**连带删掉它在索引里的那一行** —— 留着就是一条指向已删文件的死链。没有索引行的经验标为**「未索引」**,因为智能体永远查不到它们。`GLOBAL.md` 与 `INDEX.md` 本身不可删(它们是记账口径)。面板里的操作全部即时生效,不走弹窗的「保存设置」 |
|
|
1054
1059
|
| 实时日志 | 「执行日志」面板**默认展开**,方便随时回看上次执行结果;面板内自动滚到底,展示累积的 stdout / stderr(客户端渲染最近 64 KB,服务端单个 job 最多保留 100 MB 输出) |
|
|
1055
1060
|
| 实时状态 | 任务状态(pending / running / done / error / cancelled)和 PID 通过 SSE 实时推送 |
|
|
1056
1061
|
| 工具调用流 | 模型的工具调用直接渲染在对话流里,能看清智能体具体做了什么 |
|
|
@@ -1075,6 +1080,7 @@ Activity Bar 中的机器人图标视图,可直接在浏览器中与内置 AI
|
|
|
1075
1080
|
| 会话实时入列 | 新会话发出第一条消息后,左侧列表**立刻**出现这一条(带「正在生成中…」标记),不用等整轮回答跑完;回答结束、服务端落盘后自动替换成真实的时间与条数 |
|
|
1076
1081
|
| 流式对话 | 基于 SSE 的实时流式输出,包含思考过程、正文内容、工具调用和工具结果的内联渲染 |
|
|
1077
1082
|
| 工具调用展示 | 每次工具调用(run_command、read_file、edit_file、list_files、search_text、write_file)以可折叠卡片形式展示,含参数预览和执行结果 |
|
|
1083
|
+
| 任务计划 | 多步任务有一份看得见的计划:智能体在动手之前先调内置的 `update_plan` 工具,把任务拆成 3-8 个可核对的步骤,随后逐步更新状态。步骤以清单渲染,区分完成 / 进行中 / 待办三态,标题右侧带 `2/5` 进度 —— 终端里是 `✓ / ▶ / ○` 列表,Web 面板里是一张卡片,且**工具组折叠时仍然常驻**(折叠只藏别的工具调用,绝不藏当前计划) |
|
|
1078
1084
|
| 最近项目感知 | 问「我哪些项目需要 pull」时,智能体调用内置的 `list_projects` 工具,而不是自己去扫盘:返回的就是 GUI「最近项目」面板那份清单(最近目录 + 建过任务的目录,带分支 / 领先 / 落后 / 未提交数与任务进度),回答与界面对得上。领先/落后读的是本地引用,因此问到"要不要拉"时它可以带 `refresh=true` 先联网 fetch 一轮再答 |
|
|
1079
1085
|
| 会话持久化 | 所有对话保存为 JSON 文件到 `~/.zen-gitsync/agent-sessions/`;CLI 智能体(`g ai`)写入同一目录,Web 端与 CLI 端会话统一管理 |
|
|
1080
1086
|
| Skill / MCP 广场 | **Skill 广场** 与 **MCP 广场** 两个 tab 列出多个来源的 Skill 与 MCP 服务,每项带说明、周下载 / 使用次数与安装状态。可安装到**当前项目**或 **`g ai` 智能体**;已安装的可在同一张卡片上卸载,还缺环境变量的会标出「还缺环境变量」。终端侧 `g ai` 用 `/skills`(`/mcp` 为别名)查看已装清单 |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zen-gitsync",
|
|
3
|
-
"version": "2.17.
|
|
3
|
+
"version": "2.17.26",
|
|
4
4
|
"description": "Auto commit, scheduled sync, and visual GUI for Git. Run `g` in any repo for one-key commit & push, AI commit messages, scheduled background sync, and a drag-and-drop workflow builder.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
"verify:data-dir-migration": "node scripts/verify-data-dir-migration.mjs",
|
|
29
29
|
"verify:config-split": "node scripts/verify-config-split.mjs",
|
|
30
30
|
"verify:package-files": "node --test test/package-files.test.mjs",
|
|
31
|
+
"verify:memory-context": "node scripts/verify-memory-context.cjs",
|
|
32
|
+
"verify:memory-panel": "node scripts/verify-memory-panel.cjs",
|
|
31
33
|
"verify:release:self-update": "node scripts/verify-release-self-update.mjs",
|
|
32
34
|
"verify:release:self-update:reverse": "node scripts/verify-release-self-update.mjs --reverse",
|
|
33
35
|
"verify:wb-agent-dispatch": "node scripts/verify-workbench-agent-dispatch.mjs",
|
|
@@ -56,8 +58,8 @@
|
|
|
56
58
|
"verify:editor-preview-scroll": "node scripts/verify-editor-preview-scroll.cjs",
|
|
57
59
|
"verify:editor-sidebar-width": "node scripts/verify-editor-sidebar-width.cjs",
|
|
58
60
|
"verify:wb-copy-folder-name": "node scripts/verify-copy-folder-name.cjs",
|
|
59
|
-
"verify:wb-
|
|
60
|
-
"verify:
|
|
61
|
+
"verify:wb-open-with-permission": "node scripts/verify-open-with-permission-menus.cjs",
|
|
62
|
+
"verify:open-with-permission": "node --test test/open-with-permission.test.mjs",
|
|
61
63
|
"verify:repo-list": "node scripts/verify-repo-list.cjs",
|
|
62
64
|
"e2e": "cd src/ui/client && npx playwright test",
|
|
63
65
|
"e2e:headed": "cd src/ui/client && npx playwright test --headed",
|
|
@@ -96,6 +98,7 @@
|
|
|
96
98
|
"src/ui/public/**",
|
|
97
99
|
"src/ui/server/**",
|
|
98
100
|
"src/utils/**",
|
|
101
|
+
"src/memory/**",
|
|
99
102
|
"src/gitCommit.js",
|
|
100
103
|
"src/aiCommit.js",
|
|
101
104
|
"src/config.js",
|
package/scripts/release.js
CHANGED
|
@@ -832,13 +832,10 @@ async function stopRunningInstances({ quiet = false } = {}) {
|
|
|
832
832
|
return stopped
|
|
833
833
|
}
|
|
834
834
|
|
|
835
|
-
// npm
|
|
836
|
-
//
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
function looksLikeFileLock(output) {
|
|
840
|
-
return LOCKED_FILE_PATTERN.test(String(output || ''))
|
|
841
|
-
}
|
|
835
|
+
// "文件占用"的判定不再用整串正则扫输出(v2.17.25 教训:npm warn cleanup 的
|
|
836
|
+
// EPERM 是删旧目录的噪声,registry 还没就绪(E404)的每一轮都带它,整串扫会把
|
|
837
|
+
// "只能等"误判成"要动手")。统一走 classifyInstallError() 的归类短码:
|
|
838
|
+
// 只有 npm 自己裁决为 EPERM(EBUSY 已并入)才走"再杀一轮 + 强删旧目录"。
|
|
842
839
|
|
|
843
840
|
// 手动强删旧的全局包目录。fs.rm 自带 Windows 重试(EBUSY/EPERM/ENOTEMPTY 退避重试),
|
|
844
841
|
// 比 npm 自己那层浅清理更能啃下被占的目录。
|
|
@@ -931,8 +928,9 @@ async function selfUpdateGlobal(version) {
|
|
|
931
928
|
errorTally.set(code, (errorTally.get(code) || 0) + 1)
|
|
932
929
|
console.log(chalk.yellow(` ✗ [${code}] ${summarizeInstallError(res.output)}`))
|
|
933
930
|
// 文件占用和"registry 还没同步"是两类失败:前者要再杀一轮 + 强删旧目录,
|
|
934
|
-
// 后者只能等。混在一起会让前者白等满整个窗口。日志里的 [短码]
|
|
935
|
-
|
|
931
|
+
// 后者只能等。混在一起会让前者白等满整个窗口。日志里的 [短码] 就是分类结果,
|
|
932
|
+
// 也是这里唯一的分支依据(不许再整串扫输出,warn 噪声会误判)。
|
|
933
|
+
if (code === 'EPERM') {
|
|
936
934
|
console.log(chalk.yellow(' 检测到文件占用(Windows 常见):再停一轮实例 + 强制清旧全局目录'))
|
|
937
935
|
if (!KEEP_INSTANCES) await stopRunningInstances({ quiet: true })
|
|
938
936
|
await forceRemoveGlobalPackage()
|
|
@@ -1045,7 +1043,7 @@ async function selfUpdateGlobal(version) {
|
|
|
1045
1043
|
))
|
|
1046
1044
|
}
|
|
1047
1045
|
if (lastOutput) console.error(chalk.gray(lastOutput))
|
|
1048
|
-
if (
|
|
1046
|
+
if (classifyInstallError(lastOutput) === 'EPERM') {
|
|
1049
1047
|
console.error(chalk.yellow(
|
|
1050
1048
|
'看起来是文件被占(不是 registry 滞后):关掉所有 UI 实例 / 编辑器后重试,\n'
|
|
1051
1049
|
+ '必要时手动删掉全局包目录再装(注意会短暂失去全局命令)。'
|
package/scripts/run-tests.cjs
CHANGED
|
@@ -20,10 +20,15 @@ const { join, relative, sep, resolve } = require('path')
|
|
|
20
20
|
const { spawn } = require('child_process')
|
|
21
21
|
|
|
22
22
|
const ROOT = join(__dirname, '..')
|
|
23
|
+
// ⚠️ 这是**显式枚举**,不是通配。新增一个放测试的目录必须手动加一行 ——
|
|
24
|
+
// 漏了不会报错,只是那份测试永远不跑(src/memory 就是这么悄悄漏掉的:
|
|
25
|
+
// 两份测试文件静静躺在仓库里,npm test 的文件数一动不动)。
|
|
26
|
+
// 加新目录时顺手确认一句:`node scripts/run-tests.cjs --list | grep <你的文件>`。
|
|
23
27
|
const SCAN_DIRS = [
|
|
24
28
|
join(ROOT, 'test'),
|
|
25
29
|
join(ROOT, 'src', 'utils'),
|
|
26
30
|
join(ROOT, 'src', 'cli'),
|
|
31
|
+
join(ROOT, 'src', 'memory'),
|
|
27
32
|
join(ROOT, 'src', 'ui', 'server'),
|
|
28
33
|
]
|
|
29
34
|
const PATTERNS = /\.(test|spec)\.(mjs|js|ts)$/
|
package/src/cli/ai/agent.js
CHANGED
|
@@ -277,6 +277,15 @@ ${isWin ? `- 当前是 Windows cmd.exe,以下 Unix 命令**不存在**,用了必
|
|
|
277
277
|
- 先读取当前项目的规范、依赖与测试配置,再选择对应的验证命令,不要假定所有项目都使用 npm
|
|
278
278
|
- 用户要求概述或简短回答时,只读取必要的结构与入口文件,控制探索范围
|
|
279
279
|
|
|
280
|
+
# 任务计划(多步任务必用)
|
|
281
|
+
- 任务需要多步时(改代码、排查、调研、多文件改动),**动手之前**先调一次 update_plan,把要做的事
|
|
282
|
+
拆成 3-8 个可核对的步骤,让用户在你改任何东西之前就知道范围和验收标准
|
|
283
|
+
- 单步小事(读个文件、答一个问题)不要列计划,那是噪音
|
|
284
|
+
- 每次都传**完整**的当前计划(不是增量),顺序即执行顺序;同一时刻最多一个 in_progress
|
|
285
|
+
- 完成一步就更新一次状态,别攒到最后一次性刷;计划与实际不符时直接改计划,不要硬着头皮往下走
|
|
286
|
+
- 全部做完后把 steps 传空表示收尾
|
|
287
|
+
- update_plan 只是给人看的进度板,不要为了"更新计划"去改文件或跑命令
|
|
288
|
+
|
|
280
289
|
# 与用户交互
|
|
281
290
|
- 需要向用户确认、提问或汇报重要决策时,直接用普通文本输出 —— 用户能实时看到你的文本;
|
|
282
291
|
需要暂停当前任务并等待用户决定或补充信息时,调用 ask_user,不要猜测或只在普通文本里提问
|
|
@@ -356,6 +365,17 @@ ${isWin ? `- This is Windows cmd.exe. The following Unix commands do NOT exist h
|
|
|
356
365
|
- Read this project's instructions, dependencies and test configuration before choosing verification commands; do not assume npm
|
|
357
366
|
- For a brief overview, inspect only the necessary structure and entry points; keep exploration proportional to the request
|
|
358
367
|
|
|
368
|
+
# Task plan (required for multi-step work)
|
|
369
|
+
- When a task takes several steps (code changes, debugging, research, multi-file edits), call update_plan
|
|
370
|
+
**before touching anything**: break it into 3-8 verifiable steps so the user knows the scope and the
|
|
371
|
+
acceptance criteria before you modify a single file
|
|
372
|
+
- Do not plan single trivial actions (read one file, answer one question) — that is noise
|
|
373
|
+
- Always send the **complete** current plan (not a delta), in execution order; at most one in_progress
|
|
374
|
+
- Update the status right after finishing a step instead of batching everything at the end; when the
|
|
375
|
+
plan no longer matches reality, rewrite the plan instead of pushing ahead
|
|
376
|
+
- Send an empty steps list once everything is done
|
|
377
|
+
- update_plan is a progress board for humans: never edit files or run commands just to "update the plan"
|
|
378
|
+
|
|
359
379
|
# Talking to the user
|
|
360
380
|
- When you need to confirm something, ask a question, or report an important decision, just write plain text — the user sees your output in real time. Use ask_user when the task must pause for an answer. Never call tools that do not exist; the built-in set is the ${builtinToolCount} tools listed above (if the user installed MCP servers, extra tools prefixed with mcp__ will also appear in your tool list)
|
|
361
381
|
- The user can attach images (Alt+V or /image); they arrive as image_url parts in your user messages. If the current model rejects images (no vision support), tell the user to switch to a vision-capable model
|
package/src/cli/ai/termui.js
CHANGED
|
@@ -32,6 +32,7 @@ import ora from 'ora'
|
|
|
32
32
|
import stringWidth from 'string-width'
|
|
33
33
|
import { stripVTControlCharacters } from 'node:util'
|
|
34
34
|
import { boxenAdaptive } from '../ui.js'
|
|
35
|
+
import { normalizePlanSteps, summarizePlan } from './tools.js'
|
|
35
36
|
|
|
36
37
|
// ──────────────────────────────────────────────
|
|
37
38
|
// 常量
|
|
@@ -680,6 +681,11 @@ export function summarizeToolArgs(name, args, { chars = '字符' } = {}) {
|
|
|
680
681
|
return clamp(`${args?.path || '.'} depth=${args?.depth || 2}`)
|
|
681
682
|
case 'search_text':
|
|
682
683
|
return clamp(`/${args?.pattern || ''}/ ${args?.path || '.'}`)
|
|
684
|
+
case 'update_plan': {
|
|
685
|
+
const steps = normalizePlanSteps(args?.steps ?? args?.todos ?? args?.plan)
|
|
686
|
+
const why = clamp(args?.explanation)
|
|
687
|
+
return clamp([summarizePlan(steps), why].filter(Boolean).join(' — '), 100)
|
|
688
|
+
}
|
|
683
689
|
default:
|
|
684
690
|
return clamp(JSON.stringify(args ?? {}))
|
|
685
691
|
}
|
|
@@ -689,7 +695,7 @@ export function summarizeToolArgs(name, args, { chars = '字符' } = {}) {
|
|
|
689
695
|
export function printToolHeader(name, summary, write = (s) => process.stdout.write(s), { locale = 'zh-CN' } = {}) {
|
|
690
696
|
// ▶ 保留青色作为工具执行的标识色;name 用白色加粗与下方结果区分;
|
|
691
697
|
// summary 用浅灰(#a0aec0)— dim 在黑底下太暗看不清,浅灰可读且仍比 name 低调
|
|
692
|
-
const labels = { run_command: '执行命令', read_file: '读取文件', write_file: '写入文件', edit_file: '修改文件', list_files: '浏览目录', search_text: '搜索内容' }
|
|
698
|
+
const labels = { run_command: '执行命令', read_file: '读取文件', write_file: '写入文件', edit_file: '修改文件', list_files: '浏览目录', search_text: '搜索内容', update_plan: '更新计划' }
|
|
693
699
|
const rows = wrapTerminalText(summary || '', Math.max(16, Math.min(100, termWidth() - 6)))
|
|
694
700
|
const label = String(locale).startsWith('en') ? name : labels[name] || name
|
|
695
701
|
write('\n' + chalk.cyan('▸ ') + chalk.bold.white(label) + (label !== name ? chalk.gray(` ${name}`) : '') + '\n')
|
|
@@ -738,6 +744,69 @@ export function printToolResult(result, write = (s) => process.stdout.write(s),
|
|
|
738
744
|
write(rendered + timingLine + '\n')
|
|
739
745
|
}
|
|
740
746
|
|
|
747
|
+
// ──────────────────────────────────────────────
|
|
748
|
+
// 计划清单(update_plan)
|
|
749
|
+
// ──────────────────────────────────────────────
|
|
750
|
+
//
|
|
751
|
+
// 计划在终端里不能只回一句「已更新 3 步」:那等于什么也没说,用户看不到"现在到哪了"。
|
|
752
|
+
// 所以这里把步骤画成清单,并且**每次调用都重画全量**——计划是当前快照,不是增量日志。
|
|
753
|
+
//
|
|
754
|
+
// 槽线沿用 printToolResult 的 ` │ `,让计划块和其他工具结果在同一列里对齐。
|
|
755
|
+
|
|
756
|
+
/** 计划步骤的符号与配色(纯函数,便于单测) */
|
|
757
|
+
function planStepGlyph(step) {
|
|
758
|
+
if (step.status === 'completed') return { mark: '✓', markColor: chalk.hex('#10b981'), text: chalk.hex('#74747e') }
|
|
759
|
+
if (step.status === 'in_progress') return { mark: '▶', markColor: chalk.hex('#3b82f6'), text: chalk.white }
|
|
760
|
+
return { mark: '○', markColor: chalk.hex('#55555f'), text: chalk.hex('#a5a5b0') }
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* 渲染计划清单(纯函数,便于单测)。
|
|
765
|
+
* @returns {string} 以 \n 结尾的整块文本
|
|
766
|
+
*/
|
|
767
|
+
export function renderPlan(args, { locale = 'zh-CN', width = Math.min(100, termWidth() - 6) } = {}) {
|
|
768
|
+
const steps = normalizePlanSteps(args?.steps ?? args?.todos ?? args?.plan)
|
|
769
|
+
if (!steps.length) return ''
|
|
770
|
+
const zh = !String(locale).startsWith('en')
|
|
771
|
+
const rows = []
|
|
772
|
+
rows.push(chalk.bold.white(zh ? '计划' : 'Plan') + chalk.hex('#74747e')(` ${summarizePlan(steps)}`))
|
|
773
|
+
|
|
774
|
+
const why = String(args?.explanation ?? '').replace(/\s+/g, ' ').trim()
|
|
775
|
+
if (why) {
|
|
776
|
+
for (const row of wrapTerminalText(why, Math.max(16, width - 4))) {
|
|
777
|
+
rows.push(chalk.hex('#74747e')(' ' + row))
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
for (const step of steps) {
|
|
782
|
+
const { mark, markColor, text } = planStepGlyph(step)
|
|
783
|
+
// 首行带符号,其余行留 2 格缩进:窄终端下换行也不会跑回最左边
|
|
784
|
+
const lines = wrapTerminalText(step.content, Math.max(12, width - 4))
|
|
785
|
+
lines.forEach((row, i) => {
|
|
786
|
+
const prefix = i === 0 ? markColor(mark) + ' ' : ' '
|
|
787
|
+
rows.push(chalk.gray(' │ ') + prefix + text(row))
|
|
788
|
+
})
|
|
789
|
+
}
|
|
790
|
+
return rows.map(row => row).join('\n') + '\n'
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/** 打印计划清单 */
|
|
794
|
+
export function printPlan(args, write = (s) => process.stdout.write(s), { locale = 'zh-CN', width } = {}) {
|
|
795
|
+
const block = renderPlan(args, { locale, width })
|
|
796
|
+
if (block) write('\n' + block)
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* 工具执行完之后的收尾渲染钩子(turn.js 每执行一个工具都会调一次,可选)。
|
|
801
|
+
* 现在只有 update_plan 有话说;以后加工具继续在这里挂,别去改 turn.js。
|
|
802
|
+
*/
|
|
803
|
+
export function afterTool(name, args, output, { locale = 'zh-CN', write } = {}) {
|
|
804
|
+
if (name !== 'update_plan') return
|
|
805
|
+
// 已经失败就不用画计划了(画一份没生效的计划比不画更误导)
|
|
806
|
+
if (/^错误|^Error/.test(String(output || '').trim())) return
|
|
807
|
+
printPlan(args, write, { locale })
|
|
808
|
+
}
|
|
809
|
+
|
|
741
810
|
// ──────────────────────────────────────────────
|
|
742
811
|
// 杂项状态行
|
|
743
812
|
// ──────────────────────────────────────────────
|
|
@@ -768,6 +837,9 @@ export default {
|
|
|
768
837
|
summarizeToolArgs,
|
|
769
838
|
printToolHeader,
|
|
770
839
|
printToolResult,
|
|
840
|
+
renderPlan,
|
|
841
|
+
printPlan,
|
|
842
|
+
afterTool,
|
|
771
843
|
printTurnSummary,
|
|
772
844
|
renderTurnSummary,
|
|
773
845
|
wrapTerminalText,
|
|
@@ -23,6 +23,7 @@ import stringWidth from 'string-width'
|
|
|
23
23
|
import {
|
|
24
24
|
stripAnsi, truncateDisplay, summarizeToolArgs,
|
|
25
25
|
createAssistantWriter, printToolHeader, printToolResult, startSpinner,
|
|
26
|
+
renderPlan, afterTool,
|
|
26
27
|
formatDuration, wrapTerminalText, renderTurnSummary,
|
|
27
28
|
filterSlashCommands, renderSlashHintBody, parseKeyForSlashHint, SLASH_COMMANDS,
|
|
28
29
|
renderSelectableListBody, parseKeyForSelectableList,
|
|
@@ -77,6 +78,56 @@ test('summarizeToolArgs: 其余工具的摘要格式', () => {
|
|
|
77
78
|
assert.equal(summarizeToolArgs('search_text', { pattern: 'foo', path: 'src' }), '/foo/ src')
|
|
78
79
|
})
|
|
79
80
|
|
|
81
|
+
// ── update_plan 的摘要与清单 ────────────────────────────────
|
|
82
|
+
test('summarizeToolArgs: update_plan 给进度摘要而不是 JSON', () => {
|
|
83
|
+
assert.equal(
|
|
84
|
+
summarizeToolArgs('update_plan', {
|
|
85
|
+
steps: [{ content: 'A', status: 'completed' }, { content: 'B', status: 'in_progress' }, { content: 'C' }],
|
|
86
|
+
explanation: '切宿主'
|
|
87
|
+
}),
|
|
88
|
+
'3 步(1 完成 / 1 进行中 / 1 待办) — 切宿主'
|
|
89
|
+
)
|
|
90
|
+
// 没有 explanation 时不留悬空的破折号
|
|
91
|
+
assert.equal(summarizeToolArgs('update_plan', { steps: [{ content: 'A' }] }), '1 步(0 完成 / 1 待办)')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('renderPlan: 三态三种符号,进度写在标题行,空计划不渲染', () => {
|
|
95
|
+
const block = renderPlan({
|
|
96
|
+
steps: [
|
|
97
|
+
{ content: '读代码', status: 'completed' },
|
|
98
|
+
{ content: '改代码', status: 'in_progress' },
|
|
99
|
+
{ content: '验证' }
|
|
100
|
+
],
|
|
101
|
+
explanation: '先改库'
|
|
102
|
+
}, { width: 60 })
|
|
103
|
+
assert.match(stripAnsi(block), /1 完成 \/ 1 进行中 \/ 1 待办/)
|
|
104
|
+
assert.match(stripAnsi(block), /先改库/)
|
|
105
|
+
assert.match(stripAnsi(block), /│ ✓ 读代码/)
|
|
106
|
+
assert.match(stripAnsi(block), /│ ▶ 改代码/)
|
|
107
|
+
assert.match(stripAnsi(block), /│ ○ 验证/)
|
|
108
|
+
// 空计划(收尾)不该在界面上留一个空壳
|
|
109
|
+
assert.equal(renderPlan({ steps: [] }, { width: 60 }), '')
|
|
110
|
+
assert.equal(renderPlan({}, { width: 60 }), '')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('renderPlan: 窄终端下换行仍带槽线与缩进', () => {
|
|
114
|
+
const block = stripAnsi(renderPlan({ steps: [{ content: 'x'.repeat(60), status: 'in_progress' }] }, { width: 24 }))
|
|
115
|
+
const rows = block.trimEnd().split('\n')
|
|
116
|
+
assert.ok(rows.length > 2)
|
|
117
|
+
assert.ok(rows.slice(1).every(row => row.startsWith(' │ ')))
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('afterTool: 只对 update_plan 画清单,工具失败时不画', () => {
|
|
121
|
+
let out = ''
|
|
122
|
+
const write = (s) => { out += s }
|
|
123
|
+
afterTool('read_file', { path: 'a.js' }, 'ok', { locale: 'zh-CN', write })
|
|
124
|
+
assert.equal(out, '')
|
|
125
|
+
afterTool('update_plan', { steps: [{ content: 'A' }] }, '错误: 不对', { locale: 'zh-CN', write })
|
|
126
|
+
assert.equal(out, '')
|
|
127
|
+
afterTool('update_plan', { steps: [{ content: 'A' }] }, '计划已更新', { locale: 'zh-CN', write })
|
|
128
|
+
assert.match(stripAnsi(out), /│ ○ A/)
|
|
129
|
+
})
|
|
130
|
+
|
|
80
131
|
test('summarizeToolArgs: 超长命令截断到 120 字符', () => {
|
|
81
132
|
const s = summarizeToolArgs('run_command', { command: 'x'.repeat(200) })
|
|
82
133
|
assert.ok(s.length <= 123) // '$ ' 前缀 + 120 字符 + '…'
|
package/src/cli/ai/tools.js
CHANGED
|
@@ -193,6 +193,46 @@ export const TOOL_DEFINITIONS = [
|
|
|
193
193
|
},
|
|
194
194
|
},
|
|
195
195
|
},
|
|
196
|
+
{
|
|
197
|
+
type: 'function',
|
|
198
|
+
function: {
|
|
199
|
+
name: 'update_plan',
|
|
200
|
+
description: '维护当前任务的执行计划(任务清单):把复杂任务拆成 3-8 个可核对的步骤,并随进展实时更新状态。'
|
|
201
|
+
+ '什么时候用:任务需要多步(改代码/排查/调研)时**先调用一次**把步骤列出来,让用户在你动手前就知道你要做什么、怎么验收;'
|
|
202
|
+
+ '之后每完成一步、或发现计划不对就再调一次更新状态。'
|
|
203
|
+
+ '规则:① steps 是**完整**的当前计划(不是增量),每次都把没做完的步骤一起带上,顺序即执行顺序;'
|
|
204
|
+
+ '② 同一时刻最多一个 in_progress —— 真正并行的事拆成先后两步写;'
|
|
205
|
+
+ '③ 状态只能 pending → in_progress → completed,别跳回;'
|
|
206
|
+
+ '④ 做完一步就更新一次,别攒到最后一次性刷;步骤完成后再把计划删空(传空 steps)表示收尾。'
|
|
207
|
+
+ '这个工具只是给人看的进度板,不承担任何副作用:它不会改文件、不会执行命令。',
|
|
208
|
+
parameters: {
|
|
209
|
+
type: 'object',
|
|
210
|
+
properties: {
|
|
211
|
+
steps: {
|
|
212
|
+
type: 'array',
|
|
213
|
+
description: '完整计划步骤列表(覆盖上一版)。顺序即执行顺序,3-8 条为宜,上限 20 条。',
|
|
214
|
+
items: {
|
|
215
|
+
type: 'object',
|
|
216
|
+
properties: {
|
|
217
|
+
content: { type: 'string', description: '这一步要做什么(祈使句、可核对,别写"处理相关问题"这种空话)' },
|
|
218
|
+
status: {
|
|
219
|
+
type: 'string',
|
|
220
|
+
enum: ['pending', 'in_progress', 'completed'],
|
|
221
|
+
description: '状态:已完成 / 进行中 / 待办。同一时刻最多一个 in_progress',
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
required: ['content'],
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
explanation: {
|
|
228
|
+
type: 'string',
|
|
229
|
+
description: '这次调整的一句话说明(如"库侧已完成,现在切宿主")。会显示在计划清单上方;首次列计划时写清整体思路。',
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
required: ['steps'],
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
},
|
|
196
236
|
{
|
|
197
237
|
type: 'function',
|
|
198
238
|
function: {
|
|
@@ -238,7 +278,8 @@ export const TOOL_DEFINITIONS = [
|
|
|
238
278
|
properties: {
|
|
239
279
|
text: {
|
|
240
280
|
type: 'string',
|
|
241
|
-
description: '完整任务指令,会**原样**成为那条任务的 prompt
|
|
281
|
+
description: '完整任务指令,会**原样**成为那条任务的 prompt(不是标题)。写清目标、范围与验收标准;'
|
|
282
|
+
+ '可粘整段报错日志 / 需求原文(上限 10 万字),但一次只讲一件事 —— 拆成多条各自更清楚。',
|
|
242
283
|
},
|
|
243
284
|
project_path: {
|
|
244
285
|
type: 'string',
|
|
@@ -664,6 +705,79 @@ async function toolAskUser(args, ctx) {
|
|
|
664
705
|
return ctx.askUser({ question, options, allowFreeText, multiple })
|
|
665
706
|
}
|
|
666
707
|
|
|
708
|
+
// update_plan 是个**纯展示**工具:它不改文件、不执行命令,只回一句确认。
|
|
709
|
+
// 归一化在这里做完(而不是散给各端):CLI / 控制台 / Web 面板三处都拿同一份
|
|
710
|
+
// 计划,谁渲染都别再自己解析一遍模型给的原始 JSON。
|
|
711
|
+
const MAX_PLAN_STEPS = 20
|
|
712
|
+
const MAX_PLAN_EXPLANATION = 600
|
|
713
|
+
|
|
714
|
+
const PLAN_STATUS_ALIASES = new Map([
|
|
715
|
+
['completed', 'completed'], ['complete', 'completed'], ['done', 'completed'], ['finished', 'completed'],
|
|
716
|
+
['in_progress', 'in_progress'], ['inprogress', 'in_progress'], ['in-progress', 'in_progress'],
|
|
717
|
+
['active', 'in_progress'], ['doing', 'in_progress'], ['running', 'in_progress'],
|
|
718
|
+
['pending', 'pending'], ['todo', 'pending'], ['not_started', 'pending'],
|
|
719
|
+
])
|
|
720
|
+
|
|
721
|
+
// 归一化后的计划类工具名。归一化 = 小写 + 去掉分隔符,于是 update_plan /
|
|
722
|
+
// updatePlan / update-plan 落到同一个键上。
|
|
723
|
+
// 前端组件库(zen-ai-chat-ui)里另有一份同名判定 —— 浏览器端不能反过来依赖
|
|
724
|
+
// 这个 Node 模块,两边各留一份是刻意的边界,不是重复代码。
|
|
725
|
+
const PLAN_TOOL_NAMES = new Set([
|
|
726
|
+
'updateplan', 'writetodos', 'todowrite', 'todotrite', 'todorewrite', 'createplan', 'setplan', 'plan'
|
|
727
|
+
])
|
|
728
|
+
|
|
729
|
+
/** 这个工具名是不是计划类 */
|
|
730
|
+
export function isPlanToolName(name) {
|
|
731
|
+
return PLAN_TOOL_NAMES.has(String(name || '').toLowerCase().replace(/[^a-z]/g, ''))
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/** 计划步骤归一化:接受 content/text/title/task,状态词收敛成 pending/in_progress/completed */
|
|
735
|
+
export function normalizePlanSteps(raw) {
|
|
736
|
+
const list = Array.isArray(raw) ? raw : []
|
|
737
|
+
const steps = []
|
|
738
|
+
for (const item of list.slice(0, MAX_PLAN_STEPS)) {
|
|
739
|
+
const obj = typeof item === 'string' ? { content: item } : item
|
|
740
|
+
if (!obj || typeof obj !== 'object') continue
|
|
741
|
+
const content = String(obj.content ?? obj.text ?? obj.title ?? obj.task ?? '').replace(/\s+/g, ' ').trim()
|
|
742
|
+
if (!content) continue
|
|
743
|
+
const rawStatus = String(obj.status ?? obj.state ?? '').trim().toLowerCase()
|
|
744
|
+
steps.push({
|
|
745
|
+
content: content.slice(0, 200),
|
|
746
|
+
status: PLAN_STATUS_ALIASES.get(rawStatus) || 'pending'
|
|
747
|
+
})
|
|
748
|
+
}
|
|
749
|
+
return steps
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/** 计划状态摘要:`3 步(1 完成 / 1 进行中 / 1 待办)` */
|
|
753
|
+
export function summarizePlan(steps) {
|
|
754
|
+
const list = Array.isArray(steps) ? steps : []
|
|
755
|
+
if (!list.length) return '0 步'
|
|
756
|
+
const done = list.filter(s => s.status === 'completed').length
|
|
757
|
+
const active = list.filter(s => s.status === 'in_progress').length
|
|
758
|
+
const pending = list.length - done - active
|
|
759
|
+
const parts = [`${done} 完成`]
|
|
760
|
+
if (active) parts.push(`${active} 进行中`)
|
|
761
|
+
if (pending) parts.push(`${pending} 待办`)
|
|
762
|
+
return `${list.length} 步(${parts.join(' / ')})`
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
async function toolUpdatePlan(args) {
|
|
766
|
+
const rawSteps = args.steps ?? args.todos ?? args.plan
|
|
767
|
+
if (rawSteps !== undefined && !Array.isArray(rawSteps)) {
|
|
768
|
+
return '错误: update_plan 的 steps 必须是数组,形如 [{"content":"做 X","status":"pending"}]。'
|
|
769
|
+
}
|
|
770
|
+
const steps = normalizePlanSteps(rawSteps)
|
|
771
|
+
const explanation = String(args.explanation ?? '').replace(/\s+/g, ' ').trim().slice(0, MAX_PLAN_EXPLANATION)
|
|
772
|
+
if (Array.isArray(rawSteps) && rawSteps.length && !steps.length) {
|
|
773
|
+
return '错误: 每一步都要有 content(一句话说明这一步做什么);请重发完整 steps。'
|
|
774
|
+
}
|
|
775
|
+
// 不存任何全局状态:计划是**当前快照**,天然能从这轮 tool_call 的参数复原。
|
|
776
|
+
// 终端画清单(Web 面板渲染清单)都直接读参数,谁都不用维护第二份真相。
|
|
777
|
+
if (!steps.length) return '计划已清空(任务收尾)。'
|
|
778
|
+
return `计划已更新:${summarizePlan(steps)}${explanation ? ` —— ${explanation}` : ''}`
|
|
779
|
+
}
|
|
780
|
+
|
|
667
781
|
// dispatch_task 与 list_projects 同一条边界:工具的**定义**在这里(CLI 与 Web 共用
|
|
668
782
|
// 这份表),而"派到哪儿、怎么建任务、用哪个执行器"全是 GUI 侧的事。
|
|
669
783
|
// 由 GUI 侧(workbench/agentRoutes.js)把实现注入 ctx.dispatchTask —— 注入的时机还带
|
|
@@ -702,6 +816,7 @@ const TOOL_HANDLERS = {
|
|
|
702
816
|
list_files: toolListFiles,
|
|
703
817
|
search_text: toolSearchText,
|
|
704
818
|
list_projects: toolListProjects,
|
|
819
|
+
update_plan: toolUpdatePlan,
|
|
705
820
|
ask_user: toolAskUser,
|
|
706
821
|
dispatch_task: toolDispatchTask,
|
|
707
822
|
}
|
package/src/cli/ai/tools.test.js
CHANGED
|
@@ -19,7 +19,7 @@ import assert from 'node:assert/strict'
|
|
|
19
19
|
import { promises as fs } from 'node:fs'
|
|
20
20
|
import os from 'node:os'
|
|
21
21
|
import path from 'node:path'
|
|
22
|
-
import { TOOL_DEFINITIONS, executeTool, resolveCommandTimeout } from './tools.js'
|
|
22
|
+
import { TOOL_DEFINITIONS, executeTool, resolveCommandTimeout, normalizePlanSteps, isPlanToolName } from './tools.js'
|
|
23
23
|
|
|
24
24
|
let tmpDir
|
|
25
25
|
const ctx = { cwd: null, onChild: null }
|
|
@@ -385,3 +385,67 @@ test('dispatch_task 在没有注入实现的入口给一句可照做的说明,
|
|
|
385
385
|
assert.match(result, /不要假装/)
|
|
386
386
|
assert.match(await executeTool('dispatch_task', {}, ctx), /需要非空的 text/)
|
|
387
387
|
})
|
|
388
|
+
|
|
389
|
+
// ── update_plan ────────────────────────────────────────────
|
|
390
|
+
// 它是纯展示工具,但字段归一化有真实坑(模型会给 content/text/title、
|
|
391
|
+
// 状态会给 in_progress/inProgress/done),归一化错了终端清单会静默少一格,
|
|
392
|
+
// 所以这里把边界全钉住。
|
|
393
|
+
test('update_plan schema:steps 必填,状态枚举只有三态', () => {
|
|
394
|
+
const definition = TOOL_DEFINITIONS.find(tool => tool.function.name === 'update_plan')
|
|
395
|
+
assert.ok(definition)
|
|
396
|
+
assert.deepEqual(definition.function.parameters.required, ['steps'])
|
|
397
|
+
const step = definition.function.parameters.properties.steps.items
|
|
398
|
+
assert.deepEqual(step.properties.status.enum, ['pending', 'in_progress', 'completed'])
|
|
399
|
+
assert.ok(definition.function.parameters.properties.explanation)
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
test('normalizePlanSteps:状态词与文案字段都收敛,脏数据丢弃', () => {
|
|
403
|
+
assert.deepEqual(normalizePlanSteps([
|
|
404
|
+
{ content: ' A ', status: 'done' },
|
|
405
|
+
{ text: 'B', state: 'in-progress' },
|
|
406
|
+
{ title: 'C' },
|
|
407
|
+
{ content: 'D', status: 'weird-status' },
|
|
408
|
+
'E',
|
|
409
|
+
{ content: ' ' },
|
|
410
|
+
null,
|
|
411
|
+
42
|
|
412
|
+
]), [
|
|
413
|
+
{ content: 'A', status: 'completed' },
|
|
414
|
+
{ content: 'B', status: 'in_progress' },
|
|
415
|
+
{ content: 'C', status: 'pending' },
|
|
416
|
+
{ content: 'D', status: 'pending' },
|
|
417
|
+
{ content: 'E', status: 'pending' }
|
|
418
|
+
])
|
|
419
|
+
// 步数上限:模型跑偏时不至于把界面撑爆
|
|
420
|
+
assert.equal(normalizePlanSteps(Array.from({ length: 30 }, (_, i) => ({ content: `s${i}` }))).length, 20)
|
|
421
|
+
assert.deepEqual(normalizePlanSteps('不是数组'), [])
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
test('update_plan 回执带上进度摘要与说明', async () => {
|
|
425
|
+
const out = await executeTool('update_plan', {
|
|
426
|
+
steps: [
|
|
427
|
+
{ content: '读代码', status: 'completed' },
|
|
428
|
+
{ content: '改代码', status: 'in_progress' },
|
|
429
|
+
{ content: '验证', status: 'pending' }
|
|
430
|
+
],
|
|
431
|
+
explanation: '先改库再改宿主'
|
|
432
|
+
}, ctx)
|
|
433
|
+
assert.match(out, /计划已更新/)
|
|
434
|
+
assert.match(out, /3 步\(1 完成 \/ 1 进行中 \/ 1 待办\)/)
|
|
435
|
+
assert.match(out, /先改库再改宿主/)
|
|
436
|
+
// 收尾:空 steps = 计划清空,不该报"没有 content"
|
|
437
|
+
assert.match(await executeTool('update_plan', { steps: [] }, ctx), /计划已清空/)
|
|
438
|
+
// 脏参数要能被模型看懂错在哪
|
|
439
|
+
assert.match(await executeTool('update_plan', { steps: 'x' }, ctx), /必须是数组/)
|
|
440
|
+
assert.match(await executeTool('update_plan', { steps: [{ foo: 1 }] }, ctx), /都要有 content/)
|
|
441
|
+
// 没有步骤就不该带出"0 步"这种没意义的摘要
|
|
442
|
+
assert.doesNotMatch(await executeTool('update_plan', {}, ctx), /0 步/)
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
test('isPlanToolName:归一化后判定,update_plan 与 updatePlan 同义', () => {
|
|
446
|
+
assert.equal(isPlanToolName('update_plan'), true)
|
|
447
|
+
assert.equal(isPlanToolName('updatePlan'), true)
|
|
448
|
+
assert.equal(isPlanToolName('TodoWrite'), true)
|
|
449
|
+
assert.equal(isPlanToolName('read_file'), false)
|
|
450
|
+
assert.equal(isPlanToolName(''), false)
|
|
451
|
+
})
|
package/src/cli/ai/turn.js
CHANGED
|
@@ -119,6 +119,9 @@ export async function runAgentTurn(state, userText, t, images = [], dependencies
|
|
|
119
119
|
try { output = await execute(name, args, { ...state.ctx, signal: state.abortController?.signal }) }
|
|
120
120
|
finally { toolSpinner.stop(); stats.toolsMs += performance.now() - toolStart }
|
|
121
121
|
ui.printToolResult(output, undefined, performance.now() - toolStart, { full: state.fullTools, locale: state.locale })
|
|
122
|
+
// 工具级的收尾渲染(目前只有 update_plan 画计划清单)。
|
|
123
|
+
// 走钩子而不是在这里写 if (name === 'x'):再加工具时这一行不用动。
|
|
124
|
+
ui.afterTool?.(name, args, output, { locale: state.locale })
|
|
122
125
|
} else ui.printToolResult(output)
|
|
123
126
|
}
|
|
124
127
|
state.messages.push({ role: 'tool', tool_call_id: tc.id, name, content: output })
|