mocode-ai 1.2.8 → 1.2.9

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 (129) hide show
  1. package/dist/agent/core.js +126 -43
  2. package/dist/config/index.js +31 -6
  3. package/dist/config/presets.js +36 -1
  4. package/dist/context/budget.js +0 -15
  5. package/dist/llm/index.js +33 -0
  6. package/dist/repl/index.js +57 -13
  7. package/dist/session/compact.js +237 -13
  8. package/dist/session/notes.js +63 -0
  9. package/dist/session/scheduler.js +5 -9
  10. package/dist/tools/constants.js +7 -0
  11. package/package.json +1 -1
  12. package/dist/__trace_manual_test__.js +0 -1
  13. package/dist/agent/core.d.ts +0 -112
  14. package/dist/agent/index.d.ts +0 -19
  15. package/dist/agent/mode.d.ts +0 -29
  16. package/dist/agent/retry-classifier.js +0 -64
  17. package/dist/agent/spawn.d.ts +0 -36
  18. package/dist/attachments/image.d.ts +0 -22
  19. package/dist/commands/config.d.ts +0 -8
  20. package/dist/commands/skill.js +0 -230
  21. package/dist/config/file.d.ts +0 -23
  22. package/dist/config/index.d.ts +0 -114
  23. package/dist/config/presets.d.ts +0 -57
  24. package/dist/context/budget.d.ts +0 -79
  25. package/dist/context/classifier.d.ts +0 -14
  26. package/dist/context/encoders/_util.d.ts +0 -11
  27. package/dist/context/encoders/code.d.ts +0 -2
  28. package/dist/context/encoders/doc.d.ts +0 -14
  29. package/dist/context/encoders/graph.d.ts +0 -11
  30. package/dist/context/encoders/graph.js +0 -81
  31. package/dist/context/encoders/index.d.ts +0 -2
  32. package/dist/context/encoders/log.d.ts +0 -2
  33. package/dist/context/encoders/log.js +0 -2
  34. package/dist/context/encoders/memory.d.ts +0 -2
  35. package/dist/context/encoders/passthrough.d.ts +0 -11
  36. package/dist/context/encoders/search.d.ts +0 -2
  37. package/dist/context/encoders/summary.d.ts +0 -12
  38. package/dist/context/encoders/table.d.ts +0 -2
  39. package/dist/context/encoders/tree.d.ts +0 -2
  40. package/dist/context/index.d.ts +0 -6
  41. package/dist/context/lifecycle.d.ts +0 -51
  42. package/dist/context/pipeline.d.ts +0 -9
  43. package/dist/context/registry.d.ts +0 -9
  44. package/dist/context/relevance.d.ts +0 -65
  45. package/dist/context/types.d.ts +0 -36
  46. package/dist/foo.js +0 -28
  47. package/dist/foo.test.js +0 -12
  48. package/dist/index.d.ts +0 -1
  49. package/dist/llm/capabilities.d.ts +0 -19
  50. package/dist/llm/index.d.ts +0 -111
  51. package/dist/memory/discover.d.ts +0 -15
  52. package/dist/memory/index.d.ts +0 -13
  53. package/dist/memory/reflect.d.ts +0 -34
  54. package/dist/memory/store.d.ts +0 -124
  55. package/dist/pet/bridge.d.ts +0 -73
  56. package/dist/pet/protocol.d.ts +0 -83
  57. package/dist/pet/state.d.ts +0 -28
  58. package/dist/plan/active.d.ts +0 -23
  59. package/dist/plan/active.js +0 -45
  60. package/dist/plan/index.d.ts +0 -13
  61. package/dist/plan/index.js +0 -33
  62. package/dist/plan/state.d.ts +0 -2
  63. package/dist/plan/state.js +0 -11
  64. package/dist/plan/store.d.ts +0 -78
  65. package/dist/plan/store.js +0 -429
  66. package/dist/project-skill/index.js +0 -95
  67. package/dist/project-skill/initializer.js +0 -178
  68. package/dist/project-snapshot/index.js +0 -85
  69. package/dist/project-snapshot/llm-snapshot.js +0 -150
  70. package/dist/project-snapshot/static-files.js +0 -87
  71. package/dist/repl/index.d.ts +0 -22
  72. package/dist/rollback/index.d.ts +0 -79
  73. package/dist/sandbox/command.d.ts +0 -3
  74. package/dist/sandbox/index.d.ts +0 -4
  75. package/dist/sandbox/jail.d.ts +0 -17
  76. package/dist/sandbox/policy.d.ts +0 -21
  77. package/dist/sandbox/root.d.ts +0 -5
  78. package/dist/session/compact.d.ts +0 -92
  79. package/dist/session/index.d.ts +0 -13
  80. package/dist/session/notes-plan.js +0 -41
  81. package/dist/session/persist.d.ts +0 -30
  82. package/dist/session/scheduler.d.ts +0 -53
  83. package/dist/skills/discover.d.ts +0 -33
  84. package/dist/skills/index.d.ts +0 -13
  85. package/dist/skills/skill-eval.js +0 -345
  86. package/dist/skills/skill-improve.js +0 -221
  87. package/dist/skills/stats.js +0 -102
  88. package/dist/tools/builtins/apply-patch.js +0 -174
  89. package/dist/tools/builtins/ask-human.d.ts +0 -9
  90. package/dist/tools/builtins/codegraph.d.ts +0 -2
  91. package/dist/tools/builtins/codegraph.js +0 -128
  92. package/dist/tools/builtins/edit-file.d.ts +0 -2
  93. package/dist/tools/builtins/glob.d.ts +0 -2
  94. package/dist/tools/builtins/grep.d.ts +0 -2
  95. package/dist/tools/builtins/index.d.ts +0 -2
  96. package/dist/tools/builtins/memory-forget.d.ts +0 -2
  97. package/dist/tools/builtins/memory-list.d.ts +0 -2
  98. package/dist/tools/builtins/memory-save.d.ts +0 -2
  99. package/dist/tools/builtins/memory-search.d.ts +0 -2
  100. package/dist/tools/builtins/memory-update.d.ts +0 -2
  101. package/dist/tools/builtins/project-skill-update.js +0 -102
  102. package/dist/tools/builtins/read-file.d.ts +0 -2
  103. package/dist/tools/builtins/run-command.d.ts +0 -2
  104. package/dist/tools/builtins/switch-mode.d.ts +0 -2
  105. package/dist/tools/builtins/switch-mode.js +0 -44
  106. package/dist/tools/builtins/task.d.ts +0 -2
  107. package/dist/tools/builtins/todolist.d.ts +0 -2
  108. package/dist/tools/builtins/todolist.js +0 -411
  109. package/dist/tools/builtins/use-skill.d.ts +0 -2
  110. package/dist/tools/builtins/web-fetch.d.ts +0 -2
  111. package/dist/tools/builtins/web-search.d.ts +0 -2
  112. package/dist/tools/builtins/write-file.d.ts +0 -2
  113. package/dist/tools/constants.d.ts +0 -52
  114. package/dist/tools/registry.d.ts +0 -18
  115. package/dist/tools/retry.js +0 -137
  116. package/dist/tools/types.d.ts +0 -42
  117. package/dist/ui/batch.d.ts +0 -67
  118. package/dist/ui/clipboard.d.ts +0 -9
  119. package/dist/ui/content.d.ts +0 -55
  120. package/dist/ui/diff.d.ts +0 -34
  121. package/dist/ui/intervention.d.ts +0 -46
  122. package/dist/ui/layout.d.ts +0 -225
  123. package/dist/ui/markdown.d.ts +0 -46
  124. package/dist/ui/mouse.d.ts +0 -29
  125. package/dist/ui/prompt.d.ts +0 -61
  126. package/dist/ui/render.d.ts +0 -60
  127. package/dist/ui/spinner.d.ts +0 -26
  128. package/dist/ui/theme.d.ts +0 -47
  129. package/dist/ui/thinking.js +0 -80
@@ -1,6 +1,6 @@
1
1
  import { chat, chatTools, correctTokenEstimate, estimatePromptTokens, estimateTokens, } from '../llm/index.js';
2
2
  import { config } from '../config/index.js';
3
- import { MAX_HISTORY_RESULT, MAX_MEMORY_RESULT, MAX_OLD_TOOL_STUB, MAX_SKILL_RESULT, SUMMARY_MSG_MAX_CHARS, SUMMARY_OUTPUT_MAX_CHARS, SUMMARY_TRANSCRIPT_WINDOW_RATIO, } from '../tools/constants.js';
3
+ import { MAX_HISTORY_RESULT, MAX_MEMORY_RESULT, MAX_OLD_TOOL_STUB, MAX_SKILL_RESULT, SUMMARY_KEYFACTS_MAX_CHARS, SUMMARY_KEYFACTS_MIN_CHARS, SUMMARY_KEYFACTS_WINDOW_RATIO, SUMMARY_MSG_MAX_CHARS, SUMMARY_OUTPUT_MAX_CHARS, SUMMARY_TRANSCRIPT_WINDOW_RATIO, } from '../tools/constants.js';
4
4
  import { ui } from '../ui/theme.js';
5
5
  import { Spinner } from '../ui/spinner.js';
6
6
  import * as layout from '../ui/layout.js';
@@ -8,6 +8,7 @@ import { pruneAfterCompaction } from '../rollback/index.js';
8
8
  import { toText } from '../context/utils.js';
9
9
  import { DEFAULT_BUDGET_POLICY } from '../context/budget.js';
10
10
  import { collectArtifactRefs } from '../context/artifacts.js';
11
+ import { writeCompactionSnapshot } from './notes.js';
11
12
  export function createContextState() {
12
13
  return { lastEstimate: 0, correction: 1, calibrationSamples: 0 };
13
14
  }
@@ -130,6 +131,155 @@ export function stripImagesForSummary(m) {
130
131
  }
131
132
  return m;
132
133
  }
134
+ // ── 摘要钉住:Key Facts 跨代累积,叙述段照常滚动压缩 ──────────────────────
135
+ //
136
+ // 为什么需要:摘要写在 history[1](role:'system'),而 groupFromEnd 只排除 history[0],
137
+ // 于是它被当成普通 group 排到最老位置 → 下次压缩进 oldGroups → 被喂给摘要器再摘要一遍。
138
+ // 每代压缩都对上一代摘要做一次有损再压缩,首轮的用户约束经 2-3 代即糊掉。堵住这个衰减
139
+ // 源比设计任何打分模型都划算。
140
+ //
141
+ // 为什么只钉 Key Facts:钉整段(含 Objective/Completed/Next Steps)会线性膨胀——那些
142
+ // 叙述重跑一遍命令、重读一次文件就能拿回来;Key Facts 才是不在就真没了的东西(路径、
143
+ // 约束、失败结论),且每代只增加几十字符。
144
+ /** 摘要消息头。budget.ts 用同一前缀识别摘要层,勿改。 */
145
+ export const SUMMARY_MARKER = '# 会话摘要';
146
+ /** `## Key Facts` 段标题(model 按 prompt 输出,可能带 "— 说明" 后缀)。 */
147
+ const KEY_FACTS_HEADING_RE = /^ {0,3}##\s*key facts\b/i;
148
+ /** 任意 `## ` 级标题:Key Facts 段到此为止。 */
149
+ const ANY_HEADING_RE = /^ {0,3}##\s+\S/;
150
+ /** 上一代叙述段进 transcript 时的标注:告诉摘要器这是已压过一遍的内容,合并而非抄录。 */
151
+ const PRIOR_NARRATIVE_LABEL = '[prior summary — 上一代摘要的叙述部分:与下方新内容合并压缩,不要重复罗列细节]';
152
+ /** 尾部 provenance 块:解析时剥掉,重建时按当次 older 重算(否则跨代累积并重复)。 */
153
+ const ARTIFACT_REFS_RE = /\n*\[artifact refs:[^\]]*\]\s*$/;
154
+ /** Key Facts 累计预算(字符):窗口比例换算 + 上下限——小窗口留得住初始约束,
155
+ * 大窗口也不会让索引段膨胀成正文。 */
156
+ export function keyFactsBudgetChars(window) {
157
+ const byWindow = Math.floor(Math.max(0, window) * SUMMARY_KEYFACTS_WINDOW_RATIO);
158
+ return Math.max(SUMMARY_KEYFACTS_MIN_CHARS, Math.min(SUMMARY_KEYFACTS_MAX_CHARS, byWindow));
159
+ }
160
+ /**
161
+ * 拆摘要成「Key Facts 段」+「叙述段」。
162
+ * 顺带剥掉 `# 会话摘要` 头与尾部 `[artifact refs: …]` provenance——后者每次压缩按当次
163
+ * older 重算,不该跨代累积。无 Key Facts 段时 rest 为全文、keyFacts 为空。
164
+ */
165
+ export function splitSummaryText(raw) {
166
+ let text = (raw ?? '').replace(ARTIFACT_REFS_RE, '').trim();
167
+ if (text.startsWith(SUMMARY_MARKER)) {
168
+ const nl = text.indexOf('\n');
169
+ text = (nl < 0 ? '' : text.slice(nl + 1)).trim();
170
+ }
171
+ const lines = text.split('\n');
172
+ const headIdx = lines.findIndex((l) => KEY_FACTS_HEADING_RE.test(l));
173
+ if (headIdx < 0)
174
+ return { keyFacts: '', rest: text };
175
+ let endIdx = lines.length;
176
+ for (let i = headIdx + 1; i < lines.length; i++) {
177
+ if (ANY_HEADING_RE.test(lines[i])) {
178
+ endIdx = i;
179
+ break;
180
+ }
181
+ }
182
+ const keyFacts = lines.slice(headIdx + 1, endIdx).join('\n').trim();
183
+ const rest = [...lines.slice(0, headIdx), ...lines.slice(endIdx)].join('\n').trim();
184
+ return { keyFacts, rest };
185
+ }
186
+ /**
187
+ * 中断判定:signal 已 abort,或错误本身是 AbortError / APIUserAbortError。
188
+ * 与 llm 层、core 层的判定保持同一套(两处 name + signal 双判),避免各写各的漏判。
189
+ */
190
+ function isAbortError(err, signal) {
191
+ if (signal?.aborted)
192
+ return true;
193
+ // 鸭子类型判定而非 instanceof Error:DOMException / SDK 的 APIUserAbortError 在
194
+ // 不同 Node 版本下 instanceof Error 的结果不一致,认 name 更稳。
195
+ if (!err || typeof err !== 'object')
196
+ return false;
197
+ const name = err.name;
198
+ return name === 'AbortError' || name === 'APIUserAbortError';
199
+ }
200
+ /** 归一化一行事实用于去重:去项目符号/空白/句末标点后小写。 */
201
+ function factKey(line) {
202
+ return line
203
+ .toLowerCase()
204
+ .replace(/\s+/g, ' ')
205
+ .replace(/^[-*•]\s*/, '')
206
+ .replace(/[。.,;;::]+$/, '')
207
+ .trim();
208
+ }
209
+ /**
210
+ * 从摘要正文提取「当前进度快照」段:Objective / In Progress / Next Steps。
211
+ * 这是压缩后最该被记住的「做到哪了」,固结到 notes.md 供恢复步骤注入。
212
+ * 只识别 `## ` 级标题(摘要器固定输出此结构);一个都没有时返 null(不写空快照)。
213
+ */
214
+ export function extractProgressSnapshot(summaryRest) {
215
+ const WANT = new Set(['objective', 'in progress', 'next steps']);
216
+ const lines = summaryRest.split('\n');
217
+ const out = [];
218
+ let taking = false;
219
+ for (const line of lines) {
220
+ const m = line.match(/^ {0,3}##\s+(.+?)\s*$/);
221
+ if (m) {
222
+ const name = m[1].replace(/\s*[—–-].*$/, '').trim().toLowerCase();
223
+ taking = WANT.has(name);
224
+ }
225
+ if (taking)
226
+ out.push(line);
227
+ }
228
+ const text = out.join('\n').trim();
229
+ return text ? text : null;
230
+ }
231
+ /**
232
+ * 合并跨代 Key Facts:按行去重(老 → 新),超预算时丢较新条目。
233
+ *
234
+ * 丢新不丢旧的理由:最老那几条通常是首轮用户约束(不可重建,丢了就永远没了);
235
+ * 较新的事实通常在本代叙述段或保留区里还有副本。
236
+ */
237
+ export function mergeKeyFacts(prev, next, maxChars) {
238
+ const lines = [];
239
+ const seen = new Set();
240
+ for (const block of [prev ?? '', next ?? '']) {
241
+ for (const rawLine of block.split('\n')) {
242
+ const line = rawLine.trim();
243
+ if (!line)
244
+ continue;
245
+ const key = factKey(line);
246
+ if (!key || seen.has(key))
247
+ continue;
248
+ seen.add(key);
249
+ lines.push(line);
250
+ }
251
+ }
252
+ const cap = Math.max(0, Math.floor(maxChars));
253
+ const text = lines.join('\n');
254
+ if (text.length <= cap)
255
+ return text;
256
+ const kept = [];
257
+ let len = 0;
258
+ for (const line of lines) {
259
+ if (len + line.length + 1 > cap)
260
+ break;
261
+ kept.push(line);
262
+ len += line.length + 1;
263
+ }
264
+ return `${kept.join('\n')}\n…[Key Facts 超预算,已省略较新条目]`;
265
+ }
266
+ /**
267
+ * 非空 user group 判定。LLM API(OpenAI / Anthropic)要求 messages 至少含一条**非空**
268
+ * user 消息,llm/chatOnce 入口有 hasNonEmptyUser 守卫,缺则直接抛。压缩重建 history 后
269
+ * 必须仍有这样一条,否则下一步 chat() 当场失败——见下方 user 保留兜底。
270
+ */
271
+ function isUserGroup(g) {
272
+ const a = g.assistant;
273
+ return !!a && a.role === 'user' && toText(a.content).trim().length > 0;
274
+ }
275
+ /** 摘要 group 判定:非 history[0] 的 system 消息,以 `# 会话摘要` 开头,且不带 tool_calls。 */
276
+ function isSummaryGroup(g) {
277
+ const a = g.assistant;
278
+ return (!!a &&
279
+ a.role === 'system' &&
280
+ g.tools.length === 0 &&
281
+ toText(a.content).startsWith(SUMMARY_MARKER));
282
+ }
133
283
  /** 从尾向头划分 group;history[0](system)排除。连续 tool 归到前导 assistant。 */
134
284
  function groupFromEnd(history) {
135
285
  const groups = [];
@@ -273,7 +423,11 @@ function buildTranscript(stripped, scale) {
273
423
  })
274
424
  .join('\n');
275
425
  }
276
- async function defaultSummarize(older, focus) {
426
+ async function defaultSummarize(older, focus, signal) {
427
+ // 已中断就别白拼转录了(几千 token 的字符串拼接 + 一去不回的 LLM 请求)。
428
+ if (signal?.aborted) {
429
+ throw new DOMException('This operation was aborted', 'AbortError');
430
+ }
277
431
  // 摘要前剥离多模态 user 消息里的图片(base64 会撑爆摘要 prompt;image 对摘要无信息量)。
278
432
  const stripped = older.map(stripImagesForSummary);
279
433
  // 触发压缩时旧区约占窗口 50-60%,摘要器共享同一窗口——逐条封顶后旧区原文即可装入,
@@ -302,6 +456,8 @@ async function defaultSummarize(older, focus) {
302
456
  'What counts as important (keep): user requests and constraints; final state of each modified file; conclusions and results, not the steps that led there; decisions with reasons; precise references (paths, symbols, hashes, commands) that later steps must cite; failures and what was tried, so mistakes are not repeated.\n' +
303
457
  'What to drop: verbatim file contents and tool-output dumps, step-by-step recaps, exploration dead-ends that led nowhere, polite chatter, anything re-derivable by re-reading files.\n' +
304
458
  'Rules: total ≤ 400 words. State conclusions and locations (path:line where useful), never paste content. ' +
459
+ 'The Key Facts section is carried forward verbatim into future summaries: write each fact as one self-contained line ' +
460
+ '(no pronouns, no "as above", no cross-references), so it still reads correctly after later compactions. ' +
305
461
  'Never invent facts, paths, or results that are not in the transcript; if unsure whether something happened, omit it.',
306
462
  };
307
463
  const userMsg = {
@@ -311,11 +467,12 @@ async function defaultSummarize(older, focus) {
311
467
  : `请将以下会话历史压缩成交接摘要:\n\n${transcript}\n\n摘要:`,
312
468
  };
313
469
  const spinner = new Spinner((msg, frame) => layout.setStatus(msg, frame ?? undefined));
314
- spinner.start('压缩中');
470
+ spinner.start(signal ? '压缩中(Ctrl+C 取消)' : '压缩中');
315
471
  try {
472
+ // signal 必须透传:摘要是几十秒的 LLM 调用,不串进来 Ctrl+C 只能干等它跑完。
316
473
  // 空 handlers:不打印、不外显流式;tools=[] 不带工具表——摘要纯文本任务,
317
474
  // 全量工具 schema 白占几千 token 窗口,还诱导幻觉工具调用。
318
- const r = await chat([sysMsg, userMsg], {}, undefined, []);
475
+ const r = await chat([sysMsg, userMsg], {}, signal, []);
319
476
  // 推理模型可能只返 reasoning_content(content 为 null),或幻觉出 tool_calls → 视为失败
320
477
  if (r.toolCalls.length > 0 || !r.content)
321
478
  return null;
@@ -335,7 +492,21 @@ export async function compactHistory(history, opts) {
335
492
  const activeTools = opts.tools ?? chatTools;
336
493
  const estimateBefore = estimatePromptTokens(history, activeTools, state.correction);
337
494
  state.lastEstimate = estimateBefore;
338
- const groups = groupFromEnd(history);
495
+ // 调用前就已中断(用户在上一步末尾按的 Ctrl+C):一步都别做,直接冒泡。
496
+ // 不做完再抛是为了保证 history 完全未被触碰——abortRestore 才还原得干净。
497
+ if (opts.signal?.aborted) {
498
+ throw new DOMException('This operation was aborted', 'AbortError');
499
+ }
500
+ const allGroups = groupFromEnd(history);
501
+ // 摘要钉住:把已有摘要从 group 序列里摘出来,永不送进摘要器(否则每代再摘要一次 →
502
+ // 递归衰减)。它的 Key Facts 段跨代累积,叙述段则并入下方转录照常滚动压缩。
503
+ const pinnedGroups = allGroups.filter(isSummaryGroup);
504
+ const groups = pinnedGroups.length > 0 ? allGroups.filter((g) => !isSummaryGroup(g)) : allGroups;
505
+ const pinned = pinnedGroups.length > 0
506
+ ? splitSummaryText(pinnedGroups
507
+ .map((g) => toText(g.assistant?.content))
508
+ .join('\n\n'))
509
+ : null;
339
510
  // 保近期:按策略中的 token 比例累积(至少保 1 组),永不劈开 group。
340
511
  // force(手动 /compact 默认、硬闸触发)用更激进的保留比例;再加绝对上限,
341
512
  // 防大窗口(如 256k)下保留区按比例仍过大——压缩目标 = 摘要 + 最小续工上下文。
@@ -355,16 +526,31 @@ export async function compactHistory(history, opts) {
355
526
  keptTokens = nextTokens;
356
527
  }
357
528
  let oldGroups = groups.slice(0, groups.length - kept.length);
529
+ // 非空 user 兜底:保留区从尾部累积,长回合里最近的若干组常常全是 assistant+tool,
530
+ // 一个 8000 字符的工具结果就能吃满小窗口的 keepBudget → user 全落进旧区被摘要掉 →
531
+ // 重建后 history 无 user → 下一步 chat() 被 hasNonEmptyUser 守卫直接拒(整轮中断)。
532
+ // 把紧邻保留区之前的那条 user 移进保留区:它是当前任务的原始请求,且 user 消息本身极小。
533
+ // 放这里(而非重建后补 stub)是因为:原文保留,不造新消息形状,也不必进摘要器。
534
+ if (!kept.some(isUserGroup)) {
535
+ for (let i = oldGroups.length - 1; i >= 0; i--) {
536
+ if (!isUserGroup(oldGroups[i]))
537
+ continue;
538
+ kept.unshift(oldGroups[i]);
539
+ oldGroups.splice(i, 1);
540
+ break;
541
+ }
542
+ }
358
543
  // force(硬闸/手动强压):保护区不豁免——常规切分无旧区时只保最后一组,
359
544
  // 其余全部进可压区(首轮/当前轮也一样)。仍按 group 边界切,不破坏 tool_call 配对。
360
545
  // **必须保留最早 user 所在 group**:LLM API(OpenAI / Anthropic)要求 messages 至少
361
546
  // 含一条非空 user 消息,否则 400。force 旧实现把所有 user 丢进摘要 → 重建后 history
362
547
  // 无 user → 下一轮 chat() 被后端拒绝。保最早 user(而非最后一个)因为它是最原始的
363
- // 请求上下文,摘要器已覆盖后续交互。
548
+ // 请求上下文,摘要器已覆盖后续交互。用 isUserGroup(非空判定)而非只看 role:
549
+ // 空 content 的 user 同样过不了 hasNonEmptyUser 守卫。
364
550
  if (oldGroups.length === 0 && opts.force && groups.length >= 2) {
365
551
  kept.length = 0;
366
552
  const lastIdx = groups.length - 1;
367
- const firstUserIdx = groups.findIndex((g) => g.assistant?.role === 'user');
553
+ const firstUserIdx = groups.findIndex(isUserGroup);
368
554
  if (firstUserIdx >= 0 && firstUserIdx !== lastIdx) {
369
555
  kept.push(groups[firstUserIdx], groups[lastIdx]);
370
556
  oldGroups = groups.filter((_, i) => i !== firstUserIdx && i !== lastIdx);
@@ -429,23 +615,58 @@ export async function compactHistory(history, opts) {
429
615
  // 触发压缩时旧区约占窗口 50-60%,摘要请求与摘要器共享窗口,逐条封顶后即装得下;
430
616
  // 不在这里预跑微压缩——预截断会让摘要模型看到 600 字符残片,等于弄瞎它,
431
617
  // 且摘要成功后旧区整体丢弃,预截断本身也无收益。
432
- const older = flattenGroups(oldGroups);
618
+ // 上一代摘要的叙述段并入转录:它压的就是更早的轮次,不并进来等于把那些轮次整段丢弃;
619
+ // 并进来则由本代摘要统一重压(可重建内容,磨掉不心疼)。Key Facts 不并——它已被钉住。
620
+ let older = flattenGroups(oldGroups);
621
+ if (pinned?.rest) {
622
+ older = [
623
+ {
624
+ role: 'system',
625
+ content: `${PRIOR_NARRATIVE_LABEL}\n${pinned.rest}`,
626
+ },
627
+ ...older,
628
+ ];
629
+ }
433
630
  const summarizeFn = opts.summarize ?? defaultSummarize;
434
631
  let summary = null;
435
632
  try {
436
- summary = await summarizeFn(older, opts.focus);
633
+ summary = await summarizeFn(older, opts.focus, opts.signal);
437
634
  }
438
- catch {
439
- summary = null; // 摘要失败 回退微压缩兜底,不崩
635
+ catch (err) {
636
+ // **中断必须冒泡,不能降级成微压缩**:用户按了 Ctrl+C 是要立刻停,不是"换个姿势继续压"。
637
+ // 冒泡后由 runAgentCore 步骤 catch 里的 abort 分支走 abortRestore(还原 history + 模式)。
638
+ // 此处 history 尚未被改动(重建在摘要成功之后),所以直接抛是安全的。
639
+ if (isAbortError(err, opts.signal))
640
+ throw err;
641
+ summary = null; // 真·摘要失败 → 回退微压缩兜底,不崩
440
642
  }
441
643
  if (summary) {
442
644
  const artifactRefs = collectArtifactRefs(older);
443
645
  const provenance = artifactRefs.length > 0
444
646
  ? `\n\n[artifact refs: ${artifactRefs.join(', ')}]`
445
647
  : '';
648
+ // 钉住的 Key Facts 拼在段尾:单条 system 消息里尾部注意力最强,老事实不会被"读漏"。
649
+ // 恒为单条——并排多条 system 摘要会触发近因效应,早期摘要形同虚设。
650
+ const parsed = splitSummaryText(summary);
651
+ const pinnedFacts = mergeKeyFacts(pinned?.keyFacts, parsed.keyFacts, keyFactsBudgetChars(opts.window));
652
+ // P2:把 Objective/In Progress/Next Steps 固结到 notes.md——压缩那一刻
653
+ // notes.md 就有当前进度的权威副本,压缩后恢复提示据此续工,不再只依赖
654
+ // 模型自觉 plan_update。仅主 agent(共享 contextState)写;子 agent 独立
655
+ // contextState 不应污染主会话笔记。无活跃 plan 时写入(有则权威计划仍在)。
656
+ if (state === contextState) {
657
+ const snapshot = extractProgressSnapshot(parsed.rest);
658
+ if (snapshot)
659
+ writeCompactionSnapshot(snapshot);
660
+ }
661
+ const body = [
662
+ parsed.rest,
663
+ pinnedFacts ? `## Key Facts\n${pinnedFacts}` : '',
664
+ ]
665
+ .filter(Boolean)
666
+ .join('\n\n');
446
667
  const summaryMsg = {
447
668
  role: 'system',
448
- content: `# 会话摘要\n${summary}${provenance}`,
669
+ content: `${SUMMARY_MARKER}\n${body}${provenance}`,
449
670
  };
450
671
  // 原地重建:[systemPrompt, summaryMsg, ...kept]
451
672
  const systemMsg = history[0];
@@ -525,7 +746,9 @@ export async function compactHistory(history, opts) {
525
746
  * 强制走 compactHistory(manual/force 参数透传)。返 CompactResult 给 caller 文案展示。
526
747
  * 默认 manual=false 自动路径完全不变。
527
748
  */
528
- export async function maybeCompact(history, report, manualOpts, state = contextState, activeTools = chatTools) {
749
+ export async function maybeCompact(history, report, manualOpts, state = contextState, activeTools = chatTools,
750
+ /** 主 agent 的 abort signal;透传给 compactHistory → 摘要器 → chat()。 */
751
+ signal) {
529
752
  const est = estimatePromptTokens(history, activeTools, state.correction);
530
753
  state.lastEstimate = est;
531
754
  const isManual = manualOpts?.manual === true;
@@ -562,6 +785,7 @@ export async function maybeCompact(history, report, manualOpts, state = contextS
562
785
  force: manualOpts?.force === true || hardCap,
563
786
  tools: activeTools,
564
787
  contextState: state,
788
+ signal,
565
789
  });
566
790
  return r;
567
791
  }
@@ -116,11 +116,17 @@ const NOTE_SECTION_TITLES = {
116
116
  decisions: 'Decisions',
117
117
  open_questions: 'Open Questions',
118
118
  risks: 'Risks',
119
+ // compaction_snapshot 是压缩成功后自动写入的机器快照(非 note_append 手填):
120
+ // 让摘要的 Objective/In Progress/Next Steps 在压缩后仍作为活跃笔记段注入。
121
+ compaction_snapshot: 'Compaction Snapshot',
119
122
  };
120
123
  /** 预设段 key 列表(供工具 schema enum 与校验用)。 */
121
124
  export const NOTE_SECTION_KEYS = Object.keys(NOTE_SECTION_TITLES);
122
125
  /** 段注入优先级:数值越大越先占预算、越后丢弃正文。Risks 最重要。 */
123
126
  const SECTION_PRIORITY = {
127
+ // compaction_snapshot 提到最高:它是压缩当次的权威进度快照,比手填 findings 更能
128
+ // 直接告诉模型「做到哪了」,压缩后第一步最该看到的就是它。
129
+ compaction_snapshot: 5,
124
130
  risks: 4, findings: 3, decisions: 2, open_questions: 1,
125
131
  };
126
132
  /** 常驻笔记正文总预算(token)。5k:占百万级 context 的 0.5%,可常驻相当量笔记。 */
@@ -338,3 +344,60 @@ export function extractActiveNotesSections(budget = NOTES_INJECT_BUDGET_TOKENS,
338
344
  }
339
345
  return out.join('\n\n');
340
346
  }
347
+ // ── Compaction Snapshot(压缩时自动固结的进度快照)──────────────────────────
348
+ // 由 compactHistory 在摘要成功后写入;替代「模型需自觉 plan_update」的软约定——
349
+ // 压缩那一刻 notes.md 里一定有当前进度的权威副本,压缩后恢复提示据此续工。
350
+ // 与 plan/notes 不同:这是机器产出、整段替换(不逐条累积),因此不会跨压缩膨胀。
351
+ /** 快照段标题。extractActiveNotesSections 经 matchSectionKey 识别并注入。 */
352
+ export const COMPACTION_SNAPSHOT_TITLE = NOTE_SECTION_TITLES.compaction_snapshot;
353
+ /**
354
+ * 把压缩摘要的关键段固结到 notes.md 的 `## Compaction Snapshot` 段。
355
+ * 整段替换旧快照(不累积);仅在当前无活跃 plan 时写入(已有 plan 时权威计划仍在,
356
+ * 快照只会重复)。body 为空时不动。永不抛错:压缩主流程不能因快照失败而失败。
357
+ */
358
+ export function writeCompactionSnapshot(body, sessionId = getCurrentSessionId()) {
359
+ try {
360
+ const trimmed = (body ?? '').trim();
361
+ if (!trimmed)
362
+ return;
363
+ if (readActivePlanTitle(sessionId))
364
+ return; // 已有权威计划,快照是冗余
365
+ const p = getNotesFilePath(sessionId);
366
+ if (!p)
367
+ return;
368
+ let existing = '';
369
+ try {
370
+ existing = fs.readFileSync(p, 'utf8').replace(/\r\n?/g, '\n');
371
+ }
372
+ catch {
373
+ existing = '';
374
+ }
375
+ const header = `## ${COMPACTION_SNAPSHOT_TITLE}`;
376
+ const newSection = `${header}\n${trimmed}`;
377
+ const lines = existing.split('\n');
378
+ const start = lines.findIndex((l) => l.trim() === header);
379
+ let next;
380
+ if (start >= 0) {
381
+ // 段末 = 下一个 ## 或文件末;整段替换
382
+ let end = lines.length;
383
+ for (let k = start + 1; k < lines.length; k++) {
384
+ if (/^##\s/.test(lines[k])) {
385
+ end = k;
386
+ break;
387
+ }
388
+ }
389
+ const before = lines.slice(0, start).join('\n').replace(/\s+$/, '');
390
+ const after = lines.slice(end).join('\n').replace(/^\s+/, '');
391
+ next = [before, newSection, after].filter((s) => s.length > 0).join('\n\n') + '\n';
392
+ }
393
+ else {
394
+ const rest = existing.trim();
395
+ next = rest ? `${rest}\n\n${newSection}\n` : `${newSection}\n`;
396
+ }
397
+ fs.mkdirSync(path.dirname(p), { recursive: true });
398
+ fs.writeFileSync(p, next, 'utf8');
399
+ }
400
+ catch {
401
+ // 快照是 best-effort 增强,绝不影响压缩主流程。
402
+ }
403
+ }
@@ -4,8 +4,6 @@ import { evaluateBudget, scheduleActions, formatReport, DEFAULT_BUDGET_POLICY, }
4
4
  import { chatTools } from '../llm/index.js';
5
5
  import { config } from '../config/index.js';
6
6
  import { maybeCompact, contextState } from './compact.js';
7
- import * as layout from '../ui/layout.js';
8
- import { ui } from '../ui/theme.js';
9
7
  import { pruneStaleArtifacts, refreshArtifactFreshness } from '../context/artifacts.js';
10
8
  import { pruneSuperseded } from '../context/relevance.js';
11
9
  import { createAgeAwareEncodingState } from '../context/age-aware.js';
@@ -28,7 +26,7 @@ export function createBudgetScheduler(state = contextState) {
28
26
  const evaluate = (history, step, activeTools, ephemeralTokens) => evaluateBudget(history, config.contextWindowTokens, step, state.correction, activeTools, ephemeralTokens);
29
27
  const scheduler = {
30
28
  lastRunLog: null,
31
- async runStep(history, step, activeTools = chatTools, ephemeralTokens = 0) {
29
+ async runStep(history, step, activeTools = chatTools, ephemeralTokens = 0, signal) {
32
30
  // External file changes and mutations only update artifact metadata here.
33
31
  refreshArtifactFreshness(state, history);
34
32
  const report = evaluate(history, step, activeTools, ephemeralTokens);
@@ -54,11 +52,7 @@ export function createBudgetScheduler(state = contextState) {
54
52
  let compactHistoryCalled = false;
55
53
  let historyRebuilt = false;
56
54
  for (const action of actions) {
57
- if (action.kind === 'warn') {
58
- layout.contentWrite(` ${ui.accent}●${ui.reset} ${ui.yellow}调度器警告 [${action.layer}] ${action.reason}${ui.reset}\n`);
59
- continue;
60
- }
61
- const result = await maybeCompact(history, report, undefined, state, activeTools);
55
+ const result = await maybeCompact(history, report, undefined, state, activeTools, signal);
62
56
  compactHistoryCalled = true;
63
57
  historyRebuilt ||= result?.historyRebuilt === true;
64
58
  }
@@ -82,6 +76,7 @@ export async function runScheduler(history, step, state = contextState, activeTo
82
76
  }
83
77
  /** User-requested compaction bypasses automatic pressure gating. */
84
78
  export async function manualCompact(history, focus, opts) {
79
+ const signal = opts?.signal;
85
80
  if (config.contextBudget === false) {
86
81
  const result = await import('./compact.js').then(({ compactHistory }) => compactHistory(history, {
87
82
  window: config.contextWindowTokens,
@@ -89,6 +84,7 @@ export async function manualCompact(history, focus, opts) {
89
84
  focus,
90
85
  manual: true,
91
86
  force: opts?.force,
87
+ signal,
92
88
  }));
93
89
  const report = evaluateBudget(history, config.contextWindowTokens, -1, contextState.correction);
94
90
  const log = {
@@ -127,7 +123,7 @@ export async function manualCompact(history, focus, opts) {
127
123
  manual: true,
128
124
  force: opts?.force,
129
125
  focus: action.focus,
130
- });
126
+ }, contextState, chatTools, signal);
131
127
  compactHistoryCalled = true;
132
128
  if (result) {
133
129
  compactDetail = {
@@ -24,6 +24,13 @@ export const SUMMARY_MSG_MAX_CHARS = {
24
24
  export const SUMMARY_TRANSCRIPT_WINDOW_RATIO = 0.55;
25
25
  /** 摘要输出硬上限(字符):模型不听话产出超长摘要时按段落边界裁,防摘要本身撑大 history。 */
26
26
  export const SUMMARY_OUTPUT_MAX_CHARS = 6000;
27
+ // ── 跨代钉住的 Key Facts 预算(见 compact.ts mergeKeyFacts)────────────────
28
+ /** 占窗口比例。按「字符≈token」的保守换算(中文 1 字≈1 token),取 summary 层的一半。 */
29
+ export const SUMMARY_KEYFACTS_WINDOW_RATIO = 0.05;
30
+ /** 小窗口下的绝对下限:首轮约束通常就几条,32k 窗口按比例只有 1.6k 也得保住。 */
31
+ export const SUMMARY_KEYFACTS_MIN_CHARS = 1200;
32
+ /** 大窗口下的绝对上限:Key Facts 是索引不是正文,不该无限膨胀。 */
33
+ export const SUMMARY_KEYFACTS_MAX_CHARS = 8000;
27
34
  // ── 记忆(Tier-2 JSONL 工具库)──────────────────────────────────────────────
28
35
  /** 单条记忆 body 上限(字符)。进 history 前 memory_search 结果另有 MAX_MEMORY_RESULT 兜底。 */
29
36
  export const MAX_MEMORY_ENTRY = 4000;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mocode-ai",
3
- "version": "1.2.8",
3
+ "version": "1.2.9",
4
4
  "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 16 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1 +0,0 @@
1
- export const TRACE_SECRET_MARKER_20260719 = 1;
@@ -1,112 +0,0 @@
1
- import type OpenAI from 'openai';
2
- import { type ChatMessage, type ChatUsage, type ToolCallRef } from '../llm/index.js';
3
- /** 解析工具 arguments JSON;非法或空返 null(调用方据此降级到普通 preview)。 */
4
- declare function parseArgs(raw: string): Record<string, unknown> | null;
5
- /** 只读工具集:一轮多个时,连续的只读工具成组 Promise.all 并行(无副作用、互不依赖)。 */
6
- declare const READ_TOOL_NAMES: Set<string>;
7
- /** mutation 工具:写盘 + 在 executeTool 内记回滚 before 快照,必须串行保快照序。 */
8
- declare const isMutationTool: (name: string) => boolean;
9
- /** 工具调用 ● 头所需信息(交给 hooks 渲染;core 不直接写屏)。 */
10
- export interface ToolCallView {
11
- name: string;
12
- arguments: string;
13
- id: string;
14
- }
15
- /** mutation 执行前读旧内容供 diff:write_file 取整文件旧内容(不存在→null=新建),
16
- * edit_file 取 old_string 起始行号(供 diff 显示真实文件行号)。读不到则 diff 退化为相对行号。
17
- * 非 mutation 或参数非法返 { preWriteOld: null, editStartLine: 1 }。失败不阻断。 */
18
- declare function readDiffContext(tc: ToolCallRef, parsed: Record<string, unknown> | null): {
19
- preWriteOld: string | null;
20
- editStartLine: number;
21
- };
22
- /**
23
- * agent 循环的展示副作用接缝。主 agent 注入 TUI 渲染实现;子 agent 注入静默/摘要实现。
24
- * 所有方法可选——core 对 undefined hooks 安全跳过。
25
- */
26
- export interface AgentHooks {
27
- /** 流式正文增量(主 agent:走 markdown 渲染写内容区)。 */
28
- onText?: (delta: string) => void;
29
- /** 模型开始生成某 tool_call 的参数(主 agent:补换行 + 启「生成中」spinner)。 */
30
- onToolCall?: (name: string) => void;
31
- /** 每步开始,spinner 启「思考中」(主 agent:spinner.start)。 */
32
- onStepStart?: () => void;
33
- /** chat 返回后停 spinner(主 agent:spinner.stop)。 */
34
- onChatDone?: () => void;
35
- /** 流式正文末尾补换行(若 onToolCall 已补则 no-op);防 ● 行黏在正文行尾。 */
36
- onTextEnd?: () => void;
37
- /** 工具调用 ● 头渲染(主 agent:工具名 + 参数摘要)。 */
38
- onToolHeader?: (tc: ToolCallRef) => void;
39
- /** 启「执行 工具」spinner(主 agent:spinner.start)。 */
40
- onToolStart?: (name: string) => void;
41
- /** 工具执行完停 spinner(主 agent:spinner.stop)。 */
42
- onToolDone?: () => void;
43
- /** 工具结果渲染(主 agent:mutation 走 diff 块;其余走一行 preview)。 */
44
- onToolResult?: (tc: ToolCallRef, output: string, parsed: Record<string, unknown> | null, preWriteOld: string | null, editStartLine: number) => void;
45
- /** 工具步末尾补一空行(与下一轮思考/正文分隔)。 */
46
- onToolBatchEnd?: () => void;
47
- /** 无回复提示(模型既无文本也无工具调用)。 */
48
- onNoReply?: () => void;
49
- /** 达到最大步数提示。 */
50
- onMaxSteps?: () => void;
51
- /** 中断还原:停 spinner + 补换行 + (已中断)提示 + history 还原 + 模式还原。 */
52
- onAbort?: () => void;
53
- /** 跑完(正常/达上限)在回复末尾打耗时摘要行;中断不调。
54
- * usage 是本轮 chat 调用累计的 token 用量(未开启 include_usage 或全失败时为 undefined)。 */
55
- onDone?: (elapsedMs: number, usage?: ChatUsage) => void;
56
- }
57
- /** runAgentCore 的运行选项。 */
58
- export interface AgentRunOptions {
59
- history: ChatMessage[];
60
- /** 纯文本字符串,或多模态 parts 数组(OpenAI content 数组,text + image_url)。 */
61
- userInput: string | ContentPart[];
62
- signal?: AbortSignal;
63
- /** 每步 chat() 返回后回调:repl 据此重算并重画状态行 context 用量条。 */
64
- onContextUpdate?: () => void;
65
- hooks: AgentHooks;
66
- /** 步数上限;缺省 = config.maxSteps。子 agent 可传更低值。 */
67
- maxSteps?: number;
68
- /** 工具 schema 覆盖;plan 模式传 planChatTools(只读子集)。子 agent 可传受限子集。
69
- * 缺省 = 按 getAgentMode() 自动选(auto=全量 / plan=只读)。 */
70
- toolsOverride?: OpenAI.Chat.Completions.ChatCompletionTool[];
71
- /** 跳过回滚快照记录(子 agent 逻辑隔离用)。true = 本 agent 的 write_file/edit_file 改动
72
- * 不进主回滚链,主 /rollback 不撤销。透传给 executeTool。 */
73
- skipRollback?: boolean;
74
- }
75
- /** OpenAI content array 的子集(text + image_url);repl 构造 user 多模态消息用。 */
76
- export type ContentPart = {
77
- type: 'text';
78
- text: string;
79
- } | {
80
- type: 'image_url';
81
- image_url: {
82
- url: string;
83
- detail?: 'auto' | 'low' | 'high';
84
- };
85
- };
86
- /** runAgentCore 的运行结果。 */
87
- export interface AgentRunResult {
88
- /** 正常完毕 / 达上限 true;中断 false。 */
89
- completed: boolean;
90
- /** 最终 assistant 文本回复(content);无回复或中断为 null。 */
91
- finalText: string | null;
92
- /** 本轮累计 token 用量(各 chat 步 prompt+completion 之和);后端不开 include_usage 或全失败则 undefined。 */
93
- usage?: ChatUsage;
94
- }
95
- /**
96
- * agent 核心循环(纯逻辑):
97
- * 流式调 LLM(经 hooks.onText 实时渲染)→ 有 tool_calls 就分组执行并回灌
98
- * → 否则流式正文即最终回复。history 在调用间持久,由调用方持有。
99
- * 步前经 session/maybeCompact 自动压缩(接近窗口上限时三层压缩);
100
- * 工具结果进 history 前经 Context Optimization Pipeline(optimizeToolResult:类型化编码 + 长度裁剪)。
101
- *
102
- * 中断语义:signal 经 executeTool(name, args, signal) 串进工具;run_command/web_fetch 等 abort 即时杀
103
- * (树杀子进程 / 取消 fetch),循环顶 if(signal.aborted) 兜底还原。不会留下未配对的 tool_call_id。
104
- * abort 时 history 还原到本 turn 前(savedHistory 浅拷贝),模式还原,调 hooks.onAbort。
105
- *
106
- * 所有展示副作用经 hooks 注入;core 自身不直接调 layout / spinner(不依赖 ui/layout.ts)。
107
- * 但 core 仍依赖 ui/render.ts 的纯函数(summarizeToolCall / truncateDisplay / fmtElapsed)——
108
- * 这些是纯字符串格式化,无副作用,共享安全。
109
- */
110
- export declare function runAgentCore(opts: AgentRunOptions): Promise<AgentRunResult>;
111
- export { parseArgs, readDiffContext, isMutationTool, READ_TOOL_NAMES };
112
- export type { ChatMessage, ToolCallRef, ChatUsage } from '../llm/index.js';
@@ -1,19 +0,0 @@
1
- import type { ChatMessage } from '../llm/index.js';
2
- import { type AgentRunResult, type ContentPart } from './core.js';
3
- /**
4
- * agent 核心循环(主 agent,TUI 渲染版):
5
- * 流式调 LLM(onText 实时写内容区)→ 有 tool_calls 就执行并回灌
6
- * → 否则流式正文即最终回复。history 在调用间持久,由 REPL 持有。
7
- * 步前经 session/maybeCompact 自动压缩(接近窗口上限时三层压缩);
8
- * 工具结果进 history 前经 capToolResultForHistory 裁到单条上限。
9
- *
10
- * 所有正文写经 layout.contentWrite(保证落在内容区、跟踪续写位、底栏不被顶);
11
- * spinner 经 onFrame 回调刷状态行(layout.drawStatusBar),等待时内容区静止、底栏转圈。
12
- * 思考期间不写思考内容,只让 spinner 持续转「思考中…」(首个正文 / tool_call token 到达才停)。
13
- *
14
- * 循环逻辑委托 runAgentCore;本函数只注入 TUI 渲染 hooks(layout + spinner + diff + 回滚轮次)。
15
- * 开新轮次(beginTurn)在入口调——core 不依赖 rollback,回滚轮次是主 agent 的展示副作用。
16
- */
17
- export declare function runAgent(history: ChatMessage[], userInput: string | ContentPart[], signal?: AbortSignal,
18
- /** 每步 chat() 返回后回调:repl 据此重算并重画状态行 context 用量条(运行中实时刷新,不冻结在轮首)。 */
19
- onContextUpdate?: () => void): Promise<AgentRunResult>;