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.
@@ -130,7 +130,7 @@ function writeToolHeader(tc, toolRuntime) {
130
130
  const id = batch.beginBatch();
131
131
  currentBatchId = id;
132
132
  batch.bindCall(tc.id, id);
133
- batch.recordCall(id, tc.name, summarizeToolCall(tc.name, tc.arguments));
133
+ batch.recordCall(id, tc.name, summarizeToolCall(tc.name, tc.arguments), tc.id);
134
134
  batch.showLiveBatch(id, layout);
135
135
  }
136
136
  else {
@@ -143,7 +143,7 @@ function writeToolHeader(tc, toolRuntime) {
143
143
  // 结果按 tool_call id 归位:并行执行时 currentBatchId 会漂移,只按“当前批”回填会漏填,
144
144
  // 摘要行就永远停在 ◇(用户实测:子 agent 跑完主侧菱形没变成实心圆)。
145
145
  batch.bindCall(tc.id, id);
146
- batch.recordCall(id, tc.name, summarizeToolCall(tc.name, tc.arguments));
146
+ batch.recordCall(id, tc.name, summarizeToolCall(tc.name, tc.arguments), tc.id);
147
147
  // 第一条工具开始时立即落摘要;后续调用加入同一 batch,并原地刷新计数。
148
148
  batch.showLiveBatch(id, layout);
149
149
  }
@@ -160,7 +160,7 @@ function writeToolResult(tc, output, parsed, preWriteOld, editStartLine, toolRun
160
160
  // 无需独立批。全部 entry 完成后收口组容器批(落完成态 + 开放点击展开),
161
161
  // 并与后续内容约定一条分隔空行。
162
162
  const preview = summarizeToolResult(tc.name, output);
163
- batch.recordResult(batchId, tc.name, preview, null, output, isToolErrorOutput(output), tc.id);
163
+ batch.recordResult(batchId, tc.name, preview, null, output, isToolErrorOutput(output), tc.id, layout);
164
164
  if (batch.isBatchComplete(batchId)) {
165
165
  batch.endBatch(batchId, layout);
166
166
  subAgentGroupId = null;
@@ -189,7 +189,7 @@ function writeToolResult(tc, output, parsed, preWriteOld, editStartLine, toolRun
189
189
  });
190
190
  }
191
191
  const preview = diff ? '' : summarizeToolResult(tc.name, output);
192
- batch.recordResult(batchId, tc.name, preview, diff, output, isToolErrorOutput(output));
192
+ batch.recordResult(batchId, tc.name, preview, diff, output, isToolErrorOutput(output), tc.id, layout);
193
193
  batch.showLiveBatch(batchId, layout);
194
194
  // mutation 结果(成功 diff 或错误输出)立即可见,并阻止后续普通工具并入这一批。
195
195
  if (isMutationTool(tc.name, toolRuntime))
@@ -404,6 +404,7 @@ runtimeOrContext = defaultRuntime) {
404
404
  writeToolHeader(tc, runtime.context.toolRuntime);
405
405
  },
406
406
  onToolStart: (name) => spinner.start(t('agent.executing', { tool: name })),
407
+ onToolOutput: (tc, chunk) => batch.recordOutput(tc.id, chunk, layout),
407
408
  onToolDone: () => spinner.stop(),
408
409
  onToolResult: (tc, output, parsed, preWriteOld, editStartLine) => writeToolResult(tc, output, parsed, preWriteOld, editStartLine, runtime.context.toolRuntime),
409
410
  onToolBatchEnd: () => {
@@ -326,6 +326,7 @@ export async function runAgentCoreLegacy(opts, historyManager, stages) {
326
326
  delegation: delegationForOrchestrator,
327
327
  readDedup,
328
328
  argumentErrorHint: (name) => argumentErrorHint(name, runtimeContextState),
329
+ onToolOutput: (call, chunk) => hooks.onToolOutput?.(call, chunk),
329
330
  ...(opts.toolPolicy
330
331
  ? {
331
332
  expandToolGroups: (groups, reason) => opts.toolPolicy.expand(groups, reason),
@@ -396,6 +397,7 @@ export async function runAgentCoreLegacy(opts, historyManager, stages) {
396
397
  for (const index of eligibleReadonly) {
397
398
  startedReadonly[index] = ctx.toolRuntime.executeToolOutcome(calls[index].name, calls[index].arguments, signal, {
398
399
  callId: calls[index].id,
400
+ onOutput: (chunk) => hooks.onToolOutput?.(calls[index], chunk),
399
401
  allowedToolNames: currentAllowedToolNames(),
400
402
  delegation: delegationForOrchestrator(),
401
403
  readDedup,
@@ -566,6 +568,7 @@ export async function runAgentCoreLegacy(opts, historyManager, stages) {
566
568
  hooks.onToolStart?.(batch[0].name);
567
569
  const started = batch.map((tc) => ctx.toolRuntime.executeToolOutcome(tc.name, tc.arguments, signal, {
568
570
  callId: tc.id,
571
+ onOutput: (chunk) => hooks.onToolOutput?.(tc, chunk),
569
572
  allowedToolNames: currentAllowedToolNames(),
570
573
  delegation: delegationForOrchestrator(),
571
574
  readDedup,
@@ -663,6 +666,7 @@ export async function runAgentCoreLegacy(opts, historyManager, stages) {
663
666
  ? entry.denied
664
667
  : await ctx.toolRuntime.executeToolOutcome(entry.tc.name, entry.tc.arguments, signal, {
665
668
  callId: entry.tc.id,
669
+ onOutput: (chunk) => hooks.onToolOutput?.(entry.tc, chunk),
666
670
  allowedToolNames: currentAllowedToolNames(),
667
671
  delegation: delegationForOrchestrator(),
668
672
  readDedup,
@@ -751,6 +755,7 @@ export async function runAgentCoreLegacy(opts, historyManager, stages) {
751
755
  const hint = argumentErrorHint(entry.tc.name, runtimeContextState);
752
756
  return ctx.toolRuntime.executeToolOutcome(entry.tc.name, entry.tc.arguments, signal, {
753
757
  callId: entry.tc.id,
758
+ onOutput: (chunk) => hooks.onToolOutput?.(entry.tc, chunk),
754
759
  allowedToolNames: currentAllowedToolNames(),
755
760
  delegation: delegationForOrchestrator(),
756
761
  readDedup,
@@ -846,6 +851,7 @@ export async function runAgentCoreLegacy(opts, historyManager, stages) {
846
851
  const serialHint = argumentErrorHint(tc.name, runtimeContextState);
847
852
  const outcome = await ctx.toolRuntime.executeToolOutcome(tc.name, tc.arguments, signal, {
848
853
  callId: tc.id,
854
+ onOutput: (chunk) => hooks.onToolOutput?.(tc, chunk),
849
855
  allowedToolNames: currentAllowedToolNames(),
850
856
  delegation: delegationForOrchestrator(),
851
857
  readDedup,
@@ -44,6 +44,7 @@ class LegacyCompatibleToolDispatcher {
44
44
  });
45
45
  const execute = (call, hint, onLockAcquired) => toolRuntime.executeToolOutcome(call.name, call.arguments, request.signal, {
46
46
  callId: call.id,
47
+ onOutput: (chunk) => request.onToolOutput?.(call, chunk),
47
48
  allowedToolNames: request.currentAllowedToolNames(),
48
49
  delegation: request.delegation(),
49
50
  readDedup: request.readDedup,
@@ -13,6 +13,8 @@ import { defaultToolRuntime } from '../tools/registry.js';
13
13
  import { jailResolve } from '../sandbox/index.js';
14
14
  import { contextState } from '../session/compact.js';
15
15
  import { capToolResultForHistory } from '../session/compact.js';
16
+ import { getActiveSessionStore } from '../session/store.js';
17
+ import { getCurrentSessionId } from '../session/state.js';
16
18
  import { recordArtifact, knownEditTargets } from '../context/index.js';
17
19
  import { isToolResultSuccess } from '../context/utils.js';
18
20
  import { appendGuiAction } from '../session/gui-actions.js';
@@ -169,12 +171,27 @@ export function readDiffContext(tc, parsed, resolvePath = jailResolve) {
169
171
  * 不在这里改写旧正文;所有自动清理与压缩统一由 80% pressure scheduler 决定。 */
170
172
  export function pushToolResult(history, tc, output, pruner, lifecycle, _scheduler, runtimeContextState = contextState, succeededOverride) {
171
173
  const succeeded = succeededOverride ?? isToolResultSuccess(output);
174
+ const capped = capToolResultForHistory(tc.name, output);
175
+ // push-time 截断兜底:完整结果另存 compacted/result-<id>.txt,并在进 history 的
176
+ // 截断内容尾部留一行全文指针。仅主 agent(共享 contextState)落盘——子 agent 的
177
+ // 结果随其临时 history 消亡,不污染主会话。best-effort:存不上就只保留截断内容。
178
+ let content = capped;
179
+ if (capped.length < output.length && runtimeContextState === contextState) {
180
+ try {
181
+ const rel = getActiveSessionStore().archiveToolResult(getCurrentSessionId() ?? '', tc.id, output);
182
+ if (rel)
183
+ content = `${capped.replace(/\s+$/, '')}\n…[完整结果已归档,需要原文时 grep/read_file:${rel}]`;
184
+ }
185
+ catch {
186
+ // 归档失败不影响工具回灌。
187
+ }
188
+ }
172
189
  const msg = {
173
190
  role: 'tool',
174
191
  tool_call_id: tc.id,
175
192
  // Preserve evidence verbatim in normal operation; the hard per-result cap
176
193
  // remains solely as a request-size safety rail.
177
- content: capToolResultForHistory(tc.name, output),
194
+ content,
178
195
  };
179
196
  history.push(msg);
180
197
  const messageIndex = history.length - 1;
@@ -518,7 +518,10 @@ ${buildVoiceSection()}
518
518
  dynamicParts.push(`## Session state (\`.mocode/sessions/${sessionId ?? '<id>'}/notes.md\`)\n` +
519
519
  'For tasks with 3+ steps or context-loss risk, keep an execution plan with `plan_update`; record non-obvious findings/decisions/open questions/risks with `note_append`. ' +
520
520
  'Both write notes.md, which survives compaction — run read_file on it to recover context after compaction. ' +
521
- 'Their formats and rules live in each tool\u2019s description; do not hand-edit the files.');
521
+ 'Their formats and rules live in each tool\u2019s description; do not hand-edit the files. ' +
522
+ 'When compaction drops older conversation, its verbatim text (user messages, your earlier decisions, full tool args/results) is archived under ' +
523
+ `\`.mocode/sessions/${sessionId ?? '<id>'}/compacted/\` (see index.md there): the summary line ends with an [archived: …] pointer. ` +
524
+ 'Before re-running a command or guessing what an old output said, grep that directory with glob narrowed to `.mocode/sessions/**` (required: .mocode is gitignored), then read_file the segment at the hit lines.');
522
525
  // 日期段:模型需要知道今天才能判断 freshness(web 搜索、版本时效)。只随天变化,
523
526
  // 不破坏会话内前缀缓存;置于 '## Session state' 之前,子 agent 切片仍保留(无害且有用)。
524
527
  const todaySection = buildTodaySection();
package/dist/headless.js CHANGED
@@ -23,6 +23,8 @@ import { initializeAllMcp, getMcpTools, closeAllMcp } from './mcp/index.js';
23
23
  import { config, buildBasePrompt, isMemoryEnabled } from './config/index.js';
24
24
  import { effectiveSystemPrompt } from './skills/index.js';
25
25
  import { buildMemoryIndexSection } from './memory/store.js';
26
+ import { buildAboutYouSection, initPersona } from './memory/persona.js';
27
+ import { buildProjectGraphHint } from './memory/recall.js';
26
28
  import { defaultRuntime, Runtime } from './runtime/index.js';
27
29
  import { runAgentCore } from './agent/core.js';
28
30
  import { summarizeToolCall, summarizeToolResult } from './ui/render.js';
@@ -154,7 +156,12 @@ export async function runHeadless(opts) {
154
156
  }
155
157
  }
156
158
  // 系统提示:Bot 岗位角色在前,基础约定在后;白名单存在时补一句工具边界。
157
- const baseConventions = buildBasePrompt(sessionId) + buildMemoryIndexSection(isMemoryEnabled());
159
+ const memoryOn = isMemoryEnabled();
160
+ initPersona(memoryOn);
161
+ const baseConventions = buildBasePrompt(sessionId) +
162
+ buildAboutYouSection(memoryOn) +
163
+ buildMemoryIndexSection(memoryOn) +
164
+ buildProjectGraphHint(memoryOn);
158
165
  const systemContent = botInfo
159
166
  ? `# Role\n${botInfo.bot.systemPrompt}\n\n` +
160
167
  `${botInfo.bot.description ? `**Position:** ${botInfo.bot.description}\n\n` : ''}` +
@@ -164,6 +164,8 @@ const zhCN = {
164
164
  'repl.queuedCommandHeld': '排队的是斜杠命令,已填入输入框,请确认后手动发送',
165
165
  'repl.loadFailed': '(加载失败)',
166
166
  'repl.resumeAnchor': '已恢复会话 {id} · {count} 条消息 · 当前模型 {model}(/model switch 可切换)',
167
+ 'repl.compactedNotice': '── 更早的对话已被压缩成摘要(助手回复与工具过程不再保留),以下为摘要与保留的用户原话 ──',
168
+ 'repl.compactedTruncated': '…(摘要共 {total} 字符,仅显示前 {shown} 字符)',
167
169
  'repl.noSessions': '(没有已保存的会话)',
168
170
  'repl.noValue': '(无)',
169
171
  'repl.historyCleared': '(历史已清空,保留系统提示)',
@@ -554,6 +556,8 @@ const en = {
554
556
  'repl.queuedCommandHeld': 'Queued text is a slash command; filled into the input for you to send manually',
555
557
  'repl.loadFailed': '(load failed)',
556
558
  'repl.resumeAnchor': 'Resumed session {id} · {count} messages · model {model} (/model switch to change)',
559
+ 'repl.compactedNotice': '── Earlier conversation was compacted into a summary (assistant replies and tool steps are not kept); below are the summary and the preserved user messages ──',
560
+ 'repl.compactedTruncated': '… (summary is {total} chars, showing the first {shown})',
557
561
  'repl.noSessions': '(no saved sessions)',
558
562
  'repl.noValue': '(none)',
559
563
  'repl.historyCleared': '(history cleared; system prompt kept)',
package/dist/index.js CHANGED
@@ -252,7 +252,7 @@ async function main() {
252
252
  }
253
253
  process.exit(0);
254
254
  }
255
- const loaded = loadSession(id);
255
+ const loaded = loadSession(id, { restoreAssets: true });
256
256
  if (!loaded || !loaded.history.length) {
257
257
  console.error(t('cli.sessionMissing', { id }));
258
258
  process.exit(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
+ }