mocode-ai 1.7.10 → 1.8.0

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 (39) hide show
  1. package/dist/agent/index.js +50 -14
  2. package/dist/agent/run-coordinator.js +10 -4
  3. package/dist/agent/stages/tool-dispatcher.js +1 -0
  4. package/dist/agent/tool-helpers.js +55 -10
  5. package/dist/config/file.js +11 -3
  6. package/dist/config/index.js +16 -3
  7. package/dist/config/presets.js +9 -0
  8. package/dist/headless.js +8 -1
  9. package/dist/index.js +1 -1
  10. package/dist/llm/index.js +10 -1
  11. package/dist/llm/providers/anthropic.js +9 -1
  12. package/dist/llm/providers/vertex.js +7 -1
  13. package/dist/memory/graph.js +262 -1
  14. package/dist/memory/index.js +3 -1
  15. package/dist/memory/persona.js +142 -0
  16. package/dist/memory/recall-stats.js +61 -0
  17. package/dist/memory/recall.js +47 -0
  18. package/dist/memory/reflect.js +112 -17
  19. package/dist/memory/relation-vocab.js +84 -0
  20. package/dist/models/map-preset.js +4 -0
  21. package/dist/repl/commands/model-actions.js +3 -0
  22. package/dist/repl/commands/model.js +39 -3
  23. package/dist/repl/runtime.js +13 -4
  24. package/dist/runtime/runtime.js +1 -1
  25. package/dist/session/compact-archive.js +232 -0
  26. package/dist/session/compact.js +19 -2
  27. package/dist/session/persist.js +4 -3
  28. package/dist/session/retention.js +33 -2
  29. package/dist/session/store.js +187 -3
  30. package/dist/tools/builtins/edit-file.js +110 -22
  31. package/dist/tools/builtins/grep.js +5 -2
  32. package/dist/tools/builtins/memory-graph.js +2 -0
  33. package/dist/tools/builtins/memory-search.js +20 -14
  34. package/dist/tools/builtins/write-file.js +4 -2
  35. package/dist/tools/constants.js +4 -0
  36. package/dist/tools/ripgrep-client.js +11 -1
  37. package/dist/tools/tool-runtime.js +65 -2
  38. package/dist/ui/diff.js +68 -4
  39. package/package.json +1 -1
@@ -93,7 +93,7 @@ function writeGraphForScope(scope, g) {
93
93
  writeGraphAtomic(p, { entities: g.entities, edges: g.edges });
94
94
  }
95
95
  /** 单 scope 的完整图(读文件,scope 归一化)。 */
96
- function loadScopeGraph(scope) {
96
+ export function loadScopeGraph(scope) {
97
97
  const g = readGraphFile(graphPathForScope(scope));
98
98
  for (const e of g.entities)
99
99
  e.scope = scope;
@@ -125,6 +125,7 @@ export function upsertEntity(name, opts = {}) {
125
125
  const trimmed = name.trim();
126
126
  if (!trimmed)
127
127
  return { id: '', created: false, rejected: 'empty-name' };
128
+ const nextType = opts.type?.trim() || undefined;
128
129
  const g = loadScopeGraph(scope);
129
130
  const hit = findEntityIn(g, trimmed);
130
131
  if (hit) {
@@ -137,6 +138,10 @@ export function upsertEntity(name, opts = {}) {
137
138
  hit.summary = opts.summary.trim();
138
139
  dirty = true;
139
140
  }
141
+ if (nextType && (hit.type ?? '') !== nextType) {
142
+ hit.type = nextType;
143
+ dirty = true;
144
+ }
140
145
  if (dirty) {
141
146
  hit.updatedAt = nowIso();
142
147
  writeGraphForScope(scope, g);
@@ -163,6 +168,7 @@ export function upsertEntity(name, opts = {}) {
163
168
  name: trimmed,
164
169
  aliases: opts.alias && norm(opts.alias) !== norm(trimmed) ? [opts.alias.trim()] : [],
165
170
  summary: opts.summary?.trim() ?? '',
171
+ type: nextType,
166
172
  createdAt: now,
167
173
  updatedAt: now,
168
174
  scope,
@@ -257,6 +263,95 @@ export function searchGraph(query, limit = 8) {
257
263
  const edges = g.edges.filter((e) => !e.invalidAt && (hitIds.has(e.src) || hitIds.has(e.dst)));
258
264
  return { entities: scored.map((x) => x.e), edges };
259
265
  }
266
+ // ── 边(事实)级词面检索:供 memory_search 的图谱事实段 ─────────────────────
267
+ // searchGraph 是实体名/别名子串匹配,中文整句 fact 与多词 query 召回弱。这里用
268
+ // context/text-search 的 CJK 分词(unigram+bigram)对「端点实体名/别名 + relation +
269
+ // fact」做加权词面打分,再做一跳邻接扩展(命中实体相连的其它 active 边,降权补全)。
270
+ import { tokenize } from '../context/text-search.js';
271
+ const FIELD_WEIGHTS = { name: 6, alias: 4, fact: 2, relation: 2 };
272
+ /** 统计 query terms 在一段文本里的命中次数。 */
273
+ function termHits(terms, text) {
274
+ if (!text)
275
+ return 0;
276
+ const lower = text.toLowerCase();
277
+ let hits = 0;
278
+ for (const t of terms)
279
+ if (lower.includes(t))
280
+ hits++;
281
+ return hits;
282
+ }
283
+ /**
284
+ * 边级相关度检索。仅 active 边;按 scope 过滤。排序:score 降序、其次边 id(稳定)。
285
+ * 要求至少一个 query term 直接命中才返回(邻接边依附于直接命中,不会凭空出现)。
286
+ */
287
+ export function searchGraphFacts(query, opts = {}) {
288
+ const terms = [...new Set(tokenize(query))];
289
+ if (terms.length === 0)
290
+ return [];
291
+ const scope = opts.scope ?? 'all';
292
+ const limit = Math.max(1, opts.limit ?? 8);
293
+ const neighborCap = Math.max(0, opts.neighbors ?? 4);
294
+ const g = loadAllGraph();
295
+ const inScope = (s) => scope === 'all' || s === scope;
296
+ const byId = new Map(g.entities.map((e) => [e.id, e]));
297
+ const activeEdges = g.edges.filter((e) => !e.invalidAt && inScope(e.scope));
298
+ const scoreEdge = (e) => {
299
+ const src = byId.get(e.src);
300
+ const dst = byId.get(e.dst);
301
+ let score = 0;
302
+ if (src) {
303
+ score += termHits(terms, src.name) * FIELD_WEIGHTS.name;
304
+ for (const a of src.aliases)
305
+ score += termHits(terms, a) * FIELD_WEIGHTS.alias;
306
+ }
307
+ if (dst) {
308
+ score += termHits(terms, dst.name) * FIELD_WEIGHTS.name;
309
+ for (const a of dst.aliases)
310
+ score += termHits(terms, a) * FIELD_WEIGHTS.alias;
311
+ }
312
+ score += termHits(terms, e.relation) * FIELD_WEIGHTS.relation;
313
+ score += termHits(terms, e.fact) * FIELD_WEIGHTS.fact;
314
+ return score;
315
+ };
316
+ const direct = activeEdges
317
+ .map((edge) => ({ edge, score: scoreEdge(edge) }))
318
+ .filter((x) => x.score > 0)
319
+ .sort((a, b) => b.score - a.score || a.edge.id.localeCompare(b.edge.id));
320
+ const chosen = [];
321
+ const chosenIds = new Set();
322
+ const hitEndpoints = new Set();
323
+ for (const { edge, score } of direct.slice(0, limit)) {
324
+ chosen.push({
325
+ edge,
326
+ srcEntity: byId.get(edge.src) ?? null,
327
+ dstEntity: byId.get(edge.dst) ?? null,
328
+ score,
329
+ viaNeighbor: false,
330
+ });
331
+ chosenIds.add(edge.id);
332
+ hitEndpoints.add(edge.src);
333
+ hitEndpoints.add(edge.dst);
334
+ }
335
+ // 一跳邻接:命中实体相连的其它 active 边,固定降权(0.45× 直接边的保底分),补足上下文。
336
+ if (neighborCap > 0 && chosen.length > 0) {
337
+ const directTop = direct[0]?.score ?? 1;
338
+ const neighborScore = Math.max(1, Math.round(directTop * 0.45));
339
+ const extras = activeEdges
340
+ .filter((e) => !chosenIds.has(e.id) && (hitEndpoints.has(e.src) || hitEndpoints.has(e.dst)))
341
+ .sort((a, b) => a.id.localeCompare(b.id))
342
+ .slice(0, neighborCap);
343
+ for (const edge of extras) {
344
+ chosen.push({
345
+ edge,
346
+ srcEntity: byId.get(edge.src) ?? null,
347
+ dstEntity: byId.get(edge.dst) ?? null,
348
+ score: neighborScore,
349
+ viaNeighbor: true,
350
+ });
351
+ }
352
+ }
353
+ return chosen;
354
+ }
260
355
  function buildAdjacency(g, relation) {
261
356
  const adj = new Map();
262
357
  for (const e of g.edges) {
@@ -407,3 +502,169 @@ export function graphStats() {
407
502
  byScope: { project: count('project'), global: count('global') },
408
503
  };
409
504
  }
505
+ // ── 规范用户画像实体(G0 身份归一)──────────────────────────────────────────
506
+ //
507
+ // 全局图里历史上反思自由起名,把同一个用户碎成多个实体(实测:mocode user / 万 /
508
+ // user-name-wanjizhu / user-role-… 互不相连)。这里固定一个主键实体,所有"关于人"
509
+ // 的属性都挂到它身上;角色值(user-role-*)是属性的**值**不是人,不并入。
510
+ /** 全局规范用户实体的固定 id(概念名 person:primary,实现用文件安全 slug)。 */
511
+ export const PRIMARY_PERSON_ID = 'person-primary';
512
+ /** 规范用户实体的轻量类型标记。 */
513
+ export const PERSON_TYPE = 'Person';
514
+ /** 未分型实体的占位类型(历史数据无 type 字段时按此处理,不参与跨类型守卫)。 */
515
+ export const UNKNOWN_TYPE = 'unknown';
516
+ /** 实体有效类型(缺省/空串视为 unknown)。 */
517
+ function entityType(e) {
518
+ const t = (e.type ?? '').trim();
519
+ return t || UNKNOWN_TYPE;
520
+ }
521
+ /** 判定一个全局实体是否"用户指称碎片"(应并入 primary),角色值实体除外。 */
522
+ function isPersonFragment(e, selfNameHints) {
523
+ const id = e.id.toLowerCase();
524
+ const name = e.name.trim().toLowerCase();
525
+ if (id === PRIMARY_PERSON_ID)
526
+ return false;
527
+ if (id.startsWith('user-role-'))
528
+ return false; // 角色值,不是人
529
+ if (id === 'mocode-user' || name === 'mocode user')
530
+ return true;
531
+ if (id.startsWith('user-name-'))
532
+ return true;
533
+ // 经 named 边收集到的自称(如"万")
534
+ if (selfNameHints.has(name))
535
+ return true;
536
+ return false;
537
+ }
538
+ /** 确保全局规范用户实体存在(固定 id,绕过自由 slug);返回其最新内容。 */
539
+ export function ensurePrimaryPerson(displayName) {
540
+ const scope = 'global';
541
+ const g = loadScopeGraph(scope);
542
+ let p = g.entities.find((e) => e.id === PRIMARY_PERSON_ID);
543
+ if (!p) {
544
+ const now = nowIso();
545
+ p = {
546
+ id: PRIMARY_PERSON_ID,
547
+ name: (displayName ?? 'user').trim() || 'user',
548
+ aliases: [],
549
+ summary: '规范用户实体(所有跨项目的用户画像属性挂这里)',
550
+ type: PERSON_TYPE,
551
+ createdAt: now,
552
+ updatedAt: now,
553
+ scope,
554
+ };
555
+ g.entities.push(p);
556
+ writeGraphForScope(scope, g);
557
+ return p;
558
+ }
559
+ let dirty = false;
560
+ if (entityType(p) !== PERSON_TYPE) {
561
+ p.type = PERSON_TYPE; // 历史库回填:G0 已建的 primary 当时无 type
562
+ dirty = true;
563
+ }
564
+ if (displayName && displayName.trim() && displayName.trim() !== p.name) {
565
+ if (!p.aliases.includes(p.name))
566
+ p.aliases.push(p.name);
567
+ p.name = displayName.trim();
568
+ dirty = true;
569
+ }
570
+ if (!p.summary) {
571
+ p.summary = '规范用户实体(所有跨项目的用户画像属性挂这里)';
572
+ dirty = true;
573
+ }
574
+ if (dirty) {
575
+ p.updatedAt = nowIso();
576
+ writeGraphForScope(scope, g);
577
+ }
578
+ return p;
579
+ }
580
+ /**
581
+ * 把同一 scope 内 source 实体并入 target:
582
+ * - source 的 active 边 endpoint 改写为 target;改写后与 target 现有 active 边
583
+ * (src,relation,dst) 全等的重复边置 invalidAt(时序失效,不删);
584
+ * - source 名字/别名并入 target.aliases;source 标 mergedInto 保留(可追溯、可被别名词命中)。
585
+ * 类型守卫(轻量本体):双方都已明确分型(非 unknown)且类型不同 → 拒绝,防止把
586
+ * Port/Hobby 等异类实体误并进 Person;任一方 unknown(历史未分型)则照常允许。
587
+ * 幂等:source===target 或不存在则 no-op(null)。
588
+ */
589
+ export function mergeEntities(targetId, sourceId, scope = 'global') {
590
+ if (targetId === sourceId)
591
+ return null;
592
+ const g = loadScopeGraph(scope);
593
+ const target = g.entities.find((e) => e.id === targetId);
594
+ const source = g.entities.find((e) => e.id === sourceId);
595
+ if (!target || !source)
596
+ return null;
597
+ const tt = entityType(target);
598
+ const st = entityType(source);
599
+ if (tt !== UNKNOWN_TYPE && st !== UNKNOWN_TYPE && tt !== st) {
600
+ return { target: targetId, merged: [], dedupedEdges: 0, rejected: `type-mismatch: ${st} != ${tt}` };
601
+ }
602
+ const activeOf = (id) => g.edges.filter((e) => !e.invalidAt && (e.src === id || e.dst === id));
603
+ const existsActive = (src, relation, dst) => g.edges.some((e) => !e.invalidAt && e.src === src && e.relation === relation && e.dst === dst);
604
+ let dedupedEdges = 0;
605
+ for (const edge of activeOf(sourceId)) {
606
+ const nextSrc = edge.src === sourceId ? targetId : edge.src;
607
+ const nextDst = edge.dst === sourceId ? targetId : edge.dst;
608
+ if (nextSrc === nextDst) {
609
+ edge.invalidAt = nowIso(); // 自指环(并入自己)无意义,失效
610
+ dedupedEdges++;
611
+ continue;
612
+ }
613
+ if (existsActive(nextSrc, edge.relation, nextDst) && (nextSrc !== edge.src || nextDst !== edge.dst)) {
614
+ edge.invalidAt = nowIso(); // target 已有等价 active 边,去重
615
+ dedupedEdges++;
616
+ continue;
617
+ }
618
+ edge.src = nextSrc;
619
+ edge.dst = nextDst;
620
+ }
621
+ const addAlias = (a) => {
622
+ const v = a.trim();
623
+ if (v && v !== target.name && !target.aliases.includes(v))
624
+ target.aliases.push(v);
625
+ };
626
+ addAlias(source.name);
627
+ for (const a of source.aliases)
628
+ addAlias(a);
629
+ source.mergedInto = targetId;
630
+ source.updatedAt = nowIso();
631
+ target.updatedAt = nowIso();
632
+ writeGraphForScope(scope, g);
633
+ return { target: targetId, merged: [sourceId], dedupedEdges };
634
+ }
635
+ /**
636
+ * 一次性身份归一(幂等):确保 person-primary 存在,把全局图里的用户指称碎片
637
+ * (mocode-user / user-name-* / 沿 named 边找到的自称,如"万")并入它。
638
+ * 角色值实体(user-role-*)、兴趣、地点、项目/工具实体不碰。返回合并报告(无操作时 merged 为空)。
639
+ */
640
+ export function canonicalizePrimaryPerson() {
641
+ const scope = 'global';
642
+ const g = loadScopeGraph(scope);
643
+ // 沿 named 边收集自称:X --named--> N,则 N.name 是用户的名字。
644
+ const selfNameHints = new Set();
645
+ for (const e of g.edges) {
646
+ if (e.invalidAt || e.relation !== 'named')
647
+ continue;
648
+ const src = g.entities.find((x) => x.id === e.src);
649
+ const dst = g.entities.find((x) => x.id === e.dst);
650
+ if (src && dst && (src.id === 'mocode-user' || src.id.startsWith('user-name-') || /user/i.test(src.name))) {
651
+ selfNameHints.add(dst.name.trim().toLowerCase());
652
+ }
653
+ }
654
+ const fragments = g.entities.filter((e) => isPersonFragment(e, selfNameHints));
655
+ // 显示名:优先最短的中文/字母自称(通常是"万"),退回已存在 primary 的名字。
656
+ const existing = g.entities.find((e) => e.id === PRIMARY_PERSON_ID);
657
+ const nameCandidates = [...selfNameHints].filter((n) => n && !/^user/.test(n));
658
+ const displayName = nameCandidates.sort((a, b) => a.length - b.length)[0] ?? existing?.name ?? null;
659
+ ensurePrimaryPerson(displayName ?? undefined);
660
+ let deduped = 0;
661
+ const merged = [];
662
+ for (const f of fragments) {
663
+ const r = mergeEntities(PRIMARY_PERSON_ID, f.id, scope);
664
+ if (r && !r.rejected) {
665
+ merged.push(f.id);
666
+ deduped += r.dedupedEdges;
667
+ }
668
+ }
669
+ return { primary: PRIMARY_PERSON_ID, displayName, merged, dedupedEdges: deduped };
670
+ }
@@ -3,5 +3,7 @@
3
3
  // (buildAgentsImportSection) auto-imports the workspace-root AGENTS.md body into the
4
4
  // system prompt — independent of the memory switch — truncating it when it exceeds the cap.
5
5
  export { buildMemoryIndexSection, loadAll, gcMemories, } from './store.js';
6
- export { addTriple, upsertEntity, findEntity, searchGraph, neighborsOf, pathBetween, graphStats, } from './graph.js';
6
+ export { addTriple, upsertEntity, findEntity, searchGraph, searchGraphFacts, neighborsOf, pathBetween, graphStats, ensurePrimaryPerson, mergeEntities, canonicalizePrimaryPerson, PRIMARY_PERSON_ID, } from './graph.js';
7
+ export { buildAboutYouSection, initPersona, __resetPersonaInitForTests, ABOUT_YOU_MAX_CHARS } from './persona.js';
8
+ export { buildProjectGraphHint, GRAPH_HINT_MAX_CHARS } from './recall.js';
7
9
  export { kickoffReflection, drainMemoryBackground, getLastReflectResult, clearLastReflectResult, snapshotTranscript, formatReflectResult, runReflection, } from './reflect.js';
@@ -0,0 +1,142 @@
1
+ // persona.ts —— 全局「用户画像」内化层(Memory 2.1 双轨的 global 轨)。
2
+ //
3
+ // 与 buildMemoryIndexSection(条目摘要、按需召回、会衰减)不同,这里产出的
4
+ // `## About you` 是**高等级(L2)画像**:锚定在全局规范实体 person-primary 的
5
+ // active 图谱边上,每个会话无条件常驻 system prompt,体量硬封顶以保护 prompt cache。
6
+ // 身份类事实用图谱时序边表达(换工作/搬家时旧边 invalidAt),天然不受 memory 条目
7
+ // 30 天衰减影响——这是"越用越懂用户"那条易失通道之外的确定性内化通道。
8
+ //
9
+ // 纯读、不建实体(建实体/归一走 graph.canonicalizePrimaryPerson,在启动期显式做)。
10
+ import { loadScopeGraph, canonicalizePrimaryPerson, PRIMARY_PERSON_ID, } from './graph.js';
11
+ /** About you 段硬预算(字符):只放高等级画像,超了按置信/顺序截断。 */
12
+ export const ABOUT_YOU_MAX_CHARS = 800;
13
+ /** 关系 → 人类可读标签(画像白名单;不在表内的关系用 fact 原文,仍可展示)。 */
14
+ const RELATION_LABEL = {
15
+ named: '称呼',
16
+ has_role: '角色',
17
+ works_at: '就职于',
18
+ located_in: '所在地',
19
+ speaks: '语言',
20
+ verbosity: '回复详略',
21
+ communication_style: '沟通风格',
22
+ current_goal: '当前目标',
23
+ constraint: '约束',
24
+ prefers: '偏好',
25
+ avoids: '避免',
26
+ travel_preference: '旅行偏好',
27
+ has_hobby: '兴趣',
28
+ skill_level: '擅长',
29
+ related_to: '相关',
30
+ };
31
+ function displayName(name) {
32
+ // 图谱里部分历史实体名是 snake_case(leisure_travel / hangzhou),展示时松绑。
33
+ if (/[\u4e00-\u9fff]/.test(name))
34
+ return name;
35
+ return name.replace(/[_-]+/g, ' ').trim();
36
+ }
37
+ /**
38
+ * 渲染全局 L2 画像段。无 person-primary / 无 active 画像边 → 空串(零注入)。
39
+ * 优先用边上的中文 fact 原文;没有 fact 时用「关系标签 + 目标实体名」。
40
+ * 同名去重;has_hobby 合并为一行;整体截断到 ABOUT_YOU_MAX_CHARS。
41
+ */
42
+ export function buildAboutYouSection(memoryEnabled = true) {
43
+ if (!memoryEnabled)
44
+ return '';
45
+ const g = loadScopeGraph('global');
46
+ const me = g.entities.find((e) => e.id === PRIMARY_PERSON_ID);
47
+ if (!me)
48
+ return '';
49
+ const byId = new Map(g.entities.map((e) => [e.id, e]));
50
+ const outgoing = g.edges.filter((e) => !e.invalidAt && e.src === PRIMARY_PERSON_ID && e.dst !== PRIMARY_PERSON_ID);
51
+ if (outgoing.length === 0)
52
+ return '';
53
+ const hobbies = [];
54
+ const lines = [];
55
+ const seen = new Set();
56
+ const push = (text, relation) => {
57
+ const key = text.trim();
58
+ if (!key || seen.has(key))
59
+ return;
60
+ seen.add(key);
61
+ lines.push({ text: key, relation });
62
+ };
63
+ for (const e of outgoing) {
64
+ const dst = byId.get(e.dst);
65
+ const target = dst ? displayName(dst.name) : displayName(e.dst);
66
+ if (e.relation === 'has_hobby') {
67
+ if (target && !hobbies.includes(target))
68
+ hobbies.push(target);
69
+ continue;
70
+ }
71
+ if (e.fact && e.fact.trim()) {
72
+ push(e.fact.trim(), e.relation);
73
+ }
74
+ else {
75
+ const label = RELATION_LABEL[e.relation] ?? e.relation.replace(/_/g, ' ');
76
+ push(`${label}:${target}`, e.relation);
77
+ }
78
+ }
79
+ if (hobbies.length)
80
+ push(`兴趣:${hobbies.join('、')}`, 'has_hobby');
81
+ if (lines.length === 0)
82
+ return '';
83
+ // 稳定排序:白名单关系按预定义优先级,其余按文本;让输出跨会话稳定(护 cache)。
84
+ const order = Object.keys(RELATION_LABEL);
85
+ lines.sort((a, b) => {
86
+ const ia = order.indexOf(a.relation);
87
+ const ib = order.indexOf(b.relation);
88
+ if (ia !== ib)
89
+ return (ia === -1 ? 999 : ia) - (ib === -1 ? 999 : ib);
90
+ return a.text.localeCompare(b.text);
91
+ });
92
+ const header = `## About you(长期用户画像,跨项目始终生效)`;
93
+ // 内部 slug 实体名(mocode user / user-name-* 归一后留下的别名)不作为"又称"展示。
94
+ const visibleAliases = me.aliases
95
+ .map(displayName)
96
+ .filter((a) => a && a !== displayName(me.name) && !/^(mocode user|user name)\b/i.test(a));
97
+ const nameLine = `- 用户:${displayName(me.name)}${visibleAliases.length ? `(又称 ${visibleAliases.join('、')})` : ''}`;
98
+ const body = ['', header, nameLine, ...lines.map((l) => `- ${l.text}`)].join('\n');
99
+ if (body.length <= ABOUT_YOU_MAX_CHARS)
100
+ return body;
101
+ // 截断:从低优先级行往前丢,保住身份/地点/目标等高等级项。
102
+ const kept = [header, nameLine];
103
+ let budget = ABOUT_YOU_MAX_CHARS - kept.join('\n').length;
104
+ for (const l of lines) {
105
+ const piece = `- ${l.text}`;
106
+ if (piece.length + 1 > budget)
107
+ continue;
108
+ kept.push(piece);
109
+ budget -= piece.length + 1;
110
+ }
111
+ return kept.join('\n');
112
+ }
113
+ /** 仅供测试/调试:列出 person-primary 当前 active 画像边(原始结构)。 */
114
+ export function inspectPrimaryProfile() {
115
+ const g = loadScopeGraph('global');
116
+ const me = g.entities.find((e) => e.id === PRIMARY_PERSON_ID) ?? null;
117
+ const edges = g.edges.filter((e) => !e.invalidAt && (e.src === PRIMARY_PERSON_ID || e.dst === PRIMARY_PERSON_ID));
118
+ return { me, edges };
119
+ }
120
+ let personaInitialized = false;
121
+ /**
122
+ * 启动期幂等初始化:做一次全局身份归一(把 mocode-user / user-name-* / 自称等碎片
123
+ * 并入 person-primary)。每进程至多一次;记忆开关关闭时不碰图谱。best-effort——
124
+ * 归一失败不应影响 REPL 启动(调用方自行 catch)。
125
+ */
126
+ export function initPersona(memoryEnabled = true) {
127
+ if (personaInitialized)
128
+ return;
129
+ personaInitialized = true;
130
+ if (!memoryEnabled)
131
+ return;
132
+ try {
133
+ canonicalizePrimaryPerson();
134
+ }
135
+ catch {
136
+ // 归一只是增强(图谱损坏/IO 异常),绝不能拖垮 REPL 启动。
137
+ }
138
+ }
139
+ /** 测试专用:重置进程内初始化标记。 */
140
+ export function __resetPersonaInitForTests() {
141
+ personaInitialized = false;
142
+ }
@@ -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
+ }