mocode-ai 1.6.4 → 1.6.6

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 (91) hide show
  1. package/README.md +382 -365
  2. package/README.zh-CN.md +19 -2
  3. package/dist/agent/model-turn.js +14 -1
  4. package/dist/agent/run-coordinator.js +7 -1
  5. package/dist/agent/spawn.js +38 -5
  6. package/dist/agent/stages/model-runner.js +1 -1
  7. package/dist/agent/stages/tool-dispatcher.js +1 -0
  8. package/dist/agent/tool-turn.js +2 -0
  9. package/dist/agent/work-discipline.js +3 -1
  10. package/dist/bots/bus.js +117 -0
  11. package/dist/bots/cli.js +102 -0
  12. package/dist/bots/store.js +100 -0
  13. package/dist/config/index.js +53 -2
  14. package/dist/config/presets.js +40 -1
  15. package/dist/config/profiles.js +9 -1
  16. package/dist/context/clearing.js +53 -0
  17. package/dist/context/relevance.js +6 -0
  18. package/dist/context/text-search.js +113 -0
  19. package/dist/headless.js +302 -0
  20. package/dist/index.js +171 -3
  21. package/dist/jobs/approval.js +153 -0
  22. package/dist/jobs/approve-cli.js +30 -0
  23. package/dist/jobs/attach.js +61 -0
  24. package/dist/jobs/checkpoint.js +33 -0
  25. package/dist/jobs/launch.js +107 -0
  26. package/dist/jobs/runner.js +59 -0
  27. package/dist/jobs/store.js +101 -0
  28. package/dist/jobs/worktree.js +63 -0
  29. package/dist/llm/index.js +35 -8
  30. package/dist/llm/providers/anthropic.js +14 -4
  31. package/dist/llm/reasoning.js +141 -0
  32. package/dist/memory/reflect.js +1 -1
  33. package/dist/memory/store.js +31 -28
  34. package/dist/models/budgets.js +11 -0
  35. package/dist/models/catalog.js +130 -0
  36. package/dist/models/map-preset.js +50 -0
  37. package/dist/models/model-panel.js +227 -0
  38. package/dist/models/protocol.js +44 -0
  39. package/dist/models/reasoning-cap.js +88 -0
  40. package/dist/models/search.js +58 -0
  41. package/dist/models/types.js +8 -0
  42. package/dist/notify/index.js +144 -0
  43. package/dist/repl/commands/bots.js +32 -0
  44. package/dist/repl/commands/effort.js +81 -0
  45. package/dist/repl/commands/jobs.js +109 -0
  46. package/dist/repl/commands/model-actions.js +138 -0
  47. package/dist/repl/commands/model-catalog.js +67 -0
  48. package/dist/repl/commands/model-panel-ui.js +107 -0
  49. package/dist/repl/commands/model.js +22 -20
  50. package/dist/repl/commands/registry.js +12 -0
  51. package/dist/repl/commands/schedule.js +37 -0
  52. package/dist/repl/commands/session.js +54 -0
  53. package/dist/repl/commands/stats.js +28 -0
  54. package/dist/repl/commands.js +6 -58
  55. package/dist/repl/runtime.js +5 -1
  56. package/dist/repl/status-bar.js +4 -2
  57. package/dist/rollback/store.js +17 -0
  58. package/dist/runtime/runtime.js +31 -1
  59. package/dist/schedule/cli.js +114 -0
  60. package/dist/schedule/cron.js +79 -0
  61. package/dist/schedule/daemon.js +166 -0
  62. package/dist/schedule/store.js +98 -0
  63. package/dist/session/compact.js +84 -3
  64. package/dist/session/index.js +2 -0
  65. package/dist/session/retention.js +297 -0
  66. package/dist/session/scheduler.js +30 -3
  67. package/dist/session/store.js +108 -6
  68. package/dist/session/usage-stats.js +90 -0
  69. package/dist/skills/activation.js +2 -0
  70. package/dist/skills/builtin-skills.js +153 -0
  71. package/dist/skills/discover.js +3 -1
  72. package/dist/skills/runner.js +4 -1
  73. package/dist/tools/builtins/arena.js +156 -0
  74. package/dist/tools/builtins/index.js +8 -0
  75. package/dist/tools/builtins/message-bus.js +98 -0
  76. package/dist/tools/builtins/read-file.js +19 -1
  77. package/dist/tools/builtins/run-command.js +2 -1
  78. package/dist/tools/builtins/session-search.js +33 -0
  79. package/dist/tools/builtins/task.js +2 -0
  80. package/dist/tools/constants.js +8 -0
  81. package/dist/tools/read-dedup.js +62 -0
  82. package/dist/tools/tool-runtime.js +1 -0
  83. package/dist/ui/batch.js +140 -104
  84. package/dist/ui/composer.js +4 -18
  85. package/dist/ui/fuzzy-picker.js +227 -0
  86. package/dist/ui/hierarchical-picker.js +302 -0
  87. package/dist/ui/intervention.js +17 -1
  88. package/dist/ui/layout-internal/statusbar.js +18 -7
  89. package/dist/ui/prompt-internal/editor.js +10 -27
  90. package/dist/ui/render.js +2 -0
  91. package/package.json +1 -1
@@ -1,4 +1,4 @@
1
- import { chat, chatTools, correctTokenEstimate, estimatePromptTokens, estimateTokens, } from '../llm/index.js';
1
+ import { chat, chatTools, correctTokenEstimate, estimateMessagesTokens, estimatePromptTokens, estimateToolSchemaTokens, estimateTokens, } from '../llm/index.js';
2
2
  import { config } from '../config/index.js';
3
3
  import { COMPACT_USER_VERBATIM_MAX_TOKENS, COMPACT_USER_VERBATIM_WINDOW_RATIO, 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, USER_DIRECTIVE_MAX_CHARS, USER_DIRECTIVE_MAX_CONSTRAINTS, } from '../tools/constants.js';
4
4
  import { ui } from '../ui/theme.js';
@@ -9,6 +9,9 @@ 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
11
  import { writeCompactionSnapshot } from './notes.js';
12
+ import { getActiveSessionStore } from './store.js';
13
+ import { getCurrentSessionId } from './state.js';
14
+ import { toUsageRecord } from './usage-stats.js';
12
15
  /** 默认兼容依赖:旧调用方继续读取进程级 config/chat。 */
13
16
  export const defaultCompactionRuntime = {
14
17
  config,
@@ -552,7 +555,7 @@ async function defaultSummarize(older, focus, signal, runtime = defaultCompactio
552
555
  // signal 必须透传:摘要是几十秒的 LLM 调用,不串进来 Ctrl+C 只能干等它跑完。
553
556
  // 空 handlers:不打印、不外显流式;tools=[] 不带工具表——摘要纯文本任务,
554
557
  // 全量工具 schema 白占几千 token 窗口,还诱导幻觉工具调用。
555
- const r = await runtime.modelTransport([sysMsg, userMsg], {}, signal, []);
558
+ const r = await runtime.modelTransport([sysMsg, userMsg], {}, signal, [], { reasoningEffort: 'low' });
556
559
  // 推理模型可能只返 reasoning_content(content 为 null),或幻觉出 tool_calls → 视为失败
557
560
  if (r.toolCalls.length > 0 || !r.content)
558
561
  return null;
@@ -562,6 +565,74 @@ async function defaultSummarize(older, focus, signal, runtime = defaultCompactio
562
565
  spinner.stop();
563
566
  }
564
567
  }
568
+ // ── cache-safe fork 摘要器(#token-efficiency P0) ─────────────────────────
569
+ /**
570
+ * 构造 fork 前缀:父会话 history[0](system)+ 所有不在 kept 里的原始消息
571
+ * (pinned 旧摘要 + oldGroups),保持原顺序与原对象引用。
572
+ * 与父请求编码结果逐字节一致是显式/隐式缓存命中的唯一条件。
573
+ */
574
+ function buildForkMessages(history, kept) {
575
+ if (history.length === 0)
576
+ return [];
577
+ const keptSet = new Set(flattenGroups(kept));
578
+ const out = [history[0]];
579
+ for (const m of history.slice(1)) {
580
+ if (!keptSet.has(m))
581
+ out.push(m);
582
+ }
583
+ return out;
584
+ }
585
+ /** fork 尾部 user 消息:摘要任务指令(原 legacy sysMsg 的规则,措辞改为"上方对话")。 */
586
+ function compactForkInstruction(focus) {
587
+ const focusLine = focus ? `Focus: prioritize facts / decisions / file changes related to 「${focus}」.\n\n` : '';
588
+ return (focusLine +
589
+ '[COMPACT TASK] You are an aggressive session compressor writing a handoff note for an agent that is about to lose the context above. ' +
590
+ 'The agent will continue with ONLY your summary plus a few most-recent messages (not shown here), so your summary is the sole memory of every message above. ' +
591
+ 'If the messages above include a prior session summary, merge its narrative; its Key Facts are pinned separately by the system — do not duplicate them.\n' +
592
+ 'Output ONLY the summary body in this exact structure (omit empty sections):\n' +
593
+ "## Objective — the user's core request(s); cover EVERY distinct user request above, in order, noting which are completed vs pending.\n" +
594
+ '## 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' +
595
+ '## 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' +
596
+ '## Next Steps — the concrete next actions in order.\n' +
597
+ '## Key Facts — only what later steps cannot work without: exact paths, symbols/API shapes, artifact IDs/hashes, open questions, failed approaches and errors to avoid repeating. User requests and constraints are pinned verbatim by the system from the raw user messages — do not duplicate them here; you own the technical facts.\n' +
598
+ '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) later steps must cite; failures and what was tried, so mistakes are not repeated.\n' +
599
+ 'Drop: verbatim file contents and tool-output dumps, step-by-step recaps, exploration dead-ends, polite chatter, anything re-derivable by re-reading files.\n' +
600
+ 'GUI EXCEPTION to "step-by-step recaps": for computer/screen-control actions keep the SEQUENCE as an ordered list — one line per action: `N. <action> (<coordinates or target>) → <observed result>`. Never collapse it into prose and never drop attempts that produced no visible change.\n' +
601
+ 'Rules: total ≤ 400 words. State conclusions and locations (path:line where useful), never paste content. ' +
602
+ 'Write each Key Fact as one self-contained line (no pronouns, no "as above", no cross-references). ' +
603
+ 'Do not emit tool calls and do not invent facts; if unsure whether something happened, omit it.');
604
+ }
605
+ /** buffer 校验:fork 全量(含指令 + 摘要输出上限)必须装得进窗口,否则回落 legacy。 */
606
+ function forkFits(prefix, instruction, activeTools, window) {
607
+ const forkInput = estimateMessagesTokens([...prefix, { role: 'user', content: instruction }]) +
608
+ estimateToolSchemaTokens(activeTools);
609
+ const outputBudget = estimateTokens('a '.repeat(Math.max(1, Math.floor(SUMMARY_OUTPUT_MAX_CHARS / 2))));
610
+ return forkInput + outputBudget <= window;
611
+ }
612
+ /** 执行 fork 请求:复用父前缀 + activeTools,记录 compact phase 用量。 */
613
+ async function forkSummarize(prefix, instruction, signal, runtime, activeTools, state) {
614
+ if (signal?.aborted)
615
+ throw new DOMException('This operation is aborted', 'AbortError');
616
+ const messages = [...prefix, { role: 'user', content: instruction }];
617
+ const spinner = new Spinner((msg, frame) => layout.setStatus(msg, frame ?? undefined));
618
+ spinner.start(signal ? '压缩中(Ctrl+C 取消)' : '压缩中');
619
+ try {
620
+ const r = await runtime.modelTransport(messages, {}, signal, activeTools, { reasoningEffort: 'low' });
621
+ if (r.usage) {
622
+ const rec = toUsageRecord(state.currentStep ?? -1, 'compact', r.usage);
623
+ const sessionId = getCurrentSessionId();
624
+ if (rec && sessionId)
625
+ getActiveSessionStore().appendUsage(sessionId, rec);
626
+ }
627
+ // 同 legacy:幻觉 tool_calls 或空 content → 失败(调用方回落 microcompact)。
628
+ if (r.toolCalls.length > 0 || !r.content)
629
+ return null;
630
+ return capSummaryOutput(r.content);
631
+ }
632
+ finally {
633
+ spinner.stop();
634
+ }
635
+ }
565
636
  // ── 对外:compactHistory / maybeCompact ───────────────────────────────────
566
637
  /**
567
638
  * 用户可见的上下文占用口径:**裸估算**(不乘 correction)。
@@ -761,7 +832,17 @@ export async function compactHistory(history, opts) {
761
832
  ];
762
833
  }
763
834
  const summarizeFn = opts.summarize ??
764
- ((older, focus, signal) => defaultSummarize(older, focus, signal, opts.runtime ?? defaultCompactionRuntime));
835
+ (async (older, focus, signal) => {
836
+ const rt = opts.runtime ?? defaultCompactionRuntime;
837
+ // P0:fork 关闭或 buffer 装不下 → 回落 legacy 独立 compressor 路径。
838
+ if (!config.compactFork)
839
+ return defaultSummarize(older, focus, signal, rt);
840
+ const prefix = buildForkMessages(history, kept);
841
+ const instruction = compactForkInstruction(focus);
842
+ if (!forkFits(prefix, instruction, activeTools, opts.window))
843
+ return defaultSummarize(older, focus, signal, rt);
844
+ return forkSummarize(prefix, instruction, signal, rt, activeTools, state);
845
+ });
765
846
  let summary = null;
766
847
  try {
767
848
  summary = await summarizeFn(older, opts.focus, opts.signal);
@@ -12,6 +12,8 @@ export { compactHistory, maybeCompact, capToolResultForHistory, truncateMid, con
12
12
  export { runScheduler, manualCompact, createBudgetScheduler } from './scheduler.js';
13
13
  export { newSessionId, saveSession, loadSession, listSessions, sessionDir } from './persist.js';
14
14
  export { SessionStore, defaultSessionStore, getActiveSessionStore, withSessionStore } from './store.js';
15
+ // ── 老会话 retention/GC(30 天归档 / 90 天清正文)────────────────────────
16
+ export { planRetention, runRetention, readArchiveIndex, upsertArchiveIndex, loadArchivedSession, searchArchiveIndex, retentionPaths, shouldAutoGc, writeLastAutoGc, DEFAULT_RETENTION_POLICY, } from './retention.js';
15
17
  export { appendCurrentSessionTrace, appendCurrentSessionTraceEvent, appendCurrentSessionRuntimeEvent, createTraceEvent, } from './trace.js';
16
18
  export { reduceTraceMetrics, readTraceEvents, readTraceMetrics } from './trace-metrics.js';
17
19
  export { summarizeToolArguments, hashTraceValue, safeProviderId } from './trace-sanitize.js';
@@ -0,0 +1,297 @@
1
+ // Session retention / GC: 老会话的分层生命周期治理。
2
+ //
3
+ // 此前 memory 有 decay/GC(30/90 天)而 session 没有——.mocode/sessions/ 只增不减
4
+ // (实测 23MB+)。本模块对齐 memory 的节奏:
5
+ // ① archive: 超过 archiveAfterDays 未活动的会话,整份 SessionRecord gzip 到
6
+ // archive/<id>.json.gz,摘要+meta 落入 archive/index.jsonl,然后删原目录。
7
+ // ② purge: archive 中的 .gz 超过 purgeAfterDays,删 gz;index 行保留(meta+摘要),
8
+ // 仅标 purgedAt——老资料仍可检索,只是不可再整段恢复。
9
+ //
10
+ // 纯本地、确定性、零 LLM 成本。当前活跃会话(currentSessionId)永不归档。
11
+ // 所有写盘走 tmp+rename(整文件读改写),与 memory/store.ts 同一原子模型。
12
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
13
+ import path from 'node:path';
14
+ import { gzipSync, gunzipSync } from 'node:zlib';
15
+ import { TextSearchIndex } from '../context/text-search.js';
16
+ import { config } from '../config/index.js';
17
+ import { runReflection, snapshotTranscript } from '../memory/index.js';
18
+ /** 摘要消息头(与 compact.ts SUMMARY_MARKER 同值;本地复制避免拉入 compact 的重依赖图)。 */
19
+ const SUMMARY_MARKER = '# 会话摘要';
20
+ const DAY_MS = 86_400_000;
21
+ export const DEFAULT_RETENTION_POLICY = {
22
+ // env 覆盖仅供测试/调参:MOCODE_ARCHIVE_AFTER_DAYS=0 可立刻触发归档,不必真等 30 天。
23
+ archiveAfterDays: envNumber('MOCODE_ARCHIVE_AFTER_DAYS', 30),
24
+ purgeAfterDays: envNumber('MOCODE_PURGE_AFTER_DAYS', 90),
25
+ };
26
+ /** 读数字 env;允许显式 0(测试要立刻触发),非法/缺省回退默认。 */
27
+ function envNumber(key, fallback) {
28
+ const v = Number(process.env[key]);
29
+ return Number.isFinite(v) && v >= 0 ? v : fallback;
30
+ }
31
+ export function retentionPaths(sessionsRoot) {
32
+ const archiveDir = path.join(sessionsRoot, 'archive');
33
+ return {
34
+ sessionsRoot,
35
+ archiveDir,
36
+ indexPath: path.join(archiveDir, 'index.jsonl'),
37
+ lastAutoGcPath: path.join(archiveDir, '.last-auto-gc'),
38
+ };
39
+ }
40
+ /** 两次自动 GC 的最小间隔(限频,避免每次启动都跑)。 */
41
+ export const AUTO_GC_INTERVAL_MS = 24 * 60 * 60 * 1000;
42
+ /** 读上次自动 GC 时间戳;无文件/损坏返回 null。 */
43
+ export function readLastAutoGc(sessionsRoot) {
44
+ try {
45
+ const v = Number(readFileSync(retentionPaths(sessionsRoot).lastAutoGcPath, 'utf8').trim());
46
+ return Number.isFinite(v) ? v : null;
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
52
+ /** 写自动 GC 时间戳(tmp+rename 虽非必需,保持与其它落盘一致的原子性)。 */
53
+ export function writeLastAutoGc(sessionsRoot, ts = Date.now()) {
54
+ const p = retentionPaths(sessionsRoot).lastAutoGcPath;
55
+ mkdirSync(path.dirname(p), { recursive: true });
56
+ writeFileSync(p + '.tmp', String(ts));
57
+ renameSync(p + '.tmp', p);
58
+ }
59
+ /** 是否该跑自动 GC:距上次 ≥ interval(或从未跑过)。env MOCODE_AUTO_GC=false 可关闭。 */
60
+ export function shouldAutoGc(sessionsRoot, now = Date.now()) {
61
+ if (process.env.MOCODE_AUTO_GC === 'false')
62
+ return false;
63
+ const last = readLastAutoGc(sessionsRoot);
64
+ return last === null || now - last >= AUTO_GC_INTERVAL_MS;
65
+ }
66
+ function dirSize(dir) {
67
+ let total = 0;
68
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
69
+ const full = path.join(dir, entry.name);
70
+ try {
71
+ total += entry.isDirectory() ? dirSize(full) : statSync(full).size;
72
+ }
73
+ catch {
74
+ // 统计尽力而为。
75
+ }
76
+ }
77
+ return total;
78
+ }
79
+ /** 从 SessionRecord 提取会话摘要(role:system 且以标记开头的消息),无则空串。 */
80
+ function extractSummary(record) {
81
+ for (const m of record.history) {
82
+ if (m.role !== 'system')
83
+ continue;
84
+ const c = m.content;
85
+ if (typeof c === 'string' && c.startsWith(SUMMARY_MARKER))
86
+ return c;
87
+ }
88
+ return '';
89
+ }
90
+ // ── index.jsonl 读写 ─────────────────────────────────────────────────────
91
+ export function readArchiveIndex(indexPath) {
92
+ if (!existsSync(indexPath))
93
+ return [];
94
+ const out = [];
95
+ for (const line of readFileSync(indexPath, 'utf8').split('\n')) {
96
+ if (!line.trim())
97
+ continue;
98
+ try {
99
+ const e = JSON.parse(line);
100
+ if (e && typeof e.id === 'string')
101
+ out.push(e);
102
+ }
103
+ catch {
104
+ // 跳过损坏行。
105
+ }
106
+ }
107
+ return out;
108
+ }
109
+ /** 整文件原子写:tmp+rename。 */
110
+ function writeArchiveIndex(indexPath, entries) {
111
+ const tmp = indexPath + '.tmp';
112
+ writeFileSync(tmp, entries.map((e) => JSON.stringify(e)).join('\n') + (entries.length ? '\n' : ''), 'utf8');
113
+ renameSync(tmp, indexPath);
114
+ }
115
+ /** upsert 一条索引(按 id 覆盖)。 */
116
+ export function upsertArchiveIndex(indexPath, entry) {
117
+ const entries = readArchiveIndex(indexPath).filter((e) => e.id !== entry.id);
118
+ entries.push(entry);
119
+ entries.sort((a, b) => (a.id < b.id ? 1 : a.id > b.id ? -1 : 0));
120
+ writeArchiveIndex(indexPath, entries);
121
+ }
122
+ // ── 计划(不落盘)──────────────────────────────────────────────────────────
123
+ /**
124
+ * 生成 retention 计划。
125
+ * @param excludeCurrentId 当前活跃会话 id,永不归档。
126
+ */
127
+ export function planRetention(sessionsRoot, policy = DEFAULT_RETENTION_POLICY, now = Date.now(), excludeCurrentId) {
128
+ const items = [];
129
+ const paths = retentionPaths(sessionsRoot);
130
+ // ① 待归档的活会话目录
131
+ if (existsSync(sessionsRoot)) {
132
+ for (const entry of readdirSync(sessionsRoot, { withFileTypes: true })) {
133
+ if (!entry.isDirectory() || entry.name === 'archive')
134
+ continue;
135
+ if (!/^\d{8}-\d{6}/.test(entry.name))
136
+ continue;
137
+ if (entry.name === excludeCurrentId)
138
+ continue;
139
+ const dir = path.join(sessionsRoot, entry.name);
140
+ const sessionPath = path.join(dir, 'session.json');
141
+ if (!existsSync(sessionPath))
142
+ continue;
143
+ // Date.now() 截断毫秒而 mtimeMs 带小数:新写入文件 age 可能微负,
144
+ // clamp 到 0 避免阈值 0(=立即归档)下把刚写入的会话误判为未超龄跳过。
145
+ const ageDays = Math.max(0, now - statSync(sessionPath).mtimeMs) / DAY_MS;
146
+ if (ageDays < policy.archiveAfterDays)
147
+ continue;
148
+ let firstUser = '';
149
+ try {
150
+ const rec = JSON.parse(readFileSync(sessionPath, 'utf8'));
151
+ firstUser = rec.firstUser ?? '';
152
+ }
153
+ catch {
154
+ // 损坏文件仍可归档,firstUser 留空。
155
+ }
156
+ items.push({ id: entry.name, ageDays, action: 'archive', firstUser, sizeBytes: dirSize(dir) });
157
+ }
158
+ }
159
+ // ② 待 purge 的归档 gz
160
+ if (existsSync(paths.archiveDir)) {
161
+ for (const entry of readdirSync(paths.archiveDir, { withFileTypes: true })) {
162
+ if (!entry.isFile() || !entry.name.endsWith('.json.gz'))
163
+ continue;
164
+ const id = entry.name.replace(/\.json\.gz$/, '');
165
+ const full = path.join(paths.archiveDir, entry.name);
166
+ const ageDays = Math.max(0, now - statSync(full).mtimeMs) / DAY_MS;
167
+ if (ageDays < policy.purgeAfterDays)
168
+ continue;
169
+ const idx = readArchiveIndex(paths.indexPath).find((e) => e.id === id);
170
+ items.push({ id, ageDays, action: 'purge', firstUser: idx?.firstUser ?? '', sizeBytes: statSync(full).size });
171
+ }
172
+ }
173
+ items.sort((a, b) => (a.id < b.id ? 1 : -1));
174
+ return { items, bytesReclaimable: items.reduce((s, i) => s + i.sizeBytes, 0) };
175
+ }
176
+ // ── 执行 ─────────────────────────────────────────────────────────────────
177
+ /** 归档单个会话:读 session.json → gzip → 写 index → 删原目录。失败抛错由调用方统一处理。 */
178
+ function archiveSession(sessionsRoot, id, now) {
179
+ const paths = retentionPaths(sessionsRoot);
180
+ const dir = path.join(sessionsRoot, id);
181
+ const sessionPath = path.join(dir, 'session.json');
182
+ const record = JSON.parse(readFileSync(sessionPath, 'utf8'));
183
+ mkdirSync(paths.archiveDir, { recursive: true });
184
+ const gzPath = path.join(paths.archiveDir, `${id}.json.gz`);
185
+ writeFileSync(gzPath + '.tmp', gzipSync(readFileSync(sessionPath)));
186
+ renameSync(gzPath + '.tmp', gzPath);
187
+ upsertArchiveIndex(paths.indexPath, {
188
+ id: record.id,
189
+ createdAt: record.createdAt,
190
+ model: record.model,
191
+ firstUser: record.firstUser,
192
+ summary: extractSummary(record),
193
+ archivedAt: new Date(now).toISOString(),
194
+ });
195
+ rmSync(dir, { recursive: true, force: true });
196
+ }
197
+ /** purge 单个归档:删 gz,index 行保留并标 purgedAt。 */
198
+ function purgeArchived(sessionsRoot, id, now) {
199
+ const paths = retentionPaths(sessionsRoot);
200
+ const gzPath = path.join(paths.archiveDir, `${id}.json.gz`);
201
+ if (existsSync(gzPath))
202
+ unlinkSync(gzPath);
203
+ const entries = readArchiveIndex(paths.indexPath);
204
+ const e = entries.find((x) => x.id === id);
205
+ if (e && !e.purgedAt) {
206
+ e.purgedAt = new Date(now).toISOString();
207
+ writeArchiveIndex(paths.indexPath, entries);
208
+ }
209
+ }
210
+ /** 值得跑巩固的最短 history 长度;过短会话没有可沉淀的内容。 */
211
+ const MIN_MESSAGES_TO_REFLECT = 6;
212
+ /** 后台巩固单次超时:比 runReflection 默认 60s 更短,避免后台静默挂一串慢请求。 */
213
+ const CONSOLIDATE_TIMEOUT_MS = 20000;
214
+ /**
215
+ * 按计划执行归档/清除。**核心动作(归档/删正文)同步秒完并立即返回**。
216
+ *
217
+ * 记忆巩固(P2-9)是增强而非归档前提:先读出待归档会话转录、完成归档,
218
+ * 再把 reflection 放到**后台非阻塞**执行——gc 不再让用户干等 LLM。
219
+ * 记忆关闭(config.memoryEnabled=false)时完全跳过,不花一分钱。
220
+ */
221
+ export async function runRetention(sessionsRoot, plan, now = Date.now()) {
222
+ let archived = 0;
223
+ let purged = 0;
224
+ let bytesReclaimed = 0;
225
+ const pendingTranscripts = [];
226
+ for (const item of plan.items) {
227
+ if (item.action === 'archive') {
228
+ if (config.memoryEnabled) {
229
+ const transcript = readTranscript(sessionsRoot, item.id);
230
+ if (transcript)
231
+ pendingTranscripts.push(transcript);
232
+ }
233
+ archiveSession(sessionsRoot, item.id, now);
234
+ archived++;
235
+ }
236
+ else {
237
+ purgeArchived(sessionsRoot, item.id, now);
238
+ purged++;
239
+ }
240
+ bytesReclaimed += item.sizeBytes;
241
+ }
242
+ // 后台巩固:不 await、不阻塞返回。单项失败已在 runReflection 内部兜底。
243
+ for (const transcript of pendingTranscripts) {
244
+ void runReflection(transcript, AbortSignal.timeout(CONSOLIDATE_TIMEOUT_MS));
245
+ }
246
+ return { archived, purged, bytesReclaimed };
247
+ }
248
+ /** 读会话转录快照;history 过短或读取失败返回 null(跳过巩固)。 */
249
+ function readTranscript(sessionsRoot, id) {
250
+ try {
251
+ const sessionPath = path.join(sessionsRoot, id, 'session.json');
252
+ const record = JSON.parse(readFileSync(sessionPath, 'utf8'));
253
+ const history = record.history;
254
+ if (!Array.isArray(history) || history.length < MIN_MESSAGES_TO_REFLECT)
255
+ return null;
256
+ return snapshotTranscript(history, 20);
257
+ }
258
+ catch {
259
+ return null;
260
+ }
261
+ }
262
+ /**
263
+ * 跨会话搜索归档索引(episodic 层):BM25 over firstUser + summary。
264
+ * 已 purge 的会话同样可命中——meta+摘要永久保留,只是不可整段恢复。
265
+ */
266
+ export function searchArchiveIndex(sessionsRoot, query, limit = 10) {
267
+ const entries = readArchiveIndex(retentionPaths(sessionsRoot).indexPath);
268
+ if (entries.length === 0 || !query.trim())
269
+ return [];
270
+ const index = new TextSearchIndex(entries.map((e) => ({
271
+ id: e.id,
272
+ fields: [
273
+ { text: e.firstUser, weight: 3 },
274
+ { text: e.summary, weight: 1 },
275
+ ],
276
+ })));
277
+ const byId = new Map(entries.map((e) => [e.id, e]));
278
+ const hits = [];
279
+ for (const h of index.search(query, limit)) {
280
+ const e = byId.get(h.id);
281
+ if (e)
282
+ hits.push({ ...e, score: h.score });
283
+ }
284
+ return hits;
285
+ }
286
+ /** 读回一个已归档(未 purge)的完整 SessionRecord;已 purge/不存在返 null。 */
287
+ export function loadArchivedSession(sessionsRoot, id) {
288
+ const gzPath = path.join(retentionPaths(sessionsRoot).archiveDir, `${id}.json.gz`);
289
+ if (!existsSync(gzPath))
290
+ return null;
291
+ try {
292
+ return JSON.parse(gunzipSync(readFileSync(gzPath)).toString('utf8'));
293
+ }
294
+ catch {
295
+ return null;
296
+ }
297
+ }
@@ -5,6 +5,7 @@ import { chatTools } from '../llm/index.js';
5
5
  import { defaultCompactionRuntime, maybeCompact, contextState, } from './compact.js';
6
6
  import { pruneStaleArtifacts, refreshArtifactFreshness } from '../context/artifacts.js';
7
7
  import { pruneSuperseded } from '../context/relevance.js';
8
+ import { clearRetrievableResults } from '../context/clearing.js';
8
9
  import { createAgeAwareEncodingState } from '../context/age-aware.js';
9
10
  function atPressure(report) {
10
11
  return Math.max(report.rawTotal, report.total) >= report.window * DEFAULT_BUDGET_POLICY.pressureTriggerRatio;
@@ -12,11 +13,13 @@ function atPressure(report) {
12
13
  function emptyPressure(report) {
13
14
  return {
14
15
  triggered: false,
16
+ lowTriggered: false,
15
17
  before: report.total,
16
18
  after: report.total,
17
19
  superseded: 0,
18
20
  staleArtifacts: 0,
19
21
  encodedLogsAndSearches: 0,
22
+ cleared: 0,
20
23
  };
21
24
  }
22
25
  /** One scheduler instance is owned by one agent run. */
@@ -29,11 +32,14 @@ export function createBudgetScheduler(state = contextState, runtime = defaultCom
29
32
  refreshArtifactFreshness(state, history);
30
33
  const report = evaluate(history, step, activeTools, ephemeralTokens);
31
34
  const pressure = emptyPressure(report);
32
- pressure.triggered = atPressure(report);
35
+ const occupancy = Math.max(report.rawTotal, report.total);
36
+ pressure.triggered = occupancy >= report.window * DEFAULT_BUDGET_POLICY.pressureTriggerRatio;
37
+ pressure.lowTriggered = !pressure.triggered && occupancy >= report.window * runtime.config.lowPressureRatio;
33
38
  if (pressure.triggered) {
34
39
  // A single 80% pressure event owns every history rewrite. Run all enabled
35
40
  // low-cost cleanup first, then always compact; do not introduce per-stage
36
41
  // thresholds or stop early when one stage happens to cross below 80%.
42
+ // 注意:此阶段不跑 tool-clearing——旧区内容马上要喂给摘要器,清掉会把摘要弄瞎。
37
43
  if (runtime.config.contextRelprune) {
38
44
  pressure.superseded = pruneSuperseded(history, report.hotBoundary);
39
45
  }
@@ -44,12 +50,33 @@ export function createBudgetScheduler(state = contextState, runtime = defaultCom
44
50
  }
45
51
  pressure.after = evaluate(history, step, activeTools, ephemeralTokens).total;
46
52
  }
53
+ else if (pressure.lowTriggered) {
54
+ // 60-80% 低压:只做零 LLM 成本的确定性清理(masking/clearing),不摘要、不重建。
55
+ // 可重取结果在此阶段丢弃;热区(最近 4 user turn)不动。各步幂等,占用停留在
56
+ // 低压带时重复执行也很便宜。
57
+ if (runtime.config.contextRelprune) {
58
+ pressure.superseded = pruneSuperseded(history, report.hotBoundary);
59
+ }
60
+ pressure.staleArtifacts = pruneStaleArtifacts(state, history, report.hotBoundary);
61
+ if (runtime.config.toolClearing) {
62
+ pressure.cleared = clearRetrievableResults(history, report.hotBoundary);
63
+ }
64
+ if (runtime.config.contextOptimize) {
65
+ const ageAware = createAgeAwareEncodingState(history);
66
+ pressure.encodedLogsAndSearches = ageAware.sweepPressure(history, report.hotBoundary);
67
+ }
68
+ pressure.after = evaluate(history, step, activeTools, ephemeralTokens).total;
69
+ }
47
70
  // Use the trigger report intentionally: cleanup may reduce the current estimate,
48
71
  // but crossing 80% commits this step to compacting for maximum token savings.
49
- const actions = scheduleActions(report);
72
+ // 低压不进 LLM 压缩:actions 仅在 80% 高压时生成。
73
+ const actions = pressure.triggered ? scheduleActions(report) : [];
50
74
  let compactHistoryCalled = false;
51
75
  let historyRebuilt = false;
52
- let contentMutated = pressure.superseded > 0 || pressure.staleArtifacts > 0 || pressure.encodedLogsAndSearches > 0;
76
+ let contentMutated = pressure.superseded > 0 ||
77
+ pressure.staleArtifacts > 0 ||
78
+ pressure.encodedLogsAndSearches > 0 ||
79
+ pressure.cleared > 0;
53
80
  for (const _action of actions) {
54
81
  const result = await maybeCompact(history, report, undefined, state, activeTools, signal, runtime);
55
82
  compactHistoryCalled = true;
@@ -1,7 +1,10 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { AsyncLocalStorage } from 'node:async_hooks';
3
- import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
3
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
4
+ import { gzipSync } from 'node:zlib';
4
5
  import path from 'node:path';
6
+ import { MAX_TRACE_BYTES, MAX_TRACE_ROTATIONS } from '../tools/constants.js';
7
+ import { loadArchivedSession, readArchiveIndex, retentionPaths } from './retention.js';
5
8
  import { config, getActiveModel } from '../config/index.js';
6
9
  import { getWorkspaceRoot } from '../workspace/index.js';
7
10
  import { isToolRouteGroupName } from '../config/profiles.js';
@@ -110,18 +113,95 @@ export class SessionStore {
110
113
  artifactPath(id, filename) {
111
114
  return path.join(this.sessionsRoot, id, filename);
112
115
  }
116
+ /**
117
+ * 追加一条 trace 事件;当前 trace.jsonl 已达 MAX_TRACE_BYTES 时先轮转:
118
+ * 旧内容 gzip 成 trace.1.jsonl.gz,既有轮转依次后移(只留最近 MAX_TRACE_ROTATIONS 份),
119
+ * 然后清空当前文件。纯诊断数据,任何失败静默降级为普通追加。
120
+ */
113
121
  appendTrace(id, value) {
114
122
  try {
115
123
  const dir = path.join(this.sessionsRoot, id);
116
124
  mkdirSync(dir, { recursive: true });
117
125
  const line = `${JSON.stringify(value)}\n`;
118
126
  const tracePath = path.join(dir, 'trace.jsonl');
127
+ // env 覆盖仅供测试:MOCODE_MAX_TRACE_BYTES=200 可立刻触发轮转。
128
+ const envMax = Number(process.env.MOCODE_MAX_TRACE_BYTES);
129
+ const maxTraceBytes = Number.isFinite(envMax) && envMax >= 0 ? envMax : MAX_TRACE_BYTES;
130
+ const size = existsSync(tracePath) ? statSync(tracePath).size : 0;
131
+ if (size >= maxTraceBytes) {
132
+ this.rotateTrace(dir, tracePath);
133
+ }
119
134
  writeFileSync(tracePath, line, { encoding: 'utf8', flag: 'a' });
120
135
  }
121
136
  catch {
122
137
  // Observability is best-effort and cannot block coding work.
123
138
  }
124
139
  }
140
+ rotateTrace(dir, tracePath) {
141
+ const rotPath = (n) => path.join(dir, `trace.${n}.jsonl.gz`);
142
+ // 最老的一份硬删;其余从老到新依次后移,腾出 trace.1。
143
+ try {
144
+ if (existsSync(rotPath(MAX_TRACE_ROTATIONS)))
145
+ unlinkSync(rotPath(MAX_TRACE_ROTATIONS));
146
+ }
147
+ catch {
148
+ // 删失败不致命,rename 覆盖亦可。
149
+ }
150
+ for (let n = MAX_TRACE_ROTATIONS - 1; n >= 1; n--) {
151
+ const from = rotPath(n);
152
+ if (!existsSync(from))
153
+ continue;
154
+ try {
155
+ renameSync(from, rotPath(n + 1));
156
+ }
157
+ catch {
158
+ // Windows 下目标存在时 rename 可能失败:尝试删目标再移。
159
+ try {
160
+ unlinkSync(rotPath(n + 1));
161
+ renameSync(from, rotPath(n + 1));
162
+ }
163
+ catch {
164
+ // 放弃这份轮转,保留新 gz 更重要。
165
+ }
166
+ }
167
+ }
168
+ const content = readFileSync(tracePath);
169
+ writeFileSync(rotPath(1), gzipSync(content));
170
+ writeFileSync(tracePath, '');
171
+ }
172
+ /**
173
+ * 追加一条 per-step 用量记录到 <id>/usage.jsonl(#token-efficiency P1)。
174
+ * 与 appendTrace 同策略:纯诊断,失败静默,不做轮转(长会话的量级远小于 trace)。
175
+ */
176
+ appendUsage(id, value) {
177
+ try {
178
+ const dir = path.join(this.sessionsRoot, id);
179
+ mkdirSync(dir, { recursive: true });
180
+ writeFileSync(path.join(dir, 'usage.jsonl'), `${JSON.stringify(value)}\n`, { encoding: 'utf8', flag: 'a' });
181
+ }
182
+ catch {
183
+ // Metrics must never block coding work.
184
+ }
185
+ }
186
+ /** 读取 <id>/usage.jsonl 全部记录;文件不存在或损坏行跳过。 */
187
+ readUsage(id) {
188
+ const file = path.join(this.sessionsRoot, id, 'usage.jsonl');
189
+ if (!existsSync(file))
190
+ return [];
191
+ const out = [];
192
+ for (const line of readFileSync(file, 'utf8').split('\n')) {
193
+ const trimmed = line.trim();
194
+ if (!trimmed)
195
+ continue;
196
+ try {
197
+ out.push(JSON.parse(trimmed));
198
+ }
199
+ catch {
200
+ // 跳过损坏行。
201
+ }
202
+ }
203
+ return out;
204
+ }
125
205
  save(history, id, queryHistory = [], lastToolGroups = []) {
126
206
  const meta = {
127
207
  id,
@@ -143,7 +223,11 @@ export class SessionStore {
143
223
  queryHistory: [...queryHistory],
144
224
  lastToolGroups: [...lastToolGroups],
145
225
  };
146
- writeFileSync(currentPath, JSON.stringify(record), 'utf8');
226
+ // 原子落盘:先写同目录 tmp 再 rename——全量重写(长会话可达数 MB)中途崩溃/断电
227
+ // 不会留下半个 JSON 把整个会话写坏(对齐 memory/store.ts writeAtomic)。
228
+ const tmpPath = path.join(this.sessionsRoot, id, 'session.json.tmp');
229
+ writeFileSync(tmpPath, JSON.stringify(record), 'utf8');
230
+ renameSync(tmpPath, currentPath);
147
231
  if (existsSync(legacyPath))
148
232
  unlinkSync(legacyPath);
149
233
  return meta;
@@ -151,9 +235,12 @@ export class SessionStore {
151
235
  load(id) {
152
236
  const currentPath = this.sessionPath(id);
153
237
  const legacyPath = path.join(this.sessionsRoot, `${id}.json`);
154
- const source = existsSync(currentPath) ? currentPath : legacyPath;
155
- if (!existsSync(source))
156
- return null;
238
+ let source = existsSync(currentPath) ? currentPath : legacyPath;
239
+ // 活会话与 legacy 单文件都没有:回退从归档 gz 取回(30-90 天的会话仍可 resume)。
240
+ if (!existsSync(source)) {
241
+ const archived = loadArchivedSession(this.sessionsRoot, id);
242
+ return archived ?? null;
243
+ }
157
244
  try {
158
245
  const rec = JSON.parse(readFileSync(source, 'utf8'));
159
246
  if (!rec || !Array.isArray(rec.history))
@@ -211,7 +298,22 @@ export class SessionStore {
211
298
  // 跳过损坏文件。
212
299
  }
213
300
  }
214
- return out;
301
+ // 归档(未 purge)会话并入列表:正文在 archive/<id>.json.gz,仍可按 id resume。
302
+ // 与 live 去重(理论上不会重叠),按时间统一降序后再截 limit。
303
+ const liveIds = new Set(out.map((m) => m.id));
304
+ for (const e of readArchiveIndex(retentionPaths(root).indexPath)) {
305
+ if (e.purgedAt || liveIds.has(e.id))
306
+ continue;
307
+ out.push({
308
+ id: e.id,
309
+ createdAt: e.createdAt ?? idToIso(e.id),
310
+ model: e.model ?? '',
311
+ firstUser: e.firstUser ?? '',
312
+ archived: true,
313
+ });
314
+ }
315
+ out.sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0));
316
+ return Number.isFinite(maxResults) ? out.slice(0, maxResults) : out;
215
317
  }
216
318
  }
217
319
  /** 旧函数 API 的进程级兼容实例;session root 每次读取 config,保留测试和运行时切换语义。 */