dsh-prime-memory 0.26.5 → 0.26.9

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 (64) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.en.md +62 -1
  3. package/README.md +60 -2
  4. package/dist/client.js +45 -5
  5. package/dist/config.d.ts +40 -0
  6. package/dist/config.js +33 -3
  7. package/dist/contract.d.ts +14 -1
  8. package/dist/embedding-child.cjs +192 -0
  9. package/dist/embedding-worker.cjs +2 -0
  10. package/dist/graph/apply.d.ts +9 -0
  11. package/dist/graph/apply.js +12 -2
  12. package/dist/graph/search.d.ts +11 -2
  13. package/dist/graph/search.js +38 -10
  14. package/dist/hooks/recall.js +28 -1
  15. package/dist/index.js +14 -1
  16. package/dist/llm.d.ts +21 -2
  17. package/dist/llm.js +56 -25
  18. package/dist/pipeline/graph.d.ts +17 -1
  19. package/dist/pipeline/graph.js +45 -2
  20. package/dist/pipeline/l1.d.ts +15 -2
  21. package/dist/pipeline/l1.js +161 -47
  22. package/dist/pipeline/l2.js +72 -50
  23. package/dist/pipeline/md-sync.js +4 -2
  24. package/dist/pipeline/ruminate.js +2 -0
  25. package/dist/pipeline/runner.d.ts +32 -0
  26. package/dist/pipeline/runner.js +109 -11
  27. package/dist/pipeline/slot-migrate.d.ts +3 -2
  28. package/dist/pipeline/slot-migrate.js +3 -2
  29. package/dist/projection/recall.d.ts +10 -3
  30. package/dist/projection/recall.js +14 -0
  31. package/dist/settings.js +6 -0
  32. package/dist/stats.d.ts +1 -1
  33. package/dist/stats.js +35 -14
  34. package/dist/store/embedding-source.d.ts +4 -2
  35. package/dist/store/embedding-source.js +5 -2
  36. package/dist/store/file-versions.d.ts +2 -0
  37. package/dist/store/file-versions.js +2 -0
  38. package/dist/store/graph-store.d.ts +71 -1
  39. package/dist/store/graph-store.js +221 -8
  40. package/dist/store/l1.d.ts +5 -0
  41. package/dist/store/l1.js +18 -2
  42. package/dist/store/local-embedding.d.ts +9 -5
  43. package/dist/store/local-embedding.js +137 -17
  44. package/dist/store/memory-md-disable.d.ts +3 -2
  45. package/dist/store/memory-md.d.ts +3 -3
  46. package/dist/store/memory-md.js +6 -4
  47. package/dist/store/route-health.d.ts +38 -0
  48. package/dist/store/route-health.js +115 -0
  49. package/dist/store/runtime-installer.d.ts +13 -1
  50. package/dist/store/runtime-installer.js +40 -2
  51. package/dist/store/sqlite.d.ts +24 -12
  52. package/dist/store/sqlite.js +97 -31
  53. package/dist/store/supersede.d.ts +10 -2
  54. package/dist/store/supersede.js +10 -2
  55. package/dist/store/validity.d.ts +150 -0
  56. package/dist/store/validity.js +209 -0
  57. package/dist/tools/index.d.ts +15 -1
  58. package/dist/tools/index.js +393 -11
  59. package/dist/util/json-repair.d.ts +48 -0
  60. package/dist/util/json-repair.js +285 -0
  61. package/dist/validity-backfill.d.ts +78 -0
  62. package/dist/validity-backfill.js +160 -0
  63. package/dsh.plugin.json +2 -2
  64. package/package.json +3 -3
@@ -15,7 +15,10 @@ import { createHash } from 'node:crypto';
15
15
  import { defineTool } from '@deepseek-ai/dsh-tools';
16
16
  import { DOWNGRADE_TARGET, familyOf, isKnownType, typeEnumSep } from '../memory-types.js';
17
17
  import { drscRoleOf } from '../clients/drsc.js';
18
- import { RECEIPTS_QUERY_LIMIT_MAX, dimensionOf, toReceiptView } from '../store/receipts.js';
18
+ import { RECEIPTS_QUERY_LIMIT_MAX, dimensionOf, toReceiptView, buildReceipts, newRunId } from '../store/receipts.js';
19
+ import { persistReceiptsSafely } from '../store/receipts.js';
20
+ import { classifyValidity, isExitedState, passesValidityGate, stampValidity, stateOfRecord, stripValidity, validToSourceOf } from '../store/validity.js';
21
+ import { isRetired, readSupersedeMarker, stripSupersedeMarker } from '../store/supersede.js';
19
22
  import { listConflictPairs, renderConflictResolution, renderConflicts, resolveConflictPair } from '../conflict-service.js';
20
23
  import { WING_CATALOG, WING_FALLBACK, normPersistence, normScope, resolveRecordScope } from '../types.js';
21
24
  import { scopeFilterOf, workspaceIdOf } from '../workspace.js';
@@ -51,7 +54,9 @@ export function registerMemoryTools(ctx, cfg, stores, logger, modes, live,
51
54
  /** 反刍控制器(可选:未装配时 ruminate 工具返回未启用提示)。 */
52
55
  ruminate,
53
56
  /** Room 注册表(分类管理 beta.4;缺省 = memory_room_admin 返回未装配提示)。 */
54
- roomRegistry) {
57
+ roomRegistry,
58
+ /** 图谱写面钩子(确定性写面计划;缺省 = update/reproject/migrate 的图谱跟随不可用)。 */
59
+ graphWrite) {
55
60
  if (!cfg.tools)
56
61
  return { reg: () => { } };
57
62
  // ── 工具分组封印(v0.21.0):sealable 组的注册经 reg() 登记,按 live 开关动态
@@ -183,11 +188,15 @@ roomRegistry) {
183
188
  // ── memory_search: L1 结构化记忆 ──
184
189
  ctx.tools.register(defineTool({
185
190
  name: 'memory_search',
186
- description: '搜索结构化记忆(L1 原子记忆)。返回与查询相关的记忆片段:用户偏好、历史事件、项目事实、任务、规则、工作方法等。',
191
+ description: '搜索结构化记忆(L1 原子记忆)。返回与查询相关的记忆片段:用户偏好、历史事件、项目事实、任务、规则、工作方法等。' +
192
+ '默认只给仍然有效的记忆;可显式反查:include_expired 放行已过期项,as_of 按有效期回放历史时刻,follow_supersede 顺取代链取后继/反查被取代者。',
187
193
  parameters: {
188
194
  query: { type: 'string', required: true, description: '搜索查询文本(自然语言)' },
189
195
  limit: { type: 'number', description: '最大返回条数(默认 5)' },
190
196
  type: { type: 'string', description: `按记忆类型过滤(词表 ${typeEnumSep('/')})` },
197
+ include_expired: { type: 'boolean', description: '放行当前已过期(valid_to 可信地闭合)的记忆;缺省 false' },
198
+ as_of: { type: 'string', description: '有效期回放(ISO 8601):返回该时刻成立的记忆;退场的不复活' },
199
+ follow_supersede: { type: 'number', description: '顺取代链向前取 N 代后继(0..5,缺省 0);同时反查每条命中的"被谁取代"链' },
191
200
  },
192
201
  output: {
193
202
  schema: {
@@ -203,6 +212,19 @@ roomRegistry) {
203
212
  type: { type: 'string' },
204
213
  scene_name: { type: 'string' },
205
214
  score: { type: 'number' },
215
+ // 门3 反查字段(task_4.5):仅在任一参数(include_expired/as_of/follow_supersede)
216
+ // 显式给出时才写键;默认路径全部缺席(输出与改前逐字一致)。
217
+ state: { type: 'string', description: '有效性六态(current/historical/expired/expired_suspect/…)' },
218
+ valid_from: { type: 'string', description: '有效期起(ISO)' },
219
+ valid_to: { type: 'string', description: '有效期止(ISO;未闭合缺席)' },
220
+ valid_to_source: { type: 'string', description: 'valid_to 证据等级(evidence/llm_inferred/fallback_now/unknown)' },
221
+ superseded_by: { type: 'string', description: '取代链上直接后继 id' },
222
+ supersede_chain: { type: 'array', items: { type: 'string' }, description: '向后代展开的后继 id 链(follow_supersede 时)' },
223
+ superseded_by_me: { type: 'array', items: { type: 'string' }, description: '反查:活跃记录中被本条取代的 id 链(follow_supersede 时)' },
224
+ reason: { type: 'string', description: '退场/取代原因(marker.reason,仅退场行)' },
225
+ verdict: { type: 'string', description: '冲突裁决结论(仅 reason=conflict)' },
226
+ pair_id: { type: 'string', description: '冲突对 id(仅 reason=conflict)' },
227
+ conflict_type: { type: 'string', description: '冲突类型轴(仅 reason=conflict)' },
206
228
  },
207
229
  additionalProperties: false,
208
230
  },
@@ -239,14 +261,89 @@ roomRegistry) {
239
261
  crossRepoMultiplier: cfg.recall.scopeFence?.crossRepoMultiplier ?? 0.2,
240
262
  });
241
263
  }
264
+ // ── 门3 时效反查(expiry-tiering task_4.2~4.5):出口按可见性谓词过滤。──
265
+ // 默认(三参数全缺省)剔可信 expired——与门1 前"索引天然遮蔽 expired"的输出
266
+ // 逐字等价(门1/门3 配套的意义;谓词单源在 validity.passesValidityGate)。
267
+ const asOfRaw = typeof args.as_of === 'string' ? Date.parse(args.as_of) : Number.NaN;
268
+ const asOf = Number.isFinite(asOfRaw) && asOfRaw > 0 ? asOfRaw : undefined;
269
+ const follow = Math.min(Math.max(Number(args.follow_supersede) || 0, 0), 5);
270
+ const explicit = args.include_expired === true || asOf !== undefined || follow > 0;
271
+ const nowMs = Date.now();
272
+ const recById = new Map(stores.l1.getByIds(items.map((h) => h.id)).map((r) => [r.id, r]));
273
+ const kept = items.filter((h) => {
274
+ const r = recById.get(h.id);
275
+ // 行缺失 fail-open(索引/主表短暂不一致时宁给结果不丢行)。
276
+ if (!r)
277
+ return true;
278
+ return passesValidityGate(r, nowMs, { includeExpired: args.include_expired === true, asOf });
279
+ });
280
+ if (!explicit) {
281
+ return {
282
+ items: kept.map((h) => ({
283
+ id: h.id,
284
+ content: h.content,
285
+ type: h.type,
286
+ scene_name: h.scene_name,
287
+ score: Math.round(h.score * 100) / 100,
288
+ })),
289
+ };
290
+ }
291
+ // 显式反查时附带状态字段(task_4.5):面板/模型据此看清"为什么这条在这里"。
242
292
  return {
243
- items: items.map((h) => ({
244
- id: h.id,
245
- content: h.content,
246
- type: h.type,
247
- scene_name: h.scene_name,
248
- score: Math.round(h.score * 100) / 100,
249
- })),
293
+ items: kept.map((h) => {
294
+ const r = recById.get(h.id);
295
+ const mark = r ? readSupersedeMarker(r.metadata) : undefined;
296
+ const row = {
297
+ id: h.id,
298
+ content: h.content,
299
+ type: h.type,
300
+ scene_name: h.scene_name,
301
+ score: Math.round(h.score * 100) / 100,
302
+ };
303
+ if (!r)
304
+ return row;
305
+ row.state = stateOfRecord(r, nowMs);
306
+ if (r.validFrom !== undefined)
307
+ row.valid_from = new Date(r.validFrom).toISOString();
308
+ if (r.validTo !== undefined)
309
+ row.valid_to = new Date(r.validTo).toISOString();
310
+ row.valid_to_source = validToSourceOf(r.metadata);
311
+ // task_4.3 正向取代链:直接后继(marker.by)→ 按 id 取回,深度 follow 循环;
312
+ // 退场中继也算一环(链尾可能还在退场行上,读侧自己判活)。
313
+ if (follow > 0) {
314
+ const chain = [];
315
+ // 后继入口的 marker 在被取代的**旧行**上;命中行自身未被取代时无链。
316
+ // seen 集防脏数据成环(by 互指)把循环绞死——链语义本身就该是无环的。
317
+ const seenChain = new Set();
318
+ let next = mark?.by;
319
+ while (next !== undefined && chain.length < follow && !seenChain.has(next)) {
320
+ seenChain.add(next);
321
+ chain.push(next);
322
+ const nr = stores.l1.getByIds([next])[0];
323
+ if (!nr)
324
+ break;
325
+ next = readSupersedeMarker(nr.metadata)?.by;
326
+ }
327
+ if (mark?.by)
328
+ row.superseded_by = mark.by;
329
+ if (chain.length > 0)
330
+ row.supersede_chain = chain;
331
+ // task_4.4 反向链:"谁取代了谁"——活跃行里 by 指向本 id 的(全扫 LIKE,百~千量级)。
332
+ const back = stores.l1.findSupersededTargets(r.id);
333
+ if (back.length > 0)
334
+ row.superseded_by_me = back;
335
+ }
336
+ if (mark) {
337
+ row.reason = mark.reason;
338
+ if (mark.verdict)
339
+ row.verdict = mark.verdict;
340
+ if (mark.pairId)
341
+ row.pair_id = mark.pairId;
342
+ if (mark.conflictType)
343
+ row.conflict_type = mark.conflictType;
344
+ }
345
+ return row;
346
+ }),
250
347
  };
251
348
  },
252
349
  }));
@@ -577,7 +674,8 @@ roomRegistry) {
577
674
  if (item.rewritten === true)
578
675
  metadata.rewritten = true;
579
676
  const priority = Number(item.priority);
580
- return {
677
+ // valid_to 由模型/调用方经工具面直填 → 证据等级 `llm_inferred`(task_2.5 的 LLM 直填分支)。
678
+ const rec = {
581
679
  id: newMemId(),
582
680
  content,
583
681
  type,
@@ -597,6 +695,8 @@ roomRegistry) {
597
695
  ...(persistence !== undefined ? { persistence } : {}),
598
696
  ...(sourceId && sourceId.trim() ? { extKey: computeExtKey(sourceId.trim(), item, content) } : {}),
599
697
  };
698
+ stampValidity(rec, now, rec.validTo !== undefined ? 'llm_inferred' : undefined);
699
+ return rec;
600
700
  }
601
701
  /** 时间轴/溯源字段的可选入参(两个写工具共用同一形状)。 */
602
702
  const writeFields = {
@@ -655,6 +755,9 @@ roomRegistry) {
655
755
  applyManualRedaction(record, cfg, logger);
656
756
  await stores.l1.appendNew([record]);
657
757
  // 新增的投射落盘由 `appendNew` 里的 store 钩子承接(写删对称,这里不双写)。
758
+ // 写面 R2:非蒸馏写入也进投影队(与 distill 同源同优先级),闭合
759
+ // "只有蒸馏才投影"的旁路缺口——用户手加记录长期 projected=None 的真根因。
760
+ graphWrite?.enqueueGraph([record.id]);
658
761
  logger.info(`[memory] 高权限写入记忆(${record.type}${record.metadata?.hall ? '/' + String(record.metadata.hall) : ''},时间轴 ${record.persistence ?? '?'}):${record.content.slice(0, 120)}`);
659
762
  return { id: record.id };
660
763
  },
@@ -782,6 +885,9 @@ roomRegistry) {
782
885
  });
783
886
  if (records.length > 0)
784
887
  await stores.l1.appendNew(records); // 同上:批量导入也由钩子上盘
888
+ // 写面 R2:批量导入同样进投影队(同一钩子,不另一套优先级/去重)。
889
+ if (records.length > 0)
890
+ graphWrite?.enqueueGraph(records.map((r) => r.id));
785
891
  logger.info(`[memory] 批量导入 ${records.length} 条(幂等跳过 ${deduped} 条,场景 ${scene}${sourceId ? `,来源 ${sourceId}` : ''})`);
786
892
  return { written: records.length, deduped, ids: records.map((r) => r.id), skipped };
787
893
  },
@@ -858,6 +964,90 @@ roomRegistry) {
858
964
  return { deleted: n, ids };
859
965
  },
860
966
  })));
967
+ // memory_update:一等"改一条 L1"工具(graph-write-plane R1/G-update)。
968
+ //
969
+ // 形态是"新版本 + 旧版本 supersede 退场",不是物理覆写:JSONL 事实源 append-only,
970
+ // 旧行经退场汇流口软删(可 restore),退场传播顺带把"只靠旧源支撑"的图谱边标墓碑,
971
+ // 再对 [旧,新] 强制重投让边跟随——全程复用既有原语,不新造退场通道。
972
+ reg('mutate', () => ctx.tools.register(defineTool({
973
+ name: 'memory_update',
974
+ description: '修订一条既有记忆(L1):就地产新版本,旧版本自动退场(软删,可在记忆列表恢复),图谱跟随重投。需高权限模式开启。' +
975
+ '与 memory_add 的区分:改"已知 record_id 的那条"用它;新事实才用 add。不是物理删除——旧行保留。',
976
+ parameters: {
977
+ record_id: { type: 'string', required: true, description: '要修订的记忆 record_id(memory_search 返回的 id)' },
978
+ content: { type: 'string', required: true, description: '修订后的完整内容(一句话事实,语义完整)' },
979
+ ...writeFields,
980
+ scene: { type: 'string', description: '归属场景名(缺省沿用旧记录)' },
981
+ priority: { type: 'number', description: '优先级 0-100(缺省沿用旧记录)' },
982
+ },
983
+ output: {
984
+ schema: {
985
+ type: 'object',
986
+ properties: {
987
+ id: { type: 'string', description: '新版本记录 id' },
988
+ retired_id: { type: 'string', description: '被取代退场的旧 id' },
989
+ notice: { type: 'string' },
990
+ },
991
+ additionalProperties: false,
992
+ },
993
+ render: (_args, value) => [
994
+ { type: 'text', text: value.notice ?? `已修订记忆,新版本 ${value.id ?? ''}(旧版 ${value.retired_id ?? ''} 已退场可恢复)` },
995
+ ],
996
+ },
997
+ execute: async (args) => {
998
+ if (!live.get().memoryMutate)
999
+ return { notice: MUTATE_OFF_NOTICE };
1000
+ const oldId = String(args.record_id ?? '').trim();
1001
+ const content = String(args.content ?? '').trim();
1002
+ if (!oldId || !content)
1003
+ return { notice: 'record_id 与 content 都必填,未修订' };
1004
+ const old = stores.l1.getByIds([oldId])[0];
1005
+ if (!old)
1006
+ return { notice: `记录 ${oldId} 不存在,未修订(可先用 memory_search 定位 id)` };
1007
+ // 已退场的行不该被"修订复活"——那是 restore 的语义,两条路径不串。
1008
+ if (isRetired(old))
1009
+ return { notice: `记录 ${oldId} 已退场(软删态),请先在记忆列表恢复后再修订` };
1010
+ // 旧值 → WriteItem 的回映(未被覆盖的字段原样带走);再经 buildRecord 同一装配,
1011
+ // 时间轴/族判定/降级台账与 add/import 完全同源,不再第二套写法。
1012
+ const item = {
1013
+ content,
1014
+ type: typeof args.type === 'string' && args.type.trim() ? args.type : old.type,
1015
+ hall: typeof args.hall === 'string' ? args.hall : (typeof old.metadata?.hall === 'string' ? old.metadata.hall : undefined),
1016
+ persistence: typeof args.persistence === 'string' ? args.persistence : old.persistence,
1017
+ valid_from: typeof args.valid_from === 'string' ? args.valid_from : (old.validFrom ? new Date(old.validFrom).toISOString() : undefined),
1018
+ valid_to: typeof args.valid_to === 'string' ? args.valid_to : (old.validTo ? new Date(old.validTo).toISOString() : undefined),
1019
+ created_at: new Date(old.createdAt).toISOString(), // 记录时间保持旧值(血缘延续),updated_at 缺省=当下
1020
+ origin: typeof args.origin === 'string' && args.origin.trim() ? args.origin : (typeof old.metadata?.origin === 'string' ? old.metadata.origin : undefined),
1021
+ conflict: typeof args.conflict === 'boolean' ? args.conflict : (old.metadata?.conflict === true ? true : undefined),
1022
+ rewritten: typeof args.rewritten === 'boolean' ? args.rewritten : undefined,
1023
+ priority: typeof args.priority === 'number' ? args.priority : old.priority,
1024
+ dedupe_key: undefined,
1025
+ };
1026
+ const scene = typeof args.scene === 'string' && args.scene.trim() ? args.scene.trim().slice(0, 120) : old.scene_name;
1027
+ const now = Date.now();
1028
+ const newRecord = buildRecord(item, scene, now, old.workspaceId || undefined);
1029
+ newRecord.version = (old.version ?? 0) + 1;
1030
+ // extKey **不**随血缘走(刻意为之):l1_records 对非空 ext_key 建了**部分唯一索引**,
1031
+ // 旧行退场后仍长期占位——新行同键会直接撞 UNIQUE 静默丢写。幂等预检
1032
+ // (findIdByExtKey)命中退场旧行即跳过重导,重复累积已经被挡住;若旧行将来
1033
+ // 被物理 purge,同来源重导会重新落一条——那是"恢复默认"而非"静默重复",可接受。
1034
+ // 旧 metadata 的其他键(锚点/翼等溯源信息)原样带走;退场/有效性两把保留键
1035
+ // 属于旧行的状态,不带进新行(新行未退场,状态由当前事实重算)。
1036
+ const carried = stripValidity(stripSupersedeMarker(old.metadata ?? {}));
1037
+ newRecord.metadata = { ...carried, ...newRecord.metadata };
1038
+ applyManualRedaction(newRecord, cfg, logger);
1039
+ // 依照 spec:先退场旧行(汇流口会触发图谱退役传播),再写新行进投影队。
1040
+ const retiredN = stores.l1.retire([oldId], { at: new Date(now).toISOString(), reason: 'superseded', by: newRecord.id });
1041
+ if (retiredN === 0)
1042
+ return { notice: `记录 ${oldId} 退场失败(并发已处理或不可用),未写入新版` };
1043
+ await stores.l1.appendNew([newRecord]);
1044
+ // 图谱跟随:入队 + 强制重投 [旧,新](旧源的边标墓碑,新源的边重投产出)。
1045
+ graphWrite?.enqueueGraph([oldId, newRecord.id]);
1046
+ graphWrite?.reprojectGraph([oldId, newRecord.id]);
1047
+ logger.info(`[memory] 高权限修订记忆:v${newRecord.version} ${newRecord.id} ← ${oldId}(${newRecord.content.slice(0, 120)})`);
1048
+ return { id: newRecord.id, retired_id: oldId };
1049
+ },
1050
+ })));
861
1051
  // ── 图谱工具(读;受与 memory_search 同款的档位/注入拒读门 + 族过滤) ──
862
1052
  const GRAPH_OFF_NOTICE = '图谱功能未启用:部署配置 graph.enabled 未开启,当前没有可用的知识图谱。';
863
1053
  // memory_search_graph: 图谱节点检索(紧凑节点卡)
@@ -978,6 +1168,198 @@ roomRegistry) {
978
1168
  return { node: lines.join('\n') };
979
1169
  },
980
1170
  })));
1171
+ // memory_reproject:强制重投入口(graph-write-plane R3/G-reproject 工具化)。
1172
+ // 纠正通道:改完 L1 想让图谱跟随,走"删凭证 + 高优入队",不再需要人肉 SQL。
1173
+ reg('graph', () => ctx.tools.register(defineTool({
1174
+ name: 'memory_reproject',
1175
+ description: '强制把指定 L1 记录重新投影进知识图谱:抹掉"已投影"凭证、按高优先级入队,让图谱跟随最新内容。' +
1176
+ '幂等(在途任务不堆重);不直写图表——节点/边仍由确定性投影管线产出。手加/导入的记忆长期不出现在图谱里时,用它补投影。',
1177
+ parameters: {
1178
+ record_ids: {
1179
+ type: 'array',
1180
+ items: { type: 'string' },
1181
+ required: true,
1182
+ description: '待重投的 record_id 列表(上限 500;不存在的 id 会被跳过并回报)',
1183
+ },
1184
+ },
1185
+ output: {
1186
+ schema: {
1187
+ type: 'object',
1188
+ properties: {
1189
+ queued: { type: 'number', description: '实际新建的投影作业数' },
1190
+ job_ids: { type: 'array', items: { type: 'string' } },
1191
+ skipped_ids: { type: 'array', items: { type: 'string' }, description: '不在 L1 主表里的 id' },
1192
+ notice: { type: 'string' },
1193
+ },
1194
+ additionalProperties: false,
1195
+ },
1196
+ render: (_args, value) => [
1197
+ {
1198
+ type: 'text',
1199
+ text: value.notice ??
1200
+ `已入队 ${value.queued ?? 0} 个重投作业${value.skipped_ids?.length ? `(跳过不存在 ${value.skipped_ids.length} 条)` : ''}`,
1201
+ },
1202
+ ],
1203
+ },
1204
+ execute: async (args) => {
1205
+ const graph = stores.graph;
1206
+ if (!graph || !cfg.graph?.enabled)
1207
+ return { queued: 0, job_ids: [], skipped_ids: [], notice: GRAPH_OFF_NOTICE };
1208
+ if (!graphWrite)
1209
+ return { queued: 0, job_ids: [], skipped_ids: [], notice: '图谱写面未接线(runner 未装配),无法入队。' };
1210
+ const ids = [...new Set((Array.isArray(args.record_ids) ? args.record_ids : [])
1211
+ .map((x) => String(x ?? '').trim())
1212
+ .filter((x) => x.length > 0 && x.length <= 200))].slice(0, 500);
1213
+ if (ids.length === 0)
1214
+ return { queued: 0, job_ids: [], skipped_ids: [], notice: 'record_ids 为空,未入队' };
1215
+ // 预筛"不存在的 id":给不存在 id 入队只会堆一条"来源已全部删除"的 dead job,
1216
+ // 噪声且掩盖真问题;存在性在此判,重投本身仍由 forceReprojectRecord 的 PK 去重兜。
1217
+ const alive = new Set(stores.l1.getByIds(ids).map((r) => r.id));
1218
+ const kept = ids.filter((id) => alive.has(id));
1219
+ const skipped = ids.filter((id) => !alive.has(id));
1220
+ if (kept.length === 0)
1221
+ return { queued: 0, job_ids: [], skipped_ids: skipped, notice: '所给 id 均不在 L1 主表,未入队' };
1222
+ const r = graphWrite.reprojectGraph(kept);
1223
+ logger.info(`[memory] 强制重投:${kept.length} 条记录 → ${r.queued} 个作业(跳过不存在 ${skipped.length} 条)`);
1224
+ return { queued: r.queued, job_ids: r.jobIds, skipped_ids: skipped };
1225
+ },
1226
+ })));
1227
+ // memory_migrate_relation:迁移关系·源导(graph-write-plane R4/G-writeplane)。
1228
+ //
1229
+ // 两步语义:① 确定性兜底——显式边提案经 `applyRelationProposal`(即
1230
+ // applyGraphProjection 纯函数 + validSources 硬校验)落库,同 (from,relation) 换目标
1231
+ // 闭旧边、出新边;② 源导主路——新增一条承载新关系的 L1 源记录(可回溯的根据),
1232
+ // 只对它重投。刻意**不**把旧源记录拉进重投批:旧源文本仍描述旧关系,重投会把刚迁移
1233
+ // 的边翻回去("内容即真相");要连旧文本一起改,配合 memory_update 逐条修订。
1234
+ // 全程无裸 SQL:不新增、也不可能经由本工具绕过投影直写 graph 表(R5)。
1235
+ reg('mutate', () => ctx.tools.register(defineTool({
1236
+ name: 'memory_migrate_relation',
1237
+ description: '把图谱实体的关系从旧目标迁移到新目标(源导):校验来源记忆均存在且未退场 → 确定性闭旧边/出新边 → 新增一条承载新关系的 L1 源记录并触发重投。' +
1238
+ '需高权限模式开启。本工具绝不直写图表(所有边变动经确定性投影校验落库)。',
1239
+ parameters: {
1240
+ from_node_id: { type: 'string', required: true, description: '起点实体节点 id(memory_search_graph 可得)' },
1241
+ relation: { type: 'string', required: true, description: '关系词(如 owns/works_at/caused)' },
1242
+ old_to_node_id: { type: 'string', required: true, description: '旧目标节点 id(将被闭边;先 expand 确认边存在)' },
1243
+ new_to_node_id: { type: 'string', required: true, description: '新目标节点 id' },
1244
+ source_record_ids: {
1245
+ type: 'array',
1246
+ items: { type: 'string' },
1247
+ required: true,
1248
+ description: '承载该关系变更的来源记忆 id 列表(非空且均现存未退场——零无源事实不变量)',
1249
+ },
1250
+ content: { type: 'string', description: '源记录文本(缺省自动拼"<起点> <关系> <新目标>")' },
1251
+ },
1252
+ output: {
1253
+ schema: {
1254
+ type: 'object',
1255
+ properties: {
1256
+ carrier_id: { type: 'string', description: '新增的源记录 id(关系变更的可回溯根据)' },
1257
+ edge_ids: { type: 'array', items: { type: 'string' }, description: '落库的 active 边 id' },
1258
+ closed_edge_ids: { type: 'array', items: { type: 'string' }, description: '被确定性闭合(superseded)的旧边 id' },
1259
+ queued: { type: 'number', description: '为源记录入队的重投作业数' },
1260
+ notice: { type: 'string' },
1261
+ },
1262
+ additionalProperties: false,
1263
+ },
1264
+ render: (_args, value) => [
1265
+ {
1266
+ type: 'text',
1267
+ text: value.notice ??
1268
+ `关系已迁移:${value.closed_edge_ids?.length ?? 0} 条旧边闭合,${value.edge_ids?.length ?? 0} 条新边 active;源记录 ${value.carrier_id ?? ''}`,
1269
+ },
1270
+ ],
1271
+ },
1272
+ execute: async (args) => {
1273
+ if (!live.get().memoryMutate)
1274
+ return { notice: MUTATE_OFF_NOTICE };
1275
+ const graph = stores.graph;
1276
+ if (!graph || !cfg.graph?.enabled)
1277
+ return { notice: GRAPH_OFF_NOTICE };
1278
+ if (!graphWrite)
1279
+ return { notice: '图谱写面未接线(runner 未装配),无法迁移。' };
1280
+ const fromId = String(args.from_node_id ?? '').trim();
1281
+ const toId = String(args.new_to_node_id ?? '').trim();
1282
+ const oldToId = String(args.old_to_node_id ?? '').trim();
1283
+ const relation = String(args.relation ?? '').trim().slice(0, 60);
1284
+ const sources = [...new Set((Array.isArray(args.source_record_ids) ? args.source_record_ids : [])
1285
+ .map((x) => String(x ?? '').trim())
1286
+ .filter((x) => x.length > 0 && x.length <= 200))].slice(0, 20);
1287
+ if (!fromId || !toId || !oldToId || !relation)
1288
+ return { notice: '节点 id 与 relation 均为必填,未迁移' };
1289
+ if (fromId === toId)
1290
+ return { notice: '起点与新目标同节点,自环边不允许' };
1291
+ if (sources.length === 0)
1292
+ return { notice: 'source_record_ids 为空——图谱不变量"零无源事实",拒绝迁移' };
1293
+ const fromNode = graph.getNode(fromId);
1294
+ const toNode = graph.getNode(toId);
1295
+ const oldToNode = graph.getNode(oldToId);
1296
+ if (!fromNode || !toNode || !oldToNode)
1297
+ return { notice: '三节点中有不存在的(先用 memory_search_graph 取 id)' };
1298
+ // R5 硬校验:来源必须全部存在于 L1 且未退场——判据单源 classifyValidity,
1299
+ // 不因"是工具传入"而豁免(这正是本计划要根治的自证债,不能开倒车)。
1300
+ const nowMs = Date.now();
1301
+ const recs = stores.l1.getByIds(sources);
1302
+ const byId = new Map(recs.map((r) => [r.id, r]));
1303
+ const bad = [];
1304
+ for (const id of sources) {
1305
+ const r = byId.get(id);
1306
+ if (!r || isExitedState(classifyValidity({
1307
+ marker: readSupersedeMarker(r.metadata),
1308
+ persistence: r.persistence,
1309
+ validTo: r.validTo,
1310
+ validToSource: validToSourceOf(r.metadata),
1311
+ now: nowMs,
1312
+ })))
1313
+ bad.push(id);
1314
+ }
1315
+ if (bad.length > 0)
1316
+ return { notice: `来源不满足"现存未退场"不变量,拒绝迁移:${bad.join('、')}` };
1317
+ // ① 确定性兜底:显式边提案过纯函数校验落库(旧边闭、新边出);
1318
+ // 提案失败则整个迁移中止,不留下"源写了、边没迁"的半状态。
1319
+ const proposal = graph.applyRelationProposal({
1320
+ fromNodeId: fromId,
1321
+ toNodeId: toId,
1322
+ relation,
1323
+ sourceRecordIds: sources,
1324
+ });
1325
+ if (!proposal.ok) {
1326
+ return { notice: `确定性边提案被拒(${proposal.reason ?? '未知'}),未迁移—— 来源需均命中现存 L1 且形状合法` };
1327
+ }
1328
+ // ② 源导主路:承载新关系的 L1 源记录(可回溯根据),沿用首条来源的类型/族装配。
1329
+ const first = byId.get(sources[0]);
1330
+ const carrierContent = String(args.content ?? '').trim() || `${fromNode.name} ${relation} ${toNode.name}`;
1331
+ const carrier = buildRecord({
1332
+ content: carrierContent,
1333
+ type: first.type,
1334
+ persistence: first.persistence,
1335
+ origin: 'memory_migrate_relation',
1336
+ }, first.scene_name || '__manual__', nowMs, first.workspaceId || undefined);
1337
+ // 审计(task_4.4):提案来源=tool 落在源记录 metadata 上,与 LLM 投影可区分;
1338
+ // 凭证另行落 l1_receipts(kind=update,candidate 集=来源),memory_receipts 可查。
1339
+ carrier.metadata = {
1340
+ ...carrier.metadata,
1341
+ dsh_relation_migration: {
1342
+ from: fromId,
1343
+ relation,
1344
+ to: toId,
1345
+ old_to: oldToId,
1346
+ closed_edge_ids: proposal.closedEdgeIds,
1347
+ proposal_source: 'tool',
1348
+ at: new Date(nowMs).toISOString(),
1349
+ },
1350
+ };
1351
+ applyManualRedaction(carrier, cfg, logger);
1352
+ await stores.l1.appendNew([carrier]);
1353
+ // 只对载体记录入队+重投(见块注释:不拉旧源,避免文本回翻)。
1354
+ graphWrite.enqueueGraph([carrier.id]);
1355
+ const r = graphWrite.reprojectGraph([carrier.id]);
1356
+ persistReceiptsSafely((rows) => stores.l1.recordReceipts(rows), buildReceipts(newRunId(), new Date(nowMs).toISOString(), [
1357
+ { recordId: carrier.id, candidateIds: sources, action: 'update' },
1358
+ ]), logger);
1359
+ logger.info(`[memory] 关系迁移(源导):${fromId} -[${relation}]-> ${toId}(闭旧边 ${proposal.closedEdgeIds.length} 条,源 ${carrier.id})`);
1360
+ return { carrier_id: carrier.id, edge_ids: proposal.edgeIds, closed_edge_ids: proposal.closedEdgeIds, queued: r.queued };
1361
+ },
1362
+ })));
981
1363
  // ── 反刍工具(按需触发 L1→L2→L3 消化,受蒸馏开关门控) ──
982
1364
  const RUMINATE_OFF_NOTICE = '反刍功能未开放:请在记忆库面板开启「蒸馏」开关后使用。';
983
1365
  const RUMINATE_UNAVAIL_NOTICE = '反刍未初始化:存储处于降级态,无法反刍。';
@@ -0,0 +1,48 @@
1
+ /**
2
+ * 容错 JSON 解析:LLM 输出常带小语法毛刺,一次 JSON.parse 定生死会把整批记忆全丢。
3
+ *
4
+ * 三级链(见 #10):
5
+ * strict → 原样 JSON.parse,零回归;
6
+ * repaired → 字符串态扫描,做四类**只动语法不动内容**的确定性修复:
7
+ * 1) 内容里的裸 ASCII `"` → `\"`(判据:该 `"` 后第一个非空白字符不是 `,` `}` `]` `:` `"` EOF)
8
+ * 2) 字符串里的裸 `\n` `\r` `\t` → 转义
9
+ * 3) `,` 紧跟 `}` `]` 的尾逗号 → 删
10
+ * 4) 结尾未闭合的字符串与括号 → 补齐(对应 finish=length 截断)
11
+ * salvaged → 整体修不动时按顶层元素边界逐个 parse,保住坏点前后已完整的对象,
12
+ * 尾巴丢掉(不猜)。
13
+ *
14
+ * 修复/救援发生时会带上 `notes`,由调用方决定是否 warn 落盘(便于回溯:修复是兜底不是常态)。
15
+ */
16
+ export interface RepairResult<T> {
17
+ value: T;
18
+ /** 命中的档位,便于日志追溯。 */
19
+ level: 'strict' | 'repaired' | 'salvaged';
20
+ /** 修复动作描述,strict 档为空数组。 */
21
+ notes: string[];
22
+ }
23
+ /**
24
+ * 字符串态扫描修复:只动语法,字段名与值内容字节一个不改。
25
+ * 返回 null 表示扫描中遇到无法处理的结构错误(交给上层救援)。
26
+ */
27
+ export declare function repairJson(src: string): {
28
+ out: string;
29
+ notes: string[];
30
+ } | null;
31
+ /**
32
+ * 顶层逐元素救援:仅当输入是 `[...]` 且整体 parse 失败时启用。
33
+ * 按结构态(非字符串)找顶层逗号,切片成一个个元素,分别 JSON.parse,失败的丢掉。
34
+ */
35
+ export declare function salvageTopLevelElements(src: string): {
36
+ items: unknown[];
37
+ dropped: number;
38
+ } | null;
39
+ /**
40
+ * 预处理:剥 ```json```围栏 + 切到首个 `[`/`{` 直到末尾 `]`/`}`,兼容 LLM 前后附的散文。
41
+ * 与旧版 parseJson 逐字行为一致(零回归)。
42
+ */
43
+ export declare function preprocessLLMJson(raw: string): string;
44
+ /**
45
+ * 三级容错入口。传入已通过「剥围栏 + 切片」的字符串,返回解析结果与档位说明。
46
+ * 若三级皆失败,抛出 strict JSON.parse 的原始错误。
47
+ */
48
+ export declare function parseJsonTolerant<T>(raw: string): RepairResult<T>;