@max-null/dsh-memory 0.12.2 → 0.14.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
@@ -151,8 +151,13 @@ 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
153
  node scripts/analyze-cooccurrence.mjs # 关键词共现分析(记忆之间有没有关系网,只读)
154
+ npm run check:memory # 记忆文件存量体检(只读;0 = 全绿 / 1 = 有不合规 / 2 = 用法错)
154
155
  ```
155
156
 
156
157
  ## 依赖(peerDependencies,由宿主提供)
157
158
 
158
- `@deepseek-ai/cordis`、`@deepseek-ai/dsh-storage`、`@deepseek-ai/dsh-storage-domain`、`@deepseek-ai/dsh-storage-json`、`@deepseek-ai/dsh-system-prompt`、`@deepseek-ai/dsh-tools`
159
+ `@deepseek-ai/cordis`(`^4.0.1`),以及 `@deepseek-ai/dsh-storage`、`@deepseek-ai/dsh-storage-domain`、`@deepseek-ai/dsh-storage-json`、`@deepseek-ai/dsh-system-prompt`、`@deepseek-ai/dsh-tools`、`@deepseek-ai/dsh-typert-protocol`——后面六个声明为 **`>=0.1.1-rc.1 <0.3.0`**。
160
+
161
+ **为什么写显式范围而不是 caret**:caret 的上界是「下一个 minor」——`^0.1.1-rc.1` 展开为 `>=0.1.1-rc.1 <0.2.0-0`,内核一跨 minor 就判定不满足。而 peer 不满足时插件**不会被加载,也不会出现在 `did not activate` 列表里**:它在进入 fiber 图之前就被跳过,界面不报错,只表现为少一批功能。写成显式范围让 0.1 线与 0.2 线都能装载。
162
+
163
+ **验证过的内核**:`0.1.1-rc.2`(原基线)与 `0.2.0-rc.1`(0.12.2 起——typecheck 用其类型定义零错误,并在隔离实例实机加载,三个失败信号全为 0)。下限保持原值不变:它是作者实际验证过的最早版本,比它更早的内核没验证过,不声称支持。
package/dist/anchors.d.ts CHANGED
@@ -7,8 +7,19 @@
7
7
  * 只有一次 `existsSync`,与读环境变量同量级。此前这里写的是「探测必须零 IO」,那句话把
8
8
  * 「禁止 spawn」误写成了「禁止一切系统调用」——0.12.0 加 `path-exists` 时改正。
9
9
  */
10
- /** 受控词汇表:写入时只能从这四种里选,不能自创(自创的锚点无法机械校验)。 */
11
- export type AnchorKind = 'env' | 'tool-list' | 'self-version' | 'path-exists';
10
+ /**
11
+ * 受控词汇表:写入时只能从这几种里选,不能自创(自创的锚点无法机械校验)。
12
+ *
13
+ * **这是全仓库唯一的一份定义。** 工具 schema 的 `enum`、存储 zod 的 `z.enum`、以及
14
+ * `AnchorKind` 类型全部从它派生——2026-09-25 那次「154 条 project 记忆全部失联」的根因
15
+ * 就是同一份集合写了两处、加 `path-exists` 时漏了第三处(`engine.ts` 的 zod 仍只有 3 个
16
+ * kind,写入放行、加载才炸)。三份副本各自维护,漏改是**结构性必然**,不是疏忽。
17
+ *
18
+ * @see docs/设计/2026-09-25-写入兜底与失败可见化-设计方案.md §四.4
19
+ */
20
+ export declare const ANCHOR_KINDS: readonly ["env", "tool-list", "self-version", "path-exists"];
21
+ /** 受控词汇表推导出的类型——别再手写一遍这个联合。 */
22
+ export type AnchorKind = (typeof ANCHOR_KINDS)[number];
12
23
  /** 一条记忆声明的锚点。 */
13
24
  export interface MemoryAnchor {
14
25
  kind: AnchorKind;
package/dist/anchors.js CHANGED
@@ -9,6 +9,17 @@
9
9
  */
10
10
  import { existsSync } from 'node:fs';
11
11
  import { isAbsolute, resolve } from 'node:path';
12
+ /**
13
+ * 受控词汇表:写入时只能从这几种里选,不能自创(自创的锚点无法机械校验)。
14
+ *
15
+ * **这是全仓库唯一的一份定义。** 工具 schema 的 `enum`、存储 zod 的 `z.enum`、以及
16
+ * `AnchorKind` 类型全部从它派生——2026-09-25 那次「154 条 project 记忆全部失联」的根因
17
+ * 就是同一份集合写了两处、加 `path-exists` 时漏了第三处(`engine.ts` 的 zod 仍只有 3 个
18
+ * kind,写入放行、加载才炸)。三份副本各自维护,漏改是**结构性必然**,不是疏忽。
19
+ *
20
+ * @see docs/设计/2026-09-25-写入兜底与失败可见化-设计方案.md §四.4
21
+ */
22
+ export const ANCHOR_KINDS = ['env', 'tool-list', 'self-version', 'path-exists'];
12
23
  /**
13
24
  * 探测某个锚点当前的取值。
14
25
  *
@@ -40,8 +51,14 @@ export function probeAnchor(anchor, probes) {
40
51
  const target = relative ? resolve(probes.workspaceCwd, anchor.name) : anchor.name;
41
52
  return existsSync(target) ? 'present' : 'absent';
42
53
  }
43
- default:
54
+ default: {
55
+ // 编译期穷尽性:`ANCHOR_KINDS` 新增取值而这里忘了加 case 时,下面这行会报错。
56
+ // 运行期仍然返回 `undefined`(「未校验」)而不是抛错——数据里可能躺着旧版本写下的
57
+ // 未知 kind,探测路径不该因为一条坏记录把整个启动校验带崩。
58
+ const unhandled = anchor.kind;
59
+ void unhandled;
44
60
  return undefined;
61
+ }
45
62
  }
46
63
  }
47
64
  /**
package/dist/engine.d.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * is always created `suggested` and becomes effective only through `setStatus`.
6
6
  */
7
7
  import { Service } from '@deepseek-ai/cordis';
8
+ import { z } from 'zod';
8
9
  import { type PromptFile } from './prompt-files.ts';
9
10
  import type { AnchorProbes, MemoryAnchor } from './anchors.ts';
10
11
  import type { MemorySource, SourceStat } from './query-log.ts';
@@ -205,6 +206,24 @@ export interface SweepItem {
205
206
  id: string;
206
207
  summary: string;
207
208
  }
209
+ /** 一条「自动降级若生效就会被撤下」的常驻(2026-09-29 起只计量、不生效)。 */
210
+ export interface AutoDemoteCandidate extends SweepItem {
211
+ /** 距上次命中(从未命中则回退最后更新)的天数——这是定阈值时要看的那个分布。 */
212
+ idleDays: number;
213
+ }
214
+ /**
215
+ * 自动降级的判据(纯函数,2026-09-29 抽出):只针对**自动升上来的**常驻注入——人工
216
+ * 开的开关不会被时间悄悄关掉(那是人的决定,不是信号的结论)。
217
+ *
218
+ * 抽成纯函数是因为它有两个调用方,而两者对「能不能写」的要求相反:`demoteStale` 在写
219
+ * 路径上(`writeScope` 之内),而 `sweep` 承诺只读(连时间戳都不写)。判据各写一份迟早
220
+ * 漂移成「体检说该降、降级说不用降」这类两套口径——所以只留这一份。
221
+ *
222
+ * @param record - 参与判定的记录。
223
+ * @param threshold - 闲置多久算候选(毫秒时间戳,早于它才算)。
224
+ * @returns 该记录是否属于本会被撤下的常驻。
225
+ */
226
+ export declare function isAutoDemoteCandidate(record: MemoryRecord, threshold: number): boolean;
208
227
  /**
209
228
  * 只读体检的产出({@link MemoryEngine.sweep})。四类各有明确的消费者,不做「算了没人读」的采样
210
229
  * ——候选与新失效会进注入诊断行(模型每轮可见),久未命中与全量留给按需调用。
@@ -222,6 +241,14 @@ export interface SweepReport {
222
241
  candidates: Array<SweepItem & {
223
242
  hitCount: number;
224
243
  }>;
244
+ /**
245
+ * **自动降级若生效**会被撤下的常驻(2026-09-29 起只计量不生效)。
246
+ *
247
+ * 与 `longUnused` 正好互补:那个查的是「非常驻且久未命中」(可清理),这个查的是
248
+ * 「自动升上来的常驻且久未命中」(会被时间规则撤下)。留着它是因为阈值 `AUTO_DEMOTE_DAYS`
249
+ * 至今没有分布依据——等这张清单积累出「实际闲置多久」的形状,才有得可定。
250
+ */
251
+ autoDemoteCandidates: AutoDemoteCandidate[];
225
252
  /** 常驻与注入预算的占用:总数 / 实际进注入 / 预算(无限制时不出现)/ 被挤掉几条。 */
226
253
  resident: {
227
254
  total: number;
@@ -229,6 +256,21 @@ export interface SweepReport {
229
256
  budget: number | undefined;
230
257
  omitted: number;
231
258
  };
259
+ /**
260
+ * 数据完整性声明(门二,2026-09-25):这次体检**实际读到了哪些存储**。
261
+ *
262
+ * 加它是因为「读不到就说读不到,比读到了但少一半更有价值」:project 侧打不开时,前面
263
+ * 几项会给出一个**看起来正常但偏小**的数字,而没有任何信号说明它少了什么。宁可报告
264
+ * 显式写「未纳入」,也不要让人对着一个残缺的全景做判断。
265
+ *
266
+ * - `included`:project 侧已纳入;
267
+ * - `absent`:本次没有工作区上下文(`projectCwd` 为空),不是故障;
268
+ * - `unreadable`:**尝试过但打不开**——此时 `projectError` 给出原因,前几项的数字只覆盖 global。
269
+ */
270
+ coverage: {
271
+ project: 'included' | 'absent' | 'unreadable';
272
+ projectError?: string;
273
+ };
232
274
  }
233
275
  declare module '@deepseek-ai/cordis' {
234
276
  interface Context {
@@ -239,6 +281,69 @@ declare module '@deepseek-ai/cordis' {
239
281
  'memory/changed'(change: MemoryChange): void;
240
282
  }
241
283
  }
284
+ /**
285
+ * 存储块的 schema(门三的判据来源,2026-09-25)。
286
+ *
287
+ * **导出是为了让离线扫描器(`scripts/check-memory-files.mjs`)用它而不是自己再写一套**——
288
+ * 两份判据会漂移,而漂移的方向恰好是「扫描器说没事、打开时炸」这种最难查的形态。
289
+ */
290
+ export declare const blockSchema: z.ZodObject<{
291
+ namespace: z.ZodEnum<{
292
+ global: "global";
293
+ project: "project";
294
+ }>;
295
+ status: z.ZodEnum<{
296
+ suggested: "suggested";
297
+ approved: "approved";
298
+ auto: "auto";
299
+ suggest: "suggest";
300
+ }>;
301
+ injected: z.ZodOptional<z.ZodBoolean>;
302
+ quarantined: z.ZodOptional<z.ZodBoolean>;
303
+ quarantineReason: z.ZodOptional<z.ZodString>;
304
+ hitCount: z.ZodOptional<z.ZodNumber>;
305
+ source: z.ZodOptional<z.ZodEnum<{
306
+ agent: "agent";
307
+ human: "human";
308
+ }>>;
309
+ injectedAuto: z.ZodOptional<z.ZodBoolean>;
310
+ anchor: z.ZodOptional<z.ZodObject<{
311
+ kind: z.ZodEnum<{
312
+ env: "env";
313
+ "tool-list": "tool-list";
314
+ "self-version": "self-version";
315
+ "path-exists": "path-exists";
316
+ }>;
317
+ name: z.ZodOptional<z.ZodString>;
318
+ value: z.ZodString;
319
+ }, z.core.$strip>>;
320
+ stale: z.ZodOptional<z.ZodBoolean>;
321
+ staleReason: z.ZodOptional<z.ZodString>;
322
+ content: z.ZodString;
323
+ keywords: z.ZodArray<z.ZodString>;
324
+ createdAt: z.ZodNumber;
325
+ updatedAt: z.ZodNumber;
326
+ lastUsedAt: z.ZodOptional<z.ZodNumber>;
327
+ vector: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
328
+ kind: z.ZodOptional<z.ZodEnum<{
329
+ fact: "fact";
330
+ prompt: "prompt";
331
+ }>>;
332
+ meta: z.ZodOptional<z.ZodObject<{
333
+ seq: z.ZodOptional<z.ZodNumber>;
334
+ name: z.ZodString;
335
+ dimension: z.ZodOptional<z.ZodString>;
336
+ difficulty: z.ZodOptional<z.ZodString>;
337
+ tags: z.ZodArray<z.ZodString>;
338
+ summary: z.ZodString;
339
+ path: z.ZodString;
340
+ mtime: z.ZodNumber;
341
+ source: z.ZodEnum<{
342
+ user: "user";
343
+ agent: "agent";
344
+ }>;
345
+ }, z.core.$strip>>;
346
+ }, z.core.$strip>;
242
347
  /**
243
348
  * 嵌入能力(0.5.2 混合检索):缺省不配置 = 纯 BM25(行为不变);
244
349
  * 配置后 memory_search 以 BM25 与语义结果做 RRF 融合,向量持久化到
@@ -296,6 +401,12 @@ export declare class MemoryEngine extends Service {
296
401
  private readonly neighborOpenings;
297
402
  /** 进行中的工作区打开(key → promise):并发调用复用同一次,见 {@link projectTableFor}。 */
298
403
  private readonly projectOpenings;
404
+ /**
405
+ * 打不开的工作区表(门二,2026-09-25):key = 规范化 cwd,值 = 失败描述。
406
+ *
407
+ * 成功的打开会清掉对应项——「现在好没好」比「曾经坏过」更有用。见 {@link projectTableFor}。
408
+ */
409
+ private readonly projectOpenErrors;
299
410
  private projectFacilities;
300
411
  /** backend 只注册一次(registry 重名抛 duplicate;reload 清缓存后不得重复注册)。 */
301
412
  private registeredProjectBackends;
@@ -449,6 +560,28 @@ export declare class MemoryEngine extends Service {
449
560
  * 那时同步缓存已被清空(同 {@link markUsedInner} 的注释)。
450
561
  */
451
562
  private locateRecord;
563
+ /**
564
+ * **唯一**允许写块的地方(门一,2026-09-25):写前跑一次存储 schema,不合格就抛。
565
+ *
566
+ * ⚠️ `KvTable` 上有**三个**写方法(`put` / `update` / `delete`),这道门只守 `put`。
567
+ * 截至 0.14.0 全仓只用了 `put` 与 `delete`——`delete` 不需要校验(**删掉一条不合规的
568
+ * 记录正是修复手段**),而 `update`(域内原子读改写,绕过本方法)**尚未被使用**。
569
+ * 将来若要用 `update`,它同样必须过 schema——否则门一就漏了一半。
570
+ *
571
+ * 为什么要有这道门:写入路径此前**完全不校验**,而不合规的记录会让整个 domain 在
572
+ * **下次打开时**打不开——`dsh-storage-domain` 逐条校验,一条不合格即整体失败。于是故障
573
+ * 被推迟到重启之后,表现为「整个工作区的记忆消失」:2026-09-25 那次 154 条 project 记忆
574
+ * 全部不可见(数据一条没丢,丢的是可读性),链条正是**写入放行 → 加载才炸 → 失败还被吞**。
575
+ * 三层里任何一层立住都不会发生,这一道补的是第一层。
576
+ *
577
+ * 失败**抛错**而不是静默降级:降级只是把「写入放行」的坑换到别处,且调用方看不到原因。
578
+ * 错误里带上 zod 的 issue(哪个字段、什么值、期望什么)。
579
+ *
580
+ * @param table - 目标表(global 或某个工作区)。
581
+ * @param id - 记录 id。
582
+ * @param block - 待写入的块;校验通过后写回 `parsed.data`,保证 strip 与默认值行为与读路径一致。
583
+ */
584
+ private putBlock;
452
585
  /**
453
586
  * 写入协调覆盖的存储文件:global + **全部已打开的工作区表** + 本次 cwd。
454
587
  *
@@ -570,9 +703,24 @@ export declare class MemoryEngine extends Service {
570
703
  * @param now - 当前时间(测试注入用)。
571
704
  * @returns 被撤下的条数(诊断与日志用)。
572
705
  */
573
- demoteStale(projectCwd?: string, now?: number): Promise<number>;
574
- /** {@link demoteStale} 的实现体;必须经 `writeScope` 进入。 */
575
- private demoteStaleInner;
706
+ /**
707
+ * 自动降级的**计量**(2026-09-29 起不再生效):列出「若判据生效会被撤下」的常驻,
708
+ * **不修改任何状态**。
709
+ *
710
+ * 为什么停掉撤销:那个阈值(`AUTO_DEMOTE_DAYS`)至今没有分布依据——它是 2026-09-15
711
+ * 从面板的折叠阈值 `COLD_DAYS` 借来的。两者的代价不对等:展示阈值折错了点一下就能
712
+ * 展开,而行为阈值一撤,0.12.0 取消了自动升之后基本不会自己回来。按「没有真实分布时
713
+ * 拍任何数字都是误杀面」(借自 dsh-mneme 的写入准入阶段一),在拿到分布之前不该继续
714
+ * 用这个数字动真格的。
715
+ *
716
+ * 候选由 {@link sweep} 的 `autoDemoteCandidates` 报出,积累出「实际闲置多久」的形状
717
+ * 之后再定阈值。判据是 `isAutoDemoteCandidate`——与体检共用一份,不各写一份。
718
+ *
719
+ * @param projectCwd - 会话工作区;给了就连工作区表一起扫。
720
+ * @param now - 当前时间(测试注入用)。
721
+ * @returns 本会被撤下的常驻清单,按闲置天数降序。
722
+ */
723
+ demoteStale(projectCwd?: string, now?: number): Promise<AutoDemoteCandidate[]>;
576
724
  /**
577
725
  * 锚点批量校验(2026-09-15):会话启动时调一次,把失效的条目标成 `stale`。
578
726
  *
package/dist/engine.js CHANGED
@@ -17,7 +17,7 @@ import { bm25FieldScores, cosineSimilarity, rrfFuse } from "./bm25.js";
17
17
  import { parsePromptFile, scanPromptDir, writePromptFile } from "./prompt-files.js";
18
18
  import { detectSensitive } from "./quarantine.js";
19
19
  import { SELF_VERSION } from "./self.js";
20
- import { anchorHolds } from "./anchors.js";
20
+ import { ANCHOR_KINDS, anchorHolds } from "./anchors.js";
21
21
  import { DEFAULT_INJECTION_BUDGET, DEFAULT_SUMMARY_CHARS, deriveSummary, renderInjection, } from "./injection.js";
22
22
  import { QueryLog, rankGapCandidates, summarizeQueryLog, summarizeSources } from "./query-log.js";
23
23
  /** Brand a string as a {@link MemoryId} (compile-time only). */
@@ -54,7 +54,34 @@ const AUTO_DEMOTE_DAYS = 30;
54
54
  * 会把正常记录报成待清理。
55
55
  */
56
56
  const DEFAULT_SWEEP_DAYS = 90;
57
- const blockSchema = z.object({
57
+ /**
58
+ * 自动降级的判据(纯函数,2026-09-29 抽出):只针对**自动升上来的**常驻注入——人工
59
+ * 开的开关不会被时间悄悄关掉(那是人的决定,不是信号的结论)。
60
+ *
61
+ * 抽成纯函数是因为它有两个调用方,而两者对「能不能写」的要求相反:`demoteStale` 在写
62
+ * 路径上(`writeScope` 之内),而 `sweep` 承诺只读(连时间戳都不写)。判据各写一份迟早
63
+ * 漂移成「体检说该降、降级说不用降」这类两套口径——所以只留这一份。
64
+ *
65
+ * @param record - 参与判定的记录。
66
+ * @param threshold - 闲置多久算候选(毫秒时间戳,早于它才算)。
67
+ * @returns 该记录是否属于本会被撤下的常驻。
68
+ */
69
+ export function isAutoDemoteCandidate(record, threshold) {
70
+ if (record.injectedAuto !== true)
71
+ return false;
72
+ if (record.quarantined === true)
73
+ return false;
74
+ if (record.status !== 'approved')
75
+ return false;
76
+ return (record.lastUsedAt ?? record.updatedAt) <= threshold;
77
+ }
78
+ /**
79
+ * 存储块的 schema(门三的判据来源,2026-09-25)。
80
+ *
81
+ * **导出是为了让离线扫描器(`scripts/check-memory-files.mjs`)用它而不是自己再写一套**——
82
+ * 两份判据会漂移,而漂移的方向恰好是「扫描器说没事、打开时炸」这种最难查的形态。
83
+ */
84
+ export const blockSchema = z.object({
58
85
  namespace: z.enum(['global', 'project']),
59
86
  status: z.enum(['suggested', 'approved', 'auto', 'suggest']),
60
87
  injected: z.boolean().optional(),
@@ -64,7 +91,7 @@ const blockSchema = z.object({
64
91
  source: z.enum(['agent', 'human']).optional(),
65
92
  injectedAuto: z.boolean().optional(),
66
93
  anchor: z.object({
67
- kind: z.enum(['env', 'tool-list', 'self-version']),
94
+ kind: z.enum(ANCHOR_KINDS),
68
95
  name: z.string().optional(),
69
96
  value: z.string(),
70
97
  }).optional(),
@@ -89,6 +116,16 @@ const blockSchema = z.object({
89
116
  source: z.enum(['user', 'agent']),
90
117
  }).optional(),
91
118
  });
119
+ /**
120
+ * 把 zod 的 issue 压成一行(门一,2026-09-25)。
121
+ *
122
+ * 排查成本的大头在「从 domain 打不开倒推」——`dsh-storage-domain` 封装后的错误只给到
123
+ * 「哪个 domain 的哪条记录不合规」(`stored record '<id>' in table 'blocks' does not
124
+ * match its schema`),**说不出是哪个字段、什么值、期望什么**。这行补的就是那段。
125
+ */
126
+ function describeIssues(error) {
127
+ return error.issues.map(issue => `${issue.path.join('.') || '(root)'}: ${issue.message}`).join('; ');
128
+ }
92
129
  /** Shared table shape; the two domains differ only by name and backend route. */
93
130
  function memorySpec(name) {
94
131
  return defineDomain({
@@ -189,6 +226,12 @@ export class MemoryEngine extends Service {
189
226
  neighborOpenings = new Map();
190
227
  /** 进行中的工作区打开(key → promise):并发调用复用同一次,见 {@link projectTableFor}。 */
191
228
  projectOpenings = new Map();
229
+ /**
230
+ * 打不开的工作区表(门二,2026-09-25):key = 规范化 cwd,值 = 失败描述。
231
+ *
232
+ * 成功的打开会清掉对应项——「现在好没好」比「曾经坏过」更有用。见 {@link projectTableFor}。
233
+ */
234
+ projectOpenErrors = new Map();
192
235
  projectFacilities = new Map();
193
236
  /** backend 只注册一次(registry 重名抛 duplicate;reload 清缓存后不得重复注册)。 */
194
237
  registeredProjectBackends = new Set();
@@ -278,7 +321,20 @@ export class MemoryEngine extends Service {
278
321
  const opening = this.openProjectTable(key);
279
322
  this.projectOpenings.set(key, opening);
280
323
  try {
281
- return await opening;
324
+ const table = await opening;
325
+ this.projectOpenErrors.delete(key);
326
+ return table;
327
+ }
328
+ catch (error) {
329
+ // 门二(2026-09-25 故障的直接教训):打不开要说出来。此前这里让异常上抛、由调用方
330
+ // `catch(() => undefined)` 吞掉,于是一条不合规记录能让**整个工作区的记忆静默消失**
331
+ // ——用户只在「注入数 184 → 30」这种恰好被注意到的数字上才发现(154 条 project
332
+ // 记忆全部不可见,数据一条没丢,丢的是可读性)。
333
+ //
334
+ // 这里仍然上抛:调用方的 undefined 语义不变(它们按「没有 project 部分」继续),
335
+ // 变的只是**失败被记下来了**,`memory_sweep` 据此声明数据完整性。
336
+ this.projectOpenErrors.set(key, error instanceof Error ? error.message : String(error));
337
+ throw error;
282
338
  }
283
339
  finally {
284
340
  this.projectOpenings.delete(key);
@@ -547,12 +603,33 @@ export class MemoryEngine extends Service {
547
603
  }));
548
604
  const candidates = this.candidates(projectCwd)
549
605
  .map(record => ({ id: String(record.id), summary: brief(record), hitCount: record.hitCount ?? 0 }));
606
+ // 自动降级的候选(2026-09-29):判据与 `demoteStale` 共用同一个纯函数,但这里
607
+ // 只列出来、不撤下——`sweep` 承诺只读。它的用途是让那个至今没有分布依据的阈值
608
+ // 变得可观察:这张清单里的 `idleDays` 积累一段时间后,就是定阈值的依据。
609
+ const demoteThreshold = now - AUTO_DEMOTE_DAYS * 24 * 60 * 60 * 1000;
610
+ const autoDemoteCandidates = records
611
+ .filter(record => isAutoDemoteCandidate(record, demoteThreshold))
612
+ .map(record => ({
613
+ id: String(record.id),
614
+ summary: brief(record),
615
+ idleDays: Math.floor((now - (record.lastUsedAt ?? record.updatedAt)) / (24 * 60 * 60 * 1000)),
616
+ }));
550
617
  const resident = this.recallRecords(projectCwd);
551
618
  const rendered = renderInjection(resident, this.config.injectionBudget ?? DEFAULT_INJECTION_BUDGET, summaryChars);
619
+ // 完整性声明(门二):上面那些读取都经过 `projectTableFor`,打不开的话它已把原因记在
620
+ // `projectOpenErrors` 里。这里只判断「该不该有 project 侧」与「有没有失败记录」——
621
+ // 没有工作区上下文是 `absent`(不是故障),有上下文又打不开才是 `unreadable`。
622
+ const projectKey = projectCwd === undefined || projectCwd === '' ? undefined : join(projectCwd);
623
+ const projectError = projectKey === undefined ? undefined : this.projectOpenErrors.get(projectKey);
624
+ const coverage = projectKey === undefined
625
+ ? { project: 'absent' }
626
+ : projectError === undefined ? { project: 'included' } : { project: 'unreadable', projectError };
552
627
  const report = {
553
628
  stale,
554
629
  longUnused,
555
630
  candidates,
631
+ autoDemoteCandidates,
632
+ coverage,
556
633
  resident: {
557
634
  total: resident.length,
558
635
  injected: rendered.lines.length,
@@ -613,6 +690,34 @@ export class MemoryEngine extends Service {
613
690
  }
614
691
  return undefined;
615
692
  }
693
+ /**
694
+ * **唯一**允许写块的地方(门一,2026-09-25):写前跑一次存储 schema,不合格就抛。
695
+ *
696
+ * ⚠️ `KvTable` 上有**三个**写方法(`put` / `update` / `delete`),这道门只守 `put`。
697
+ * 截至 0.14.0 全仓只用了 `put` 与 `delete`——`delete` 不需要校验(**删掉一条不合规的
698
+ * 记录正是修复手段**),而 `update`(域内原子读改写,绕过本方法)**尚未被使用**。
699
+ * 将来若要用 `update`,它同样必须过 schema——否则门一就漏了一半。
700
+ *
701
+ * 为什么要有这道门:写入路径此前**完全不校验**,而不合规的记录会让整个 domain 在
702
+ * **下次打开时**打不开——`dsh-storage-domain` 逐条校验,一条不合格即整体失败。于是故障
703
+ * 被推迟到重启之后,表现为「整个工作区的记忆消失」:2026-09-25 那次 154 条 project 记忆
704
+ * 全部不可见(数据一条没丢,丢的是可读性),链条正是**写入放行 → 加载才炸 → 失败还被吞**。
705
+ * 三层里任何一层立住都不会发生,这一道补的是第一层。
706
+ *
707
+ * 失败**抛错**而不是静默降级:降级只是把「写入放行」的坑换到别处,且调用方看不到原因。
708
+ * 错误里带上 zod 的 issue(哪个字段、什么值、期望什么)。
709
+ *
710
+ * @param table - 目标表(global 或某个工作区)。
711
+ * @param id - 记录 id。
712
+ * @param block - 待写入的块;校验通过后写回 `parsed.data`,保证 strip 与默认值行为与读路径一致。
713
+ */
714
+ async putBlock(table, id, block) {
715
+ const parsed = blockSchema.safeParse(block);
716
+ if (!parsed.success) {
717
+ throw new Error(`memory: refusing to store an invalid record ${id} — ${describeIssues(parsed.error)}`);
718
+ }
719
+ await table.put(id, parsed.data);
720
+ }
616
721
  /**
617
722
  * 写入协调覆盖的存储文件:global + **全部已打开的工作区表** + 本次 cwd。
618
723
  *
@@ -704,7 +809,7 @@ export class MemoryEngine extends Service {
704
809
  ? { quarantined: true, ...verdict.reason === undefined ? {} : { quarantineReason: verdict.reason } }
705
810
  : {},
706
811
  };
707
- await table.put(id, block);
812
+ await this.putBlock(table, id, block);
708
813
  const record = toRecord(id, block);
709
814
  this.ctx.emit('memory/changed', { operation: 'remembered', record });
710
815
  return record;
@@ -807,7 +912,7 @@ export class MemoryEngine extends Service {
807
912
  fresh.set(String(missingRecord.id), [...vector]);
808
913
  const block = missingRecord.table.get(missingRecord.id);
809
914
  if (block !== undefined) {
810
- await missingRecord.table.put(missingRecord.id, { ...block, vector: [...vector] });
915
+ await this.putBlock(missingRecord.table, missingRecord.id, { ...block, vector: [...vector] });
811
916
  }
812
917
  }
813
918
  }
@@ -844,9 +949,13 @@ export class MemoryEngine extends Service {
844
949
  this.ctx.logger?.info?.(`dsh-memory: 旧数据迁移 放行 ${migration.approved} / 隔离 ${migration.quarantined}`
845
950
  + ` / 回填计数 ${migration.counted}`);
846
951
  }
847
- const demoted = await this.demoteStale(projectCwd);
848
- if (demoted > 0)
849
- this.ctx.logger?.info?.(`dsh-memory: 长期未命中,撤下常驻 ${demoted} 条`);
952
+ // 自动降级自 2026-09-29 起只计量不生效(阈值缺分布依据,见 `demoteStale`)。
953
+ // 这里只报一行,让人知道有多少条落在那个数字的另一侧、最久的闲置了多久。
954
+ const demoteCandidates = await this.demoteStale(projectCwd);
955
+ if (demoteCandidates.length > 0) {
956
+ this.ctx.logger?.info?.(`dsh-memory: ${demoteCandidates.length} 条常驻闲置超 ${AUTO_DEMOTE_DAYS} 天`
957
+ + `(只计量不生效;最久 ${demoteCandidates[0]?.idleDays ?? 0} 天)`);
958
+ }
850
959
  const staled = await this.verifyAnchors(this.anchorProbes(projectCwd), projectCwd);
851
960
  if (staled > 0)
852
961
  this.ctx.logger?.info?.(`dsh-memory: 锚点校验更新 ${staled} 条`);
@@ -949,7 +1058,7 @@ export class MemoryEngine extends Service {
949
1058
  hitCount: (block.hitCount ?? 0) + 1,
950
1059
  lastUsedAt: now,
951
1060
  };
952
- await table.put(id, updated);
1061
+ await this.putBlock(table, id, updated);
953
1062
  }
954
1063
  // ── 查询日志与影响力统计(⑥ 2026-09-15)────────────────────────────────
955
1064
  /** 查询日志(惰性创建;位置见 {@link queryLogPath})。 */
@@ -1047,37 +1156,35 @@ export class MemoryEngine extends Service {
1047
1156
  * @param now - 当前时间(测试注入用)。
1048
1157
  * @returns 被撤下的条数(诊断与日志用)。
1049
1158
  */
1159
+ /**
1160
+ * 自动降级的**计量**(2026-09-29 起不再生效):列出「若判据生效会被撤下」的常驻,
1161
+ * **不修改任何状态**。
1162
+ *
1163
+ * 为什么停掉撤销:那个阈值(`AUTO_DEMOTE_DAYS`)至今没有分布依据——它是 2026-09-15
1164
+ * 从面板的折叠阈值 `COLD_DAYS` 借来的。两者的代价不对等:展示阈值折错了点一下就能
1165
+ * 展开,而行为阈值一撤,0.12.0 取消了自动升之后基本不会自己回来。按「没有真实分布时
1166
+ * 拍任何数字都是误杀面」(借自 dsh-mneme 的写入准入阶段一),在拿到分布之前不该继续
1167
+ * 用这个数字动真格的。
1168
+ *
1169
+ * 候选由 {@link sweep} 的 `autoDemoteCandidates` 报出,积累出「实际闲置多久」的形状
1170
+ * 之后再定阈值。判据是 `isAutoDemoteCandidate`——与体检共用一份,不各写一份。
1171
+ *
1172
+ * @param projectCwd - 会话工作区;给了就连工作区表一起扫。
1173
+ * @param now - 当前时间(测试注入用)。
1174
+ * @returns 本会被撤下的常驻清单,按闲置天数降序。
1175
+ */
1050
1176
  async demoteStale(projectCwd, now = Date.now()) {
1051
- return this.writeScope(projectCwd, () => this.demoteStaleInner(projectCwd, now));
1052
- }
1053
- /** {@link demoteStale} 的实现体;必须经 `writeScope` 进入。 */
1054
- async demoteStaleInner(projectCwd, now = Date.now()) {
1055
1177
  const threshold = now - AUTO_DEMOTE_DAYS * 24 * 60 * 60 * 1000;
1056
- const tables = [this.requireTable('global')];
1057
- if (projectCwd !== undefined && projectCwd !== '') {
1058
- const project = await this.projectTableFor(projectCwd);
1059
- if (project !== undefined)
1060
- tables.push(project);
1061
- }
1062
- let demoted = 0;
1063
- for (const table of tables) {
1064
- // 先快照键值对:put 会改到底层 Map,边遍历边写不安全
1065
- for (const [id, block] of [...table.entries()]) {
1066
- if (block.injectedAuto !== true)
1067
- continue;
1068
- if (block.quarantined === true)
1069
- continue;
1070
- if (normalizeBlock(block).status !== 'approved')
1071
- continue;
1072
- const last = block.lastUsedAt ?? block.updatedAt;
1073
- if (last > threshold)
1074
- continue;
1075
- await table.put(id, { ...block, injected: false, injectedAuto: false });
1076
- this.ctx.emit('memory/changed', { operation: 'injected', id: MemoryId(id), injected: false });
1077
- demoted += 1;
1078
- }
1079
- }
1080
- return demoted;
1178
+ const summaryChars = this.config.summaryChars ?? DEFAULT_SUMMARY_CHARS;
1179
+ const records = await this.allRecords(undefined, projectCwd);
1180
+ return records
1181
+ .filter(record => isAutoDemoteCandidate(record, threshold))
1182
+ .map(record => ({
1183
+ id: String(record.id),
1184
+ summary: deriveSummary(record.content, summaryChars),
1185
+ idleDays: Math.floor((now - (record.lastUsedAt ?? record.updatedAt)) / (24 * 60 * 60 * 1000)),
1186
+ }))
1187
+ .sort((left, right) => right.idleDays - left.idleDays);
1081
1188
  }
1082
1189
  /**
1083
1190
  * 锚点批量校验(2026-09-15):会话启动时调一次,把失效的条目标成 `stale`。
@@ -1130,7 +1237,7 @@ export class MemoryEngine extends Service {
1130
1237
  injected: false,
1131
1238
  injectedAuto: false,
1132
1239
  };
1133
- await table.put(id, updated);
1240
+ await this.putBlock(table, id, updated);
1134
1241
  this.ctx.emit('memory/changed', { operation: 'status', id: MemoryId(id), status: normalizeBlock(updated).status });
1135
1242
  changed += 1;
1136
1243
  }
@@ -1216,7 +1323,7 @@ export class MemoryEngine extends Service {
1216
1323
  stats.counted += 1;
1217
1324
  }
1218
1325
  if (updated !== undefined)
1219
- await table.put(id, updated);
1326
+ await this.putBlock(table, id, updated);
1220
1327
  }
1221
1328
  }
1222
1329
  return stats;
@@ -1265,7 +1372,7 @@ export class MemoryEngine extends Service {
1265
1372
  throw new Error(`memory '${id}' is already stored in '${to}'`);
1266
1373
  // 先写目标、再删源:中间态里它同时存在于两处(重复好过丢失),而两次写同处一个
1267
1374
  // writeScope,外部看不到中间态。
1268
- await destination.put(id, found.block);
1375
+ await this.putBlock(destination, id, found.block);
1269
1376
  await found.table.delete(id);
1270
1377
  // 继承只向上,而 global 人人可见——所以「移进 global」与「移进自己的某个祖先」都能
1271
1378
  // 让源层继续看得见它;反过来(global → 某工作区、祖先 → 后代)就是缩小范围。
@@ -1349,7 +1456,7 @@ export class MemoryEngine extends Service {
1349
1456
  ...status === 'approved' ? { quarantined: false, quarantineReason: undefined } : {},
1350
1457
  updatedAt: Date.now(),
1351
1458
  };
1352
- await table.put(id, updated);
1459
+ await this.putBlock(table, id, updated);
1353
1460
  const record = toRecord(id, updated);
1354
1461
  this.ctx.emit('memory/changed', { operation: 'status', id, status });
1355
1462
  return record;
@@ -1386,7 +1493,7 @@ export class MemoryEngine extends Service {
1386
1493
  injectedAuto: false,
1387
1494
  updatedAt: Date.now(),
1388
1495
  };
1389
- await found.table.put(id, updated);
1496
+ await this.putBlock(found.table, id, updated);
1390
1497
  const record = toRecord(id, updated);
1391
1498
  this.ctx.emit('memory/changed', { operation: 'injected', id, injected });
1392
1499
  return record;
@@ -1445,7 +1552,7 @@ export class MemoryEngine extends Service {
1445
1552
  injectedAuto: false,
1446
1553
  updatedAt: Date.now(),
1447
1554
  };
1448
- await found.table.put(id, updated);
1555
+ await this.putBlock(found.table, id, updated);
1449
1556
  this.ctx.emit('memory/changed', { operation: 'status', id, status: normalizeBlock(updated).status });
1450
1557
  return updated;
1451
1558
  }
@@ -1531,7 +1638,7 @@ export class MemoryEngine extends Service {
1531
1638
  throw new Error(`cannot update unknown memory '${id}'`);
1532
1639
  const { table, block } = found;
1533
1640
  const updated = applyPatch(block);
1534
- await table.put(id, updated);
1641
+ await this.putBlock(table, id, updated);
1535
1642
  return publish(block, updated);
1536
1643
  }
1537
1644
  /**
@@ -1679,7 +1786,7 @@ export class MemoryEngine extends Service {
1679
1786
  source: file.meta.source,
1680
1787
  },
1681
1788
  };
1682
- await table.put(id, block);
1789
+ await this.putBlock(table, id, block);
1683
1790
  changed++;
1684
1791
  }
1685
1792
  for (const record of existing) {
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@
7
7
  import { defineTool } from '@deepseek-ai/dsh-tools';
8
8
  import { MemoryEngine } from "./engine.js";
9
9
  import { MemoryGateway } from "./remote.js";
10
+ import { ANCHOR_KINDS } from "./anchors.js";
10
11
  import { mountMemoryApi } from "./routes.js";
11
12
  import { SELF_DESCRIPTION } from "./self.js";
12
13
  import { DEFAULT_INJECTION_BUDGET, DEFAULT_SUMMARY_CHARS, candidateNotice, indexNotice, neighborNotice, neutralizeBraces, omittedNotice, renderInjection, staleNotice } from "./injection.js";
@@ -64,7 +65,7 @@ const RECORD_SCHEMA = {
64
65
  additionalProperties: false,
65
66
  description: '有效性锚点:这个值一变,这条记忆就自动失效。`memory_update` 传新值即改写(当场重新校验),传 `null` 即清除。',
66
67
  properties: {
67
- kind: { type: 'string', required: true, enum: ['env', 'tool-list', 'self-version', 'path-exists'] },
68
+ kind: { type: 'string', required: true, enum: [...ANCHOR_KINDS] },
68
69
  name: { type: 'string' },
69
70
  value: { type: 'string', required: true },
70
71
  },
@@ -141,6 +142,30 @@ const SWEEP_SCHEMA = {
141
142
  omitted: { type: 'number', required: true },
142
143
  },
143
144
  },
145
+ autoDemoteCandidates: {
146
+ type: 'array',
147
+ required: true,
148
+ description: '自动降级若生效会被撤下的常驻(0.13.0 起只计量不生效)。`idleDays` 就是定那个阈值要看的分布。',
149
+ items: {
150
+ type: 'object',
151
+ additionalProperties: false,
152
+ properties: {
153
+ id: { type: 'string', required: true },
154
+ summary: { type: 'string', required: true },
155
+ idleDays: { type: 'number', required: true },
156
+ },
157
+ },
158
+ },
159
+ coverage: {
160
+ type: 'object',
161
+ required: true,
162
+ additionalProperties: false,
163
+ description: '数据完整性声明(0.14.0):本次体检实际读到了哪些存储。`project` 为 `unreadable` 时,前面几项只覆盖 global——别对着一个看起来正常的偏小数字做判断。',
164
+ properties: {
165
+ project: { type: 'string', required: true, enum: ['included', 'absent', 'unreadable'] },
166
+ projectError: { type: 'string', description: '`unreadable` 时的原因;其余情况不出现。' },
167
+ },
168
+ },
144
169
  },
145
170
  };
146
171
  /** 模板记录(0.6.0):不暴露本地文件路径(meta.path 引擎内部使用)。 */
@@ -375,7 +400,7 @@ export async function apply(ctx, config) {
375
400
  additionalProperties: false,
376
401
  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). 可选锚点:把记忆绑到可探测的环境值,值变了自动失效。只用于「随环境变化的事实」。',
377
402
  properties: {
378
- 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`).' },
403
+ kind: { type: 'string', required: true, enum: [...ANCHOR_KINDS], 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`).' },
379
404
  name: { type: 'string', description: 'For kind=env: the variable name.' },
380
405
  value: { type: 'string', required: true, description: 'The value probed at write time.' },
381
406
  },
@@ -636,7 +661,7 @@ export async function apply(ctx, config) {
636
661
  type: 'object',
637
662
  additionalProperties: false,
638
663
  properties: {
639
- 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`).' },
664
+ kind: { type: 'string', required: true, enum: [...ANCHOR_KINDS], 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`).' },
640
665
  name: { type: 'string', description: 'For kind=env: the variable name. For kind=path-exists: the path (relative paths resolve against the session workspace).' },
641
666
  value: { type: 'string', required: true, description: 'The value probed at write time.' },
642
667
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@max-null/dsh-memory",
3
- "version": "0.12.2",
3
+ "version": "0.14.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": {
@@ -27,7 +27,8 @@
27
27
  "scripts/scan-orphans.mjs",
28
28
  "scripts/unzstd-frames.mjs",
29
29
  "scripts/ancestor-probe.mjs",
30
- "scripts/analyze-cooccurrence.mjs"
30
+ "scripts/analyze-cooccurrence.mjs",
31
+ "scripts/check-memory-files.mjs"
31
32
  ],
32
33
  "dsh": {
33
34
  "bundle": {
@@ -60,6 +61,7 @@
60
61
  "typecheck": "tsc --noEmit -p tsconfig.json",
61
62
  "test": "vitest run",
62
63
  "mutate:check": "node scripts/mutate-check.mjs",
64
+ "check:memory": "node scripts/check-memory-files.mjs",
63
65
  "prepublishOnly": "npm run build"
64
66
  },
65
67
  "dependencies": {
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 记忆存储文件的存量体检(门三,2026-09-25)。
4
+ *
5
+ * **能回答什么**:给出的记忆文件里,有没有**不合规的存储块**——那种块会让整个 domain 在
6
+ * 下次打开时失败(`dsh-storage-domain` 逐条校验,一条不合格即整体失败),表现为「整个工作区
7
+ * 的记忆静默消失」。2026-09-25 那次 154 条 project 记忆全部不可见就是这么来的:写入放行、
8
+ * 加载才炸、失败还被吞。
9
+ *
10
+ * **判据怎么算**:用插件自己的 `blockSchema`(从 `dist/engine.js` import)逐条 `safeParse`。
11
+ * 同源是刻意的——另写一套判定迟早漂移成「扫描器说没事、打开时炸」这种最难查的形态。
12
+ *
13
+ * **哪些情况答不了**:
14
+ * - 只校验 `tables.blocks` 里的条目,不校验外层信封结构;
15
+ * - 不处理压缩帧(那是 `unzstd-frames.mjs` 的事);
16
+ * - 不判断「记录该不该存在」——那是价值判断,本脚本只看格式。
17
+ *
18
+ * 用法:
19
+ * node scripts/check-memory-files.mjs # 扫 $DSH_HOME 与当前目录下的记忆文件
20
+ * node scripts/check-memory-files.mjs <路径...> # 扫指定文件或目录(目录会递归找 *.json)
21
+ *
22
+ * 退出码:0 = 全绿 / 1 = 命中不合规 / 2 = 用法或环境错误。
23
+ *
24
+ * 前置:本脚本 import 编译产物 `dist/engine.js`,所以要先 `npm run build`。
25
+ */
26
+
27
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
28
+ import { homedir } from 'node:os'
29
+ import { join, resolve } from 'node:path'
30
+
31
+ const USAGE = 2
32
+
33
+ function fail(message) {
34
+ console.error(`check-memory-files: ${message}`)
35
+ process.exit(USAGE)
36
+ }
37
+
38
+ let blockSchema
39
+ try {
40
+ ({ blockSchema } = await import('../dist/engine.js'))
41
+ } catch (error) {
42
+ fail(
43
+ `无法加载 dist/engine.js(先跑 npm run build):${error instanceof Error ? error.message : String(error)}`,
44
+ )
45
+ }
46
+ if (blockSchema === undefined) fail('dist/engine.js 没有导出 blockSchema——构建产物太旧?')
47
+
48
+ /** 递归收集目录下的 *.json(跳过 node_modules)。 */
49
+ function collect(target, out = []) {
50
+ let stat
51
+ try {
52
+ stat = statSync(target)
53
+ } catch {
54
+ return out
55
+ }
56
+ if (stat.isFile()) {
57
+ if (target.endsWith('.json')) out.push(target)
58
+ return out
59
+ }
60
+ if (!stat.isDirectory()) return out
61
+ if (target.includes('node_modules')) return out
62
+ for (const entry of readdirSync(target, { withFileTypes: true })) {
63
+ if (entry.name === 'node_modules') continue
64
+ collect(join(target, entry.name), out)
65
+ }
66
+ return out
67
+ }
68
+
69
+ /** 默认扫描目标:$DSH_HOME/storages 与当前目录的 .dsh/storages。 */
70
+ function defaultTargets() {
71
+ const dshHome = process.env.DSH_HOME ?? join(homedir(), '.dsh')
72
+ const targets = [join(dshHome, 'storages'), join(process.cwd(), '.dsh', 'storages')]
73
+ return targets.filter(existsSync)
74
+ }
75
+
76
+ const args = process.argv.slice(2)
77
+ const explicit = args.length > 0
78
+ const targets = explicit ? args.map(arg => resolve(arg)) : defaultTargets()
79
+
80
+ // 显式给的路径必须存在。**把「什么都没扫到」报成「全绿」是同一个病**——偏小的数字看起来
81
+ // 正常,而判断者会据此认为存量是干净的。门二存在的理由就是这句话。
82
+ if (explicit) {
83
+ const missing = targets.filter(target => !existsSync(target))
84
+ if (missing.length > 0) fail(`路径不存在:${missing.join('、')}`)
85
+ }
86
+ if (targets.length === 0) fail('没有可扫描的目标(既没给路径,也没找到 $DSH_HOME/storages 或 ./.dsh/storages)')
87
+
88
+ const files = [...new Set(targets.flatMap(target => collect(target)))].sort()
89
+ let scanned = 0
90
+ let records = 0
91
+ const violations = []
92
+
93
+ for (const file of files) {
94
+ let raw
95
+ try {
96
+ raw = JSON.parse(readFileSync(file, 'utf8'))
97
+ } catch {
98
+ continue // 不是 JSON(或不是存储文件)—— 不是本脚本的事
99
+ }
100
+ const blocks = raw?.tables?.blocks
101
+ if (blocks === null || typeof blocks !== 'object') continue
102
+ scanned += 1
103
+ for (const [id, block] of Object.entries(blocks)) {
104
+ records += 1
105
+ const parsed = blockSchema.safeParse(block)
106
+ if (parsed.success) continue
107
+ const issues = parsed.error.issues
108
+ .map(issue => `${issue.path.join('.') || '(root)'}: ${issue.message}`)
109
+ .join('; ')
110
+ violations.push({ file, id, issues })
111
+ }
112
+ }
113
+
114
+ console.log(`check-memory-files: 扫描 ${scanned} 个记忆文件 / ${records} 条记录`)
115
+ // 一个记忆文件都没扫到,同样是可疑的——不能报「全部合规」。判断者需要知道这次体检
116
+ // **没有覆盖任何东西**(同门二:读不到就说读不到)。
117
+ if (scanned === 0) fail(`看了 ${files.length} 个 JSON,但没有一个是记忆文件——目标选对了吗?`)
118
+ if (violations.length === 0) {
119
+ console.log('check-memory-files: ✅ 全部合规')
120
+ process.exit(0)
121
+ }
122
+
123
+ console.error(`check-memory-files: ✗ ${violations.length} 条不合规——它们会让所在 domain 打不开`)
124
+ for (const violation of violations) {
125
+ console.error(` ${violation.file}`)
126
+ console.error(` ${violation.id}: ${violation.issues}`)
127
+ }
128
+ process.exit(1)