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.
- package/dist/agent/tool-helpers.js +18 -1
- package/dist/config/index.js +4 -1
- package/dist/headless.js +8 -1
- package/dist/index.js +1 -1
- package/dist/memory/graph.js +262 -1
- package/dist/memory/index.js +3 -1
- package/dist/memory/persona.js +142 -0
- package/dist/memory/recall-stats.js +61 -0
- package/dist/memory/recall.js +47 -0
- package/dist/memory/reflect.js +112 -17
- package/dist/memory/relation-vocab.js +84 -0
- package/dist/repl/runtime.js +13 -4
- package/dist/runtime/runtime.js +1 -1
- package/dist/session/compact-archive.js +232 -0
- package/dist/session/compact.js +19 -2
- package/dist/session/persist.js +4 -3
- package/dist/session/retention.js +33 -2
- package/dist/session/store.js +187 -3
- package/dist/tools/builtins/grep.js +5 -2
- package/dist/tools/builtins/memory-graph.js +2 -0
- package/dist/tools/builtins/memory-search.js +20 -14
- package/dist/tools/constants.js +4 -0
- package/dist/tools/ripgrep-client.js +11 -1
- package/package.json +1 -1
|
@@ -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
|
|
194
|
+
content,
|
|
178
195
|
};
|
|
179
196
|
history.push(msg);
|
|
180
197
|
const messageIndex = history.length - 1;
|
package/dist/config/index.js
CHANGED
|
@@ -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
|
|
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` : ''}` +
|
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);
|
package/dist/memory/graph.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/memory/index.js
CHANGED
|
@@ -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
|
+
}
|