mocode-ai 1.7.9 → 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.
@@ -0,0 +1,61 @@
1
+ // recall-stats.ts —— 项目图谱 pull 召回的轻量埋点。
2
+ //
3
+ // 目的(memory-2.2 决策):项目图谱曾"只写不读"。pull 通道(常驻 hint + memory_search
4
+ // 强化)上线后,用零依赖计数器回答"模型到底有没有在用图谱":搜了几次、图谱段有几次命中。
5
+ // 数据只存项目 <cwd>/.mocode/memory-recall-stats.json,供 memory_graph stats 展示,据此
6
+ // 决定要不要再加"turn 起步阈值注入"的安全网。全部静默容错、tmp+rename 原子写。
7
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
8
+ import path from 'node:path';
9
+ const EMPTY = { searches: 0, graphHits: 0, graphEmpty: 0, lastSearchedAt: null };
10
+ function statsPath() {
11
+ return path.join(process.cwd(), '.mocode', 'memory-recall-stats.json');
12
+ }
13
+ /** 读取项目召回统计;文件缺失/损坏一律返回零值(不抛错)。 */
14
+ export function readRecallStats() {
15
+ try {
16
+ const p = statsPath();
17
+ if (!existsSync(p))
18
+ return { ...EMPTY };
19
+ const obj = JSON.parse(readFileSync(p, 'utf8'));
20
+ return {
21
+ searches: Number.isFinite(obj.searches) ? Number(obj.searches) : 0,
22
+ graphHits: Number.isFinite(obj.graphHits) ? Number(obj.graphHits) : 0,
23
+ graphEmpty: Number.isFinite(obj.graphEmpty) ? Number(obj.graphEmpty) : 0,
24
+ lastSearchedAt: typeof obj.lastSearchedAt === 'string' ? obj.lastSearchedAt : null,
25
+ };
26
+ }
27
+ catch {
28
+ return { ...EMPTY };
29
+ }
30
+ }
31
+ /**
32
+ * 记录一次 memory_search。graphHit=true 表示图谱事实段非空。
33
+ * 返回更新后的统计;写盘失败静默(埋点绝不影响检索主流程)。
34
+ */
35
+ export function recordRecallSearch(graphHit) {
36
+ const s = readRecallStats();
37
+ s.searches += 1;
38
+ if (graphHit)
39
+ s.graphHits += 1;
40
+ else
41
+ s.graphEmpty += 1;
42
+ s.lastSearchedAt = new Date().toISOString();
43
+ try {
44
+ const p = statsPath();
45
+ mkdirSync(path.dirname(p), { recursive: true });
46
+ const tmp = p + '.tmp';
47
+ writeFileSync(tmp, JSON.stringify(s, null, 2), 'utf8');
48
+ renameSync(tmp, p);
49
+ }
50
+ catch {
51
+ // 静默:埋点不可写不影响功能
52
+ }
53
+ return s;
54
+ }
55
+ /** 一行人类可读摘要(供 memory_graph stats)。 */
56
+ export function formatRecallStats(s = readRecallStats()) {
57
+ if (s.searches === 0)
58
+ return '图谱召回:尚无 memory_search 调用(pull 通道未被使用)';
59
+ const rate = Math.round((s.graphHits / s.searches) * 100);
60
+ return `图谱召回:memory_search ${s.searches} 次,图谱命中 ${s.graphHits}(${rate}%) / 空 ${s.graphEmpty}`;
61
+ }
@@ -0,0 +1,47 @@
1
+ // recall.ts —— 项目知识图谱的「可检索性」提示(pull 召回的触发器)。
2
+ //
3
+ // 问题:项目 triples 写入 memory-graph.json 后,常驻上下文里没有任何线索告诉模型
4
+ // "仓库里存了这些事实",模型不知道要搜 → 85 条事实近乎只写不读。这里产出一小段
5
+ // 稳定的项目图谱提示(主要主题实体 + 检索指引),拼进 system prompt,让模型在相关时
6
+ // 主动 memory_search / memory_graph。只给"地图"不给正文,正文仍走按需 pull(零空转成本)。
7
+ //
8
+ // 与 About you(global、内化画像)对称:这是 project 轨、可检索的仓库事实索引。
9
+ // 纯读、叶子依赖(graph)。内容只依赖图文件,会话内不随每步变化(同 Memory Index 的启动快照语义)。
10
+ import { loadScopeGraph } from './graph.js';
11
+ /** 图谱提示段硬预算(字符)。 */
12
+ export const GRAPH_HINT_MAX_CHARS = 600;
13
+ /** 列出的主题实体上限。 */
14
+ const GRAPH_HINT_TOP_ENTITIES = 12;
15
+ /**
16
+ * 项目图谱常驻提示:无 active 边 → 空串(零注入)。
17
+ * 主题实体按 active 度数(参与的 active 边数)降序、再按名字排序,取前 N。
18
+ */
19
+ export function buildProjectGraphHint(memoryEnabled = true, scope = 'project') {
20
+ if (!memoryEnabled)
21
+ return '';
22
+ const g = loadScopeGraph(scope);
23
+ const activeEdges = g.edges.filter((e) => !e.invalidAt);
24
+ if (activeEdges.length === 0)
25
+ return '';
26
+ const degree = new Map();
27
+ for (const e of activeEdges) {
28
+ degree.set(e.src, (degree.get(e.src) ?? 0) + 1);
29
+ degree.set(e.dst, (degree.get(e.dst) ?? 0) + 1);
30
+ }
31
+ const named = g.entities.filter((x) => !!x && typeof x.name === 'string' && x.name.length > 0);
32
+ const top = named
33
+ .map((e) => ({ e, d: degree.get(e.id) ?? 0 }))
34
+ .filter((x) => x.d > 0)
35
+ .sort((a, b) => b.d - a.d || a.e.name.localeCompare(b.e.name))
36
+ .slice(0, GRAPH_HINT_TOP_ENTITIES)
37
+ .map((x) => x.e.name);
38
+ const header = `## Project knowledge graph (query on demand, not auto-loaded)`;
39
+ const body = `This repo has a memory graph with ${g.entities.length} entities / ${activeEdges.length} facts ` +
40
+ `(modules, tools, configs, decisions, pitfalls…). When relevant to the current task, query it before re-deriving: ` +
41
+ `memory_search("keywords") also returns matching graph facts (CJK-aware, with one-hop neighbors); ` +
42
+ `memory_graph action=neighbors|path expands connections. Main subjects: ${top.join('、')}.`;
43
+ const out = ['', header, body].join('\n');
44
+ return out.length <= GRAPH_HINT_MAX_CHARS
45
+ ? out
46
+ : out.slice(0, GRAPH_HINT_MAX_CHARS).replace(/[、,]\s*[^、,]*$/, ' …');
47
+ }
@@ -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
+ }
@@ -3,7 +3,12 @@ import * as layout from '../ui/layout.js';
3
3
  import * as batch from '../ui/batch.js';
4
4
  import { displayWidth, padEndDisplay, summarizeToolCall, summarizeToolResult } from '../ui/render.js';
5
5
  import { renderChip } from '../attachments/image.js';
6
+ import { t } from '../i18n/index.js';
6
7
  import { PROMPT } from './commands.js';
8
+ /** compact 摘要消息头(与 session/compact.ts SUMMARY_MARKER 同值;本地复制避免拉入 compact 的重依赖图,同 retention.ts)。 */
9
+ const SUMMARY_MARKER = '# 会话摘要';
10
+ /** 回放时摘要最多显示的字符数(摘要上限 6000,全量回放会淹没保留的用户原话)。 */
11
+ const SUMMARY_REPLAY_MAX_CHARS = 3000;
7
12
  /** side channel:history 中 user 消息的 index → 附件列表,供 renderHistory 复显文件名。runTurn 在 push 前设。 */
8
13
  export const messageAttachments = new Map();
9
14
  export function formatUserMessage(lines, trailingBlank = true) {
@@ -69,8 +74,25 @@ export function renderHistory(history) {
69
74
  };
70
75
  for (let idx = 0; idx < history.length; idx++) {
71
76
  const m = history[idx];
72
- if (m.role === 'system')
77
+ if (m.role === 'system') {
78
+ // compact 摘要是被压缩掉的助手回复/工具过程的唯一留存:回放时显示出来(history[0] 的系统提示仍跳过)。
79
+ const body = textOf(m.content);
80
+ if (idx > 0 && body.startsWith(SUMMARY_MARKER)) {
81
+ flushBatch();
82
+ layout.normalizeMutationBoundary();
83
+ layout.contentWrite(`${ui.dim}${t('repl.compactedNotice')}${ui.reset}\n\n`);
84
+ const text = body.slice(SUMMARY_MARKER.length).trim();
85
+ if (text.length > SUMMARY_REPLAY_MAX_CHARS) {
86
+ layout.contentWriteMdOnce(text.slice(0, SUMMARY_REPLAY_MAX_CHARS));
87
+ layout.contentWrite(`${ui.dim}${t('repl.compactedTruncated', { total: text.length, shown: SUMMARY_REPLAY_MAX_CHARS })}${ui.reset}\n`);
88
+ }
89
+ else {
90
+ layout.contentWriteMdOnce(text);
91
+ }
92
+ layout.normalizeMutationBoundary();
93
+ }
73
94
  continue;
95
+ }
74
96
  if (m.role === 'user') {
75
97
  flushBatch(); // 上一轮 batch(若有)收尾
76
98
  const lines = textOf(m.content).split('\n');
@@ -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);