@max-null/dsh-memory 0.6.1 → 0.7.1

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
@@ -5,7 +5,7 @@
5
5
  * is always created `suggested` and becomes effective only through `setStatus`.
6
6
  */
7
7
  import { randomUUID } from 'node:crypto';
8
- import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
8
+ import { existsSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
9
9
  import { homedir } from 'node:os';
10
10
  import { join } from 'node:path';
11
11
  import { Service } from '@deepseek-ai/cordis';
@@ -14,14 +14,45 @@ import { defineDomain, domainTable, DomainFacility } from '@deepseek-ai/dsh-stor
14
14
  import { JsonStorageBackend } from '@deepseek-ai/dsh-storage-json';
15
15
  import { bm25FieldScores, cosineSimilarity, rrfFuse } from "./bm25.js";
16
16
  import { parsePromptFile, scanPromptDir, writePromptFile } from "./prompt-files.js";
17
+ import { detectSensitive } from "./quarantine.js";
18
+ import { SELF_VERSION } from "./self.js";
19
+ import { anchorHolds } from "./anchors.js";
20
+ import { QueryLog, rankGapCandidates, summarizeQueryLog, summarizeSources } from "./query-log.js";
17
21
  /** Brand a string as a {@link MemoryId} (compile-time only). */
18
22
  export function MemoryId(id) {
19
23
  return id;
20
24
  }
25
+ /** 自动升常驻所需的命中次数(2026-09-15):一次偶然命中不足以证明「每轮都值得付费」。 */
26
+ const AUTO_INJECT_HITS = 2;
27
+ /**
28
+ * 一次检索里最多把前多少条记为「被使用」(2026-09-15)。
29
+ *
30
+ * 不设上限时,命中计数退化成「和查询有任意字符重叠」:BM25 对常见 2-gram 会给全库打分,
31
+ * 一句「实机验证标记」在本机就能命中 81 条,于是**两次检索把所有 approved 记忆都升成常驻**,
32
+ * 注入预算当场被历史条目占满——「反复被命中才值得每轮付费」这个判据也就失效了。
33
+ * 取前 K 名接近「模型真的会看的那几条」,且与检索返回值解耦(返回值不动)。
34
+ */
35
+ const HIT_MARK_LIMIT = 5;
36
+ /** 反查候选的正文摘要长度(字符)。 */
37
+ const GAP_EXCERPT_CHARS = 160;
38
+ /** 自动降级的天数阈值(2026-09-15):与面板的冷数据判定(COLD_DAYS=30)对齐。 */
39
+ const AUTO_DEMOTE_DAYS = 30;
21
40
  const blockSchema = z.object({
22
41
  namespace: z.enum(['global', 'project']),
23
42
  status: z.enum(['suggested', 'approved', 'auto', 'suggest']),
24
43
  injected: z.boolean().optional(),
44
+ quarantined: z.boolean().optional(),
45
+ quarantineReason: z.string().optional(),
46
+ hitCount: z.number().optional(),
47
+ source: z.enum(['agent', 'human']).optional(),
48
+ injectedAuto: z.boolean().optional(),
49
+ anchor: z.object({
50
+ kind: z.enum(['env', 'tool-list', 'self-version']),
51
+ name: z.string().optional(),
52
+ value: z.string(),
53
+ }).optional(),
54
+ stale: z.boolean().optional(),
55
+ staleReason: z.string().optional(),
25
56
  content: z.string(),
26
57
  keywords: z.array(z.string()),
27
58
  createdAt: z.number(),
@@ -97,6 +128,15 @@ function toRecord(id, block) {
97
128
  createdAt: block.createdAt,
98
129
  updatedAt: block.updatedAt,
99
130
  ...block.lastUsedAt === undefined ? {} : { lastUsedAt: block.lastUsedAt },
131
+ quarantined: block.quarantined === true,
132
+ ...block.quarantineReason === undefined ? {} : { quarantineReason: block.quarantineReason },
133
+ hitCount: block.hitCount ?? 0,
134
+ // ⑧ 来源(2026-09-15):旧记录无此字段 → 不透传(= 来源未知),不猜
135
+ ...block.source === undefined ? {} : { source: block.source },
136
+ ...block.injectedAuto === undefined ? {} : { injectedAuto: block.injectedAuto },
137
+ ...block.anchor === undefined ? {} : { anchor: block.anchor },
138
+ ...block.stale === undefined ? {} : { stale: block.stale },
139
+ ...block.staleReason === undefined ? {} : { staleReason: block.staleReason },
100
140
  };
101
141
  // 0.6.0 模板库字段(缺省 fact;prompt 元数据透传)
102
142
  return {
@@ -130,6 +170,12 @@ export class MemoryEngine extends Service {
130
170
  /** backend 只注册一次(registry 重名抛 duplicate;reload 清缓存后不得重复注册)。 */
131
171
  registeredProjectBackends = new Set();
132
172
  facility;
173
+ /** 各存储文件上次被我们确认过的指纹;用于识别「另一个实例写过」。 */
174
+ storeStamps = new Map();
175
+ /** 写入串行链:「刷新—读—改—写」必须整体互斥,否则并发写会在其间穿插。 */
176
+ writeChain = Promise.resolve();
177
+ /** 会话启动维护只跑一次的标记(2026-09-15)。 */
178
+ maintenanceDone = false;
133
179
  constructor(ctx, config = {}) {
134
180
  super(ctx, 'memory');
135
181
  this.config = config;
@@ -226,8 +272,92 @@ export class MemoryEngine extends Service {
226
272
  this.projectFacilities.clear();
227
273
  await this.openGlobalFacility();
228
274
  }
229
- /** Create one record in `suggested` status — never self-promoting. */
275
+ /**
276
+ * Create one record. 低危内容直接 `approved`(静默生效,2026-09-15 静默记忆机制);
277
+ * 命中危险内容规则则隔离:`status` 留 `suggested`(天然不注入)**并且**置
278
+ * `quarantined`(不进检索),双保险,等待人工放行或删除。
279
+ */
280
+ // ── 多实例写入协调(2026-09-17)────────────────────────────────────────
281
+ //
282
+ // 另一个 DSH 实例(web 与 SSiD 同时运行)共用同一个 DSH_HOME 时,它写的记忆只落在
283
+ // 磁盘上;我们的内存态若比磁盘旧,下一次整份覆盖就会把对方的新记忆抹掉。DSH 的
284
+ // 存储层明确不做跨进程协调(storage-json「writer per process and last-write-wins
285
+ // is correct」、storage-sqlite「cross-process coordination is out of scope」),
286
+ // 所以协调放在这里:开写前比对存储文件指纹,变过就重载——等价于「update 前先
287
+ // select」。残余窗口只剩两个实例真正同时写,而记忆写入是低频操作。
288
+ /** 文件指纹:mtime + size(size 兜住同一毫秒内的两次写)。 */
289
+ static fileStamp(file) {
290
+ try {
291
+ const stat = statSync(file);
292
+ return `${stat.mtimeMs}:${stat.size}`;
293
+ }
294
+ catch {
295
+ return 'missing';
296
+ }
297
+ }
298
+ /** 全局记忆的存储文件(JsonStorageBackend 的 unit 文件名 = domain 名)。 */
299
+ globalStoreFile() {
300
+ return join(this.config.globalRoot ?? globalRoot(), 'memory.json');
301
+ }
302
+ /** 某工作区记忆的存储文件。 */
303
+ projectStoreFile(projectCwd) {
304
+ const key = join(projectCwd);
305
+ return join(this.projectRootFor(key), `${this.projectBackendName(key)}.json`);
306
+ }
307
+ /** 记录当前指纹,作为「这就是我们造成的状态」的基准(写入完成后调用)。 */
308
+ noteStoreStamps(projectCwd) {
309
+ const global = this.globalStoreFile();
310
+ this.storeStamps.set(global, MemoryEngine.fileStamp(global));
311
+ const scopes = new Set(this.projectTables.keys());
312
+ if (projectCwd !== undefined && projectCwd !== '')
313
+ scopes.add(join(projectCwd));
314
+ for (const key of scopes) {
315
+ const file = this.projectStoreFile(key);
316
+ this.storeStamps.set(file, MemoryEngine.fileStamp(file));
317
+ }
318
+ }
319
+ /**
320
+ * 写前新鲜度门:存储文件被外部改过就重载,避免整份覆盖吃掉对方的写入。
321
+ * 必须在取表**之前**调用——{@link reload} 重开 facility,此前取到的 table 引用会失效。
322
+ */
323
+ async refreshForWrite(projectCwd) {
324
+ if (this.storeStamps.size === 0) {
325
+ this.noteStoreStamps(projectCwd);
326
+ return;
327
+ }
328
+ const files = [this.globalStoreFile()];
329
+ if (projectCwd !== undefined && projectCwd !== '')
330
+ files.push(this.projectStoreFile(projectCwd));
331
+ for (const file of files) {
332
+ if (MemoryEngine.fileStamp(file) === this.storeStamps.get(file))
333
+ continue;
334
+ await this.reload();
335
+ this.noteStoreStamps(projectCwd);
336
+ return;
337
+ }
338
+ }
339
+ /**
340
+ * 一次写入的完整作用域:串行化 → 新鲜度门 → 执行 → 记指纹。
341
+ * 刷新与「读—改—写」必须同处一个互斥区,否则并发写会在两者之间穿插。
342
+ * @param projectCwd - 本次写入涉及的工作区;无则只覆盖全局存储。
343
+ * @param run - 实际的读改写;必须在内部重新取表(刷新后旧引用失效)。
344
+ * @returns `run` 的返回值。
345
+ */
346
+ writeScope(projectCwd, run) {
347
+ const task = this.writeChain.then(async () => {
348
+ await this.refreshForWrite(projectCwd);
349
+ const result = await run();
350
+ this.noteStoreStamps(projectCwd);
351
+ return result;
352
+ });
353
+ this.writeChain = task.then(() => undefined, () => undefined);
354
+ return task;
355
+ }
230
356
  async remember(input, projectCwd) {
357
+ return this.writeScope(projectCwd, () => this.rememberInner(input, projectCwd));
358
+ }
359
+ /** {@link remember} 的实现体;必须经 `writeScope` 进入(多实例写入协调)。 */
360
+ async rememberInner(input, projectCwd) {
231
361
  const namespace = input.namespace ?? 'global';
232
362
  const table = namespace === 'project'
233
363
  ? await this.projectTableFor(projectCwd)
@@ -236,14 +366,23 @@ export class MemoryEngine extends Service {
236
366
  throw new Error('cannot write project memory without a workspace cwd');
237
367
  const id = randomUUID();
238
368
  const now = Date.now();
369
+ // 关键词一并参与判定:密钥塞进 keywords 同样会在检索结果里外泄
370
+ const verdict = detectSensitive(input.content, ...(input.keywords ?? []));
239
371
  const block = {
240
372
  namespace,
241
- status: 'suggested',
373
+ status: verdict.quarantined ? 'suggested' : 'approved',
242
374
  injected: false,
243
375
  content: input.content,
244
376
  keywords: (input.keywords ?? []).map(keyword => keyword.toLowerCase()),
245
377
  createdAt: now,
246
378
  updatedAt: now,
379
+ // ⑧ 来源(2026-09-15):缺省 'agent'——写入这条记忆的就是主模型
380
+ source: input.source ?? 'agent',
381
+ // 有效性锚点(可选):值变了这条记忆会被标 stale,不再被当成仍然正确
382
+ ...input.anchor === undefined ? {} : { anchor: input.anchor },
383
+ ...verdict.quarantined
384
+ ? { quarantined: true, ...verdict.reason === undefined ? {} : { quarantineReason: verdict.reason } }
385
+ : {},
247
386
  };
248
387
  await table.put(id, block);
249
388
  const record = toRecord(id, block);
@@ -252,12 +391,30 @@ export class MemoryEngine extends Service {
252
391
  }
253
392
  async list(filter, projectCwd) {
254
393
  const records = await this.allRecords(filter?.namespace, projectCwd);
255
- return records.filter(record => (filter?.status === undefined || record.status === filter.status)
394
+ return records.filter(record =>
395
+ // 隔离记录默认整体排除(不进列表、不进检索,因而也不进注入预览);
396
+ // 面板要用 filter.quarantined === true 显式取出来审(2026-09-15)。
397
+ (filter?.quarantined === undefined ? record.quarantined !== true : record.quarantined === filter.quarantined)
398
+ && (filter?.status === undefined || record.status === filter.status)
256
399
  && (filter?.injected === undefined || record.injected === filter.injected)
257
400
  && (filter?.kind === undefined || (record.kind ?? 'fact') === filter.kind));
258
401
  }
402
+ /**
403
+ * 关键词检索。每次成功返回的检索都记一条查询日志(⑥ 2026-09-15)——命中与否都记:
404
+ * 漏检不产生任何事件,只有日志能事后回答「哪些查询本该命中谁」(反例 4)。日志是旁路:
405
+ * 写失败只记 warn,绝不改变本次检索结果。
406
+ */
259
407
  async search(query, filter, projectCwd) {
260
- const records = await this.list(filter, projectCwd);
408
+ const hits = await this.rank(query, filter, projectCwd);
409
+ this.recordQuery(query, hits.length, filter);
410
+ return hits;
411
+ }
412
+ /** 检索打分本体(BM25 + 可选语义融合);查询日志由 {@link search} 记录。 */
413
+ async rank(query, filter, projectCwd) {
414
+ // 记忆检索默认只看 fact(2026-09-15):模板是文件库、有独立的 prompt_search 通道,
415
+ // 混进来的代价不只是占返回条数——模板也会被记命中,然后「因为经常被搜到」升成常驻。
416
+ // 显式传 kind 的调用方(prompt_search)照旧生效。
417
+ const records = await this.list({ kind: 'fact', ...filter }, projectCwd);
261
418
  // 0.5.2:content 与 keywords 分离打加权 BM25(人工关键词命中权重更高)
262
419
  const scores = bm25FieldScores(query, records.map(record => ({
263
420
  body: record.content,
@@ -267,8 +424,10 @@ export class MemoryEngine extends Service {
267
424
  .map((record, index) => ({ record, score: scores[index] ?? 0 }))
268
425
  .filter(hit => hit.score > 0)
269
426
  .sort((left, right) => right.score - left.score);
270
- // 0.5.3:命中即标记使用(冷热追踪)——写回按命中快照之后执行,不影响本次结果
271
- await this.markUsed(bm25Ranked.map(hit => hit.record.id), projectCwd);
427
+ // 0.5.3:命中即标记使用(冷热追踪)——写回按命中快照之后执行,不影响本次结果。
428
+ // 2026-09-15:只记前 `HIT_MARK_LIMIT` 名(BM25 命中面极宽,全记等于给全库计数);
429
+ // 记账在语义融合之前,按 BM25 名次取,融合结果只影响返回顺序、不影响计数。
430
+ await this.markUsed(bm25Ranked.slice(0, HIT_MARK_LIMIT).map(hit => hit.record.id), projectCwd);
272
431
  // 0.5.2:配置 embeddings 时与语义结果 RRF 融合;缺省/失败降级回纯 BM25
273
432
  if (this.config.embeddings === undefined)
274
433
  return bm25Ranked;
@@ -343,6 +502,56 @@ export class MemoryEngine extends Service {
343
502
  .slice(0, topK);
344
503
  }
345
504
  /** 按已知 id 定位记录所在表(global 优先,其次当前工作区表)。 */
505
+ /**
506
+ * 会话启动维护(2026-09-15):一次扫描做完三件事——旧数据迁移、长期未命中的自动降级、
507
+ * 锚点批量校验。只跑一次、幂等、**失败不阻断**(记忆可用性优先于维护完成度)。
508
+ *
509
+ * 调用点在 `ensureProjectOpen`:它是 async,且每会话首次打开工作区表时必经。
510
+ * `memory:recall` 的 provider 是**同步**回调,不能在那里 await。
511
+ *
512
+ * @param projectCwd - 会话工作区:迁移/降级/校验都连它一起处理。
513
+ */
514
+ async runMaintenanceOnce(projectCwd) {
515
+ if (this.maintenanceDone)
516
+ return;
517
+ this.maintenanceDone = true;
518
+ try {
519
+ const migration = await this.migrateLegacy(projectCwd);
520
+ if (migration.approved > 0 || migration.quarantined > 0 || migration.counted > 0) {
521
+ this.ctx.logger?.info?.(`dsh-memory: 旧数据迁移 放行 ${migration.approved} / 隔离 ${migration.quarantined}`
522
+ + ` / 回填计数 ${migration.counted}`);
523
+ }
524
+ const demoted = await this.demoteStale(projectCwd);
525
+ if (demoted > 0)
526
+ this.ctx.logger?.info?.(`dsh-memory: 长期未命中,撤下常驻 ${demoted} 条`);
527
+ const staled = await this.verifyAnchors(this.anchorProbes(), projectCwd);
528
+ if (staled > 0)
529
+ this.ctx.logger?.info?.(`dsh-memory: 锚点校验更新 ${staled} 条`);
530
+ }
531
+ catch (error) {
532
+ this.ctx.logger?.warn?.(`dsh-memory: 启动维护失败(不影响使用):${String(error)}`);
533
+ }
534
+ }
535
+ /**
536
+ * 锚点探测上下文。`toolNames` 拿不到时返回 `undefined`——**不能给空数组**:那会被判成
537
+ * 「工具全没了」,让所有 `tool-list` 锚点误失效。
538
+ */
539
+ anchorProbes() {
540
+ let toolNames;
541
+ try {
542
+ const tools = this.ctx.get('tools');
543
+ const listed = tools?.list?.();
544
+ if (Array.isArray(listed)) {
545
+ toolNames = listed
546
+ .map(tool => tool.name)
547
+ .filter((name) => typeof name === 'string');
548
+ }
549
+ }
550
+ catch {
551
+ toolNames = undefined;
552
+ }
553
+ return { ...(toolNames === undefined ? {} : { toolNames }), selfVersion: SELF_VERSION };
554
+ }
346
555
  tableOf(id, projectCwd) {
347
556
  const global = this.requireTable('global');
348
557
  if (global.get(id) !== undefined)
@@ -352,29 +561,327 @@ export class MemoryEngine extends Service {
352
561
  const project = this.projectTables.get(join(projectCwd));
353
562
  return project !== undefined && project.get(id) !== undefined ? project : undefined;
354
563
  }
355
- /** 命中标记:把 lastUsedAt 写回(命中记录不存在时跳过;不 emit 事件——统计性字段)。 */
564
+ /** 命中标记:写回 lastUsedAt 与命中次数,达标即自动升常驻(2026-09-15)。 */
356
565
  async markUsed(ids, projectCwd) {
566
+ return this.writeScope(projectCwd, () => this.markUsedInner(ids, projectCwd));
567
+ }
568
+ /** {@link markUsed} 的实现体;必须经 `writeScope` 进入(`recordUse` 在此之内)。 */
569
+ async markUsedInner(ids, projectCwd) {
357
570
  if (ids.length === 0)
358
571
  return;
359
572
  const now = Date.now();
360
573
  const global = this.requireTable('global');
361
- const project = projectCwd === undefined || projectCwd === ''
362
- ? undefined
363
- : this.projectTables.get(join(projectCwd));
574
+ // 取表走异步版:`writeScope` 可能在本次进入前刚 `reload()`(外部实例改过存储),
575
+ // 那时同步缓存已被清空——继续读缓存会把 project 侧的命中静默漏记。
576
+ const project = await this.projectTableFor(projectCwd);
364
577
  for (const id of ids) {
365
578
  const globalBlock = global.get(id);
366
579
  if (globalBlock !== undefined) {
367
- await global.put(id, { ...globalBlock, lastUsedAt: now });
580
+ await this.recordUse(global, id, globalBlock, now);
368
581
  continue;
369
582
  }
370
583
  if (project !== undefined) {
371
584
  const block = project.get(id);
372
585
  if (block !== undefined)
373
- await project.put(id, { ...block, lastUsedAt: now });
586
+ await this.recordUse(project, id, block, now);
587
+ }
588
+ }
589
+ }
590
+ /**
591
+ * 命中记账 + 自动升级(2026-09-15 静默记忆机制):命中次数达阈值即自动打开常驻
592
+ * 注入——「被反复检索命中」是它值得每轮付费的唯一客观证据。统计性字段不 emit,
593
+ * 只有真的改变了注入状态才发 `memory/changed`。
594
+ */
595
+ async recordUse(table, id, block, now) {
596
+ const hitCount = (block.hitCount ?? 0) + 1;
597
+ const normalized = normalizeBlock(block);
598
+ // 人工设置过的(injectedAuto === false)双向豁免:既不被降级,也不被升级。
599
+ // 少了这一条,人手动关掉的记忆会在下次命中时被系统重新打开——用户唯一能
600
+ // 表达「不要这条常驻」的动作就失效了,而这与降级侧「人工决定优先」不对称。
601
+ const promote = normalized.status === 'approved'
602
+ && block.quarantined !== true
603
+ && block.injectedAuto !== false
604
+ // 模板永不注入(0.6.0):prompt 记录连自动升级的资格都没有。少了这一条,
605
+ // 模板会因「经常被搜到」而升成常驻,把注入预算花在从不该进上下文的东西上。
606
+ && (block.kind ?? 'fact') !== 'prompt'
607
+ && !normalized.injected
608
+ && hitCount >= AUTO_INJECT_HITS;
609
+ const updated = {
610
+ ...block,
611
+ hitCount,
612
+ lastUsedAt: now,
613
+ ...promote ? { injected: true, injectedAuto: true } : {},
614
+ };
615
+ await table.put(id, updated);
616
+ if (promote)
617
+ this.ctx.emit('memory/changed', { operation: 'injected', id, injected: true });
618
+ }
619
+ // ── 查询日志与影响力统计(⑥ 2026-09-15)────────────────────────────────
620
+ /** 查询日志(惰性创建;位置见 {@link queryLogPath})。 */
621
+ queryLogInstance;
622
+ queryLog() {
623
+ this.queryLogInstance ??= new QueryLog({ path: this.queryLogPath() });
624
+ return this.queryLogInstance;
625
+ }
626
+ /** 查询日志文件路径:与 `memory.json` 同目录(不落子目录)。 */
627
+ queryLogPath() {
628
+ return this.config.queryLogPath ?? join(this.config.globalRoot ?? globalRoot(), 'query-log.json');
629
+ }
630
+ /**
631
+ * 记录一次检索:查询文本(截断到 120 字符)+ 命中条数 + 时间。**失败绝不影响检索**——
632
+ * 日志是旁路证据,不是检索的一部分。模板库检索不进日志:`prompt_search` 复用同一条
633
+ * `search` 通道,但模板是文件库、不属于「记忆漏检」的观察面。
634
+ *
635
+ * 查询文本是新的明文落盘点,因此先过危险内容硬拦(与写入路径同一套规则):命中就只记
636
+ * 规则名、不留原文——凭据不该因为「只是被搜了一下」而进另一个明文文件。
637
+ */
638
+ recordQuery(query, hits, filter) {
639
+ if (filter?.kind === 'prompt')
640
+ return;
641
+ try {
642
+ const verdict = detectSensitive(query);
643
+ this.queryLog().appendQuery(verdict.quarantined
644
+ ? { query: `[已脱敏:命中 ${verdict.reason ?? 'dangerous-content'}]`, hits }
645
+ : { query, hits });
646
+ }
647
+ catch (error) {
648
+ this.ctx.logger?.warn?.(`dsh-memory: 查询日志写入跳过:${String(error)}`);
649
+ }
650
+ }
651
+ /**
652
+ * 影响力统计(⑥ / 反例 5):按来源聚合「写入量 / 被检索命中数 / 常驻注入数」。
653
+ * 只算 fact 记录(模板索引不是记忆),隔离记录不计(与 list 的默认口径一致:
654
+ * 它们不在流通里)。「被人工删除数」暂缺的原因见 `summarizeSources` 的注释。
655
+ */
656
+ async sourceStats(projectCwd) {
657
+ const records = await this.list({ kind: 'fact' }, projectCwd);
658
+ return summarizeSources(records.map(record => ({
659
+ ...record.source === undefined ? {} : { source: record.source },
660
+ hitCount: record.hitCount,
661
+ injected: record.injected,
662
+ })));
663
+ }
664
+ /**
665
+ * 低频反查(⑥,**只产出报告,不自动改写**):拿「命中 0 的查询」与既有记忆反查,
666
+ * 给出「本应命中却没命中」的候选,供人工或模型补 keywords。
667
+ *
668
+ * 不做自动补写:判断「这条记忆是否真该被那个查询找到」需要 LLM 语义判断,插件只提供
669
+ * 证据(候选为空即知识缺口——「这类知识你还没有」本身也是有用信息)。调用时机由人
670
+ * 或模型定,不在检索路径上、不产生每轮成本。
671
+ *
672
+ * @param projectCwd - 会话工作区;候选库连工作区记忆一起取。
673
+ * @param limit - 最多反查几个空命中查询(按最近出现排序)。
674
+ */
675
+ async suggestKeywordGaps(projectCwd, limit = 10) {
676
+ const summary = summarizeQueryLog(this.queryLog().readLog(), limit);
677
+ if (summary.gaps.length === 0)
678
+ return { logSize: summary.total, misses: summary.misses, gaps: [] };
679
+ const records = await this.list({ kind: 'fact' }, projectCwd);
680
+ const byId = new Map(records.map(record => [String(record.id), record]));
681
+ const docs = records.map(record => ({
682
+ id: String(record.id),
683
+ body: record.content,
684
+ tags: record.keywords.join(' '),
685
+ }));
686
+ return {
687
+ logSize: summary.total,
688
+ misses: summary.misses,
689
+ gaps: summary.gaps.map(gap => ({
690
+ query: gap.query,
691
+ count: gap.count,
692
+ lastAt: gap.lastAt,
693
+ candidates: rankGapCandidates(gap.query, docs).flatMap(candidate => {
694
+ const record = byId.get(candidate.id);
695
+ return record === undefined ? [] : [{
696
+ id: candidate.id,
697
+ excerpt: record.content.slice(0, GAP_EXCERPT_CHARS),
698
+ keywords: record.keywords,
699
+ exact: candidate.exact,
700
+ relaxed: candidate.relaxed,
701
+ }];
702
+ }),
703
+ })),
704
+ };
705
+ }
706
+ /**
707
+ * 长期未命中的自动降级(2026-09-15 淘汰机制):**只撤自动开的常驻注入**——人工开
708
+ * 的开关不会被时间悄悄关掉(那是人的决定,不是信号的结论)。判定用 lastUsedAt,
709
+ * 从未命中过则回退 updatedAt。会话启动时调一次即可(见 index.ts 的预热路径)。
710
+ *
711
+ * @param projectCwd - 会话工作区;给了就连工作区表一起扫。
712
+ * @param now - 当前时间(测试注入用)。
713
+ * @returns 被撤下的条数(诊断与日志用)。
714
+ */
715
+ async demoteStale(projectCwd, now = Date.now()) {
716
+ return this.writeScope(projectCwd, () => this.demoteStaleInner(projectCwd, now));
717
+ }
718
+ /** {@link demoteStale} 的实现体;必须经 `writeScope` 进入。 */
719
+ async demoteStaleInner(projectCwd, now = Date.now()) {
720
+ const threshold = now - AUTO_DEMOTE_DAYS * 24 * 60 * 60 * 1000;
721
+ const tables = [this.requireTable('global')];
722
+ if (projectCwd !== undefined && projectCwd !== '') {
723
+ const project = await this.projectTableFor(projectCwd);
724
+ if (project !== undefined)
725
+ tables.push(project);
726
+ }
727
+ let demoted = 0;
728
+ for (const table of tables) {
729
+ // 先快照键值对:put 会改到底层 Map,边遍历边写不安全
730
+ for (const [id, block] of [...table.entries()]) {
731
+ if (block.injectedAuto !== true)
732
+ continue;
733
+ if (block.quarantined === true)
734
+ continue;
735
+ if (normalizeBlock(block).status !== 'approved')
736
+ continue;
737
+ const last = block.lastUsedAt ?? block.updatedAt;
738
+ if (last > threshold)
739
+ continue;
740
+ await table.put(id, { ...block, injected: false, injectedAuto: false });
741
+ this.ctx.emit('memory/changed', { operation: 'injected', id: MemoryId(id), injected: false });
742
+ demoted += 1;
374
743
  }
375
744
  }
745
+ return demoted;
746
+ }
747
+ /**
748
+ * 锚点批量校验(2026-09-15):会话启动时调一次,把失效的条目标成 `stale`。
749
+ *
750
+ * 失效同时**撤掉常驻注入**——内容可能已经错了,继续每轮注入就是在误导(反例 2 的
751
+ * 教训);但**不删除、也不排除检索**,它在结果里带标注,等模型核对后回写。
752
+ * 探测不到(`undefined`)按「未校验」处理,不动任何状态。
753
+ *
754
+ * @param probes - 探测上下文(工具清单 / 自身版本 / env 读取器)。
755
+ * @param projectCwd - 会话工作区;给了就连工作区表一起校验。
756
+ * @returns 本次状态发生变化的条数。
757
+ */
758
+ async verifyAnchors(probes, projectCwd) {
759
+ return this.writeScope(projectCwd, () => this.verifyAnchorsInner(probes, projectCwd));
760
+ }
761
+ /** {@link verifyAnchors} 的实现体;必须经 `writeScope` 进入。 */
762
+ async verifyAnchorsInner(probes, projectCwd) {
763
+ const tables = [this.requireTable('global')];
764
+ if (projectCwd !== undefined && projectCwd !== '') {
765
+ const project = await this.projectTableFor(projectCwd);
766
+ if (project !== undefined)
767
+ tables.push(project);
768
+ }
769
+ let changed = 0;
770
+ for (const table of tables) {
771
+ for (const [id, block] of [...table.entries()]) {
772
+ if (block.anchor === undefined)
773
+ continue;
774
+ const holds = anchorHolds(block.anchor, probes);
775
+ if (holds === undefined)
776
+ continue; // 未校验:探测不到就不动
777
+ if ((block.stale === true) === !holds)
778
+ continue; // 状态没变
779
+ const label = `${block.anchor.kind}${block.anchor.name === undefined ? '' : `:${block.anchor.name}`}`;
780
+ const updated = holds
781
+ ? { ...block, stale: false, staleReason: undefined }
782
+ : {
783
+ ...block,
784
+ stale: true,
785
+ staleReason: `${label} 声明为 ${block.anchor.value},当前不符`,
786
+ injected: false,
787
+ injectedAuto: false,
788
+ };
789
+ await table.put(id, updated);
790
+ this.ctx.emit('memory/changed', { operation: 'status', id: MemoryId(id), status: normalizeBlock(updated).status });
791
+ changed += 1;
792
+ }
793
+ }
794
+ return changed;
795
+ }
796
+ /**
797
+ * 旧数据迁移(2026-09-15 静默记忆机制):一次性、幂等,会话启动维护时调用。
798
+ *
799
+ * 旧语义里 `suggested` 是「等人工审核」,新语义里它只剩「隔离」与「人工回退」两种含义——
800
+ * 不迁移的话那些记录会永久卡住:既不注入,也没人去放行。迁移逐条过危险内容检测:
801
+ * 安全则升 `approved`(写入即生效),命中则标 `quarantined` 进隔离(**必须先检测再放行**:
802
+ * 历史条目若含密钥,直接放行等于让它开始进注入)。
803
+ *
804
+ * 另外把「有 `lastUsedAt` 但无 `hitCount`」的老记录回填 `hitCount = 1`——它们可能早已被
805
+ * 反复用过,不该从零开始攒两次命中。旧 `injected: true` 且无 `injectedAuto` 的一律不动:
806
+ * 那是人工决定,不该被自动降级。
807
+ *
808
+ * @param projectCwd - 会话工作区;给了就连工作区表一起迁移。
809
+ * @returns 迁移统计(放行 / 隔离 / 回填计数)。
810
+ */
811
+ async migrateLegacy(projectCwd) {
812
+ return this.writeScope(projectCwd, () => this.migrateLegacyInner(projectCwd));
813
+ }
814
+ /** {@link migrateLegacy} 的实现体;必须经 `writeScope` 进入。 */
815
+ async migrateLegacyInner(projectCwd) {
816
+ const tables = [this.requireTable('global')];
817
+ if (projectCwd !== undefined && projectCwd !== '') {
818
+ const project = await this.projectTableFor(projectCwd);
819
+ if (project !== undefined)
820
+ tables.push(project);
821
+ }
822
+ const stats = { approved: 0, quarantined: 0, counted: 0 };
823
+ // 幂等:先判断是否真的有事可做——没有就直接返回,连备份都不做(否则每天首次启动
824
+ // 都会留下一份无意义的 .bak)
825
+ const needsMigration = tables.some(table => [...table.entries()].some(([, block]) => {
826
+ const normalized = normalizeBlock(block);
827
+ if (normalized.status === 'suggested' && block.quarantined !== true)
828
+ return true;
829
+ return block.hitCount === undefined && block.lastUsedAt !== undefined;
830
+ }));
831
+ if (!needsMigration)
832
+ return stats;
833
+ // 迁移前备份全局存储(工作区记忆随 git 分享,有 git 兜底)。备份失败只记日志、不阻断:
834
+ // 迁移本身就是幂等的,最坏情况是重复跑一次。
835
+ try {
836
+ const file = join(this.config.globalRoot ?? globalRoot(), 'memory.json');
837
+ if (existsSync(file)) {
838
+ const backup = `${file}.bak-${new Date().toISOString().slice(0, 10)}-migration`;
839
+ if (!existsSync(backup))
840
+ writeFileSync(backup, readFileSync(file));
841
+ }
842
+ }
843
+ catch (error) {
844
+ this.ctx.logger?.warn?.(`dsh-memory: migration backup skipped: ${String(error)}`);
845
+ }
846
+ for (const table of tables) {
847
+ for (const [id, block] of [...table.entries()]) {
848
+ const normalized = normalizeBlock(block);
849
+ let updated;
850
+ // ① 旧的 suggested:安全则放行、危险则隔离
851
+ if (normalized.status === 'suggested' && block.quarantined !== true) {
852
+ const verdict = detectSensitive(block.content, ...block.keywords);
853
+ if (verdict.quarantined) {
854
+ updated = {
855
+ ...block,
856
+ status: 'suggested',
857
+ injected: false,
858
+ quarantined: true,
859
+ ...verdict.reason === undefined ? {} : { quarantineReason: verdict.reason },
860
+ };
861
+ stats.quarantined += 1;
862
+ }
863
+ else {
864
+ updated = { ...block, status: 'approved', injected: normalized.injected };
865
+ stats.approved += 1;
866
+ }
867
+ }
868
+ // ② 命中计数回填(在 ① 的结果之上叠加,避免两次 put 互相覆盖)
869
+ const base = updated ?? block;
870
+ if (base.hitCount === undefined && base.lastUsedAt !== undefined) {
871
+ updated = { ...base, hitCount: 1 };
872
+ stats.counted += 1;
873
+ }
874
+ if (updated !== undefined)
875
+ await table.put(id, updated);
876
+ }
877
+ }
878
+ return stats;
376
879
  }
377
880
  async forget(id, projectCwd) {
881
+ return this.writeScope(projectCwd, () => this.forgetInner(id, projectCwd));
882
+ }
883
+ /** {@link forget} 的实现体;必须经 `writeScope` 进入。 */
884
+ async forgetInner(id, projectCwd) {
378
885
  if (await this.requireTable('global').delete(id)) {
379
886
  this.ctx.emit('memory/changed', { operation: 'forgotten', id });
380
887
  return true;
@@ -387,6 +894,10 @@ export class MemoryEngine extends Service {
387
894
  return false;
388
895
  }
389
896
  async setStatus(id, status, projectCwd) {
897
+ return this.writeScope(projectCwd, () => this.setStatusInner(id, status, projectCwd));
898
+ }
899
+ /** {@link setStatus} 的实现体;必须经 `writeScope` 进入。 */
900
+ async setStatusInner(id, status, projectCwd) {
390
901
  const global = this.requireTable('global').get(id);
391
902
  if (global !== undefined) {
392
903
  const updated = {
@@ -394,6 +905,8 @@ export class MemoryEngine extends Service {
394
905
  status,
395
906
  // 旧数据可能缺 injected,写回时补全(读时迁移值)
396
907
  injected: normalizeBlock(global).injected,
908
+ // 人工放行 = 审核通过并解除隔离(2026-09-15)
909
+ ...status === 'approved' ? { quarantined: false, quarantineReason: undefined } : {},
397
910
  updatedAt: Date.now(),
398
911
  };
399
912
  await this.requireTable('global').put(id, updated);
@@ -409,6 +922,7 @@ export class MemoryEngine extends Service {
409
922
  ...block,
410
923
  status,
411
924
  injected: normalizeBlock(block).injected,
925
+ ...status === 'approved' ? { quarantined: false, quarantineReason: undefined } : {},
412
926
  updatedAt: Date.now(),
413
927
  };
414
928
  await project.put(id, updated);
@@ -424,6 +938,10 @@ export class MemoryEngine extends Service {
424
938
  * 「常驻注入」开关调用(remote.setInjected)。
425
939
  */
426
940
  async setInjected(id, injected, projectCwd) {
941
+ return this.writeScope(projectCwd, () => this.setInjectedInner(id, injected, projectCwd));
942
+ }
943
+ /** {@link setInjected} 的实现体;必须经 `writeScope` 进入。 */
944
+ async setInjectedInner(id, injected, projectCwd) {
427
945
  const locate = async () => {
428
946
  const global = this.requireTable('global').get(id);
429
947
  if (global !== undefined)
@@ -443,10 +961,16 @@ export class MemoryEngine extends Service {
443
961
  if ((found.block.kind ?? 'fact') === 'prompt') {
444
962
  throw new Error(`cannot set injected of prompt record '${id}': prompt templates are never injected`);
445
963
  }
964
+ // 隔离记录不可注入(2026-09-15):先放行(setStatus approved)再开开关
965
+ if (injected && found.block.quarantined === true) {
966
+ throw new Error(`cannot inject quarantined memory '${id}': approve it first`);
967
+ }
446
968
  const updated = {
447
969
  ...found.block,
448
970
  status: normalizeBlock(found.block).status,
449
971
  injected,
972
+ // 人工设置过即接管:此后既不被「长期未命中」自动降级,也不被「反复命中」自动升级(2026-09-15)
973
+ injectedAuto: false,
450
974
  updatedAt: Date.now(),
451
975
  };
452
976
  await found.table.put(id, updated);
@@ -455,19 +979,32 @@ export class MemoryEngine extends Service {
455
979
  return record;
456
980
  }
457
981
  /**
458
- * 修改一条记忆的内容/关键词(0.3.1 整理记忆用)。内容被模型改动后
459
- * 必须重新人工审核:status 重置为 suggested(自然停止注入——注入仅对
460
- * approved 生效);injected 保留原值(审核通过后注入开关原样恢复)。
982
+ * 修改一条记忆的内容/关键词(0.3.1 整理记忆用)。2026-09-15 起:改动**不再**
983
+ * 退回待审核——静默机制下记忆由系统与模型自行维护,更新即保持原有审核状态;
984
+ * `injected` 保留原值。若新内容命中危险规则则转为隔离(与写入路径同一判定)。
461
985
  */
462
986
  async update(id, patch, projectCwd) {
987
+ return this.writeScope(projectCwd, () => this.updateInner(id, patch, projectCwd));
988
+ }
989
+ /** {@link update} 的实现体;必须经 `writeScope` 进入。 */
990
+ async updateInner(id, patch, projectCwd) {
463
991
  const applyPatch = (block) => {
464
992
  const normalized = normalizeBlock(block);
993
+ const content = patch.content ?? block.content;
994
+ const keywords = patch.keywords === undefined
995
+ ? block.keywords
996
+ : patch.keywords.map(keyword => keyword.toLowerCase());
997
+ const verdict = detectSensitive(content, ...keywords);
465
998
  return {
466
999
  ...block,
467
- status: 'suggested',
1000
+ // 命中危险规则:退回 suggested(不注入)+ 隔离(不检索)双保险;否则保持原状态
1001
+ status: verdict.quarantined ? 'suggested' : normalized.status,
468
1002
  injected: normalized.injected,
1003
+ ...verdict.quarantined
1004
+ ? { quarantined: true, ...verdict.reason === undefined ? {} : { quarantineReason: verdict.reason } }
1005
+ : { quarantined: false, quarantineReason: undefined },
469
1006
  ...(patch.content === undefined ? {} : { content: patch.content }),
470
- ...(patch.keywords === undefined ? {} : { keywords: patch.keywords.map(keyword => keyword.toLowerCase()) }),
1007
+ ...(patch.keywords === undefined ? {} : { keywords }),
471
1008
  updatedAt: Date.now(),
472
1009
  };
473
1010
  };
@@ -476,7 +1013,7 @@ export class MemoryEngine extends Service {
476
1013
  const updated = applyPatch(global);
477
1014
  await this.requireTable('global').put(id, updated);
478
1015
  const record = toRecord(id, updated);
479
- this.ctx.emit('memory/changed', { operation: 'status', id, status: 'suggested' });
1016
+ this.ctx.emit('memory/changed', { operation: 'status', id, status: normalizeBlock(updated).status });
480
1017
  return record;
481
1018
  }
482
1019
  const project = await this.projectTableFor(projectCwd);
@@ -486,7 +1023,7 @@ export class MemoryEngine extends Service {
486
1023
  const updated = applyPatch(block);
487
1024
  await project.put(id, updated);
488
1025
  const record = toRecord(id, updated);
489
- this.ctx.emit('memory/changed', { operation: 'status', id, status: 'suggested' });
1026
+ this.ctx.emit('memory/changed', { operation: 'status', id, status: normalizeBlock(updated).status });
490
1027
  return record;
491
1028
  }
492
1029
  }
@@ -502,6 +1039,8 @@ export class MemoryEngine extends Service {
502
1039
  if (this.projectTables.has(key))
503
1040
  return;
504
1041
  await this.projectTableFor(key);
1042
+ // 会话内首次打开工作区表时做一次启动维护(迁移 / 降级 / 锚点校验,2026-09-15)
1043
+ await this.runMaintenanceOnce(projectCwd);
505
1044
  }
506
1045
  /**
507
1046
  * 注入专用(0.3.4 + 0.5.1):system prompt 的 `memory:recall` context
@@ -511,7 +1050,12 @@ export class MemoryEngine extends Service {
511
1050
  * (`ensureProjectOpen` 提供预热,工具/面板路径也会打开)。
512
1051
  */
513
1052
  recallRecords(projectCwd) {
514
- const injected = (table) => this.recordsOf(table).filter(record => record.status === 'approved' && record.injected);
1053
+ const injected = (table) =>
1054
+ // 模板永不注入(0.6.0):这一条是兜底——即使存储里出现被标了 injected 的 prompt
1055
+ // 记录(旧版本或人工改文件留下的),也不让它进上下文。
1056
+ this.recordsOf(table).filter(record => record.status === 'approved'
1057
+ && record.injected
1058
+ && (record.kind ?? 'fact') !== 'prompt');
515
1059
  const global = injected(this.requireTable('global'));
516
1060
  if (projectCwd === undefined || projectCwd === '')
517
1061
  return global;