@max-null/dsh-memory 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -150,6 +150,7 @@ node scripts/verify-loader.mjs # 用 Loader 端到端验证插件可加载
150
150
  node scripts/scan-orphans.mjs [根目录] # 清点「当前代码打不开」的记忆文件(只读)
151
151
  node scripts/unzstd-frames.mjs <源> <目标> # 解多帧 zstd(读 DSH 会话日志用)
152
152
  node scripts/ancestor-probe.mjs plan [--cwd <cwd>] # 复算祖先链(只读);seed/check/clean 验 ④-A
153
+ node scripts/analyze-cooccurrence.mjs # 关键词共现分析(记忆之间有没有关系网,只读)
153
154
  ```
154
155
 
155
156
  ## 依赖(peerDependencies,由宿主提供)
package/dist/anchors.d.ts CHANGED
@@ -2,12 +2,13 @@
2
2
  * 有效性锚点(2026-09-15 静默记忆机制):把一条记忆绑定到某个**可探测的环境值**上,
3
3
  * 值变了就自动失效——解决「版本特性类记忆随环境升级静默变错」的问题。
4
4
  *
5
- * 第一版只做**进程内可探测**的类型:校验跑在会话启动的路径上,探测必须零 IO。
6
- * 需要跑子进程才能拿到的(如 PowerShell 版本)留待后续——那要先解决「插件直接
7
- * spawn 会绕过 DSH 的 shell 沙箱」的合规问题,不能顺手开这个口子。
5
+ * 探测跑在会话启动的校验路径上,因此**禁止起子进程**(那要先解决「插件直接 spawn 会绕过
6
+ * DSH 的 shell 沙箱」的合规问题,不能顺手开这个口子)。**文件探测不在此列**:`path-exists`
7
+ * 只有一次 `existsSync`,与读环境变量同量级。此前这里写的是「探测必须零 IO」,那句话把
8
+ * 「禁止 spawn」误写成了「禁止一切系统调用」——0.12.0 加 `path-exists` 时改正。
8
9
  */
9
- /** 受控词汇表:写入时只能从这三种里选,不能自创(自创的锚点无法机械校验)。 */
10
- export type AnchorKind = 'env' | 'tool-list' | 'self-version';
10
+ /** 受控词汇表:写入时只能从这四种里选,不能自创(自创的锚点无法机械校验)。 */
11
+ export type AnchorKind = 'env' | 'tool-list' | 'self-version' | 'path-exists';
11
12
  /** 一条记忆声明的锚点。 */
12
13
  export interface MemoryAnchor {
13
14
  kind: AnchorKind;
@@ -27,6 +28,13 @@ export interface AnchorProbes {
27
28
  selfVersion: string;
28
29
  /** 环境变量读取器;缺省 `process.env`。 */
29
30
  env?: (name: string) => string | undefined;
31
+ /**
32
+ * 会话工作区 cwd:`path-exists` 用它把**相对路径**解析成绝对路径(0.12.0)。
33
+ *
34
+ * 缺省时相对路径判为「**未校验**」而不是「不存在」——定位不到与文件真的不在是两件事,
35
+ * 混起来会让所有相对路径锚点在拿不到 cwd 的路径上集体误失效。
36
+ */
37
+ workspaceCwd?: string;
30
38
  }
31
39
  /**
32
40
  * 探测某个锚点当前的取值。
package/dist/anchors.js CHANGED
@@ -2,10 +2,13 @@
2
2
  * 有效性锚点(2026-09-15 静默记忆机制):把一条记忆绑定到某个**可探测的环境值**上,
3
3
  * 值变了就自动失效——解决「版本特性类记忆随环境升级静默变错」的问题。
4
4
  *
5
- * 第一版只做**进程内可探测**的类型:校验跑在会话启动的路径上,探测必须零 IO。
6
- * 需要跑子进程才能拿到的(如 PowerShell 版本)留待后续——那要先解决「插件直接
7
- * spawn 会绕过 DSH 的 shell 沙箱」的合规问题,不能顺手开这个口子。
5
+ * 探测跑在会话启动的校验路径上,因此**禁止起子进程**(那要先解决「插件直接 spawn 会绕过
6
+ * DSH 的 shell 沙箱」的合规问题,不能顺手开这个口子)。**文件探测不在此列**:`path-exists`
7
+ * 只有一次 `existsSync`,与读环境变量同量级。此前这里写的是「探测必须零 IO」,那句话把
8
+ * 「禁止 spawn」误写成了「禁止一切系统调用」——0.12.0 加 `path-exists` 时改正。
8
9
  */
10
+ import { existsSync } from 'node:fs';
11
+ import { isAbsolute, resolve } from 'node:path';
9
12
  /**
10
13
  * 探测某个锚点当前的取值。
11
14
  *
@@ -27,6 +30,16 @@ export function probeAnchor(anchor, probes) {
27
30
  return [...probes.toolNames].sort().join(',');
28
31
  case 'self-version':
29
32
  return probes.selfVersion;
33
+ case 'path-exists': {
34
+ if (anchor.name === undefined || anchor.name === '')
35
+ return undefined;
36
+ const relative = !isAbsolute(anchor.name);
37
+ // 相对路径但没有工作区:定位不到 → 未校验(不能判失效)
38
+ if (relative && (probes.workspaceCwd === undefined || probes.workspaceCwd === ''))
39
+ return undefined;
40
+ const target = relative ? resolve(probes.workspaceCwd, anchor.name) : anchor.name;
41
+ return existsSync(target) ? 'present' : 'absent';
42
+ }
30
43
  default:
31
44
  return undefined;
32
45
  }
package/dist/engine.d.ts CHANGED
@@ -114,6 +114,14 @@ export interface MemoryFilter {
114
114
  quarantined?: boolean;
115
115
  /** 记录类别过滤(0.6.0);缺省不过滤(工具层显式传 kind='fact' 保持旧行为)。 */
116
116
  kind?: MemoryKind;
117
+ /**
118
+ * 更新时间下界 / 上界(0.12.0;epoch ms,含端点)。
119
+ *
120
+ * **只有 `list` 读这两个字段**——给 `search` 加时间过滤要动候选集(过滤与打分、`markUsed`
121
+ * 记账的交互:被过滤掉的命中还算不算「用过」),那是另一件要单独论证的事,本版不做。
122
+ */
123
+ after?: number;
124
+ before?: number;
117
125
  }
118
126
  /** {@link MemoryEngine.update} 的补丁:省略的字段保持原值(`injected` 省略时连 `injectedAuto` 一起保持)。 */
119
127
  export interface MemoryPatch {
@@ -184,6 +192,36 @@ export type MemoryChange = {
184
192
  from: string;
185
193
  to: string;
186
194
  };
195
+ /** 体检报告的单条记录:摘要长度与注入行同量级,便于直接念给人听或写进诊断行。 */
196
+ export interface SweepItem {
197
+ id: string;
198
+ summary: string;
199
+ }
200
+ /**
201
+ * 只读体检的产出({@link MemoryEngine.sweep})。四类各有明确的消费者,不做「算了没人读」的采样
202
+ * ——候选与新失效会进注入诊断行(模型每轮可见),久未命中与全量留给按需调用。
203
+ */
204
+ export interface SweepReport {
205
+ /** 当前失效的记录,带原因(锚点不符 / 被取代 / 被撤回)。 */
206
+ stale: Array<SweepItem & {
207
+ reason: string | undefined;
208
+ }>;
209
+ /** 久未被检索命中的**非常驻**记录——常驻的「没被查过」是正常的,它本来就每轮在场。 */
210
+ longUnused: Array<SweepItem & {
211
+ lastUsed: number;
212
+ }>;
213
+ /** 候选:被反复检索命中但没有被钉成常驻(见 `candidates`)。 */
214
+ candidates: Array<SweepItem & {
215
+ hitCount: number;
216
+ }>;
217
+ /** 常驻与注入预算的占用:总数 / 实际进注入 / 预算(无限制时不出现)/ 被挤掉几条。 */
218
+ resident: {
219
+ total: number;
220
+ injected: number;
221
+ budget: number | undefined;
222
+ omitted: number;
223
+ };
224
+ }
187
225
  declare module '@deepseek-ai/cordis' {
188
226
  interface Context {
189
227
  memory: MemoryEngine;
@@ -260,6 +298,13 @@ export declare class MemoryEngine extends Service {
260
298
  private writeChain;
261
299
  /** 会话启动维护只跑一次的标记(2026-09-15)。 */
262
300
  private maintenanceDone;
301
+ /** 最近一次体检的结果(进程内,不落盘):供注入侧报「本次校验发现的新失效」。 */
302
+ private lastSweep;
303
+ /**
304
+ * 最近一次体检结果(只读)。未跑过时为 `undefined`——注入侧据此决定要不要报那一行,
305
+ * 它只在真有新失效时出现,稀有所以不构成噪音。
306
+ */
307
+ get lastSweepReport(): SweepReport | undefined;
263
308
  constructor(ctx: import('@deepseek-ai/cordis').Context, config?: MemoryConfig);
264
309
  protected [Service.init](): Promise<void>;
265
310
  /** 打开全局存储域(init 与 reload 共用;backend 复用已注册实例)。 */
@@ -363,6 +408,30 @@ export declare class MemoryEngine extends Service {
363
408
  * 未打开的层级本轮不计入,影响可忽略(链上祖先在检索路径打开后即缓存)。
364
409
  */
365
410
  indexCandidates(projectCwd?: string): MemoryRecord[];
411
+ /**
412
+ * 只读体检(0.12.0):把「该退场的」和「钉了却没进来的」摆成一张清单,**不修改任何状态**
413
+ * ——连时间戳都不写。
414
+ *
415
+ * 为什么坚持只读:一只读的工具,最坏情况是报告不准,判断者按错的清单做了坏决定也能追溯;
416
+ * 一个会改状态的工具,最坏情况是错了都看不见。这决定了它不需要「每日一次」的守卫,也不
417
+ * 需要定期读者——有人问它就活着,没人问它不产生任何成本(不占预算、不落盘、不写状态)。
418
+ */
419
+ sweep(projectCwd?: string, options?: {
420
+ days?: number;
421
+ now?: number;
422
+ }): Promise<SweepReport>;
423
+ /**
424
+ * 候选(0.12.0):被反复检索命中、但没有被钉成常驻的记录。**只提示,不改状态。**
425
+ *
426
+ * 筛选条件与原自动升级判据逐条对应(approved、未隔离、未被人工否决、非模板、未常驻)
427
+ * ——判据没变,变的只是它的作用:从「自动钉住」改为「列出来等一个判断」。`hitCount`
428
+ * 只增不减,所以候选会一直留着,直到被钉住、被删或记录本身消失——这不是缺陷,是提示
429
+ * 该有的持续性:它只在被消费之后才消失。
430
+ *
431
+ * 与 {@link indexCandidates} 的分工:那个取**全部**记录(供索引行统计规模与主题),
432
+ * 这个只取满足候选条件的(供体检报告与诊断行)。
433
+ */
434
+ candidates(projectCwd?: string): MemoryRecord[];
366
435
  /**
367
436
  * 按 id 定位记录所在表:global 优先,其次工作区链(自身 → 祖先,由近及远)。
368
437
  * **异步版**——写路径必须用它:`writeScope` 可能在本次进入前刚 `reload()`,
@@ -425,6 +494,9 @@ export declare class MemoryEngine extends Service {
425
494
  /**
426
495
  * 锚点探测上下文。`toolNames` 拿不到时返回 `undefined`——**不能给空数组**:那会被判成
427
496
  * 「工具全没了」,让所有 `tool-list` 锚点误失效。
497
+ *
498
+ * `workspaceCwd`(0.12.0)供 `path-exists` 把相对路径解析成绝对路径;缺省时相对路径
499
+ * 判为「未校验」而不是「不存在」。
428
500
  */
429
501
  private anchorProbes;
430
502
  /** 同步版定位:只查**已打开**的表(global + 链上已缓存的工作区表)。 */
@@ -434,9 +506,16 @@ export declare class MemoryEngine extends Service {
434
506
  /** {@link markUsed} 的实现体;必须经 `writeScope` 进入(`recordUse` 在此之内)。 */
435
507
  private markUsedInner;
436
508
  /**
437
- * 命中记账 + 自动升级(2026-09-15 静默记忆机制):命中次数达阈值即自动打开常驻
438
- * 注入——「被反复检索命中」是它值得每轮付费的唯一客观证据。统计性字段不 emit,
439
- * 只有真的改变了注入状态才发 `memory/changed`。
509
+ * 命中记账(2026-09-15 静默记忆机制;0.12.0 取消自动升级)。
510
+ *
511
+ * 只记 `hitCount` 与 `lastUsedAt`,**不再据此改变注入状态**。原判据是「被反复检索
512
+ * 命中」= 值得每轮付费的唯一客观证据,但这条证据测错了东西:命中次数度量的是
513
+ * **它被想起来了**,而常驻的语义是**它不能被忘**——能被检索命中的恰恰是检索已经
514
+ * 够用的那些,真正该常驻的(想不起来去查的)命中次数永远是 0。达到
515
+ * {@link CANDIDATE_HITS} 的记录改由 {@link candidates} 列出,进体检报告与诊断行
516
+ * 提示,改不改由判断者决定(判据详见设计文档 §二事实四)。
517
+ *
518
+ * 统计性字段不 emit:本函数现在不改变任何被订阅的状态。
440
519
  */
441
520
  private recordUse;
442
521
  /** 查询日志(惰性创建;位置见 {@link queryLogPath})。 */
@@ -563,6 +642,33 @@ export declare class MemoryEngine extends Service {
563
642
  setInjected(id: MemoryId, injected: boolean, projectCwd?: string): Promise<MemoryRecord>;
564
643
  /** {@link setInjected} 的实现体;必须经 `writeScope` 进入。 */
565
644
  private setInjectedInner;
645
+ /**
646
+ * 语义作废(0.12.0):把 `targetId` 标成**被 `id` 取代**。
647
+ *
648
+ * 与「改内容」的区别:`update` 会让旧内容直接消失,「取代」把痕迹留下来——检索时看到
649
+ * 「这条已作废,原因是 X」对被主动查的人是信息而不是干扰。`id` 只用来写原因,本身不被
650
+ * 改动;两条都必须定位得到,否则拒绝(写一个指向不存在记忆的原因没有意义)。
651
+ *
652
+ * 下游复用既有的 `stale` 行为:撤常驻、不排除检索、带标注等核对回写。
653
+ */
654
+ supersede(id: MemoryId, targetId: MemoryId, projectCwd?: string): Promise<MemoryRecord>;
655
+ /** {@link supersede} 的实现体;必须经 `writeScope` 进入。返回**被标记的那条**。 */
656
+ private supersedeInner;
657
+ /**
658
+ * 撤回(0.12.0):把本条标成作废并写明原因。**内容保留**——不删除、也不排除检索,
659
+ * 与锚点失效后「带标注等核对」是同一条路径。
660
+ */
661
+ retract(id: MemoryId, reason: string, projectCwd?: string): Promise<MemoryRecord>;
662
+ /** {@link retract} 的实现体;必须经 `writeScope` 进入。 */
663
+ private retractInner;
664
+ /**
665
+ * 标失效的共用落点(0.12.0):写 `stale` + 原因、撤掉常驻、发 `memory/changed`。
666
+ *
667
+ * 与 `verifyAnchorsInner` 的失效分支保持**同一组字段**(不动 `status`、不删内容、不排除
668
+ * 检索)——「被取代」「被撤回」「锚点不符」在下游是同一种状态,只在 `staleReason` 里区分
669
+ * 来源。失效是一个维度、多种来源,这是那条判据的落点。
670
+ */
671
+ private markStale;
566
672
  /**
567
673
  * 修改一条记忆的内容/关键词/注入位(0.3.1 整理记忆用)。2026-09-15 起:改动**不再**
568
674
  * 退回待审核——静默机制下记忆由系统与模型自行维护,更新即保持原有审核状态。2026-09-18
package/dist/engine.js CHANGED
@@ -18,26 +18,42 @@ import { parsePromptFile, scanPromptDir, writePromptFile } from "./prompt-files.
18
18
  import { detectSensitive } from "./quarantine.js";
19
19
  import { SELF_VERSION } from "./self.js";
20
20
  import { anchorHolds } from "./anchors.js";
21
+ import { DEFAULT_INJECTION_BUDGET, DEFAULT_SUMMARY_CHARS, deriveSummary, renderInjection, } from "./injection.js";
21
22
  import { QueryLog, rankGapCandidates, summarizeQueryLog, summarizeSources } from "./query-log.js";
22
23
  /** Brand a string as a {@link MemoryId} (compile-time only). */
23
24
  export function MemoryId(id) {
24
25
  return id;
25
26
  }
26
- /** 自动升常驻所需的命中次数(2026-09-15):一次偶然命中不足以证明「每轮都值得付费」。 */
27
- const AUTO_INJECT_HITS = 2;
27
+ /**
28
+ * 候选提示所需的命中次数(2026-09-15 起是自动升常驻的阈值;0.12.0 改为只提示)。
29
+ *
30
+ * 达到它的记录进 {@link MemoryEngine.candidates},由体检报告与诊断行列出——**不再据此
31
+ * 改变注入状态**。阈值本身没动:取消自动升之后,它影响的是提示清单的宽度,不再是状态变更。
32
+ * 一次偶然命中不足以说明任何事,所以起点仍是 2。
33
+ */
34
+ const CANDIDATE_HITS = 2;
28
35
  /**
29
36
  * 一次检索里最多把前多少条记为「被使用」(2026-09-15)。
30
37
  *
31
38
  * 不设上限时,命中计数退化成「和查询有任意字符重叠」:BM25 对常见 2-gram 会给全库打分,
32
- * 一句「实机验证标记」在本机就能命中 81 条,于是**两次检索把所有 approved 记忆都升成常驻**,
33
- * 注入预算当场被历史条目占满——「反复被命中才值得每轮付费」这个判据也就失效了。
34
- * 取前 K 名接近「模型真的会看的那几条」,且与检索返回值解耦(返回值不动)。
39
+ * 一句「实机验证标记」在本机就能命中 81 条。0.12.0 之前这会让**两次检索把所有 approved
40
+ * 记忆都升成常驻**、注入预算当场被历史条目占满;取消自动升之后危害降级为「候选清单被灌满
41
+ * 噪音」,但上限仍然必要——它保证 `hitCount` 度量的是「模型真的会看的那几条」。
42
+ * 与检索返回值解耦(返回值不动)。
35
43
  */
36
44
  const HIT_MARK_LIMIT = 5;
37
45
  /** 反查候选的正文摘要长度(字符)。 */
38
46
  const GAP_EXCERPT_CHARS = 160;
39
47
  /** 自动降级的天数阈值(2026-09-15):与面板的冷数据判定(COLD_DAYS=30)对齐。 */
40
48
  const AUTO_DEMOTE_DAYS = 30;
49
+ /**
50
+ * 体检里「久未命中」的默认天数阈值(0.12.0)。
51
+ *
52
+ * 比自动降级的 30 天大得多,因为两者判的不是一类东西:30 天判「自动升上来的常驻是否还有
53
+ * 价值」,而久未命中的对象**本来就不是常驻**——非常驻的记忆几十天不用是常态,阈值定得太低
54
+ * 会把正常记录报成待清理。
55
+ */
56
+ const DEFAULT_SWEEP_DAYS = 90;
41
57
  const blockSchema = z.object({
42
58
  namespace: z.enum(['global', 'project']),
43
59
  status: z.enum(['suggested', 'approved', 'auto', 'suggest']),
@@ -183,6 +199,15 @@ export class MemoryEngine extends Service {
183
199
  writeChain = Promise.resolve();
184
200
  /** 会话启动维护只跑一次的标记(2026-09-15)。 */
185
201
  maintenanceDone = false;
202
+ /** 最近一次体检的结果(进程内,不落盘):供注入侧报「本次校验发现的新失效」。 */
203
+ lastSweep;
204
+ /**
205
+ * 最近一次体检结果(只读)。未跑过时为 `undefined`——注入侧据此决定要不要报那一行,
206
+ * 它只在真有新失效时出现,稀有所以不构成噪音。
207
+ */
208
+ get lastSweepReport() {
209
+ return this.lastSweep;
210
+ }
186
211
  constructor(ctx, config = {}) {
187
212
  super(ctx, 'memory');
188
213
  this.config = config;
@@ -491,6 +516,78 @@ export class MemoryEngine extends Service {
491
516
  }
492
517
  return candidates;
493
518
  }
519
+ /**
520
+ * 只读体检(0.12.0):把「该退场的」和「钉了却没进来的」摆成一张清单,**不修改任何状态**
521
+ * ——连时间戳都不写。
522
+ *
523
+ * 为什么坚持只读:一只读的工具,最坏情况是报告不准,判断者按错的清单做了坏决定也能追溯;
524
+ * 一个会改状态的工具,最坏情况是错了都看不见。这决定了它不需要「每日一次」的守卫,也不
525
+ * 需要定期读者——有人问它就活着,没人问它不产生任何成本(不占预算、不落盘、不写状态)。
526
+ */
527
+ async sweep(projectCwd, options) {
528
+ const now = options?.now ?? Date.now();
529
+ const days = options?.days ?? DEFAULT_SWEEP_DAYS;
530
+ const threshold = now - days * 24 * 60 * 60 * 1000;
531
+ const summaryChars = this.config.summaryChars ?? DEFAULT_SUMMARY_CHARS;
532
+ const brief = (record) => deriveSummary(record.content, summaryChars);
533
+ const records = await this.allRecords(undefined, projectCwd);
534
+ const stale = records
535
+ .filter(record => record.stale === true)
536
+ .map(record => ({ id: String(record.id), summary: brief(record), reason: record.staleReason }));
537
+ // 只看非常驻:常驻的「没被检索过」是正常的(它本来就每轮在场,不需要被查)
538
+ const longUnused = records
539
+ .filter(record => record.injected !== true && (record.lastUsedAt ?? record.updatedAt) < threshold)
540
+ .map(record => ({
541
+ id: String(record.id),
542
+ summary: brief(record),
543
+ lastUsed: record.lastUsedAt ?? record.updatedAt,
544
+ }));
545
+ const candidates = this.candidates(projectCwd)
546
+ .map(record => ({ id: String(record.id), summary: brief(record), hitCount: record.hitCount ?? 0 }));
547
+ const resident = this.recallRecords(projectCwd);
548
+ const rendered = renderInjection(resident, this.config.injectionBudget ?? DEFAULT_INJECTION_BUDGET, summaryChars);
549
+ return {
550
+ stale,
551
+ longUnused,
552
+ candidates,
553
+ resident: {
554
+ total: resident.length,
555
+ injected: rendered.lines.length,
556
+ // `renderInjection` 用 `null` 表示「预算无限制」,而输出 schema 用「字段缺席」表达
557
+ // 同一件事(可选字段推导出 `number | undefined`)——在这里归一,避免两套空值语义。
558
+ budget: rendered.budget ?? undefined,
559
+ omitted: rendered.omitted,
560
+ },
561
+ };
562
+ }
563
+ /**
564
+ * 候选(0.12.0):被反复检索命中、但没有被钉成常驻的记录。**只提示,不改状态。**
565
+ *
566
+ * 筛选条件与原自动升级判据逐条对应(approved、未隔离、未被人工否决、非模板、未常驻)
567
+ * ——判据没变,变的只是它的作用:从「自动钉住」改为「列出来等一个判断」。`hitCount`
568
+ * 只增不减,所以候选会一直留着,直到被钉住、被删或记录本身消失——这不是缺陷,是提示
569
+ * 该有的持续性:它只在被消费之后才消失。
570
+ *
571
+ * 与 {@link indexCandidates} 的分工:那个取**全部**记录(供索引行统计规模与主题),
572
+ * 这个只取满足候选条件的(供体检报告与诊断行)。
573
+ */
574
+ candidates(projectCwd) {
575
+ const picked = (table) => this.recordsOf(table).filter(record => record.status === 'approved'
576
+ && record.quarantined !== true
577
+ && record.injected !== true
578
+ && record.injectedAuto !== false
579
+ && (record.kind ?? 'fact') !== 'prompt'
580
+ && (record.hitCount ?? 0) >= CANDIDATE_HITS);
581
+ const found = picked(this.requireTable('global'));
582
+ if (projectCwd === undefined || projectCwd === '')
583
+ return found;
584
+ for (const { key } of this.projectChain(projectCwd)) {
585
+ const table = this.projectTables.get(key);
586
+ if (table !== undefined)
587
+ found.push(...picked(table));
588
+ }
589
+ return found;
590
+ }
494
591
  /**
495
592
  * 按 id 定位记录所在表:global 优先,其次工作区链(自身 → 祖先,由近及远)。
496
593
  * **异步版**——写路径必须用它:`writeScope` 可能在本次进入前刚 `reload()`,
@@ -612,6 +709,9 @@ export class MemoryEngine extends Service {
612
709
  (filter?.quarantined === undefined ? record.quarantined !== true : record.quarantined === filter.quarantined)
613
710
  && (filter?.status === undefined || record.status === filter.status)
614
711
  && (filter?.injected === undefined || record.injected === filter.injected)
712
+ // 0.12.0:按更新时间筛——与注入排序、注入行的日期标记同一个依据,不制造第二个「新」的定义
713
+ && (filter?.after === undefined || record.updatedAt >= filter.after)
714
+ && (filter?.before === undefined || record.updatedAt <= filter.before)
615
715
  && (filter?.kind === undefined || (record.kind ?? 'fact') === filter.kind));
616
716
  }
617
717
  /**
@@ -739,9 +839,12 @@ export class MemoryEngine extends Service {
739
839
  const demoted = await this.demoteStale(projectCwd);
740
840
  if (demoted > 0)
741
841
  this.ctx.logger?.info?.(`dsh-memory: 长期未命中,撤下常驻 ${demoted} 条`);
742
- const staled = await this.verifyAnchors(this.anchorProbes(), projectCwd);
842
+ const staled = await this.verifyAnchors(this.anchorProbes(projectCwd), projectCwd);
743
843
  if (staled > 0)
744
844
  this.ctx.logger?.info?.(`dsh-memory: 锚点校验更新 ${staled} 条`);
845
+ // 体检(0.12.0):只读,产物存进实例字段,供注入侧报「本次校验发现的新失效」。
846
+ // 它在 try 内——报告没有记忆本身重要,失败由下面的 catch 兜住、不阻断启动。
847
+ this.lastSweep = await this.sweep(projectCwd);
745
848
  }
746
849
  catch (error) {
747
850
  this.ctx.logger?.warn?.(`dsh-memory: 启动维护失败(不影响使用):${String(error)}`);
@@ -750,8 +853,11 @@ export class MemoryEngine extends Service {
750
853
  /**
751
854
  * 锚点探测上下文。`toolNames` 拿不到时返回 `undefined`——**不能给空数组**:那会被判成
752
855
  * 「工具全没了」,让所有 `tool-list` 锚点误失效。
856
+ *
857
+ * `workspaceCwd`(0.12.0)供 `path-exists` 把相对路径解析成绝对路径;缺省时相对路径
858
+ * 判为「未校验」而不是「不存在」。
753
859
  */
754
- anchorProbes() {
860
+ anchorProbes(workspaceCwd) {
755
861
  let toolNames;
756
862
  try {
757
863
  const tools = this.ctx.get('tools');
@@ -765,7 +871,11 @@ export class MemoryEngine extends Service {
765
871
  catch {
766
872
  toolNames = undefined;
767
873
  }
768
- return { ...(toolNames === undefined ? {} : { toolNames }), selfVersion: SELF_VERSION };
874
+ return {
875
+ ...(toolNames === undefined ? {} : { toolNames }),
876
+ selfVersion: SELF_VERSION,
877
+ ...(workspaceCwd === undefined || workspaceCwd === '' ? {} : { workspaceCwd }),
878
+ };
769
879
  }
770
880
  /** 同步版定位:只查**已打开**的表(global + 链上已缓存的工作区表)。 */
771
881
  tableOf(id, projectCwd) {
@@ -812,33 +922,24 @@ export class MemoryEngine extends Service {
812
922
  }
813
923
  }
814
924
  /**
815
- * 命中记账 + 自动升级(2026-09-15 静默记忆机制):命中次数达阈值即自动打开常驻
816
- * 注入——「被反复检索命中」是它值得每轮付费的唯一客观证据。统计性字段不 emit,
817
- * 只有真的改变了注入状态才发 `memory/changed`。
925
+ * 命中记账(2026-09-15 静默记忆机制;0.12.0 取消自动升级)。
926
+ *
927
+ * 只记 `hitCount` 与 `lastUsedAt`,**不再据此改变注入状态**。原判据是「被反复检索
928
+ * 命中」= 值得每轮付费的唯一客观证据,但这条证据测错了东西:命中次数度量的是
929
+ * **它被想起来了**,而常驻的语义是**它不能被忘**——能被检索命中的恰恰是检索已经
930
+ * 够用的那些,真正该常驻的(想不起来去查的)命中次数永远是 0。达到
931
+ * {@link CANDIDATE_HITS} 的记录改由 {@link candidates} 列出,进体检报告与诊断行
932
+ * 提示,改不改由判断者决定(判据详见设计文档 §二事实四)。
933
+ *
934
+ * 统计性字段不 emit:本函数现在不改变任何被订阅的状态。
818
935
  */
819
936
  async recordUse(table, id, block, now) {
820
- const hitCount = (block.hitCount ?? 0) + 1;
821
- const normalized = normalizeBlock(block);
822
- // 人工设置过的(injectedAuto === false)双向豁免:既不被降级,也不被升级。
823
- // 少了这一条,人手动关掉的记忆会在下次命中时被系统重新打开——用户唯一能
824
- // 表达「不要这条常驻」的动作就失效了,而这与降级侧「人工决定优先」不对称。
825
- const promote = normalized.status === 'approved'
826
- && block.quarantined !== true
827
- && block.injectedAuto !== false
828
- // 模板永不注入(0.6.0):prompt 记录连自动升级的资格都没有。少了这一条,
829
- // 模板会因「经常被搜到」而升成常驻,把注入预算花在从不该进上下文的东西上。
830
- && (block.kind ?? 'fact') !== 'prompt'
831
- && !normalized.injected
832
- && hitCount >= AUTO_INJECT_HITS;
833
937
  const updated = {
834
938
  ...block,
835
- hitCount,
939
+ hitCount: (block.hitCount ?? 0) + 1,
836
940
  lastUsedAt: now,
837
- ...promote ? { injected: true, injectedAuto: true } : {},
838
941
  };
839
942
  await table.put(id, updated);
840
- if (promote)
841
- this.ctx.emit('memory/changed', { operation: 'injected', id, injected: true });
842
943
  }
843
944
  // ── 查询日志与影响力统计(⑥ 2026-09-15)────────────────────────────────
844
945
  /** 查询日志(惰性创建;位置见 {@link queryLogPath})。 */
@@ -1271,6 +1372,64 @@ export class MemoryEngine extends Service {
1271
1372
  this.ctx.emit('memory/changed', { operation: 'injected', id, injected });
1272
1373
  return record;
1273
1374
  }
1375
+ /**
1376
+ * 语义作废(0.12.0):把 `targetId` 标成**被 `id` 取代**。
1377
+ *
1378
+ * 与「改内容」的区别:`update` 会让旧内容直接消失,「取代」把痕迹留下来——检索时看到
1379
+ * 「这条已作废,原因是 X」对被主动查的人是信息而不是干扰。`id` 只用来写原因,本身不被
1380
+ * 改动;两条都必须定位得到,否则拒绝(写一个指向不存在记忆的原因没有意义)。
1381
+ *
1382
+ * 下游复用既有的 `stale` 行为:撤常驻、不排除检索、带标注等核对回写。
1383
+ */
1384
+ async supersede(id, targetId, projectCwd) {
1385
+ return this.writeScope(projectCwd, () => this.supersedeInner(id, targetId, projectCwd));
1386
+ }
1387
+ /** {@link supersede} 的实现体;必须经 `writeScope` 进入。返回**被标记的那条**。 */
1388
+ async supersedeInner(id, targetId, projectCwd) {
1389
+ if (id === targetId)
1390
+ throw new Error(`cannot supersede a memory with itself ('${id}')`);
1391
+ if (await this.locateRecord(id, projectCwd) === undefined) {
1392
+ throw new Error(`cannot supersede from unknown memory '${id}'`);
1393
+ }
1394
+ const target = await this.locateRecord(targetId, projectCwd);
1395
+ if (target === undefined)
1396
+ throw new Error(`cannot supersede unknown memory '${targetId}'`);
1397
+ return toRecord(targetId, await this.markStale(targetId, target, `被 ${String(id).slice(0, 8)} 取代`));
1398
+ }
1399
+ /**
1400
+ * 撤回(0.12.0):把本条标成作废并写明原因。**内容保留**——不删除、也不排除检索,
1401
+ * 与锚点失效后「带标注等核对」是同一条路径。
1402
+ */
1403
+ async retract(id, reason, projectCwd) {
1404
+ return this.writeScope(projectCwd, () => this.retractInner(id, reason, projectCwd));
1405
+ }
1406
+ /** {@link retract} 的实现体;必须经 `writeScope` 进入。 */
1407
+ async retractInner(id, reason, projectCwd) {
1408
+ const found = await this.locateRecord(id, projectCwd);
1409
+ if (found === undefined)
1410
+ throw new Error(`cannot retract unknown memory '${id}'`);
1411
+ return toRecord(id, await this.markStale(id, found, `已撤回:${reason}`));
1412
+ }
1413
+ /**
1414
+ * 标失效的共用落点(0.12.0):写 `stale` + 原因、撤掉常驻、发 `memory/changed`。
1415
+ *
1416
+ * 与 `verifyAnchorsInner` 的失效分支保持**同一组字段**(不动 `status`、不删内容、不排除
1417
+ * 检索)——「被取代」「被撤回」「锚点不符」在下游是同一种状态,只在 `staleReason` 里区分
1418
+ * 来源。失效是一个维度、多种来源,这是那条判据的落点。
1419
+ */
1420
+ async markStale(id, found, reason) {
1421
+ const updated = {
1422
+ ...found.block,
1423
+ stale: true,
1424
+ staleReason: reason,
1425
+ injected: false,
1426
+ injectedAuto: false,
1427
+ updatedAt: Date.now(),
1428
+ };
1429
+ await found.table.put(id, updated);
1430
+ this.ctx.emit('memory/changed', { operation: 'status', id, status: normalizeBlock(updated).status });
1431
+ return updated;
1432
+ }
1274
1433
  /**
1275
1434
  * 修改一条记忆的内容/关键词/注入位(0.3.1 整理记忆用)。2026-09-15 起:改动**不再**
1276
1435
  * 退回待审核——静默机制下记忆由系统与模型自行维护,更新即保持原有审核状态。2026-09-18
package/dist/index.js CHANGED
@@ -9,12 +9,38 @@ 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, indexNotice, neighborNotice, omittedNotice, renderInjection } from "./injection.js";
12
+ import { DEFAULT_INJECTION_BUDGET, DEFAULT_SUMMARY_CHARS, candidateNotice, indexNotice, neighborNotice, neutralizeBraces, omittedNotice, renderInjection, staleNotice } 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";
16
16
  export const name = 'dsh-memory';
17
17
  export const inject = ['storage', 'systemPrompt', 'tools', 'webServer', 'webRuntime'];
18
+ /**
19
+ * 解析时间过滤参数(0.12.0):`7d` / `30d` 这类相对天数,或 `YYYY-MM-DD` 绝对日期。
20
+ *
21
+ * **为什么用字符串而不是数字**:工具参数里目前没有 number 类型的先例(`type: 'number'` 只
22
+ * 出现在输出 schema),不拿一个未验证的参数类型去赌;字符串也天然容纳 `7d` 这种相对写法。
23
+ * 解析不出(空串 / 格式不对)一律返回 `undefined` = 不过滤,**不报错**——过滤条件是辅助
24
+ * 信息,让它把一次查询整体打断得不偿失。
25
+ *
26
+ * 绝对日期按**本地时区**解释(`.dsh` 的存储时间戳是 epoch,用户在本地日期里思考),
27
+ * 相对天数以 `now` 为基准往前推。
28
+ */
29
+ function parseTimeFilter(input, now) {
30
+ if (input === undefined)
31
+ return undefined;
32
+ const text = input.trim();
33
+ if (text === '')
34
+ return undefined;
35
+ const relative = /^(\d+)d$/.exec(text);
36
+ if (relative !== null)
37
+ return now - Number(relative[1]) * 24 * 60 * 60 * 1000;
38
+ const absolute = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(text);
39
+ if (absolute !== null) {
40
+ return new Date(Number(absolute[1]), Number(absolute[2]) - 1, Number(absolute[3])).getTime();
41
+ }
42
+ return undefined;
43
+ }
18
44
  const RECORD_SCHEMA = {
19
45
  type: 'object',
20
46
  additionalProperties: false,
@@ -43,6 +69,70 @@ const HIT_SCHEMA = {
43
69
  score: { type: 'number', required: true },
44
70
  },
45
71
  };
72
+ /**
73
+ * 体检报告的输出(0.12.0):四类清单一律只读。`summary` 与注入行同量级,直接念给人听
74
+ * 不失真——这个工具的主要用法就是「你问起时我念给你看」。
75
+ */
76
+ const SWEEP_SCHEMA = {
77
+ type: 'object',
78
+ additionalProperties: false,
79
+ properties: {
80
+ stale: {
81
+ type: 'array',
82
+ required: true,
83
+ description: '当前失效的记录(锚点不符 / 被取代 / 已撤回),带原因。',
84
+ items: {
85
+ type: 'object',
86
+ additionalProperties: false,
87
+ properties: {
88
+ id: { type: 'string', required: true },
89
+ summary: { type: 'string', required: true },
90
+ reason: { type: 'string' },
91
+ },
92
+ },
93
+ },
94
+ longUnused: {
95
+ type: 'array',
96
+ required: true,
97
+ description: '久未被检索命中的非常驻记录——清理或归档的候选材料。',
98
+ items: {
99
+ type: 'object',
100
+ additionalProperties: false,
101
+ properties: {
102
+ id: { type: 'string', required: true },
103
+ summary: { type: 'string', required: true },
104
+ lastUsed: { type: 'number', required: true },
105
+ },
106
+ },
107
+ },
108
+ candidates: {
109
+ type: 'array',
110
+ required: true,
111
+ description: '被反复检索命中但没有被钉成常驻:要不要钉的待办。',
112
+ items: {
113
+ type: 'object',
114
+ additionalProperties: false,
115
+ properties: {
116
+ id: { type: 'string', required: true },
117
+ summary: { type: 'string', required: true },
118
+ hitCount: { type: 'number', required: true },
119
+ },
120
+ },
121
+ },
122
+ resident: {
123
+ type: 'object',
124
+ required: true,
125
+ additionalProperties: false,
126
+ description: '常驻与注入预算的占用;`omitted > 0` 表示「钉了却没进来」。',
127
+ properties: {
128
+ total: { type: 'number', required: true },
129
+ injected: { type: 'number', required: true },
130
+ budget: { type: 'number', description: '生效预算(字符);无限制时不出现。' },
131
+ omitted: { type: 'number', required: true },
132
+ },
133
+ },
134
+ },
135
+ };
46
136
  /** 模板记录(0.6.0):不暴露本地文件路径(meta.path 引擎内部使用)。 */
47
137
  const PROMPT_RECORD_SCHEMA = {
48
138
  type: 'object',
@@ -115,19 +205,24 @@ const GUIDANCE = 'Use memory tools for cross-session preferences, habits, and pr
115
205
  + '记结论时一并记下**它是怎么来的**——它为什么成立,以及是什么场景把它逼出来的。没有出处的结论像一把'
116
206
  + '没有来历的尺子:看起来天经地义,而天经地义的东西最容易被机械套用。'
117
207
  + 'Pass `injected: true` on memory_save / memory_update to pin a memory resident in every turn\'s context: '
118
- + 'the same switch the settings panel exposes, exempting the record from both automatic rules (promote on '
119
- + 'repeated hits, demote after long idle). Reserve it for rules, standing agreements and judgement criteria '
208
+ + 'the same switch the settings panel exposes, exempting the record from the remaining automatic rule '
209
+ + '(demote after long idle; repeated hits no longer promote anything — they only feed the candidate hint). '
210
+ + 'Reserve it for rules, standing agreements and judgement criteria '
120
211
  + '— the kind that must apply even when nobody thinks to search for them; facts, references and case notes '
121
212
  + 'stay search-only. '
122
213
  + '给 memory_save / memory_update 传 `injected: true` 即把这条钉成每轮常驻——与设置面板是同一个开关,'
123
- + '此后不受两条自动规则(反复命中自动开启 / 长期未命中自动撤下)影响。只用于规则、长期约定与判据这类'
214
+ + '此后不受剩下那条自动规则(长期未命中自动撤下)影响;反复命中已不再自动开启任何东西,只喂候选提示。'
215
+ + '只用于规则、长期约定与判据这类'
124
216
  + '「没人想起来搜也必须生效」的记忆;事实、参考与案例留作按需检索。'
125
217
  + 'When earlier context may be relevant, call memory_search to recall it — reviewable memories '
126
218
  + '相关历史上下文可用 memory_search 检索;被隔离的记录不进检索。'
127
219
  + 'Every memory is plaintext and inspectable with memory_list; memory_forget removes one. '
128
220
  + '所有记忆均为明文,可用 memory_list 查看;memory_forget 删除一条。'
129
221
  + 'Approved + injected memories of the CURRENT session workspace are every-turn injected too. '
130
- + '已审核 + 常驻注入开关打开的工作区记忆也会每轮注入(按当前会话工作区路由)。';
222
+ + '已审核 + 常驻注入开关打开的工作区记忆也会每轮注入(按当前会话工作区路由)。'
223
+ + 'When the library grows, call memory_sweep for a read-only health check (stale records with reasons, '
224
+ + 'long-unused ones, candidates that keep being retrieved without being pinned, and budget usage). '
225
+ + '库变大之后用 memory_sweep 做只读体检——失效记录(带原因)、久未命中、被反复检索却未常驻的候选、以及预算占用。';
131
226
  // 0.5.1:`memory:recall` provider 可经 AssembleContext.agent 拿到当前会话
132
227
  // 的 header.cwd——注入 = global + 当前会话工作区的 approved+injected
133
228
  // (0.3.4 起工作区记忆就按会话 cwd 路由;provider 是同步回调,只读已
@@ -135,6 +230,8 @@ const GUIDANCE = 'Use memory tools for cross-session preferences, habits, and pr
135
230
  // 0.5.2:注入经摘要化 + 预算截断(renderInjection,纯函数)防上下文膨胀。
136
231
  // 0.9.0:装不下的条目不再是无声丢弃——末尾追加一行预算诊断(omittedNotice),
137
232
  // 报出被挡在外面的 id;它只在有出局时出现,清理干净即消失。
233
+ // 0.11.1:返回值经 neutralizeBraces 中和字面 `{{`——严格插值下它会抛错,让该会话的
234
+ // 全部模型请求一起失败(机制见 injection.ts 的 neutralizeBraces)。
138
235
  function recallText(memory, projectCwd, budget, summaryChars) {
139
236
  const rendered = renderInjection(memory.recallRecords(projectCwd), budget, summaryChars);
140
237
  const parts = [];
@@ -145,6 +242,14 @@ function recallText(memory, projectCwd, budget, summaryChars) {
145
242
  const notice = omittedNotice(rendered);
146
243
  if (notice !== '')
147
244
  parts.push(notice);
245
+ // 0.12.0:新失效(稀有,出现即值得看一眼)与候选(该不该钉的待办)。
246
+ // 两者都不占预算;候选必须走到眼前,因为判断者就是读这段上下文的模型自己。
247
+ const stale = staleNotice(memory.lastSweepReport?.stale ?? []);
248
+ if (stale !== '')
249
+ parts.push(stale);
250
+ const candidates = candidateNotice(memory.candidates(projectCwd));
251
+ if (candidates !== '')
252
+ parts.push(candidates);
148
253
  // ① 索引行:给出「库里还有什么、按什么去搜」(不占预算、长度常数级)
149
254
  const index = indexNotice(memory.indexCandidates(projectCwd));
150
255
  if (index !== '')
@@ -153,7 +258,10 @@ function recallText(memory, projectCwd, budget, summaryChars) {
153
258
  const neighbors = neighborNotice(memory.neighborsOf(projectCwd));
154
259
  if (neighbors !== '')
155
260
  parts.push(neighbors);
156
- return parts.join('\n');
261
+ // 中和整个注入副本,而不是在各子行内部:四条子路径(注入行 / 预算诊断 / 索引行 /
262
+ // 子项目索引)在这里唯一汇合,一处即全覆盖,将来新增子行也不必记得各自中和。
263
+ // 这四行都承载模型或用户可写的文本——记忆正文、关键词、子项目目录名。
264
+ return neutralizeBraces(parts.join('\n'));
157
265
  }
158
266
  /** 从组装上下文取当前 agent 会话的工作区 cwd(AssembleContext.agent 由 dsh-agent 声明合并提供)。 */
159
267
  function assemblyProjectCwd(assembly) {
@@ -256,7 +364,7 @@ export async function apply(ctx, config) {
256
364
  additionalProperties: false,
257
365
  description: 'Optional validity anchor: bind this memory to a probeable environment value so it auto-expires when that value changes. Use only for facts that depend on the environment (tool behaviour under a specific version, config decided by an env var). 可选锚点:把记忆绑到可探测的环境值,值变了自动失效。只用于「随环境变化的事实」。',
258
366
  properties: {
259
- kind: { type: 'string', required: true, enum: ['env', 'tool-list', 'self-version'], description: 'env = a named environment variable; tool-list = the available tool set; self-version = this plugin version.' },
367
+ kind: { type: 'string', required: true, enum: ['env', 'tool-list', 'self-version', 'path-exists'], description: 'env = a named environment variable; tool-list = the available tool set; self-version = this plugin version; path-exists = a path that should still exist (`name` = the path, `value` = `present` or `absent`).' },
260
368
  name: { type: 'string', description: 'For kind=env: the variable name.' },
261
369
  value: { type: 'string', required: true, description: 'The value probed at write time.' },
262
370
  },
@@ -282,21 +390,28 @@ export async function apply(ctx, config) {
282
390
  }));
283
391
  ctx.tools.register(defineTool({
284
392
  name: 'memory_list',
285
- description: 'List every stored memory, optionally filtered by namespace, status, or injected switch. Every memory is plaintext and inspectable. 列出全部记忆,可按 namespace/status/injected 过滤;均为明文可查。',
393
+ description: 'List every stored memory, optionally filtered by namespace, status, injected switch, or update time. Every memory is plaintext and inspectable. 列出全部记忆,可按 namespace/status/injected/更新时间过滤;均为明文可查。',
286
394
  parameters: {
287
395
  namespace: { type: 'string', enum: ['global', 'project'], description: 'Restrict to one namespace. 限定单个命名空间.' },
288
396
  status: { type: 'string', enum: ['suggested', 'approved'], description: 'Restrict to one review status. 限定审核状态(suggested=待审核 / approved=已审核).' },
289
397
  injected: { type: 'boolean', description: 'Restrict by the persistent-injection switch. 按常驻注入开关过滤.' },
398
+ after: { type: 'string', description: 'Only memories updated at/after this time: `YYYY-MM-DD`, or a relative form like `7d` / `30d`. 只返回此后更新过的记忆(`YYYY-MM-DD`,或相对形式 `7d` / `30d`).' },
399
+ before: { type: 'string', description: 'Only memories updated at/before this time (same forms as `after`). 只返回此前更新过的记忆(写法同 after).' },
290
400
  },
291
401
  output: {
292
402
  schema: { type: 'array', items: RECORD_SCHEMA },
293
403
  render: (_args, value) => renderJson(value),
294
404
  },
295
405
  execute(args, exec) {
406
+ const now = Date.now();
407
+ const after = parseTimeFilter(args.after, now);
408
+ const before = parseTimeFilter(args.before, now);
296
409
  return memory.list({
297
410
  ...args.namespace === undefined ? {} : { namespace: args.namespace },
298
411
  ...args.status === undefined ? {} : { status: args.status },
299
412
  ...args.injected === undefined ? {} : { injected: args.injected },
413
+ ...after === undefined ? {} : { after },
414
+ ...before === undefined ? {} : { before },
300
415
  }, execProjectCwd(exec)).then(records => records.map(recordValue));
301
416
  },
302
417
  presentCall: () => present('List memories', 'read'),
@@ -500,18 +615,28 @@ export async function apply(ctx, config) {
500
615
  id: { type: 'string', required: true, description: 'Exact memory id from memory_list. 记忆 id(来自 memory_list).' },
501
616
  content: { type: 'string', description: 'New content; omit to keep current. 新内容;省略则保留现有内容.' },
502
617
  keywords: { type: 'array', items: { type: 'string' }, description: 'New keywords; omit to keep current. 新关键词;省略则保留现有.' },
503
- injected: { type: 'boolean', description: 'Set or clear the persistent-injection switch; omit to keep the current value. Setting it records a deliberate decision — the memory stops being auto-promoted or auto-demoted. See memory_save for what deserves residency. 设置或清除常驻注入开关;省略则保持原值。给值即视为有意决定——此后不受自动升降影响。何时该常驻见 memory_save。' },
618
+ supersedes: { type: 'string', description: 'Mark the memory with this id as superseded by the current one (the current record is unchanged; only the other gets the void mark). 把该 id 指向的记忆标成「被本条取代」——本条不变,只写对方的作废标记。与 content / retract 互斥。' },
619
+ retract: { type: 'string', description: 'Retract this memory: give a reason and it is marked void (content kept, no longer injected, still searchable). 撤回本条:给一个原因即标记作废——内容保留、不再进注入,检索仍可检回。与 content / supersedes 互斥。' },
620
+ injected: { type: 'boolean', description: 'Set or clear the persistent-injection switch; omit to keep the current value. Setting it records a deliberate decision — the memory stops being auto-demoted (repeated hits no longer promote anything). See memory_save for what deserves residency. 设置或清除常驻注入开关;省略则保持原值。给值即视为有意决定——此后不受自动降级影响(反复命中已不再自动开启任何东西)。何时该常驻见 memory_save。' },
504
621
  },
505
622
  output: {
506
623
  schema: RECORD_SCHEMA,
507
624
  render: (_args, value) => renderJson(value),
508
625
  },
509
626
  execute(args, exec) {
510
- return memory.update(args.id, {
627
+ const cwd = execProjectCwd(exec);
628
+ const id = args.id;
629
+ // 三种动作互斥,按 retract > supersedes > update 的次序取第一个(与工具描述一致):
630
+ // 前两个是「标状态」,第三个才是「改内容」。同时给多个时以更重的动作为准。
631
+ if (args.retract !== undefined)
632
+ return memory.retract(id, args.retract, cwd).then(recordValue);
633
+ if (args.supersedes !== undefined)
634
+ return memory.supersede(id, args.supersedes, cwd).then(recordValue);
635
+ return memory.update(id, {
511
636
  ...args.content === undefined ? {} : { content: args.content },
512
637
  ...args.keywords === undefined ? {} : { keywords: args.keywords },
513
638
  ...args.injected === undefined ? {} : { injected: args.injected },
514
- }, execProjectCwd(exec)).then(recordValue);
639
+ }, cwd).then(recordValue);
515
640
  },
516
641
  presentCall: args => present('Update memory', 'other', args.id),
517
642
  }));
@@ -530,4 +655,17 @@ export async function apply(ctx, config) {
530
655
  },
531
656
  presentCall: args => present('Confirm memory', 'other', args.id),
532
657
  }));
658
+ ctx.tools.register(defineTool({
659
+ name: 'memory_sweep',
660
+ description: 'Read-only health check: lists stale memories (with the reason), long-unused non-resident ones, candidates that keep being retrieved without being pinned, and how much of the injection budget the resident set takes. Changes nothing. 只读体检:列出失效的记忆(带原因)、久未被检索命中的非常驻记录、被反复检索却未常驻的候选,以及常驻占用了多少注入预算。不修改任何状态——最坏情况是报告不准,而不是误改内容。',
661
+ parameters: {},
662
+ output: {
663
+ schema: SWEEP_SCHEMA,
664
+ render: (_args, value) => renderJson(value),
665
+ },
666
+ execute(_args, exec) {
667
+ return memory.sweep(execProjectCwd(exec));
668
+ },
669
+ presentCall: () => present('Sweep memory', 'other', 'read-only'),
670
+ }));
533
671
  }
@@ -18,7 +18,28 @@ export declare const OMITTED_SUMMARY_CHARS = 24;
18
18
  * 空内容/空首行回退为去掉换行后的前 maxChars(保证 any 内容有摘要)。
19
19
  */
20
20
  export declare function deriveSummary(content: string, maxChars: number): string;
21
+ /**
22
+ * 中和注入副本里的字面 `{{`。DSH 的系统提示词走**严格插值**:section/context 的
23
+ * 文本里出现 `{{name}}` 会当场抛错(未注册 → `unknown`;名字不合
24
+ * `/^[a-z][a-z0-9_]*$/` → `malformed`),而组装发生在模型调用之前——该会话的
25
+ * 全部模型请求一起失败,模型无从自救(它改不了自己的记忆)。
26
+ *
27
+ * 循环替换直至稳定:单遍 `replaceAll` 处理不了 3 连以上左花括号——`{{{{a}}}}`
28
+ * 单遍得 `{ {{ {a}}}}`,仍含 `{{`。实测 1..1000 连最多 2 轮收敛。
29
+ *
30
+ * 只施加于**注入副本**:存储与面板预览都保留原文。记忆是给人看的审计窗口,
31
+ * 显示被改写过的内容会让人以为记忆本身被改过。
32
+ */
33
+ export declare function neutralizeBraces(text: string): string;
21
34
  /** 一条注入行的渲染文本(与面板预览完全一致)。 */
35
+ /**
36
+ * 日期标记(0.12.0):`YYYY-MM-DD`,按**本地时区**取,输入是 epoch ms。
37
+ *
38
+ * 用绝对日期而不是「18 天前」:相对时间每轮都在变,会把一个稳定的注入副本变成每轮漂移的
39
+ * 文本——`memory:recall` 本来就是动态 context,但不必要的漂移仍应避免。**减少变化的维度
40
+ * 是廉价的稳定性。**
41
+ */
42
+ export declare function formatDay(at: number): string;
22
43
  export declare function injectionLine(record: MemoryRecord, summaryChars: number): string;
23
44
  export interface InjectionRender {
24
45
  /** 实际注入行(已按预算截断、按更新时间降序)。 */
@@ -41,9 +62,12 @@ export interface InjectionRender {
41
62
  chars: number;
42
63
  }
43
64
  /**
44
- * 由候选记录渲染注入行:按使用序降序(lastUsedAt 优先、缺省回退
45
- * updatedAt——最近使用/更新的记忆最先),逐条累加开销直到预算用尽;
46
- * 预算为 null 或非正数时全量注入(不过滤)。
65
+ * 由候选记录渲染注入行:按**更新时间**降序(最近更新的最先),逐条累加开销直到预算
66
+ * 用尽;预算为 null 或非正数时全量注入(不过滤)。
67
+ *
68
+ * 0.12.0 起不再看 `lastUsedAt`:那是**检索**留下的痕迹,而检索是「需要时才用」的行为
69
+ * ——拿它决定「谁每轮在场」,等于让「查得多的」挤掉「钉得牢的」,而且一次 `memory_search`
70
+ * 会当场改变下一轮的注入内容(`markUsed` 就在检索路径上)。排序只该由内容本身的变更驱动。
47
71
  */
48
72
  export declare function renderInjection(records: readonly MemoryRecord[], budget: number | null, summaryChars: number): InjectionRender;
49
73
  /**
@@ -54,6 +78,31 @@ export declare function renderInjection(records: readonly MemoryRecord[], budget
54
78
  * {@link OMITTED_NOTICE_LIMIT} 只列前 N 个。
55
79
  */
56
80
  export declare function omittedNotice(rendered: InjectionRender): string;
81
+ /** 注入侧报「新失效」的条数上限——在稀有与有用之间取舍,只报几条就够指向问题。 */
82
+ export declare const STALE_NOTICE_LIMIT = 3;
83
+ /** 候选提示列出的条数上限。 */
84
+ export declare const CANDIDATE_NOTICE_LIMIT = 3;
85
+ /** 候选提示里每条摘要的长度:比注入行更短,这一行本身不该占太多。 */
86
+ export declare const CANDIDATE_SUMMARY_CHARS = 24;
87
+ /**
88
+ * 新失效提示行(0.12.0):报「本次启动校验发现几条记忆失效」。
89
+ *
90
+ * **它稀有,所以不构成噪音**:一条记忆失效意味着「照它行动会出错」,值得每轮看见,直到被
91
+ * 核对回写或删除。与 {@link omittedNotice} 一样**不占注入预算**(调用方追加在注入行之后)。
92
+ */
93
+ export declare function staleNotice(stale: ReadonlyArray<{
94
+ summary: string;
95
+ }>): string;
96
+ /**
97
+ * 候选提示行(0.12.0):报「有几条被反复检索命中、但没有被钉成常驻」。
98
+ *
99
+ * **判断者是模型自己**——一份不会主动去调的体检报告等于不存在,所以候选必须自己走到它眼前。
100
+ * `hitCount` 只增不减,候选会一直留着直到被消费(钉住或删掉):这不是缺陷,是提示该有的
101
+ * 持续性。**不占注入预算**。
102
+ */
103
+ export declare function candidateNotice(candidates: ReadonlyArray<{
104
+ content: string;
105
+ }>): string;
57
106
  /** 子项目索引行列出的条数上限(④-B):超出以 `…` 结尾,防这行本身失控。 */
58
107
  export declare const NEIGHBOR_NOTICE_LIMIT = 5;
59
108
  /**
package/dist/injection.js CHANGED
@@ -28,19 +28,53 @@ export function deriveSummary(content, maxChars) {
28
28
  return content;
29
29
  return collapsed.length <= maxChars ? collapsed : `${collapsed.slice(0, maxChars)}…`;
30
30
  }
31
+ /**
32
+ * 中和注入副本里的字面 `{{`。DSH 的系统提示词走**严格插值**:section/context 的
33
+ * 文本里出现 `{{name}}` 会当场抛错(未注册 → `unknown`;名字不合
34
+ * `/^[a-z][a-z0-9_]*$/` → `malformed`),而组装发生在模型调用之前——该会话的
35
+ * 全部模型请求一起失败,模型无从自救(它改不了自己的记忆)。
36
+ *
37
+ * 循环替换直至稳定:单遍 `replaceAll` 处理不了 3 连以上左花括号——`{{{{a}}}}`
38
+ * 单遍得 `{ {{ {a}}}}`,仍含 `{{`。实测 1..1000 连最多 2 轮收敛。
39
+ *
40
+ * 只施加于**注入副本**:存储与面板预览都保留原文。记忆是给人看的审计窗口,
41
+ * 显示被改写过的内容会让人以为记忆本身被改过。
42
+ */
43
+ export function neutralizeBraces(text) {
44
+ let result = text;
45
+ while (result.includes('{{'))
46
+ result = result.replaceAll('{{', '{ {');
47
+ return result;
48
+ }
31
49
  /** 一条注入行的渲染文本(与面板预览完全一致)。 */
50
+ /**
51
+ * 日期标记(0.12.0):`YYYY-MM-DD`,按**本地时区**取,输入是 epoch ms。
52
+ *
53
+ * 用绝对日期而不是「18 天前」:相对时间每轮都在变,会把一个稳定的注入副本变成每轮漂移的
54
+ * 文本——`memory:recall` 本来就是动态 context,但不必要的漂移仍应避免。**减少变化的维度
55
+ * 是廉价的稳定性。**
56
+ */
57
+ export function formatDay(at) {
58
+ const date = new Date(at);
59
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
60
+ }
32
61
  export function injectionLine(record, summaryChars) {
33
62
  // ④-A:跨工作区召回时带来源标记(`..` / `../..`);当前工作区不带,保持既有格式
34
63
  const scope = record.scope === undefined ? '' : `@${record.scope}`;
35
- return `- [memory:${String(record.id).slice(0, 8)}:${record.namespace}${scope}] ${deriveSummary(record.content, summaryChars)}`;
64
+ // 0.12.0:日期固定在 scope 之前——日期是数字、scope 是点号,顺序固定即无歧义。
65
+ // 「这条是什么时候记的」直接决定要不要先核对再照它做,尤其对随环境失效的那类记忆。
66
+ return `- [memory:${String(record.id).slice(0, 8)}:${record.namespace}@${formatDay(record.updatedAt)}${scope}] ${deriveSummary(record.content, summaryChars)}`;
36
67
  }
37
68
  /**
38
- * 由候选记录渲染注入行:按使用序降序(lastUsedAt 优先、缺省回退
39
- * updatedAt——最近使用/更新的记忆最先),逐条累加开销直到预算用尽;
40
- * 预算为 null 或非正数时全量注入(不过滤)。
69
+ * 由候选记录渲染注入行:按**更新时间**降序(最近更新的最先),逐条累加开销直到预算
70
+ * 用尽;预算为 null 或非正数时全量注入(不过滤)。
71
+ *
72
+ * 0.12.0 起不再看 `lastUsedAt`:那是**检索**留下的痕迹,而检索是「需要时才用」的行为
73
+ * ——拿它决定「谁每轮在场」,等于让「查得多的」挤掉「钉得牢的」,而且一次 `memory_search`
74
+ * 会当场改变下一轮的注入内容(`markUsed` 就在检索路径上)。排序只该由内容本身的变更驱动。
41
75
  */
42
76
  export function renderInjection(records, budget, summaryChars) {
43
- const ordered = [...records].sort((left, right) => (right.lastUsedAt ?? right.updatedAt) - (left.lastUsedAt ?? left.updatedAt));
77
+ const ordered = [...records].sort((left, right) => right.updatedAt - left.updatedAt);
44
78
  const lines = [];
45
79
  const omittedRecords = [];
46
80
  let chars = 0;
@@ -77,6 +111,40 @@ export function omittedNotice(rendered) {
77
111
  const truncated = rendered.omittedRecords.length > shown.length ? '…' : '';
78
112
  return `(另有 ${rendered.omitted} 条常驻因预算未注入:${labels.join('、')}${truncated})`;
79
113
  }
114
+ /** 注入侧报「新失效」的条数上限——在稀有与有用之间取舍,只报几条就够指向问题。 */
115
+ export const STALE_NOTICE_LIMIT = 3;
116
+ /** 候选提示列出的条数上限。 */
117
+ export const CANDIDATE_NOTICE_LIMIT = 3;
118
+ /** 候选提示里每条摘要的长度:比注入行更短,这一行本身不该占太多。 */
119
+ export const CANDIDATE_SUMMARY_CHARS = 24;
120
+ /**
121
+ * 新失效提示行(0.12.0):报「本次启动校验发现几条记忆失效」。
122
+ *
123
+ * **它稀有,所以不构成噪音**:一条记忆失效意味着「照它行动会出错」,值得每轮看见,直到被
124
+ * 核对回写或删除。与 {@link omittedNotice} 一样**不占注入预算**(调用方追加在注入行之后)。
125
+ */
126
+ export function staleNotice(stale) {
127
+ if (stale.length === 0)
128
+ return '';
129
+ const shown = stale.slice(0, STALE_NOTICE_LIMIT);
130
+ const truncated = stale.length > shown.length ? '…' : '';
131
+ return `(本次校验发现 ${stale.length} 条记忆失效:${shown.map(item => item.summary).join('、')}${truncated})`;
132
+ }
133
+ /**
134
+ * 候选提示行(0.12.0):报「有几条被反复检索命中、但没有被钉成常驻」。
135
+ *
136
+ * **判断者是模型自己**——一份不会主动去调的体检报告等于不存在,所以候选必须自己走到它眼前。
137
+ * `hitCount` 只增不减,候选会一直留着直到被消费(钉住或删掉):这不是缺陷,是提示该有的
138
+ * 持续性。**不占注入预算**。
139
+ */
140
+ export function candidateNotice(candidates) {
141
+ if (candidates.length === 0)
142
+ return '';
143
+ const shown = candidates.slice(0, CANDIDATE_NOTICE_LIMIT);
144
+ const labels = shown.map(record => deriveSummary(record.content, CANDIDATE_SUMMARY_CHARS));
145
+ const truncated = candidates.length > shown.length ? '…' : '';
146
+ return `(另有 ${candidates.length} 条被反复检索但未常驻:${labels.join('、')}${truncated})`;
147
+ }
80
148
  /** 子项目索引行列出的条数上限(④-B):超出以 `…` 结尾,防这行本身失控。 */
81
149
  export const NEIGHBOR_NOTICE_LIMIT = 5;
82
150
  /**
package/dist/self.js CHANGED
@@ -5,6 +5,8 @@
5
5
  * 0.9.2:补齐常驻注入机制——`injected` 参数、注入预算上限、预算诊断行的含义。
6
6
  * 0.10.0:修正工具清单(6 → 10,补提示词模板工具)与两层记忆说明——原清单漏了
7
7
  * `prompt_*` 系列,照它读会以为本插件只有记忆工具。
8
+ * 0.12.0:工具清单 10 → 11(补 `memory_sweep`);删掉「命中累计 2 次自动开启常驻」
9
+ * (该规则已取消,命中只喂候选提示);注入取舍依据由「最近使用」改为「最近更新」。
8
10
  */
9
11
  import { createRequire } from 'node:module';
10
12
  export const SELF_VERSION = (() => {
@@ -19,7 +21,7 @@ export const SELF_VERSION = (() => {
19
21
  }
20
22
  })();
21
23
  export const SELF_DESCRIPTION = `[记忆系统自述] 本环境内置跨会话记忆服务(dsh-memory v${SELF_VERSION})。`
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 文件、**永不注入**,走这条通道,不会混进记忆检索)。'
24
+ + '你有 11 个工具:**记忆工具** memory_save / memory_search / memory_list / memory_update / memory_confirm / memory_forget / memory_sweep(`memory_confirm` 仅在用户明确要求放行隔离记录时用;`memory_sweep` 是只读体检——失效清单、久未命中、候选与预算占用,不修改任何状态);**提示词模板工具** prompt_search / prompt_get / prompt_list / prompt_add(模板是 md 文件、**永不注入**,走这条通道,不会混进记忆检索)。'
23
25
  + '记忆分两层:`global` 跨项目(偏好 / 习惯 / 环境知识),`project` 跟随工作区(落在 `<cwd>/.dsh/storages/`,随 git 提交分享)。'
24
26
  + '**何时写入**(不必刻意找机会,遇到下面这些就动手):'
25
27
  + '① 用户表达长期偏好或约定(「以后都…」「记住…」「我们的规矩是…」);'
@@ -30,9 +32,9 @@ export const SELF_DESCRIPTION = `[记忆系统自述] 本环境内置跨会话
30
32
  + '**锚点(可选)**:若这条记忆描述的是「随某个环境值变化的事实」(某工具版本下的行为、某环境变量决定的配置),用 anchor 参数把它绑到那个值上——值变了它会自动失效并标 stale,不会再被当成仍然正确。'
31
33
  + '写入即生效(`approved`),人工只在例外时介入;命中密钥/凭据规则的写入会被隔离(不进注入与检索)。'
32
34
  + '**常驻注入**:每轮注入「global + 当前会话工作区」里 approved 且开着常驻开关的记忆单行摘要(形如 `- [memory:id:namespace] 摘要`);全文要靠 memory_search 取。'
33
- + '开关有两条自动规则:命中累计 2 次自动开启、30 天未命中自动撤下;**给 memory_save / memory_update 传 `injected` 即显式接管**,此后这两条都不再适用。'
35
+ + '开关只有一条自动规则:**自动升上来的**常驻在 30 天未命中后撤下;命中次数不再自动改变注入状态(它只喂体检报告的候选提示)。**给 memory_save / memory_update 传 `injected` 即显式接管**,此后这条规则也不再适用。'
34
36
  + '**钉的判据是「失效可察觉性」**:这条记忆若没被想起来,你会不会根本意识不到自己漏了它?会 → 钉常驻(规则 / 委托 / 判据);不会 → 留检索(事实 / 参考 / 案例)。'
35
- + '**注入有预算**(默认 1500 字符,约装 11 条),装不下的整条丢弃、按最近使用优先取舍——所以「钉了常驻」只等于「有资格排队」。'
37
+ + '**注入有预算**(默认 1500 字符,约装 11 条),装不下的整条丢弃、按**最近更新**优先取舍(不是最近被检索——检索是「需要时才用」的行为,拿它决定「谁每轮在场」会让查得多的挤掉钉得牢的)——所以「钉了常驻」只等于「有资格排队」。'
36
38
  + '装不下时注入末尾会出现一行 `(另有 N 条常驻因预算未注入:…短摘要…)`——**那是「有常驻记忆并不在场」的唯一信号**,看到它就该逐条评估撤下。'
37
39
  + '发现某条记忆与实际不符时用 memory_update 修正(保持生效);确认没用的用 memory_forget。'
38
40
  + '(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.11.0",
3
+ "version": "0.12.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": {
@@ -26,7 +26,8 @@
26
26
  "docs/shots",
27
27
  "scripts/scan-orphans.mjs",
28
28
  "scripts/unzstd-frames.mjs",
29
- "scripts/ancestor-probe.mjs"
29
+ "scripts/ancestor-probe.mjs",
30
+ "scripts/analyze-cooccurrence.mjs"
30
31
  ],
31
32
  "dsh": {
32
33
  "bundle": {
@@ -58,6 +59,7 @@
58
59
  "build": "tsc -p tsconfig.json && node build-client.mjs",
59
60
  "typecheck": "tsc --noEmit -p tsconfig.json",
60
61
  "test": "vitest run",
62
+ "mutate:check": "node scripts/mutate-check.mjs",
61
63
  "prepublishOnly": "npm run build"
62
64
  },
63
65
  "dependencies": {
@@ -0,0 +1,209 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 关键词共现分析——回答「记忆之间有没有关系网」。
4
+ *
5
+ * **为什么需要它**:记忆系统有两个方向可选——「报规模与主题」(我们现在的做法)与
6
+ * 「给记忆建关系」(图 / 相关推荐)。选哪个不该靠直觉,而该看数据里到底有没有关系。
7
+ * 本脚本就是量这件事的:把两条记忆共享的关键词个数当关联强度,统计多少对有关联、
8
+ * 它们连成几个簇、多少条是孤立点。
9
+ *
10
+ * **能回答什么**:
11
+ * - 孤立点占比——**关系维度值不值得做**的第一判据。孤立率高(比如 >50%)说明记忆
12
+ * 本质是「一条条独立事实」,图与相关推荐都没有数据支撑。
13
+ * - 簇的规模与主题——若确实成簇,簇内高频词就是「成片的主题」,比全局词频 top-N 更聚焦。
14
+ * - 阈值敏感度——共享 1 个词 vs 2 个词的对数比,决定「关联」的门槛该定在哪。
15
+ *
16
+ * **判据怎么算**:
17
+ * - 只统计 approved、非隔离、非模板(`kind !== 'prompt'`)的记录——与 `indexNotice` /
18
+ * `recallRecords` 的候选口径一致,否则会把不该参与的东西算进来。
19
+ * - 关联门槛默认「共享 ≥2 个关键词」。**不要降到 1**:实测共享 1 个的有 273 对、
20
+ * 共享 ≥2 的只有 38 对(7 倍差),降到 1 等于把「都含 dsh」也算成关联。
21
+ * - 连通分量用洪水填充;**孤立点 = 自己成一个分量的记录**。
22
+ *
23
+ * **哪些情况答不了**:
24
+ * - 只做**词面**共现,不理解语义:「pnpm」与「包管理器」不共享词就是孤立点,哪怕它们
25
+ * 讲的是同一件事。跨过这一层需要语义检索(见 `docs/决策/2026-09-18-语义检索通道启用判据.md`)。
26
+ * - 结论只在「关键词是多角度的」这个前提下成立:若写入时关键词给得敷衍(比如都只给一个
27
+ * 项目名),脚本会报出虚高的关联。
28
+ * - **只读**:不写任何存储文件,可随时重跑。
29
+ *
30
+ * 用法:
31
+ * node scripts/analyze-cooccurrence.mjs [--workspace <路径>] [--min-shared N] [--top N] [--help]
32
+ *
33
+ * 退出码:0 = 正常;2 = 用法错误。
34
+ */
35
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
36
+ import { homedir } from 'node:os'
37
+ import { dirname, join } from 'node:path'
38
+
39
+ const USAGE = `关键词共现分析——回答「记忆之间有没有关系网」。
40
+
41
+ 用法:
42
+ node scripts/analyze-cooccurrence.mjs [--workspace <路径>] [--min-shared N] [--top N] [--help]
43
+
44
+ --workspace P 工作区根(默认从 cwd 起向上找第一个含 .dsh/storages 的目录)
45
+ --min-shared N 关联门槛:共享几个关键词才算有关联(默认 2,不建议降到 1)
46
+ --top N 展示最大的几个簇(默认 5)
47
+
48
+ 只读,不写任何文件。
49
+ 退出码:0 = 正常;2 = 用法错误。`
50
+
51
+ const argv = process.argv.slice(2)
52
+ if (argv.includes('--help') || argv.includes('-h')) {
53
+ console.log(USAGE)
54
+ process.exit(0)
55
+ }
56
+ const readString = (name) => {
57
+ const index = argv.indexOf(name)
58
+ return index === -1 ? undefined : argv[index + 1]
59
+ }
60
+ const readNumber = (name, fallback) => {
61
+ const raw = readString(name)
62
+ if (raw === undefined) return fallback
63
+ const value = Number(raw)
64
+ if (!Number.isInteger(value) || value < 0) {
65
+ console.error(`${name} 需要一个非负整数,收到:${raw}`)
66
+ process.exit(2)
67
+ }
68
+ return value
69
+ }
70
+ const MIN_SHARED = readNumber('--min-shared', 2)
71
+ const TOP = readNumber('--top', 5)
72
+
73
+ /** 从 cwd 起向上找第一个含 `.dsh/storages` 的目录——工作区根通常就是它。 */
74
+ function findWorkspace(start) {
75
+ let current = start
76
+ for (;;) {
77
+ if (existsSync(join(current, '.dsh', 'storages'))) return current
78
+ const parent = dirname(current)
79
+ if (parent === current) return start
80
+ current = parent
81
+ }
82
+ }
83
+
84
+ const workspace = readString('--workspace') ?? findWorkspace(process.cwd())
85
+ const dshHome = process.env.DSH_HOME ?? join(homedir(), '.dsh')
86
+
87
+ /**
88
+ * 两个存储:`project` 在工作区下(随 git 分享),`global` 在 DSH_HOME 下。
89
+ * project 的文件名带 cwd 哈希后缀,所以扫目录而不是拼固定名;双重前缀的历史残骸
90
+ * (`memory_project_memory_project_*`)不是当前代码认得的名字,排除掉。
91
+ */
92
+ const sources = []
93
+ const projectStorages = join(workspace, '.dsh', 'storages')
94
+ let projectNames = []
95
+ try {
96
+ projectNames = readdirSync(projectStorages)
97
+ } catch {
98
+ projectNames = [] // 工作区没有 project 存储:只分析 global
99
+ }
100
+ for (const name of projectNames) {
101
+ if (!name.startsWith('memory_project') || !name.endsWith('.json')) continue
102
+ if (name.startsWith('memory_project_memory_project')) continue
103
+ sources.push(['project', join(projectStorages, name)])
104
+ }
105
+ sources.push(['global', join(dshHome, 'storages', 'memory.json')])
106
+
107
+ console.log(`工作区:${workspace}`)
108
+ console.log(`DSH_HOME:${dshHome}\n`)
109
+
110
+ const records = []
111
+ for (const [namespace, file] of sources) {
112
+ let doc
113
+ try {
114
+ doc = JSON.parse(readFileSync(file, 'utf8'))
115
+ } catch {
116
+ continue // 存储不存在或读不动:跳过,不报错
117
+ }
118
+ for (const [id, block] of Object.entries(doc.tables?.blocks ?? {})) {
119
+ if (block === null || typeof block !== 'object' || typeof block.content !== 'string') continue
120
+ if (block.quarantined === true) continue
121
+ if ((block.status ?? 'approved') !== 'approved') continue
122
+ if ((block.kind ?? 'fact') === 'prompt') continue
123
+ records.push({
124
+ id: String(id),
125
+ namespace,
126
+ keywords: new Set((block.keywords ?? []).map(keyword => String(keyword).toLowerCase())),
127
+ title: block.content.split('\n')[0].slice(0, 46),
128
+ })
129
+ }
130
+ }
131
+
132
+ if (records.length === 0) {
133
+ console.log('没有可分析的记忆(未找到 project / global 存储,或库里没有 approved 记录)。')
134
+ process.exit(0)
135
+ }
136
+
137
+ console.log(`候选记忆:${records.length} 条(approved、非隔离、非模板)`)
138
+ const sizes = records.map(record => record.keywords.size)
139
+ console.log(`关键词密度:平均 ${(sizes.reduce((a, b) => a + b, 0) / sizes.length).toFixed(1)} / 最少 ${Math.min(...sizes)} / 最多 ${Math.max(...sizes)}`)
140
+ console.log(`关联门槛:共享 ≥${MIN_SHARED} 个关键词\n`)
141
+
142
+ const histogram = new Map()
143
+ const adjacency = new Map()
144
+ const link = (a, b) => {
145
+ if (!adjacency.has(a)) adjacency.set(a, new Set())
146
+ if (!adjacency.has(b)) adjacency.set(b, new Set())
147
+ adjacency.get(a).add(b)
148
+ adjacency.get(b).add(a)
149
+ }
150
+ for (let i = 0; i < records.length; i += 1) {
151
+ for (let j = i + 1; j < records.length; j += 1) {
152
+ let shared = 0
153
+ for (const keyword of records[i].keywords) if (records[j].keywords.has(keyword)) shared += 1
154
+ if (shared === 0) continue
155
+ histogram.set(shared, (histogram.get(shared) ?? 0) + 1)
156
+ if (shared >= MIN_SHARED) link(records[i].id, records[j].id)
157
+ }
158
+ }
159
+
160
+ console.log('共享关键词个数的分布(对数):')
161
+ for (const [shared, count] of [...histogram.entries()].sort((a, b) => a[0] - b[0])) {
162
+ const noise = shared === 1 ? ' ← 噪声级,通常不计入关联' : ''
163
+ console.log(` ${String(shared).padStart(2)} 个 → ${String(count).padStart(4)} 对${noise}`)
164
+ }
165
+
166
+ const seen = new Set()
167
+ const clusters = []
168
+ for (const record of records) {
169
+ if (seen.has(record.id)) continue
170
+ const stack = [record.id]
171
+ const component = []
172
+ seen.add(record.id)
173
+ while (stack.length > 0) {
174
+ const current = stack.pop()
175
+ component.push(current)
176
+ for (const neighbor of adjacency.get(current) ?? []) {
177
+ if (!seen.has(neighbor)) {
178
+ seen.add(neighbor)
179
+ stack.push(neighbor)
180
+ }
181
+ }
182
+ }
183
+ clusters.push(component)
184
+ }
185
+ clusters.sort((a, b) => b.length - a.length)
186
+ const isolated = clusters.filter(cluster => cluster.length === 1).length
187
+
188
+ console.log(`\n连通分量:${clusters.length} 个`)
189
+ console.log(` 规模:${clusters.map(cluster => cluster.length).slice(0, 12).join(' / ')}${clusters.length > 12 ? ' …' : ''}`)
190
+ console.log(` 孤立点:${isolated} 个(${((isolated / records.length) * 100).toFixed(0)}%) | 参与成簇:${records.length - isolated} 个`)
191
+
192
+ const byId = new Map(records.map(record => [record.id, record]))
193
+ console.log(`\n最大的 ${TOP} 个簇(簇内高频词 = 成片的主题):`)
194
+ let shown = 0
195
+ for (const component of clusters) {
196
+ if (component.length === 1 || shown >= TOP) break
197
+ shown += 1
198
+ const frequency = new Map()
199
+ for (const id of component) {
200
+ for (const keyword of byId.get(id).keywords) frequency.set(keyword, (frequency.get(keyword) ?? 0) + 1)
201
+ }
202
+ const top = [...frequency.entries()]
203
+ .filter(([, count]) => count >= 2)
204
+ .sort((a, b) => b[1] - a[1])
205
+ .slice(0, 6)
206
+ console.log(`\n ● ${component.length} 条:${top.map(([keyword, count]) => `${keyword}(${count})`).join('、') || '(无重复词)'}`)
207
+ for (const id of component.slice(0, 5)) console.log(` · ${byId.get(id).title}`)
208
+ if (component.length > 5) console.log(` … 另 ${component.length - 5} 条`)
209
+ }