@max-null/dsh-memory 0.9.1 → 0.10.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/README.md +17 -1
- package/dist/engine.d.ts +70 -0
- package/dist/engine.js +225 -86
- package/dist/index.js +28 -4
- package/dist/injection.d.ts +30 -0
- package/dist/injection.js +56 -1
- package/dist/self.js +11 -3
- package/package.json +3 -2
- package/scripts/scan-orphans.mjs +110 -0
package/README.md
CHANGED
|
@@ -41,7 +41,7 @@ npm install @max-null/dsh-memory
|
|
|
41
41
|
|
|
42
42
|
- **服务** `ctx.memory`:`remember` / `list` / `search` / `forget` / `setStatus`
|
|
43
43
|
- **工具**:`memory_save`、`memory_list`、`memory_search`、`memory_confirm`、`memory_forget`、`memory_update`
|
|
44
|
-
- **注入**:`tool:memory` 指引 section + `memory:recall` 召回 context(global 的 `approved + injected` + 当前会话工作区的 `approved + injected`,带 `[memory:<id>:<namespace>]` 来源标记;摘要化 + 预算截断)
|
|
44
|
+
- **注入**:`tool:memory` 指引 section(工具用法 + 常驻注入判据)+ `memory:self` 机制自述 + `memory:recall` 召回 context(global 的 `approved + injected` + 当前会话工作区的 `approved + injected`,带 `[memory:<id>:<namespace>]` 来源标记;摘要化 + 预算截断)
|
|
45
45
|
- **检索**:BM25(CJK 单字 + 2-gram,content 与 keywords 字段分离加权;中文多字查询精度显著优于单字切分);可选语义融合(见「可选配置」)
|
|
46
46
|
|
|
47
47
|
## 两层存储(global / project)
|
|
@@ -55,6 +55,22 @@ npm install @max-null/dsh-memory
|
|
|
55
55
|
|
|
56
56
|
两个根都可用 config 覆盖(`globalRoot` / `projectRoot`)。`memory_list` / `memory_search` 不带 `namespace` 过滤时会同时查两层。旧版双重前缀文件名(`memory_project_memory_project_<hash>.json`)在打开时自动迁移为规范名。
|
|
57
57
|
|
|
58
|
+
## 跨工作区可见性(0.10.0)
|
|
59
|
+
|
|
60
|
+
project 记忆按**会话工作区**分文件存放,所以「工作区」就是可见性的边界。0.10.0 起补了三个方向——针对的是同一个盲区:**不知道存在**(检索是有意图的动作,搜不出自己不知道存在的东西)。
|
|
61
|
+
|
|
62
|
+
| 方向 | 机制 | 效果 |
|
|
63
|
+
|---|---|---|
|
|
64
|
+
| 向上 | **祖先链** | 在子目录里开会话能检索到父目录的记忆(反向不行,链是单向向上的),结果带 `..` / `../..` 来源标记 |
|
|
65
|
+
| 向下 | **子项目索引行** | 注入里多一行「本工作区下另有 N 个子项目带记忆:xxx(M 条)——用 memory_search 检索」 |
|
|
66
|
+
| 全局 | **记忆索引行** | 注入里多一行「索引:当前可见 N 条记忆,主题集中在 X(14)、Y(12)…——用 memory_search 检索」 |
|
|
67
|
+
|
|
68
|
+
三条边界:**写入永远只落当前工作区**(「我在这个项目里记的东西」不该被推理到别处);但**检索得到的记录就改得动**——`memory_update` / `memory_forget` 同样沿链定位;**注入路径只读当前工作区**(远处的记忆进检索、不进每轮成本)。
|
|
69
|
+
|
|
70
|
+
后两行**不占注入预算**、自消除(没有内容时整行不出现),且**长度常数级**——不随记忆增长。索引行里的主题取自各条的关键词词频,与检索用的是同一套词汇,所以**索引里出现的词就是能搜到的词**。
|
|
71
|
+
|
|
72
|
+
**孤儿文件清点**:`node scripts/scan-orphans.mjs [根目录]` 报出「哪些记忆文件当前代码已经打不开」(历史命名遗留:早期无哈希后缀、双重 `memory_project_` 前缀)。**只读,不做任何处置**——旧文件可能含未过隔离检查的内容,处置需人工拍板。
|
|
73
|
+
|
|
58
74
|
## 多实例共存(0.7.1)
|
|
59
75
|
|
|
60
76
|
两个 DSH 实例(例如 DSH web 与 SSiD 桌面壳)可以同时运行、共用同一个 `DSH_HOME`,**记忆不再互相抹掉**。
|
package/dist/engine.d.ts
CHANGED
|
@@ -82,6 +82,11 @@ export interface MemoryRecord {
|
|
|
82
82
|
kind?: MemoryKind;
|
|
83
83
|
/** prompt 记录元数据(仅 kind==='prompt')。 */
|
|
84
84
|
meta?: PromptMetaIndex;
|
|
85
|
+
/**
|
|
86
|
+
* 来源工作区(④-A 2026-09-19):仅在跨工作区召回时出现——`undefined` = 当前会话
|
|
87
|
+
* 工作区,`..` / `../..` = 上级工作区。检索结果与注入行据此标出来源,避免「记忆串味」。
|
|
88
|
+
*/
|
|
89
|
+
scope?: string;
|
|
85
90
|
}
|
|
86
91
|
export interface MemoryWrite {
|
|
87
92
|
content: string;
|
|
@@ -234,6 +239,10 @@ export declare class MemoryEngine extends Service {
|
|
|
234
239
|
private globalTable?;
|
|
235
240
|
/** project 域按工作区 cwd 懒打开 + 缓存(多会话并发各工作区独立)。 */
|
|
236
241
|
private projectTables;
|
|
242
|
+
/** 子项目发现缓存(④-B 2026-09-19):key = 工作区路径;值 = 其直接子目录中带记忆的(按条数降序)。 */
|
|
243
|
+
private readonly neighborCache;
|
|
244
|
+
/** 进行中的子项目发现(key → promise):预热是 fire-and-forget,之后的显式调用与它合流而非重复扫描。 */
|
|
245
|
+
private readonly neighborOpenings;
|
|
237
246
|
/** 进行中的工作区打开(key → promise):并发调用复用同一次,见 {@link projectTableFor}。 */
|
|
238
247
|
private readonly projectOpenings;
|
|
239
248
|
private projectFacilities;
|
|
@@ -295,6 +304,66 @@ export declare class MemoryEngine extends Service {
|
|
|
295
304
|
private globalStoreFile;
|
|
296
305
|
/** 某工作区记忆的存储文件。 */
|
|
297
306
|
private projectStoreFile;
|
|
307
|
+
/**
|
|
308
|
+
* 工作区链(④-A 2026-09-19):当前 cwd 起向上,收集**记忆文件已存在**的层级。
|
|
309
|
+
*
|
|
310
|
+
* 两条约束:
|
|
311
|
+
* ① **自身始终纳入**(depth 0)——即使文件还不存在,也保持既有行为(`projectTableFor`
|
|
312
|
+
* 会创建它,会话预热路径同样如此)。
|
|
313
|
+
* ② **祖先只收文件已存在的**——对不存在的层级打开表会让 JsonStorageBackend **创建**空
|
|
314
|
+
* 文件,等于在用户每个祖先目录里留一个空记忆文件。
|
|
315
|
+
*
|
|
316
|
+
* `depth` 是真实的上级层数(每向上一步 +1,无论该级是否有文件),供来源标记渲染成
|
|
317
|
+
* `..` / `../..`。路径一律先 `join()` 规范化再算哈希——`projectBackendName` 对字符串
|
|
318
|
+
* 敏感,`H:\a\b` 与 `H:/a/b` 会得出**不同**的文件名(设计文档 §2.3)。
|
|
319
|
+
*/
|
|
320
|
+
private projectChain;
|
|
321
|
+
/**
|
|
322
|
+
* 打开工作区链上的表(由近及远)。复用 {@link projectTableFor} 的缓存与并发去重——
|
|
323
|
+
* 链上每级各自一张表,重复调用不会重复打开。
|
|
324
|
+
*/
|
|
325
|
+
private projectChainTables;
|
|
326
|
+
/** 来源标记:depth 0(当前工作区)不带标记;祖先渲染为 `..` / `../..`。 */
|
|
327
|
+
private static scopeLabel;
|
|
328
|
+
/**
|
|
329
|
+
* 子项目发现(④-B 2026-09-19):扫描 cwd 的**直接子目录**(不递归——实测一层即覆盖全部
|
|
330
|
+
* 实际情况:19 个子目录里 3 个带记忆),找出带记忆文件、且其中有非隔离记录的。
|
|
331
|
+
*
|
|
332
|
+
* 结果缓存进 {@link neighborCache}——注入 provider 是同步的,只能读缓存;扫描本身在这个
|
|
333
|
+
* 异步方法里做,由 `ensureProjectOpen` 预热路径触发。
|
|
334
|
+
*
|
|
335
|
+
* 跳过点开头的目录(`.dsh` / `.git` 等不是子项目);只报条数、不搬运内容——索引行的用途
|
|
336
|
+
* 是「告诉你去搜」,不是把子项目的记忆变成当前会话的持续成本。
|
|
337
|
+
*/
|
|
338
|
+
discoverNeighbors(projectCwd: string): Promise<Array<{
|
|
339
|
+
name: string;
|
|
340
|
+
count: number;
|
|
341
|
+
}>>;
|
|
342
|
+
/** {@link discoverNeighbors} 的实际扫描;并发去重由调用方负责。 */
|
|
343
|
+
private scanNeighbors;
|
|
344
|
+
/**
|
|
345
|
+
* 同步读取已发现的子项目(④-B):注入 provider 是同步回调,只能读缓存。
|
|
346
|
+
* 缓存由 {@link discoverNeighbors} 在预热路径填充;未跑过时返回空数组——该轮索引行
|
|
347
|
+
* 缺席、下一轮补上,与工作区表预热同一套取舍。
|
|
348
|
+
*/
|
|
349
|
+
neighborsOf(projectCwd?: string): Array<{
|
|
350
|
+
name: string;
|
|
351
|
+
count: number;
|
|
352
|
+
}>;
|
|
353
|
+
/**
|
|
354
|
+
* 同步读取索引候选(① 2026-09-19):global + 工作区链上**已打开**的表里的全部记录。
|
|
355
|
+
*
|
|
356
|
+
* 与 {@link recallRecords} 的分工:后者只取 `injected` 的(进注入行),前者取全部
|
|
357
|
+
* (供索引行统计规模与主题)。同样是同步只读已缓存的表——注入 provider 是同步回调,
|
|
358
|
+
* 未打开的层级本轮不计入,影响可忽略(链上祖先在检索路径打开后即缓存)。
|
|
359
|
+
*/
|
|
360
|
+
indexCandidates(projectCwd?: string): MemoryRecord[];
|
|
361
|
+
/**
|
|
362
|
+
* 按 id 定位记录所在表:global 优先,其次工作区链(自身 → 祖先,由近及远)。
|
|
363
|
+
* **异步版**——写路径必须用它:`writeScope` 可能在本次进入前刚 `reload()`,
|
|
364
|
+
* 那时同步缓存已被清空(同 {@link markUsedInner} 的注释)。
|
|
365
|
+
*/
|
|
366
|
+
private locateRecord;
|
|
298
367
|
/** 记录当前指纹,作为「这就是我们造成的状态」的基准(写入完成后调用)。 */
|
|
299
368
|
private noteStoreStamps;
|
|
300
369
|
/**
|
|
@@ -344,6 +413,7 @@ export declare class MemoryEngine extends Service {
|
|
|
344
413
|
* 「工具全没了」,让所有 `tool-list` 锚点误失效。
|
|
345
414
|
*/
|
|
346
415
|
private anchorProbes;
|
|
416
|
+
/** 同步版定位:只查**已打开**的表(global + 链上已缓存的工作区表)。 */
|
|
347
417
|
private tableOf;
|
|
348
418
|
/** 命中标记:写回 lastUsedAt 与命中次数,达标即自动升常驻(2026-09-15)。 */
|
|
349
419
|
private markUsed;
|
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,6 +356,158 @@ export class MemoryEngine extends Service {
|
|
|
350
356
|
const key = join(projectCwd);
|
|
351
357
|
return join(this.projectRootFor(key), `${this.projectBackendName(key)}.json`);
|
|
352
358
|
}
|
|
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
|
+
}
|
|
353
511
|
/** 记录当前指纹,作为「这就是我们造成的状态」的基准(写入完成后调用)。 */
|
|
354
512
|
noteStoreStamps(projectCwd) {
|
|
355
513
|
const global = this.globalStoreFile();
|
|
@@ -601,14 +759,19 @@ export class MemoryEngine extends Service {
|
|
|
601
759
|
}
|
|
602
760
|
return { ...(toolNames === undefined ? {} : { toolNames }), selfVersion: SELF_VERSION };
|
|
603
761
|
}
|
|
762
|
+
/** 同步版定位:只查**已打开**的表(global + 链上已缓存的工作区表)。 */
|
|
604
763
|
tableOf(id, projectCwd) {
|
|
605
764
|
const global = this.requireTable('global');
|
|
606
765
|
if (global.get(id) !== undefined)
|
|
607
766
|
return global;
|
|
608
767
|
if (projectCwd === undefined || projectCwd === '')
|
|
609
768
|
return undefined;
|
|
610
|
-
const
|
|
611
|
-
|
|
769
|
+
for (const { key } of this.projectChain(projectCwd)) {
|
|
770
|
+
const table = this.projectTables.get(key);
|
|
771
|
+
if (table !== undefined && table.get(id) !== undefined)
|
|
772
|
+
return table;
|
|
773
|
+
}
|
|
774
|
+
return undefined;
|
|
612
775
|
}
|
|
613
776
|
/** 命中标记:写回 lastUsedAt 与命中次数,达标即自动升常驻(2026-09-15)。 */
|
|
614
777
|
async markUsed(ids, projectCwd) {
|
|
@@ -622,17 +785,21 @@ export class MemoryEngine extends Service {
|
|
|
622
785
|
const global = this.requireTable('global');
|
|
623
786
|
// 取表走异步版:`writeScope` 可能在本次进入前刚 `reload()`(外部实例改过存储),
|
|
624
787
|
// 那时同步缓存已被清空——继续读缓存会把 project 侧的命中静默漏记。
|
|
625
|
-
|
|
788
|
+
// ④-A:链上的祖先表同样纳入。跨工作区命中的条目也要记账——否则它们的
|
|
789
|
+
// `lastUsedAt` 长期不动,会被 30 天规则误撤(「用得到却没被记录」)。
|
|
790
|
+
const chainTables = await this.projectChainTables(projectCwd);
|
|
626
791
|
for (const id of ids) {
|
|
627
792
|
const globalBlock = global.get(id);
|
|
628
793
|
if (globalBlock !== undefined) {
|
|
629
794
|
await this.recordUse(global, id, globalBlock, now);
|
|
630
795
|
continue;
|
|
631
796
|
}
|
|
632
|
-
|
|
633
|
-
const block =
|
|
634
|
-
if (block !== undefined)
|
|
635
|
-
await this.recordUse(
|
|
797
|
+
for (const { table } of chainTables) {
|
|
798
|
+
const block = table.get(id);
|
|
799
|
+
if (block !== undefined) {
|
|
800
|
+
await this.recordUse(table, id, block, now);
|
|
801
|
+
break;
|
|
802
|
+
}
|
|
636
803
|
}
|
|
637
804
|
}
|
|
638
805
|
}
|
|
@@ -935,10 +1102,12 @@ export class MemoryEngine extends Service {
|
|
|
935
1102
|
this.ctx.emit('memory/changed', { operation: 'forgotten', id });
|
|
936
1103
|
return true;
|
|
937
1104
|
}
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
1105
|
+
// ④-A:删除要能命中链上的祖先表——「检索得到却删不掉」是最别扭的一种不一致
|
|
1106
|
+
for (const { table } of await this.projectChainTables(projectCwd)) {
|
|
1107
|
+
if (await table.delete(id)) {
|
|
1108
|
+
this.ctx.emit('memory/changed', { operation: 'forgotten', id });
|
|
1109
|
+
return true;
|
|
1110
|
+
}
|
|
942
1111
|
}
|
|
943
1112
|
return false;
|
|
944
1113
|
}
|
|
@@ -947,40 +1116,24 @@ export class MemoryEngine extends Service {
|
|
|
947
1116
|
}
|
|
948
1117
|
/** {@link setStatus} 的实现体;必须经 `writeScope` 进入。 */
|
|
949
1118
|
async setStatusInner(id, status, projectCwd) {
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
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}'`);
|
|
1119
|
+
// ④-A:定位走工作区链(global 优先,其次自身 → 祖先)——检索得到的记录就该改得动
|
|
1120
|
+
const found = await this.locateRecord(id, projectCwd);
|
|
1121
|
+
if (found === undefined)
|
|
1122
|
+
throw new Error(`cannot set status of unknown memory '${id}'`);
|
|
1123
|
+
const { table, block } = found;
|
|
1124
|
+
const updated = {
|
|
1125
|
+
...block,
|
|
1126
|
+
status,
|
|
1127
|
+
// 旧数据可能缺 injected,写回时补全(读时迁移值)
|
|
1128
|
+
injected: normalizeBlock(block).injected,
|
|
1129
|
+
// 人工放行 = 审核通过并解除隔离(2026-09-15)
|
|
1130
|
+
...status === 'approved' ? { quarantined: false, quarantineReason: undefined } : {},
|
|
1131
|
+
updatedAt: Date.now(),
|
|
1132
|
+
};
|
|
1133
|
+
await table.put(id, updated);
|
|
1134
|
+
const record = toRecord(id, updated);
|
|
1135
|
+
this.ctx.emit('memory/changed', { operation: 'status', id, status });
|
|
1136
|
+
return record;
|
|
984
1137
|
}
|
|
985
1138
|
/**
|
|
986
1139
|
* 注入维度开关(0.3.0):只改 injected,不动审核状态。供 UI 面板「常驻注入」开关调用
|
|
@@ -994,19 +1147,8 @@ export class MemoryEngine extends Service {
|
|
|
994
1147
|
}
|
|
995
1148
|
/** {@link setInjected} 的实现体;必须经 `writeScope` 进入。 */
|
|
996
1149
|
async setInjectedInner(id, injected, projectCwd) {
|
|
997
|
-
|
|
998
|
-
|
|
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();
|
|
1150
|
+
// ④-A:定位走工作区链(global 优先,其次自身 → 祖先)
|
|
1151
|
+
const found = await this.locateRecord(id, projectCwd);
|
|
1010
1152
|
if (found === undefined)
|
|
1011
1153
|
throw new Error(`cannot set injected of unknown memory '${id}'`);
|
|
1012
1154
|
// 模板永不注入(0.6.0):prompt 记录拒绝开关(UI 对该类不展示开关,此处兜底)
|
|
@@ -1077,22 +1219,14 @@ export class MemoryEngine extends Service {
|
|
|
1077
1219
|
}
|
|
1078
1220
|
return record;
|
|
1079
1221
|
};
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
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}'`);
|
|
1222
|
+
// ④-A:定位走工作区链(global 优先,其次自身 → 祖先)——检索得到的记录就该改得动
|
|
1223
|
+
const found = await this.locateRecord(id, projectCwd);
|
|
1224
|
+
if (found === undefined)
|
|
1225
|
+
throw new Error(`cannot update unknown memory '${id}'`);
|
|
1226
|
+
const { table, block } = found;
|
|
1227
|
+
const updated = applyPatch(block);
|
|
1228
|
+
await table.put(id, updated);
|
|
1229
|
+
return publish(block, updated);
|
|
1096
1230
|
}
|
|
1097
1231
|
/**
|
|
1098
1232
|
* 预热某工作区的 project 表(异步打开并缓存)。system prompt 的
|
|
@@ -1101,6 +1235,10 @@ export class MemoryEngine extends Service {
|
|
|
1101
1235
|
*/
|
|
1102
1236
|
async ensureProjectOpen(projectCwd) {
|
|
1103
1237
|
const key = join(projectCwd);
|
|
1238
|
+
// ④-B:子项目发现与表预热相互独立——表可能早已打开,而发现还没跑过。
|
|
1239
|
+
// 注入 provider 是同步的、只能读缓存,所以必须在这条异步预热路径上先把发现做掉。
|
|
1240
|
+
if (!this.neighborCache.has(key))
|
|
1241
|
+
void this.discoverNeighbors(key).catch(() => undefined);
|
|
1104
1242
|
if (this.projectTables.has(key))
|
|
1105
1243
|
return;
|
|
1106
1244
|
await this.projectTableFor(key);
|
|
@@ -1130,19 +1268,20 @@ export class MemoryEngine extends Service {
|
|
|
1130
1268
|
return [...global, ...injected(project)];
|
|
1131
1269
|
}
|
|
1132
1270
|
async allRecords(namespace, projectCwd) {
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
return
|
|
1138
|
-
|
|
1271
|
+
// ④-A:project 侧从「当前 cwd 那一张表」扩为「工作区链上的表」(自身 + 已存在的祖先)。
|
|
1272
|
+
// 祖先记录带 scope 标记(`..` / `../..`),让调用方与用户都看得出这条来自哪一级。
|
|
1273
|
+
const projectRecords = async () => {
|
|
1274
|
+
const tables = await this.projectChainTables(projectCwd);
|
|
1275
|
+
return tables.flatMap(({ depth, table }) => {
|
|
1276
|
+
const scope = MemoryEngine.scopeLabel(depth);
|
|
1277
|
+
return this.recordsOf(table).map(record => scope === undefined ? record : { ...record, scope });
|
|
1278
|
+
});
|
|
1279
|
+
};
|
|
1280
|
+
if (namespace === 'project')
|
|
1281
|
+
return projectRecords();
|
|
1139
1282
|
if (namespace === 'global')
|
|
1140
1283
|
return this.recordsOf(this.requireTable('global'));
|
|
1141
|
-
|
|
1142
|
-
return [
|
|
1143
|
-
...this.recordsOf(this.requireTable('global')),
|
|
1144
|
-
...(project === undefined ? [] : this.recordsOf(project)),
|
|
1145
|
-
];
|
|
1284
|
+
return [...this.recordsOf(this.requireTable('global')), ...await projectRecords()];
|
|
1146
1285
|
}
|
|
1147
1286
|
recordsOf(table) {
|
|
1148
1287
|
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) {
|
|
@@ -92,15 +94,29 @@ function promptValue(record) {
|
|
|
92
94
|
}
|
|
93
95
|
// 0.3.0:审核语义双语(host 侧 GUIDANCE 无法跟随 DSH locale 动态切换,
|
|
94
96
|
// 采用中英双语都写、模型自取——设计文档「风险与注意」)。
|
|
97
|
+
// 0.9.2:常驻注入不再是「人工开关」——0.8.0 起模型侧可经 `injected` 参数显式钉住。
|
|
98
|
+
// 旧表述会让模型以为自己没有该权限,也与同段「人工只在例外时介入」自相矛盾。
|
|
99
|
+
// 0.10.0:补 namespace 判据——此前只有工具 schema 的 `defaults to global`,全体系没有
|
|
100
|
+
// 任何「该记哪一层」的说明,陌生环境的模型会全写 global,project 层因此永远空着。
|
|
95
101
|
const GUIDANCE = 'Use memory tools for cross-session preferences, habits, and project conventions. '
|
|
96
102
|
+ '记忆工具用于跨会话的偏好、习惯与项目约定。'
|
|
97
103
|
+ 'memory_save writes a memory that takes effect immediately (`approved`) — memories are maintained '
|
|
98
104
|
+ 'by the system and the model, and humans intervene only as exceptions. Credential-like content is '
|
|
99
105
|
+ 'quarantined instead. memory_save 写入即生效(`approved`)——记忆由系统与模型自行维护,人工只在例外时介入;'
|
|
100
106
|
+ '命中危险内容规则(密钥/凭据)的写入会被隔离。'
|
|
101
|
-
+ '
|
|
102
|
-
+ '
|
|
103
|
-
+ '
|
|
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
|
+
+ 'Pass `injected: true` on memory_save / memory_update to pin a memory resident in every turn\'s context: '
|
|
113
|
+
+ 'the same switch the settings panel exposes, exempting the record from both automatic rules (promote on '
|
|
114
|
+
+ 'repeated hits, demote after long idle). Reserve it for rules, standing agreements and judgement criteria '
|
|
115
|
+
+ '— the kind that must apply even when nobody thinks to search for them; facts, references and case notes '
|
|
116
|
+
+ 'stay search-only. '
|
|
117
|
+
+ '给 memory_save / memory_update 传 `injected: true` 即把这条钉成每轮常驻——与设置面板是同一个开关,'
|
|
118
|
+
+ '此后不受两条自动规则(反复命中自动开启 / 长期未命中自动撤下)影响。只用于规则、长期约定与判据这类'
|
|
119
|
+
+ '「没人想起来搜也必须生效」的记忆;事实、参考与案例留作按需检索。'
|
|
104
120
|
+ 'When earlier context may be relevant, call memory_search to recall it — reviewable memories '
|
|
105
121
|
+ '相关历史上下文可用 memory_search 检索;被隔离的记录不进检索。'
|
|
106
122
|
+ 'Every memory is plaintext and inspectable with memory_list; memory_forget removes one. '
|
|
@@ -124,6 +140,14 @@ function recallText(memory, projectCwd, budget, summaryChars) {
|
|
|
124
140
|
const notice = omittedNotice(rendered);
|
|
125
141
|
if (notice !== '')
|
|
126
142
|
parts.push(notice);
|
|
143
|
+
// ① 索引行:给出「库里还有什么、按什么去搜」(不占预算、长度常数级)
|
|
144
|
+
const index = indexNotice(memory.indexCandidates(projectCwd));
|
|
145
|
+
if (index !== '')
|
|
146
|
+
parts.push(index);
|
|
147
|
+
// ④-B:子项目索引(有才出、不占预算)——补「不知道存在」这个检索的结构性盲区
|
|
148
|
+
const neighbors = neighborNotice(memory.neighborsOf(projectCwd));
|
|
149
|
+
if (neighbors !== '')
|
|
150
|
+
parts.push(neighbors);
|
|
127
151
|
return parts.join('\n');
|
|
128
152
|
}
|
|
129
153
|
/** 从组装上下文取当前 agent 会话的工作区 cwd(AssembleContext.agent 由 dsh-agent 声明合并提供)。 */
|
package/dist/injection.d.ts
CHANGED
|
@@ -54,3 +54,33 @@ export declare function renderInjection(records: readonly MemoryRecord[], budget
|
|
|
54
54
|
* {@link OMITTED_NOTICE_LIMIT} 只列前 N 个。
|
|
55
55
|
*/
|
|
56
56
|
export declare function omittedNotice(rendered: InjectionRender): string;
|
|
57
|
+
/** 子项目索引行列出的条数上限(④-B):超出以 `…` 结尾,防这行本身失控。 */
|
|
58
|
+
export declare const NEIGHBOR_NOTICE_LIMIT = 5;
|
|
59
|
+
/**
|
|
60
|
+
* 子项目索引行(④-B 2026-09-19):告诉模型「当前工作区下面还有什么」——它补的是
|
|
61
|
+
* 「不知道存在」这个检索的结构性盲区,而**不搬运内容**(正文照旧走 memory_search)。
|
|
62
|
+
*
|
|
63
|
+
* 与预算诊断行同待遇:**不占注入预算**、自消除(没有子项目记忆时返回空串)。
|
|
64
|
+
* 只报条数不报 id——这一行的用途是给出**规模与入口**,不是当检索结果用。
|
|
65
|
+
*/
|
|
66
|
+
export declare function neighborNotice(neighbors: ReadonlyArray<{
|
|
67
|
+
name: string;
|
|
68
|
+
count: number;
|
|
69
|
+
}>): string;
|
|
70
|
+
/** 索引行列出的主题数上限(① 2026-09-19):常数级——这行不随库增长。 */
|
|
71
|
+
export declare const INDEX_NOTICE_TOPICS = 5;
|
|
72
|
+
/**
|
|
73
|
+
* 记忆索引行(① 2026-09-19):给出「库里还有什么、按什么去搜」。
|
|
74
|
+
*
|
|
75
|
+
* 它补的是**检索的结构性盲区**——检索是有意图的动作,不知道某条记忆存在就搜不出它。
|
|
76
|
+
* 注入行每轮只装得下约 11 条,其余上百条对模型完全不可见;这一行报出**规模与入口**,
|
|
77
|
+
* 让「不在场」变成「知道存在、需要时去取」。
|
|
78
|
+
*
|
|
79
|
+
* 为什么不做成一条常驻记忆(用户最初的设想):那样它会自己占约 135 字符预算、参与零和
|
|
80
|
+
* 竞争、可能自己也被挤掉,而且记忆增删后**不会自动更新**(目录与内容不一致且无人察觉)。
|
|
81
|
+
* 做成系统生成的行则:不占预算、不可能腐化、永远反映当前状态。
|
|
82
|
+
*
|
|
83
|
+
* 主题取自各条的 `keywords` 词频——那是记忆写入时被要求「给多角度」的字段,**与检索用的
|
|
84
|
+
* 是同一套词汇**,所以索引里出现的词就是能搜到的词。同频时按字典序排,保证输出确定。
|
|
85
|
+
*/
|
|
86
|
+
export declare function indexNotice(records: ReadonlyArray<MemoryRecord>): string;
|
package/dist/injection.js
CHANGED
|
@@ -30,7 +30,9 @@ export function deriveSummary(content, maxChars) {
|
|
|
30
30
|
}
|
|
31
31
|
/** 一条注入行的渲染文本(与面板预览完全一致)。 */
|
|
32
32
|
export function injectionLine(record, summaryChars) {
|
|
33
|
-
|
|
33
|
+
// ④-A:跨工作区召回时带来源标记(`..` / `../..`);当前工作区不带,保持既有格式
|
|
34
|
+
const scope = record.scope === undefined ? '' : `@${record.scope}`;
|
|
35
|
+
return `- [memory:${String(record.id).slice(0, 8)}:${record.namespace}${scope}] ${deriveSummary(record.content, summaryChars)}`;
|
|
34
36
|
}
|
|
35
37
|
/**
|
|
36
38
|
* 由候选记录渲染注入行:按使用序降序(lastUsedAt 优先、缺省回退
|
|
@@ -75,3 +77,56 @@ export function omittedNotice(rendered) {
|
|
|
75
77
|
const truncated = rendered.omittedRecords.length > shown.length ? '…' : '';
|
|
76
78
|
return `(另有 ${rendered.omitted} 条常驻因预算未注入:${labels.join('、')}${truncated})`;
|
|
77
79
|
}
|
|
80
|
+
/** 子项目索引行列出的条数上限(④-B):超出以 `…` 结尾,防这行本身失控。 */
|
|
81
|
+
export const NEIGHBOR_NOTICE_LIMIT = 5;
|
|
82
|
+
/**
|
|
83
|
+
* 子项目索引行(④-B 2026-09-19):告诉模型「当前工作区下面还有什么」——它补的是
|
|
84
|
+
* 「不知道存在」这个检索的结构性盲区,而**不搬运内容**(正文照旧走 memory_search)。
|
|
85
|
+
*
|
|
86
|
+
* 与预算诊断行同待遇:**不占注入预算**、自消除(没有子项目记忆时返回空串)。
|
|
87
|
+
* 只报条数不报 id——这一行的用途是给出**规模与入口**,不是当检索结果用。
|
|
88
|
+
*/
|
|
89
|
+
export function neighborNotice(neighbors) {
|
|
90
|
+
if (neighbors.length === 0)
|
|
91
|
+
return '';
|
|
92
|
+
const shown = neighbors.slice(0, NEIGHBOR_NOTICE_LIMIT);
|
|
93
|
+
const labels = shown.map(neighbor => `${neighbor.name}(${neighbor.count} 条)`);
|
|
94
|
+
const truncated = neighbors.length > shown.length ? '…' : '';
|
|
95
|
+
return `(本工作区下另有 ${neighbors.length} 个子项目带记忆:${labels.join('、')}${truncated}——用 memory_search 检索)`;
|
|
96
|
+
}
|
|
97
|
+
/** 索引行列出的主题数上限(① 2026-09-19):常数级——这行不随库增长。 */
|
|
98
|
+
export const INDEX_NOTICE_TOPICS = 5;
|
|
99
|
+
/**
|
|
100
|
+
* 记忆索引行(① 2026-09-19):给出「库里还有什么、按什么去搜」。
|
|
101
|
+
*
|
|
102
|
+
* 它补的是**检索的结构性盲区**——检索是有意图的动作,不知道某条记忆存在就搜不出它。
|
|
103
|
+
* 注入行每轮只装得下约 11 条,其余上百条对模型完全不可见;这一行报出**规模与入口**,
|
|
104
|
+
* 让「不在场」变成「知道存在、需要时去取」。
|
|
105
|
+
*
|
|
106
|
+
* 为什么不做成一条常驻记忆(用户最初的设想):那样它会自己占约 135 字符预算、参与零和
|
|
107
|
+
* 竞争、可能自己也被挤掉,而且记忆增删后**不会自动更新**(目录与内容不一致且无人察觉)。
|
|
108
|
+
* 做成系统生成的行则:不占预算、不可能腐化、永远反映当前状态。
|
|
109
|
+
*
|
|
110
|
+
* 主题取自各条的 `keywords` 词频——那是记忆写入时被要求「给多角度」的字段,**与检索用的
|
|
111
|
+
* 是同一套词汇**,所以索引里出现的词就是能搜到的词。同频时按字典序排,保证输出确定。
|
|
112
|
+
*/
|
|
113
|
+
export function indexNotice(records) {
|
|
114
|
+
const usable = records.filter(record => record.status === 'approved'
|
|
115
|
+
&& record.quarantined !== true
|
|
116
|
+
&& (record.kind ?? 'fact') !== 'prompt');
|
|
117
|
+
if (usable.length === 0)
|
|
118
|
+
return '';
|
|
119
|
+
const counts = new Map();
|
|
120
|
+
for (const record of usable) {
|
|
121
|
+
for (const keyword of record.keywords) {
|
|
122
|
+
counts.set(keyword, (counts.get(keyword) ?? 0) + 1);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (counts.size === 0)
|
|
126
|
+
return '';
|
|
127
|
+
const topics = [...counts.entries()]
|
|
128
|
+
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
|
|
129
|
+
.slice(0, INDEX_NOTICE_TOPICS);
|
|
130
|
+
const labels = topics.map(([topic, count]) => `${topic}(${count})`);
|
|
131
|
+
return `(索引:当前可见 ${usable.length} 条记忆,主题集中在 ${labels.join('、')}——用 memory_search 检索)`;
|
|
132
|
+
}
|
package/dist/self.js
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
* 记忆机制自述(memory:self 注入内容 + 面板「注入预览」共用)。
|
|
3
3
|
* 0.3.2 起:LLM 每轮知道本环境有记忆机制;不落用户存储,随发版更新,
|
|
4
4
|
* 版本号动态读取 package.json("只跟随 dsh-memory 组件发版变动")。
|
|
5
|
+
* 0.9.2:补齐常驻注入机制——`injected` 参数、注入预算上限、预算诊断行的含义。
|
|
6
|
+
* 0.10.0:修正工具清单(6 → 10,补提示词模板工具)与两层记忆说明——原清单漏了
|
|
7
|
+
* `prompt_*` 系列,照它读会以为本插件只有记忆工具。
|
|
5
8
|
*/
|
|
6
9
|
import { createRequire } from 'node:module';
|
|
7
10
|
export const SELF_VERSION = (() => {
|
|
@@ -16,7 +19,8 @@ export const SELF_VERSION = (() => {
|
|
|
16
19
|
}
|
|
17
20
|
})();
|
|
18
21
|
export const SELF_DESCRIPTION = `[记忆系统自述] 本环境内置跨会话记忆服务(dsh-memory v${SELF_VERSION})。`
|
|
19
|
-
+ '你有
|
|
22
|
+
+ '你有 10 个工具:**记忆工具** memory_save / memory_search / memory_list / memory_update / memory_confirm / memory_forget(`memory_confirm` 仅在用户明确要求放行隔离记录时用);**提示词模板工具** prompt_search / prompt_get / prompt_list / prompt_add(模板是 md 文件、**永不注入**,走这条通道,不会混进记忆检索)。'
|
|
23
|
+
+ '记忆分两层:`global` 跨项目(偏好 / 习惯 / 环境知识),`project` 跟随工作区(落在 `<cwd>/.dsh/storages/`,随 git 提交分享)。'
|
|
20
24
|
+ '**何时写入**(不必刻意找机会,遇到下面这些就动手):'
|
|
21
25
|
+ '① 用户表达长期偏好或约定(「以后都…」「记住…」「我们的规矩是…」);'
|
|
22
26
|
+ '② 用户纠正了你的做法或理解——把正确结论记下来;'
|
|
@@ -25,6 +29,10 @@ export const SELF_DESCRIPTION = `[记忆系统自述] 本环境内置跨会话
|
|
|
25
29
|
+ '**怎么写**:一条一件事,并给多角度关键词(同义词、缩写、中英),否则将来检索不到它。'
|
|
26
30
|
+ '**锚点(可选)**:若这条记忆描述的是「随某个环境值变化的事实」(某工具版本下的行为、某环境变量决定的配置),用 anchor 参数把它绑到那个值上——值变了它会自动失效并标 stale,不会再被当成仍然正确。'
|
|
27
31
|
+ '写入即生效(`approved`),人工只在例外时介入;命中密钥/凭据规则的写入会被隔离(不进注入与检索)。'
|
|
28
|
-
+ '
|
|
32
|
+
+ '**常驻注入**:每轮注入「global + 当前会话工作区」里 approved 且开着常驻开关的记忆单行摘要(形如 `- [memory:id:namespace] 摘要`);全文要靠 memory_search 取。'
|
|
33
|
+
+ '开关有两条自动规则:命中累计 2 次自动开启、30 天未命中自动撤下;**给 memory_save / memory_update 传 `injected` 即显式接管**,此后这两条都不再适用。'
|
|
34
|
+
+ '**钉的判据是「失效可察觉性」**:这条记忆若没被想起来,你会不会根本意识不到自己漏了它?会 → 钉常驻(规则 / 委托 / 判据);不会 → 留检索(事实 / 参考 / 案例)。'
|
|
35
|
+
+ '**注入有预算**(默认 1500 字符,约装 11 条),装不下的整条丢弃、按最近使用优先取舍——所以「钉了常驻」只等于「有资格排队」。'
|
|
36
|
+
+ '装不下时注入末尾会出现一行 `(另有 N 条常驻因预算未注入:…短摘要…)`——**那是「有常驻记忆并不在场」的唯一信号**,看到它就该逐条评估撤下。'
|
|
29
37
|
+ '发现某条记忆与实际不符时用 memory_update 修正(保持生效);确认没用的用 memory_forget。'
|
|
30
|
-
+ '(English: dsh-memory provides cross-session memory. Writes take effect immediately; save durable preferences, corrections and reusable conclusions — not transient state, guesses, or facts readable from the repo (record where to look instead). Always add multi-angle keywords.)';
|
|
38
|
+
+ '(English: dsh-memory provides cross-session memory. Writes take effect immediately; save durable preferences, corrections and reusable conclusions — not transient state, guesses, or facts readable from the repo (record where to look instead). Always add multi-angle keywords. Pass `injected: true` to keep one resident every turn — reserve that for rules and standing agreements; facts stay search-only. Resident injection runs on a budget, so a pinned memory is only queued, never guaranteed to be in context.)';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@max-null/dsh-memory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Cross-session plaintext memory plugin for the DeepSeek Harness — deterministic BM25 recall, writes take effect immediately, signal-driven injection, no embeddings",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
"cordis.patch.yml",
|
|
24
24
|
"README.md",
|
|
25
25
|
"LICENSE",
|
|
26
|
-
"docs/shots"
|
|
26
|
+
"docs/shots",
|
|
27
|
+
"scripts/scan-orphans.mjs"
|
|
27
28
|
],
|
|
28
29
|
"dsh": {
|
|
29
30
|
"bundle": {
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 孤儿记忆文件清点(④-C 2026-09-19)——**只读,不做任何处置**。
|
|
4
|
+
*
|
|
5
|
+
* 背景:project 记忆的文件名是 `memory_project_<djb2(工作区路径)>.json`,而代码只按这个
|
|
6
|
+
* 名字去找。历史上换过命名(早期无哈希后缀、有过双重 `memory_project_` 前缀),那些旧文件
|
|
7
|
+
* 因此**永远不会被打开**——不是「等人放行」,是代码已经不认识这个文件名了。实测
|
|
8
|
+
* (2026-09-19)全工作区 136 条 project 记忆里有 33 条处于这个状态。
|
|
9
|
+
*
|
|
10
|
+
* 本脚本回答一个问题:**哪些文件当前代码打不开**。它不迁移、不删除、不修改任何东西。
|
|
11
|
+
* 处置(归档 / 选择性导入 / 删除)是一次显式的人工动作,理由见设计文档 §3.4:
|
|
12
|
+
* 旧文件可能含 0.7.0 之前未过 `detectSensitive` 的内容,自动捞出来等于绕过隔离检查。
|
|
13
|
+
*
|
|
14
|
+
* 用法:node scripts/scan-orphans.mjs [根目录](缺省为当前目录)
|
|
15
|
+
*/
|
|
16
|
+
import { readdirSync, existsSync, readFileSync, statSync } from 'node:fs'
|
|
17
|
+
import { dirname, join } from 'node:path'
|
|
18
|
+
|
|
19
|
+
/** 与 engine.ts 的 projectBackendName 逐字一致:djb2 over 小写路径,36 进制。 */
|
|
20
|
+
function projectBackendName(cwd) {
|
|
21
|
+
let h = 5381
|
|
22
|
+
const text = cwd.toLowerCase()
|
|
23
|
+
for (let i = 0; i < text.length; i++) h = ((h << 5) + h + text.charCodeAt(i)) | 0
|
|
24
|
+
return `memory_project_${Math.abs(h).toString(36)}`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 递归找出所有 `.dsh/storages` 目录;跳过 node_modules / .git,限深防跑飞。 */
|
|
28
|
+
function findStorageDirs(root, depth = 0, out = []) {
|
|
29
|
+
if (depth > 4) return out
|
|
30
|
+
let entries = []
|
|
31
|
+
try {
|
|
32
|
+
entries = readdirSync(root, { withFileTypes: true })
|
|
33
|
+
} catch {
|
|
34
|
+
return out
|
|
35
|
+
}
|
|
36
|
+
for (const entry of entries) {
|
|
37
|
+
if (!entry.isDirectory() || entry.name === 'node_modules' || entry.name === '.git') continue
|
|
38
|
+
const full = join(root, entry.name)
|
|
39
|
+
if (entry.name === '.dsh') {
|
|
40
|
+
const storages = join(full, 'storages')
|
|
41
|
+
if (existsSync(storages)) out.push(storages)
|
|
42
|
+
continue
|
|
43
|
+
}
|
|
44
|
+
findStorageDirs(full, depth + 1, out)
|
|
45
|
+
}
|
|
46
|
+
return out
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 数一个记忆文件里的记录条数;读不出来返回 null(诚实报告「读不了」而不是 0)。 */
|
|
50
|
+
function countRecords(file) {
|
|
51
|
+
try {
|
|
52
|
+
const blocks = JSON.parse(readFileSync(file, 'utf8'))?.tables?.blocks ?? {}
|
|
53
|
+
return Object.values(blocks).filter(block => block && block.content).length
|
|
54
|
+
} catch {
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const root = process.argv[2] ?? process.cwd()
|
|
60
|
+
const storageDirs = findStorageDirs(root)
|
|
61
|
+
|
|
62
|
+
console.log(`扫描根:${root}`)
|
|
63
|
+
console.log(`发现 ${storageDirs.length} 个 storages 目录\n`)
|
|
64
|
+
|
|
65
|
+
let orphans = 0
|
|
66
|
+
let orphanRecords = 0
|
|
67
|
+
let healthy = 0
|
|
68
|
+
|
|
69
|
+
for (const storages of storageDirs) {
|
|
70
|
+
const workspace = dirname(dirname(storages))
|
|
71
|
+
const expected = `${projectBackendName(workspace)}.json`
|
|
72
|
+
let files = []
|
|
73
|
+
try {
|
|
74
|
+
files = readdirSync(storages).filter(name => name.startsWith('memory_project') && name.endsWith('.json'))
|
|
75
|
+
} catch {
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
if (files.length === 0) continue
|
|
79
|
+
|
|
80
|
+
const rows = files.map(name => {
|
|
81
|
+
const file = join(storages, name)
|
|
82
|
+
return {
|
|
83
|
+
name,
|
|
84
|
+
ok: name === expected,
|
|
85
|
+
count: countRecords(file),
|
|
86
|
+
size: statSync(file).size,
|
|
87
|
+
mtime: new Date(statSync(file).mtimeMs).toISOString().slice(0, 10),
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
const bad = rows.filter(row => !row.ok)
|
|
91
|
+
const good = rows.filter(row => row.ok)
|
|
92
|
+
|
|
93
|
+
console.log(`── ${workspace}`)
|
|
94
|
+
console.log(` 期望文件名:${expected}`)
|
|
95
|
+
for (const row of good) {
|
|
96
|
+
healthy += 1
|
|
97
|
+
console.log(` ✅ ${row.name} ${row.count ?? '?'} 条 ${row.size}B ${row.mtime}`)
|
|
98
|
+
}
|
|
99
|
+
for (const row of bad) {
|
|
100
|
+
orphans += 1
|
|
101
|
+
orphanRecords += row.count ?? 0
|
|
102
|
+
console.log(` ⚠️ ${row.name} ${row.count ?? '?'} 条 ${row.size}B ${row.mtime} ← 代码打不开`)
|
|
103
|
+
}
|
|
104
|
+
console.log('')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
console.log('── 汇总 ──')
|
|
108
|
+
console.log(`能被代码打开:${healthy} 个文件`)
|
|
109
|
+
console.log(`打不开(孤儿):${orphans} 个文件,共 ${orphanRecords} 条记录`)
|
|
110
|
+
console.log('\n本脚本只清点。处置(归档 / 选择性导入 / 删除)见设计文档 §3.4,需人工拍板后单独执行。')
|