@max-null/dsh-memory 0.6.1 → 0.7.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
@@ -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,8 @@ export class MemoryEngine extends Service {
130
170
  /** backend 只注册一次(registry 重名抛 duplicate;reload 清缓存后不得重复注册)。 */
131
171
  registeredProjectBackends = new Set();
132
172
  facility;
173
+ /** 会话启动维护只跑一次的标记(2026-09-15)。 */
174
+ maintenanceDone = false;
133
175
  constructor(ctx, config = {}) {
134
176
  super(ctx, 'memory');
135
177
  this.config = config;
@@ -226,7 +268,11 @@ export class MemoryEngine extends Service {
226
268
  this.projectFacilities.clear();
227
269
  await this.openGlobalFacility();
228
270
  }
229
- /** Create one record in `suggested` status — never self-promoting. */
271
+ /**
272
+ * Create one record. 低危内容直接 `approved`(静默生效,2026-09-15 静默记忆机制);
273
+ * 命中危险内容规则则隔离:`status` 留 `suggested`(天然不注入)**并且**置
274
+ * `quarantined`(不进检索),双保险,等待人工放行或删除。
275
+ */
230
276
  async remember(input, projectCwd) {
231
277
  const namespace = input.namespace ?? 'global';
232
278
  const table = namespace === 'project'
@@ -236,14 +282,23 @@ export class MemoryEngine extends Service {
236
282
  throw new Error('cannot write project memory without a workspace cwd');
237
283
  const id = randomUUID();
238
284
  const now = Date.now();
285
+ // 关键词一并参与判定:密钥塞进 keywords 同样会在检索结果里外泄
286
+ const verdict = detectSensitive(input.content, ...(input.keywords ?? []));
239
287
  const block = {
240
288
  namespace,
241
- status: 'suggested',
289
+ status: verdict.quarantined ? 'suggested' : 'approved',
242
290
  injected: false,
243
291
  content: input.content,
244
292
  keywords: (input.keywords ?? []).map(keyword => keyword.toLowerCase()),
245
293
  createdAt: now,
246
294
  updatedAt: now,
295
+ // ⑧ 来源(2026-09-15):缺省 'agent'——写入这条记忆的就是主模型
296
+ source: input.source ?? 'agent',
297
+ // 有效性锚点(可选):值变了这条记忆会被标 stale,不再被当成仍然正确
298
+ ...input.anchor === undefined ? {} : { anchor: input.anchor },
299
+ ...verdict.quarantined
300
+ ? { quarantined: true, ...verdict.reason === undefined ? {} : { quarantineReason: verdict.reason } }
301
+ : {},
247
302
  };
248
303
  await table.put(id, block);
249
304
  const record = toRecord(id, block);
@@ -252,12 +307,30 @@ export class MemoryEngine extends Service {
252
307
  }
253
308
  async list(filter, projectCwd) {
254
309
  const records = await this.allRecords(filter?.namespace, projectCwd);
255
- return records.filter(record => (filter?.status === undefined || record.status === filter.status)
310
+ return records.filter(record =>
311
+ // 隔离记录默认整体排除(不进列表、不进检索,因而也不进注入预览);
312
+ // 面板要用 filter.quarantined === true 显式取出来审(2026-09-15)。
313
+ (filter?.quarantined === undefined ? record.quarantined !== true : record.quarantined === filter.quarantined)
314
+ && (filter?.status === undefined || record.status === filter.status)
256
315
  && (filter?.injected === undefined || record.injected === filter.injected)
257
316
  && (filter?.kind === undefined || (record.kind ?? 'fact') === filter.kind));
258
317
  }
318
+ /**
319
+ * 关键词检索。每次成功返回的检索都记一条查询日志(⑥ 2026-09-15)——命中与否都记:
320
+ * 漏检不产生任何事件,只有日志能事后回答「哪些查询本该命中谁」(反例 4)。日志是旁路:
321
+ * 写失败只记 warn,绝不改变本次检索结果。
322
+ */
259
323
  async search(query, filter, projectCwd) {
260
- const records = await this.list(filter, projectCwd);
324
+ const hits = await this.rank(query, filter, projectCwd);
325
+ this.recordQuery(query, hits.length, filter);
326
+ return hits;
327
+ }
328
+ /** 检索打分本体(BM25 + 可选语义融合);查询日志由 {@link search} 记录。 */
329
+ async rank(query, filter, projectCwd) {
330
+ // 记忆检索默认只看 fact(2026-09-15):模板是文件库、有独立的 prompt_search 通道,
331
+ // 混进来的代价不只是占返回条数——模板也会被记命中,然后「因为经常被搜到」升成常驻。
332
+ // 显式传 kind 的调用方(prompt_search)照旧生效。
333
+ const records = await this.list({ kind: 'fact', ...filter }, projectCwd);
261
334
  // 0.5.2:content 与 keywords 分离打加权 BM25(人工关键词命中权重更高)
262
335
  const scores = bm25FieldScores(query, records.map(record => ({
263
336
  body: record.content,
@@ -267,8 +340,10 @@ export class MemoryEngine extends Service {
267
340
  .map((record, index) => ({ record, score: scores[index] ?? 0 }))
268
341
  .filter(hit => hit.score > 0)
269
342
  .sort((left, right) => right.score - left.score);
270
- // 0.5.3:命中即标记使用(冷热追踪)——写回按命中快照之后执行,不影响本次结果
271
- await this.markUsed(bm25Ranked.map(hit => hit.record.id), projectCwd);
343
+ // 0.5.3:命中即标记使用(冷热追踪)——写回按命中快照之后执行,不影响本次结果。
344
+ // 2026-09-15:只记前 `HIT_MARK_LIMIT` 名(BM25 命中面极宽,全记等于给全库计数);
345
+ // 记账在语义融合之前,按 BM25 名次取,融合结果只影响返回顺序、不影响计数。
346
+ await this.markUsed(bm25Ranked.slice(0, HIT_MARK_LIMIT).map(hit => hit.record.id), projectCwd);
272
347
  // 0.5.2:配置 embeddings 时与语义结果 RRF 融合;缺省/失败降级回纯 BM25
273
348
  if (this.config.embeddings === undefined)
274
349
  return bm25Ranked;
@@ -343,6 +418,56 @@ export class MemoryEngine extends Service {
343
418
  .slice(0, topK);
344
419
  }
345
420
  /** 按已知 id 定位记录所在表(global 优先,其次当前工作区表)。 */
421
+ /**
422
+ * 会话启动维护(2026-09-15):一次扫描做完三件事——旧数据迁移、长期未命中的自动降级、
423
+ * 锚点批量校验。只跑一次、幂等、**失败不阻断**(记忆可用性优先于维护完成度)。
424
+ *
425
+ * 调用点在 `ensureProjectOpen`:它是 async,且每会话首次打开工作区表时必经。
426
+ * `memory:recall` 的 provider 是**同步**回调,不能在那里 await。
427
+ *
428
+ * @param projectCwd - 会话工作区:迁移/降级/校验都连它一起处理。
429
+ */
430
+ async runMaintenanceOnce(projectCwd) {
431
+ if (this.maintenanceDone)
432
+ return;
433
+ this.maintenanceDone = true;
434
+ try {
435
+ const migration = await this.migrateLegacy(projectCwd);
436
+ if (migration.approved > 0 || migration.quarantined > 0 || migration.counted > 0) {
437
+ this.ctx.logger?.info?.(`dsh-memory: 旧数据迁移 放行 ${migration.approved} / 隔离 ${migration.quarantined}`
438
+ + ` / 回填计数 ${migration.counted}`);
439
+ }
440
+ const demoted = await this.demoteStale(projectCwd);
441
+ if (demoted > 0)
442
+ this.ctx.logger?.info?.(`dsh-memory: 长期未命中,撤下常驻 ${demoted} 条`);
443
+ const staled = await this.verifyAnchors(this.anchorProbes(), projectCwd);
444
+ if (staled > 0)
445
+ this.ctx.logger?.info?.(`dsh-memory: 锚点校验更新 ${staled} 条`);
446
+ }
447
+ catch (error) {
448
+ this.ctx.logger?.warn?.(`dsh-memory: 启动维护失败(不影响使用):${String(error)}`);
449
+ }
450
+ }
451
+ /**
452
+ * 锚点探测上下文。`toolNames` 拿不到时返回 `undefined`——**不能给空数组**:那会被判成
453
+ * 「工具全没了」,让所有 `tool-list` 锚点误失效。
454
+ */
455
+ anchorProbes() {
456
+ let toolNames;
457
+ try {
458
+ const tools = this.ctx.get('tools');
459
+ const listed = tools?.list?.();
460
+ if (Array.isArray(listed)) {
461
+ toolNames = listed
462
+ .map(tool => tool.name)
463
+ .filter((name) => typeof name === 'string');
464
+ }
465
+ }
466
+ catch {
467
+ toolNames = undefined;
468
+ }
469
+ return { ...(toolNames === undefined ? {} : { toolNames }), selfVersion: SELF_VERSION };
470
+ }
346
471
  tableOf(id, projectCwd) {
347
472
  const global = this.requireTable('global');
348
473
  if (global.get(id) !== undefined)
@@ -352,7 +477,7 @@ export class MemoryEngine extends Service {
352
477
  const project = this.projectTables.get(join(projectCwd));
353
478
  return project !== undefined && project.get(id) !== undefined ? project : undefined;
354
479
  }
355
- /** 命中标记:把 lastUsedAt 写回(命中记录不存在时跳过;不 emit 事件——统计性字段)。 */
480
+ /** 命中标记:写回 lastUsedAt 与命中次数,达标即自动升常驻(2026-09-15)。 */
356
481
  async markUsed(ids, projectCwd) {
357
482
  if (ids.length === 0)
358
483
  return;
@@ -364,15 +489,293 @@ export class MemoryEngine extends Service {
364
489
  for (const id of ids) {
365
490
  const globalBlock = global.get(id);
366
491
  if (globalBlock !== undefined) {
367
- await global.put(id, { ...globalBlock, lastUsedAt: now });
492
+ await this.recordUse(global, id, globalBlock, now);
368
493
  continue;
369
494
  }
370
495
  if (project !== undefined) {
371
496
  const block = project.get(id);
372
497
  if (block !== undefined)
373
- await project.put(id, { ...block, lastUsedAt: now });
498
+ await this.recordUse(project, id, block, now);
499
+ }
500
+ }
501
+ }
502
+ /**
503
+ * 命中记账 + 自动升级(2026-09-15 静默记忆机制):命中次数达阈值即自动打开常驻
504
+ * 注入——「被反复检索命中」是它值得每轮付费的唯一客观证据。统计性字段不 emit,
505
+ * 只有真的改变了注入状态才发 `memory/changed`。
506
+ */
507
+ async recordUse(table, id, block, now) {
508
+ const hitCount = (block.hitCount ?? 0) + 1;
509
+ const normalized = normalizeBlock(block);
510
+ // 人工设置过的(injectedAuto === false)双向豁免:既不被降级,也不被升级。
511
+ // 少了这一条,人手动关掉的记忆会在下次命中时被系统重新打开——用户唯一能
512
+ // 表达「不要这条常驻」的动作就失效了,而这与降级侧「人工决定优先」不对称。
513
+ const promote = normalized.status === 'approved'
514
+ && block.quarantined !== true
515
+ && block.injectedAuto !== false
516
+ // 模板永不注入(0.6.0):prompt 记录连自动升级的资格都没有。少了这一条,
517
+ // 模板会因「经常被搜到」而升成常驻,把注入预算花在从不该进上下文的东西上。
518
+ && (block.kind ?? 'fact') !== 'prompt'
519
+ && !normalized.injected
520
+ && hitCount >= AUTO_INJECT_HITS;
521
+ const updated = {
522
+ ...block,
523
+ hitCount,
524
+ lastUsedAt: now,
525
+ ...promote ? { injected: true, injectedAuto: true } : {},
526
+ };
527
+ await table.put(id, updated);
528
+ if (promote)
529
+ this.ctx.emit('memory/changed', { operation: 'injected', id, injected: true });
530
+ }
531
+ // ── 查询日志与影响力统计(⑥ 2026-09-15)────────────────────────────────
532
+ /** 查询日志(惰性创建;位置见 {@link queryLogPath})。 */
533
+ queryLogInstance;
534
+ queryLog() {
535
+ this.queryLogInstance ??= new QueryLog({ path: this.queryLogPath() });
536
+ return this.queryLogInstance;
537
+ }
538
+ /** 查询日志文件路径:与 `memory.json` 同目录(不落子目录)。 */
539
+ queryLogPath() {
540
+ return this.config.queryLogPath ?? join(this.config.globalRoot ?? globalRoot(), 'query-log.json');
541
+ }
542
+ /**
543
+ * 记录一次检索:查询文本(截断到 120 字符)+ 命中条数 + 时间。**失败绝不影响检索**——
544
+ * 日志是旁路证据,不是检索的一部分。模板库检索不进日志:`prompt_search` 复用同一条
545
+ * `search` 通道,但模板是文件库、不属于「记忆漏检」的观察面。
546
+ *
547
+ * 查询文本是新的明文落盘点,因此先过危险内容硬拦(与写入路径同一套规则):命中就只记
548
+ * 规则名、不留原文——凭据不该因为「只是被搜了一下」而进另一个明文文件。
549
+ */
550
+ recordQuery(query, hits, filter) {
551
+ if (filter?.kind === 'prompt')
552
+ return;
553
+ try {
554
+ const verdict = detectSensitive(query);
555
+ this.queryLog().appendQuery(verdict.quarantined
556
+ ? { query: `[已脱敏:命中 ${verdict.reason ?? 'dangerous-content'}]`, hits }
557
+ : { query, hits });
558
+ }
559
+ catch (error) {
560
+ this.ctx.logger?.warn?.(`dsh-memory: 查询日志写入跳过:${String(error)}`);
561
+ }
562
+ }
563
+ /**
564
+ * 影响力统计(⑥ / 反例 5):按来源聚合「写入量 / 被检索命中数 / 常驻注入数」。
565
+ * 只算 fact 记录(模板索引不是记忆),隔离记录不计(与 list 的默认口径一致:
566
+ * 它们不在流通里)。「被人工删除数」暂缺的原因见 `summarizeSources` 的注释。
567
+ */
568
+ async sourceStats(projectCwd) {
569
+ const records = await this.list({ kind: 'fact' }, projectCwd);
570
+ return summarizeSources(records.map(record => ({
571
+ ...record.source === undefined ? {} : { source: record.source },
572
+ hitCount: record.hitCount,
573
+ injected: record.injected,
574
+ })));
575
+ }
576
+ /**
577
+ * 低频反查(⑥,**只产出报告,不自动改写**):拿「命中 0 的查询」与既有记忆反查,
578
+ * 给出「本应命中却没命中」的候选,供人工或模型补 keywords。
579
+ *
580
+ * 不做自动补写:判断「这条记忆是否真该被那个查询找到」需要 LLM 语义判断,插件只提供
581
+ * 证据(候选为空即知识缺口——「这类知识你还没有」本身也是有用信息)。调用时机由人
582
+ * 或模型定,不在检索路径上、不产生每轮成本。
583
+ *
584
+ * @param projectCwd - 会话工作区;候选库连工作区记忆一起取。
585
+ * @param limit - 最多反查几个空命中查询(按最近出现排序)。
586
+ */
587
+ async suggestKeywordGaps(projectCwd, limit = 10) {
588
+ const summary = summarizeQueryLog(this.queryLog().readLog(), limit);
589
+ if (summary.gaps.length === 0)
590
+ return { logSize: summary.total, misses: summary.misses, gaps: [] };
591
+ const records = await this.list({ kind: 'fact' }, projectCwd);
592
+ const byId = new Map(records.map(record => [String(record.id), record]));
593
+ const docs = records.map(record => ({
594
+ id: String(record.id),
595
+ body: record.content,
596
+ tags: record.keywords.join(' '),
597
+ }));
598
+ return {
599
+ logSize: summary.total,
600
+ misses: summary.misses,
601
+ gaps: summary.gaps.map(gap => ({
602
+ query: gap.query,
603
+ count: gap.count,
604
+ lastAt: gap.lastAt,
605
+ candidates: rankGapCandidates(gap.query, docs).flatMap(candidate => {
606
+ const record = byId.get(candidate.id);
607
+ return record === undefined ? [] : [{
608
+ id: candidate.id,
609
+ excerpt: record.content.slice(0, GAP_EXCERPT_CHARS),
610
+ keywords: record.keywords,
611
+ exact: candidate.exact,
612
+ relaxed: candidate.relaxed,
613
+ }];
614
+ }),
615
+ })),
616
+ };
617
+ }
618
+ /**
619
+ * 长期未命中的自动降级(2026-09-15 淘汰机制):**只撤自动开的常驻注入**——人工开
620
+ * 的开关不会被时间悄悄关掉(那是人的决定,不是信号的结论)。判定用 lastUsedAt,
621
+ * 从未命中过则回退 updatedAt。会话启动时调一次即可(见 index.ts 的预热路径)。
622
+ *
623
+ * @param projectCwd - 会话工作区;给了就连工作区表一起扫。
624
+ * @param now - 当前时间(测试注入用)。
625
+ * @returns 被撤下的条数(诊断与日志用)。
626
+ */
627
+ async demoteStale(projectCwd, now = Date.now()) {
628
+ const threshold = now - AUTO_DEMOTE_DAYS * 24 * 60 * 60 * 1000;
629
+ const tables = [this.requireTable('global')];
630
+ if (projectCwd !== undefined && projectCwd !== '') {
631
+ const project = await this.projectTableFor(projectCwd);
632
+ if (project !== undefined)
633
+ tables.push(project);
634
+ }
635
+ let demoted = 0;
636
+ for (const table of tables) {
637
+ // 先快照键值对:put 会改到底层 Map,边遍历边写不安全
638
+ for (const [id, block] of [...table.entries()]) {
639
+ if (block.injectedAuto !== true)
640
+ continue;
641
+ if (block.quarantined === true)
642
+ continue;
643
+ if (normalizeBlock(block).status !== 'approved')
644
+ continue;
645
+ const last = block.lastUsedAt ?? block.updatedAt;
646
+ if (last > threshold)
647
+ continue;
648
+ await table.put(id, { ...block, injected: false, injectedAuto: false });
649
+ this.ctx.emit('memory/changed', { operation: 'injected', id: MemoryId(id), injected: false });
650
+ demoted += 1;
374
651
  }
375
652
  }
653
+ return demoted;
654
+ }
655
+ /**
656
+ * 锚点批量校验(2026-09-15):会话启动时调一次,把失效的条目标成 `stale`。
657
+ *
658
+ * 失效同时**撤掉常驻注入**——内容可能已经错了,继续每轮注入就是在误导(反例 2 的
659
+ * 教训);但**不删除、也不排除检索**,它在结果里带标注,等模型核对后回写。
660
+ * 探测不到(`undefined`)按「未校验」处理,不动任何状态。
661
+ *
662
+ * @param probes - 探测上下文(工具清单 / 自身版本 / env 读取器)。
663
+ * @param projectCwd - 会话工作区;给了就连工作区表一起校验。
664
+ * @returns 本次状态发生变化的条数。
665
+ */
666
+ async verifyAnchors(probes, projectCwd) {
667
+ const tables = [this.requireTable('global')];
668
+ if (projectCwd !== undefined && projectCwd !== '') {
669
+ const project = await this.projectTableFor(projectCwd);
670
+ if (project !== undefined)
671
+ tables.push(project);
672
+ }
673
+ let changed = 0;
674
+ for (const table of tables) {
675
+ for (const [id, block] of [...table.entries()]) {
676
+ if (block.anchor === undefined)
677
+ continue;
678
+ const holds = anchorHolds(block.anchor, probes);
679
+ if (holds === undefined)
680
+ continue; // 未校验:探测不到就不动
681
+ if ((block.stale === true) === !holds)
682
+ continue; // 状态没变
683
+ const label = `${block.anchor.kind}${block.anchor.name === undefined ? '' : `:${block.anchor.name}`}`;
684
+ const updated = holds
685
+ ? { ...block, stale: false, staleReason: undefined }
686
+ : {
687
+ ...block,
688
+ stale: true,
689
+ staleReason: `${label} 声明为 ${block.anchor.value},当前不符`,
690
+ injected: false,
691
+ injectedAuto: false,
692
+ };
693
+ await table.put(id, updated);
694
+ this.ctx.emit('memory/changed', { operation: 'status', id: MemoryId(id), status: normalizeBlock(updated).status });
695
+ changed += 1;
696
+ }
697
+ }
698
+ return changed;
699
+ }
700
+ /**
701
+ * 旧数据迁移(2026-09-15 静默记忆机制):一次性、幂等,会话启动维护时调用。
702
+ *
703
+ * 旧语义里 `suggested` 是「等人工审核」,新语义里它只剩「隔离」与「人工回退」两种含义——
704
+ * 不迁移的话那些记录会永久卡住:既不注入,也没人去放行。迁移逐条过危险内容检测:
705
+ * 安全则升 `approved`(写入即生效),命中则标 `quarantined` 进隔离(**必须先检测再放行**:
706
+ * 历史条目若含密钥,直接放行等于让它开始进注入)。
707
+ *
708
+ * 另外把「有 `lastUsedAt` 但无 `hitCount`」的老记录回填 `hitCount = 1`——它们可能早已被
709
+ * 反复用过,不该从零开始攒两次命中。旧 `injected: true` 且无 `injectedAuto` 的一律不动:
710
+ * 那是人工决定,不该被自动降级。
711
+ *
712
+ * @param projectCwd - 会话工作区;给了就连工作区表一起迁移。
713
+ * @returns 迁移统计(放行 / 隔离 / 回填计数)。
714
+ */
715
+ async migrateLegacy(projectCwd) {
716
+ const tables = [this.requireTable('global')];
717
+ if (projectCwd !== undefined && projectCwd !== '') {
718
+ const project = await this.projectTableFor(projectCwd);
719
+ if (project !== undefined)
720
+ tables.push(project);
721
+ }
722
+ const stats = { approved: 0, quarantined: 0, counted: 0 };
723
+ // 幂等:先判断是否真的有事可做——没有就直接返回,连备份都不做(否则每天首次启动
724
+ // 都会留下一份无意义的 .bak)
725
+ const needsMigration = tables.some(table => [...table.entries()].some(([, block]) => {
726
+ const normalized = normalizeBlock(block);
727
+ if (normalized.status === 'suggested' && block.quarantined !== true)
728
+ return true;
729
+ return block.hitCount === undefined && block.lastUsedAt !== undefined;
730
+ }));
731
+ if (!needsMigration)
732
+ return stats;
733
+ // 迁移前备份全局存储(工作区记忆随 git 分享,有 git 兜底)。备份失败只记日志、不阻断:
734
+ // 迁移本身就是幂等的,最坏情况是重复跑一次。
735
+ try {
736
+ const file = join(this.config.globalRoot ?? globalRoot(), 'memory.json');
737
+ if (existsSync(file)) {
738
+ const backup = `${file}.bak-${new Date().toISOString().slice(0, 10)}-migration`;
739
+ if (!existsSync(backup))
740
+ writeFileSync(backup, readFileSync(file));
741
+ }
742
+ }
743
+ catch (error) {
744
+ this.ctx.logger?.warn?.(`dsh-memory: migration backup skipped: ${String(error)}`);
745
+ }
746
+ for (const table of tables) {
747
+ for (const [id, block] of [...table.entries()]) {
748
+ const normalized = normalizeBlock(block);
749
+ let updated;
750
+ // ① 旧的 suggested:安全则放行、危险则隔离
751
+ if (normalized.status === 'suggested' && block.quarantined !== true) {
752
+ const verdict = detectSensitive(block.content, ...block.keywords);
753
+ if (verdict.quarantined) {
754
+ updated = {
755
+ ...block,
756
+ status: 'suggested',
757
+ injected: false,
758
+ quarantined: true,
759
+ ...verdict.reason === undefined ? {} : { quarantineReason: verdict.reason },
760
+ };
761
+ stats.quarantined += 1;
762
+ }
763
+ else {
764
+ updated = { ...block, status: 'approved', injected: normalized.injected };
765
+ stats.approved += 1;
766
+ }
767
+ }
768
+ // ② 命中计数回填(在 ① 的结果之上叠加,避免两次 put 互相覆盖)
769
+ const base = updated ?? block;
770
+ if (base.hitCount === undefined && base.lastUsedAt !== undefined) {
771
+ updated = { ...base, hitCount: 1 };
772
+ stats.counted += 1;
773
+ }
774
+ if (updated !== undefined)
775
+ await table.put(id, updated);
776
+ }
777
+ }
778
+ return stats;
376
779
  }
377
780
  async forget(id, projectCwd) {
378
781
  if (await this.requireTable('global').delete(id)) {
@@ -394,6 +797,8 @@ export class MemoryEngine extends Service {
394
797
  status,
395
798
  // 旧数据可能缺 injected,写回时补全(读时迁移值)
396
799
  injected: normalizeBlock(global).injected,
800
+ // 人工放行 = 审核通过并解除隔离(2026-09-15)
801
+ ...status === 'approved' ? { quarantined: false, quarantineReason: undefined } : {},
397
802
  updatedAt: Date.now(),
398
803
  };
399
804
  await this.requireTable('global').put(id, updated);
@@ -409,6 +814,7 @@ export class MemoryEngine extends Service {
409
814
  ...block,
410
815
  status,
411
816
  injected: normalizeBlock(block).injected,
817
+ ...status === 'approved' ? { quarantined: false, quarantineReason: undefined } : {},
412
818
  updatedAt: Date.now(),
413
819
  };
414
820
  await project.put(id, updated);
@@ -443,10 +849,16 @@ export class MemoryEngine extends Service {
443
849
  if ((found.block.kind ?? 'fact') === 'prompt') {
444
850
  throw new Error(`cannot set injected of prompt record '${id}': prompt templates are never injected`);
445
851
  }
852
+ // 隔离记录不可注入(2026-09-15):先放行(setStatus approved)再开开关
853
+ if (injected && found.block.quarantined === true) {
854
+ throw new Error(`cannot inject quarantined memory '${id}': approve it first`);
855
+ }
446
856
  const updated = {
447
857
  ...found.block,
448
858
  status: normalizeBlock(found.block).status,
449
859
  injected,
860
+ // 人工设置过即接管:此后既不被「长期未命中」自动降级,也不被「反复命中」自动升级(2026-09-15)
861
+ injectedAuto: false,
450
862
  updatedAt: Date.now(),
451
863
  };
452
864
  await found.table.put(id, updated);
@@ -455,19 +867,28 @@ export class MemoryEngine extends Service {
455
867
  return record;
456
868
  }
457
869
  /**
458
- * 修改一条记忆的内容/关键词(0.3.1 整理记忆用)。内容被模型改动后
459
- * 必须重新人工审核:status 重置为 suggested(自然停止注入——注入仅对
460
- * approved 生效);injected 保留原值(审核通过后注入开关原样恢复)。
870
+ * 修改一条记忆的内容/关键词(0.3.1 整理记忆用)。2026-09-15 起:改动**不再**
871
+ * 退回待审核——静默机制下记忆由系统与模型自行维护,更新即保持原有审核状态;
872
+ * `injected` 保留原值。若新内容命中危险规则则转为隔离(与写入路径同一判定)。
461
873
  */
462
874
  async update(id, patch, projectCwd) {
463
875
  const applyPatch = (block) => {
464
876
  const normalized = normalizeBlock(block);
877
+ const content = patch.content ?? block.content;
878
+ const keywords = patch.keywords === undefined
879
+ ? block.keywords
880
+ : patch.keywords.map(keyword => keyword.toLowerCase());
881
+ const verdict = detectSensitive(content, ...keywords);
465
882
  return {
466
883
  ...block,
467
- status: 'suggested',
884
+ // 命中危险规则:退回 suggested(不注入)+ 隔离(不检索)双保险;否则保持原状态
885
+ status: verdict.quarantined ? 'suggested' : normalized.status,
468
886
  injected: normalized.injected,
887
+ ...verdict.quarantined
888
+ ? { quarantined: true, ...verdict.reason === undefined ? {} : { quarantineReason: verdict.reason } }
889
+ : { quarantined: false, quarantineReason: undefined },
469
890
  ...(patch.content === undefined ? {} : { content: patch.content }),
470
- ...(patch.keywords === undefined ? {} : { keywords: patch.keywords.map(keyword => keyword.toLowerCase()) }),
891
+ ...(patch.keywords === undefined ? {} : { keywords }),
471
892
  updatedAt: Date.now(),
472
893
  };
473
894
  };
@@ -476,7 +897,7 @@ export class MemoryEngine extends Service {
476
897
  const updated = applyPatch(global);
477
898
  await this.requireTable('global').put(id, updated);
478
899
  const record = toRecord(id, updated);
479
- this.ctx.emit('memory/changed', { operation: 'status', id, status: 'suggested' });
900
+ this.ctx.emit('memory/changed', { operation: 'status', id, status: normalizeBlock(updated).status });
480
901
  return record;
481
902
  }
482
903
  const project = await this.projectTableFor(projectCwd);
@@ -486,7 +907,7 @@ export class MemoryEngine extends Service {
486
907
  const updated = applyPatch(block);
487
908
  await project.put(id, updated);
488
909
  const record = toRecord(id, updated);
489
- this.ctx.emit('memory/changed', { operation: 'status', id, status: 'suggested' });
910
+ this.ctx.emit('memory/changed', { operation: 'status', id, status: normalizeBlock(updated).status });
490
911
  return record;
491
912
  }
492
913
  }
@@ -502,6 +923,8 @@ export class MemoryEngine extends Service {
502
923
  if (this.projectTables.has(key))
503
924
  return;
504
925
  await this.projectTableFor(key);
926
+ // 会话内首次打开工作区表时做一次启动维护(迁移 / 降级 / 锚点校验,2026-09-15)
927
+ await this.runMaintenanceOnce(projectCwd);
505
928
  }
506
929
  /**
507
930
  * 注入专用(0.3.4 + 0.5.1):system prompt 的 `memory:recall` context
@@ -511,7 +934,12 @@ export class MemoryEngine extends Service {
511
934
  * (`ensureProjectOpen` 提供预热,工具/面板路径也会打开)。
512
935
  */
513
936
  recallRecords(projectCwd) {
514
- const injected = (table) => this.recordsOf(table).filter(record => record.status === 'approved' && record.injected);
937
+ const injected = (table) =>
938
+ // 模板永不注入(0.6.0):这一条是兜底——即使存储里出现被标了 injected 的 prompt
939
+ // 记录(旧版本或人工改文件留下的),也不让它进上下文。
940
+ this.recordsOf(table).filter(record => record.status === 'approved'
941
+ && record.injected
942
+ && (record.kind ?? 'fact') !== 'prompt');
515
943
  const global = injected(this.requireTable('global'));
516
944
  if (projectCwd === undefined || projectCwd === '')
517
945
  return global;