@max-null/dsh-memory 0.9.2 → 0.11.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.
package/dist/engine.js CHANGED
@@ -6,8 +6,9 @@
6
6
  */
7
7
  import { randomUUID } from 'node:crypto';
8
8
  import { existsSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
9
+ import { readdir } from 'node:fs/promises';
9
10
  import { homedir } from 'node:os';
10
- import { join } from 'node:path';
11
+ import { dirname, join } from 'node:path';
11
12
  import { Service } from '@deepseek-ai/cordis';
12
13
  import { z } from 'zod';
13
14
  import { defineDomain, domainTable, DomainFacility } from '@deepseek-ai/dsh-storage-domain';
@@ -166,6 +167,10 @@ export class MemoryEngine extends Service {
166
167
  globalTable;
167
168
  /** project 域按工作区 cwd 懒打开 + 缓存(多会话并发各工作区独立)。 */
168
169
  projectTables = new Map();
170
+ /** 子项目发现缓存(④-B 2026-09-19):key = 工作区路径;值 = 其直接子目录中带记忆的(按条数降序)。 */
171
+ neighborCache = new Map();
172
+ /** 进行中的子项目发现(key → promise):预热是 fire-and-forget,之后的显式调用与它合流而非重复扫描。 */
173
+ neighborOpenings = new Map();
169
174
  /** 进行中的工作区打开(key → promise):并发调用复用同一次,见 {@link projectTableFor}。 */
170
175
  projectOpenings = new Map();
171
176
  projectFacilities = new Map();
@@ -311,6 +316,7 @@ export class MemoryEngine extends Service {
311
316
  await facility.closeAll();
312
317
  this.projectTables.clear();
313
318
  this.projectFacilities.clear();
319
+ this.neighborCache.clear(); // ④-B:子项目发现随存储一并作废(外部实例可能刚改过)
314
320
  await this.openGlobalFacility();
315
321
  for (const key of opened) {
316
322
  // 重开失败 = 该工作区退回「未打开」(本轮注入缺这部分,下次访问重试),
@@ -350,15 +356,178 @@ export class MemoryEngine extends Service {
350
356
  const key = join(projectCwd);
351
357
  return join(this.projectRootFor(key), `${this.projectBackendName(key)}.json`);
352
358
  }
353
- /** 记录当前指纹,作为「这就是我们造成的状态」的基准(写入完成后调用)。 */
354
- noteStoreStamps(projectCwd) {
355
- const global = this.globalStoreFile();
356
- this.storeStamps.set(global, MemoryEngine.fileStamp(global));
359
+ /**
360
+ * 工作区链(④-A 2026-09-19):当前 cwd 起向上,收集**记忆文件已存在**的层级。
361
+ *
362
+ * 两条约束:
363
+ * ① **自身始终纳入**(depth 0)——即使文件还不存在,也保持既有行为(`projectTableFor`
364
+ * 会创建它,会话预热路径同样如此)。
365
+ * ② **祖先只收文件已存在的**——对不存在的层级打开表会让 JsonStorageBackend **创建**空
366
+ * 文件,等于在用户每个祖先目录里留一个空记忆文件。
367
+ *
368
+ * `depth` 是真实的上级层数(每向上一步 +1,无论该级是否有文件),供来源标记渲染成
369
+ * `..` / `../..`。路径一律先 `join()` 规范化再算哈希——`projectBackendName` 对字符串
370
+ * 敏感,`H:\a\b` 与 `H:/a/b` 会得出**不同**的文件名(设计文档 §2.3)。
371
+ */
372
+ projectChain(cwd) {
373
+ const key = join(cwd);
374
+ const chain = [{ key, depth: 0 }];
375
+ let cur = key;
376
+ let depth = 0;
377
+ for (;;) {
378
+ const parent = dirname(cur);
379
+ if (parent === cur)
380
+ break; // 到盘根
381
+ cur = parent;
382
+ depth += 1;
383
+ if (existsSync(this.projectStoreFile(cur)))
384
+ chain.push({ key: cur, depth });
385
+ }
386
+ return chain;
387
+ }
388
+ /**
389
+ * 打开工作区链上的表(由近及远)。复用 {@link projectTableFor} 的缓存与并发去重——
390
+ * 链上每级各自一张表,重复调用不会重复打开。
391
+ */
392
+ async projectChainTables(cwd) {
393
+ if (cwd === undefined || cwd === '')
394
+ return [];
395
+ const out = [];
396
+ for (const { key, depth } of this.projectChain(cwd)) {
397
+ const table = await this.projectTableFor(key).catch(() => undefined);
398
+ if (table !== undefined)
399
+ out.push({ key, depth, table });
400
+ }
401
+ return out;
402
+ }
403
+ /** 来源标记:depth 0(当前工作区)不带标记;祖先渲染为 `..` / `../..`。 */
404
+ static scopeLabel(depth) {
405
+ if (depth === 0)
406
+ return undefined;
407
+ return Array.from({ length: depth }, () => '..').join('/');
408
+ }
409
+ /**
410
+ * 子项目发现(④-B 2026-09-19):扫描 cwd 的**直接子目录**(不递归——实测一层即覆盖全部
411
+ * 实际情况:19 个子目录里 3 个带记忆),找出带记忆文件、且其中有非隔离记录的。
412
+ *
413
+ * 结果缓存进 {@link neighborCache}——注入 provider 是同步的,只能读缓存;扫描本身在这个
414
+ * 异步方法里做,由 `ensureProjectOpen` 预热路径触发。
415
+ *
416
+ * 跳过点开头的目录(`.dsh` / `.git` 等不是子项目);只报条数、不搬运内容——索引行的用途
417
+ * 是「告诉你去搜」,不是把子项目的记忆变成当前会话的持续成本。
418
+ */
419
+ async discoverNeighbors(projectCwd) {
420
+ const key = join(projectCwd);
421
+ const cached = this.neighborCache.get(key);
422
+ if (cached !== undefined)
423
+ return cached;
424
+ // 进行中那次复用:`ensureProjectOpen` 的预热是 fire-and-forget,紧随其后的显式调用
425
+ // (工具路径 / 测试 / 面板)若各扫一遍,既浪费也可能读到半成品。与 `projectTableFor`
426
+ // 同一套去重语义。
427
+ const inFlight = this.neighborOpenings.get(key);
428
+ if (inFlight !== undefined)
429
+ return await inFlight;
430
+ const scan = this.scanNeighbors(key);
431
+ this.neighborOpenings.set(key, scan);
432
+ try {
433
+ return await scan;
434
+ }
435
+ finally {
436
+ this.neighborOpenings.delete(key);
437
+ }
438
+ }
439
+ /** {@link discoverNeighbors} 的实际扫描;并发去重由调用方负责。 */
440
+ async scanNeighbors(key) {
441
+ const found = [];
442
+ let entries = [];
443
+ try {
444
+ entries = await readdir(key, { withFileTypes: true });
445
+ }
446
+ catch {
447
+ entries = []; // 目录读不到(不存在 / 无权限):当作没有子项目,不报错
448
+ }
449
+ for (const entry of entries) {
450
+ if (!entry.isDirectory() || entry.name.startsWith('.'))
451
+ continue;
452
+ const sub = join(key, entry.name);
453
+ if (!existsSync(this.projectStoreFile(sub)))
454
+ continue;
455
+ const table = await this.projectTableFor(sub).catch(() => undefined);
456
+ if (table === undefined)
457
+ continue;
458
+ const count = this.recordsOf(table).filter(record => record.quarantined !== true).length;
459
+ if (count > 0)
460
+ found.push({ name: entry.name, count });
461
+ }
462
+ found.sort((left, right) => right.count - left.count);
463
+ this.neighborCache.set(key, found);
464
+ return found;
465
+ }
466
+ /**
467
+ * 同步读取已发现的子项目(④-B):注入 provider 是同步回调,只能读缓存。
468
+ * 缓存由 {@link discoverNeighbors} 在预热路径填充;未跑过时返回空数组——该轮索引行
469
+ * 缺席、下一轮补上,与工作区表预热同一套取舍。
470
+ */
471
+ neighborsOf(projectCwd) {
472
+ if (projectCwd === undefined || projectCwd === '')
473
+ return [];
474
+ return this.neighborCache.get(join(projectCwd)) ?? [];
475
+ }
476
+ /**
477
+ * 同步读取索引候选(① 2026-09-19):global + 工作区链上**已打开**的表里的全部记录。
478
+ *
479
+ * 与 {@link recallRecords} 的分工:后者只取 `injected` 的(进注入行),前者取全部
480
+ * (供索引行统计规模与主题)。同样是同步只读已缓存的表——注入 provider 是同步回调,
481
+ * 未打开的层级本轮不计入,影响可忽略(链上祖先在检索路径打开后即缓存)。
482
+ */
483
+ indexCandidates(projectCwd) {
484
+ const candidates = this.recordsOf(this.requireTable('global'));
485
+ if (projectCwd === undefined || projectCwd === '')
486
+ return candidates;
487
+ for (const { key } of this.projectChain(projectCwd)) {
488
+ const table = this.projectTables.get(key);
489
+ if (table !== undefined)
490
+ candidates.push(...this.recordsOf(table));
491
+ }
492
+ return candidates;
493
+ }
494
+ /**
495
+ * 按 id 定位记录所在表:global 优先,其次工作区链(自身 → 祖先,由近及远)。
496
+ * **异步版**——写路径必须用它:`writeScope` 可能在本次进入前刚 `reload()`,
497
+ * 那时同步缓存已被清空(同 {@link markUsedInner} 的注释)。
498
+ */
499
+ async locateRecord(id, projectCwd) {
500
+ const global = this.requireTable('global');
501
+ const globalBlock = global.get(id);
502
+ if (globalBlock !== undefined)
503
+ return { table: global, block: globalBlock };
504
+ for (const { table } of await this.projectChainTables(projectCwd)) {
505
+ const block = table.get(id);
506
+ if (block !== undefined)
507
+ return { table, block };
508
+ }
509
+ return undefined;
510
+ }
511
+ /**
512
+ * 写入协调覆盖的存储文件:global + **全部已打开的工作区表** + 本次 cwd。
513
+ *
514
+ * 记录({@link noteStoreStamps})与比对({@link refreshForWrite})必须共用这一个定义——
515
+ * 两边各写一份正是漂移的来源:在此之前比对侧只列 global + cwd,而 ④-A 把**读**路径扩到了
516
+ * 祖先链(`projectChain`),于是被缓存过的祖先层「指纹记下了却从不被检查」——外部改动看不见,
517
+ * 下一次写回把它**整份覆盖**。**读扩到哪,门就得跟着扩到哪。**
518
+ */
519
+ storeScopeFiles(projectCwd) {
520
+ const files = [this.globalStoreFile()];
357
521
  const scopes = new Set(this.projectTables.keys());
358
522
  if (projectCwd !== undefined && projectCwd !== '')
359
523
  scopes.add(join(projectCwd));
360
- for (const key of scopes) {
361
- const file = this.projectStoreFile(key);
524
+ for (const key of scopes)
525
+ files.push(this.projectStoreFile(key));
526
+ return files;
527
+ }
528
+ /** 记录当前指纹,作为「这就是我们造成的状态」的基准(写入完成后调用)。 */
529
+ noteStoreStamps(projectCwd) {
530
+ for (const file of this.storeScopeFiles(projectCwd)) {
362
531
  this.storeStamps.set(file, MemoryEngine.fileStamp(file));
363
532
  }
364
533
  }
@@ -371,10 +540,7 @@ export class MemoryEngine extends Service {
371
540
  this.noteStoreStamps(projectCwd);
372
541
  return;
373
542
  }
374
- const files = [this.globalStoreFile()];
375
- if (projectCwd !== undefined && projectCwd !== '')
376
- files.push(this.projectStoreFile(projectCwd));
377
- for (const file of files) {
543
+ for (const file of this.storeScopeFiles(projectCwd)) {
378
544
  if (MemoryEngine.fileStamp(file) === this.storeStamps.get(file))
379
545
  continue;
380
546
  await this.reload();
@@ -601,14 +767,19 @@ export class MemoryEngine extends Service {
601
767
  }
602
768
  return { ...(toolNames === undefined ? {} : { toolNames }), selfVersion: SELF_VERSION };
603
769
  }
770
+ /** 同步版定位:只查**已打开**的表(global + 链上已缓存的工作区表)。 */
604
771
  tableOf(id, projectCwd) {
605
772
  const global = this.requireTable('global');
606
773
  if (global.get(id) !== undefined)
607
774
  return global;
608
775
  if (projectCwd === undefined || projectCwd === '')
609
776
  return undefined;
610
- const project = this.projectTables.get(join(projectCwd));
611
- return project !== undefined && project.get(id) !== undefined ? project : undefined;
777
+ for (const { key } of this.projectChain(projectCwd)) {
778
+ const table = this.projectTables.get(key);
779
+ if (table !== undefined && table.get(id) !== undefined)
780
+ return table;
781
+ }
782
+ return undefined;
612
783
  }
613
784
  /** 命中标记:写回 lastUsedAt 与命中次数,达标即自动升常驻(2026-09-15)。 */
614
785
  async markUsed(ids, projectCwd) {
@@ -622,17 +793,21 @@ export class MemoryEngine extends Service {
622
793
  const global = this.requireTable('global');
623
794
  // 取表走异步版:`writeScope` 可能在本次进入前刚 `reload()`(外部实例改过存储),
624
795
  // 那时同步缓存已被清空——继续读缓存会把 project 侧的命中静默漏记。
625
- const project = await this.projectTableFor(projectCwd);
796
+ // ④-A:链上的祖先表同样纳入。跨工作区命中的条目也要记账——否则它们的
797
+ // `lastUsedAt` 长期不动,会被 30 天规则误撤(「用得到却没被记录」)。
798
+ const chainTables = await this.projectChainTables(projectCwd);
626
799
  for (const id of ids) {
627
800
  const globalBlock = global.get(id);
628
801
  if (globalBlock !== undefined) {
629
802
  await this.recordUse(global, id, globalBlock, now);
630
803
  continue;
631
804
  }
632
- if (project !== undefined) {
633
- const block = project.get(id);
634
- if (block !== undefined)
635
- await this.recordUse(project, id, block, now);
805
+ for (const { table } of chainTables) {
806
+ const block = table.get(id);
807
+ if (block !== undefined) {
808
+ await this.recordUse(table, id, block, now);
809
+ break;
810
+ }
636
811
  }
637
812
  }
638
813
  }
@@ -935,52 +1110,129 @@ export class MemoryEngine extends Service {
935
1110
  this.ctx.emit('memory/changed', { operation: 'forgotten', id });
936
1111
  return true;
937
1112
  }
938
- const project = await this.projectTableFor(projectCwd);
939
- if (project !== undefined && await project.delete(id)) {
940
- this.ctx.emit('memory/changed', { operation: 'forgotten', id });
941
- return true;
1113
+ // ④-A:删除要能命中链上的祖先表——「检索得到却删不掉」是最别扭的一种不一致
1114
+ for (const { table } of await this.projectChainTables(projectCwd)) {
1115
+ if (await table.delete(id)) {
1116
+ this.ctx.emit('memory/changed', { operation: 'forgotten', id });
1117
+ return true;
1118
+ }
942
1119
  }
943
1120
  return false;
944
1121
  }
1122
+ /**
1123
+ * 把一条记忆移到另一层(迭代设计 §3.2)。**两个方向共用一个操作**:向上提到祖先层或
1124
+ * `global`,向下把误放高层的降回它真正属于的工作区。
1125
+ *
1126
+ * 原设计(`memory_promote`)只允许向上,理由是「往下或横向会让原本能看见的人失明」。
1127
+ * 但**移动的本意就是改变谁能看见**——那不是副作用,是目的。所以约束从「禁止方向」
1128
+ * 换成「让后果可见」:返回值里的 `sourceStillSees` 就是那个后果。
1129
+ */
1130
+ async move(id, to, projectCwd) {
1131
+ return this.writeScope(projectCwd, () => this.moveInner(id, to, projectCwd));
1132
+ }
1133
+ /** {@link move} 的实现体;必须经 `writeScope` 进入。 */
1134
+ async moveInner(id, to, projectCwd) {
1135
+ const target = MemoryEngine.resolveTarget(to, projectCwd);
1136
+ const found = await this.locateWithKey(id, projectCwd);
1137
+ if (found === undefined)
1138
+ throw new Error(`cannot move unknown memory '${id}'`);
1139
+ const destination = target.kind === 'global'
1140
+ ? this.requireTable('global')
1141
+ : await this.projectTableFor(target.key);
1142
+ if (destination === undefined)
1143
+ throw new Error('cannot move a project memory without a workspace cwd');
1144
+ if (destination === found.table)
1145
+ throw new Error(`memory '${id}' is already stored in '${to}'`);
1146
+ // 先写目标、再删源:中间态里它同时存在于两处(重复好过丢失),而两次写同处一个
1147
+ // writeScope,外部看不到中间态。
1148
+ await destination.put(id, found.block);
1149
+ await found.table.delete(id);
1150
+ // 继承只向上,而 global 人人可见——所以「移进 global」与「移进自己的某个祖先」都能
1151
+ // 让源层继续看得见它;反过来(global → 某工作区、祖先 → 后代)就是缩小范围。
1152
+ const sourceStillSees = target.kind === 'global'
1153
+ || (found.key !== 'global' && MemoryEngine.isAncestorOrSelf(target.key, found.key));
1154
+ const movedTo = target.kind === 'global' ? 'global' : target.key;
1155
+ this.ctx.emit('memory/changed', { operation: 'moved', id, from: found.key, to: movedTo });
1156
+ return { id, from: found.key, to: movedTo, sourceStillSees };
1157
+ }
1158
+ /** 按 id 定位记录**并带上它所在的层**(`locateRecord` 只返回表,而移动必须知道来源层)。 */
1159
+ async locateWithKey(id, projectCwd) {
1160
+ const global = this.requireTable('global');
1161
+ const globalBlock = global.get(id);
1162
+ if (globalBlock !== undefined)
1163
+ return { key: 'global', table: global, block: globalBlock };
1164
+ for (const { key, table } of await this.projectChainTables(projectCwd)) {
1165
+ const block = table.get(id);
1166
+ if (block !== undefined)
1167
+ return { key, table, block };
1168
+ }
1169
+ return undefined;
1170
+ }
1171
+ /**
1172
+ * 解析 `memory_move` 的目标:`global`、`self` / `.`(当前 cwd),或相对祖先(`..`、`../..`)。
1173
+ *
1174
+ * 用**相对深度**而不是绝对路径,是为了和来源标记 `scope`(`..` / `../..`)说同一套话。
1175
+ * 允许落在**还没有记忆文件**的祖先层——移动是显式动作,在那儿创建文件正是目的,
1176
+ * 不是 `projectChain` 要避免的那种副作用。
1177
+ */
1178
+ static resolveTarget(to, projectCwd) {
1179
+ const raw = to.trim();
1180
+ if (raw === 'global')
1181
+ return { kind: 'global' };
1182
+ if (projectCwd === undefined || projectCwd === '') {
1183
+ throw new Error('moving a memory into a workspace layer needs a session cwd');
1184
+ }
1185
+ if (raw === 'self' || raw === '.')
1186
+ return { kind: 'workspace', key: join(projectCwd) };
1187
+ const parts = raw.split(/[\\/]/).filter(part => part !== '' && part !== '.');
1188
+ if (!parts.every(part => part === '..')) {
1189
+ throw new Error(`unknown move target '${to}' — use 'global', 'self', or a relative ancestor ('..', '../..')`);
1190
+ }
1191
+ let key = join(projectCwd);
1192
+ for (let i = 0; i < parts.length; i++) {
1193
+ const parent = dirname(key);
1194
+ if (parent === key)
1195
+ throw new Error(`move target '${to}' points above the filesystem root`);
1196
+ key = parent;
1197
+ }
1198
+ return { kind: 'workspace', key };
1199
+ }
1200
+ /** `ancestor` 是否就是 `node` 本身或它的祖先——用来判断移动后源层是否仍看得见。 */
1201
+ static isAncestorOrSelf(ancestor, node) {
1202
+ const want = join(ancestor).toLowerCase();
1203
+ let cur = join(node);
1204
+ for (;;) {
1205
+ if (cur.toLowerCase() === want)
1206
+ return true;
1207
+ const parent = dirname(cur);
1208
+ if (parent === cur)
1209
+ return false;
1210
+ cur = parent;
1211
+ }
1212
+ }
945
1213
  async setStatus(id, status, projectCwd) {
946
1214
  return this.writeScope(projectCwd, () => this.setStatusInner(id, status, projectCwd));
947
1215
  }
948
1216
  /** {@link setStatus} 的实现体;必须经 `writeScope` 进入。 */
949
1217
  async setStatusInner(id, status, projectCwd) {
950
- const global = this.requireTable('global').get(id);
951
- if (global !== undefined) {
952
- const updated = {
953
- ...global,
954
- status,
955
- // 旧数据可能缺 injected,写回时补全(读时迁移值)
956
- injected: normalizeBlock(global).injected,
957
- // 人工放行 = 审核通过并解除隔离(2026-09-15)
958
- ...status === 'approved' ? { quarantined: false, quarantineReason: undefined } : {},
959
- updatedAt: Date.now(),
960
- };
961
- await this.requireTable('global').put(id, updated);
962
- const record = toRecord(id, updated);
963
- this.ctx.emit('memory/changed', { operation: 'status', id, status });
964
- return record;
965
- }
966
- const project = await this.projectTableFor(projectCwd);
967
- if (project !== undefined) {
968
- const block = project.get(id);
969
- if (block !== undefined) {
970
- const updated = {
971
- ...block,
972
- status,
973
- injected: normalizeBlock(block).injected,
974
- ...status === 'approved' ? { quarantined: false, quarantineReason: undefined } : {},
975
- updatedAt: Date.now(),
976
- };
977
- await project.put(id, updated);
978
- const record = toRecord(id, updated);
979
- this.ctx.emit('memory/changed', { operation: 'status', id, status });
980
- return record;
981
- }
982
- }
983
- throw new Error(`cannot set status of unknown memory '${id}'`);
1218
+ // ④-A:定位走工作区链(global 优先,其次自身 → 祖先)——检索得到的记录就该改得动
1219
+ const found = await this.locateRecord(id, projectCwd);
1220
+ if (found === undefined)
1221
+ throw new Error(`cannot set status of unknown memory '${id}'`);
1222
+ const { table, block } = found;
1223
+ const updated = {
1224
+ ...block,
1225
+ status,
1226
+ // 旧数据可能缺 injected,写回时补全(读时迁移值)
1227
+ injected: normalizeBlock(block).injected,
1228
+ // 人工放行 = 审核通过并解除隔离(2026-09-15)
1229
+ ...status === 'approved' ? { quarantined: false, quarantineReason: undefined } : {},
1230
+ updatedAt: Date.now(),
1231
+ };
1232
+ await table.put(id, updated);
1233
+ const record = toRecord(id, updated);
1234
+ this.ctx.emit('memory/changed', { operation: 'status', id, status });
1235
+ return record;
984
1236
  }
985
1237
  /**
986
1238
  * 注入维度开关(0.3.0):只改 injected,不动审核状态。供 UI 面板「常驻注入」开关调用
@@ -994,19 +1246,8 @@ export class MemoryEngine extends Service {
994
1246
  }
995
1247
  /** {@link setInjected} 的实现体;必须经 `writeScope` 进入。 */
996
1248
  async setInjectedInner(id, injected, projectCwd) {
997
- const locate = async () => {
998
- const global = this.requireTable('global').get(id);
999
- if (global !== undefined)
1000
- return { table: this.requireTable('global'), block: global };
1001
- const project = await this.projectTableFor(projectCwd);
1002
- if (project !== undefined) {
1003
- const block = project.get(id);
1004
- if (block !== undefined)
1005
- return { table: project, block };
1006
- }
1007
- return undefined;
1008
- };
1009
- const found = await locate();
1249
+ // ④-A:定位走工作区链(global 优先,其次自身 → 祖先)
1250
+ const found = await this.locateRecord(id, projectCwd);
1010
1251
  if (found === undefined)
1011
1252
  throw new Error(`cannot set injected of unknown memory '${id}'`);
1012
1253
  // 模板永不注入(0.6.0):prompt 记录拒绝开关(UI 对该类不展示开关,此处兜底)
@@ -1077,22 +1318,14 @@ export class MemoryEngine extends Service {
1077
1318
  }
1078
1319
  return record;
1079
1320
  };
1080
- const global = this.requireTable('global').get(id);
1081
- if (global !== undefined) {
1082
- const updated = applyPatch(global);
1083
- await this.requireTable('global').put(id, updated);
1084
- return publish(global, updated);
1085
- }
1086
- const project = await this.projectTableFor(projectCwd);
1087
- if (project !== undefined) {
1088
- const block = project.get(id);
1089
- if (block !== undefined) {
1090
- const updated = applyPatch(block);
1091
- await project.put(id, updated);
1092
- return publish(block, updated);
1093
- }
1094
- }
1095
- throw new Error(`cannot update unknown memory '${id}'`);
1321
+ // ④-A:定位走工作区链(global 优先,其次自身 → 祖先)——检索得到的记录就该改得动
1322
+ const found = await this.locateRecord(id, projectCwd);
1323
+ if (found === undefined)
1324
+ throw new Error(`cannot update unknown memory '${id}'`);
1325
+ const { table, block } = found;
1326
+ const updated = applyPatch(block);
1327
+ await table.put(id, updated);
1328
+ return publish(block, updated);
1096
1329
  }
1097
1330
  /**
1098
1331
  * 预热某工作区的 project 表(异步打开并缓存)。system prompt 的
@@ -1101,6 +1334,10 @@ export class MemoryEngine extends Service {
1101
1334
  */
1102
1335
  async ensureProjectOpen(projectCwd) {
1103
1336
  const key = join(projectCwd);
1337
+ // ④-B:子项目发现与表预热相互独立——表可能早已打开,而发现还没跑过。
1338
+ // 注入 provider 是同步的、只能读缓存,所以必须在这条异步预热路径上先把发现做掉。
1339
+ if (!this.neighborCache.has(key))
1340
+ void this.discoverNeighbors(key).catch(() => undefined);
1104
1341
  if (this.projectTables.has(key))
1105
1342
  return;
1106
1343
  await this.projectTableFor(key);
@@ -1130,19 +1367,20 @@ export class MemoryEngine extends Service {
1130
1367
  return [...global, ...injected(project)];
1131
1368
  }
1132
1369
  async allRecords(namespace, projectCwd) {
1133
- if (namespace === 'project') {
1134
- const project = await this.projectTableFor(projectCwd);
1135
- if (project === undefined)
1136
- return [];
1137
- return this.recordsOf(project);
1138
- }
1370
+ // ④-A:project 侧从「当前 cwd 那一张表」扩为「工作区链上的表」(自身 + 已存在的祖先)。
1371
+ // 祖先记录带 scope 标记(`..` / `../..`),让调用方与用户都看得出这条来自哪一级。
1372
+ const projectRecords = async () => {
1373
+ const tables = await this.projectChainTables(projectCwd);
1374
+ return tables.flatMap(({ depth, table }) => {
1375
+ const scope = MemoryEngine.scopeLabel(depth);
1376
+ return this.recordsOf(table).map(record => scope === undefined ? record : { ...record, scope });
1377
+ });
1378
+ };
1379
+ if (namespace === 'project')
1380
+ return projectRecords();
1139
1381
  if (namespace === 'global')
1140
1382
  return this.recordsOf(this.requireTable('global'));
1141
- const project = await this.projectTableFor(projectCwd);
1142
- return [
1143
- ...this.recordsOf(this.requireTable('global')),
1144
- ...(project === undefined ? [] : this.recordsOf(project)),
1145
- ];
1383
+ return [...this.recordsOf(this.requireTable('global')), ...await projectRecords()];
1146
1384
  }
1147
1385
  recordsOf(table) {
1148
1386
  return [...table.entries()].map(([id, block]) => toRecord(id, block));
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ import { MemoryEngine } from "./engine.js";
9
9
  import { MemoryGateway } from "./remote.js";
10
10
  import { mountMemoryApi } from "./routes.js";
11
11
  import { SELF_DESCRIPTION } from "./self.js";
12
- import { DEFAULT_INJECTION_BUDGET, DEFAULT_SUMMARY_CHARS, omittedNotice, renderInjection } from "./injection.js";
12
+ import { DEFAULT_INJECTION_BUDGET, DEFAULT_SUMMARY_CHARS, indexNotice, neighborNotice, omittedNotice, renderInjection } from "./injection.js";
13
13
  export { MemoryEngine } from "./engine.js";
14
14
  export { MemoryId } from "./engine.js";
15
15
  export { bm25FieldScores, bm25Scores, cosineSimilarity, rrfFuse, tokenize } from "./bm25.js";
@@ -32,6 +32,7 @@ const RECORD_SCHEMA = {
32
32
  staleReason: { type: 'string' },
33
33
  hitCount: { type: 'number' },
34
34
  quarantined: { type: 'boolean' },
35
+ scope: { type: 'string', description: '来源工作区(④-A):缺省 = 当前会话工作区;`..` / `../..` = 上级工作区。' },
35
36
  },
36
37
  };
37
38
  const HIT_SCHEMA = {
@@ -72,6 +73,7 @@ function recordValue(record) {
72
73
  ...record.staleReason === undefined ? {} : { staleReason: record.staleReason },
73
74
  hitCount: record.hitCount,
74
75
  ...record.quarantined === true ? { quarantined: true } : {},
76
+ ...record.scope === undefined ? {} : { scope: record.scope },
75
77
  };
76
78
  }
77
79
  function hitValue(hit) {
@@ -94,12 +96,24 @@ function promptValue(record) {
94
96
  // 采用中英双语都写、模型自取——设计文档「风险与注意」)。
95
97
  // 0.9.2:常驻注入不再是「人工开关」——0.8.0 起模型侧可经 `injected` 参数显式钉住。
96
98
  // 旧表述会让模型以为自己没有该权限,也与同段「人工只在例外时介入」自相矛盾。
99
+ // 0.10.0:补 namespace 判据——此前只有工具 schema 的 `defaults to global`,全体系没有
100
+ // 任何「该记哪一层」的说明,陌生环境的模型会全写 global,project 层因此永远空着。
97
101
  const GUIDANCE = 'Use memory tools for cross-session preferences, habits, and project conventions. '
98
102
  + '记忆工具用于跨会话的偏好、习惯与项目约定。'
99
103
  + 'memory_save writes a memory that takes effect immediately (`approved`) — memories are maintained '
100
104
  + 'by the system and the model, and humans intervene only as exceptions. Credential-like content is '
101
105
  + 'quarantined instead. memory_save 写入即生效(`approved`)——记忆由系统与模型自行维护,人工只在例外时介入;'
102
106
  + '命中危险内容规则(密钥/凭据)的写入会被隔离。'
107
+ + 'Choose the namespace by one question — **does this still hold in a different project?** Yes → `global` '
108
+ + '(preferences, habits, environment knowledge); no → `project` (this repository\'s own conventions, '
109
+ + 'architecture decisions, its pitfalls). '
110
+ + '用一句话选 namespace——**换个项目,这条还成立吗?** 成立 → `global`(偏好、习惯、环境知识);'
111
+ + '不成立 → `project`(本仓库自己的约定、架构决策、它踩过的坑)。'
112
+ + 'Record where a conclusion came from, not just the conclusion: the reason it holds, and the situation '
113
+ + 'that forced it out. A conclusion without its origin is a ruler with no provenance — it looks '
114
+ + 'self-evident, and self-evident things get applied mechanically. '
115
+ + '记结论时一并记下**它是怎么来的**——它为什么成立,以及是什么场景把它逼出来的。没有出处的结论像一把'
116
+ + '没有来历的尺子:看起来天经地义,而天经地义的东西最容易被机械套用。'
103
117
  + 'Pass `injected: true` on memory_save / memory_update to pin a memory resident in every turn\'s context: '
104
118
  + 'the same switch the settings panel exposes, exempting the record from both automatic rules (promote on '
105
119
  + 'repeated hits, demote after long idle). Reserve it for rules, standing agreements and judgement criteria '
@@ -131,6 +145,14 @@ function recallText(memory, projectCwd, budget, summaryChars) {
131
145
  const notice = omittedNotice(rendered);
132
146
  if (notice !== '')
133
147
  parts.push(notice);
148
+ // ① 索引行:给出「库里还有什么、按什么去搜」(不占预算、长度常数级)
149
+ const index = indexNotice(memory.indexCandidates(projectCwd));
150
+ if (index !== '')
151
+ parts.push(index);
152
+ // ④-B:子项目索引(有才出、不占预算)——补「不知道存在」这个检索的结构性盲区
153
+ const neighbors = neighborNotice(memory.neighborsOf(projectCwd));
154
+ if (neighbors !== '')
155
+ parts.push(neighbors);
134
156
  return parts.join('\n');
135
157
  }
136
158
  /** 从组装上下文取当前 agent 会话的工作区 cwd(AssembleContext.agent 由 dsh-agent 声明合并提供)。 */
@@ -446,6 +468,31 @@ export async function apply(ctx, config) {
446
468
  },
447
469
  presentCall: args => present('Forget memory', 'other', args.id),
448
470
  }));
471
+ ctx.tools.register(defineTool({
472
+ name: 'memory_move',
473
+ description: 'Move one memory to another layer: `global`, `self` (the current workspace), or a relative ancestor (`..` / `../..`). Use it when a memory sits at the wrong level — a project-specific rule parked in `global` that leaks into unrelated workspaces, or a fact that turned out to hold for sibling projects too. The record keeps its id, and the result reports whether the source layer still sees it. 把一条记忆移到另一层:`global`、`self`(当前工作区)或相对祖先(`..` / `../..`)。用于记忆待错了层——只属于某个项目的规矩躺在 `global` 里、漏进无关工作区,或某条事实其实对兄弟项目也成立。id 保持不变,结果里会报告移动后源层是否仍看得见它。',
474
+ parameters: {
475
+ id: { type: 'string', required: true, description: 'Exact memory id from memory_list or memory_search. 要移动的记忆 id。' },
476
+ to: { type: 'string', required: true, description: 'Target layer: `global`, `self` (current workspace), or a relative ancestor such as `..` / `../..`. 目标层:`global`、`self`(当前工作区),或 `..` / `../..` 这样的相对祖先。' },
477
+ },
478
+ output: {
479
+ schema: {
480
+ type: 'object',
481
+ additionalProperties: false,
482
+ properties: {
483
+ id: { type: 'string', required: true },
484
+ from: { type: 'string', required: true, description: '移动前所在的层(`global` 或工作区路径)。' },
485
+ to: { type: 'string', required: true, description: '移动后的层。' },
486
+ sourceStillSees: { type: 'boolean', required: true, description: '源层移动后是否仍看得见它。为假表示源层及其下层从此看不见这条记忆。' },
487
+ },
488
+ },
489
+ render: (_args, value) => renderJson(value),
490
+ },
491
+ execute(args, exec) {
492
+ return memory.move(args.id, args.to, execProjectCwd(exec));
493
+ },
494
+ presentCall: args => present('Move memory', 'other', `${args.id} → ${args.to}`),
495
+ }));
449
496
  ctx.tools.register(defineTool({
450
497
  name: 'memory_update',
451
498
  description: 'Update the content, keywords or persistent-injection switch of one stored memory (e.g. correcting stale facts). The record keeps its review status; content that looks like a credential quarantines it instead. 修改一条记忆的内容、关键词或常驻注入开关(如修正过时信息)。改动后记忆保持原有审核状态;若新内容命中危险规则则转为隔离。',