mocode-ai 1.2.7 → 1.2.8

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.
@@ -24,7 +24,11 @@ export const DEFAULT_BUDGET_POLICY = {
24
24
  reserve: 0.05,
25
25
  },
26
26
  hotTurnWindow: 4,
27
- compactKeepRatio: 0.40,
27
+ // 压缩保留区:自动 15% / 强压 5%,且绝对上限 48k——摘要+最近几轮足够续工,
28
+ // 旧 0.40 在大窗口下导致"160k 压到 100k"的无效压缩。
29
+ compactKeepRatio: 0.15,
30
+ compactForceKeepRatio: 0.05,
31
+ compactKeepMaxTokens: 48000,
28
32
  pressureTriggerRatio: 0.80,
29
33
  schedulerTargetRatio: 0.80,
30
34
  estimateSafetyFactor: 1.05,
package/dist/llm/index.js CHANGED
@@ -356,11 +356,16 @@ async function chatOnce(messages, handlers, signal, toolsOverride) {
356
356
  const stream = await create({
357
357
  model: getActiveModel(),
358
358
  messages: normalizeImageDetail(messages),
359
- tools: activeTools,
360
359
  stream: true,
361
- // 显式声明允许一次响应携带多个 tool_call(OpenAI 兼容协议标准字段)。
362
- // 不设置时依赖各家后端的默认值,某些第三方网关/模型在缺省时会退化为串行单步调用。
363
- ...(activeTools.length > 0 ? { parallel_tool_calls: true } : {}),
360
+ // 空工具表(如摘要请求)不发 tools 字段——部分网关拒绝空数组。
361
+ ...(activeTools.length > 0
362
+ ? {
363
+ tools: activeTools,
364
+ // 显式声明允许一次响应携带多个 tool_call(OpenAI 兼容协议标准字段)。
365
+ // 不设置时依赖各家后端的默认值,某些第三方网关/模型在缺省时会退化为串行单步调用。
366
+ parallel_tool_calls: true,
367
+ }
368
+ : {}),
364
369
  ...(config.maxTokens ? { max_tokens: config.maxTokens } : {}),
365
370
  ...(config.includeUsage ? { stream_options: { include_usage: true } } : {}),
366
371
  }, signal ? { signal } : undefined);
@@ -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 } from '../tools/constants.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';
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';
@@ -221,45 +221,105 @@ function microcompactGroup(g) {
221
221
  return done;
222
222
  }
223
223
  // ── 默认摘要器:复用 chat(),空 handlers 不打印 ──────────────────────────
224
- async function defaultSummarize(older, focus) {
225
- // 摘要前剥离多模态 user 消息里的图片(base64 会撑爆摘要 prompt;image 对摘要无信息量)。
226
- const stripped = older.map(stripImagesForSummary);
227
- let transcript = stripped
224
+ /** 单条消息封顶:中间段省略、保头 + 尾(头有路径/意图,尾有结论/报错),总长 ≤ max。 */
225
+ function capMessageText(text, max) {
226
+ if (text.length <= max)
227
+ return text;
228
+ const removed = text.length - max;
229
+ const marker = `\n…[中间 ${removed} 字符已省略]…\n`;
230
+ let remain = max - marker.length;
231
+ if (remain <= 0)
232
+ return text.slice(0, Math.max(0, max)) + marker;
233
+ const head = Math.ceil(remain * 0.6);
234
+ return text.slice(0, head) + marker + text.slice(text.length - (remain - head));
235
+ }
236
+ /** tool_call 参数封顶:只保头(路径/目标等关键信息在开头,长正文对摘要无信息量)。 */
237
+ function capArgsHead(args, max) {
238
+ if (args.length <= max)
239
+ return args;
240
+ return args.slice(0, max) + ` …[另有 ${args.length - max} 字符已省略]`;
241
+ }
242
+ /** 摘要输出硬上限:按最后换行处裁断(不留半句),防模型不听话产出巨长摘要撑大 history。 */
243
+ function capSummaryOutput(summary) {
244
+ if (summary.length <= SUMMARY_OUTPUT_MAX_CHARS)
245
+ return summary;
246
+ const cut = summary.lastIndexOf('\n', SUMMARY_OUTPUT_MAX_CHARS);
247
+ const at = cut > SUMMARY_OUTPUT_MAX_CHARS * 0.5 ? cut : SUMMARY_OUTPUT_MAX_CHARS;
248
+ return summary.slice(0, at) + '\n[摘要输出超长,已截断]';
249
+ }
250
+ /** 按封顶配额拼转录。scale ∈ (0,1] 等比缩小各角色配额(总预算不足时用)。 */
251
+ function buildTranscript(stripped, scale) {
252
+ const caps = SUMMARY_MSG_MAX_CHARS;
253
+ const capFor = (role) => Math.max(200, Math.floor(scale *
254
+ (role === 'user'
255
+ ? caps.user
256
+ : role === 'assistant'
257
+ ? caps.assistant
258
+ : role === 'tool'
259
+ ? caps.tool
260
+ : caps.other)));
261
+ return stripped
228
262
  .map((m) => {
229
- const role = m.role;
230
- let line = `${role}: ${toText(m.content)}`;
263
+ const cap = capFor(m.role);
264
+ let line = `${m.role}: ${capMessageText(toText(m.content), cap)}`;
231
265
  const tcs = m.tool_calls;
232
266
  if (tcs) {
233
267
  for (const tc of tcs) {
234
- line += `\n [tool_call ${tc?.function?.name}] ${tc?.function?.arguments ?? ''}`;
268
+ const args = tc?.function?.arguments ?? '';
269
+ line += `\n [tool_call ${tc?.function?.name}] ${capArgsHead(args, Math.max(200, Math.floor(scale * caps.tool)))}`;
235
270
  }
236
271
  }
237
272
  return line;
238
273
  })
239
274
  .join('\n');
240
- // 防摘要提示本身溢出:超 60% 窗口就先中截到 50%
241
- if (estimateTokens(transcript) >
242
- Math.floor(config.contextWindowTokens * 0.6)) {
243
- transcript = truncateMid(transcript, Math.floor(config.contextWindowTokens * 0.5));
275
+ }
276
+ async function defaultSummarize(older, focus) {
277
+ // 摘要前剥离多模态 user 消息里的图片(base64 会撑爆摘要 prompt;image 对摘要无信息量)
278
+ const stripped = older.map(stripImagesForSummary);
279
+ // 触发压缩时旧区约占窗口 50-60%,摘要器共享同一窗口——逐条封顶后旧区原文即可装入,
280
+ // 不需要"先压一遍再摘要"。封顶策略:先按满额封顶;总量仍超窗口 55% 预算时等比缩小
281
+ // 配额重拼(优先保条数/每轮都有代表,其次保单条长度);再超才整段中截兜底(罕见)。
282
+ let transcript = buildTranscript(stripped, 1);
283
+ const tokenBudget = Math.floor(config.contextWindowTokens * SUMMARY_TRANSCRIPT_WINDOW_RATIO);
284
+ let tokens = estimateTokens(transcript);
285
+ if (tokens > tokenBudget) {
286
+ transcript = buildTranscript(stripped, tokenBudget / tokens);
287
+ tokens = estimateTokens(transcript);
288
+ if (tokens > Math.floor(config.contextWindowTokens * 0.6)) {
289
+ transcript = truncateMid(transcript, Math.floor(config.contextWindowTokens * 0.5));
290
+ }
244
291
  }
245
292
  const sysMsg = {
246
293
  role: 'system',
247
- content: 'You are a session summarizer. Output only the summary body, max 300 words, preserving: the user\'s core request; files read/written/modified and key changes; source artifact IDs/hashes; key commands run and their result highlights; decisions made; current task progress and next step; open questions. Do not recap every detail.',
294
+ content: 'You are an aggressive session compressor writing a handoff note for an agent that lost its context. ' +
295
+ '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' +
296
+ 'Output ONLY the summary body in this exact structure (omit empty sections):\n' +
297
+ '## Objective — the user\'s core request(s); cover EVERY distinct user request in the transcript, in order, noting which are completed vs pending.\n' +
298
+ '## 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' +
299
+ '## 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' +
300
+ '## Next Steps — the concrete next actions in order.\n' +
301
+ '## Key Facts — only what later steps cannot work without: exact paths, symbols/API shapes, artifact IDs/hashes, constraints, open questions, failed approaches and errors to avoid repeating.\n' +
302
+ '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
+ '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
+ 'Rules: total ≤ 400 words. State conclusions and locations (path:line where useful), never paste content. ' +
305
+ 'Never invent facts, paths, or results that are not in the transcript; if unsure whether something happened, omit it.',
248
306
  };
249
307
  const userMsg = {
250
308
  role: 'user',
251
309
  content: focus
252
- ? `请将以下会话历史压缩成摘要,重点保留与「${focus}」相关的事实/决策/文件改动:\n\n${transcript}\n\n摘要:`
253
- : `请将以下会话历史压缩成摘要:\n\n${transcript}\n\n摘要:`,
310
+ ? `请将以下会话历史压缩成交接摘要,重点保留与「${focus}」相关的事实/决策/文件改动:\n\n${transcript}\n\n摘要:`
311
+ : `请将以下会话历史压缩成交接摘要:\n\n${transcript}\n\n摘要:`,
254
312
  };
255
313
  const spinner = new Spinner((msg, frame) => layout.setStatus(msg, frame ?? undefined));
256
314
  spinner.start('压缩中');
257
315
  try {
258
- const r = await chat([sysMsg, userMsg], {}); // 空 handlers:不打印、不外显流式
316
+ // handlers:不打印、不外显流式;tools=[] 不带工具表——摘要纯文本任务,
317
+ // 全量工具 schema 白占几千 token 窗口,还诱导幻觉工具调用。
318
+ const r = await chat([sysMsg, userMsg], {}, undefined, []);
259
319
  // 推理模型可能只返 reasoning_content(content 为 null),或幻觉出 tool_calls → 视为失败
260
320
  if (r.toolCalls.length > 0 || !r.content)
261
321
  return null;
262
- return r.content;
322
+ return capSummaryOutput(r.content);
263
323
  }
264
324
  finally {
265
325
  spinner.stop();
@@ -277,7 +337,12 @@ export async function compactHistory(history, opts) {
277
337
  state.lastEstimate = estimateBefore;
278
338
  const groups = groupFromEnd(history);
279
339
  // 保近期:按策略中的 token 比例累积(至少保 1 组),永不劈开 group。
280
- const keepBudget = Math.floor(opts.window * DEFAULT_BUDGET_POLICY.compactKeepRatio);
340
+ // force(手动 /compact 默认、硬闸触发)用更激进的保留比例;再加绝对上限,
341
+ // 防大窗口(如 256k)下保留区按比例仍过大——压缩目标 = 摘要 + 最小续工上下文。
342
+ const keepRatio = opts.force
343
+ ? DEFAULT_BUDGET_POLICY.compactForceKeepRatio
344
+ : DEFAULT_BUDGET_POLICY.compactKeepRatio;
345
+ const keepBudget = Math.min(Math.floor(opts.window * keepRatio), DEFAULT_BUDGET_POLICY.compactKeepMaxTokens);
281
346
  const kept = [];
282
347
  let keptTokens = 0;
283
348
  for (let k = groups.length - 1; k >= 0; k--) {
@@ -360,18 +425,10 @@ export async function compactHistory(history, opts) {
360
425
  }
361
426
  return { ...noop, reason: 'noop-protected', protectedRatio };
362
427
  }
363
- // 第一层:微压缩——旧区原地截短(保 tool_call_id,无 LLM 调用)
364
- // 覆盖三类大字段,均只裁模型/工具产物,不动 user 原话与 system(摘要):
365
- // tool 结果 content;
366
- // 旧 assistant 的 tool_calls.arguments —— provenance stub:整体超长才进,大字段
367
- // 替换为 "<N 字符,已省略>"(保 path + JSON 合法,见 stubToolCallArguments);
368
- // ③ 旧 assistant 正文 content(模型长解释,回看价值低)。
369
- let microcompactDone = false;
370
- for (const g of oldGroups) {
371
- if (microcompactGroup(g))
372
- microcompactDone = true;
373
- }
374
- // 第二层:摘要——把旧区(微压缩后)压成一条 system 摘要
428
+ // 主层:摘要——把旧区【原始内容】压成一条 system 摘要。
429
+ // 触发压缩时旧区约占窗口 50-60%,摘要请求与摘要器共享窗口,逐条封顶后即装得下;
430
+ // 不在这里预跑微压缩——预截断会让摘要模型看到 600 字符残片,等于弄瞎它,
431
+ // 且摘要成功后旧区整体丢弃,预截断本身也无收益。
375
432
  const older = flattenGroups(oldGroups);
376
433
  const summarizeFn = opts.summarize ?? defaultSummarize;
377
434
  let summary = null;
@@ -379,7 +436,7 @@ export async function compactHistory(history, opts) {
379
436
  summary = await summarizeFn(older, opts.focus);
380
437
  }
381
438
  catch {
382
- summary = null; // 摘要失败 → 回退仅微压缩,不崩
439
+ summary = null; // 摘要失败 → 回退微压缩兜底,不崩
383
440
  }
384
441
  if (summary) {
385
442
  const artifactRefs = collectArtifactRefs(older);
@@ -417,7 +474,15 @@ export async function compactHistory(history, opts) {
417
474
  reason: 'summarize',
418
475
  };
419
476
  }
420
- // 摘要失败:回退仅微压缩(tool content 已原地改),结构不动
477
+ // 摘要失败兜底:微压缩——旧区原地截短( tool_call_id,无 LLM 调用),结构不动。
478
+ // 覆盖三类大字段,均只裁模型/工具产物,不动 user 原话与 system:
479
+ // ① tool 结果 content;② 旧 assistant 的 tool_calls.arguments(provenance stub,
480
+ // 整体超长才进,大字段替换为 "<N 字符,已省略>",保 path + JSON 合法);③ 旧 assistant 正文。
481
+ let microcompactDone = false;
482
+ for (const g of oldGroups) {
483
+ if (microcompactGroup(g))
484
+ microcompactDone = true;
485
+ }
421
486
  const estimateAfter = estimatePromptTokens(history, activeTools, state.correction);
422
487
  state.lastEstimate = estimateAfter;
423
488
  state.lastUsage = undefined; // token 数已变,旧 usage 失效
@@ -10,6 +10,20 @@ export const MAX_HISTORY_RESULT = 8000;
10
10
  export const MAX_SKILL_RESULT = 64000;
11
11
  /** 微压缩时旧工具结果截到的存根长度(字符)。 */
12
12
  export const MAX_OLD_TOOL_STUB = 600;
13
+ // ── 上下文压缩摘要(见 session/compact.ts defaultSummarize)────────────────
14
+ /** 摘要输入单条消息封顶:逐条封顶保证每轮对话都有代表,替代整段中截(会切掉中间整轮)。
15
+ * 转录总量超 SUMMARY_TRANSCRIPT_WINDOW_RATIO 时,这些封顶会被等比缩小重拼。 */
16
+ export const SUMMARY_MSG_MAX_CHARS = {
17
+ user: 3000, // 用户原话价值最高,给大额度
18
+ assistant: 1600,
19
+ tool: 800,
20
+ other: 2000,
21
+ };
22
+ /** 摘要转录总预算占上下文窗口的比例:超预算时等比缩小单条封顶重拼——
23
+ * 优先保消息条数(每轮都有代表),其次才保单条长度。 */
24
+ export const SUMMARY_TRANSCRIPT_WINDOW_RATIO = 0.55;
25
+ /** 摘要输出硬上限(字符):模型不听话产出超长摘要时按段落边界裁,防摘要本身撑大 history。 */
26
+ export const SUMMARY_OUTPUT_MAX_CHARS = 6000;
13
27
  // ── 记忆(Tier-2 JSONL 工具库)──────────────────────────────────────────────
14
28
  /** 单条记忆 body 上限(字符)。进 history 前 memory_search 结果另有 MAX_MEMORY_RESULT 兜底。 */
15
29
  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.7",
3
+ "version": "1.2.8",
4
4
  "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 16 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {