mocode-ai 1.3.9 → 1.4.0

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 (134) hide show
  1. package/README.md +78 -70
  2. package/README.zh-CN.md +81 -73
  3. package/bin/mocode-agent-host.js +1 -1
  4. package/dist/agent/core.js +226 -293
  5. package/dist/agent/index.js +14 -10
  6. package/dist/agent/mode.js +3 -3
  7. package/dist/agent/runtime-context.js +45 -0
  8. package/dist/agent/spawn.js +101 -86
  9. package/dist/agent/tool-helpers.js +134 -0
  10. package/dist/agent/trace-state.js +118 -0
  11. package/dist/agents/coordinator.js +6 -1
  12. package/dist/changeset/index.js +9 -8
  13. package/dist/config/index.js +109 -77
  14. package/dist/config/presets.js +3 -1
  15. package/dist/config/profiles.js +125 -0
  16. package/dist/context/age-aware.js +2 -4
  17. package/dist/context/artifacts.js +6 -13
  18. package/dist/context/budget.js +6 -8
  19. package/dist/context/classifier.js +1 -2
  20. package/dist/context/encoders/code.js +2 -6
  21. package/dist/context/encoders/command.js +1 -3
  22. package/dist/context/encoders/search.js +1 -3
  23. package/dist/context/index.js +1 -1
  24. package/dist/context/lifecycle.js +5 -3
  25. package/dist/context/pipeline.js +1 -1
  26. package/dist/context/relevance.js +2 -4
  27. package/dist/context/token-calibration.js +13 -18
  28. package/dist/host/stdio.js +73 -12
  29. package/dist/i18n/index.js +96 -44
  30. package/dist/index.js +1 -1
  31. package/dist/llm/index.js +39 -23
  32. package/dist/llm/provider.js +23 -0
  33. package/dist/llm/providers/anthropic.js +12 -18
  34. package/dist/llm/tool-schema.js +48 -0
  35. package/dist/mcp/client.js +70 -25
  36. package/dist/mcp/config.js +1 -3
  37. package/dist/mcp/index.js +3 -1
  38. package/dist/memory/reflect.js +3 -6
  39. package/dist/memory/store.js +10 -12
  40. package/dist/permissions/index.js +66 -22
  41. package/dist/pet/bridge.js +2 -2
  42. package/dist/repl/commands/appearance.js +72 -0
  43. package/dist/repl/commands/compact.js +114 -0
  44. package/dist/repl/commands/context.js +17 -0
  45. package/dist/repl/commands/image.js +71 -0
  46. package/dist/repl/commands/memory.js +51 -0
  47. package/dist/repl/commands/mode.js +47 -0
  48. package/dist/repl/commands/model.js +435 -0
  49. package/dist/repl/commands/pet.js +70 -0
  50. package/dist/repl/commands/registry.js +46 -0
  51. package/dist/repl/commands/session.js +102 -0
  52. package/dist/repl/commands/skill.js +95 -0
  53. package/dist/repl/commands/system.js +202 -0
  54. package/dist/repl/commands/tool-group.js +233 -0
  55. package/dist/repl/commands/types.js +4 -0
  56. package/dist/repl/commands.js +324 -0
  57. package/dist/repl/index.js +5 -2577
  58. package/dist/repl/message-format.js +151 -0
  59. package/dist/repl/running-input.js +147 -0
  60. package/dist/repl/runtime.js +816 -0
  61. package/dist/repl/status-bar.js +161 -0
  62. package/dist/rollback/index.js +44 -14
  63. package/dist/runtime/browser-manager.js +1 -1
  64. package/dist/runtime/dev-server-manager.js +1 -1
  65. package/dist/runtime/input-injector.js +313 -0
  66. package/dist/runtime/screen-capture.js +89 -0
  67. package/dist/runtime/screen-pipeline.js +223 -0
  68. package/dist/sandbox/index.js +1 -1
  69. package/dist/sandbox/policy.js +8 -2
  70. package/dist/session/compact.js +22 -34
  71. package/dist/session/index.js +2 -2
  72. package/dist/session/notes.js +39 -5
  73. package/dist/session/persist.js +14 -8
  74. package/dist/session/scheduler.js +3 -4
  75. package/dist/session/trace-metrics.js +6 -3
  76. package/dist/skills/activation.js +10 -5
  77. package/dist/skills/discover.js +5 -17
  78. package/dist/skills/index.js +1 -1
  79. package/dist/skills/runner.js +28 -44
  80. package/dist/skills/toolmap.js +5 -2
  81. package/dist/tools/builtins/browser.js +17 -3
  82. package/dist/tools/builtins/computer.js +322 -0
  83. package/dist/tools/builtins/dev-server.js +1 -3
  84. package/dist/tools/builtins/edit-file.js +21 -7
  85. package/dist/tools/builtins/grep.js +1 -1
  86. package/dist/tools/builtins/index.js +28 -25
  87. package/dist/tools/builtins/memory-forget.js +1 -1
  88. package/dist/tools/builtins/memory-graph.js +4 -7
  89. package/dist/tools/builtins/memory-list.js +2 -6
  90. package/dist/tools/builtins/memory-save.js +8 -2
  91. package/dist/tools/builtins/memory-search.js +4 -4
  92. package/dist/tools/builtins/note-append.js +14 -3
  93. package/dist/tools/builtins/plan-update.js +40 -9
  94. package/dist/tools/builtins/run-command.js +8 -8
  95. package/dist/tools/builtins/screenshot.js +11 -87
  96. package/dist/tools/builtins/task.js +29 -40
  97. package/dist/tools/builtins/use-skill.js +1 -1
  98. package/dist/tools/builtins/view-image.js +5 -3
  99. package/dist/tools/builtins/web-fetch.js +1 -3
  100. package/dist/tools/builtins/web-search.js +3 -7
  101. package/dist/tools/builtins/write-file.js +4 -2
  102. package/dist/tools/constants.js +50 -37
  103. package/dist/tools/policy.js +221 -0
  104. package/dist/tools/registry.js +40 -19
  105. package/dist/tools/resource-lock.js +4 -2
  106. package/dist/tools/router.js +143 -0
  107. package/dist/tools/validation.js +6 -5
  108. package/dist/ui/batch.js +63 -23
  109. package/dist/ui/clipboard.js +17 -5
  110. package/dist/ui/content.js +166 -13
  111. package/dist/ui/diff.js +61 -19
  112. package/dist/ui/fuzzy.js +35 -2
  113. package/dist/ui/intervention.js +2 -4
  114. package/dist/ui/layout-internal/content-write.js +587 -0
  115. package/dist/ui/layout-internal/core.js +590 -0
  116. package/dist/ui/layout-internal/input-paint.js +365 -0
  117. package/dist/ui/layout-internal/screen.js +128 -0
  118. package/dist/ui/layout-internal/scroll.js +152 -0
  119. package/dist/ui/layout-internal/selection.js +229 -0
  120. package/dist/ui/layout-internal/state.js +82 -0
  121. package/dist/ui/layout-internal/statusbar.js +351 -0
  122. package/dist/ui/layout-types.js +1 -0
  123. package/dist/ui/layout.js +3 -2288
  124. package/dist/ui/markdown.js +10 -2
  125. package/dist/ui/prompt-internal/editor.js +945 -0
  126. package/dist/ui/prompt-internal/paste.js +52 -0
  127. package/dist/ui/prompt-internal/pickers.js +519 -0
  128. package/dist/ui/prompt-internal/types.js +1 -0
  129. package/dist/ui/prompt.js +4 -1506
  130. package/dist/ui/render.js +87 -2
  131. package/dist/verification/affected.js +11 -5
  132. package/dist/verification/discovery.js +1 -3
  133. package/dist/verification/profile.js +11 -3
  134. package/package.json +11 -2
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Computer Use 截图管线:捕获 → 缩放/裁剪(纯 JS PNG) → norm1000 坐标换算。
3
+ *
4
+ * 职责边界:
5
+ * - 抓屏本身在 screen-capture.ts(平台相关),本文件只管图像后处理与坐标数学。
6
+ * - 坐标对外只暴露归一化 0-1000 整数网格(x 右 / y 下),模型从不感知物理分辨率;
7
+ * 执行层按最近一次截图的 scale 因子换算回物理像素。
8
+ * - 零原生图像依赖:PNG 解码/编码用 Node 内置 zlib 手写,最近邻重采样。
9
+ * 只支持截图工具产出的 8-bit RGB/RGBA PNG(screencapture/PowerShell Bitmap 均为该族)。
10
+ */
11
+ import { inflateSync, deflateSync } from 'node:zlib';
12
+ const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
13
+ function paeth(a, b, c) {
14
+ const p = a + b - c;
15
+ const pa = Math.abs(p - a);
16
+ const pb = Math.abs(p - b);
17
+ const pc = Math.abs(p - c);
18
+ if (pa <= pb && pa <= pc)
19
+ return a;
20
+ if (pb <= pc)
21
+ return b;
22
+ return c;
23
+ }
24
+ /** 解码 PNG → RGBA 像素。非 8-bit RGB/RGBA 直接抛错(截图工具不会产出其它形态)。 */
25
+ export function decodePng(buf) {
26
+ if (buf.length < 8 || !buf.subarray(0, 8).equals(PNG_SIG))
27
+ throw new Error('not a PNG');
28
+ let pos = 8;
29
+ let width = 0;
30
+ let height = 0;
31
+ let bitDepth = 0;
32
+ let colorType = 0;
33
+ const idat = [];
34
+ while (pos + 8 <= buf.length) {
35
+ const len = buf.readUInt32BE(pos);
36
+ const type = buf.toString('ascii', pos + 4, pos + 8);
37
+ const data = buf.subarray(pos + 8, pos + 8 + len);
38
+ if (type === 'IHDR') {
39
+ width = data.readUInt32BE(0);
40
+ height = data.readUInt32BE(4);
41
+ bitDepth = data[8];
42
+ colorType = data[9];
43
+ }
44
+ else if (type === 'IDAT') {
45
+ idat.push(Buffer.from(data));
46
+ }
47
+ else if (type === 'IEND') {
48
+ break;
49
+ }
50
+ pos += 8 + len + 4; // +4 CRC
51
+ }
52
+ if (!width || !height || bitDepth !== 8 || (colorType !== 2 && colorType !== 6)) {
53
+ throw new Error(`unsupported PNG (bitDepth=${bitDepth} colorType=${colorType})`);
54
+ }
55
+ const bpp = colorType === 6 ? 4 : 3;
56
+ const stride = width * bpp;
57
+ const raw = inflateSync(Buffer.concat(idat));
58
+ const out = Buffer.alloc(stride * height);
59
+ // 反滤镜
60
+ for (let y = 0; y < height; y++) {
61
+ const f = raw[y * (stride + 1)];
62
+ const rowIn = y * (stride + 1) + 1;
63
+ const rowOut = y * stride;
64
+ for (let x = 0; x < stride; x++) {
65
+ const cur = raw[rowIn + x];
66
+ const a = x >= bpp ? out[rowOut + x - bpp] : 0;
67
+ const b = y > 0 ? out[rowOut + x - stride] : 0;
68
+ const c = x >= bpp && y > 0 ? out[rowOut + x - stride - bpp] : 0;
69
+ let v;
70
+ switch (f) {
71
+ case 0:
72
+ v = cur;
73
+ break;
74
+ case 1:
75
+ v = cur + a;
76
+ break;
77
+ case 2:
78
+ v = cur + b;
79
+ break;
80
+ case 3:
81
+ v = cur + ((a + b) >> 1);
82
+ break;
83
+ case 4:
84
+ v = cur + paeth(a, b, c);
85
+ break;
86
+ default: throw new Error(`bad PNG filter ${f}`);
87
+ }
88
+ out[rowOut + x] = v & 0xff;
89
+ }
90
+ }
91
+ // RGB → RGBA
92
+ if (bpp === 4)
93
+ return { width, height, data: out };
94
+ const rgba = Buffer.alloc(width * height * 4);
95
+ for (let i = 0, j = 0; i < out.length; i += 3, j += 4) {
96
+ rgba[j] = out[i];
97
+ rgba[j + 1] = out[i + 1];
98
+ rgba[j + 2] = out[i + 2];
99
+ rgba[j + 3] = 255;
100
+ }
101
+ return { width, height, data: rgba };
102
+ }
103
+ function crc32(buf) {
104
+ let c;
105
+ let crc = 0xffffffff;
106
+ for (let i = 0; i < buf.length; i++) {
107
+ c = (crc ^ buf[i]) & 0xff;
108
+ for (let k = 0; k < 8; k++)
109
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
110
+ crc = (crc >>> 8) ^ c;
111
+ }
112
+ return (crc ^ 0xffffffff) >>> 0;
113
+ }
114
+ function chunk(type, data) {
115
+ const len = Buffer.alloc(4);
116
+ len.writeUInt32BE(data.length, 0);
117
+ const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
118
+ const crc = Buffer.alloc(4);
119
+ crc.writeUInt32BE(crc32(body), 0);
120
+ return Buffer.concat([len, body, crc]);
121
+ }
122
+ /** 编码 RGBA → PNG(colorType 6,filter 0)。 */
123
+ export function encodePng(img) {
124
+ const { width, height, data } = img;
125
+ const stride = width * 4;
126
+ const raw = Buffer.alloc((stride + 1) * height);
127
+ for (let y = 0; y < height; y++) {
128
+ raw[y * (stride + 1)] = 0; // filter none
129
+ data.copy(raw, y * (stride + 1) + 1, y * stride, y * stride + stride);
130
+ }
131
+ const ihdr = Buffer.alloc(13);
132
+ ihdr.writeUInt32BE(width, 0);
133
+ ihdr.writeUInt32BE(height, 4);
134
+ ihdr[8] = 8; // bitDepth
135
+ ihdr[9] = 6; // RGBA
136
+ ihdr[10] = 0; // compression
137
+ ihdr[11] = 0; // filter
138
+ ihdr[12] = 0; // interlace
139
+ return Buffer.concat([
140
+ PNG_SIG,
141
+ chunk('IHDR', ihdr),
142
+ chunk('IDAT', deflateSync(raw)),
143
+ chunk('IEND', Buffer.alloc(0)),
144
+ ]);
145
+ }
146
+ // ── 缩放 / 裁剪(最近邻)─────────────────────────────────────────────────
147
+ function resample(img, newW, newH) {
148
+ const { width, height, data } = img;
149
+ const out = Buffer.alloc(newW * newH * 4);
150
+ for (let y = 0; y < newH; y++) {
151
+ const srcY = Math.min(height - 1, Math.floor((y * height) / newH));
152
+ for (let x = 0; x < newW; x++) {
153
+ const srcX = Math.min(width - 1, Math.floor((x * width) / newW));
154
+ const s = (srcY * width + srcX) * 4;
155
+ const d = (y * newW + x) * 4;
156
+ out[d] = data[s];
157
+ out[d + 1] = data[s + 1];
158
+ out[d + 2] = data[s + 2];
159
+ out[d + 3] = data[s + 3];
160
+ }
161
+ }
162
+ return { width: newW, height: newH, data: out };
163
+ }
164
+ /** 长边缩到 ≤ maxEdge,保持纵横比。已 ≤ maxEdge 时原样返回(零成本直通)。 */
165
+ export function downscale(img, maxEdge) {
166
+ const long = Math.max(img.width, img.height);
167
+ if (long <= maxEdge)
168
+ return { img, scale: 1 };
169
+ const scale = maxEdge / long;
170
+ const newW = Math.max(1, Math.round(img.width * scale));
171
+ const newH = Math.max(1, Math.round(img.height * scale));
172
+ return { img: resample(img, newW, newH), scale };
173
+ }
174
+ /** 长边精确缩放到 edge(放大或缩小,zoom 动作用),保持纵横比。 */
175
+ export function fitToLongEdge(img, edge) {
176
+ const long = Math.max(img.width, img.height);
177
+ if (long === edge)
178
+ return img;
179
+ const scale = edge / long;
180
+ const newW = Math.max(1, Math.round(img.width * scale));
181
+ const newH = Math.max(1, Math.round(img.height * scale));
182
+ return resample(img, newW, newH);
183
+ }
184
+ /** 裁出 (x,y,w,h) 矩形,越界自动收敛到图像内。 */
185
+ export function crop(img, x, y, w, h) {
186
+ const cx = Math.max(0, Math.min(img.width - 1, Math.round(x)));
187
+ const cy = Math.max(0, Math.min(img.height - 1, Math.round(y)));
188
+ const cw = Math.max(1, Math.min(img.width - cx, Math.round(w)));
189
+ const ch = Math.max(1, Math.min(img.height - cy, Math.round(h)));
190
+ const out = Buffer.alloc(cw * ch * 4);
191
+ for (let row = 0; row < ch; row++) {
192
+ const s = ((cy + row) * img.width + cx) * 4;
193
+ img.data.copy(out, row * cw * 4, s, s + cw * 4);
194
+ }
195
+ return { width: cw, height: ch, data: out };
196
+ }
197
+ // ── norm1000 坐标换算 ──────────────────────────────────────────────────
198
+ /** 归一化坐标(0-1000)→ 物理像素。scale = 喂模型的图相对物理的缩放比。 */
199
+ export function normToPhysical(nx, ny, physW, physH) {
200
+ const x = Math.round((clamp1000(nx) / 1000) * physW);
201
+ const y = Math.round((clamp1000(ny) / 1000) * physH);
202
+ return [Math.min(physW - 1, x), Math.min(physH - 1, y)];
203
+ }
204
+ /** 物理像素 → 归一化坐标(0-1000)。 */
205
+ export function physicalToNorm(px, py, physW, physH) {
206
+ return [clamp1000(Math.round((px / physW) * 1000)), clamp1000(Math.round((py / physH) * 1000))];
207
+ }
208
+ /** 归一化矩形 [x,y,w,h](0-1000)→ 物理矩形像素。 */
209
+ export function normRectToPhysical(rect, physW, physH) {
210
+ const [nx, ny, nw, nh] = rect;
211
+ const [x0, y0] = normToPhysical(nx, ny, physW, physH);
212
+ const [x1, y1] = normToPhysical(nx + nw, ny + nh, physW, physH);
213
+ return { x: x0, y: y0, w: Math.max(1, x1 - x0), h: Math.max(1, y1 - y0) };
214
+ }
215
+ function clamp1000(v) {
216
+ if (Number.isNaN(v))
217
+ return 0;
218
+ return Math.max(0, Math.min(1000, Math.round(v)));
219
+ }
220
+ /** 校验一个值是否为有限数且落在 norm1000 区间。 */
221
+ export function isNormCoord(v) {
222
+ return typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 1000;
223
+ }
@@ -2,4 +2,4 @@
2
2
  export { getSandboxRoot, setSandboxRoot, withSandboxRoot } from './root.js';
3
3
  export { jailResolve, jailGlobPattern, isInsideRoot } from './jail.js';
4
4
  export { filterEnv, isCommandDenied } from './command.js';
5
- export { SANDBOX_EXEMPT_TOOLS, SANDBOX_PATH_TOOLS, enforceSandbox, } from './policy.js';
5
+ export { SANDBOX_EXEMPT_TOOLS, SANDBOX_PATH_TOOLS, enforceSandbox } from './policy.js';
@@ -14,9 +14,15 @@ import { jailResolve, jailGlobPattern } from './jail.js';
14
14
  * Shift+Tab 等用户面触发,不经工具路径。
15
15
  */
16
16
  export const SANDBOX_EXEMPT_TOOLS = new Set([
17
- 'memory_save', 'memory_update', 'memory_forget', 'memory_search', 'memory_list', 'memory_graph',
17
+ 'memory_save',
18
+ 'memory_update',
19
+ 'memory_forget',
20
+ 'memory_search',
21
+ 'memory_list',
22
+ 'memory_graph',
18
23
  'use_skill',
19
- 'web_search', 'web_fetch',
24
+ 'web_search',
25
+ 'web_fetch',
20
26
  'ask_human',
21
27
  'sub-agent',
22
28
  ]);
@@ -19,7 +19,7 @@ export function truncateMid(text, max) {
19
19
  return text;
20
20
  const removed = text.length - max;
21
21
  const marker = `…[已截断 ${removed} 字符]…`;
22
- let remain = max - marker.length;
22
+ const remain = max - marker.length;
23
23
  if (remain <= 0)
24
24
  return marker.slice(0, Math.max(0, max));
25
25
  const head = Math.ceil(remain * 0.6);
@@ -179,7 +179,10 @@ export function splitSummaryText(raw) {
179
179
  break;
180
180
  }
181
181
  }
182
- const keyFacts = lines.slice(headIdx + 1, endIdx).join('\n').trim();
182
+ const keyFacts = lines
183
+ .slice(headIdx + 1, endIdx)
184
+ .join('\n')
185
+ .trim();
183
186
  const rest = [...lines.slice(0, headIdx), ...lines.slice(endIdx)].join('\n').trim();
184
187
  return { keyFacts, rest };
185
188
  }
@@ -219,7 +222,10 @@ export function extractProgressSnapshot(summaryRest) {
219
222
  for (const line of lines) {
220
223
  const m = line.match(/^ {0,3}##\s+(.+?)\s*$/);
221
224
  if (m) {
222
- const name = m[1].replace(/\s*[—–-].*$/, '').trim().toLowerCase();
225
+ const name = m[1]
226
+ .replace(/\s*[—–-].*$/, '')
227
+ .trim()
228
+ .toLowerCase();
223
229
  taking = WANT.has(name);
224
230
  }
225
231
  if (taking)
@@ -275,10 +281,7 @@ function isUserGroup(g) {
275
281
  /** 摘要 group 判定:非 history[0] 的 system 消息,以 `# 会话摘要` 开头,且不带 tool_calls。 */
276
282
  function isSummaryGroup(g) {
277
283
  const a = g.assistant;
278
- return (!!a &&
279
- a.role === 'system' &&
280
- g.tools.length === 0 &&
281
- toText(a.content).startsWith(SUMMARY_MARKER));
284
+ return !!a && a.role === 'system' && g.tools.length === 0 && toText(a.content).startsWith(SUMMARY_MARKER);
282
285
  }
283
286
  /** 从尾向头划分 group;history[0](system)排除。连续 tool 归到前导 assistant。 */
284
287
  function groupFromEnd(history) {
@@ -292,9 +295,7 @@ function groupFromEnd(history) {
292
295
  tools.unshift(history[i]);
293
296
  i--;
294
297
  }
295
- if (i >= 1 &&
296
- history[i].role === 'assistant' &&
297
- history[i].tool_calls) {
298
+ if (i >= 1 && history[i].role === 'assistant' && history[i].tool_calls) {
298
299
  groups.unshift({ assistant: history[i], tools });
299
300
  i--;
300
301
  }
@@ -377,7 +378,7 @@ function capMessageText(text, max) {
377
378
  return text;
378
379
  const removed = text.length - max;
379
380
  const marker = `\n…[中间 ${removed} 字符已省略]…\n`;
380
- let remain = max - marker.length;
381
+ const remain = max - marker.length;
381
382
  if (remain <= 0)
382
383
  return text.slice(0, Math.max(0, max)) + marker;
383
384
  const head = Math.ceil(remain * 0.6);
@@ -448,7 +449,7 @@ async function defaultSummarize(older, focus, signal) {
448
449
  content: 'You are an aggressive session compressor writing a handoff note for an agent that lost its context. ' +
449
450
  'The agent will continue the task with ONLY your summary plus a few most-recent messages, so your summary is the sole memory of everything older.\n' +
450
451
  'Output ONLY the summary body in this exact structure (omit empty sections):\n' +
451
- '## Objective — the user\'s core request(s); cover EVERY distinct user request in the transcript, in order, noting which are completed vs pending.\n' +
452
+ "## Objective — the user's core request(s); cover EVERY distinct user request in the transcript, in order, noting which are completed vs pending.\n" +
452
453
  '## Completed — what is already done: files created/modified (exact paths), key change per file, commands run and their outcomes (pass/fail, key numbers), decisions made and why.\n' +
453
454
  '## In Progress — what is being worked on right now and exactly where it stopped (e.g. "edit applied to foo.ts, test not yet run").\n' +
454
455
  '## Next Steps — the concrete next actions in order.\n' +
@@ -503,24 +504,19 @@ export async function compactHistory(history, opts) {
503
504
  const pinnedGroups = allGroups.filter(isSummaryGroup);
504
505
  const groups = pinnedGroups.length > 0 ? allGroups.filter((g) => !isSummaryGroup(g)) : allGroups;
505
506
  const pinned = pinnedGroups.length > 0
506
- ? splitSummaryText(pinnedGroups
507
- .map((g) => toText(g.assistant?.content))
508
- .join('\n\n'))
507
+ ? splitSummaryText(pinnedGroups.map((g) => toText(g.assistant?.content)).join('\n\n'))
509
508
  : null;
510
509
  // 保近期:按策略中的 token 比例累积(至少保 1 组),永不劈开 group。
511
510
  // force(手动 /compact 默认、硬闸触发)用更激进的保留比例;再加绝对上限,
512
511
  // 防大窗口(如 256k)下保留区按比例仍过大——压缩目标 = 摘要 + 最小续工上下文。
513
- const keepRatio = opts.force
514
- ? DEFAULT_BUDGET_POLICY.compactForceKeepRatio
515
- : DEFAULT_BUDGET_POLICY.compactKeepRatio;
512
+ const keepRatio = opts.force ? DEFAULT_BUDGET_POLICY.compactForceKeepRatio : DEFAULT_BUDGET_POLICY.compactKeepRatio;
516
513
  const keepBudget = Math.min(Math.floor(opts.window * keepRatio), DEFAULT_BUDGET_POLICY.compactKeepMaxTokens);
517
514
  const kept = [];
518
515
  let keptTokens = 0;
519
516
  for (let k = groups.length - 1; k >= 0; k--) {
520
517
  const g = groups[k];
521
518
  const nextTokens = keptTokens + groupTokens(g);
522
- if (kept.length >= 1
523
- && correctTokenEstimate(nextTokens, state.correction) > keepBudget)
519
+ if (kept.length >= 1 && correctTokenEstimate(nextTokens, state.correction) > keepBudget)
524
520
  break;
525
521
  kept.unshift(g);
526
522
  keptTokens = nextTokens;
@@ -576,8 +572,7 @@ export async function compactHistory(history, opts) {
576
572
  // force 但只剩单组(如首轮的单个 assistant+tools 组):原地微压缩、不动结构。
577
573
  // 纯 user 原话组无可压项(不能毁掉用户请求),落到下方 noop 提示。
578
574
  const single = groups[0];
579
- const userOnly = single.tools.length === 0
580
- && single.assistant?.role === 'user';
575
+ const userOnly = single.tools.length === 0 && single.assistant?.role === 'user';
581
576
  if (!userOnly && microcompactGroup(single)) {
582
577
  const estimateAfter = estimatePromptTokens(history, activeTools, state.correction);
583
578
  state.lastEstimate = estimateAfter;
@@ -642,9 +637,7 @@ export async function compactHistory(history, opts) {
642
637
  }
643
638
  if (summary) {
644
639
  const artifactRefs = collectArtifactRefs(older);
645
- const provenance = artifactRefs.length > 0
646
- ? `\n\n[artifact refs: ${artifactRefs.join(', ')}]`
647
- : '';
640
+ const provenance = artifactRefs.length > 0 ? `\n\n[artifact refs: ${artifactRefs.join(', ')}]` : '';
648
641
  // 钉住的 Key Facts 拼在段尾:单条 system 消息里尾部注意力最强,老事实不会被"读漏"。
649
642
  // 恒为单条——并排多条 system 摘要会触发近因效应,早期摘要形同虚设。
650
643
  const parsed = splitSummaryText(summary);
@@ -658,12 +651,7 @@ export async function compactHistory(history, opts) {
658
651
  if (snapshot)
659
652
  writeCompactionSnapshot(snapshot);
660
653
  }
661
- const body = [
662
- parsed.rest,
663
- pinnedFacts ? `## Key Facts\n${pinnedFacts}` : '',
664
- ]
665
- .filter(Boolean)
666
- .join('\n\n');
654
+ const body = [parsed.rest, pinnedFacts ? `## Key Facts\n${pinnedFacts}` : ''].filter(Boolean).join('\n\n');
667
655
  const summaryMsg = {
668
656
  role: 'system',
669
657
  content: `${SUMMARY_MARKER}\n${body}${provenance}`,
@@ -754,9 +742,9 @@ signal) {
754
742
  const isManual = manualOpts?.manual === true;
755
743
  // Hard guard: the scheduler has already attempted pressure cleanup; if raw
756
744
  // content remains near the provider limit, compact even when correction < 1.
757
- let hardCap = typeof report?.rawTotal === 'number'
758
- && typeof report?.window === 'number'
759
- && report.rawTotal >= DEFAULT_BUDGET_POLICY.pressureTriggerRatio * report.window;
745
+ let hardCap = typeof report?.rawTotal === 'number' &&
746
+ typeof report?.window === 'number' &&
747
+ report.rawTotal >= DEFAULT_BUDGET_POLICY.pressureTriggerRatio * report.window;
760
748
  // 手动路径:旁路 autoCompact / report / 总阈三重门
761
749
  if (!isManual) {
762
750
  if (!hardCap && !config.autoCompact)
@@ -9,8 +9,8 @@ export { compactHistory, maybeCompact, capToolResultForHistory, truncateMid, con
9
9
  // agent/core.ts 在 age-aware sweep 后调用 runScheduler(history, step):评估五区预算,
10
10
  // 只执行可落地的 warn / compact_history;开关关闭时退化为 maybeCompact。
11
11
  // repl /compact 命令调 manualCompact(history, focus?):与自动路径共享决策,focus 透传摘要 prompt。
12
- export { runScheduler, manualCompact, createBudgetScheduler, } from './scheduler.js';
13
- export { newSessionId, saveSession, loadSession, listSessions, sessionDir, } from './persist.js';
12
+ export { runScheduler, manualCompact, createBudgetScheduler } from './scheduler.js';
13
+ export { newSessionId, saveSession, loadSession, listSessions, sessionDir } from './persist.js';
14
14
  export { appendCurrentSessionTrace, appendCurrentSessionTraceEvent, appendCurrentSessionRuntimeEvent, createTraceEvent, } from './trace.js';
15
15
  export { reduceTraceMetrics, readTraceEvents, readTraceMetrics } from './trace-metrics.js';
16
16
  export { summarizeToolArguments, hashTraceValue, safeProviderId } from './trace-sanitize.js';
@@ -27,9 +27,21 @@ export function getNotesMtime(sessionId = getCurrentSessionId()) {
27
27
  return null;
28
28
  }
29
29
  }
30
+ /** 单个步骤短标签的字符上限(软截断,防模型把 content 整段塞进 title)。 */
31
+ export const PLAN_STEP_TITLE_MAX = 60;
32
+ /** 清洗步骤短标签:压平换行、去掉星号(避免破坏 `**title**` 的行首解析)。 */
33
+ function sanitizeStepTitle(raw) {
34
+ return raw
35
+ .replace(/[\r\n]+/g, ' ')
36
+ .replace(/\*/g, '')
37
+ .replace(/\s+/g, ' ')
38
+ .trim()
39
+ .slice(0, PLAN_STEP_TITLE_MAX);
40
+ }
30
41
  /**
31
42
  * 把 plan 渲染为 canonical markdown 段(不含首尾多余空行)。
32
43
  * 复选框格式 `- [ ] N.` / `- [x] N.` 与 repl 状态栏 chip 的计数正则严格对齐;
44
+ * 步骤正文为 `**短标签** — 详细描述`(有 title 时)或裸 content(老 plan 兼容),
33
45
  * in_progress 步骤追加 ` ◀ 当前` 后缀(仍计为未完成),让 compact 重注入后模型能直接续上。
34
46
  * 全部完成时标题写为 `## Done:`(自动结算),extractActivePlanSection 随之返回 null。
35
47
  */
@@ -42,15 +54,34 @@ export function renderPlanSection(plan) {
42
54
  lines.push('', '### Steps');
43
55
  plan.steps.forEach((s, i) => {
44
56
  const box = s.status === 'completed' ? '[x]' : '[ ]';
45
- const suffix = s.status === 'in_progress'
46
- ? ` ◀ ${(s.activeForm ?? '').trim() || 'current'}`
47
- : '';
48
- lines.push(`- ${box} ${i + 1}. ${s.content}${suffix}`);
57
+ const title = s.title ? sanitizeStepTitle(s.title) : '';
58
+ const body = title ? `**${title}** — ${s.content}` : s.content;
59
+ const suffix = s.status === 'in_progress' ? ` ◀ ${(s.activeForm ?? '').trim() || 'current'}` : '';
60
+ lines.push(`- ${box} ${i + 1}. ${body}${suffix}`);
49
61
  });
50
62
  const done = plan.steps.filter((s) => s.status === 'completed').length;
51
63
  lines.push('', '### Progress', `- ${done}/${plan.steps.length} steps complete`);
52
64
  return lines.join('\n');
53
65
  }
66
+ /**
67
+ * 从步骤行正文(去掉 `- [ ] N. ` 前缀后)提取 UI 用的短标签。兼容三种写法:
68
+ * - `**短标签** — 详细描述 ◀ 进行中` (plan_update 写了 title 的新格式)
69
+ * - `**短标签** — 详细描述`
70
+ * - `详细描述 ◀ 进行中` (老 plan,无 title)
71
+ * 优先级 title > activeForm > content:状态栏 chip 只有一行,短的先上,细节交给
72
+ * composePlanLines 截断保单行(脚栏不撑高)。老 plan 无 title 时行为与加 title 前一致。
73
+ * 与 renderPlanSection 配对(一个写一个读),格式定义与解析同处,避免散落两处走样。
74
+ */
75
+ export function planStepLabel(raw) {
76
+ const body = raw.trim();
77
+ const afIdx = body.lastIndexOf('◀');
78
+ const activeForm = afIdx >= 0 ? body.slice(afIdx + 1).trim() : '';
79
+ const main = (afIdx >= 0 ? body.slice(0, afIdx) : body).trim();
80
+ const m = main.match(/^\*\*(.+?)\*\*(?:\s*—\s*)?([\s\S]*)$/);
81
+ const title = m?.[1]?.trim() ?? '';
82
+ const content = (m ? (m[2] ?? '') : main).trim();
83
+ return title || activeForm || content || main;
84
+ }
54
85
  /** 读取当前活跃 `## Plan:` 段的标题;无活跃 plan 返 null。 */
55
86
  export function readActivePlanTitle(sessionId = getCurrentSessionId()) {
56
87
  const p = getNotesFilePath(sessionId);
@@ -127,7 +158,10 @@ const SECTION_PRIORITY = {
127
158
  // compaction_snapshot 提到最高:它是压缩当次的权威进度快照,比手填 findings 更能
128
159
  // 直接告诉模型「做到哪了」,压缩后第一步最该看到的就是它。
129
160
  compaction_snapshot: 5,
130
- risks: 4, findings: 3, decisions: 2, open_questions: 1,
161
+ risks: 4,
162
+ findings: 3,
163
+ decisions: 2,
164
+ open_questions: 1,
131
165
  };
132
166
  /** 常驻笔记正文总预算(token)。5k:占百万级 context 的 0.5%,可常驻相当量笔记。 */
133
167
  const NOTES_INJECT_BUDGET_TOKENS = 5000;
@@ -1,6 +1,7 @@
1
- import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync, } from 'node:fs';
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { config, getActiveModel } from '../config/index.js';
4
+ import { isToolRouteGroupName } from '../config/profiles.js';
4
5
  import { truncateDisplay } from '../ui/render.js';
5
6
  /** 会话目录(确保存在)。 */
6
7
  export function sessionDir() {
@@ -35,7 +36,9 @@ function toText(content) {
35
36
  function firstUserOf(history) {
36
37
  for (const m of history) {
37
38
  if (m.role === 'user') {
38
- const text = toText(m.content).replace(/\n/g, ' ').trim();
39
+ const text = toText(m.content)
40
+ .replace(/\n/g, ' ')
41
+ .trim();
39
42
  return truncateDisplay(text, 40);
40
43
  }
41
44
  }
@@ -45,7 +48,7 @@ function sessionPath(id) {
45
48
  return path.join(config.sessionDir, id, 'session.json');
46
49
  }
47
50
  /** 保存会话到磁盘。全新且没有 query 的会话不创建文件;已有会话即使回滚为空也必须覆盖旧记录。 */
48
- export function saveSession(history, id, queryHistory = []) {
51
+ export function saveSession(history, id, queryHistory = [], lastToolGroups = []) {
49
52
  const meta = {
50
53
  id,
51
54
  createdAt: idToIso(id),
@@ -56,15 +59,17 @@ export function saveSession(history, id, queryHistory = []) {
56
59
  };
57
60
  const currentPath = sessionPath(id);
58
61
  const legacyPath = path.join(config.sessionDir, `${id}.json`);
59
- if (history.length <= 1 &&
60
- queryHistory.length === 0 &&
61
- !existsSync(currentPath) &&
62
- !existsSync(legacyPath)) {
62
+ if (history.length <= 1 && queryHistory.length === 0 && !existsSync(currentPath) && !existsSync(legacyPath)) {
63
63
  return meta;
64
64
  }
65
65
  const dir = path.join(config.sessionDir, id);
66
66
  mkdirSync(dir, { recursive: true });
67
- const record = { ...meta, history, queryHistory: [...queryHistory] };
67
+ const record = {
68
+ ...meta,
69
+ history,
70
+ queryHistory: [...queryHistory],
71
+ lastToolGroups: [...lastToolGroups],
72
+ };
68
73
  writeFileSync(currentPath, JSON.stringify(record), 'utf8');
69
74
  // 一旦写入新式目录,删除旧式扁平副本,避免已回滚消息仍残留在磁盘。
70
75
  if (existsSync(legacyPath))
@@ -94,6 +99,7 @@ export function loadSession(id) {
94
99
  queryHistory: Array.isArray(rec.queryHistory)
95
100
  ? rec.queryHistory.filter((query) => typeof query === 'string')
96
101
  : undefined,
102
+ lastToolGroups: Array.isArray(rec.lastToolGroups) ? rec.lastToolGroups.filter(isToolRouteGroupName) : undefined,
97
103
  };
98
104
  }
99
105
  catch {
@@ -8,8 +8,7 @@ import { pruneStaleArtifacts, refreshArtifactFreshness } from '../context/artifa
8
8
  import { pruneSuperseded } from '../context/relevance.js';
9
9
  import { createAgeAwareEncodingState } from '../context/age-aware.js';
10
10
  function atPressure(report) {
11
- return Math.max(report.rawTotal, report.total)
12
- >= report.window * DEFAULT_BUDGET_POLICY.pressureTriggerRatio;
11
+ return Math.max(report.rawTotal, report.total) >= report.window * DEFAULT_BUDGET_POLICY.pressureTriggerRatio;
13
12
  }
14
13
  function emptyPressure(report) {
15
14
  return {
@@ -51,7 +50,7 @@ export function createBudgetScheduler(state = contextState) {
51
50
  const actions = scheduleActions(report);
52
51
  let compactHistoryCalled = false;
53
52
  let historyRebuilt = false;
54
- for (const action of actions) {
53
+ for (const _action of actions) {
55
54
  const result = await maybeCompact(history, report, undefined, state, activeTools, signal);
56
55
  compactHistoryCalled = true;
57
56
  historyRebuilt ||= result?.historyRebuilt === true;
@@ -112,7 +111,7 @@ export async function manualCompact(history, focus, opts) {
112
111
  actions = [...actions, { kind: 'compact_history', focus }];
113
112
  }
114
113
  else if (focus) {
115
- actions = actions.map((action) => action.kind === 'compact_history' ? { ...action, focus } : action);
114
+ actions = actions.map((action) => (action.kind === 'compact_history' ? { ...action, focus } : action));
116
115
  }
117
116
  let compactHistoryCalled = false;
118
117
  let compactDetail;
@@ -51,9 +51,12 @@ export function readTraceEvents(file) {
51
51
  continue;
52
52
  try {
53
53
  const value = JSON.parse(line);
54
- if (value.schemaVersion === 1 && typeof value.type === 'string' &&
55
- typeof value.sessionId === 'string' && typeof value.turnId === 'number' &&
56
- value.data && typeof value.data === 'object') {
54
+ if (value.schemaVersion === 1 &&
55
+ typeof value.type === 'string' &&
56
+ typeof value.sessionId === 'string' &&
57
+ typeof value.turnId === 'number' &&
58
+ value.data &&
59
+ typeof value.data === 'object') {
57
60
  events.push(value);
58
61
  }
59
62
  }
@@ -1,6 +1,6 @@
1
- // inline skill 激活态(设计 §3.6):use_skill 成功加载 inline skill 时置位,
1
+ // inline skill 激活态(设计 §3.6):use_skill 成功加载 inline skill 时置位,
2
2
  // 该 turn 内模型的 disallowed-tools 约束生效(constants.getRuntimeDisabledTools 消费)。
3
- // 一 turn 语义:下一次用户轮开始时由 repl 调 clearSkillActivation() 清除。
3
+ // 同一 turn 连续加载多个 inline skill 时 deny 取并集,只能继续收窄;下一次真实用户轮清除。
4
4
  //
5
5
  // 刻意不设 allowed 授权:inline 加载不过信任门禁,自动放行 confirm 级工具会让
6
6
  // 未信任的项目 skill 绕过权限确认。allowed-tools 只在 fork 模式下经 spawnAgent
@@ -9,12 +9,17 @@
9
9
  // 叶子模块:仅依赖 toolmap(亦叶子),不引 tools/permissions/agent,避免环。
10
10
  import { mapSkillToolName } from './toolmap.js';
11
11
  let active = null;
12
- /** use_skill 成功加载 inline skill 时调用。 */
12
+ /** use_skill 成功加载 inline skill 时调用;同一 turn 的 deny 只累加、不撤销。 */
13
13
  export function activateSkill(skill) {
14
- const disallowed = skill.disallowedTools?.map(mapSkillToolName).filter((t) => t != null);
14
+ const disallowed = new Set(active?.disallowed ?? []);
15
+ for (const token of skill.disallowedTools ?? []) {
16
+ const mapped = mapSkillToolName(token);
17
+ if (mapped)
18
+ disallowed.add(mapped);
19
+ }
15
20
  active = {
16
21
  name: skill.name,
17
- disallowed: disallowed && disallowed.length ? new Set(disallowed) : null,
22
+ disallowed: disallowed.size > 0 ? disallowed : null,
18
23
  };
19
24
  }
20
25
  /** 用户新轮开始时清激活态。 */
@@ -89,23 +89,12 @@ function resolveContext(meta) {
89
89
  return 'fork'; // 旧内部别名
90
90
  return 'inline';
91
91
  }
92
- /** `agent:` 映射到 mocode 的 read/write 双态;未知值记 warning 并保守按 read。 */
93
- function resolveAgentMode(meta, warnings) {
94
- const a = scalar(meta, 'agent');
95
- if (!a)
96
- return undefined;
97
- const low = a.toLowerCase();
98
- if (['explore', 'plan', 'research', 'read'].includes(low))
99
- return 'read';
100
- if (['general-purpose', 'write'].includes(low))
101
- return 'write';
102
- warnings.push(`未知 agent 值 "${a}",按 read(只读,保守)处理`);
103
- return 'read';
104
- }
105
- /** 收集本轮不支持、已忽略的字段,用于 /skills 提示(不阻断加载)。 */
92
+ /** 收集本轮不支持、已忽略的字段,用于 /skills 提示(不阻断加载)。
93
+ * `agent:` 曾在旧 read/write 双模式下决定子 agent 工具面;子 agent 现与主 agent 同权,
94
+ * 该字段不再产生任何效果,按"已忽略"提示作者,避免其误以为仍有限权语义。 */
106
95
  function collectWarnings(meta) {
107
96
  const warnings = [];
108
- for (const k of ['hooks', 'model', 'effort', 'paths']) {
97
+ for (const k of ['hooks', 'model', 'effort', 'paths', 'agent']) {
109
98
  if (k in meta)
110
99
  warnings.push(`字段 "${k}" 当前不支持,已忽略`);
111
100
  }
@@ -230,7 +219,7 @@ export function discoverSkills() {
230
219
  continue;
231
220
  seenMdPaths.push(skillMdPath);
232
221
  const content = readFileSync(skillMdPath, 'utf8');
233
- const { meta, body } = parseFrontmatter(content);
222
+ const { meta } = parseFrontmatter(content);
234
223
  const skillName = (scalar(meta, 'name') || name).trim();
235
224
  const description = (scalar(meta, 'description') || '').trim();
236
225
  if (!description)
@@ -245,7 +234,6 @@ export function discoverSkills() {
245
234
  skillMdPath,
246
235
  // body 留空:用户/项目 skill 由 getSkillBody 走文件读取(保留热重载语义)。
247
236
  context: resolveContext(meta),
248
- agentMode: resolveAgentMode(meta, warnings),
249
237
  allowedTools: asArray(meta, 'allowed-tools'),
250
238
  disallowedTools: asArray(meta, 'disallowed-tools'),
251
239
  modelInvocable: !truthy(meta, 'disable-model-invocation'),