mocode-ai 0.5.4 → 0.5.5

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.
@@ -0,0 +1,68 @@
1
+ // 跨上下文模块共享的小工具函数。
2
+ //
3
+ // 目的:消除 `relevance.ts` / `lifecycle.ts` / `budget.ts` / `compact.ts` / `session/drop.ts`
4
+ // 之间的 copy-paste。这些函数逻辑完全一致,统一维护于此,避免行为漂移。
5
+ //
6
+ // 不变量:
7
+ // - 永不抛错(对齐「调度器永不抛错」契约)。
8
+ // - 仅依赖 `ChatMessage` 的最小形状,不反向 import agent / session / tools。
9
+ /** 把消息 content 拍平成字符串(OpenAI 可能 string / null / 多模态数组)。
10
+ * 用于估算 token、内容匹配、stub 拼接等场景。 */
11
+ export function toText(content) {
12
+ if (content == null)
13
+ return '';
14
+ if (typeof content === 'string')
15
+ return content;
16
+ try {
17
+ return JSON.stringify(content);
18
+ }
19
+ catch {
20
+ return String(content);
21
+ }
22
+ }
23
+ /** 从 history 末尾向前找最后一个 user 消息的索引;无 user 返 -1。
24
+ * 用于划定「当前轮保护区」——该 user 及其之后的消息(agent 本轮还在用)不动。
25
+ * 注:history[0] 是 system,故下界为 1。 */
26
+ export function lastUserIndex(history) {
27
+ for (let i = history.length - 1; i >= 1; i--) {
28
+ if (history[i].role === 'user')
29
+ return i;
30
+ }
31
+ return -1;
32
+ }
33
+ /** 取 tool 消息对应的工具名(从紧邻的前导 assistant.tool_calls 按 tool_call_id 配对找)。
34
+ * 返回 null 表示找不到(孤儿 tool 消息,极少见),调用方保守跳过。 */
35
+ export function toolNameOf(history, idx) {
36
+ const tcId = history[idx].tool_call_id;
37
+ if (!tcId)
38
+ return null;
39
+ for (let j = idx - 1; j >= 1; j--) {
40
+ const m = history[j];
41
+ if (m.role !== 'assistant')
42
+ continue;
43
+ const tcs = m.tool_calls;
44
+ if (!tcs)
45
+ continue;
46
+ const hit = tcs.find((tc) => tc?.id === tcId);
47
+ if (hit)
48
+ return hit.function?.name ?? null;
49
+ }
50
+ return null;
51
+ }
52
+ /** 解析工具 arguments JSON,只取 `path` 字段;非法 / 无 path 返 null。
53
+ * 用于 read_file / edit_file / write_file 等以 path 为关键字的工具,供跨消息关联使用。 */
54
+ export function extractPath(argsRaw) {
55
+ if (!argsRaw)
56
+ return null;
57
+ let parsed;
58
+ try {
59
+ parsed = JSON.parse(argsRaw);
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ if (!parsed || typeof parsed !== 'object')
65
+ return null;
66
+ const p = parsed.path;
67
+ return typeof p === 'string' && p ? p : null;
68
+ }
@@ -455,14 +455,15 @@ function textOf(c) {
455
455
  * 回放默认全折叠;用户可鼠标点击摘要行展开(由 BatchRenderer 接管,见 ui/batch.ts)。
456
456
  */
457
457
  export function renderHistory(history) {
458
- const idToName = new Map();
459
- // 当前累积的 batch(assistant.tool_calls + 后续 tool 消息);一旦遇到非 tool 消息即收尾出摘要
460
- let pendingBatch = [];
458
+ const idToEntry = new Map();
459
+ // 普通工具可跨 assistant 步聚合;mutation 各自占一个 group,并切断前后普通工具。
460
+ let pendingBatches = [];
461
+ let normalBatch = null;
461
462
  const flushBatch = () => {
462
- if (pendingBatch.length === 0)
463
- return;
464
- batch.writeSummaryOnly(pendingBatch, layout);
465
- pendingBatch = [];
463
+ for (const entries of pendingBatches)
464
+ batch.writeSummaryOnly(entries, layout);
465
+ pendingBatches = [];
466
+ normalBatch = null;
466
467
  };
467
468
  for (let idx = 0; idx < history.length; idx++) {
468
469
  const m = history[idx];
@@ -505,14 +506,25 @@ export function renderHistory(history) {
505
506
  for (const tc of tcs) {
506
507
  const name = tc?.function?.name ?? '';
507
508
  const args = tc?.function?.arguments ?? '';
508
- if (tc?.id && name)
509
- idToName.set(tc.id, name);
510
- pendingBatch.push({
509
+ const entry = {
511
510
  name,
512
511
  callSummary: summarizeToolCall(name, args),
513
512
  resultSummary: '',
514
513
  diffBlock: null,
515
- });
514
+ };
515
+ if (batch.isMutationToolName(name)) {
516
+ normalBatch = null;
517
+ pendingBatches.push([entry]);
518
+ }
519
+ else {
520
+ if (!normalBatch) {
521
+ normalBatch = [];
522
+ pendingBatches.push(normalBatch);
523
+ }
524
+ normalBatch.push(entry);
525
+ }
526
+ if (tc?.id)
527
+ idToEntry.set(tc.id, entry);
516
528
  }
517
529
  continue; // 跳过后续 tool 消息处理循环(由下一分支填 result)
518
530
  }
@@ -522,18 +534,10 @@ export function renderHistory(history) {
522
534
  }
523
535
  if (m.role === 'tool') {
524
536
  const id = m.tool_call_id ?? '';
525
- const name = idToName.get(id) ?? '';
537
+ const target = idToEntry.get(id);
538
+ const name = target?.name ?? '';
526
539
  const output = textOf(m.content);
527
540
  const preview = summarizeToolResult(name, output);
528
- // 匹配 pendingBatch 中尚未填 result 的同名 entry;同名前缀 tool 较罕见(并行工具同 id 不同名)
529
- let target;
530
- for (let i = pendingBatch.length - 1; i >= 0; i--) {
531
- const e = pendingBatch[i];
532
- if (e.name === name && !e.resultSummary) {
533
- target = e;
534
- break;
535
- }
536
- }
537
541
  if (target) {
538
542
  target.resultSummary = preview;
539
543
  target.fullOutput = output;
@@ -853,6 +857,7 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
853
857
  if (!loadSnapshots(loaded.id))
854
858
  rebuildFromHistory(history);
855
859
  contextState.lastUsage = undefined;
860
+ contextState.correction = 1;
856
861
  lastTurnUsage = undefined; // 续接:旧会话的 token 累计已无意义,清空等下轮覆写
857
862
  layout.clearContent();
858
863
  renderHistory(history);
@@ -985,6 +990,7 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
985
990
  currentSessionId = undefined; // 下轮起新会话文件
986
991
  turnCount = 0; // 反思 cadence 重新计数
987
992
  contextState.lastUsage = undefined;
993
+ contextState.correction = 1;
988
994
  lastTurnUsage = undefined; // 清空旧轮的 token 累计
989
995
  pendingAttachments = []; // 一并清空待发图片
990
996
  layout.clearContent();
@@ -2,24 +2,12 @@ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, } from
2
2
  import path from 'node:path';
3
3
  import { config } from '../config/index.js';
4
4
  import { truncateDisplay } from '../ui/render.js';
5
+ import { toText } from '../context/utils.js';
5
6
  let turnIdCounter = 0;
6
7
  let currentTurnId = 0;
7
8
  let turns = [];
8
9
  let snapshots = [];
9
10
  const MUTATION_TOOLS = new Set(['write_file', 'edit_file']);
10
- /** 把任意 content 拍平成字符串(OpenAI 可能是 string / 多模态数组)。 */
11
- function toText(content) {
12
- if (content == null)
13
- return '';
14
- if (typeof content === 'string')
15
- return content;
16
- try {
17
- return JSON.stringify(content);
18
- }
19
- catch {
20
- return String(content);
21
- }
22
- }
23
11
  /** 规整成 cwd 相对路径(快照存相对,跨 /resume 同项目可识别;resolve 已归一 ./ 和 ..)。 */
24
12
  function toRel(p) {
25
13
  try {
@@ -5,10 +5,14 @@ import { ui } from '../ui/theme.js';
5
5
  import { Spinner } from '../ui/spinner.js';
6
6
  import * as layout from '../ui/layout.js';
7
7
  import { pruneAfterCompaction } from '../rollback/index.js';
8
+ import { toText } from '../context/utils.js';
8
9
  /** 跨模块共享的上下文状态:agent 写 lastUsage,compact 写 lastEstimate,repl 的 /context 读。
9
- * scheduler.ts 写最近一次调度日志(可选,repl 可读不到时 no-op)。 */
10
+ * scheduler.ts 写最近一次调度日志(可选,repl 可读不到时 no-op)。
11
+ * correction:API 实测 token / 估算 token 的校正系数(1.0 = 无偏差;>1 = 低估;<1 = 高估)。
12
+ * 由 agent/core.ts 在每次 chat 响应后更新;compact/repl 在 usage 失效时同步重置。 */
10
13
  export const contextState = {
11
14
  lastEstimate: 0,
15
+ correction: 1,
12
16
  };
13
17
  /** 中截:text 太长时保 head + 标记 + tail,总长 ≤ max。 */
14
18
  export function truncateMid(text, max) {
@@ -97,19 +101,7 @@ export function capToolResultForHistory(name, output) {
97
101
  return output;
98
102
  return truncateMid(output, MAX_HISTORY_RESULT);
99
103
  }
100
- // ── 内部:消息内容拍平 / group 划分 ──────────────────────────────────────
101
- function toText(content) {
102
- if (content == null)
103
- return '';
104
- if (typeof content === 'string')
105
- return content;
106
- try {
107
- return JSON.stringify(content);
108
- }
109
- catch {
110
- return String(content);
111
- }
112
- }
104
+ // ── 内部:group 划分(toText 已移至 context/utils.ts 统一维护) ────────────
113
105
  /**
114
106
  * 把多模态 content 拍平成纯文本(供摘要 transcript 用):text parts 拼接;image_url parts
115
107
  * 替换为 `[图片已剥离: <mime>]` stub,避免 base64 进摘要 prompt(LLM 看到也无意义,反而撑爆 token)。
@@ -323,6 +315,7 @@ export async function compactHistory(history, opts) {
323
315
  const estimateAfter = estimateMessagesTokens(history) + schemaTokens;
324
316
  contextState.lastEstimate = estimateAfter;
325
317
  contextState.lastUsage = undefined;
318
+ contextState.correction = 1;
326
319
  layout.contentWrite(` ${ui.bold}${ui.accent}●${ui.reset} ${ui.accent}强制压缩(focus on early history)${ui.reset} ${ui.dim}${estimateBefore} → ${estimateAfter} tokens${ui.reset}\n`);
327
320
  return {
328
321
  compacted: true,
@@ -409,6 +402,7 @@ export async function compactHistory(history, opts) {
409
402
  const estimateAfter = estimateMessagesTokens(history) + schemaTokens;
410
403
  contextState.lastEstimate = estimateAfter;
411
404
  contextState.lastUsage = undefined; // 压缩后旧 usage 失效,/context 改用估算
405
+ contextState.correction = 1;
412
406
  layout.contentWrite(` ${ui.bold}${ui.accent}●${ui.reset} ${ui.accent}压缩上下文${ui.reset} ${ui.dim}${estimateBefore} → ${estimateAfter} tokens${ui.reset}\n`);
413
407
  // 抖动保护:压缩后仍超阈 → 提示 /clear,不死循环
414
408
  if (estimateAfter >= opts.threshold * opts.window) {
@@ -426,6 +420,7 @@ export async function compactHistory(history, opts) {
426
420
  const estimateAfter = estimateMessagesTokens(history) + schemaTokens;
427
421
  contextState.lastEstimate = estimateAfter;
428
422
  contextState.lastUsage = undefined; // 结构虽未变,但 token 数已变,旧 usage 失效
423
+ contextState.correction = 1;
429
424
  if (microcompactDone) {
430
425
  layout.contentWrite(` ${ui.bold}${ui.accent}●${ui.reset} ${ui.accent}微压缩旧工具结果${ui.reset} ${ui.dim}${estimateBefore} → ${estimateAfter} tokens${ui.reset}\n`);
431
426
  return {
@@ -11,49 +11,7 @@
11
11
  // - 原地修改 history(同 compact:length=0;push 重建,repl 持有同一引用)。
12
12
  // - 永不抛错(对齐「调度器永不抛错」契约);无匹配 / 无可剔除 → 返 dropped=0。
13
13
  import { messageTokens, estimateTokens, } from '../llm/index.js';
14
- /** 把消息 content 拍平成字符串(OpenAI 可能 string / null / 多模态数组)。 */
15
- function toText(content) {
16
- if (content == null)
17
- return '';
18
- if (typeof content === 'string')
19
- return content;
20
- try {
21
- return JSON.stringify(content);
22
- }
23
- catch {
24
- return String(content);
25
- }
26
- }
27
- /**
28
- * 从 history 末尾向前找最后一个 user 消息的索引;无 user 返 -1。
29
- * 用于划定"当前轮保护区"——该 user 及其之后的消息一律不剔除。
30
- */
31
- function lastUserIndex(history) {
32
- for (let i = history.length - 1; i >= 1; i--) {
33
- if (history[i].role === 'user')
34
- return i;
35
- }
36
- return -1;
37
- }
38
- /** 取 tool 消息对应的工具名(从紧邻的前导 assistant.tool_calls 按 tool_call_id 配对找)。 */
39
- function toolNameOf(history, idx) {
40
- const tcId = history[idx].tool_call_id;
41
- if (!tcId)
42
- return null;
43
- for (let j = idx - 1; j >= 1; j--) {
44
- const m = history[j];
45
- if (m.role !== 'assistant')
46
- continue;
47
- const tcs = m
48
- .tool_calls;
49
- if (!tcs)
50
- continue;
51
- const hit = tcs.find((tc) => tc?.id === tcId);
52
- if (hit)
53
- return hit.function?.name ?? null;
54
- }
55
- return null;
56
- }
14
+ import { lastUserIndex, toText, toolNameOf } from '../context/utils.js';
57
15
  /**
58
16
  * 剔除历史里命中的旧 tool 结果(原地修改 history)。
59
17
  *
@@ -45,7 +45,7 @@ export function createBudgetScheduler() {
45
45
  // 占位:push-time 三闸(cap / pruner / lifecycle)已自动跑;此接缝供将来演进。
46
46
  },
47
47
  async runStep(history, step) {
48
- const report = evaluateBudget(history, config.contextWindowTokens, step);
48
+ const report = evaluateBudget(history, config.contextWindowTokens, step, contextState.correction);
49
49
  const actions = scheduleActions(report);
50
50
  let compactHistoryCalled = false;
51
51
  for (const a of actions) {
@@ -104,7 +104,7 @@ export async function manualCompact(history, focus, opts) {
104
104
  }));
105
105
  return {
106
106
  step: -1,
107
- report: evaluateBudget(history, config.contextWindowTokens, -1),
107
+ report: evaluateBudget(history, config.contextWindowTokens, -1, contextState.correction),
108
108
  actions: [{ kind: 'compact_history', focus }],
109
109
  compactHistoryCalled: true,
110
110
  ts: Date.now(),
@@ -118,7 +118,7 @@ export async function manualCompact(history, focus, opts) {
118
118
  },
119
119
  };
120
120
  }
121
- const report = evaluateBudget(history, config.contextWindowTokens, -1);
121
+ const report = evaluateBudget(history, config.contextWindowTokens, -1, contextState.correction);
122
122
  let actions = scheduleActions(report);
123
123
  // 用户显式说「要压」:即使 report 不含 history 触发,仍追加 compact_history
124
124
  const hasCompact = actions.some(a => a.kind === 'compact_history');
@@ -36,10 +36,8 @@ export const todolistTool = {
36
36
  'BAD: "打开文件A → 修改函数X → 保存文件A → 运行测试" (too fine-grained, just do it)',
37
37
  '',
38
38
  '## UPDATE WORKFLOW',
39
- 'Update the plan AFTER completing each step not before, not in bulk at the end.',
40
- '- Mark the current step `done` + set the next step `in_progress` in the same `todolist` call.',
41
- '- Use `update` for single steps; `batch_update` ONLY when 2-3 steps finish simultaneously.',
42
- '- ⛔ NEVER defer all updates to the end — this defeats the purpose of the progress chip.',
39
+ 'Update in real-time after completing each step (one step = one update, or batch_update for 2-3 at once).',
40
+ 'Do NOT batch all updates at the end update as you go so the chip reflects real progress.',
43
41
  'All steps done/skipped → plan auto-finishes, archives, and chip disappears.',
44
42
  '',
45
43
  '## LIFECYCLE',