@max-null/dsh-memory 0.12.2 → 0.15.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 +6 -1
- package/dist/anchors.d.ts +13 -2
- package/dist/anchors.js +18 -1
- package/dist/engine.d.ts +87 -3
- package/dist/engine.js +140 -80
- package/dist/index.js +28 -3
- package/dist/schema.d.ts +88 -0
- package/dist/schema.js +69 -0
- package/package.json +4 -2
- package/scripts/check-memory-files.mjs +133 -0
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
|
|
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
|
-
|
|
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
|
@@ -205,6 +205,24 @@ export interface SweepItem {
|
|
|
205
205
|
id: string;
|
|
206
206
|
summary: string;
|
|
207
207
|
}
|
|
208
|
+
/** 一条「自动降级若生效就会被撤下」的常驻(2026-09-29 起只计量、不生效)。 */
|
|
209
|
+
export interface AutoDemoteCandidate extends SweepItem {
|
|
210
|
+
/** 距上次命中(从未命中则回退最后更新)的天数——这是定阈值时要看的那个分布。 */
|
|
211
|
+
idleDays: number;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* 自动降级的判据(纯函数,2026-09-29 抽出):只针对**自动升上来的**常驻注入——人工
|
|
215
|
+
* 开的开关不会被时间悄悄关掉(那是人的决定,不是信号的结论)。
|
|
216
|
+
*
|
|
217
|
+
* 抽成纯函数是因为它有两个调用方,而两者对「能不能写」的要求相反:`demoteStale` 在写
|
|
218
|
+
* 路径上(`writeScope` 之内),而 `sweep` 承诺只读(连时间戳都不写)。判据各写一份迟早
|
|
219
|
+
* 漂移成「体检说该降、降级说不用降」这类两套口径——所以只留这一份。
|
|
220
|
+
*
|
|
221
|
+
* @param record - 参与判定的记录。
|
|
222
|
+
* @param threshold - 闲置多久算候选(毫秒时间戳,早于它才算)。
|
|
223
|
+
* @returns 该记录是否属于本会被撤下的常驻。
|
|
224
|
+
*/
|
|
225
|
+
export declare function isAutoDemoteCandidate(record: MemoryRecord, threshold: number): boolean;
|
|
208
226
|
/**
|
|
209
227
|
* 只读体检的产出({@link MemoryEngine.sweep})。四类各有明确的消费者,不做「算了没人读」的采样
|
|
210
228
|
* ——候选与新失效会进注入诊断行(模型每轮可见),久未命中与全量留给按需调用。
|
|
@@ -222,6 +240,14 @@ export interface SweepReport {
|
|
|
222
240
|
candidates: Array<SweepItem & {
|
|
223
241
|
hitCount: number;
|
|
224
242
|
}>;
|
|
243
|
+
/**
|
|
244
|
+
* **自动降级若生效**会被撤下的常驻(2026-09-29 起只计量不生效)。
|
|
245
|
+
*
|
|
246
|
+
* 与 `longUnused` 正好互补:那个查的是「非常驻且久未命中」(可清理),这个查的是
|
|
247
|
+
* 「自动升上来的常驻且久未命中」(会被时间规则撤下)。留着它是因为阈值 `AUTO_DEMOTE_DAYS`
|
|
248
|
+
* 至今没有分布依据——等这张清单积累出「实际闲置多久」的形状,才有得可定。
|
|
249
|
+
*/
|
|
250
|
+
autoDemoteCandidates: AutoDemoteCandidate[];
|
|
225
251
|
/** 常驻与注入预算的占用:总数 / 实际进注入 / 预算(无限制时不出现)/ 被挤掉几条。 */
|
|
226
252
|
resident: {
|
|
227
253
|
total: number;
|
|
@@ -229,6 +255,21 @@ export interface SweepReport {
|
|
|
229
255
|
budget: number | undefined;
|
|
230
256
|
omitted: number;
|
|
231
257
|
};
|
|
258
|
+
/**
|
|
259
|
+
* 数据完整性声明(门二,2026-09-25):这次体检**实际读到了哪些存储**。
|
|
260
|
+
*
|
|
261
|
+
* 加它是因为「读不到就说读不到,比读到了但少一半更有价值」:project 侧打不开时,前面
|
|
262
|
+
* 几项会给出一个**看起来正常但偏小**的数字,而没有任何信号说明它少了什么。宁可报告
|
|
263
|
+
* 显式写「未纳入」,也不要让人对着一个残缺的全景做判断。
|
|
264
|
+
*
|
|
265
|
+
* - `included`:project 侧已纳入;
|
|
266
|
+
* - `absent`:本次没有工作区上下文(`projectCwd` 为空),不是故障;
|
|
267
|
+
* - `unreadable`:**尝试过但打不开**——此时 `projectError` 给出原因,前几项的数字只覆盖 global。
|
|
268
|
+
*/
|
|
269
|
+
coverage: {
|
|
270
|
+
project: 'included' | 'absent' | 'unreadable';
|
|
271
|
+
projectError?: string;
|
|
272
|
+
};
|
|
232
273
|
}
|
|
233
274
|
declare module '@deepseek-ai/cordis' {
|
|
234
275
|
interface Context {
|
|
@@ -296,6 +337,12 @@ export declare class MemoryEngine extends Service {
|
|
|
296
337
|
private readonly neighborOpenings;
|
|
297
338
|
/** 进行中的工作区打开(key → promise):并发调用复用同一次,见 {@link projectTableFor}。 */
|
|
298
339
|
private readonly projectOpenings;
|
|
340
|
+
/**
|
|
341
|
+
* 打不开的工作区表(门二,2026-09-25):key = 规范化 cwd,值 = 失败描述。
|
|
342
|
+
*
|
|
343
|
+
* 成功的打开会清掉对应项——「现在好没好」比「曾经坏过」更有用。见 {@link projectTableFor}。
|
|
344
|
+
*/
|
|
345
|
+
private readonly projectOpenErrors;
|
|
299
346
|
private projectFacilities;
|
|
300
347
|
/** backend 只注册一次(registry 重名抛 duplicate;reload 清缓存后不得重复注册)。 */
|
|
301
348
|
private registeredProjectBackends;
|
|
@@ -449,6 +496,28 @@ export declare class MemoryEngine extends Service {
|
|
|
449
496
|
* 那时同步缓存已被清空(同 {@link markUsedInner} 的注释)。
|
|
450
497
|
*/
|
|
451
498
|
private locateRecord;
|
|
499
|
+
/**
|
|
500
|
+
* **唯一**允许写块的地方(门一,2026-09-25):写前跑一次存储 schema,不合格就抛。
|
|
501
|
+
*
|
|
502
|
+
* ⚠️ `KvTable` 上有**三个**写方法(`put` / `update` / `delete`),这道门只守 `put`。
|
|
503
|
+
* 截至 0.14.0 全仓只用了 `put` 与 `delete`——`delete` 不需要校验(**删掉一条不合规的
|
|
504
|
+
* 记录正是修复手段**),而 `update`(域内原子读改写,绕过本方法)**尚未被使用**。
|
|
505
|
+
* 将来若要用 `update`,它同样必须过 schema——否则门一就漏了一半。
|
|
506
|
+
*
|
|
507
|
+
* 为什么要有这道门:写入路径此前**完全不校验**,而不合规的记录会让整个 domain 在
|
|
508
|
+
* **下次打开时**打不开——`dsh-storage-domain` 逐条校验,一条不合格即整体失败。于是故障
|
|
509
|
+
* 被推迟到重启之后,表现为「整个工作区的记忆消失」:2026-09-25 那次 154 条 project 记忆
|
|
510
|
+
* 全部不可见(数据一条没丢,丢的是可读性),链条正是**写入放行 → 加载才炸 → 失败还被吞**。
|
|
511
|
+
* 三层里任何一层立住都不会发生,这一道补的是第一层。
|
|
512
|
+
*
|
|
513
|
+
* 失败**抛错**而不是静默降级:降级只是把「写入放行」的坑换到别处,且调用方看不到原因。
|
|
514
|
+
* 错误里带上 zod 的 issue(哪个字段、什么值、期望什么)。
|
|
515
|
+
*
|
|
516
|
+
* @param table - 目标表(global 或某个工作区)。
|
|
517
|
+
* @param id - 记录 id。
|
|
518
|
+
* @param block - 待写入的块;校验通过后写回 `parsed.data`,保证 strip 与默认值行为与读路径一致。
|
|
519
|
+
*/
|
|
520
|
+
private putBlock;
|
|
452
521
|
/**
|
|
453
522
|
* 写入协调覆盖的存储文件:global + **全部已打开的工作区表** + 本次 cwd。
|
|
454
523
|
*
|
|
@@ -570,9 +639,24 @@ export declare class MemoryEngine extends Service {
|
|
|
570
639
|
* @param now - 当前时间(测试注入用)。
|
|
571
640
|
* @returns 被撤下的条数(诊断与日志用)。
|
|
572
641
|
*/
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
642
|
+
/**
|
|
643
|
+
* 自动降级的**计量**(2026-09-29 起不再生效):列出「若判据生效会被撤下」的常驻,
|
|
644
|
+
* **不修改任何状态**。
|
|
645
|
+
*
|
|
646
|
+
* 为什么停掉撤销:那个阈值(`AUTO_DEMOTE_DAYS`)至今没有分布依据——它是 2026-09-15
|
|
647
|
+
* 从面板的折叠阈值 `COLD_DAYS` 借来的。两者的代价不对等:展示阈值折错了点一下就能
|
|
648
|
+
* 展开,而行为阈值一撤,0.12.0 取消了自动升之后基本不会自己回来。按「没有真实分布时
|
|
649
|
+
* 拍任何数字都是误杀面」(借自 dsh-mneme 的写入准入阶段一),在拿到分布之前不该继续
|
|
650
|
+
* 用这个数字动真格的。
|
|
651
|
+
*
|
|
652
|
+
* 候选由 {@link sweep} 的 `autoDemoteCandidates` 报出,积累出「实际闲置多久」的形状
|
|
653
|
+
* 之后再定阈值。判据是 `isAutoDemoteCandidate`——与体检共用一份,不各写一份。
|
|
654
|
+
*
|
|
655
|
+
* @param projectCwd - 会话工作区;给了就连工作区表一起扫。
|
|
656
|
+
* @param now - 当前时间(测试注入用)。
|
|
657
|
+
* @returns 本会被撤下的常驻清单,按闲置天数降序。
|
|
658
|
+
*/
|
|
659
|
+
demoteStale(projectCwd?: string, now?: number): Promise<AutoDemoteCandidate[]>;
|
|
576
660
|
/**
|
|
577
661
|
* 锚点批量校验(2026-09-15):会话启动时调一次,把失效的条目标成 `stale`。
|
|
578
662
|
*
|
package/dist/engine.js
CHANGED
|
@@ -10,7 +10,6 @@ import { readdir } from 'node:fs/promises';
|
|
|
10
10
|
import { homedir } from 'node:os';
|
|
11
11
|
import { dirname, join } from 'node:path';
|
|
12
12
|
import { Service } from '@deepseek-ai/cordis';
|
|
13
|
-
import { z } from 'zod';
|
|
14
13
|
import { defineDomain, domainTable, DomainFacility } from '@deepseek-ai/dsh-storage-domain';
|
|
15
14
|
import { JsonStorageBackend } from '@deepseek-ai/dsh-storage-json';
|
|
16
15
|
import { bm25FieldScores, cosineSimilarity, rrfFuse } from "./bm25.js";
|
|
@@ -18,6 +17,7 @@ import { parsePromptFile, scanPromptDir, writePromptFile } from "./prompt-files.
|
|
|
18
17
|
import { detectSensitive } from "./quarantine.js";
|
|
19
18
|
import { SELF_VERSION } from "./self.js";
|
|
20
19
|
import { anchorHolds } from "./anchors.js";
|
|
20
|
+
import { blockSchema, describeIssues } from "./schema.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,41 +54,31 @@ const AUTO_DEMOTE_DAYS = 30;
|
|
|
54
54
|
* 会把正常记录报成待清理。
|
|
55
55
|
*/
|
|
56
56
|
const DEFAULT_SWEEP_DAYS = 90;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
name: z.string(),
|
|
83
|
-
dimension: z.string().optional(),
|
|
84
|
-
difficulty: z.string().optional(),
|
|
85
|
-
tags: z.array(z.string()),
|
|
86
|
-
summary: z.string(),
|
|
87
|
-
path: z.string(),
|
|
88
|
-
mtime: z.number(),
|
|
89
|
-
source: z.enum(['user', 'agent']),
|
|
90
|
-
}).optional(),
|
|
91
|
-
});
|
|
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 与 zod issue 的单行格式化,都在 {@link ./schema.ts}——单独成模块是为了
|
|
80
|
+
* 让离线扫描器能零 peer 依赖地加载它(见该文件头部)。
|
|
81
|
+
*/
|
|
92
82
|
/** Shared table shape; the two domains differ only by name and backend route. */
|
|
93
83
|
function memorySpec(name) {
|
|
94
84
|
return defineDomain({
|
|
@@ -189,6 +179,12 @@ export class MemoryEngine extends Service {
|
|
|
189
179
|
neighborOpenings = new Map();
|
|
190
180
|
/** 进行中的工作区打开(key → promise):并发调用复用同一次,见 {@link projectTableFor}。 */
|
|
191
181
|
projectOpenings = new Map();
|
|
182
|
+
/**
|
|
183
|
+
* 打不开的工作区表(门二,2026-09-25):key = 规范化 cwd,值 = 失败描述。
|
|
184
|
+
*
|
|
185
|
+
* 成功的打开会清掉对应项——「现在好没好」比「曾经坏过」更有用。见 {@link projectTableFor}。
|
|
186
|
+
*/
|
|
187
|
+
projectOpenErrors = new Map();
|
|
192
188
|
projectFacilities = new Map();
|
|
193
189
|
/** backend 只注册一次(registry 重名抛 duplicate;reload 清缓存后不得重复注册)。 */
|
|
194
190
|
registeredProjectBackends = new Set();
|
|
@@ -278,7 +274,20 @@ export class MemoryEngine extends Service {
|
|
|
278
274
|
const opening = this.openProjectTable(key);
|
|
279
275
|
this.projectOpenings.set(key, opening);
|
|
280
276
|
try {
|
|
281
|
-
|
|
277
|
+
const table = await opening;
|
|
278
|
+
this.projectOpenErrors.delete(key);
|
|
279
|
+
return table;
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
// 门二(2026-09-25 故障的直接教训):打不开要说出来。此前这里让异常上抛、由调用方
|
|
283
|
+
// `catch(() => undefined)` 吞掉,于是一条不合规记录能让**整个工作区的记忆静默消失**
|
|
284
|
+
// ——用户只在「注入数 184 → 30」这种恰好被注意到的数字上才发现(154 条 project
|
|
285
|
+
// 记忆全部不可见,数据一条没丢,丢的是可读性)。
|
|
286
|
+
//
|
|
287
|
+
// 这里仍然上抛:调用方的 undefined 语义不变(它们按「没有 project 部分」继续),
|
|
288
|
+
// 变的只是**失败被记下来了**,`memory_sweep` 据此声明数据完整性。
|
|
289
|
+
this.projectOpenErrors.set(key, error instanceof Error ? error.message : String(error));
|
|
290
|
+
throw error;
|
|
282
291
|
}
|
|
283
292
|
finally {
|
|
284
293
|
this.projectOpenings.delete(key);
|
|
@@ -547,12 +556,33 @@ export class MemoryEngine extends Service {
|
|
|
547
556
|
}));
|
|
548
557
|
const candidates = this.candidates(projectCwd)
|
|
549
558
|
.map(record => ({ id: String(record.id), summary: brief(record), hitCount: record.hitCount ?? 0 }));
|
|
559
|
+
// 自动降级的候选(2026-09-29):判据与 `demoteStale` 共用同一个纯函数,但这里
|
|
560
|
+
// 只列出来、不撤下——`sweep` 承诺只读。它的用途是让那个至今没有分布依据的阈值
|
|
561
|
+
// 变得可观察:这张清单里的 `idleDays` 积累一段时间后,就是定阈值的依据。
|
|
562
|
+
const demoteThreshold = now - AUTO_DEMOTE_DAYS * 24 * 60 * 60 * 1000;
|
|
563
|
+
const autoDemoteCandidates = records
|
|
564
|
+
.filter(record => isAutoDemoteCandidate(record, demoteThreshold))
|
|
565
|
+
.map(record => ({
|
|
566
|
+
id: String(record.id),
|
|
567
|
+
summary: brief(record),
|
|
568
|
+
idleDays: Math.floor((now - (record.lastUsedAt ?? record.updatedAt)) / (24 * 60 * 60 * 1000)),
|
|
569
|
+
}));
|
|
550
570
|
const resident = this.recallRecords(projectCwd);
|
|
551
571
|
const rendered = renderInjection(resident, this.config.injectionBudget ?? DEFAULT_INJECTION_BUDGET, summaryChars);
|
|
572
|
+
// 完整性声明(门二):上面那些读取都经过 `projectTableFor`,打不开的话它已把原因记在
|
|
573
|
+
// `projectOpenErrors` 里。这里只判断「该不该有 project 侧」与「有没有失败记录」——
|
|
574
|
+
// 没有工作区上下文是 `absent`(不是故障),有上下文又打不开才是 `unreadable`。
|
|
575
|
+
const projectKey = projectCwd === undefined || projectCwd === '' ? undefined : join(projectCwd);
|
|
576
|
+
const projectError = projectKey === undefined ? undefined : this.projectOpenErrors.get(projectKey);
|
|
577
|
+
const coverage = projectKey === undefined
|
|
578
|
+
? { project: 'absent' }
|
|
579
|
+
: projectError === undefined ? { project: 'included' } : { project: 'unreadable', projectError };
|
|
552
580
|
const report = {
|
|
553
581
|
stale,
|
|
554
582
|
longUnused,
|
|
555
583
|
candidates,
|
|
584
|
+
autoDemoteCandidates,
|
|
585
|
+
coverage,
|
|
556
586
|
resident: {
|
|
557
587
|
total: resident.length,
|
|
558
588
|
injected: rendered.lines.length,
|
|
@@ -613,6 +643,34 @@ export class MemoryEngine extends Service {
|
|
|
613
643
|
}
|
|
614
644
|
return undefined;
|
|
615
645
|
}
|
|
646
|
+
/**
|
|
647
|
+
* **唯一**允许写块的地方(门一,2026-09-25):写前跑一次存储 schema,不合格就抛。
|
|
648
|
+
*
|
|
649
|
+
* ⚠️ `KvTable` 上有**三个**写方法(`put` / `update` / `delete`),这道门只守 `put`。
|
|
650
|
+
* 截至 0.14.0 全仓只用了 `put` 与 `delete`——`delete` 不需要校验(**删掉一条不合规的
|
|
651
|
+
* 记录正是修复手段**),而 `update`(域内原子读改写,绕过本方法)**尚未被使用**。
|
|
652
|
+
* 将来若要用 `update`,它同样必须过 schema——否则门一就漏了一半。
|
|
653
|
+
*
|
|
654
|
+
* 为什么要有这道门:写入路径此前**完全不校验**,而不合规的记录会让整个 domain 在
|
|
655
|
+
* **下次打开时**打不开——`dsh-storage-domain` 逐条校验,一条不合格即整体失败。于是故障
|
|
656
|
+
* 被推迟到重启之后,表现为「整个工作区的记忆消失」:2026-09-25 那次 154 条 project 记忆
|
|
657
|
+
* 全部不可见(数据一条没丢,丢的是可读性),链条正是**写入放行 → 加载才炸 → 失败还被吞**。
|
|
658
|
+
* 三层里任何一层立住都不会发生,这一道补的是第一层。
|
|
659
|
+
*
|
|
660
|
+
* 失败**抛错**而不是静默降级:降级只是把「写入放行」的坑换到别处,且调用方看不到原因。
|
|
661
|
+
* 错误里带上 zod 的 issue(哪个字段、什么值、期望什么)。
|
|
662
|
+
*
|
|
663
|
+
* @param table - 目标表(global 或某个工作区)。
|
|
664
|
+
* @param id - 记录 id。
|
|
665
|
+
* @param block - 待写入的块;校验通过后写回 `parsed.data`,保证 strip 与默认值行为与读路径一致。
|
|
666
|
+
*/
|
|
667
|
+
async putBlock(table, id, block) {
|
|
668
|
+
const parsed = blockSchema.safeParse(block);
|
|
669
|
+
if (!parsed.success) {
|
|
670
|
+
throw new Error(`memory: refusing to store an invalid record ${id} — ${describeIssues(parsed.error)}`);
|
|
671
|
+
}
|
|
672
|
+
await table.put(id, parsed.data);
|
|
673
|
+
}
|
|
616
674
|
/**
|
|
617
675
|
* 写入协调覆盖的存储文件:global + **全部已打开的工作区表** + 本次 cwd。
|
|
618
676
|
*
|
|
@@ -704,7 +762,7 @@ export class MemoryEngine extends Service {
|
|
|
704
762
|
? { quarantined: true, ...verdict.reason === undefined ? {} : { quarantineReason: verdict.reason } }
|
|
705
763
|
: {},
|
|
706
764
|
};
|
|
707
|
-
await
|
|
765
|
+
await this.putBlock(table, id, block);
|
|
708
766
|
const record = toRecord(id, block);
|
|
709
767
|
this.ctx.emit('memory/changed', { operation: 'remembered', record });
|
|
710
768
|
return record;
|
|
@@ -807,7 +865,7 @@ export class MemoryEngine extends Service {
|
|
|
807
865
|
fresh.set(String(missingRecord.id), [...vector]);
|
|
808
866
|
const block = missingRecord.table.get(missingRecord.id);
|
|
809
867
|
if (block !== undefined) {
|
|
810
|
-
await missingRecord.table
|
|
868
|
+
await this.putBlock(missingRecord.table, missingRecord.id, { ...block, vector: [...vector] });
|
|
811
869
|
}
|
|
812
870
|
}
|
|
813
871
|
}
|
|
@@ -844,9 +902,13 @@ export class MemoryEngine extends Service {
|
|
|
844
902
|
this.ctx.logger?.info?.(`dsh-memory: 旧数据迁移 放行 ${migration.approved} / 隔离 ${migration.quarantined}`
|
|
845
903
|
+ ` / 回填计数 ${migration.counted}`);
|
|
846
904
|
}
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
905
|
+
// 自动降级自 2026-09-29 起只计量不生效(阈值缺分布依据,见 `demoteStale`)。
|
|
906
|
+
// 这里只报一行,让人知道有多少条落在那个数字的另一侧、最久的闲置了多久。
|
|
907
|
+
const demoteCandidates = await this.demoteStale(projectCwd);
|
|
908
|
+
if (demoteCandidates.length > 0) {
|
|
909
|
+
this.ctx.logger?.info?.(`dsh-memory: ${demoteCandidates.length} 条常驻闲置超 ${AUTO_DEMOTE_DAYS} 天`
|
|
910
|
+
+ `(只计量不生效;最久 ${demoteCandidates[0]?.idleDays ?? 0} 天)`);
|
|
911
|
+
}
|
|
850
912
|
const staled = await this.verifyAnchors(this.anchorProbes(projectCwd), projectCwd);
|
|
851
913
|
if (staled > 0)
|
|
852
914
|
this.ctx.logger?.info?.(`dsh-memory: 锚点校验更新 ${staled} 条`);
|
|
@@ -949,7 +1011,7 @@ export class MemoryEngine extends Service {
|
|
|
949
1011
|
hitCount: (block.hitCount ?? 0) + 1,
|
|
950
1012
|
lastUsedAt: now,
|
|
951
1013
|
};
|
|
952
|
-
await
|
|
1014
|
+
await this.putBlock(table, id, updated);
|
|
953
1015
|
}
|
|
954
1016
|
// ── 查询日志与影响力统计(⑥ 2026-09-15)────────────────────────────────
|
|
955
1017
|
/** 查询日志(惰性创建;位置见 {@link queryLogPath})。 */
|
|
@@ -1047,37 +1109,35 @@ export class MemoryEngine extends Service {
|
|
|
1047
1109
|
* @param now - 当前时间(测试注入用)。
|
|
1048
1110
|
* @returns 被撤下的条数(诊断与日志用)。
|
|
1049
1111
|
*/
|
|
1112
|
+
/**
|
|
1113
|
+
* 自动降级的**计量**(2026-09-29 起不再生效):列出「若判据生效会被撤下」的常驻,
|
|
1114
|
+
* **不修改任何状态**。
|
|
1115
|
+
*
|
|
1116
|
+
* 为什么停掉撤销:那个阈值(`AUTO_DEMOTE_DAYS`)至今没有分布依据——它是 2026-09-15
|
|
1117
|
+
* 从面板的折叠阈值 `COLD_DAYS` 借来的。两者的代价不对等:展示阈值折错了点一下就能
|
|
1118
|
+
* 展开,而行为阈值一撤,0.12.0 取消了自动升之后基本不会自己回来。按「没有真实分布时
|
|
1119
|
+
* 拍任何数字都是误杀面」(借自 dsh-mneme 的写入准入阶段一),在拿到分布之前不该继续
|
|
1120
|
+
* 用这个数字动真格的。
|
|
1121
|
+
*
|
|
1122
|
+
* 候选由 {@link sweep} 的 `autoDemoteCandidates` 报出,积累出「实际闲置多久」的形状
|
|
1123
|
+
* 之后再定阈值。判据是 `isAutoDemoteCandidate`——与体检共用一份,不各写一份。
|
|
1124
|
+
*
|
|
1125
|
+
* @param projectCwd - 会话工作区;给了就连工作区表一起扫。
|
|
1126
|
+
* @param now - 当前时间(测试注入用)。
|
|
1127
|
+
* @returns 本会被撤下的常驻清单,按闲置天数降序。
|
|
1128
|
+
*/
|
|
1050
1129
|
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
1130
|
const threshold = now - AUTO_DEMOTE_DAYS * 24 * 60 * 60 * 1000;
|
|
1056
|
-
const
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
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;
|
|
1131
|
+
const summaryChars = this.config.summaryChars ?? DEFAULT_SUMMARY_CHARS;
|
|
1132
|
+
const records = await this.allRecords(undefined, projectCwd);
|
|
1133
|
+
return records
|
|
1134
|
+
.filter(record => isAutoDemoteCandidate(record, threshold))
|
|
1135
|
+
.map(record => ({
|
|
1136
|
+
id: String(record.id),
|
|
1137
|
+
summary: deriveSummary(record.content, summaryChars),
|
|
1138
|
+
idleDays: Math.floor((now - (record.lastUsedAt ?? record.updatedAt)) / (24 * 60 * 60 * 1000)),
|
|
1139
|
+
}))
|
|
1140
|
+
.sort((left, right) => right.idleDays - left.idleDays);
|
|
1081
1141
|
}
|
|
1082
1142
|
/**
|
|
1083
1143
|
* 锚点批量校验(2026-09-15):会话启动时调一次,把失效的条目标成 `stale`。
|
|
@@ -1130,7 +1190,7 @@ export class MemoryEngine extends Service {
|
|
|
1130
1190
|
injected: false,
|
|
1131
1191
|
injectedAuto: false,
|
|
1132
1192
|
};
|
|
1133
|
-
await
|
|
1193
|
+
await this.putBlock(table, id, updated);
|
|
1134
1194
|
this.ctx.emit('memory/changed', { operation: 'status', id: MemoryId(id), status: normalizeBlock(updated).status });
|
|
1135
1195
|
changed += 1;
|
|
1136
1196
|
}
|
|
@@ -1216,7 +1276,7 @@ export class MemoryEngine extends Service {
|
|
|
1216
1276
|
stats.counted += 1;
|
|
1217
1277
|
}
|
|
1218
1278
|
if (updated !== undefined)
|
|
1219
|
-
await
|
|
1279
|
+
await this.putBlock(table, id, updated);
|
|
1220
1280
|
}
|
|
1221
1281
|
}
|
|
1222
1282
|
return stats;
|
|
@@ -1265,7 +1325,7 @@ export class MemoryEngine extends Service {
|
|
|
1265
1325
|
throw new Error(`memory '${id}' is already stored in '${to}'`);
|
|
1266
1326
|
// 先写目标、再删源:中间态里它同时存在于两处(重复好过丢失),而两次写同处一个
|
|
1267
1327
|
// writeScope,外部看不到中间态。
|
|
1268
|
-
await
|
|
1328
|
+
await this.putBlock(destination, id, found.block);
|
|
1269
1329
|
await found.table.delete(id);
|
|
1270
1330
|
// 继承只向上,而 global 人人可见——所以「移进 global」与「移进自己的某个祖先」都能
|
|
1271
1331
|
// 让源层继续看得见它;反过来(global → 某工作区、祖先 → 后代)就是缩小范围。
|
|
@@ -1349,7 +1409,7 @@ export class MemoryEngine extends Service {
|
|
|
1349
1409
|
...status === 'approved' ? { quarantined: false, quarantineReason: undefined } : {},
|
|
1350
1410
|
updatedAt: Date.now(),
|
|
1351
1411
|
};
|
|
1352
|
-
await
|
|
1412
|
+
await this.putBlock(table, id, updated);
|
|
1353
1413
|
const record = toRecord(id, updated);
|
|
1354
1414
|
this.ctx.emit('memory/changed', { operation: 'status', id, status });
|
|
1355
1415
|
return record;
|
|
@@ -1386,7 +1446,7 @@ export class MemoryEngine extends Service {
|
|
|
1386
1446
|
injectedAuto: false,
|
|
1387
1447
|
updatedAt: Date.now(),
|
|
1388
1448
|
};
|
|
1389
|
-
await found.table
|
|
1449
|
+
await this.putBlock(found.table, id, updated);
|
|
1390
1450
|
const record = toRecord(id, updated);
|
|
1391
1451
|
this.ctx.emit('memory/changed', { operation: 'injected', id, injected });
|
|
1392
1452
|
return record;
|
|
@@ -1445,7 +1505,7 @@ export class MemoryEngine extends Service {
|
|
|
1445
1505
|
injectedAuto: false,
|
|
1446
1506
|
updatedAt: Date.now(),
|
|
1447
1507
|
};
|
|
1448
|
-
await found.table
|
|
1508
|
+
await this.putBlock(found.table, id, updated);
|
|
1449
1509
|
this.ctx.emit('memory/changed', { operation: 'status', id, status: normalizeBlock(updated).status });
|
|
1450
1510
|
return updated;
|
|
1451
1511
|
}
|
|
@@ -1531,7 +1591,7 @@ export class MemoryEngine extends Service {
|
|
|
1531
1591
|
throw new Error(`cannot update unknown memory '${id}'`);
|
|
1532
1592
|
const { table, block } = found;
|
|
1533
1593
|
const updated = applyPatch(block);
|
|
1534
|
-
await
|
|
1594
|
+
await this.putBlock(table, id, updated);
|
|
1535
1595
|
return publish(block, updated);
|
|
1536
1596
|
}
|
|
1537
1597
|
/**
|
|
@@ -1679,7 +1739,7 @@ export class MemoryEngine extends Service {
|
|
|
1679
1739
|
source: file.meta.source,
|
|
1680
1740
|
},
|
|
1681
1741
|
};
|
|
1682
|
-
await
|
|
1742
|
+
await this.putBlock(table, id, block);
|
|
1683
1743
|
changed++;
|
|
1684
1744
|
}
|
|
1685
1745
|
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: [
|
|
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: [
|
|
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: [
|
|
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/dist/schema.d.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 存储块的 schema —— **零 peer 依赖地可独立加载**。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独成模块:离线扫描器(`scripts/check-memory-files.mjs`)要 import 编译产物来跑
|
|
5
|
+
* 存量校验,而 `engine.js` 在运行时 import 了 `@deepseek-ai/cordis`、
|
|
6
|
+
* `@deepseek-ai/dsh-storage-domain`、`@deepseek-ai/dsh-storage-json`——那三个都是**由宿主
|
|
7
|
+
* 提供的 peer**,装版实体里没有。2026-09-29 实测:在 profile 里直接跑扫描器会报
|
|
8
|
+
* `Cannot find package '@deepseek-ai/cordis' imported from dist/engine.js`,于是
|
|
9
|
+
* 「用插件自己的 schema 扫存量」这条设计**在用户环境里根本跑不起来**。
|
|
10
|
+
*
|
|
11
|
+
* 本模块只依赖 `zod`(真 `dependencies`,装版有)与 `./anchors.ts`(只有 `node:fs` /
|
|
12
|
+
* `node:path`),所以 `dist/schema.js` 能被独立加载——**判据仍然只有一份**,不另写。
|
|
13
|
+
*
|
|
14
|
+
* @see docs/设计/2026-09-25-写入兜底与失败可见化-设计方案.md §四.3
|
|
15
|
+
*/
|
|
16
|
+
import { z } from 'zod';
|
|
17
|
+
/**
|
|
18
|
+
* 存储块的 schema(门一与门三共用的判据来源,2026-09-25)。
|
|
19
|
+
*
|
|
20
|
+
* 门一(写入端)在 `putBlock` 里用它拒绝不合规的块;门三(存量体检)在扫描器里用它逐条
|
|
21
|
+
* 校验已有文件——**同一份定义**,因为两份判据会漂移,而漂移的方向恰好是「扫描器说没事、
|
|
22
|
+
* 打开时炸」这种最难查的形态。
|
|
23
|
+
*/
|
|
24
|
+
export declare const blockSchema: z.ZodObject<{
|
|
25
|
+
namespace: z.ZodEnum<{
|
|
26
|
+
global: "global";
|
|
27
|
+
project: "project";
|
|
28
|
+
}>;
|
|
29
|
+
status: z.ZodEnum<{
|
|
30
|
+
suggested: "suggested";
|
|
31
|
+
approved: "approved";
|
|
32
|
+
auto: "auto";
|
|
33
|
+
suggest: "suggest";
|
|
34
|
+
}>;
|
|
35
|
+
injected: z.ZodOptional<z.ZodBoolean>;
|
|
36
|
+
quarantined: z.ZodOptional<z.ZodBoolean>;
|
|
37
|
+
quarantineReason: z.ZodOptional<z.ZodString>;
|
|
38
|
+
hitCount: z.ZodOptional<z.ZodNumber>;
|
|
39
|
+
source: z.ZodOptional<z.ZodEnum<{
|
|
40
|
+
agent: "agent";
|
|
41
|
+
human: "human";
|
|
42
|
+
}>>;
|
|
43
|
+
injectedAuto: z.ZodOptional<z.ZodBoolean>;
|
|
44
|
+
anchor: z.ZodOptional<z.ZodObject<{
|
|
45
|
+
kind: z.ZodEnum<{
|
|
46
|
+
env: "env";
|
|
47
|
+
"tool-list": "tool-list";
|
|
48
|
+
"self-version": "self-version";
|
|
49
|
+
"path-exists": "path-exists";
|
|
50
|
+
}>;
|
|
51
|
+
name: z.ZodOptional<z.ZodString>;
|
|
52
|
+
value: z.ZodString;
|
|
53
|
+
}, z.core.$strip>>;
|
|
54
|
+
stale: z.ZodOptional<z.ZodBoolean>;
|
|
55
|
+
staleReason: z.ZodOptional<z.ZodString>;
|
|
56
|
+
content: z.ZodString;
|
|
57
|
+
keywords: z.ZodArray<z.ZodString>;
|
|
58
|
+
createdAt: z.ZodNumber;
|
|
59
|
+
updatedAt: z.ZodNumber;
|
|
60
|
+
lastUsedAt: z.ZodOptional<z.ZodNumber>;
|
|
61
|
+
vector: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
|
|
62
|
+
kind: z.ZodOptional<z.ZodEnum<{
|
|
63
|
+
fact: "fact";
|
|
64
|
+
prompt: "prompt";
|
|
65
|
+
}>>;
|
|
66
|
+
meta: z.ZodOptional<z.ZodObject<{
|
|
67
|
+
seq: z.ZodOptional<z.ZodNumber>;
|
|
68
|
+
name: z.ZodString;
|
|
69
|
+
dimension: z.ZodOptional<z.ZodString>;
|
|
70
|
+
difficulty: z.ZodOptional<z.ZodString>;
|
|
71
|
+
tags: z.ZodArray<z.ZodString>;
|
|
72
|
+
summary: z.ZodString;
|
|
73
|
+
path: z.ZodString;
|
|
74
|
+
mtime: z.ZodNumber;
|
|
75
|
+
source: z.ZodEnum<{
|
|
76
|
+
user: "user";
|
|
77
|
+
agent: "agent";
|
|
78
|
+
}>;
|
|
79
|
+
}, z.core.$strip>>;
|
|
80
|
+
}, z.core.$strip>;
|
|
81
|
+
/**
|
|
82
|
+
* 把 zod 的 issue 压成一行(门一,2026-09-25)。
|
|
83
|
+
*
|
|
84
|
+
* 排查成本的大头在「从 domain 打不开倒推」——`dsh-storage-domain` 封装后的错误只给到
|
|
85
|
+
* 「哪个 domain 的哪条记录不合规」(`stored record '<id>' in table 'blocks' does not
|
|
86
|
+
* match its schema`),**说不出是哪个字段、什么值、期望什么**。这行补的就是那段。
|
|
87
|
+
*/
|
|
88
|
+
export declare function describeIssues(error: z.ZodError): string;
|
package/dist/schema.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 存储块的 schema —— **零 peer 依赖地可独立加载**。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独成模块:离线扫描器(`scripts/check-memory-files.mjs`)要 import 编译产物来跑
|
|
5
|
+
* 存量校验,而 `engine.js` 在运行时 import 了 `@deepseek-ai/cordis`、
|
|
6
|
+
* `@deepseek-ai/dsh-storage-domain`、`@deepseek-ai/dsh-storage-json`——那三个都是**由宿主
|
|
7
|
+
* 提供的 peer**,装版实体里没有。2026-09-29 实测:在 profile 里直接跑扫描器会报
|
|
8
|
+
* `Cannot find package '@deepseek-ai/cordis' imported from dist/engine.js`,于是
|
|
9
|
+
* 「用插件自己的 schema 扫存量」这条设计**在用户环境里根本跑不起来**。
|
|
10
|
+
*
|
|
11
|
+
* 本模块只依赖 `zod`(真 `dependencies`,装版有)与 `./anchors.ts`(只有 `node:fs` /
|
|
12
|
+
* `node:path`),所以 `dist/schema.js` 能被独立加载——**判据仍然只有一份**,不另写。
|
|
13
|
+
*
|
|
14
|
+
* @see docs/设计/2026-09-25-写入兜底与失败可见化-设计方案.md §四.3
|
|
15
|
+
*/
|
|
16
|
+
import { z } from 'zod';
|
|
17
|
+
import { ANCHOR_KINDS } from "./anchors.js";
|
|
18
|
+
/**
|
|
19
|
+
* 存储块的 schema(门一与门三共用的判据来源,2026-09-25)。
|
|
20
|
+
*
|
|
21
|
+
* 门一(写入端)在 `putBlock` 里用它拒绝不合规的块;门三(存量体检)在扫描器里用它逐条
|
|
22
|
+
* 校验已有文件——**同一份定义**,因为两份判据会漂移,而漂移的方向恰好是「扫描器说没事、
|
|
23
|
+
* 打开时炸」这种最难查的形态。
|
|
24
|
+
*/
|
|
25
|
+
export const blockSchema = z.object({
|
|
26
|
+
namespace: z.enum(['global', 'project']),
|
|
27
|
+
status: z.enum(['suggested', 'approved', 'auto', 'suggest']),
|
|
28
|
+
injected: z.boolean().optional(),
|
|
29
|
+
quarantined: z.boolean().optional(),
|
|
30
|
+
quarantineReason: z.string().optional(),
|
|
31
|
+
hitCount: z.number().optional(),
|
|
32
|
+
source: z.enum(['agent', 'human']).optional(),
|
|
33
|
+
injectedAuto: z.boolean().optional(),
|
|
34
|
+
anchor: z.object({
|
|
35
|
+
kind: z.enum(ANCHOR_KINDS),
|
|
36
|
+
name: z.string().optional(),
|
|
37
|
+
value: z.string(),
|
|
38
|
+
}).optional(),
|
|
39
|
+
stale: z.boolean().optional(),
|
|
40
|
+
staleReason: z.string().optional(),
|
|
41
|
+
content: z.string(),
|
|
42
|
+
keywords: z.array(z.string()),
|
|
43
|
+
createdAt: z.number(),
|
|
44
|
+
updatedAt: z.number(),
|
|
45
|
+
lastUsedAt: z.number().optional(),
|
|
46
|
+
vector: z.array(z.number()).optional(),
|
|
47
|
+
kind: z.enum(['fact', 'prompt']).optional(),
|
|
48
|
+
meta: z.object({
|
|
49
|
+
seq: z.number().optional(),
|
|
50
|
+
name: z.string(),
|
|
51
|
+
dimension: z.string().optional(),
|
|
52
|
+
difficulty: z.string().optional(),
|
|
53
|
+
tags: z.array(z.string()),
|
|
54
|
+
summary: z.string(),
|
|
55
|
+
path: z.string(),
|
|
56
|
+
mtime: z.number(),
|
|
57
|
+
source: z.enum(['user', 'agent']),
|
|
58
|
+
}).optional(),
|
|
59
|
+
});
|
|
60
|
+
/**
|
|
61
|
+
* 把 zod 的 issue 压成一行(门一,2026-09-25)。
|
|
62
|
+
*
|
|
63
|
+
* 排查成本的大头在「从 domain 打不开倒推」——`dsh-storage-domain` 封装后的错误只给到
|
|
64
|
+
* 「哪个 domain 的哪条记录不合规」(`stored record '<id>' in table 'blocks' does not
|
|
65
|
+
* match its schema`),**说不出是哪个字段、什么值、期望什么**。这行补的就是那段。
|
|
66
|
+
*/
|
|
67
|
+
export function describeIssues(error) {
|
|
68
|
+
return error.issues.map(issue => `${issue.path.join('.') || '(root)'}: ${issue.message}`).join('; ');
|
|
69
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@max-null/dsh-memory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.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,133 @@
|
|
|
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/schema.js` import)逐条 `safeParse`。
|
|
11
|
+
* 同源是刻意的——另写一套判定迟早漂移成「扫描器说没事、打开时炸」这种最难查的形态。
|
|
12
|
+
*
|
|
13
|
+
* 为什么是 `dist/schema.js` 而不是 `dist/engine.js`:后者在运行时 import 了 `cordis` /
|
|
14
|
+
* `dsh-storage-domain` / `dsh-storage-json`,那三个是**宿主提供的 peer**,装版实体里没有
|
|
15
|
+
* ——直接加载会报 `Cannot find package '@deepseek-ai/cordis'`(2026-09-29 实测)。
|
|
16
|
+
* `schema.js` 只依赖 `zod`(真 dependencies)与 `anchors.js`(只有 node 内置),能独立加载。
|
|
17
|
+
*
|
|
18
|
+
* **哪些情况答不了**:
|
|
19
|
+
* - 只校验 `tables.blocks` 里的条目,不校验外层信封结构;
|
|
20
|
+
* - 不处理压缩帧(那是 `unzstd-frames.mjs` 的事);
|
|
21
|
+
* - 不判断「记录该不该存在」——那是价值判断,本脚本只看格式。
|
|
22
|
+
*
|
|
23
|
+
* 用法:
|
|
24
|
+
* node scripts/check-memory-files.mjs # 扫 $DSH_HOME 与当前目录下的记忆文件
|
|
25
|
+
* node scripts/check-memory-files.mjs <路径...> # 扫指定文件或目录(目录会递归找 *.json)
|
|
26
|
+
*
|
|
27
|
+
* 退出码:0 = 全绿 / 1 = 命中不合规 / 2 = 用法或环境错误。
|
|
28
|
+
*
|
|
29
|
+
* 前置:本脚本 import 编译产物 `dist/engine.js`,所以要先 `npm run build`。
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
|
33
|
+
import { homedir } from 'node:os'
|
|
34
|
+
import { join, resolve } from 'node:path'
|
|
35
|
+
|
|
36
|
+
const USAGE = 2
|
|
37
|
+
|
|
38
|
+
function fail(message) {
|
|
39
|
+
console.error(`check-memory-files: ${message}`)
|
|
40
|
+
process.exit(USAGE)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let blockSchema
|
|
44
|
+
try {
|
|
45
|
+
({ blockSchema } = await import('../dist/schema.js'))
|
|
46
|
+
} catch (error) {
|
|
47
|
+
fail(
|
|
48
|
+
`无法加载 dist/schema.js(先跑 npm run build):${error instanceof Error ? error.message : String(error)}`,
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
if (blockSchema === undefined) fail('dist/schema.js 没有导出 blockSchema——构建产物太旧?')
|
|
52
|
+
|
|
53
|
+
/** 递归收集目录下的 *.json(跳过 node_modules)。 */
|
|
54
|
+
function collect(target, out = []) {
|
|
55
|
+
let stat
|
|
56
|
+
try {
|
|
57
|
+
stat = statSync(target)
|
|
58
|
+
} catch {
|
|
59
|
+
return out
|
|
60
|
+
}
|
|
61
|
+
if (stat.isFile()) {
|
|
62
|
+
if (target.endsWith('.json')) out.push(target)
|
|
63
|
+
return out
|
|
64
|
+
}
|
|
65
|
+
if (!stat.isDirectory()) return out
|
|
66
|
+
if (target.includes('node_modules')) return out
|
|
67
|
+
for (const entry of readdirSync(target, { withFileTypes: true })) {
|
|
68
|
+
if (entry.name === 'node_modules') continue
|
|
69
|
+
collect(join(target, entry.name), out)
|
|
70
|
+
}
|
|
71
|
+
return out
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 默认扫描目标:$DSH_HOME/storages 与当前目录的 .dsh/storages。 */
|
|
75
|
+
function defaultTargets() {
|
|
76
|
+
const dshHome = process.env.DSH_HOME ?? join(homedir(), '.dsh')
|
|
77
|
+
const targets = [join(dshHome, 'storages'), join(process.cwd(), '.dsh', 'storages')]
|
|
78
|
+
return targets.filter(existsSync)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const args = process.argv.slice(2)
|
|
82
|
+
const explicit = args.length > 0
|
|
83
|
+
const targets = explicit ? args.map(arg => resolve(arg)) : defaultTargets()
|
|
84
|
+
|
|
85
|
+
// 显式给的路径必须存在。**把「什么都没扫到」报成「全绿」是同一个病**——偏小的数字看起来
|
|
86
|
+
// 正常,而判断者会据此认为存量是干净的。门二存在的理由就是这句话。
|
|
87
|
+
if (explicit) {
|
|
88
|
+
const missing = targets.filter(target => !existsSync(target))
|
|
89
|
+
if (missing.length > 0) fail(`路径不存在:${missing.join('、')}`)
|
|
90
|
+
}
|
|
91
|
+
if (targets.length === 0) fail('没有可扫描的目标(既没给路径,也没找到 $DSH_HOME/storages 或 ./.dsh/storages)')
|
|
92
|
+
|
|
93
|
+
const files = [...new Set(targets.flatMap(target => collect(target)))].sort()
|
|
94
|
+
let scanned = 0
|
|
95
|
+
let records = 0
|
|
96
|
+
const violations = []
|
|
97
|
+
|
|
98
|
+
for (const file of files) {
|
|
99
|
+
let raw
|
|
100
|
+
try {
|
|
101
|
+
raw = JSON.parse(readFileSync(file, 'utf8'))
|
|
102
|
+
} catch {
|
|
103
|
+
continue // 不是 JSON(或不是存储文件)—— 不是本脚本的事
|
|
104
|
+
}
|
|
105
|
+
const blocks = raw?.tables?.blocks
|
|
106
|
+
if (blocks === null || typeof blocks !== 'object') continue
|
|
107
|
+
scanned += 1
|
|
108
|
+
for (const [id, block] of Object.entries(blocks)) {
|
|
109
|
+
records += 1
|
|
110
|
+
const parsed = blockSchema.safeParse(block)
|
|
111
|
+
if (parsed.success) continue
|
|
112
|
+
const issues = parsed.error.issues
|
|
113
|
+
.map(issue => `${issue.path.join('.') || '(root)'}: ${issue.message}`)
|
|
114
|
+
.join('; ')
|
|
115
|
+
violations.push({ file, id, issues })
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
console.log(`check-memory-files: 扫描 ${scanned} 个记忆文件 / ${records} 条记录`)
|
|
120
|
+
// 一个记忆文件都没扫到,同样是可疑的——不能报「全部合规」。判断者需要知道这次体检
|
|
121
|
+
// **没有覆盖任何东西**(同门二:读不到就说读不到)。
|
|
122
|
+
if (scanned === 0) fail(`看了 ${files.length} 个 JSON,但没有一个是记忆文件——目标选对了吗?`)
|
|
123
|
+
if (violations.length === 0) {
|
|
124
|
+
console.log('check-memory-files: ✅ 全部合规')
|
|
125
|
+
process.exit(0)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
console.error(`check-memory-files: ✗ ${violations.length} 条不合规——它们会让所在 domain 打不开`)
|
|
129
|
+
for (const violation of violations) {
|
|
130
|
+
console.error(` ${violation.file}`)
|
|
131
|
+
console.error(` ${violation.id}: ${violation.issues}`)
|
|
132
|
+
}
|
|
133
|
+
process.exit(1)
|