dsh-prime-memory 0.11.0 → 0.12.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 (47) hide show
  1. package/CHANGELOG.en.md +36 -1
  2. package/CHANGELOG.ja.md +36 -1
  3. package/CHANGELOG.ko.md +36 -1
  4. package/CHANGELOG.md +40 -0
  5. package/README.en.md +28 -0
  6. package/README.ja.md +25 -0
  7. package/README.ko.md +25 -0
  8. package/README.md +22 -0
  9. package/dist/client.js +54 -30
  10. package/dist/config.d.ts +39 -1
  11. package/dist/config.js +16 -0
  12. package/dist/conflict-service.d.ts +38 -0
  13. package/dist/conflict-service.js +65 -0
  14. package/dist/graph/search.d.ts +15 -0
  15. package/dist/graph/search.js +54 -1
  16. package/dist/hooks/recall.js +4 -0
  17. package/dist/index.d.ts +28 -1
  18. package/dist/index.js +12 -3
  19. package/dist/pipeline/l1.d.ts +7 -1
  20. package/dist/pipeline/l1.js +165 -20
  21. package/dist/pipeline/runner.js +6 -1
  22. package/dist/prompts/l1-dedup.d.ts +22 -1
  23. package/dist/prompts/l1-dedup.js +61 -4
  24. package/dist/stats.d.ts +12 -0
  25. package/dist/stats.js +222 -33
  26. package/dist/store/conflicts.d.ts +62 -0
  27. package/dist/store/conflicts.js +69 -0
  28. package/dist/store/graph-store.d.ts +72 -1
  29. package/dist/store/graph-store.js +165 -1
  30. package/dist/store/l1.d.ts +97 -4
  31. package/dist/store/l1.js +163 -21
  32. package/dist/store/receipts.d.ts +157 -0
  33. package/dist/store/receipts.js +139 -0
  34. package/dist/store/search-utils.d.ts +15 -0
  35. package/dist/store/search-utils.js +20 -0
  36. package/dist/store/session-modes.d.ts +7 -0
  37. package/dist/store/session-modes.js +9 -0
  38. package/dist/store/sqlite.d.ts +116 -8
  39. package/dist/store/sqlite.js +366 -46
  40. package/dist/store/vec-utils.d.ts +16 -0
  41. package/dist/store/vec-utils.js +24 -0
  42. package/dist/tools/index.js +204 -31
  43. package/dist/types.d.ts +48 -0
  44. package/dist/types.js +40 -0
  45. package/dist/workspace.d.ts +46 -0
  46. package/dist/workspace.js +105 -0
  47. package/package.json +9 -3
@@ -0,0 +1,24 @@
1
+ /**
2
+ * vec0 编码工具:抽取向量 ↔ Buffer 的**唯一**编码约定。
3
+ *
4
+ * 为什么单独一个文件:`l1_vec` / `l0_vec`(在 `sqlite.ts`)与 `graph_node_vec`
5
+ * (在 `graph-store.ts`)必须用**同一种**二进制编码,否则同一份 vec0 扩展会出现
6
+ * 两种字节布局。而 `sqlite.ts` 已经 import 了 `GraphStore`——若让 `graph-store.ts`
7
+ * 反过来 import `sqlite.ts`,就形成模块环:能跑,但生死取决于加载顺序,
8
+ * 属于"隐式依赖",不是可以留在代码里的东西。
9
+ */
10
+ /** 全零向量(cosine 未定义,不可入向量表)。reindex 侧用它区分"不可嵌入"与"写入失败"。 */
11
+ export function isZeroVector(vec) {
12
+ for (const v of vec) {
13
+ if (v !== 0)
14
+ return false;
15
+ }
16
+ return true;
17
+ }
18
+ /**
19
+ * Float32Array → Buffer(零拷贝视图,共享底层 ArrayBuffer)。
20
+ * vec0 的 `float[N]` 列按 little-endian 连续 float32 读取,这正是该视图的布局。
21
+ */
22
+ export function vecToBuffer(vec) {
23
+ return Buffer.from(vec.buffer, vec.byteOffset, vec.byteLength);
24
+ }
@@ -1,5 +1,8 @@
1
1
  import { defineTool } from '@deepseek-ai/dsh-tools';
2
- import { normPersistence } from '../types.js';
2
+ import { RECEIPTS_QUERY_LIMIT_MAX, dimensionOf, toReceiptView } from '../store/receipts.js';
3
+ import { renderConflictResolution, resolveConflictPair } from '../conflict-service.js';
4
+ import { normPersistence, normScope, resolveRecordScope } from '../types.js';
5
+ import { scopeFilterOf, workspaceIdOf } from '../workspace.js';
3
6
  import { GRAPH_STATUS_LABELS } from '../prompts/graph-projection.js';
4
7
  const OFF_NOTICE = '本会话的记忆档位为"关闭":该会话对记忆系统完全隐身,不读取也不写入记忆。';
5
8
  const WRITE_ONLY_NOTICE = '本会话为只写模式:记忆照常沉淀,但不读取。';
@@ -9,37 +12,81 @@ export function registerMemoryTools(ctx, cfg, stores, logger, modes, live,
9
12
  ruminate) {
10
13
  if (!cfg.tools)
11
14
  return;
15
+ /**
16
+ * 沿父链解析**有效档位归属会话**(§A 修复)。
17
+ *
18
+ * 子代理以新 session id 调用工具时,其自身通常不在档位表里——原实现直接回落
19
+ * 全局默认档(auto),于是父会话被用户显式设为 `off`/只写时,**子代理仍能读到
20
+ * 用户明确关闭的记忆**,构成"用户显式指令被绕过"(P0)。
21
+ *
22
+ * 现改为沿 `session.header.parentSession` 上溯至**首个有显式档位的祖先**。
23
+ * 同步通路由 task_4 spike 实测确认(`findings.md §8`):子代理会话的 header
24
+ * **无条件**携带父会话 id(`dsh-subagent/.../child-agent.js:111-125`)。
25
+ *
26
+ * 多级链(孙代理等)需要按 id 取某个会话的 header → 走 `ctx.get('agents')`
27
+ * 的**宽容路径**(cordis 属性访问对未 inject 的服务会抛 "without inject";
28
+ * 同款先例见 `src/hooks/recall.ts:402-407,461`)。
29
+ *
30
+ * 降级(全部不抛错、不新增拒绝路径):
31
+ * - `exec.agent` 缺失 → 返回 undefined(保持既有 fail-open);
32
+ * - 服务缺失 / 链断 → 停止上溯,用自身 id(= 默认档,与修复前一致);
33
+ * - 链上做环检测,自环或成环都能终止。
34
+ */
35
+ const resolveModeOwner = (exec) => {
36
+ const agent = exec.agent;
37
+ const selfId = agent?.id;
38
+ if (selfId === undefined)
39
+ return undefined;
40
+ // 自身有显式档位 → 自己说了算(子代理会话也可被单独设置)
41
+ if (modes.hasEntry(selfId))
42
+ return selfId;
43
+ const seen = new Set([selfId]);
44
+ let cur = agent?.session?.header?.parentSession;
45
+ while (cur !== undefined && !seen.has(cur)) {
46
+ if (modes.hasEntry(cur))
47
+ return cur;
48
+ seen.add(cur);
49
+ // 继续上溯:取该会话的 header(服务缺失时返回 undefined → 循环自然结束)
50
+ const upstream = ctx.get?.('agents');
51
+ cur = upstream?.get?.(cur)?.session?.header?.parentSession;
52
+ }
53
+ return selfId; // 无祖先设过 → 自身(= 默认档,行为与修复前一致)
54
+ };
12
55
  /**
13
56
  * 调用会话的检索族(auto → undefined 不过滤;off/只写 → null 表示整体禁用)。
14
57
  * fail-open:exec.agent 缺失(宿主调用路径未带 agent 标识)按全族检索放行——
15
58
  * 档位隔离依赖宿主正确传递 exec.agent.id,缺失只告警一次不拒绝工具调用。
16
59
  */
17
60
  let warnedNoAgent = false;
18
- const familyOfCaller = (agentId) => {
19
- if (agentId === undefined) {
61
+ const familyOfCaller = (exec) => {
62
+ const owner = resolveModeOwner(exec);
63
+ if (owner === undefined) {
20
64
  if (!warnedNoAgent) {
21
65
  warnedNoAgent = true;
22
66
  logger.warn('[memory] 工具调用缺少 agent 标识(exec.agent 未传递),档位过滤退化为全族检索');
23
67
  }
24
68
  return undefined;
25
69
  }
26
- const mode = modes.get(agentId);
70
+ const mode = modes.get(owner);
27
71
  if (mode === 'off')
28
72
  return null;
29
73
  // 只写会话拒读:与注入同属读维度,不拒则"不注入"从工具路径漏风
30
- if (!modes.resolvedRecall(agentId, live.get().recall))
74
+ if (!modes.resolvedRecall(owner, live.get().recall))
31
75
  return null;
32
76
  return mode === 'auto' ? undefined : mode;
33
77
  };
34
78
  /** 拒读时的归因文案(familyOfCaller 判 null 后重查内存 Map,成本可忽略):
35
- * off 完全隐身 / 会话只写覆盖 / 全局召回关——三种停用各说各话,不谎报只写。 */
36
- const blockNoticeOf = (agentId) => {
37
- if (agentId !== undefined) {
38
- if (modes.get(agentId) === 'off')
79
+ * off 完全隐身 / 会话只写覆盖 / 全局召回关——三种停用各说各话,不谎报只写。
80
+ * **注意按"有效档位归属会话"归因**:子代理拒读时文案取的是其祖先的档位,
81
+ * 而非子代理自身(后者未设置,会谎报成 off)。 */
82
+ const blockNoticeOf = (exec) => {
83
+ const owner = resolveModeOwner(exec);
84
+ if (owner !== undefined) {
85
+ if (modes.get(owner) === 'off')
39
86
  return OFF_NOTICE;
40
- if (modes.getRecall(agentId) === false)
87
+ if (modes.getRecall(owner) === false)
41
88
  return WRITE_ONLY_NOTICE;
42
- if (!modes.resolvedRecall(agentId, live.get().recall))
89
+ if (!modes.resolvedRecall(owner, live.get().recall))
43
90
  return GLOBAL_OFF_NOTICE;
44
91
  }
45
92
  return OFF_NOTICE;
@@ -79,11 +126,17 @@ ruminate) {
79
126
  ],
80
127
  },
81
128
  execute: async (args, exec) => {
82
- const family = familyOfCaller(exec.agent?.id);
129
+ const family = familyOfCaller(exec);
83
130
  if (family === null)
84
- return { items: [], notice: blockNoticeOf(exec.agent?.id) };
131
+ return { items: [], notice: blockNoticeOf(exec) };
85
132
  const limit = Math.min(Math.max(args.limit ?? 5, 1), 20);
86
- const hits = await stores.l1.search(args.query, limit, { type: args.type || undefined, family: family ?? undefined });
133
+ const hits = await stores.l1.search(args.query, limit, {
134
+ type: args.type || undefined,
135
+ family: family ?? undefined,
136
+ // §E 可见范围:`cfg.scope` 非 workspace 时恒为 undefined(= 不过滤),
137
+ // 零漂移由 `scopeFilterOf` 一处收口保证,不靠各调用点各自判断。
138
+ workspaceId: scopeFilterOf(cfg.scope, exec),
139
+ });
87
140
  return {
88
141
  items: hits.map((h) => ({
89
142
  content: h.content,
@@ -128,8 +181,8 @@ ruminate) {
128
181
  ],
129
182
  },
130
183
  execute: async (args, exec) => {
131
- if (familyOfCaller(exec.agent?.id) === null)
132
- return { items: [], notice: blockNoticeOf(exec.agent?.id) };
184
+ if (familyOfCaller(exec) === null)
185
+ return { items: [], notice: blockNoticeOf(exec) };
133
186
  const limit = Math.min(Math.max(args.limit ?? 5, 1), 20);
134
187
  const records = await stores.l0.search(args.query, limit);
135
188
  return {
@@ -162,8 +215,8 @@ ruminate) {
162
215
  ],
163
216
  },
164
217
  execute: async (args, exec) => {
165
- if (familyOfCaller(exec.agent?.id) === null)
166
- return { content: blockNoticeOf(exec.agent?.id) };
218
+ if (familyOfCaller(exec) === null)
219
+ return { content: blockNoticeOf(exec) };
167
220
  const p = args.path.trim();
168
221
  let content;
169
222
  if (p === 'persona.md' || p === 'persona-chat.md' || p === 'persona' || p === 'persona-chat') {
@@ -174,7 +227,7 @@ ruminate) {
174
227
  }
175
228
  else {
176
229
  // 场景文件在两族目录里按名查找(先本族后另一族)
177
- const primary = familyOfCaller(exec.agent?.id) ?? 'chat';
230
+ const primary = familyOfCaller(exec) ?? 'chat';
178
231
  const other = primary === 'chat' ? 'work' : 'chat';
179
232
  content =
180
233
  (await stores.scenes[primary].read(p)) ?? (await stores.scenes[other].read(p));
@@ -208,7 +261,7 @@ ruminate) {
208
261
  * 时间轴同时写顶层字段(时间增强列)与 metadata(列迁移前的兼容层,
209
262
  * 也是面板与图谱时间锚的读取点);`cf`/`rw` 落在 metadata.conflict/rewritten。
210
263
  */
211
- function buildRecord(item, sceneName, now) {
264
+ function buildRecord(item, sceneName, now, workspaceId) {
212
265
  const content = String(item.content ?? '').trim();
213
266
  const type = ADD_TYPES.includes(String(item.type ?? '')) ? String(item.type) : 'episodic';
214
267
  const family = type.startsWith('work') ? 'work' : 'chat';
@@ -247,6 +300,9 @@ ruminate) {
247
300
  version: 0,
248
301
  metadata,
249
302
  family,
303
+ // §E 归属:与抽取管线**同一判据**(`resolveRecordScope`)。写入路径不止一条
304
+ // (pipeline / 本工具 / 批量导入),共用同一函数才不会有"某条路径忘了标归属"。
305
+ ...resolveRecordScope(normScope(cfg.scope), family, workspaceId),
250
306
  ...(validFrom !== undefined ? { validFrom } : {}),
251
307
  ...(validTo !== undefined ? { validTo } : {}),
252
308
  ...(persistence !== undefined ? { persistence } : {}),
@@ -295,14 +351,14 @@ ruminate) {
295
351
  },
296
352
  render: (_args, value) => [{ type: 'text', text: value.notice ?? ('已记录记忆 ' + (value.id ?? '')) }],
297
353
  },
298
- execute: async (args) => {
354
+ execute: async (args, exec) => {
299
355
  if (!live.get().memoryMutate)
300
356
  return { notice: MUTATE_OFF_NOTICE };
301
357
  const content = String(args.content ?? '').trim();
302
358
  if (!content)
303
359
  return { notice: 'content 为空,未写入' };
304
360
  const scene = typeof args.scene === 'string' && args.scene.trim() ? args.scene.trim().slice(0, 120) : '__manual__';
305
- const record = buildRecord(args, scene, Date.now());
361
+ const record = buildRecord(args, scene, Date.now(), workspaceIdOf(exec));
306
362
  await stores.l1.appendNew([record]);
307
363
  logger.info(`[memory] 高权限写入记忆(${record.type}${record.metadata?.hall ? '/' + String(record.metadata.hall) : ''},时间轴 ${record.persistence ?? '?'}):${record.content.slice(0, 120)}`);
308
364
  return { id: record.id };
@@ -361,7 +417,7 @@ ruminate) {
361
417
  { type: 'text', text: value.notice ?? `已导入 ${value.written ?? 0} 条记忆` },
362
418
  ],
363
419
  },
364
- execute: async (args) => {
420
+ execute: async (args, exec) => {
365
421
  if (!live.get().memoryMutate) {
366
422
  return { written: 0, ids: [], skipped: [], notice: MUTATE_OFF_NOTICE };
367
423
  }
@@ -396,7 +452,7 @@ ruminate) {
396
452
  return;
397
453
  }
398
454
  seen.add(key);
399
- records.push(buildRecord(item, scene, now));
455
+ records.push(buildRecord(item, scene, now, workspaceIdOf(exec)));
400
456
  });
401
457
  if (records.length > 0)
402
458
  await stores.l1.appendNew(records);
@@ -432,9 +488,12 @@ ruminate) {
432
488
  const query = String(args.query ?? '').trim();
433
489
  if (!query)
434
490
  return { deleted: 0, ids: [], notice: 'query 为空,未删除' };
435
- const family = familyOfCaller(exec.agent?.id);
491
+ const family = familyOfCaller(exec);
436
492
  const limit = Math.min(Math.max(args.limit ?? 3, 1), 10);
437
- const hits = await stores.l1.search(query, limit, { family: family && family !== null ? family : undefined });
493
+ const hits = await stores.l1.search(query, limit, {
494
+ family: family && family !== null ? family : undefined,
495
+ workspaceId: scopeFilterOf(cfg.scope, exec),
496
+ });
438
497
  const ids = hits.map((h) => h.id);
439
498
  if (ids.length === 0)
440
499
  return { deleted: 0, ids: [], notice: '未找到匹配的记忆,未删除' };
@@ -480,9 +539,9 @@ ruminate) {
480
539
  render: (_args, value) => [{ type: 'text', text: value.notice ?? renderGraphCards(value.items ?? []) }],
481
540
  },
482
541
  execute: async (args, exec) => {
483
- const family = familyOfCaller(exec.agent?.id);
542
+ const family = familyOfCaller(exec);
484
543
  if (family === null)
485
- return { items: [], notice: blockNoticeOf(exec.agent?.id) };
544
+ return { items: [], notice: blockNoticeOf(exec) };
486
545
  const graph = stores.graph;
487
546
  if (!graph)
488
547
  return { items: [], notice: GRAPH_OFF_NOTICE };
@@ -524,9 +583,9 @@ ruminate) {
524
583
  render: (_args, value) => [{ type: 'text', text: value.notice ?? (value.node || '(节点不存在)') }],
525
584
  },
526
585
  execute: async (args, exec) => {
527
- const family = familyOfCaller(exec.agent?.id);
586
+ const family = familyOfCaller(exec);
528
587
  if (family === null)
529
- return { notice: blockNoticeOf(exec.agent?.id) };
588
+ return { notice: blockNoticeOf(exec) };
530
589
  const graph = stores.graph;
531
590
  if (!graph)
532
591
  return { notice: GRAPH_OFF_NOTICE };
@@ -691,7 +750,121 @@ ruminate) {
691
750
  };
692
751
  },
693
752
  }));
694
- logger.info('[memory] 工具已注册: memory_search / conversation_search / memory_read_scene / memory_search_graph / memory_expand_graph_node,及高权限 memory_add/memory_import/memory_delete / memory_ruminate / memory_ruminate_cancel / memory_ruminate_status');
753
+ // ── memory_receipts: §B 决策凭证回溯(读;受与 memory_search 同款档位门) ──
754
+ // 为什么给它一个模型可见的工具:凭证链的价值全在"事后能问"。若只有 RPC 端点,
755
+ // 用户得自己去浏览器/curl 才能回溯,而真正会问「这条记忆怎么来的」的场合
756
+ // 恰恰是在对话里。工具是这条链唯一的**用户可见出口**。
757
+ ctx.tools.register(defineTool({
758
+ name: 'memory_receipts',
759
+ description: '回溯 L1 记忆的**去重决策出处**(决策凭证链)。按 record_id 问"这条记忆出自哪一轮蒸馏、当时看到什么候选池、被判定成了什么";按 run_id 问"那一轮蒸馏都判了什么"(跨多条记录)。两者同给即问"这条记录在那一轮里被判成了什么"。返回决策当时的结论与输入指纹,**不含记忆正文**。注意:由于记录 id 每轮新铸,按 record_id 查询目前通常只返回一条——它回答的是"出自哪",不是"历次变更"。',
760
+ parameters: {
761
+ record_id: { type: 'string', description: '按记忆记录 id 回溯(与 run_id 至少给一个)' },
762
+ run_id: { type: 'string', description: '按某轮蒸馏的 run id 回溯(与 record_id 至少给一个)' },
763
+ limit: { type: 'number', description: `最大返回条数(默认 20,上限 ${RECEIPTS_QUERY_LIMIT_MAX})` },
764
+ },
765
+ output: {
766
+ schema: {
767
+ type: 'object',
768
+ properties: {
769
+ dimension: { type: 'string', description: '命中的维度:record / run / both / none' },
770
+ items: {
771
+ type: 'array',
772
+ items: {
773
+ type: 'object',
774
+ properties: {
775
+ receipt_id: { type: 'string' },
776
+ run_id: { type: 'string' },
777
+ record_id: { type: 'string' },
778
+ kind: { type: 'string', description: 'store / update / merge / skip / conflict / skip_missing' },
779
+ input_digest: { type: 'string' },
780
+ decided_at: { type: 'string' },
781
+ },
782
+ additionalProperties: false,
783
+ },
784
+ },
785
+ total: { type: 'number' },
786
+ notice: { type: 'string', description: '非结果的状态提示(如本会话记忆已关闭)' },
787
+ },
788
+ additionalProperties: false,
789
+ },
790
+ render: (_args, value) => [
791
+ { type: 'text', text: value.notice ?? renderReceipts(value.dimension, value.items ?? [], value.total ?? 0) },
792
+ ],
793
+ },
794
+ execute: async (args, exec) => {
795
+ // 档位拒读门与 memory_search 同款:off 会话对记忆系统完全隐身,不该反过来
796
+ // 能内省记忆系统的判定史(凭证虽不含正文,但泄漏"存在哪些记录/判了什么")。
797
+ const family = familyOfCaller(exec);
798
+ if (family === null)
799
+ return { dimension: 'none', items: [], total: 0, notice: blockNoticeOf(exec) };
800
+ const query = {
801
+ recordId: typeof args.record_id === 'string' && args.record_id.trim() ? args.record_id.trim() : undefined,
802
+ runId: typeof args.run_id === 'string' && args.run_id.trim() ? args.run_id.trim() : undefined,
803
+ };
804
+ const dimension = dimensionOf(query);
805
+ if (dimension === 'none') {
806
+ return {
807
+ dimension,
808
+ items: [],
809
+ total: 0,
810
+ notice: '需要至少一个维度:record_id(这条记忆出自哪一轮、当时候选池是什么)或 run_id(某一轮蒸馏的全部决策)。' +
811
+ '不提供"查全部凭证"——那等于把整库判定史一次性导出。',
812
+ };
813
+ }
814
+ const limit = Math.min(Math.max(args.limit ?? 20, 1), RECEIPTS_QUERY_LIMIT_MAX);
815
+ const rows = stores.l1.listReceipts({ ...query, limit });
816
+ return { dimension, items: rows.map(toReceiptView), total: stores.l1.countReceipts(query) };
817
+ },
818
+ }));
819
+ // ── memory_resolve_conflict: §C 矛盾冻结的人工裁决出口 ──
820
+ // 冻结把裁决权交还给人,那么**必须**有一个"人能把结论说回去"的出口——
821
+ // 否则待裁决队列是个只进不出的黑洞,安全阀(task_24)会成为唯一出路,
822
+ // 那等于把 opt-in 的冻结悄悄退回成"超时后机器自己判"。
823
+ ctx.tools.register(defineTool({
824
+ name: 'memory_resolve_conflict',
825
+ description: '裁决一条**矛盾冻结**的待裁决对(§C)。冻结产生的冲突对停放在待裁决队列里,双方记忆都不被改写,直到你在这里给出结论:winner(判 LLM 建议的胜方为真,败方从检索中退场)、loser(判败方为真)、both(判定两者其实是各自独立的事实,都保留)。需先开启 conflictFreeze 配置;待裁决对可用 memory_conflicts 查看。',
826
+ parameters: {
827
+ pair_id: { type: 'string', description: '待裁决对的 pair_id(来自待裁决队列)' },
828
+ outcome: { type: 'string', description: '裁决结论:winner | loser | both' },
829
+ },
830
+ output: {
831
+ schema: {
832
+ type: 'object',
833
+ properties: {
834
+ pair_id: { type: 'string' },
835
+ outcome: { type: 'string' },
836
+ resolved_at: { type: 'string', description: '裁决时刻(ISO);空串表示未生效' },
837
+ removed_record_id: { type: 'string', description: '因裁决从检索中退场的记录 id(无则空串)' },
838
+ notice: { type: 'string', description: '非结果的状态提示(如未开启冻结 / 该对不存在或已裁决)' },
839
+ },
840
+ additionalProperties: false,
841
+ },
842
+ render: (_args, value) => [{ type: 'text', text: renderConflictResolution(value) }],
843
+ },
844
+ execute: async (args, exec) => {
845
+ const family = familyOfCaller(exec);
846
+ if (family === null) {
847
+ return { pair_id: '', outcome: '', resolved_at: '', removed_record_id: '', notice: blockNoticeOf(exec) };
848
+ }
849
+ const empty = { pair_id: '', outcome: '', resolved_at: '', removed_record_id: '' };
850
+ const pairId = typeof args.pair_id === 'string' ? args.pair_id.trim() : '';
851
+ const outcome = typeof args.outcome === 'string' ? args.outcome.trim() : '';
852
+ if (!pairId)
853
+ return { ...empty, notice: '需要 pair_id:待裁决对没有"全部裁决"这种用法。' };
854
+ return resolveConflictPair({ l1: stores.l1, conflictFreezeEnabled: cfg.conflictFreeze?.enabled === true }, pairId, outcome);
855
+ },
856
+ }));
857
+ logger.info('[memory] 工具已注册: memory_search / conversation_search / memory_read_scene / memory_receipts / memory_search_graph / memory_expand_graph_node,及高权限 memory_add/memory_import/memory_delete / memory_resolve_conflict / memory_ruminate / memory_ruminate_cancel / memory_ruminate_status');
858
+ }
859
+ /** 凭证回溯的人类可读渲染(含"还有多少条没显示")。 */
860
+ function renderReceipts(dimension, items, total) {
861
+ const what = dimension === 'record' ? '该记录出自哪一轮' : dimension === 'run' ? '该批次的全部决策' : '该记录在该批次中的决策';
862
+ if (items.length === 0)
863
+ return `(${what}:没有查到凭证——该 id 可能从未走过 L1 去重,或凭证已超出保留窗口)`;
864
+ const lines = items.map((it, i) => `${i + 1}. [${it.kind ?? ''}] run=${it.run_id ?? ''} record=${it.record_id ?? ''}` +
865
+ `\n 时刻: ${it.decided_at ?? ''}\n 输入指纹: ${(it.input_digest ?? '').slice(0, 16)}…`);
866
+ const more = total > items.length ? `\n…共 ${total} 条,已显示 ${items.length} 条` : '';
867
+ return `${what}(${items.length} 条):\n${lines.join('\n')}${more}`;
695
868
  }
696
869
  function renderGraphCards(items) {
697
870
  if (!items || items.length === 0)
package/dist/types.d.ts CHANGED
@@ -83,6 +83,48 @@ export interface ExtractedMemory {
83
83
  export declare function normExtractedFamily(raw: unknown): MemoryFamily | undefined;
84
84
  /** 记录族三级兜底链:会话档位强制(纯档)→ 抽取显式判定(auto)→ type 前缀推导(旧输出兜底)。 */
85
85
  export declare function resolveRecordFamily(forced: MemoryFamily | undefined, extracted: unknown, type: string): MemoryFamily;
86
+ /**
87
+ * 存储作用域(§E):**可见范围**,与 family(内容类型)正交(ADR-0008 条 1)。
88
+ * - `global` —— 跨工作区可见(默认;既有单根数据全部归此档);
89
+ * - `workspace` —— 仅在本工作区可见。
90
+ *
91
+ * 正交的含义是**四象限都存在**。把这条轴与 family 合并(如"work 族一律 workspace")
92
+ * 会把二维决策压成一维偏好,日后任何一格需要例外时都要返工。
93
+ */
94
+ export type MemoryScope = 'global' | 'workspace';
95
+ /** 配置侧的作用域**模式**取值(与记录级归属同词汇,但语义是「新记忆默认归哪档」)。 */
96
+ export type ScopeMode = MemoryScope;
97
+ /** 作用域归一:非法/缺省一律归 `global`(ADR-0008 条 4:不抛错、不阻断启动)。 */
98
+ export declare function normScope(raw: unknown): MemoryScope;
99
+ /**
100
+ * 可见性判定(§E **读取侧**,与 `resolveRecordScope` 是同一判据的两面)。
101
+ * `global` 记录对任何工作区可见;`workspace` 记录只对**归属工作区相同**的调用可见。
102
+ *
103
+ * `want` 为空串表示"不做过滤"——调用方没传工作区标识(即 `cfg.scope='global'`)时
104
+ * 必须**看不见这个函数存在**,而不是"过滤掉一切"。两条路径分开写死,
105
+ * 免得日后有人把"没传"误当成"匹配空归属"。
106
+ */
107
+ export declare function isScopeVisible(scope: unknown, workspaceId: unknown, want: string): boolean;
108
+ /**
109
+ * 记录级归属判定(§E 写入侧)。与 `resolveRecordFamily` 同构的三级链:
110
+ * **记录显式覆盖 → 配置模式默认 → 兜底 global**。
111
+ *
112
+ * 三条刻意的规则:
113
+ * ① `workspace` 模式下 **work 族默认归 workspace、chat 族默认仍 global**——
114
+ * 个人记忆本就该跨项目("用户偏好简洁回答"不属于任何项目),而污染面恰在项目之间。
115
+ * 注意这是**默认值不是推导规则**:`explicit` 能把它推翻(四象限)。
116
+ * ② **工作区标识缺失时回落 `global`**——不抛、不阻断。宁可退化成"全局可见",
117
+ * 也不能因为拿不到 cwd 就让记忆**写不进去**(fail-open,与本仓库既有降级同向)。
118
+ * ③ `cfg='global'` 时**不写**工作区归属(清空 `workspaceId`)——保证既有部署零漂移:
119
+ * 检索侧"是否传 workspaceId"就是开关,不传即不过滤。
120
+ *
121
+ * @param explicit 记录级显式声明(四象限的载体)。**当前无产品调用方**,
122
+ * 存在的意义是把"正交"从文档里的一句话变成可测的语义;写入入口属未排期项。
123
+ */
124
+ export declare function resolveRecordScope(cfgScope: ScopeMode, family: MemoryFamily, workspaceId?: string, explicit?: MemoryScope): {
125
+ scope: MemoryScope;
126
+ workspaceId: string;
127
+ };
86
128
  /** L1 持久化记录(磁盘与 DB 的权威形状;version/source_message_ids/metadata 由写入侧补默认)。 */
87
129
  export interface MemoryRecord {
88
130
  id: string;
@@ -104,6 +146,12 @@ export interface MemoryRecord {
104
146
  sessionId?: string;
105
147
  /** 所属族(写入缺省由 familyForType(type) 回填;召回/浏览/去重候选按族过滤的唯一依据)。 */
106
148
  family?: MemoryFamily;
149
+ /** 可见范围(§E;写入缺省由 `resolveRecordScope` 回填,缺省 `global`)。
150
+ * 与 family 正交:family 问"这是什么内容",scope 问"它该在多大范围内可见"。 */
151
+ scope?: MemoryScope;
152
+ /** 工作区标识(仅 `scope='workspace'` 时非空;`global` 记录恒为空串)。
153
+ * 取值为会话 cwd 的归一形态——"哪个工作区"由会话本身回答,不做额外配置。 */
154
+ workspaceId?: string;
107
155
  /**
108
156
  * 有效期起(epoch ms):该事实在**真实世界**开始成立的时间。
109
157
  * 与 createdAt(入库时间)是两条不同的轴——"2026-03 在 A 项目"这条事实,
package/dist/types.js CHANGED
@@ -36,3 +36,43 @@ export function normExtractedFamily(raw) {
36
36
  export function resolveRecordFamily(forced, extracted, type) {
37
37
  return forced ?? normExtractedFamily(extracted) ?? familyForType(type);
38
38
  }
39
+ /** 作用域归一:非法/缺省一律归 `global`(ADR-0008 条 4:不抛错、不阻断启动)。 */
40
+ export function normScope(raw) {
41
+ return typeof raw === 'string' && raw.toLowerCase() === 'workspace' ? 'workspace' : 'global';
42
+ }
43
+ /**
44
+ * 可见性判定(§E **读取侧**,与 `resolveRecordScope` 是同一判据的两面)。
45
+ * `global` 记录对任何工作区可见;`workspace` 记录只对**归属工作区相同**的调用可见。
46
+ *
47
+ * `want` 为空串表示"不做过滤"——调用方没传工作区标识(即 `cfg.scope='global'`)时
48
+ * 必须**看不见这个函数存在**,而不是"过滤掉一切"。两条路径分开写死,
49
+ * 免得日后有人把"没传"误当成"匹配空归属"。
50
+ */
51
+ export function isScopeVisible(scope, workspaceId, want) {
52
+ if (normScope(scope) === 'global')
53
+ return true;
54
+ return typeof workspaceId === 'string' && workspaceId !== '' && workspaceId === want;
55
+ }
56
+ /**
57
+ * 记录级归属判定(§E 写入侧)。与 `resolveRecordFamily` 同构的三级链:
58
+ * **记录显式覆盖 → 配置模式默认 → 兜底 global**。
59
+ *
60
+ * 三条刻意的规则:
61
+ * ① `workspace` 模式下 **work 族默认归 workspace、chat 族默认仍 global**——
62
+ * 个人记忆本就该跨项目("用户偏好简洁回答"不属于任何项目),而污染面恰在项目之间。
63
+ * 注意这是**默认值不是推导规则**:`explicit` 能把它推翻(四象限)。
64
+ * ② **工作区标识缺失时回落 `global`**——不抛、不阻断。宁可退化成"全局可见",
65
+ * 也不能因为拿不到 cwd 就让记忆**写不进去**(fail-open,与本仓库既有降级同向)。
66
+ * ③ `cfg='global'` 时**不写**工作区归属(清空 `workspaceId`)——保证既有部署零漂移:
67
+ * 检索侧"是否传 workspaceId"就是开关,不传即不过滤。
68
+ *
69
+ * @param explicit 记录级显式声明(四象限的载体)。**当前无产品调用方**,
70
+ * 存在的意义是把"正交"从文档里的一句话变成可测的语义;写入入口属未排期项。
71
+ */
72
+ export function resolveRecordScope(cfgScope, family, workspaceId, explicit) {
73
+ const wantWorkspace = explicit ?? (cfgScope === 'workspace' && family === 'work' ? 'workspace' : 'global');
74
+ // 没有工作区标识就无从谈"本工作区"——无论请求来自默认还是显式,一律回落 global
75
+ if (wantWorkspace === 'workspace' && workspaceId)
76
+ return { scope: 'workspace', workspaceId };
77
+ return { scope: 'global', workspaceId: '' };
78
+ }
@@ -0,0 +1,46 @@
1
+ /** 会话上下文的最小形状(与 `tools/index.ts` 的 `ToolExecLike` 同源,只取 cwd)。 */
2
+ export interface WorkspaceExecLike {
3
+ agent?: {
4
+ session?: {
5
+ header?: {
6
+ cwd?: string;
7
+ };
8
+ };
9
+ };
10
+ }
11
+ /**
12
+ * 把 cwd 归一成工作区标识。**纯字符串操作**——不碰文件系统、不抛、无 I/O。
13
+ *
14
+ * 三条归一:① `path.resolve` 吃掉 `..` 与尾分隔符;② Windows 大小写不敏感 →
15
+ * 转小写(否则 `E:\proj` 与 `e:\proj` 会被当成两个工作区,记忆凭空分成两份);
16
+ * ③ 空/非字符串 → `undefined`(= 不做隔离,由调用方回落)。
17
+ *
18
+ * @returns 归一后的绝对路径;无法归一时 `undefined`。
19
+ */
20
+ export declare function normalizeWorkspacePath(raw: unknown, platform?: NodeJS.Platform): string | undefined;
21
+ /**
22
+ * 从工具执行上下文取当前工作区标识。取不到 → `undefined`(**不隔离**)。
23
+ *
24
+ * 这是 §E 唯一从宿主读工作区的入口——所有调用点共用它,免得某处自己拼
25
+ * `exec.agent.session.header.cwd` 而漏掉归一(那会让两个拼写不同的同一目录
26
+ * 各写各的归属)。
27
+ */
28
+ export declare function workspaceIdOf(exec: unknown): string | undefined;
29
+ /**
30
+ * §E 统一的「要不要过滤」判据:配置归一后**不是** `workspace` 时返回 `undefined`,
31
+ * 调用方把它原样传给 `L1SearchOptions.workspaceId` → 走"不过滤"分支。
32
+ *
33
+ * 把判断收在这**一个**函数里,是为了让**零漂移成为结构性保证**:
34
+ * 既有部署(`scope='global'`)在任何调用点都**不可能**意外传进一个工作区标识。
35
+ * 若让每个调用点自己去读 `cfg.scope`,迟早有一处漏判——而漏判的表现是
36
+ * "某条路径悄悄不做隔离",不会有任何报错。
37
+ */
38
+ export declare function scopeFilterOf(cfgScope: unknown, exec: unknown): string | undefined;
39
+ /**
40
+ * 按会话 id 解析工作区标识(**写入侧**用:后台蒸馏手上只有 `sessionId`,没有 `exec`)。
41
+ *
42
+ * 走 `ctx.get('agents')` 的**宽容路径**——与 §A 的多级父链解析同一招,
43
+ * 刻意不把它写进 `inject`(声明成硬依赖会让宿主缺该服务时整棵插件树加载失败)。
44
+ * 任何一步拿不到(服务缺失 / agent 不在 / header 无 cwd)→ `undefined` = 不隔离。
45
+ */
46
+ export declare function sessionWorkspaceIdOf(ctx: unknown, sessionId: string): string | undefined;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * §E 工作区标识解析:从会话上下文取"当前工作区",供存储作用域过滤使用。
3
+ *
4
+ * ## 为什么是 cwd,而不是 `ctx.workspaceRegistry` 的 `WorkspaceId`
5
+ *
6
+ * DSH 宿主**已有**工作区实体(`@deepseek-ai/dsh-workspace`):`Workspace.id` 是
7
+ * 一个 uuid,文档明确写着 *"A generated uuid, never the path: path normalization
8
+ * rewrites paths, and a reference anchor must stay stable."* 看似该用它。但四条理由
9
+ * 决定我们用 **canonical cwd**:
10
+ *
11
+ * 1. **同步可得**。`session.header.cwd` 与 §A 用的 `session.header.parentSession`
12
+ * 是**同一条通路**(`dsh-session` 的 `SessionHeader`,两个字段相邻),
13
+ * §A 的 spike 已经把这条通路实测过了——零新增风险。而 `WorkspaceId` 要经
14
+ * `workspaceRegistry.resolveByPath()`,是 **async**。
15
+ * 2. **不能声明 inject**。`workspaceRegistry` 若是硬依赖,宿主缺该服务时**整棵插件树
16
+ * 加载失败**——违反 ADR-0008 条 4。走 `ctx.get()` 宽容路径虽可行,但那就不是
17
+ * "同步可得"了,退化成异步 + 服务缺失分支。
18
+ * 3. **宿主自己就拿 cwd 当工作区身份**。`Workspace` 的成员判据原文:
19
+ * *"Membership requires both an id in that account and a session header whose
20
+ * canonical cwd equals the workspace path."* —— cwd 是宿主认可的身份来源,
21
+ * 不是我们另造的标尺。
22
+ * 4. **uuid 需要一个"已注册"的工作区记录**。消费者不该为了记忆隔离,先要求用户
23
+ * 去宿主里注册工作区。未注册目录在 uuid 方案下**没有 id**,只能整片回落 global,
24
+ * 等于隔离静默失效。
25
+ *
26
+ * ## 已知边界(如实标注,非"待办"而是"选择的代价")
27
+ *
28
+ * 本实现用**字符串归一**(`path.resolve` + Windows 小写),**不解析符号链接**;
29
+ * 宿主 `realpathNormalize()` 走 `fs.realpath`,两者对符号链接拼写不同的同一目录
30
+ * 会给出不同标识。后果是**过度隔离**(同一目录的两种拼写互相看不见),而非泄漏。
31
+ * 取这个方向是因为:拼写差异在实践中罕见(同一会话的 cwd 来自同一来源),
32
+ * 而"过度隔离"至少是安全方向;且 `ctr.realpath` 会**在路径不存在时 reject**,
33
+ * 把一次 fs 失败带进写入与召回主链路,代价高于收益。
34
+ *
35
+ * **另注**:`realpathNormalize` 是 async 且会抛,若要接它必须包 try/catch 并回落——
36
+ * 这属可选精化,已登记为开放项,不在本波做。
37
+ */
38
+ import * as path from 'node:path';
39
+ import { normScope } from './types.js';
40
+ /**
41
+ * 把 cwd 归一成工作区标识。**纯字符串操作**——不碰文件系统、不抛、无 I/O。
42
+ *
43
+ * 三条归一:① `path.resolve` 吃掉 `..` 与尾分隔符;② Windows 大小写不敏感 →
44
+ * 转小写(否则 `E:\proj` 与 `e:\proj` 会被当成两个工作区,记忆凭空分成两份);
45
+ * ③ 空/非字符串 → `undefined`(= 不做隔离,由调用方回落)。
46
+ *
47
+ * @returns 归一后的绝对路径;无法归一时 `undefined`。
48
+ */
49
+ export function normalizeWorkspacePath(raw, platform = process.platform) {
50
+ if (typeof raw !== 'string')
51
+ return undefined;
52
+ const trimmed = raw.trim();
53
+ if (!trimmed)
54
+ return undefined;
55
+ try {
56
+ const resolved = path.resolve(trimmed);
57
+ return platform === 'win32' ? resolved.toLowerCase() : resolved;
58
+ }
59
+ catch {
60
+ // 理论上不会走到(纯字符串解析),但宁可回落也不让一次路径解析炸掉蒸馏
61
+ return undefined;
62
+ }
63
+ }
64
+ /**
65
+ * 从工具执行上下文取当前工作区标识。取不到 → `undefined`(**不隔离**)。
66
+ *
67
+ * 这是 §E 唯一从宿主读工作区的入口——所有调用点共用它,免得某处自己拼
68
+ * `exec.agent.session.header.cwd` 而漏掉归一(那会让两个拼写不同的同一目录
69
+ * 各写各的归属)。
70
+ */
71
+ export function workspaceIdOf(exec) {
72
+ const cwd = exec?.agent?.session?.header?.cwd;
73
+ return normalizeWorkspacePath(cwd);
74
+ }
75
+ /**
76
+ * §E 统一的「要不要过滤」判据:配置归一后**不是** `workspace` 时返回 `undefined`,
77
+ * 调用方把它原样传给 `L1SearchOptions.workspaceId` → 走"不过滤"分支。
78
+ *
79
+ * 把判断收在这**一个**函数里,是为了让**零漂移成为结构性保证**:
80
+ * 既有部署(`scope='global'`)在任何调用点都**不可能**意外传进一个工作区标识。
81
+ * 若让每个调用点自己去读 `cfg.scope`,迟早有一处漏判——而漏判的表现是
82
+ * "某条路径悄悄不做隔离",不会有任何报错。
83
+ */
84
+ export function scopeFilterOf(cfgScope, exec) {
85
+ if (normScope(cfgScope) !== 'workspace')
86
+ return undefined;
87
+ return workspaceIdOf(exec);
88
+ }
89
+ /**
90
+ * 按会话 id 解析工作区标识(**写入侧**用:后台蒸馏手上只有 `sessionId`,没有 `exec`)。
91
+ *
92
+ * 走 `ctx.get('agents')` 的**宽容路径**——与 §A 的多级父链解析同一招,
93
+ * 刻意不把它写进 `inject`(声明成硬依赖会让宿主缺该服务时整棵插件树加载失败)。
94
+ * 任何一步拿不到(服务缺失 / agent 不在 / header 无 cwd)→ `undefined` = 不隔离。
95
+ */
96
+ export function sessionWorkspaceIdOf(ctx, sessionId) {
97
+ try {
98
+ const agents = ctx?.get?.('agents');
99
+ const cwd = agents?.get?.(sessionId)?.session?.header?.cwd;
100
+ return normalizeWorkspacePath(cwd);
101
+ }
102
+ catch {
103
+ return undefined;
104
+ }
105
+ }