zen-gitsync 2.17.24 → 2.17.25

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.
Files changed (71) hide show
  1. package/README.md +6 -0
  2. package/package.json +6 -3
  3. package/scripts/run-tests.cjs +5 -0
  4. package/src/cli/ai/agent.js +20 -0
  5. package/src/cli/ai/termui.js +73 -1
  6. package/src/cli/ai/termui.test.js +51 -0
  7. package/src/cli/ai/tools.js +114 -0
  8. package/src/cli/ai/tools.test.js +65 -1
  9. package/src/cli/ai/turn.js +3 -0
  10. package/src/memory/library.js +273 -0
  11. package/src/memory/library.test.js +187 -0
  12. package/src/memory/store.js +199 -0
  13. package/src/memory/store.test.js +137 -0
  14. package/src/memory/templates.js +113 -0
  15. package/src/paths.js +18 -0
  16. package/src/ui/public/assets/{AgentEngineSelector-CvIoGCLD.js → AgentEngineSelector-BArUIELz.js} +1 -1
  17. package/src/ui/public/assets/{AgentView-DnN-vCMi.css → AgentView-B3LhhRsy.css} +1 -1
  18. package/src/ui/public/assets/{AgentView-BrqS78Rt.js → AgentView-LpT8A9Fo.js} +1 -1
  19. package/src/ui/public/assets/{AppVersionBadge-BHxZm7-h.js → AppVersionBadge-DNXQm_Si.js} +2 -2
  20. package/src/ui/public/assets/{BranchSelector-w5ybmWVv.js → BranchSelector-DZPTz5Ql.js} +1 -1
  21. package/src/ui/public/assets/{CommitForm-pRCnAfz6.js → CommitForm-6avyu1Kv.js} +1 -1
  22. package/src/ui/public/assets/{CommonDialog-qXAsFhNS.js → CommonDialog-CRXyMlp5.js} +1 -1
  23. package/src/ui/public/assets/EditorView-BIHL8I9g.js +1 -0
  24. package/src/ui/public/assets/{EditorView-C6HFYeEM.css → EditorView-DqxBkn6c.css} +1 -1
  25. package/src/ui/public/assets/{FileDiffViewer-Bc8j9cQn.js → FileDiffViewer-BEYcCYs5.js} +1 -1
  26. package/src/ui/public/assets/{FlowExecutionViewer-CF4HkGu6.js → FlowExecutionViewer-DKRHDqbG.js} +1 -1
  27. package/src/ui/public/assets/{FlowOrchestrationWorkspace-a3Zk4BMK.js → FlowOrchestrationWorkspace-BnCsF4z4.js} +1 -1
  28. package/src/ui/public/assets/{IconButton-BjIHmE4p.js → IconButton-D4df4t9A.js} +1 -1
  29. package/src/ui/public/assets/{LogList-CqpSAW5T.js → LogList-DXqB4JJv.js} +1 -1
  30. package/src/ui/public/assets/{MindmapView-CgptM54l.js → MindmapView-Ddc55wzp.js} +1 -1
  31. package/src/ui/public/assets/{MonitorView-qb0EPvQW.js → MonitorView-DeERavq-.js} +1 -1
  32. package/src/ui/public/assets/{ProjectStartupButton-CmankQtk.js → ProjectStartupButton-DaLU4iS4.js} +1 -1
  33. package/src/ui/public/assets/{RecentDirectoriesChat-CsHHckPP.js → RecentDirectoriesChat-DVtsf7lW.js} +1 -1
  34. package/src/ui/public/assets/RecentDirectoriesChat-o4xK1Koc.css +1 -0
  35. package/src/ui/public/assets/{RemoteManagerDialog-Cpg4g5KW.js → RemoteManagerDialog-C3cfDsZi.js} +1 -1
  36. package/src/ui/public/assets/{RemoteRepoCard-DCc-adLb.js → RemoteRepoCard-D1Hso5Fq.js} +1 -1
  37. package/src/ui/public/assets/{SourceMapView-I41XvvZr.js → SourceMapView-Dwn5wy1o.js} +1 -1
  38. package/src/ui/public/assets/{SvgIcon-BB80ERhO.js → SvgIcon-diKLLWK9.js} +1 -1
  39. package/src/ui/public/assets/{TemplateManager-BB_bEiBB.js → TemplateManager-D9gyZbHP.js} +1 -1
  40. package/src/ui/public/assets/{UserInputNode-y9gz0EKH.js → UserInputNode-JNtVWcbE.js} +1 -1
  41. package/src/ui/public/assets/{WorkbenchView-Bkzbu5Sp.css → WorkbenchView-CJCxYK-K.css} +1 -1
  42. package/src/ui/public/assets/WorkbenchView-DxaAjZAA.js +6 -0
  43. package/src/ui/public/assets/_plugin-vue_export-helper-C7IvbEC3.js +19 -0
  44. package/src/ui/public/assets/agentConversations-nN-yDz8q.js +5 -0
  45. package/src/ui/public/assets/{configStore-BR8vEJCp.js → configStore-C-htWfWm.js} +1 -1
  46. package/src/ui/public/assets/{gitStore-Bz77W-S0.js → gitStore-DYpd8vZK.js} +1 -1
  47. package/src/ui/public/assets/index-BZFKDSGv.css +1 -0
  48. package/src/ui/public/assets/index-eq1OUcdk.js +29 -0
  49. package/src/ui/public/assets/{previewSandbox-luPexzF4.js → previewSandbox-Z707CZD9.js} +1 -1
  50. package/src/ui/public/assets/{toolsStore-CeI7jM5o.js → toolsStore-DFu4Alwg.js} +1 -1
  51. package/src/ui/public/assets/vendor-BY5ZDlW1.css +1 -0
  52. package/src/ui/public/assets/{vendor-CyWsJd6S.js → vendor-BjomZKSS.js} +28 -120
  53. package/src/ui/public/index.html +14 -14
  54. package/src/ui/server/index.js +4 -0
  55. package/src/ui/server/routes/fileOpen.js +43 -7
  56. package/src/ui/server/routes/memory.js +125 -0
  57. package/src/ui/server/routes/workbench/agentChat.js +35 -3
  58. package/src/ui/server/routes/workbench/agentChatShared.test.js +49 -0
  59. package/src/ui/server/routes/workbench/index.js +19 -2
  60. package/src/ui/server/routes/workbench/memoryContext.js +125 -0
  61. package/src/ui/server/routes/workbench/memoryContext.test.js +79 -0
  62. package/src/ui/server/routes/workbench/runExternalTurn.js +6 -1
  63. package/src/ui/server/routes/workbench/taskRunner.js +48 -5
  64. package/src/ui/public/assets/EditorView-DnuGnX3k.js +0 -1
  65. package/src/ui/public/assets/RecentDirectoriesChat-vHT8jMxW.css +0 -1
  66. package/src/ui/public/assets/WorkbenchView-DKqriLBP.js +0 -6
  67. package/src/ui/public/assets/_plugin-vue_export-helper-Dd_YmKEO.js +0 -19
  68. package/src/ui/public/assets/agentConversations-CT9E8eQr.js +0 -5
  69. package/src/ui/public/assets/index-CsLyZsXy.js +0 -29
  70. package/src/ui/public/assets/index-D2rf9QKB.css +0 -1
  71. package/src/ui/public/assets/vendor-BzMymUBi.css +0 -1
@@ -10,29 +10,29 @@
10
10
  <meta name="theme-color" content="#141820" media="(prefers-color-scheme: dark)" />
11
11
  <title>Zen GitSync</title>
12
12
  <!-- 字体改为自托管(@font-face 见 src/main.css),不再依赖 Google Fonts 外链 -->
13
- <script type="module" crossorigin src="/assets/index-CsLyZsXy.js"></script>
13
+ <script type="module" crossorigin src="/assets/index-eq1OUcdk.js"></script>
14
14
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
15
15
  <link rel="modulepreload" crossorigin href="/assets/element-plus-Bx3j6vzG.js">
16
16
  <link rel="modulepreload" crossorigin href="/assets/monaco-DkJuNcCr.js">
17
- <link rel="modulepreload" crossorigin href="/assets/vendor-CyWsJd6S.js">
18
- <link rel="modulepreload" crossorigin href="/assets/_plugin-vue_export-helper-Dd_YmKEO.js">
19
- <link rel="modulepreload" crossorigin href="/assets/SvgIcon-BB80ERhO.js">
20
- <link rel="modulepreload" crossorigin href="/assets/IconButton-BjIHmE4p.js">
21
- <link rel="modulepreload" crossorigin href="/assets/configStore-BR8vEJCp.js">
17
+ <link rel="modulepreload" crossorigin href="/assets/vendor-BjomZKSS.js">
18
+ <link rel="modulepreload" crossorigin href="/assets/_plugin-vue_export-helper-C7IvbEC3.js">
19
+ <link rel="modulepreload" crossorigin href="/assets/SvgIcon-diKLLWK9.js">
20
+ <link rel="modulepreload" crossorigin href="/assets/IconButton-D4df4t9A.js">
21
+ <link rel="modulepreload" crossorigin href="/assets/configStore-C-htWfWm.js">
22
22
  <link rel="modulepreload" crossorigin href="/assets/socket-io-TBtw0msS.js">
23
- <link rel="modulepreload" crossorigin href="/assets/gitStore-Bz77W-S0.js">
23
+ <link rel="modulepreload" crossorigin href="/assets/gitStore-DYpd8vZK.js">
24
24
  <link rel="modulepreload" crossorigin href="/assets/fileIcon-B5p5pGN9.js">
25
25
  <link rel="modulepreload" crossorigin href="/assets/flow-mindmap-OPE_D4da.js">
26
- <link rel="modulepreload" crossorigin href="/assets/previewSandbox-luPexzF4.js">
26
+ <link rel="modulepreload" crossorigin href="/assets/previewSandbox-Z707CZD9.js">
27
27
  <link rel="modulepreload" crossorigin href="/assets/useThemeObserver-DYxFoHh1.js">
28
- <link rel="modulepreload" crossorigin href="/assets/CommonDialog-qXAsFhNS.js">
29
- <link rel="modulepreload" crossorigin href="/assets/FileDiffViewer-Bc8j9cQn.js">
30
- <link rel="modulepreload" crossorigin href="/assets/toolsStore-CeI7jM5o.js">
31
- <link rel="modulepreload" crossorigin href="/assets/TemplateManager-BB_bEiBB.js">
28
+ <link rel="modulepreload" crossorigin href="/assets/CommonDialog-CRXyMlp5.js">
29
+ <link rel="modulepreload" crossorigin href="/assets/FileDiffViewer-BEYcCYs5.js">
30
+ <link rel="modulepreload" crossorigin href="/assets/toolsStore-DFu4Alwg.js">
31
+ <link rel="modulepreload" crossorigin href="/assets/TemplateManager-D9gyZbHP.js">
32
32
  <link rel="modulepreload" crossorigin href="/assets/vue-flow-fVVGZspa.js">
33
33
  <link rel="stylesheet" crossorigin href="/assets/element-plus-Qexo5hiM.css">
34
34
  <link rel="stylesheet" crossorigin href="/assets/monaco-B2lPvkrc.css">
35
- <link rel="stylesheet" crossorigin href="/assets/vendor-BzMymUBi.css">
35
+ <link rel="stylesheet" crossorigin href="/assets/vendor-BY5ZDlW1.css">
36
36
  <link rel="stylesheet" crossorigin href="/assets/SvgIcon-BQ6bR7l0.css">
37
37
  <link rel="stylesheet" crossorigin href="/assets/IconButton-W2X09OKH.css">
38
38
  <link rel="stylesheet" crossorigin href="/assets/flow-mindmap-BMpmHdqh.css">
@@ -41,7 +41,7 @@
41
41
  <link rel="stylesheet" crossorigin href="/assets/FileDiffViewer-B6zy6NiA.css">
42
42
  <link rel="stylesheet" crossorigin href="/assets/TemplateManager-DiM3vrbf.css">
43
43
  <link rel="stylesheet" crossorigin href="/assets/vue-flow-CavUTEzp.css">
44
- <link rel="stylesheet" crossorigin href="/assets/index-D2rf9QKB.css">
44
+ <link rel="stylesheet" crossorigin href="/assets/index-BZFKDSGv.css">
45
45
  </head>
46
46
  <body>
47
47
  <div id="app"></div>
@@ -52,6 +52,7 @@ import { registerCodeAnalysisRoutes } from './routes/codeAnalysis.js';
52
52
  import { registerInstancesRoutes } from './routes/instances.js';
53
53
  import { registerMonitorRoutes } from './routes/monitor.js';
54
54
  import { registerMindmapRoutes } from './routes/mindmap.js';
55
+ import { registerMemoryRoutes } from './routes/memory.js';
55
56
  import { registerAgentRoutes } from './routes/workbench/agentRoutes.js';
56
57
  import { createWorkspaceSnapshotter } from './routes/aiContext/wiring.js';
57
58
  import { createInstanceRegistry, getRegistryPath } from './utils/instanceRegistry.js';
@@ -478,6 +479,9 @@ async function startUIServer(noOpen = false, savePort = false) {
478
479
  // 思维导图:.mindmap.json 文件的列/读/写/建/删/重命名
479
480
  registerMindmapRoutes({ app });
480
481
 
482
+ // 记忆库:跨轮经验的浏览 / 读正文 / 删条目(设置 → 记忆库面板)
483
+ registerMemoryRoutes({ app });
484
+
481
485
  // 智能体:Web 端 AI 编码助手(含工具调用 + 会话持久化)
482
486
  // 快照生成器在上面已经建好(要走同一个实例),这里注入进去复用。
483
487
  registerAgentRoutes({
@@ -437,14 +437,46 @@ async function launchClaudeCode(dirPath, { permissionMode } = {}) {
437
437
  return launchInTerminal(dirPath, 'claude', cliArgs);
438
438
  }
439
439
 
440
- async function launchCodex(dirPath) {
441
- // OpenAI Codex CLI - 无 permissionMode 参数(与 claude 不同)
440
+ /**
441
+ * codex 的启动参数(按权限档位)。与 claude / opencode 的档位对应关系:
442
+ *
443
+ * 默认(不带 token) → 一个 flag 都不加,跟随 `~/.codex/config.toml` 的
444
+ * approval / sandbox 设置
445
+ * 'sandboxed' → `-a never -s workspace-write`:**从不问人,但命令仍在沙箱里跑**
446
+ * (写限工作区、默认无网络)。这是"不想被批准提示打断、又不想
447
+ * 完全交出去"的那一档,语义上对应 claude 的「批准文件编辑」。
448
+ * 'bypass' → `--dangerously-bypass-approvals-and-sandbox`:免批准 + 免沙箱。
449
+ * 官方帮助原文 "Skip all confirmation prompts and execute commands
450
+ * without sandboxing. EXTREMELY DANGEROUS."。**完全批准**就是它。
451
+ * 'bypassPermissions'→ 同上。只为让调用方(顶栏菜单与编排台项目菜单)跟 claude 那档
452
+ * 共用同一个字符串,不是第三种行为。
453
+ *
454
+ * ⚠️ codex 没有 claude 的 `--permission-mode`,两档都得自己拼 flag。
455
+ * ⚠️ 别只看 sandbox:本机 config.toml 写的是 `sandbox_mode = "danger-full-access"`,
456
+ * 也就是说这台机器的**默认档本来就没有沙箱**,完全批准档真正多出来的只是"不再弹批准"。
457
+ *
458
+ * 回归 `test/open-with-permission.test.mjs`(与 opencode 那份参数映射放一起验)。
459
+ */
460
+ const CODEX_MODE_FLAGS = new Map([
461
+ ['sandboxed', ['-a', 'never', '-s', 'workspace-write']],
462
+ ['bypass', ['--dangerously-bypass-approvals-and-sandbox']],
463
+ ['bypassPermissions', ['--dangerously-bypass-approvals-and-sandbox']],
464
+ ]);
465
+
466
+ export function buildCodexArgs(permissionMode) {
467
+ // Map.get 对非字符串返回 undefined → 未知 token 一律空数组,白名单式,不存在拼参数注入
468
+ return [...(CODEX_MODE_FLAGS.get(permissionMode) || [])];
469
+ }
470
+
471
+ async function launchCodex(dirPath, { permissionMode } = {}) {
472
+ // OpenAI Codex CLI - https://github.com/openai/codex
473
+ const cliArgs = buildCodexArgs(permissionMode);
442
474
  if (process.platform === 'win32') {
443
- return spawnDetached('cmd.exe', ['/c', 'start', '""', 'codex'], {
475
+ return spawnDetached('cmd.exe', ['/c', 'start', '""', 'codex', ...cliArgs], {
444
476
  cwd: dirPath
445
477
  });
446
478
  }
447
- return launchInTerminal(dirPath, 'codex');
479
+ return launchInTerminal(dirPath, 'codex', cliArgs);
448
480
  }
449
481
 
450
482
  /**
@@ -986,7 +1018,7 @@ export function registerFileOpenRoutes({
986
1018
  // 用 Codex 打开目录
987
1019
  app.post('/api/open-directory-with-codex', asyncRoute(async (req, res) => {
988
1020
  try {
989
- const { path: dirPath } = req.body || {};
1021
+ const { path: dirPath, permissionMode } = req.body || {};
990
1022
  if (!dirPath) {
991
1023
  throw new HttpError(400, '目录路径不能为空');
992
1024
  }
@@ -998,8 +1030,12 @@ export function registerFileOpenRoutes({
998
1030
  }
999
1031
 
1000
1032
  try {
1001
- await launchCodex(dirPath);
1002
- res.json({ success: true, message: '已用 Codex 打开目录' });
1033
+ await launchCodex(dirPath, { permissionMode });
1034
+ const codexArgs = buildCodexArgs(permissionMode);
1035
+ const message = codexArgs.length
1036
+ ? `已用 Codex 打开目录(${codexArgs.join(' ')})`
1037
+ : '已用 Codex 打开目录';
1038
+ res.json({ success: true, message });
1003
1039
  } catch (error) {
1004
1040
  res.status(400).json({
1005
1041
  success: false,
@@ -0,0 +1,125 @@
1
+ // Copyright 2026 xz333221
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ //
15
+ // 记忆库的浏览 / 删除接口(设置 → 记忆库面板用)。
16
+ //
17
+ // 口径(与其它 workbench 路由一致,详见 routes/workbench/index.js 头部三条):
18
+ // - 非 SSE 路由统一 asyncRoute 包装,**handler 内不写 try/catch**
19
+ // - 定制状态码用 `throw new HttpError(4xx, msg)`
20
+ // - 危险操作(删)要求调用方显式传 `confirm: true`
21
+ //
22
+ // ⚠️ **本文件不导出"任意路径"接口。** 前端只传 `{scope, file}`,
23
+ // 绝对路径由 src/memory/library.js 从 scope 重新拼(见那个文件头注释)。
24
+ // 这是结构性防越界:不合法参数压根不参与路径拼接,
25
+ // 也就没有"前端传个 path 就删到别处"的可能。**别为了"省一次校验"改成传路径。**
26
+
27
+ import { asyncRoute, HttpError } from '../utils/asyncRoute.js';
28
+ import {
29
+ listScopes,
30
+ listEntries,
31
+ readEntry,
32
+ deleteEntry,
33
+ isValidScope,
34
+ isValidFile,
35
+ memoryDirExists,
36
+ SCOPE_GLOBAL,
37
+ SCOPE_GLOBAL_INDEX,
38
+ } from '../../../memory/library.js';
39
+
40
+ /** 批量删除的单次上限:再多就该分批,别让一个请求把整个库清空 */
41
+ const BATCH_MAX = 200;
42
+
43
+ function requireScope(scope) {
44
+ if (!isValidScope(scope)) throw new HttpError(400, 'scope 不合法');
45
+ return scope;
46
+ }
47
+
48
+ function requireFile(file) {
49
+ if (!isValidFile(file)) throw new HttpError(400, 'file 不合法');
50
+ return file;
51
+ }
52
+
53
+ export function registerMemoryRoutes({ app }) {
54
+ // ── 有哪些可看的范围(全局两篇 + 每个项目)──────────────────────────
55
+ app.get('/api/memory/scopes', asyncRoute(async (_req, res) => {
56
+ const available = await memoryDirExists();
57
+ if (!available) {
58
+ // 没就绪是**合法状态**(用户还没开过工作台),给空列表而不是 500:
59
+ // 面板据此显示"尚未初始化",而不是一个看不懂的报错
60
+ res.json({ success: true, available: false, scopes: [] });
61
+ return;
62
+ }
63
+ res.json({ success: true, available: true, scopes: await listScopes() });
64
+ }));
65
+
66
+ // ── 某个范围下有哪些条目 ────────────────────────────────────────────
67
+ app.get('/api/memory/entries', asyncRoute(async (req, res) => {
68
+ const scope = requireScope(String(req.query.scope || ''));
69
+ res.json({ success: true, scope, entries: await listEntries(scope) });
70
+ }));
71
+
72
+ // ── 读单条正文(展开看内容时按需拉)─────────────────────────────────
73
+ app.get('/api/memory/entry', asyncRoute(async (req, res) => {
74
+ const scope = requireScope(String(req.query.scope || ''));
75
+ const file = requireFile(String(req.query.file || ''));
76
+ const r = await readEntry(scope, file);
77
+ if (r.error === 'invalid') throw new HttpError(400, 'scope 或 file 不合法');
78
+ if (r.tooLarge) throw new HttpError(413, `内容过大(${Math.round(r.size / 1024)} KB),不予展示`);
79
+ if (r.missing) throw new HttpError(404, '条目不存在');
80
+ res.json({ success: true, scope, file, content: r.content, size: r.size, mtime: r.mtime });
81
+ }));
82
+
83
+ // ── 删一条(连带清掉 INDEX.md 里那行索引)───────────────────────────
84
+ app.delete('/api/memory/entry', asyncRoute(async (req, res) => {
85
+ if (req.body?.confirm !== true) throw new HttpError(400, '需要 confirm: true');
86
+ const scope = requireScope(String(req.body?.scope || ''));
87
+ const file = requireFile(String(req.body?.file || ''));
88
+ // 全局两篇是索引/规范本身,删了整套记忆就失去记账口径 —— 不给删
89
+ if (scope === SCOPE_GLOBAL || scope === SCOPE_GLOBAL_INDEX) {
90
+ throw new HttpError(400, '全局索引与规范文件不可删除');
91
+ }
92
+ const r = await deleteEntry(scope, file);
93
+ res.json({ success: true, ...r });
94
+ }));
95
+
96
+ // ── 批量删 ──────────────────────────────────────────────────────────
97
+ app.post('/api/memory/batch-delete', asyncRoute(async (req, res) => {
98
+ if (req.body?.confirm !== true) throw new HttpError(400, '需要 confirm: true');
99
+ const items = Array.isArray(req.body?.items) ? req.body.items : [];
100
+ if (items.length === 0) throw new HttpError(400, '没有选中任何条目');
101
+ if (items.length > BATCH_MAX) throw new HttpError(400, `一次最多删 ${BATCH_MAX} 条`);
102
+
103
+ let removed = 0;
104
+ let indexLinesRemoved = 0;
105
+ const failed = [];
106
+ for (const it of items) {
107
+ const scope = String(it?.scope || '');
108
+ const file = String(it?.file || '');
109
+ if (!isValidScope(scope) || !isValidFile(file)) { failed.push({ scope, file, reason: 'invalid' }); continue; }
110
+ if (scope === SCOPE_GLOBAL || scope === SCOPE_GLOBAL_INDEX) {
111
+ failed.push({ scope, file, reason: 'protected' });
112
+ continue;
113
+ }
114
+ try {
115
+ const r = await deleteEntry(scope, file);
116
+ if (r.removed) removed += 1;
117
+ if (r.indexLineRemoved) indexLinesRemoved += 1;
118
+ } catch (err) {
119
+ // 单条失败**不中断整批** —— 否则前几条已删、后几条报错,用户重试会重复删
120
+ failed.push({ scope, file, reason: err?.message || 'delete failed' });
121
+ }
122
+ }
123
+ res.json({ success: true, removed, indexLinesRemoved, failed });
124
+ }));
125
+ }
@@ -21,7 +21,10 @@
21
21
  // - { type: 'meta', sessionId, isNew, title }
22
22
  // - { type: 'thinking', delta } — 推理过程增量
23
23
  // - { type: 'content', delta } — 正文增量
24
- // - { type: 'tool_call_start', toolCallId, name, argsPreview }
24
+ // - { type: 'tool_call_start', toolCallId, name, argsPreview, arguments }
25
+ // arguments 只在计划类工具(update_plan 等)上出现:前端要拿原始 steps 渲染
26
+ // 计划清单,argsPreview 是截断过的摘要拆不出步骤。其他工具不带全文,免得
27
+ // write_file 那种几十 KB 的 content 灌进 SSE。
25
28
  // - { type: 'tool_output', toolCallId, chunk } — 命令执行中的增量输出(仅展示)
26
29
  // - { type: 'tool_result', toolCallId, name, result }
27
30
  // - { type: 'ask_user', interactionId, question, options, allowFreeText, multiple }
@@ -33,7 +36,7 @@ import os from 'os';
33
36
  import { logger } from './shared.js';
34
37
 
35
38
  // 从 CLI 侧导入工具定义、执行器与 LLM 传输层(同一 monorepo,路径可达)
36
- import { TOOL_DEFINITIONS, executeTool } from '../../../../cli/ai/tools.js';
39
+ import { TOOL_DEFINITIONS, executeTool, normalizePlanSteps, summarizePlan, isPlanToolName } from '../../../../cli/ai/tools.js';
37
40
  import { prepareRequestMessages } from '../../../../cli/ai/context.js';
38
41
  import { streamChatOnce } from '../../../../cli/ai/transport.js';
39
42
  import { checkDangerousCommand } from '../../../../cli/ai/safety.js';
@@ -131,6 +134,15 @@ ${isWin ? `- 当前是 Windows,以下 Unix 命令**不存在**,用了必定报"
131
134
  - run_command 默认就在工作目录执行,不要再加 cd 前缀;默认超时 120 秒,长任务加大 timeout_seconds(最大 600)
132
135
  - 命令在 ${shellDesc} 下执行,注意语法兼容
133
136
 
137
+ # 任务计划(多步任务必用)
138
+ - 任务需要多步时(改代码、排查、调研、多文件改动),**动手之前**先调一次 update_plan,把要做的事
139
+ 拆成 3-8 个可核对的步骤,让用户在你改任何东西之前就知道范围和验收标准
140
+ - 单步小事(读个文件、答一个问题)不要列计划,那是噪音
141
+ - 每次都传**完整**的当前计划(不是增量),顺序即执行顺序;同一时刻最多一个 in_progress
142
+ - 完成一步就更新一次状态,别攒到最后一次性刷;计划与实际不符时直接改计划,不要硬着头皮往下走
143
+ - 全部做完后把 steps 传空表示收尾
144
+ - update_plan 只是给人看的进度板,不要为了"更新计划"去改文件或跑命令
145
+
134
146
  # 与用户交互
135
147
  - 需要向用户确认、提问或汇报重要决策时,直接用普通文本输出
136
148
  - 需要暂停当前任务并等待用户决定或补充信息时,调用 ask_user,不要猜测或只在普通文本里提问
@@ -213,6 +225,17 @@ ${isWin ? `- This is Windows. The following Unix commands do NOT exist here:
213
225
  - run_command defaults to the working directory; default timeout 120s, max 600s
214
226
  - When the task must pause for a decision or missing detail, call ask_user and wait for the user's answer. The user may attach images to a message; they arrive as image_url parts in the user message. If the current model rejects images (no vision support), tell the user to switch to a vision-capable model
215
227
 
228
+ # Task plan (required for multi-step work)
229
+ - When a task takes several steps (code changes, debugging, research, multi-file edits), call update_plan
230
+ **before touching anything**: break it into 3-8 verifiable steps so the user knows the scope and the
231
+ acceptance criteria before you modify a single file
232
+ - Do not plan single trivial actions (read one file, answer one question) — that is noise
233
+ - Always send the **complete** current plan (not a delta), in execution order; at most one in_progress
234
+ - Update the status right after finishing a step instead of batching everything at the end; when the
235
+ plan no longer matches reality, rewrite the plan instead of pushing ahead
236
+ - Send an empty steps list once everything is done
237
+ - update_plan is a progress board for humans: never edit files or run commands just to "update the plan"
238
+
216
239
  # Output
217
240
  - Your text output is displayed in the user's Web UI
218
241
  - After completing a task, briefly summarize the result`;
@@ -384,7 +407,11 @@ export async function runAgentTurn({ session, model, userMessage, images = [], c
384
407
 
385
408
  // 工具参数预览(给前端展示)
386
409
  const argsPreview = summarizeArgs(name, args);
387
- send({ type: 'tool_call_start', toolCallId, name, argsPreview });
410
+ // 计划类工具额外发**完整参数**:前端要拿原始 steps 渲染清单,而 argsPreview
411
+ // 是截断过的摘要,拆不出步骤。只给计划类工具发完整参数 ——
412
+ // write_file 的 content 可能几十 KB,为它开一路全文等于给 SSE 带宽找麻烦。
413
+ const rawArgsForClient = isPlanToolName(name) ? rawArgs : undefined;
414
+ send({ type: 'tool_call_start', toolCallId, name, argsPreview, arguments: rawArgsForClient });
388
415
 
389
416
  const toolCtx = {
390
417
  ...ctx,
@@ -430,6 +457,11 @@ function summarizeArgs(name, args) {
430
457
  return String(args.path || '.');
431
458
  case 'search_text':
432
459
  return String(args.pattern || '');
460
+ case 'update_plan': {
461
+ const steps = normalizePlanSteps(args.steps ?? args.todos ?? args.plan);
462
+ const why = String(args.explanation || '').replace(/\s+/g, ' ').trim();
463
+ return [summarizePlan(steps), why].filter(Boolean).join(' — ').slice(0, 200);
464
+ }
433
465
  default:
434
466
  return JSON.stringify(args).slice(0, 200);
435
467
  }
@@ -179,3 +179,52 @@ test('400 且正文提到 tool/function 时提示换模型,而不是甩网关 JS
179
179
  assert.match(error.error, /不支持 function calling/)
180
180
  assert.match(error.error, /换用支持工具调用的模型/)
181
181
  })
182
+
183
+ // ── 计划工具的透传契约 ──────────────────────────────────────
184
+ // 前端要拿原始 steps 渲染计划清单,而它拿到的是 argsPreview(截断过的摘要)——
185
+ // 摘要里拆不出步骤,所以服务端必须为计划类工具额外带一份完整 arguments。
186
+ // 这条钉死了「只给计划类发全文」的口径:多发会让 write_file 的 content
187
+ // 几十 KB 灌进 SSE,少发则前端永远退化成普通工具块。
188
+ test('计划类工具的 tool_call_start 带完整 arguments,其他工具不带', async () => {
189
+ const planArgs = JSON.stringify({
190
+ steps: [
191
+ { content: '读代码', status: 'completed' },
192
+ { content: '改代码', status: 'in_progress' }
193
+ ],
194
+ explanation: '先改库再改宿主'
195
+ })
196
+ const round = [
197
+ sse([event({
198
+ choices: [{ delta: { tool_calls: [
199
+ { index: 0, id: 'p1', function: { name: 'update_plan', arguments: planArgs } },
200
+ { index: 1, id: 'p2', function: { name: 'write_file', arguments: JSON.stringify({ path: 'a.js', content: 'x'.repeat(50) }) } }
201
+ ] } }, { finish_reason: 'tool_calls' }],
202
+ }), 'data: [DONE]\n\n']),
203
+ okStream(),
204
+ ]
205
+ let turn = 0
206
+ const session = newSession('ag-sandbox-plan', withSystem())
207
+
208
+ const { events } = await runWeb({
209
+ session,
210
+ userMessage: '把这个功能做出来',
211
+ respond: () => round[turn++] || okStream(),
212
+ })
213
+
214
+ const starts = events.filter(e => e.type === 'tool_call_start')
215
+ assert.equal(starts.length, 2)
216
+ const planStart = starts.find(e => e.name === 'update_plan')
217
+ const writeStart = starts.find(e => e.name === 'write_file')
218
+
219
+ // 计划:全文必须一字不差地带到前端(前端靠它渲染清单)
220
+ assert.equal(planStart.arguments, planArgs)
221
+ assert.match(planStart.argsPreview, /1\/2|1 完成/)
222
+ // 其他工具:只给摘要,不灌全文
223
+ assert.equal(writeStart.arguments, undefined)
224
+
225
+ const planResult = events.find(e => e.type === 'tool_result' && e.name === 'update_plan')
226
+ assert.match(planResult.result, /计划已更新/)
227
+ // 计划也要进会话记录 —— 重新打开会话时前端靠历史里的 arguments 复原清单
228
+ const assistantWithPlan = session.messages.find(m => m.role === 'assistant' && m.tool_calls?.some(t => t.function.name === 'update_plan'))
229
+ assert.equal(assistantWithPlan.tool_calls[0].function.arguments, planArgs)
230
+ })
@@ -108,6 +108,7 @@ import {
108
108
  canonicalProjectPath,
109
109
  } from './projectRegistry.js';
110
110
  import { buildEnvContextBlock } from './envContext.js';
111
+ import { ensureMemoryStore } from '../../../../memory/store.js';
111
112
  import { createJobSettledRefresher } from '../aiContext/jobRefresh.js';
112
113
  import {
113
114
  createDispatcher,
@@ -1157,6 +1158,23 @@ ${subSummaries.map((s, i) => `\n### [${i + 1}] ${s.name} (${s.root})\n${s.summar
1157
1158
  });
1158
1159
  });
1159
1160
 
1161
+ /**
1162
+ * 铺一次记忆库种子(幂等,only-if-missing)。
1163
+ *
1164
+ * **为什么在这里而不是 taskRunner 每次派发时**:目录不存在时若每次派发都尝试
1165
+ * 铺,等于把"有没有记忆"绑在"这次有没有干活"上;而且首次铺种子的 IO 抖动会
1166
+ * 落在派发的关键路径上。注册路由时铺一次,语义是"工作台起来了,记忆库就位"。
1167
+ *
1168
+ * 失败只记日志不抛:记忆库铺不出来不该让整个工作台起不来
1169
+ * (与上面 resolveEnvContext 同一原则)。真正注入时 taskRunner 拿不到项目索引
1170
+ * 会自动降级成"只有全局那层",Agent 照样能用。
1171
+ */
1172
+ ensureMemoryStore()
1173
+ .then(({ created }) => {
1174
+ if (created.length) logger.info(`[workbench] 已铺记忆库种子: ${created.join(', ')}`);
1175
+ })
1176
+ .catch((err) => logger.warn('[workbench] 铺记忆库种子失败,记忆功能降级:', err?.message || err));
1177
+
1160
1178
  /**
1161
1179
  * 「工作台任务执行结束」→ 定向刷新快照。
1162
1180
  *
@@ -1165,8 +1183,7 @@ ${subSummaries.map((s, i) => `\n### [${i + 1}] ${s.name} (${s.root})\n${s.summar
1165
1183
  *
1166
1184
  * `bus.on` 前先 `off` 掉上一个:bus 是模块级单例,重复注册(例如同一进程里
1167
1185
  * 再次装配路由)会让同一次 job 结束被刷好几遍。
1168
- */
1169
- if (jobSettledHandler) bus.off('event', jobSettledHandler);
1186
+ */ if (jobSettledHandler) bus.off('event', jobSettledHandler);
1170
1187
  jobSettledHandler = createJobSettledRefresher({
1171
1188
  getSnapshotter: getAiContextSnapshotter,
1172
1189
  onError: (err) => logger.warn('[workbench] 任务结束后刷新工作区快照失败:', err?.message || err),
@@ -0,0 +1,125 @@
1
+ // Copyright 2026 xz333221
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ //
15
+ // 派发任务时注入给 Agent 的「记忆指针块」—— 让被派出去的 agent 知道
16
+ // 本机有一份跨会话经验库、以及**怎么按预算的方式去读它**。
17
+ //
18
+ // ── 与 envContext 的关系(同一个位置,两件事)────────────────────────
19
+ // envContext.js 讲的是"这台机器此刻的现场"(项目清单 / 看板 / 真相源路径),
20
+ // 快照语义、每次都变;这里讲的是"这个仓库踩过的坑",长期累积、很少变。
21
+ // 两者都在 taskRunner.js 拼 prompt 时前置,注入失败都不该挡住任务执行。
22
+ //
23
+ // ── 为什么用"指针"而不是把索引内容塞进 prompt ───────────────────────
24
+ // 把 INDEX.md 全文塞进 prompt 是最省事也最糟的做法:索引会随记忆增长线性膨胀,
25
+ // 而**每次派发都要为每一条不相关的历史经验付 token**。塞路径 + 塞纪律(各 5~8 行),
26
+ // 剩下的交给 Agent 自己的 read 工具按需取 —— 这正是"索引式摘要"这个需求的核心。
27
+ //
28
+ // ⚠️ 文案可以硬编码中文(与 envContext.js 同一口径):这里是喂给模型的 prompt
29
+ // 内容,不经过前端 $t()、不显示在界面上,也不该跟着界面语言变。
30
+ //
31
+ // 纯函数:只吃入参、不读文件、不做 IO。这样单测不用碰真实用户数据 ——
32
+ // 与 envContext.js / promptParts.js 同一个理由。取数在 store.js。
33
+
34
+ /** 指针块自身的体积上限(字节)。超了就是文案写飘了,Agent 读它比查索引还贵。 */
35
+ export const MEMORY_BLOCK_MAX_BYTES = 2048;
36
+
37
+ /** 状态标签:给 UI / 日志判断"这次注入了几层信息",不参与 prompt 语义 */
38
+ export const MEMORY_BLOCK_LEVEL = {
39
+ /** 记忆库不可用(目录不存在且铺种子失败)—— 完全不注入 */
40
+ NONE: 'none',
41
+ /** 库在,但没有本项目目录 —— 只给全局路径 */
42
+ GLOBAL_ONLY: 'global-only',
43
+ /** 库在且本项目目录在 —— 给全局 + 本项目两个路径 */
44
+ PROJECT: 'project',
45
+ };
46
+
47
+ /**
48
+ * 拼记忆指针块。
49
+ *
50
+ * @param {object} input
51
+ * @param {string} input.memoryDir 记忆库根目录(~/<pkg>/memory 的绝对路径)
52
+ * @param {string} [input.projectIndexFile] 本项目索引绝对路径;空/不存在 → 只给全局那层
53
+ * @param {boolean} [input.capture=true] 是否附上"收尾时怎么记"那一段。
54
+ * 续聊轮可以关掉(同一场对话里第二、三轮再讲一遍捕获纪律是噪音)。
55
+ * @param {string} [input.locale] 暂未使用;预留让文案随界面语言走(中文硬编码是
56
+ * 当前口径,见文件头注释)
57
+ * @returns {{ block: string, level: string }} block 为 '' 表示不注入
58
+ */
59
+ export function buildMemoryPointerBlock(input = {}) {
60
+ const {
61
+ memoryDir = '',
62
+ projectIndexFile = '',
63
+ capture = true,
64
+ } = input;
65
+
66
+ if (!memoryDir) return { block: '', level: MEMORY_BLOCK_LEVEL.NONE };
67
+
68
+ const lines = [
69
+ '## 跨会话记忆库(本机)',
70
+ '',
71
+ `本机有一份长期经验库,是**之前在这个仓库干活的 agent 留下的坑**。开工前先查,别重复踩:`,
72
+ '',
73
+ `- 全局索引:\`${memoryDir}/INDEX.md\`(项目清单 + 通用坑主题词)`,
74
+ ];
75
+
76
+ let level = MEMORY_BLOCK_LEVEL.GLOBAL_ONLY;
77
+ if (projectIndexFile) {
78
+ lines.push(`- **本项目索引:\`${projectIndexFile}\`** ← 先读这个`);
79
+ level = MEMORY_BLOCK_LEVEL.PROJECT;
80
+ } else {
81
+ lines.push('- 本项目还没有记忆库条目(首次在本仓库干活时会被自动建好)');
82
+ }
83
+
84
+ lines.push(
85
+ '',
86
+ '**怎么读(别烧 token)**:',
87
+ '',
88
+ '1. 用 read 工具读上面那个索引;',
89
+ '2. 索引里某条的关键词和当前任务**真正相关**时,才去读它指向的 `lessons/<topic>.md`;',
90
+ '3. 没命中就别读。**不要预加载全部 lessons,也不要读 `archive/`** —— ',
91
+ ' 索引是给你做取舍的,不是让你通读的。',
92
+ );
93
+
94
+ if (capture) {
95
+ lines.push(
96
+ '',
97
+ '**收尾时(做完了、准备回复之前)自检一次**,四条里任意一条为"是"就记一条:',
98
+ '',
99
+ '1. 本轮卡住 ≥3 分钟,或走了 A→放弃→B→放弃→C 的反复路径;',
100
+ '2. 用错了工具 / 写错了文件 / grep 命中被构建产物淹没 / 探针假绿;',
101
+ '3. 用户纠正了你的方向、用法或命名;',
102
+ '4. 发现"看似绕路但更快"或"反直觉但正确"、且被验证有效的做法。',
103
+ '',
104
+ '记的时候:',
105
+ '',
106
+ '- 写"**以后怎么做**",不写"我做了什么"(后者是 commit message 的活);',
107
+ '- 单条 ≤25 行,形状 ` lessons/<topic>.md`:`# 一句话讲清坑是什么` / 现象根因 / `**How to apply:**`;',
108
+ '- 同主题**追加/改写已有文件**,不要新建近似重复的(先 read 一眼);',
109
+ '- 跨仓库都成立 → 写 `${memoryDir}/GLOBAL.md`;只跟本仓库有关 → 写本项目 `lessons/`;',
110
+ `- 写完**必须在对应 INDEX.md 补一行** \`- [标题](lessons/<file>.md) — ≤100 字摘要\`。**没索引的记忆等于不存在。**`,
111
+ '- 四条全否 → 一条都别写。不要为沉淀而沉淀。',
112
+ );
113
+ }
114
+
115
+ let block = lines.join('\n');
116
+
117
+ // 文案哪天写飘了也不许把 prompt 撑大:超预算就砍掉捕获段(最长的可省部分),
118
+ // 召回指针本身必须留着 —— 那是这个块存在的全部理由。
119
+ if (Buffer.byteLength(block, 'utf8') > MEMORY_BLOCK_MAX_BYTES && capture) {
120
+ lines.length = lines.findIndex((l) => l.includes('收尾时'));
121
+ block = lines.join('\n').trimEnd();
122
+ }
123
+
124
+ return { block, level };
125
+ }