mocode-ai 1.7.10 → 1.7.11

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.
@@ -13,7 +13,8 @@ import path from 'node:path';
13
13
  import { chat } from '../llm/index.js';
14
14
  import { config, isMemoryEnabled } from '../config/index.js';
15
15
  import { saveEntry, updateEntry, forgetEntry, loadAll, gcMemories } from './store.js';
16
- import { addTriple } from './graph.js';
16
+ import { addTriple, ensurePrimaryPerson, PRIMARY_PERSON_ID } from './graph.js';
17
+ import { normalizeRelation, isKnownRelation, relationCheatSheet } from './relation-vocab.js';
17
18
  // ── 日志(静默容错,裁尾保最近)─────────────────────────────────────────────
18
19
  function logPath() {
19
20
  return path.join(process.cwd(), '.mocode', 'memory.log');
@@ -91,7 +92,7 @@ function buildMemorySample() {
91
92
  }
92
93
  const TYPES = 'decision | fact | pitfall | reference | feedback';
93
94
  const REFLECT_SYS = `You are mocode's memory reflector. Review the recent session and existing memories, producing **only** updates worth remembering long-term.
94
- Output strictly JSON (no markdown code blocks, no explanatory text): {"saves":[{"type":"...","name":"...","summary":"...","body":"..."}],"updates":[{"id":"...","reason":"...","summary":"...","body":"..."}],"forgets":[{"id":"...","reason":"..."}],"triples":[{"src":"...","relation":"...","dst":"...","fact":"..."}]}
95
+ Output strictly JSON (no markdown code blocks, no explanatory text): {"saves":[{"type":"...","name":"...","summary":"...","body":"..."}],"updates":[{"id":"...","reason":"...","summary":"...","body":"..."}],"forgets":[{"id":"...","reason":"..."}],"triples":[{"src":"...","relation":"...","dst":"...","fact":"..."}],"profile":[{"relation":"...","dst":"...","fact":"..."}]}
95
96
  Empty arrays are valid (if nothing is worth saving, all arrays are empty).
96
97
  Rules:
97
98
  ① Only store non-obvious, cross-session-useful facts/decisions/pitfalls; do not store current bugs, temp files, undecided TODOs, or volatile items;
@@ -100,16 +101,17 @@ Rules:
100
101
  ④ names in saves must be concise and not collide with existing ones; type ∈ {${TYPES}};
101
102
  ⑤ If an existing memory contradicts new facts or is outdated, update the old entry (modify summary/body) rather than creating a duplicate;
102
103
  ⑥ forgets are for memories clearly stale / superseded by a new entry (archive, not hard-delete);
103
- ⑦ triples are knowledge-graph facts distilled from this session: concise entity names (lowercase snake_case or proper nouns), relation in snake_case (e.g. depends_on, decided_by, implemented_in, conflicts_with), plus a one-line fact. Only emit triples that are stable, non-obvious and cross-session-useful (2-6 at most); they may reference entities from saves/updates or existing memories.`;
104
+ ⑦ triples are PROJECT knowledge-graph facts about THIS CODEBASE distilled from this session: concise entity names (lowercase snake_case or proper nouns) plus a one-line fact. Prioritize the high-reuse structural relations in this order — dependency/ownership (depends_on, uses, used_by, calls, part_of), location (implemented_in, reads, writes, configured_by, runs_on, listens_on), verification (tests, documented_in), rationale/risk (decided_by, conflicts_with); full list: ${relationCheatSheet('project')}. Favor facts that would help a future session navigate or avoid re-deriving (which module implements X, what depends on Y, what conflicts with Z). A relation outside this list is allowed only when none fits (it gets flagged for review). Only emit triples that are stable, non-obvious and reusable in this repo (2-6 at most).
105
+ ⑧ profile is for facts about THE USER (the human), which must apply across ALL projects — identity, role, expertise, language/communication style, stable preferences, current goals, constraints. They are attached to the single canonical person entity. Each item: dst is a short atomic value/entity, fact is one neutral Chinese sentence stating it. Prefer these relations: ${relationCheatSheet('global')}. A relation outside this list is allowed only when none fits. NEVER put repo paths, commands, file names, ports, secrets or one-task details in profile — those belong in triples. Only emit profile when the user explicitly revealed something durable about themselves (0-3 items); when unsure, omit.`;
104
106
  const REFLECT_USER = (transcript, sample) => `## Recent session\n${transcript}\n\n## Existing memories\n${sample}\n\nProduce JSON:`;
105
- function parsePlan(content) {
107
+ export function parsePlan(content) {
106
108
  if (!content)
107
109
  return null;
108
110
  let s = content.trim();
109
- // 去 ```json … ``` 代码块(模型偶发包裹)
110
- const fence = s.match(/```(?:json)?\s*([\s\S]*?)```/i);
111
- if (fence)
112
- s = fence[1].trim();
111
+ // 去 ```json … ``` 代码块(模型偶发包裹);取最后一个块(前面可能有解释文字)。
112
+ const fences = [...s.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi)];
113
+ if (fences.length)
114
+ s = fences[fences.length - 1][1].trim();
113
115
  // 容错:截到首个 { 到末个 }
114
116
  const first = s.indexOf('{');
115
117
  const last = s.lastIndexOf('}');
@@ -122,12 +124,40 @@ function parsePlan(content) {
122
124
  return p;
123
125
  }
124
126
  catch {
127
+ // 再试一次:去掉对象/数组尾逗号(模型常见的非法 JSON)。
128
+ try {
129
+ const p = JSON.parse(s.replace(/,\s*([}\]])/g, '$1'));
130
+ if (p && typeof p === 'object')
131
+ return p;
132
+ }
133
+ catch {
134
+ // 落到 null
135
+ }
125
136
  return null;
126
137
  }
127
138
  }
139
+ /** 取一次反思 LLM 返回的可解析文本(content 优先;tool_calls 的 JSON 串参数兜底)。 */
140
+ async function fetchReflection(messages, signal) {
141
+ const r = await chat(messages, {}, signal, undefined, { reasoningEffort: 'low' });
142
+ if (!r.toolCalls.length && r.content)
143
+ return { content: r.content, raw: null };
144
+ // 推理模型偶发只把 JSON 塞进 tool_calls 参数、或 content 为 null:从工具调用里捞 JSON 串。
145
+ for (const tc of r.toolCalls) {
146
+ const args = tc.function?.arguments;
147
+ if (typeof args === 'string' && args.includes('{'))
148
+ return { content: args, raw: null };
149
+ }
150
+ // 无可用正文(典型:只吐 reasoning_content):保留简短指纹供日志排查。
151
+ const fingerprint = r.content
152
+ ? `content=${r.content.slice(0, 200)}`
153
+ : `content=null, toolCalls=${r.toolCalls.length}`;
154
+ return { content: null, raw: fingerprint };
155
+ }
128
156
  /**
129
157
  * 跑一次反思:chat() 空 handlers(静默)→ 解析 JSON → 经 store 落地 → gcMemories。
130
- * 解析失败整 pass 放弃(不部分落地)。store 调用全同步,落地是一个原子块。
158
+ * 首轮解析失败时做**一次修复式重试**(明确要求"只输出 JSON、不要思考过程"),
159
+ * 重试仍失败才报 parse-failed——此时 rawParseFailure 带原始返回指纹,由 kickoff 落
160
+ * memory.log,不再像旧版那样无声丢掉一整轮且无从排查。store 调用全同步。
131
161
  * 60s 超时(AbortSignal.timeout)防 exit 时 drain 挂死。
132
162
  */
133
163
  export async function runReflection(transcript, signal) {
@@ -135,7 +165,7 @@ export async function runReflection(transcript, signal) {
135
165
  const sample = buildMemorySample();
136
166
  const sys = { role: 'system', content: REFLECT_SYS };
137
167
  const user = { role: 'user', content: REFLECT_USER(transcript, sample) };
138
- let result = {
168
+ const result = {
139
169
  ts,
140
170
  saves: 0,
141
171
  updates: 0,
@@ -145,12 +175,27 @@ export async function runReflection(transcript, signal) {
145
175
  gcCapped: 0,
146
176
  gcGced: 0,
147
177
  };
178
+ const timeoutSignal = signal ?? AbortSignal.timeout(60000);
148
179
  let content = null;
149
180
  try {
150
- const r = await chat([sys, user], {}, signal ?? AbortSignal.timeout(60000), undefined, { reasoningEffort: 'low' });
151
- // 推理模型偶发只返 reasoning_content(content null)或幻觉 tool_calls → 视为无产出
152
- if (!r.toolCalls.length && r.content)
153
- content = r.content;
181
+ const first = await fetchReflection([sys, user], timeoutSignal);
182
+ content = first.content;
183
+ // 首轮拿不到可解析正文 → 一次修复式重试(同一超时信号的剩余预算)。
184
+ if (parsePlan(content) === null) {
185
+ result.repairAttempted = true;
186
+ const repairUser = {
187
+ role: 'user',
188
+ content: '你的上一条回复无法被解析为目标 JSON。现在只输出一个 JSON 对象,不要 markdown 围栏、不要解释、不要思考过程,' +
189
+ '形状严格为 {"saves":[],"updates":[],"forgets":[],"triples":[]};没有值得长期记住的内容就全部给空数组。',
190
+ };
191
+ const second = await fetchReflection([sys, user, { role: 'assistant', content: content ?? '' }, repairUser], timeoutSignal);
192
+ if (second.content)
193
+ content = second.content;
194
+ if (!content && first.raw)
195
+ result.rawParseFailure = first.raw;
196
+ else if (second.raw && parsePlan(content) === null)
197
+ result.rawParseFailure = second.raw;
198
+ }
154
199
  }
155
200
  catch (e) {
156
201
  result.error = e instanceof Error ? e.name + ': ' + e.message : String(e);
@@ -159,6 +204,8 @@ export async function runReflection(transcript, signal) {
159
204
  const plan = parsePlan(content);
160
205
  if (!plan) {
161
206
  result.error = 'parse-failed';
207
+ if (!result.rawParseFailure)
208
+ result.rawParseFailure = (content ?? '').slice(0, 300);
162
209
  return result;
163
210
  }
164
211
  let saves = 0;
@@ -201,14 +248,19 @@ export async function runReflection(transcript, signal) {
201
248
  }
202
249
  }
203
250
  // 知识图谱三元组:容错——单项失败跳过,不影响 saves/updates/forgets 已落地的结果。
251
+ // 关系名先归一(别名/拼写);词表外关系仍允许写(软封闭世界),但计 relViolations 供观测。
204
252
  let triples = 0;
253
+ let relViolations = 0;
205
254
  if (Array.isArray(plan.triples)) {
206
255
  for (const t of plan.triples.slice(0, 10)) {
207
256
  if (!t?.src || !t?.relation || !t?.dst)
208
257
  continue;
258
+ const relation = normalizeRelation(String(t.relation));
259
+ if (!isKnownRelation(relation, 'project'))
260
+ relViolations++;
209
261
  const r = addTriple({
210
262
  src: String(t.src),
211
- relation: String(t.relation),
263
+ relation,
212
264
  dst: String(t.dst),
213
265
  fact: t.fact ? String(t.fact) : undefined,
214
266
  });
@@ -216,8 +268,43 @@ export async function runReflection(transcript, signal) {
216
268
  triples++;
217
269
  }
218
270
  }
271
+ // 全局用户画像:挂到规范 person-primary、写 global 图(与项目 triples 分流)。
272
+ let profiles = 0;
273
+ if (Array.isArray(plan.profile) && plan.profile.length > 0) {
274
+ let personId = PRIMARY_PERSON_ID;
275
+ try {
276
+ personId = ensurePrimaryPerson().id;
277
+ }
278
+ catch {
279
+ personId = PRIMARY_PERSON_ID;
280
+ }
281
+ for (const p of plan.profile.slice(0, 6)) {
282
+ if (!p?.relation || !p.dst)
283
+ continue;
284
+ const relation = normalizeRelation(String(p.relation));
285
+ if (!isKnownRelation(relation, 'global'))
286
+ relViolations++;
287
+ const r = addTriple({
288
+ src: personId,
289
+ relation,
290
+ dst: String(p.dst),
291
+ fact: p.fact ? String(p.fact) : undefined,
292
+ scope: 'global',
293
+ });
294
+ if (r.ok && !r.duplicate)
295
+ profiles++;
296
+ }
297
+ }
219
298
  const gc = gcMemories();
220
- result = { ...result, saves, updates, forgets, triples, gcDecayed: gc.decayed, gcCapped: gc.capped, gcGced: gc.gced };
299
+ result.saves = saves;
300
+ result.updates = updates;
301
+ result.forgets = forgets;
302
+ result.triples = triples;
303
+ result.profiles = profiles;
304
+ result.relViolations = relViolations;
305
+ result.gcDecayed = gc.decayed;
306
+ result.gcCapped = gc.capped;
307
+ result.gcGced = gc.gced;
221
308
  return result;
222
309
  }
223
310
  function normalizeType(t) {
@@ -231,9 +318,13 @@ function normalizeType(t) {
231
318
  // ── 后台编排:kickoff / drain / 缓存 ─────────────────────────────────────────
232
319
  let inflight = null;
233
320
  let lastReflectResult = null;
234
- /** 摘要串(供 repl flush):存N 改N 忘N 图N;有错误附上。 */
321
+ /** 摘要串(供 repl flush):存N 改N 忘N 图N [画像N 违N];有错误附上。 */
235
322
  export function formatReflectResult(r) {
236
323
  const parts = [`存${r.saves}`, `改${r.updates}`, `忘${r.forgets}`, `图${r.triples}`];
324
+ if (r.profiles)
325
+ parts.push(`画像${r.profiles}`);
326
+ if (r.relViolations)
327
+ parts.push(`违${r.relViolations}`);
237
328
  if (r.gcDecayed || r.gcCapped || r.gcGced) {
238
329
  parts.push(`遗忘(衰减${r.gcDecayed}/封顶${r.gcCapped}/清除${r.gcGced})`);
239
330
  }
@@ -256,6 +347,10 @@ export function kickoffReflection(transcript) {
256
347
  const r = await runReflection(transcript);
257
348
  lastReflectResult = r;
258
349
  appendLog(`[${r.ts}] ${formatReflectResult(r)}`);
350
+ if (r.error === 'parse-failed' && r.rawParseFailure) {
351
+ // 留证:旧版 parse-failed 无声丢弃,无法排查(实测约三成反思命中)。
352
+ appendLog(` ↳ parse-failed 原始返回: ${r.rawParseFailure.replace(/\s+/g, ' ').slice(0, 500)}`);
353
+ }
259
354
  }
260
355
  catch (e) {
261
356
  appendLog(`[${new Date().toISOString()}] 反思异常: ${e instanceof Error ? e.message : String(e)}`);
@@ -0,0 +1,84 @@
1
+ // relation-vocab.ts —— 受控关系词表(轻量,不含实体类型/domain-range)。
2
+ //
3
+ // 这是 memory-2.2 本体方案里"软封闭世界"的最小落地:只约束**关系名**,不引入实体类型
4
+ // 体系或推理。反思抽取 triples/profile 时优先用词表内关系;词表外关系**仍允许写入**
5
+ // (软策略,不阻断长尾),但会被计数为 relViolation 供观测。等真实数据证明需要,再升级
6
+ // 为完整 ontology.ts(实体 type + domain/range + 逆/传递)。
7
+ //
8
+ // 纯常量 + 纯函数,叶子模块,无 IO。
9
+ /** 项目轨(懂仓库)首选关系:高复用、可结构化的连接。 */
10
+ export const PROJECT_RELATIONS = [
11
+ 'depends_on',
12
+ 'uses',
13
+ 'used_by',
14
+ 'calls',
15
+ 'part_of',
16
+ 'implemented_in',
17
+ 'reads',
18
+ 'writes',
19
+ 'configured_by',
20
+ 'runs_on',
21
+ 'listens_on',
22
+ 'tests',
23
+ 'documented_in',
24
+ 'decided_by',
25
+ 'conflicts_with',
26
+ 'related_to',
27
+ ];
28
+ /** 用户轨(懂用户)画像关系:挂 person-primary。 */
29
+ export const PROFILE_RELATIONS = [
30
+ 'named',
31
+ 'has_role',
32
+ 'works_at',
33
+ 'located_in',
34
+ 'speaks',
35
+ 'verbosity',
36
+ 'communication_style',
37
+ 'prefers',
38
+ 'avoid',
39
+ 'avoids',
40
+ 'current_goal',
41
+ 'constraint',
42
+ 'has_hobby',
43
+ 'skill_level',
44
+ 'related_to',
45
+ ];
46
+ /** 常见同义/拼写漂移 → 规范关系(反思落库前归范)。 */
47
+ export const RELATION_ALIASES = {
48
+ employed_by: 'works_at',
49
+ employer: 'works_at',
50
+ lives_in: 'located_in',
51
+ based_in: 'located_in',
52
+ location: 'located_in',
53
+ hobby: 'has_hobby',
54
+ role: 'has_role',
55
+ is_a: 'related_to',
56
+ uses_port: 'listens_on',
57
+ port: 'listens_on',
58
+ depends: 'depends_on',
59
+ dependency: 'depends_on',
60
+ implements: 'implemented_in',
61
+ impl: 'implemented_in',
62
+ decides: 'decided_by',
63
+ conflict: 'conflicts_with',
64
+ configure: 'configured_by',
65
+ };
66
+ const PROJECT_SET = new Set(PROJECT_RELATIONS);
67
+ const PROFILE_SET = new Set(PROFILE_RELATIONS);
68
+ /** 关系名归一:lowercase + 空白转下划线 + 别名映射。 */
69
+ export function normalizeRelation(raw) {
70
+ const base = String(raw ?? '')
71
+ .trim()
72
+ .toLowerCase()
73
+ .replace(/\s+/g, '_');
74
+ return RELATION_ALIASES[base] ?? base;
75
+ }
76
+ /** 关系是否在对应 scope 的首选词表内(先归一再判定)。 */
77
+ export function isKnownRelation(relation, scope) {
78
+ const r = normalizeRelation(relation);
79
+ return scope === 'global' ? PROFILE_SET.has(r) : PROJECT_SET.has(r);
80
+ }
81
+ /** 注入反思提示词的紧凑词表(按 scope)。 */
82
+ export function relationCheatSheet(scope) {
83
+ return scope === 'global' ? PROFILE_RELATIONS.join(', ') : PROJECT_RELATIONS.join(', ');
84
+ }
@@ -24,7 +24,7 @@ import { renderChip } from '../attachments/image.js';
24
24
  import { contextState, appendCurrentSessionRuntimeEvent } from '../session/index.js';
25
25
  import { effectiveSystemPrompt } from '../skills/index.js';
26
26
  import { clearSkillActivation } from '../skills/activation.js';
27
- import { buildMemoryIndexSection, kickoffReflection, drainMemoryBackground, getLastReflectResult, clearLastReflectResult, snapshotTranscript, formatReflectResult, } from '../memory/index.js';
27
+ import { buildMemoryIndexSection, buildAboutYouSection, buildProjectGraphHint, initPersona, kickoffReflection, drainMemoryBackground, getLastReflectResult, clearLastReflectResult, snapshotTranscript, formatReflectResult, } from '../memory/index.js';
28
28
  import { collectQueryHistory } from '../session/query-history.js';
29
29
  import { ToolPolicyController } from '../tools/policy.js';
30
30
  import { routeToolGroups } from '../tools/router.js';
@@ -198,9 +198,18 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
198
198
  // 与开关联动:① base 用 buildBasePrompt() 取代 config.systemPrompt(后者是启动时一次性
199
199
  // 求值的常量,运行时 /memory_switch 不会刷新);② plan suffix 走 getPlanModeSuffix() 现拼;
200
200
  // ③ AGENTS.md 存在工作区根时由 base 无条件自动导入正文(超长截断,与 memory 开关无关);④ Memory Index 按开关注入。
201
- const buildSystemMessage = (planMode) => effectiveSystemPrompt(buildBasePrompt(currentSessionId) +
202
- (planMode ? getPlanModeSuffix() : '') +
203
- buildMemoryIndexSection(isMemoryEnabled()));
201
+ // 启动期一次性身份归一(幂等,best-effort):把全局图里的用户指称碎片并入
202
+ // person-primary,随后 About you 段才能渲染出完整画像。
203
+ initPersona(isMemoryEnabled());
204
+ // 开关现读:运行时 /memory_switch 重建 history[0] 时,About you 与 Memory Index 同步显隐。
205
+ const buildSystemMessage = (planMode) => {
206
+ const memoryOn = isMemoryEnabled();
207
+ return effectiveSystemPrompt(buildBasePrompt(currentSessionId) +
208
+ buildAboutYouSection(memoryOn) +
209
+ (planMode ? getPlanModeSuffix() : '') +
210
+ buildMemoryIndexSection(memoryOn) +
211
+ buildProjectGraphHint(memoryOn));
212
+ };
204
213
  // 有预加载(--resume)则用它,并把 history[0] 刷成当前 system prompt(config 可能已变);
205
214
  // 否则新会话只塞 system 提示(默认 auto)。
206
215
  const history = initialHistory && initialHistory.length ? initialHistory : [{ role: 'system', content: buildSystemMessage(false) }];
@@ -274,7 +274,7 @@ export class Runtime {
274
274
  }
275
275
  resumeSession(id) {
276
276
  this.assertIdle('resume session');
277
- const loaded = this.context.sessionStore.load(id);
277
+ const loaded = this.context.sessionStore.load(id, { restoreAssets: true });
278
278
  if (!loaded?.history.length)
279
279
  return null;
280
280
  this.context.sessionStore.setCurrentSessionId(loaded.id);
@@ -0,0 +1,232 @@
1
+ // compact-archive.ts — 压缩旧区原文的归档渲染器。
2
+ //
3
+ // 目的:compactHistory 成功摘要后旧区消息会被整体丢弃(连 session.json 也被
4
+ // 压缩后的 history 覆盖)。这里把旧区渲染成 grep 友好的 Markdown 段文件,落盘到
5
+ // `<session>/compacted/seg-NNN.md`,模型事后可用现有 grep/read_file 回捞精确原文,
6
+ // 不新增任何工具。
7
+ //
8
+ // 设计约束:
9
+ // - 纯函数,不碰文件系统(落盘/编号/淘汰在 SessionStore)。
10
+ // - 每个 seg 文件首行是单行 HTML 注释元数据(自描述,index.md 可随时重建)。
11
+ // - 图片不归档(base64 无检索价值且体积爆炸),只留占位,与 stripImagesForSummary 同口径。
12
+ // - 消息按 history 原时序;turn 序号按非空 user 消息递增,方便模型按回合定位。
13
+ /** 压缩归档子目录名(相对会话目录)。 */
14
+ export const COMPACTED_DIRNAME = 'compacted';
15
+ /** 段索引文件名。 */
16
+ export const ARCHIVE_INDEX_FILE = 'index.md';
17
+ /** seg 文件名:seg-001.md … seg-999.md(超出也不报错,只是不补零)。 */
18
+ export function segmentFileName(seq) {
19
+ return `seg-${String(seq).padStart(3, '0')}.md`;
20
+ }
21
+ export const SEGMENT_FILE_RE = /^seg-(\d+)\.md$/;
22
+ /** push-time 被 capToolResultForHistory 截断的工具完整结果,按 tool_call_id 单存。 */
23
+ export function resultBlobFileName(toolCallId) {
24
+ // call id 理论上安全(toolu_xxx / call-N),仍做一次白名单清洗防路径穿越。
25
+ const safe = toolCallId.replace(/[^A-Za-z0-9_.-]/g, '_').slice(0, 120) || 'unknown';
26
+ return `result-${safe}.txt`;
27
+ }
28
+ export const RESULT_BLOB_FILE_RE = /^result-.+\.txt$/;
29
+ /** retention 归档时打包进 gz 的 compacted/ 资产白名单(其余临时文件不带走)。 */
30
+ export function isCompactedAssetName(name) {
31
+ return SEGMENT_FILE_RE.test(name) || RESULT_BLOB_FILE_RE.test(name) || name === ARCHIVE_INDEX_FILE;
32
+ }
33
+ function asToolCalls(m) {
34
+ const tcs = m.tool_calls;
35
+ if (!Array.isArray(tcs))
36
+ return [];
37
+ return tcs;
38
+ }
39
+ /** 多模态 content 拍平:text 拼接;image_url 留占位;其余形状 JSON 化(与摘要器同口径)。 */
40
+ function messageText(content) {
41
+ if (content == null)
42
+ return '';
43
+ if (typeof content === 'string')
44
+ return content;
45
+ if (Array.isArray(content)) {
46
+ const parts = [];
47
+ for (const p of content) {
48
+ if (p && typeof p === 'object') {
49
+ const part = p;
50
+ if (part.type === 'text')
51
+ parts.push(part.text ?? '');
52
+ else if (part.type === 'image_url') {
53
+ const url = part.image_url?.url ?? '';
54
+ const mime = url.startsWith('data:') ? url.slice(5, url.indexOf(';')) : 'image';
55
+ parts.push(`[图片已剥离: ${mime}]`);
56
+ }
57
+ else {
58
+ parts.push(`[${part.type ?? 'content'} part]`);
59
+ }
60
+ }
61
+ else {
62
+ parts.push(String(p ?? ''));
63
+ }
64
+ }
65
+ return parts.join('');
66
+ }
67
+ try {
68
+ return JSON.stringify(content);
69
+ }
70
+ catch {
71
+ return String(content);
72
+ }
73
+ }
74
+ const PATH_KEYS = new Set(['path', 'file', 'filePath', 'filename', 'dir', 'directory']);
75
+ const MAX_META_FILES = 8;
76
+ const MAX_FIRST_USER_CHARS = 60;
77
+ function collectFilesFromArgs(argsRaw, sink) {
78
+ if (!argsRaw || sink.length >= MAX_META_FILES)
79
+ return;
80
+ let parsed;
81
+ try {
82
+ parsed = JSON.parse(argsRaw);
83
+ }
84
+ catch {
85
+ return;
86
+ }
87
+ if (!parsed || typeof parsed !== 'object')
88
+ return;
89
+ for (const [k, v] of Object.entries(parsed)) {
90
+ if (typeof v !== 'string' || !PATH_KEYS.has(k))
91
+ continue;
92
+ const norm = v.replace(/\\/g, '/').replace(/^\.\//, '');
93
+ if (norm && !sink.includes(norm))
94
+ sink.push(norm);
95
+ if (sink.length >= MAX_META_FILES)
96
+ break;
97
+ }
98
+ }
99
+ function oneLine(text, max) {
100
+ const line = text.replace(/\s+/g, ' ').trim();
101
+ return line.length > max ? `${line.slice(0, max)}…` : line;
102
+ }
103
+ /**
104
+ * 把一段旧区消息渲染成 Markdown 段。输出同时包含文件全文与元数据,
105
+ * 调用方负责把返回的 { markdown, meta } 一起落盘。
106
+ */
107
+ export function renderCompactedSegment(seq, messages, archivedAt = new Date().toISOString()) {
108
+ const file = segmentFileName(seq);
109
+ const tools = [];
110
+ const files = [];
111
+ let turns = 0;
112
+ let firstUser = '';
113
+ // tool_call_id → tool 名(tool 消息自身不带工具名,靠前导 assistant 配对)。
114
+ const callNames = new Map();
115
+ for (const m of messages) {
116
+ if (m.role !== 'assistant')
117
+ continue;
118
+ for (const tc of asToolCalls(m)) {
119
+ const name = tc.function?.name;
120
+ if (tc.id && name)
121
+ callNames.set(tc.id, name);
122
+ if (name && !tools.includes(name))
123
+ tools.push(name);
124
+ collectFilesFromArgs(tc.function?.arguments, files);
125
+ }
126
+ }
127
+ const blocks = [];
128
+ for (const m of messages) {
129
+ if (m.role === 'user') {
130
+ const text = messageText(m.content);
131
+ if (text.trim()) {
132
+ turns++;
133
+ if (!firstUser)
134
+ firstUser = oneLine(text, MAX_FIRST_USER_CHARS);
135
+ }
136
+ blocks.push(`### [turn ${turns}] user\n${text.trim()}`);
137
+ continue;
138
+ }
139
+ if (m.role === 'system') {
140
+ // 上一代摘要的叙述段(pin 除外):内容会随本代重新压缩,归档里标记出处。
141
+ blocks.push(`### [prior-summary] system\n${messageText(m.content).trim()}`);
142
+ continue;
143
+ }
144
+ if (m.role === 'tool') {
145
+ const id = m.tool_call_id ?? '';
146
+ const name = (id && callNames.get(id)) || '';
147
+ const label = [`### [turn ${turns}] tool_result`, id && `call=${id}`, name && `tool=${name}`]
148
+ .filter(Boolean)
149
+ .join(' ');
150
+ blocks.push(`${label}\n${messageText(m.content).trim()}`);
151
+ continue;
152
+ }
153
+ // assistant:正文一个块,每个 tool_call 一个块(参数是 grep 的高频目标,独立成块)。
154
+ const text = messageText(m.content).trim();
155
+ if (text)
156
+ blocks.push(`### [turn ${turns}] assistant\n${text}`);
157
+ for (const tc of asToolCalls(m)) {
158
+ const name = tc.function?.name;
159
+ const label = [`### [turn ${turns}] assistant call`, tc.id && `call=${tc.id}`, name && `tool=${name}`]
160
+ .filter(Boolean)
161
+ .join(' ');
162
+ const args = (tc.function?.arguments ?? '').trim();
163
+ blocks.push(`${label}\n${args ? `args: ${args}` : '(no args)'}`);
164
+ }
165
+ }
166
+ const body = blocks.join('\n\n') + '\n';
167
+ const baseMeta = {
168
+ seq,
169
+ file,
170
+ archivedAt,
171
+ messages: messages.length,
172
+ turns,
173
+ tools,
174
+ files,
175
+ firstUser,
176
+ };
177
+ const heading = `# 已压缩历史段 ${file}\n\n`;
178
+ // chars 是淘汰策略的依据,需含 header 自身:先估后定(header 只差 chars 一个数字位)。
179
+ const measure = (chars) => `<!-- mocode-archive ${JSON.stringify({ ...baseMeta, chars })} -->\n\n`;
180
+ let chars = measure(0).length + heading.length + body.length;
181
+ let header = measure(chars);
182
+ if (header.length !== measure(0).length) {
183
+ // 数字位数变化(9→10)的罕见情况,再定标一次即收敛。
184
+ chars = header.length + heading.length + body.length;
185
+ header = measure(chars);
186
+ }
187
+ const meta = { ...baseMeta, chars };
188
+ return { markdown: `${header}${heading}${body}`, meta };
189
+ }
190
+ /** 从 seg 文件内容解析首行元数据;损坏/旧格式返 null。 */
191
+ export function parseSegmentMeta(markdown) {
192
+ const nl = markdown.indexOf('\n');
193
+ const first = (nl >= 0 ? markdown.slice(0, nl) : markdown).trim();
194
+ const m = /^<!-- mocode-archive (\{.*\}) -->$/.exec(first);
195
+ if (!m)
196
+ return null;
197
+ try {
198
+ const meta = JSON.parse(m[1]);
199
+ if (typeof meta.seq === 'number' && typeof meta.file === 'string')
200
+ return meta;
201
+ return null;
202
+ }
203
+ catch {
204
+ return null;
205
+ }
206
+ }
207
+ /**
208
+ * 由全部段元数据重建 index.md。模型读它决定去哪段 grep;确定性输出,
209
+ * 不经过摘要器(避免漏写)。relativeDir 是相对 cwd 的 POSIX 路径,
210
+ * 直接写给模型/grep 用。
211
+ */
212
+ export function renderArchiveIndex(metas, relativeDir) {
213
+ const sorted = [...metas].sort((a, b) => a.seq - b.seq);
214
+ const lines = [
215
+ '# 已压缩历史索引(compacted history index)',
216
+ '',
217
+ '本目录是历次上下文压缩前的原始消息(用户原话、assistant 决策、工具参数与完整结果)。',
218
+ `路径:\`${relativeDir}/\``,
219
+ '需要精确原文(报错、命令/测试输出、旧决策)时,不要凭摘要猜也不要急着重跑:',
220
+ `1. 用 grep 搜索并把 glob 限定在 \`${relativeDir}/**\`(.mocode 在 .gitignore 里,必须 glob 限定才搜得到);`,
221
+ '2. 按命中行号用 read_file 的 offset/limit 精读对应 seg 文件。',
222
+ '',
223
+ ];
224
+ for (const m of sorted) {
225
+ const tools = m.tools.length ? m.tools.join(', ') : '-';
226
+ const files = m.files.length ? m.files.join(', ') : '-';
227
+ const first = m.firstUser ? m.firstUser.replace(/\|/g, '/') : '(无 user 消息)';
228
+ lines.push(`- [${m.file}](${m.file}) · ${m.archivedAt} · ${m.turns} turns · ${m.messages} msgs · 工具 ${tools} · 文件 ${files} · 首句:${first}`);
229
+ }
230
+ lines.push('');
231
+ return lines.join('\n');
232
+ }
@@ -160,6 +160,8 @@ const ANY_HEADING_RE = /^ {0,3}##\s+\S/;
160
160
  const PRIOR_NARRATIVE_LABEL = '[prior summary — 上一代摘要的叙述部分:与下方新内容合并压缩,不要重复罗列细节]';
161
161
  /** 尾部 provenance 块:解析时剥掉,重建时按当次 older 重算(否则跨代累积并重复)。 */
162
162
  const ARTIFACT_REFS_RE = /\n*\[artifact refs:[^\]]*\]\s*$/;
163
+ /** 尾部归档指针:同理按当次压缩重算(指向最新 seg),跨代不累积进叙述/Key Facts。 */
164
+ const ARCHIVED_REFS_RE = /\n*\[archived:[^\]]*\]\s*$/;
163
165
  /** Key Facts 累计预算(字符):窗口比例换算 + 上下限——小窗口留得住初始约束,
164
166
  * 大窗口也不会让索引段膨胀成正文。 */
165
167
  export function keyFactsBudgetChars(window) {
@@ -172,7 +174,7 @@ export function keyFactsBudgetChars(window) {
172
174
  * older 重算,不该跨代累积。无 Key Facts 段时 rest 为全文、keyFacts 为空。
173
175
  */
174
176
  export function splitSummaryText(raw) {
175
- let text = (raw ?? '').replace(ARTIFACT_REFS_RE, '').trim();
177
+ let text = (raw ?? '').replace(ARCHIVED_REFS_RE, '').replace(ARTIFACT_REFS_RE, '').trim();
176
178
  if (text.startsWith(SUMMARY_MARKER)) {
177
179
  const nl = text.indexOf('\n');
178
180
  text = (nl < 0 ? '' : text.slice(nl + 1)).trim();
@@ -874,10 +876,25 @@ export async function compactHistory(history, opts) {
874
876
  if (snapshot)
875
877
  writeCompactionSnapshot(snapshot);
876
878
  }
879
+ // 旧区原文归档(仅主 agent):摘要成功后这些消息将被整体丢弃且 session.json
880
+ // 随后被覆盖——落盘 compacted/seg-NNN.md,模型事后靠 grep/read_file 回捞精确原文。
881
+ // best-effort:失败只丢回捞能力,绝不阻塞压缩。
882
+ let archivedPath = null;
883
+ if (state === contextState) {
884
+ try {
885
+ archivedPath = getActiveSessionStore().archiveCompactedMessages(getCurrentSessionId() ?? '', older);
886
+ }
887
+ catch {
888
+ archivedPath = null;
889
+ }
890
+ }
877
891
  const body = [parsed.rest, pinnedFacts ? `## Key Facts\n${pinnedFacts}` : ''].filter(Boolean).join('\n\n');
892
+ const archivedRef = archivedPath
893
+ ? `\n\n[archived: ${archivedPath} — 被压缩内容的精确原文;更早段见同目录 index.md]`
894
+ : '';
878
895
  const summaryMsg = {
879
896
  role: 'system',
880
- content: `${SUMMARY_MARKER}\n${body}${provenance}`,
897
+ content: `${SUMMARY_MARKER}\n${body}${provenance}${archivedRef}`,
881
898
  };
882
899
  // 原地重建:[systemPrompt, summaryMsg, ...kept]
883
900
  const systemMsg = history[0];
@@ -11,9 +11,10 @@ export function newSessionId() {
11
11
  export function saveSession(history, id, queryHistory = [], lastToolGroups = []) {
12
12
  return defaultSessionStore.save(history, id, queryHistory, lastToolGroups);
13
13
  }
14
- /** 加载会话;不存在或损坏时返回 null。 */
15
- export function loadSession(id) {
16
- return defaultSessionStore.load(id);
14
+ /** 加载会话;不存在或损坏时返回 null。restoreAssets=true 时(resume)把归档 gz
15
+ * 内嵌的 compacted 资产写回磁盘;列表/检索等只读场景保持默认不产生写盘副作用。 */
16
+ export function loadSession(id, opts) {
17
+ return defaultSessionStore.load(id, opts);
17
18
  }
18
19
  /** 列出最近会话,按 createdAt 降序。 */
19
20
  export function listSessions(limit) {