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.
Files changed (79) hide show
  1. package/README.md +6 -0
  2. package/package.json +6 -3
  3. package/scripts/release.js +8 -10
  4. package/scripts/run-tests.cjs +5 -0
  5. package/src/cli/ai/agent.js +20 -0
  6. package/src/cli/ai/termui.js +73 -1
  7. package/src/cli/ai/termui.test.js +51 -0
  8. package/src/cli/ai/tools.js +116 -1
  9. package/src/cli/ai/tools.test.js +65 -1
  10. package/src/cli/ai/turn.js +3 -0
  11. package/src/memory/library.js +273 -0
  12. package/src/memory/library.test.js +187 -0
  13. package/src/memory/store.js +199 -0
  14. package/src/memory/store.test.js +137 -0
  15. package/src/memory/templates.js +113 -0
  16. package/src/paths.js +18 -0
  17. package/src/ui/public/assets/{AgentEngineSelector-CvIoGCLD.js → AgentEngineSelector-CEayBStS.js} +1 -1
  18. package/src/ui/public/assets/{AgentView-DnN-vCMi.css → AgentView-B3LhhRsy.css} +1 -1
  19. package/src/ui/public/assets/{AgentView-BrqS78Rt.js → AgentView-BhLlAvng.js} +1 -1
  20. package/src/ui/public/assets/{AppVersionBadge-BHxZm7-h.js → AppVersionBadge-btxeyJpH.js} +2 -2
  21. package/src/ui/public/assets/{BranchSelector-w5ybmWVv.js → BranchSelector-ERybqfLW.js} +1 -1
  22. package/src/ui/public/assets/{CommitForm-pRCnAfz6.js → CommitForm-DcXdYwKn.js} +1 -1
  23. package/src/ui/public/assets/{CommonDialog-qXAsFhNS.js → CommonDialog-5bwugMhs.js} +1 -1
  24. package/src/ui/public/assets/EditorView-BzxNDG8A.js +1 -0
  25. package/src/ui/public/assets/{EditorView-C6HFYeEM.css → EditorView-DqxBkn6c.css} +1 -1
  26. package/src/ui/public/assets/{FileDiffViewer-Bc8j9cQn.js → FileDiffViewer-BBOtL3bR.js} +1 -1
  27. package/src/ui/public/assets/{FlowExecutionViewer-CF4HkGu6.js → FlowExecutionViewer-BPMDn3Md.js} +1 -1
  28. package/src/ui/public/assets/{FlowOrchestrationWorkspace-a3Zk4BMK.js → FlowOrchestrationWorkspace-PtCM4LB1.js} +1 -1
  29. package/src/ui/public/assets/{IconButton-BjIHmE4p.js → IconButton-BELiW6cx.js} +1 -1
  30. package/src/ui/public/assets/{LogList-CqpSAW5T.js → LogList-BAD5dV_N.js} +1 -1
  31. package/src/ui/public/assets/{MindmapView-CgptM54l.js → MindmapView-BAwb7Gs1.js} +1 -1
  32. package/src/ui/public/assets/{MonitorView-qb0EPvQW.js → MonitorView-DIqKQfiM.js} +1 -1
  33. package/src/ui/public/assets/{ProjectStartupButton-CmankQtk.js → ProjectStartupButton-DnQyvZOG.js} +1 -1
  34. package/src/ui/public/assets/{RecentDirectoriesChat-CsHHckPP.js → RecentDirectoriesChat-BMZA0DgP.js} +1 -1
  35. package/src/ui/public/assets/RecentDirectoriesChat-o4xK1Koc.css +1 -0
  36. package/src/ui/public/assets/{RemoteManagerDialog-Cpg4g5KW.js → RemoteManagerDialog-BmdcIfwA.js} +1 -1
  37. package/src/ui/public/assets/{RemoteRepoCard-DCc-adLb.js → RemoteRepoCard-DqKIIcGh.js} +1 -1
  38. package/src/ui/public/assets/{SourceMapView-I41XvvZr.js → SourceMapView-Sd_871DD.js} +1 -1
  39. package/src/ui/public/assets/{SvgIcon-BB80ERhO.js → SvgIcon-DyQoHzNw.js} +1 -1
  40. package/src/ui/public/assets/{TemplateManager-BB_bEiBB.js → TemplateManager-BI1EXn7d.js} +1 -1
  41. package/src/ui/public/assets/{UserInputNode-y9gz0EKH.js → UserInputNode-3qtC_swg.js} +1 -1
  42. package/src/ui/public/assets/{WorkbenchView-Bkzbu5Sp.css → WorkbenchView-BNxybEqF.css} +1 -1
  43. package/src/ui/public/assets/WorkbenchView-wAdnj8Kl.js +6 -0
  44. package/src/ui/public/assets/_plugin-vue_export-helper-CGArUtKL.js +19 -0
  45. package/src/ui/public/assets/agentConversations-cmS8iux5.js +5 -0
  46. package/src/ui/public/assets/{configStore-BR8vEJCp.js → configStore-Vh8LZWlL.js} +1 -1
  47. package/src/ui/public/assets/{gitStore-Bz77W-S0.js → gitStore-B9gZQf2q.js} +1 -1
  48. package/src/ui/public/assets/index-BZFKDSGv.css +1 -0
  49. package/src/ui/public/assets/index-DE-31lcL.js +29 -0
  50. package/src/ui/public/assets/{previewSandbox-luPexzF4.js → previewSandbox-DmecmT9g.js} +1 -1
  51. package/src/ui/public/assets/{toolsStore-CeI7jM5o.js → toolsStore-DgCBDdhm.js} +1 -1
  52. package/src/ui/public/assets/vendor-BY5ZDlW1.css +1 -0
  53. package/src/ui/public/assets/{vendor-CyWsJd6S.js → vendor-BjomZKSS.js} +28 -120
  54. package/src/ui/public/index.html +14 -14
  55. package/src/ui/server/index.js +4 -0
  56. package/src/ui/server/routes/fileOpen.js +43 -7
  57. package/src/ui/server/routes/memory.js +125 -0
  58. package/src/ui/server/routes/workbench/agentChat.js +35 -3
  59. package/src/ui/server/routes/workbench/agentChatShared.test.js +49 -0
  60. package/src/ui/server/routes/workbench/dispatchInstruction.js +8 -6
  61. package/src/ui/server/routes/workbench/dispatchInstruction.test.js +29 -2
  62. package/src/ui/server/routes/workbench/index.js +41 -17
  63. package/src/ui/server/routes/workbench/memoryContext.js +125 -0
  64. package/src/ui/server/routes/workbench/memoryContext.test.js +79 -0
  65. package/src/ui/server/routes/workbench/orchestratorPrompt.test.js +47 -2
  66. package/src/ui/server/routes/workbench/orchestratorStore.js +29 -1
  67. package/src/ui/server/routes/workbench/runExternalTurn.js +6 -1
  68. package/src/ui/server/routes/workbench/shared.js +28 -2
  69. package/src/ui/server/routes/workbench/taskRunner.js +48 -5
  70. package/src/utils/selfUpdatePolicy.js +26 -4
  71. package/src/utils/selfUpdatePolicy.test.js +21 -2
  72. package/src/ui/public/assets/EditorView-DnuGnX3k.js +0 -1
  73. package/src/ui/public/assets/RecentDirectoriesChat-vHT8jMxW.css +0 -1
  74. package/src/ui/public/assets/WorkbenchView-DKqriLBP.js +0 -6
  75. package/src/ui/public/assets/_plugin-vue_export-helper-Dd_YmKEO.js +0 -19
  76. package/src/ui/public/assets/agentConversations-CT9E8eQr.js +0 -5
  77. package/src/ui/public/assets/index-CsLyZsXy.js +0 -29
  78. package/src/ui/public/assets/index-D2rf9QKB.css +0 -1
  79. package/src/ui/public/assets/vendor-BzMymUBi.css +0 -1
@@ -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
+ }
@@ -0,0 +1,79 @@
1
+ // memoryContext.js 的单测。
2
+ //
3
+ // 纯函数、不碰文件系统(见该文件头注释),所以直接喂入参断输出即可 ——
4
+ // 与 envContext.test.js 同一个理由,也正是它要保持纯的原因。
5
+ import { test } from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+ import {
8
+ buildMemoryPointerBlock,
9
+ MEMORY_BLOCK_MAX_BYTES,
10
+ MEMORY_BLOCK_LEVEL,
11
+ } from './memoryContext.js';
12
+
13
+ const DIR = '/home/u/.zen-gitsync/memory';
14
+ const PIDX = `${DIR}/projects/my-repo/INDEX.md`;
15
+
16
+ test('没有 memoryDir → 完全不注入', () => {
17
+ const { block, level } = buildMemoryPointerBlock({});
18
+ assert.equal(block, '');
19
+ assert.equal(level, MEMORY_BLOCK_LEVEL.NONE);
20
+ });
21
+
22
+ test('只有全局层:给全局索引路径,明说本项目还没有条目', () => {
23
+ const { block, level } = buildMemoryPointerBlock({ memoryDir: DIR });
24
+ assert.equal(level, MEMORY_BLOCK_LEVEL.GLOBAL_ONLY);
25
+ assert.ok(block.includes(`${DIR}/INDEX.md`));
26
+ assert.ok(block.includes('本项目还没有记忆库条目'));
27
+ // 捕获段里仍会出现 lessons/ —— Agent 收尾时要知道往哪儿写;
28
+ // 这里断言的是**不出现项目索引路径**(没有的路径给了就是骗 Agent)。
29
+ assert.ok(!block.includes(`${DIR}/projects/`));
30
+ assert.ok(!block.includes('本项目索引'));
31
+ });
32
+
33
+ test('有项目层:标为 PROJECT 并点名本项目索引', () => {
34
+ const { block, level } = buildMemoryPointerBlock({ memoryDir: DIR, projectIndexFile: PIDX });
35
+ assert.equal(level, MEMORY_BLOCK_LEVEL.PROJECT);
36
+ assert.ok(block.includes(PIDX));
37
+ assert.ok(!block.includes('本项目还没有记忆库条目'));
38
+ });
39
+
40
+ test('capture=false → 保留召回纪律、砍掉捕获纪律', () => {
41
+ const withCap = buildMemoryPointerBlock({ memoryDir: DIR, projectIndexFile: PIDX, capture: true });
42
+ const noCap = buildMemoryPointerBlock({ memoryDir: DIR, projectIndexFile: PIDX, capture: false });
43
+ assert.ok(withCap.block.includes('收尾时'));
44
+ assert.ok(!noCap.block.includes('收尾时'));
45
+ // 砍的只是长的那段,召回指针必须还在 —— 那才是这个块存在的理由
46
+ assert.ok(noCap.block.includes(PIDX));
47
+ assert.ok(noCap.block.length > 0);
48
+ assert.ok(Buffer.byteLength(noCap.block, 'utf8') < Buffer.byteLength(withCap.block, 'utf8'));
49
+ });
50
+
51
+ test('块本身不能超预算(这是它替代"索引全文进 prompt"的前提)', () => {
52
+ const { block } = buildMemoryPointerBlock({ memoryDir: DIR, projectIndexFile: PIDX });
53
+ assert.ok(
54
+ Buffer.byteLength(block, 'utf8') <= MEMORY_BLOCK_MAX_BYTES,
55
+ `块 ${Buffer.byteLength(block, 'utf8')}B 超过 ${MEMORY_BLOCK_MAX_BYTES}B 预算`
56
+ );
57
+ });
58
+
59
+ test('超预算时降级:砍捕获段、保住召回指针(不静默产出超大 prompt)', () => {
60
+ const { block } = buildMemoryPointerBlock({ memoryDir: DIR, projectIndexFile: PIDX, capture: true });
61
+ const recallAt = block.indexOf('不要预加载全部 lessons');
62
+ const captureAt = block.indexOf('收尾时');
63
+ assert.ok(recallAt > 0, '召回纪律必须在');
64
+ assert.ok(captureAt > recallAt, '捕获纪律必须排在召回纪律之后');
65
+ });
66
+
67
+ test('纪律里必须写死两条硬约束:懒加载 + 索引缺一不可', () => {
68
+ const { block } = buildMemoryPointerBlock({ memoryDir: DIR, projectIndexFile: PIDX });
69
+ assert.match(block, /不要预加载全部 lessons/);
70
+ assert.match(block, /archive/);
71
+ assert.match(block, /没索引的记忆等于不存在/);
72
+ assert.match(block, /以后怎么做/);
73
+ });
74
+
75
+ test('续接场景(capture 由调用方关掉)不产生空块', () => {
76
+ const { block } = buildMemoryPointerBlock({ memoryDir: DIR, projectIndexFile: PIDX, capture: false });
77
+ assert.ok(block.trim().length > 0);
78
+ assert.ok(block.includes(PIDX), '关掉捕获纪律不能把召回指针一起关掉');
79
+ });
@@ -24,8 +24,8 @@
24
24
  import { test } from 'node:test';
25
25
  import assert from 'node:assert/strict';
26
26
 
27
- import { resolveDispatchPrompt, normalizeOrchestrator } from './orchestratorStore.js';
28
- import { MAX_DEFAULT_PROMPT_CHARS } from './shared.js';
27
+ import { resolveDispatchPrompt, normalizeOrchestrator, withInstructionPreview } from './orchestratorStore.js';
28
+ import { MAX_DEFAULT_PROMPT_CHARS, INSTRUCTION_PREVIEW_CHARS } from './shared.js';
29
29
 
30
30
  /** 造一份最小可用的编排状态(字段与 normalizeOrchestrator 的输出对齐) */
31
31
  function stateWith({ defaultPrompt = '', projectPrompts = {} } = {}) {
@@ -157,3 +157,48 @@ test('normalizeOrchestrator:指令的 promptSource 只认白名单,脏值归
157
157
  });
158
158
  assert.deepEqual(s.instructions.map(i => i.promptSource), ['both', 'global', '', '']);
159
159
  });
160
+
161
+ // ── 指令流水的「下发预览」截断(2026-09-29)────────────────────────────
162
+ //
163
+ // 指令上限从 4000 放宽到 100000 之后,唯一会先崩的是**5s 轮询**:
164
+ // /api/workbench/orchestrator 每轮下发 state.instructions 整份(200 条上限),
165
+ // 不截就是 200 × 100000 = 20MB 一轮。
166
+ //
167
+ // 关键在"截哪一份":落盘那份(orchestrator.json)与任务正文(task.desc)必须完整 ——
168
+ // 指令流水的意义就是"我说过什么"能被翻到,而 Agent 侧的真相源清单指着那个文件。
169
+ // 所以这里守的是"只截下发、且如实标出被截过"。
170
+
171
+ test('withInstructionPreview:超长正文截到预览上限并标出已截断', () => {
172
+ const long = 'x'.repeat(INSTRUCTION_PREVIEW_CHARS + 500);
173
+ const r = withInstructionPreview({ id: 'a', text: long, status: 'accepted' });
174
+ assert.equal(r.text.length, INSTRUCTION_PREVIEW_CHARS);
175
+ assert.equal(r.textTruncated, true);
176
+ // 其余字段一个都不能丢:前端要靠 status / projectPath / targetSource 渲染这行
177
+ assert.equal(r.id, 'a');
178
+ assert.equal(r.status, 'accepted');
179
+ });
180
+
181
+ test('withInstructionPreview:没超长时原样返回,且不误标截断', () => {
182
+ const short = '一句话指令';
183
+ const r = withInstructionPreview({ id: 'a', text: short });
184
+ assert.equal(r.text, short);
185
+ assert.equal(r.textTruncated, false);
186
+ // 恰好等于上限不算超(截断口是 >)
187
+ const atLimit = withInstructionPreview({ id: 'b', text: 'y'.repeat(INSTRUCTION_PREVIEW_CHARS) });
188
+ assert.equal(atLimit.textTruncated, false);
189
+ assert.equal(atLimit.text.length, INSTRUCTION_PREVIEW_CHARS);
190
+ });
191
+
192
+ test('withInstructionPreview:不改传入的记录(落盘那份必须还是完整的)', () => {
193
+ const long = 'z'.repeat(INSTRUCTION_PREVIEW_CHARS + 10);
194
+ const original = { id: 'a', text: long };
195
+ withInstructionPreview(original);
196
+ assert.equal(original.text.length, INSTRUCTION_PREVIEW_CHARS + 10, '截断只发生在返回的新对象上');
197
+ });
198
+
199
+ test('withInstructionPreview:脏输入不炸(缺 text / null / 非对象)', () => {
200
+ assert.deepEqual(withInstructionPreview({ id: 'a' }), { id: 'a', text: '', textTruncated: false });
201
+ assert.equal(withInstructionPreview(null).text, '');
202
+ assert.equal(withInstructionPreview(undefined).text, '');
203
+ assert.equal(withInstructionPreview({ text: 123 }).text, '');
204
+ });
@@ -39,6 +39,7 @@ import {
39
39
  ORCHESTRATOR_REPORTS_FILE,
40
40
  MAX_ORCHESTRATOR_INSTRUCTIONS,
41
41
  MAX_DEFAULT_PROMPT_CHARS,
42
+ INSTRUCTION_PREVIEW_CHARS,
42
43
  MAX_PROGRESS_REPORTS,
43
44
  PROGRESS_REPORT_INTERVALS_MS,
44
45
  DEFAULT_PROGRESS_REPORT_INTERVAL_MS,
@@ -245,6 +246,27 @@ export function resolveDispatchPrompt(state, projectPath) {
245
246
  return { text, source };
246
247
  }
247
248
 
249
+ /**
250
+ * 把一条指令流水压成「可以每 5 秒下发」的形状:正文只留开头 INSTRUCTION_PREVIEW_CHARS 字。
251
+ *
252
+ * 为什么需要它:指令正文上限已放宽到十万字(MAX_INSTRUCTION_CHARS),而
253
+ * /api/workbench/orchestrator 是 5s 轮询、下发 state.instructions 整份(200 条)。
254
+ * 不截就是 200 × 100000 = 20MB 一轮。
255
+ *
256
+ * 只压**下发**:落盘那份(orchestrator.json)与任务正文(task.desc)都保持完整,
257
+ * 指令流水的意义就是"我说过什么"能被翻到。附 textTruncated 让前端能如实说"已截断",
258
+ * 而不是让用户以为当初就只写了这么多。
259
+ *
260
+ * @param {object} it orchestratorStore 的指令记录
261
+ * @returns {object} 新对象(不改原记录 —— 落盘那份必须还是完整的)
262
+ */
263
+ export function withInstructionPreview(it) {
264
+ const src = it && typeof it === 'object' ? it : {};
265
+ const text = typeof src.text === 'string' ? src.text : '';
266
+ const truncated = text.length > INSTRUCTION_PREVIEW_CHARS;
267
+ return { ...src, text: truncated ? text.slice(0, INSTRUCTION_PREVIEW_CHARS) : text, textTruncated: truncated };
268
+ }
269
+
248
270
  /** 追加一条人类干预指令记录,返回写入后的那条记录 */
249
271
  export async function appendInstruction(entry) {
250
272
  return serialize(async () => {
@@ -508,12 +530,18 @@ export function buildActivityFeed({ jobs = [], tasks = [], instructions = [] } =
508
530
 
509
531
  for (const it of Array.isArray(instructions) ? instructions : []) {
510
532
  if (!it || !it.id) continue;
533
+ // 正文只带**开头一段**进活动流:这条流跟着 5s 轮询下发(截断口径见
534
+ // withInstructionPreview)。落盘那份不截 —— 完整原文在 orchestrator.json 与
535
+ // task.desc 里,要全文的人去那两个地方拿。
536
+ const preview = withInstructionPreview(it);
511
537
  rows.push({
512
538
  id: `ins:${it.id}`,
513
539
  kind: 'user',
514
540
  at: it.at,
515
541
  instructionId: it.id,
516
- text: it.text || '',
542
+ text: preview.text,
543
+ // 前端据此显示"已截断",而不是让人以为指令本来就那么短
544
+ textTruncated: preview.textTruncated,
517
545
  taskId: it.taskId || null,
518
546
  instructionStatus: it.status || 'created',
519
547
  reason: it.reason || '',
@@ -40,6 +40,7 @@
40
40
  import { spawn, execFile } from 'child_process';
41
41
  import { logger } from './shared.js';
42
42
  import { engineLabel } from './agentEngines.js';
43
+ import { isPlanToolName } from '../../../../cli/ai/tools.js';
43
44
  import {
44
45
  createToolCallTracker,
45
46
  createClaudeEventHandler,
@@ -181,7 +182,11 @@ export async function runExternalTurn({
181
182
  const id = u.id || '';
182
183
  if (id && !announced.has(id)) {
183
184
  announced.add(id);
184
- send({ type: 'tool_call_start', toolCallId: id, name: u.name, argsPreview: u.argsPreview || '' });
185
+ // 计划类工具额外带完整参数:外部引擎里 claude 的 TodoWrite / opencode 的
186
+ // todowrite 就是计划,前端要靠原始 steps 渲染清单,而 argsPreview 是摘要。
187
+ // 只给计划类发全文,与内置 g ai 那条链路(agentChat.js)保持同一口径。
188
+ const planArgs = isPlanToolName(u.name) ? (u.arguments || '') : undefined;
189
+ send({ type: 'tool_call_start', toolCallId: id, name: u.name, argsPreview: u.argsPreview || '', arguments: planArgs });
185
190
  }
186
191
  if (u.status !== 'running') {
187
192
  send({
@@ -102,6 +102,31 @@ export const TRUTH_FILES = {
102
102
  // 就不会出现"派发时写进去了、回头在编辑器里一保存又被悄悄截掉"。
103
103
  export const MAX_DEFAULT_PROMPT_CHARS = 4000;
104
104
 
105
+ // 派发指令正文长度上限。
106
+ //
107
+ // 2026-09-29 从 4000 放宽到 100000:执行侧走的是 claude / opencode / codex,
108
+ // 上下文窗口早已是百万级,而 prompt 是**经 stdin 喂进去**的(见 taskRunner 的
109
+ // launchXxxRun),不受 Windows 命令行 32K 上限约束。4000 是模型上下文还小的时候
110
+ // 定的,现在它卡住的不是模型,是"粘一份完整报错日志 / 一整段需求"这种最普通的用法。
111
+ //
112
+ // 十万字不是"随便多大都行",它同时要落在三份存储里(见下方各自的注释):
113
+ // · tasks.json 的 task.desc —— 一次执行真正用的正文
114
+ // · jobs.json 的 job.prompt —— 保留策略 500 条 / 256MB 兜底
115
+ // · orchestrator.json 的指令流水 —— 200 条上限
116
+ // 真正会让界面卡住的不是这三份,而是**轮询下发**(INSTRUCTION_PREVIEW_CHARS 那道口子)。
117
+ export const MAX_INSTRUCTION_CHARS = 100000;
118
+
119
+ // 指令流水进 5s 轮询 / 活动流时的正文上限。
120
+ //
121
+ // 为什么单独一道:/api/workbench/orchestrator 每 5 秒把 state.instructions 与活动流
122
+ // 整份下发(MAX_ORCHESTRATOR_INSTRUCTIONS = 200 条)。指令放宽到十万字之后,
123
+ // 不截就是 200 × 100000 = 20MB 一轮 —— 界面先卡住,而"卡住"和"上限"毫无关系。
124
+ //
125
+ // 只截**下发**这一份,不截落盘:流水存在的意义就是"我说过什么"能被翻到,
126
+ // 落盘那份必须完整(Agent 侧的真相源清单也指着 orchestrator.json)。
127
+ // 任务正文本身更不截 —— 完整正文在 task.desc 里,流水这条只是同一句话的旁证。
128
+ export const INSTRUCTION_PREVIEW_CHARS = 2000;
129
+
105
130
  // 子项目识别 / 文件扫描时需要跳过的目录
106
131
  export const SKIP_DIRS = new Set([
107
132
  'node_modules', 'dist', 'build', '.next', '.nuxt', '__pycache__',
@@ -117,12 +142,13 @@ export const MANIFEST_FILES = [
117
142
  'Gemfile', 'pubspec.yaml'
118
143
  ];
119
144
 
120
- // 附件大小 / 数量限制
145
+ // 附件大小限制(**数量不限**)
121
146
  // 单文件 20MB:4K 屏截图(PNG)5–15MB 是常态,卡在 5MB 会让"随手截一张就超"。
122
147
  // 真正需要小体积的是图片,由前端上传前压缩保证(见 useWorkbenchAttachments.ts),
123
148
  // 这里只做最后一道兜底,避免超大 body 把内存吃光。
149
+ // 数量曾经按 9 个封顶,2026-09-29 按需求放开:一次要交十几张截图 / 一堆日志是常态,
150
+ // 封顶只会逼人分批建任务。数量放开后单文件上限成了唯一的内存关口,所以它必须留着。
124
151
  export const MAX_IMAGE_BYTES = 20 * 1024 * 1024; // 单个附件最大 20MB
125
- export const MAX_ATTACHMENTS_PER_TASK = 9; // 一个任务最多挂 9 个附件
126
152
 
127
153
  // ── 时间 / ID 工具 ─────────────────────────────────────────
128
154
  export function nowIso() {
@@ -45,6 +45,7 @@ import {
45
45
  } from './shared.js';
46
46
  import { buildAttachmentBlock } from './pdfText.js';
47
47
  import { composePromptBody } from './promptParts.js';
48
+ import { buildMemoryPointerBlock } from './memoryContext.js';
48
49
  import {
49
50
  jobs,
50
51
  cancelledJobs,
@@ -52,6 +53,11 @@ import {
52
53
  flushJobsSaveNow,
53
54
  scheduleActiveJobsSave,
54
55
  } from './jobStore.js';
56
+ // MEMORY_DIR 在 paths.js(路径唯一真相源),projectIndexFile 在 memory/store.js ——
57
+ // 两个模块各取一处,别图省事都从 store.js 拿:store.js 没有 re-export MEMORY_DIR,
58
+ // 混着写会在**链接期**就炸(node --check 查不出来,得靠动态 import 或真跑单测)。
59
+ import { MEMORY_DIR } from '../../../../paths.js';
60
+ import { projectIndexFile } from '../../../../memory/store.js';
55
61
 
56
62
  // ── 运行环境上下文的提供者 ────────────────────────────────────────────────
57
63
  // 为什么用注入而不是在这里直接读:拼这份上下文要「最近目录」(在 configManager 上),
@@ -86,6 +92,23 @@ async function resolveEnvContext(repoPath) {
86
92
  }
87
93
  }
88
94
 
95
+ /**
96
+ * 取本仓库的记忆索引路径。
97
+ *
98
+ * **只读不建** —— 与 resolveEnvContext 同一原则:拼 prompt 是锦上添花,
99
+ * 记忆库不存在就只给全局那层,让 Agent 收尾时自己建(它有文件工具,也有那个动机)。
100
+ * 在这里建目录等于"读一次就产生一次副作用",派发任务不该有这种语义。
101
+ */
102
+ function resolveProjectMemoryIndex(repoPath) {
103
+ if (!repoPath) return '';
104
+ try {
105
+ return projectIndexFile(repoPath);
106
+ } catch (err) {
107
+ logger.warn(`[workbench] 记忆索引路径解析失败,本次不注入项目层: ${err.message}`);
108
+ return '';
109
+ }
110
+ }
111
+
89
112
  // 用 detached 进程跑 claude;进程退出时回填状态。
90
113
  // 返回 { pid, child }:调用方可以监听 child.stdout/stderr 实时收集输出。
91
114
  // 不再走 cmd /k 弹窗——claude -p 是非交互模式,输出通过 stdout pipe 实时回传
@@ -885,13 +908,33 @@ export async function runSingleSubtask(task, sub, repoPath, branch, options) {
885
908
  }
886
909
  }
887
910
 
888
- // ── 运行环境上下文:项目清单 + 看板概览 + 真相源文件路径 ──
889
- // 拼在**最前面**、任务正文压尾:
890
- // 越靠后离模型的注意力中心越近,用户真正要办的那句话必须在最后一屏。
891
- // task.envContext === false 可以单任务关掉(默认开 —— 老任务不迁移也一并受益)。
911
+ // ── 两块「背景」都拼在最前面,任务正文压尾 ──
912
+ // 顺序刻意是 [环境上下文 → 记忆指针 → 任务正文]:
913
+ // · 越靠后离模型的注意力中心越近,**用户真正要办的那句话必须在最后一屏**;
914
+ // · 记忆块夹在两块之间,不能放末尾 —— 放末尾等于把"背景"讲成了"当前任务",
915
+ // Agent 会以为这轮用户要求它去整理记忆库。
916
+ // 两块都可用 `task.<key> === false` 单任务关掉(默认开 —— 老任务不迁移也一并受益)。
917
+ const prefixBlocks = [];
918
+
892
919
  if (task.envContext !== false) {
893
920
  const envBlock = await resolveEnvContext(repoPath);
894
- if (envBlock) prompt = `${envBlock}\n\n---\n\n${prompt}`;
921
+ if (envBlock) prefixBlocks.push(envBlock);
922
+ }
923
+
924
+ if (task.memoryContext !== false) {
925
+ const memBlock = buildMemoryPointerBlock({
926
+ memoryDir: MEMORY_DIR,
927
+ // 首次在某仓库干活时目录还不存在 —— 这时不给项目路径,块里会明说
928
+ // "本项目还没有条目",Agent 收尾时自己建。
929
+ projectIndexFile: resolveProjectMemoryIndex(repoPath),
930
+ // 续聊轮不再重复讲捕获纪律:同一场对话里讲第二遍纯属噪音。
931
+ capture: !resumeSessionId,
932
+ }).block;
933
+ if (memBlock) prefixBlocks.push(memBlock);
934
+ }
935
+
936
+ if (prefixBlocks.length > 0) {
937
+ prompt = `${prefixBlocks.join('\n\n---\n\n')}\n\n---\n\n${prompt}`;
895
938
  }
896
939
 
897
940
  const jobId = genId();
@@ -105,19 +105,41 @@ export function nextPollIntervalMs({ elapsedMs, fastWindowMs, fastIntervalMs, sl
105
105
  * 与"EPERM(文件被占)"分不清 —— 两者的处理方式完全相反(一个只能等,一个要杀实例)。
106
106
  * 汇总成 `E404 ×8 / ETARGET ×2` 这样一行,下一次事故就不用再靠猜。
107
107
  *
108
+ * v2.17.25 实测教训(优先级为什么长这样):registry 还没就绪(E404)的每一轮
109
+ * 输出里都附带 `npm warn cleanup Failed to remove ... EPERM` —— 那是 npm 删旧
110
+ * 全局目录没删干净的动作噪声,不是失败根因。按旧的"EPERM 子串优先"判法被
111
+ * 误判成文件占用,导致每轮反复停实例 + 强删全局目录。所以:
112
+ * ① npm 自己报了 `npm error code <CODE>` 行 → 以它为最终裁决;
113
+ * ② 没有 code 行 → 只在 **error 行**里找文件占用类(warn 行永远不算);
114
+ * ③ 还没有 → 才对全文兜底(兼容只截到 warn cleanup 的残缺输出)。
115
+ *
108
116
  * @param {string} output - npm install 的 stdout+stderr
109
- * @returns {string} E404 | ETARGET | EPERM | EBUSY | ENOTFOUND | ETIMEDOUT | ENETUNREACH | '其他'
117
+ * @returns {string} E404 | ETARGET | EPERM | ENOTFOUND | ETIMEDOUT | ENETUNREACH | 其他 | npm 原生未识别短码
110
118
  */
111
119
  export function classifyInstallError(output) {
112
120
  const s = String(output || '')
113
121
  if (!s) return '其他'
114
- // 顺序有讲究:E404 / ETARGET 是"registry 还没就绪",EPERM / EBUSY 是"文件被占",
115
- // 两类混在一起时优先报前者(它是自更新这一段的主题),但两者都会各自计数。
116
- if (/EPERM|EBUSY|operation not permitted|Failed to remove/i.test(s)) return 'EPERM'
122
+ // ① npm 的裁决码行,如 `npm error code E404`
123
+ const codeLine = s.match(/^\s*npm (?:error|ERR!) code ([A-Z0-9_]+)\b/m)
124
+ if (codeLine) return normalizeErrCode(codeLine[1])
125
+ // ② error 行里的文件占用类(warn 行不算 —— 那是清理噪声)
126
+ const errorLines = s.split(/\r?\n/).filter((l) => /^\s*npm (?:error|ERR!)/.test(l))
127
+ if (errorLines.some((l) => /EPERM|EBUSY|operation not permitted|Failed to remove/i.test(l))) return 'EPERM'
128
+ // ③ 全文兜底:registry 类优先(它是自更新这一段的主题),文件占用类殿后
117
129
  if (/\bETARGET\b/.test(s)) return 'ETARGET'
118
130
  if (/\bE404\b|404 Not Found/i.test(s)) return 'E404'
119
131
  if (/\bENOTFOUND\b/.test(s)) return 'ENOTFOUND'
120
132
  if (/\bETIMEDOUT\b|timed out/i.test(s)) return 'ETIMEDOUT'
121
133
  if (/\bENETUNREACH\b|ECONNRESET|ECONNREFUSED/i.test(s)) return 'ENETUNREACH'
134
+ if (/EPERM|EBUSY|operation not permitted|Failed to remove/i.test(s)) return 'EPERM'
122
135
  return '其他'
123
136
  }
137
+
138
+ // npm 原生错误码 → 本脚本的对账短码。EBUSY 与 EPERM 同属"文件被占"一类
139
+ // (处置完全相同:杀实例 + 清目录),合并进同一个桶方便 errorTally 汇总;
140
+ // 没见过的码原样返回,别硬塞 —— 归错桶比归不出来更误导。
141
+ function normalizeErrCode(code) {
142
+ if (code === 'EBUSY') return 'EPERM'
143
+ if (code === 'ECONNRESET' || code === 'ECONNREFUSED') return 'ENETUNREACH'
144
+ return code
145
+ }
@@ -189,6 +189,24 @@ test('classifyInstallError: EPERM / EBUSY → 文件被占(和"还没就绪"完
189
189
  assert.equal(classifyInstallError('npm error EBUSY: resource busy or locked'), 'EPERM')
190
190
  })
191
191
 
192
+ test('classifyInstallError: npm 的 code 行是最终裁决(v2.17.25 实测回归)', () => {
193
+ // v2.17.25 真实形态:registry 还没就绪(E404)的每一轮都附带 npm warn cleanup EPERM
194
+ // (删旧全局目录的噪声)。旧判法按"EPERM 子串优先"误判成文件占用,
195
+ // 导致每轮反复停实例 + 强删全局目录。有 code 行时必须以它为准。
196
+ const notReady = [
197
+ 'npm warn cleanup Failed to remove some directories: EPERM',
198
+ 'npm error code E404',
199
+ 'npm error 404 Not Found - GET https://registry.npmjs.org/zen-gitsync/-/zen-gitsync-2.17.25.tgz - Not found',
200
+ ].join('\n')
201
+ assert.equal(classifyInstallError(notReady), 'E404')
202
+ // npm 自己裁决成文件占用 → 才允许走"杀实例 + 清目录"
203
+ assert.equal(classifyInstallError('npm error code EPERM\nnpm error rename EPERM: operation not permitted'), 'EPERM')
204
+ // EBUSY 与 EPERM 同属"文件被占",归进同一个桶
205
+ assert.equal(classifyInstallError('npm error code EBUSY\nnpm error resource busy or locked'), 'EPERM')
206
+ // 没见过的码原样返回,不硬塞进既有短码
207
+ assert.equal(classifyInstallError('npm error code EAI_AGAIN\nnpm error getaddrinfo EAI_AGAIN'), 'EAI_AGAIN')
208
+ })
209
+
192
210
  test('classifyInstallError: 网络类与空输入不炸,各有短码', () => {
193
211
  assert.equal(classifyInstallError('npm error code ENOTFOUND registry.npmjs.org'), 'ENOTFOUND')
194
212
  assert.equal(classifyInstallError('npm error code ETIMEDOUT'), 'ETIMEDOUT')
@@ -198,7 +216,8 @@ test('classifyInstallError: 网络类与空输入不炸,各有短码', () => {
198
216
  assert.equal(classifyInstallError('some unknown failure'), '其他')
199
217
  })
200
218
 
201
- test('classifyInstallError: 两类同时出现时优先报文件占用(它是唯一要"动手"的那类)', () => {
202
- const mixed = 'npm error code E404\nnpm error EPERM: operation not permitted'
219
+ test('classifyInstallError: 无 code 行时,error 行里的文件占用类仍优先于 registry 类', () => {
220
+ // 两条都是 error 行、又没有裁决 code 行 → 文件占用是唯一要"动手"的那类,优先报
221
+ const mixed = 'npm error E404 Not Found - GET https://...\nnpm error EPERM: operation not permitted'
203
222
  assert.equal(classifyInstallError(mixed), 'EPERM')
204
223
  })