@max-null/dsh-memory 0.10.0 → 0.11.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 +10 -3
- package/dist/engine.d.ts +42 -0
- package/dist/engine.js +109 -10
- package/dist/index.js +30 -0
- package/package.json +4 -2
- package/scripts/ancestor-probe.mjs +415 -0
- package/scripts/unzstd-frames.mjs +90 -0
package/README.md
CHANGED
|
@@ -39,8 +39,8 @@ npm install @max-null/dsh-memory
|
|
|
39
39
|
|
|
40
40
|
## 提供的服务与工具
|
|
41
41
|
|
|
42
|
-
- **服务** `ctx.memory`:`remember` / `list` / `search` / `forget` / `setStatus`
|
|
43
|
-
- **工具**:`memory_save`、`memory_list`、`memory_search`、`memory_confirm`、`memory_forget`、`memory_update`
|
|
42
|
+
- **服务** `ctx.memory`:`remember` / `list` / `search` / `forget` / `setStatus` / `move`
|
|
43
|
+
- **工具**:`memory_save`、`memory_list`、`memory_search`、`memory_confirm`、`memory_forget`、`memory_update`、`memory_move`
|
|
44
44
|
- **注入**:`tool:memory` 指引 section(工具用法 + 常驻注入判据)+ `memory:self` 机制自述 + `memory:recall` 召回 context(global 的 `approved + injected` + 当前会话工作区的 `approved + injected`,带 `[memory:<id>:<namespace>]` 来源标记;摘要化 + 预算截断)
|
|
45
45
|
- **检索**:BM25(CJK 单字 + 2-gram,content 与 keywords 字段分离加权;中文多字查询精度显著优于单字切分);可选语义融合(见「可选配置」)
|
|
46
46
|
|
|
@@ -55,7 +55,7 @@ npm install @max-null/dsh-memory
|
|
|
55
55
|
|
|
56
56
|
两个根都可用 config 覆盖(`globalRoot` / `projectRoot`)。`memory_list` / `memory_search` 不带 `namespace` 过滤时会同时查两层。旧版双重前缀文件名(`memory_project_memory_project_<hash>.json`)在打开时自动迁移为规范名。
|
|
57
57
|
|
|
58
|
-
## 跨工作区可见性(0.10.0)
|
|
58
|
+
## 跨工作区可见性(0.10.0 / 0.11.0)
|
|
59
59
|
|
|
60
60
|
project 记忆按**会话工作区**分文件存放,所以「工作区」就是可见性的边界。0.10.0 起补了三个方向——针对的是同一个盲区:**不知道存在**(检索是有意图的动作,搜不出自己不知道存在的东西)。
|
|
61
61
|
|
|
@@ -67,10 +67,14 @@ project 记忆按**会话工作区**分文件存放,所以「工作区」就
|
|
|
67
67
|
|
|
68
68
|
三条边界:**写入永远只落当前工作区**(「我在这个项目里记的东西」不该被推理到别处);但**检索得到的记录就改得动**——`memory_update` / `memory_forget` 同样沿链定位;**注入路径只读当前工作区**(远处的记忆进检索、不进每轮成本)。
|
|
69
69
|
|
|
70
|
+
**0.11.0 起,「放错了层」有了显式的修正通道**:`memory_move(id, to)` 把一条记忆移到 `global`、`self`(当前工作区)或相对祖先(`..` / `../..`)。它是**例外动作**——默认写入仍然只落当前工作区,只有当你判定「这条其实属于上一层、或上一层的那一支」时才动它。移动**保持 id**、**从源层删除**(不是复制),并报告移动后源层是否仍看得见它:向上移动源层不失明(继承只向上,`global` 人人可见),向下移动则会让源层及其下层从此看不见它。典型用途是**把放高了的记忆降回它真正属于的工作区**——比如只属于某一个生态的规矩躺在 `global` 里、漏进了无关项目。判据与边界见设计文档 §3.2。
|
|
71
|
+
|
|
70
72
|
后两行**不占注入预算**、自消除(没有内容时整行不出现),且**长度常数级**——不随记忆增长。索引行里的主题取自各条的关键词词频,与检索用的是同一套词汇,所以**索引里出现的词就是能搜到的词**。
|
|
71
73
|
|
|
72
74
|
**孤儿文件清点**:`node scripts/scan-orphans.mjs [根目录]` 报出「哪些记忆文件当前代码已经打不开」(历史命名遗留:早期无哈希后缀、双重 `memory_project_` 前缀)。**只读,不做任何处置**——旧文件可能含未过隔离检查的内容,处置需人工拍板。
|
|
73
75
|
|
|
76
|
+
**祖先链诊断与探针**:`node scripts/ancestor-probe.mjs plan [--cwd <会话 cwd>]` 只读地复算该 cwd 的祖先链(哪些层级带记忆文件、引擎会纳入几级),并回答「这个 cwd 上祖先链是否**可观测**」——若以上祖先层都没有记忆文件,祖先链生效与否行为逐字相同,那是个零差异的观测点。要实测检索侧跨链,用 `seed` / `check` / `clean` 在祖先层造一条临时探针再撤销。**前提**:探针只对**没打开过该层**的会话有效——引擎的表打开即缓存,而写路径的新鲜度门(`refreshForWrite`)只比对 global 与当前 cwd,**不含祖先层**,被缓存过的层会用陈旧内存态覆盖掉外部写入。
|
|
77
|
+
|
|
74
78
|
## 多实例共存(0.7.1)
|
|
75
79
|
|
|
76
80
|
两个 DSH 实例(例如 DSH web 与 SSiD 桌面壳)可以同时运行、共用同一个 `DSH_HOME`,**记忆不再互相抹掉**。
|
|
@@ -143,6 +147,9 @@ npm run typecheck # tsc 严格类型检查
|
|
|
143
147
|
npm test # vitest 单测
|
|
144
148
|
npm run build # 产出 dist/
|
|
145
149
|
node scripts/verify-loader.mjs # 用 Loader 端到端验证插件可加载
|
|
150
|
+
node scripts/scan-orphans.mjs [根目录] # 清点「当前代码打不开」的记忆文件(只读)
|
|
151
|
+
node scripts/unzstd-frames.mjs <源> <目标> # 解多帧 zstd(读 DSH 会话日志用)
|
|
152
|
+
node scripts/ancestor-probe.mjs plan [--cwd <cwd>] # 复算祖先链(只读);seed/check/clean 验 ④-A
|
|
146
153
|
```
|
|
147
154
|
|
|
148
155
|
## 依赖(peerDependencies,由宿主提供)
|
package/dist/engine.d.ts
CHANGED
|
@@ -178,6 +178,11 @@ export type MemoryChange = {
|
|
|
178
178
|
operation: 'quarantined';
|
|
179
179
|
id: MemoryId;
|
|
180
180
|
quarantined: boolean;
|
|
181
|
+
} | {
|
|
182
|
+
operation: 'moved';
|
|
183
|
+
id: MemoryId;
|
|
184
|
+
from: string;
|
|
185
|
+
to: string;
|
|
181
186
|
};
|
|
182
187
|
declare module '@deepseek-ai/cordis' {
|
|
183
188
|
interface Context {
|
|
@@ -364,6 +369,15 @@ export declare class MemoryEngine extends Service {
|
|
|
364
369
|
* 那时同步缓存已被清空(同 {@link markUsedInner} 的注释)。
|
|
365
370
|
*/
|
|
366
371
|
private locateRecord;
|
|
372
|
+
/**
|
|
373
|
+
* 写入协调覆盖的存储文件:global + **全部已打开的工作区表** + 本次 cwd。
|
|
374
|
+
*
|
|
375
|
+
* 记录({@link noteStoreStamps})与比对({@link refreshForWrite})必须共用这一个定义——
|
|
376
|
+
* 两边各写一份正是漂移的来源:在此之前比对侧只列 global + cwd,而 ④-A 把**读**路径扩到了
|
|
377
|
+
* 祖先链(`projectChain`),于是被缓存过的祖先层「指纹记下了却从不被检查」——外部改动看不见,
|
|
378
|
+
* 下一次写回把它**整份覆盖**。**读扩到哪,门就得跟着扩到哪。**
|
|
379
|
+
*/
|
|
380
|
+
private storeScopeFiles;
|
|
367
381
|
/** 记录当前指纹,作为「这就是我们造成的状态」的基准(写入完成后调用)。 */
|
|
368
382
|
private noteStoreStamps;
|
|
369
383
|
/**
|
|
@@ -508,6 +522,34 @@ export declare class MemoryEngine extends Service {
|
|
|
508
522
|
forget(id: MemoryId, projectCwd?: string): Promise<boolean>;
|
|
509
523
|
/** {@link forget} 的实现体;必须经 `writeScope` 进入。 */
|
|
510
524
|
private forgetInner;
|
|
525
|
+
/**
|
|
526
|
+
* 把一条记忆移到另一层(迭代设计 §3.2)。**两个方向共用一个操作**:向上提到祖先层或
|
|
527
|
+
* `global`,向下把误放高层的降回它真正属于的工作区。
|
|
528
|
+
*
|
|
529
|
+
* 原设计(`memory_promote`)只允许向上,理由是「往下或横向会让原本能看见的人失明」。
|
|
530
|
+
* 但**移动的本意就是改变谁能看见**——那不是副作用,是目的。所以约束从「禁止方向」
|
|
531
|
+
* 换成「让后果可见」:返回值里的 `sourceStillSees` 就是那个后果。
|
|
532
|
+
*/
|
|
533
|
+
move(id: MemoryId, to: string, projectCwd?: string): Promise<{
|
|
534
|
+
id: MemoryId;
|
|
535
|
+
from: string;
|
|
536
|
+
to: string;
|
|
537
|
+
sourceStillSees: boolean;
|
|
538
|
+
}>;
|
|
539
|
+
/** {@link move} 的实现体;必须经 `writeScope` 进入。 */
|
|
540
|
+
private moveInner;
|
|
541
|
+
/** 按 id 定位记录**并带上它所在的层**(`locateRecord` 只返回表,而移动必须知道来源层)。 */
|
|
542
|
+
private locateWithKey;
|
|
543
|
+
/**
|
|
544
|
+
* 解析 `memory_move` 的目标:`global`、`self` / `.`(当前 cwd),或相对祖先(`..`、`../..`)。
|
|
545
|
+
*
|
|
546
|
+
* 用**相对深度**而不是绝对路径,是为了和来源标记 `scope`(`..` / `../..`)说同一套话。
|
|
547
|
+
* 允许落在**还没有记忆文件**的祖先层——移动是显式动作,在那儿创建文件正是目的,
|
|
548
|
+
* 不是 `projectChain` 要避免的那种副作用。
|
|
549
|
+
*/
|
|
550
|
+
private static resolveTarget;
|
|
551
|
+
/** `ancestor` 是否就是 `node` 本身或它的祖先——用来判断移动后源层是否仍看得见。 */
|
|
552
|
+
private static isAncestorOrSelf;
|
|
511
553
|
setStatus(id: MemoryId, status: MemoryStatus, projectCwd?: string): Promise<MemoryRecord>;
|
|
512
554
|
/** {@link setStatus} 的实现体;必须经 `writeScope` 进入。 */
|
|
513
555
|
private setStatusInner;
|
package/dist/engine.js
CHANGED
|
@@ -508,15 +508,26 @@ export class MemoryEngine extends Service {
|
|
|
508
508
|
}
|
|
509
509
|
return undefined;
|
|
510
510
|
}
|
|
511
|
-
/**
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
511
|
+
/**
|
|
512
|
+
* 写入协调覆盖的存储文件:global + **全部已打开的工作区表** + 本次 cwd。
|
|
513
|
+
*
|
|
514
|
+
* 记录({@link noteStoreStamps})与比对({@link refreshForWrite})必须共用这一个定义——
|
|
515
|
+
* 两边各写一份正是漂移的来源:在此之前比对侧只列 global + cwd,而 ④-A 把**读**路径扩到了
|
|
516
|
+
* 祖先链(`projectChain`),于是被缓存过的祖先层「指纹记下了却从不被检查」——外部改动看不见,
|
|
517
|
+
* 下一次写回把它**整份覆盖**。**读扩到哪,门就得跟着扩到哪。**
|
|
518
|
+
*/
|
|
519
|
+
storeScopeFiles(projectCwd) {
|
|
520
|
+
const files = [this.globalStoreFile()];
|
|
515
521
|
const scopes = new Set(this.projectTables.keys());
|
|
516
522
|
if (projectCwd !== undefined && projectCwd !== '')
|
|
517
523
|
scopes.add(join(projectCwd));
|
|
518
|
-
for (const key of scopes)
|
|
519
|
-
|
|
524
|
+
for (const key of scopes)
|
|
525
|
+
files.push(this.projectStoreFile(key));
|
|
526
|
+
return files;
|
|
527
|
+
}
|
|
528
|
+
/** 记录当前指纹,作为「这就是我们造成的状态」的基准(写入完成后调用)。 */
|
|
529
|
+
noteStoreStamps(projectCwd) {
|
|
530
|
+
for (const file of this.storeScopeFiles(projectCwd)) {
|
|
520
531
|
this.storeStamps.set(file, MemoryEngine.fileStamp(file));
|
|
521
532
|
}
|
|
522
533
|
}
|
|
@@ -529,10 +540,7 @@ export class MemoryEngine extends Service {
|
|
|
529
540
|
this.noteStoreStamps(projectCwd);
|
|
530
541
|
return;
|
|
531
542
|
}
|
|
532
|
-
const
|
|
533
|
-
if (projectCwd !== undefined && projectCwd !== '')
|
|
534
|
-
files.push(this.projectStoreFile(projectCwd));
|
|
535
|
-
for (const file of files) {
|
|
543
|
+
for (const file of this.storeScopeFiles(projectCwd)) {
|
|
536
544
|
if (MemoryEngine.fileStamp(file) === this.storeStamps.get(file))
|
|
537
545
|
continue;
|
|
538
546
|
await this.reload();
|
|
@@ -1111,6 +1119,97 @@ export class MemoryEngine extends Service {
|
|
|
1111
1119
|
}
|
|
1112
1120
|
return false;
|
|
1113
1121
|
}
|
|
1122
|
+
/**
|
|
1123
|
+
* 把一条记忆移到另一层(迭代设计 §3.2)。**两个方向共用一个操作**:向上提到祖先层或
|
|
1124
|
+
* `global`,向下把误放高层的降回它真正属于的工作区。
|
|
1125
|
+
*
|
|
1126
|
+
* 原设计(`memory_promote`)只允许向上,理由是「往下或横向会让原本能看见的人失明」。
|
|
1127
|
+
* 但**移动的本意就是改变谁能看见**——那不是副作用,是目的。所以约束从「禁止方向」
|
|
1128
|
+
* 换成「让后果可见」:返回值里的 `sourceStillSees` 就是那个后果。
|
|
1129
|
+
*/
|
|
1130
|
+
async move(id, to, projectCwd) {
|
|
1131
|
+
return this.writeScope(projectCwd, () => this.moveInner(id, to, projectCwd));
|
|
1132
|
+
}
|
|
1133
|
+
/** {@link move} 的实现体;必须经 `writeScope` 进入。 */
|
|
1134
|
+
async moveInner(id, to, projectCwd) {
|
|
1135
|
+
const target = MemoryEngine.resolveTarget(to, projectCwd);
|
|
1136
|
+
const found = await this.locateWithKey(id, projectCwd);
|
|
1137
|
+
if (found === undefined)
|
|
1138
|
+
throw new Error(`cannot move unknown memory '${id}'`);
|
|
1139
|
+
const destination = target.kind === 'global'
|
|
1140
|
+
? this.requireTable('global')
|
|
1141
|
+
: await this.projectTableFor(target.key);
|
|
1142
|
+
if (destination === undefined)
|
|
1143
|
+
throw new Error('cannot move a project memory without a workspace cwd');
|
|
1144
|
+
if (destination === found.table)
|
|
1145
|
+
throw new Error(`memory '${id}' is already stored in '${to}'`);
|
|
1146
|
+
// 先写目标、再删源:中间态里它同时存在于两处(重复好过丢失),而两次写同处一个
|
|
1147
|
+
// writeScope,外部看不到中间态。
|
|
1148
|
+
await destination.put(id, found.block);
|
|
1149
|
+
await found.table.delete(id);
|
|
1150
|
+
// 继承只向上,而 global 人人可见——所以「移进 global」与「移进自己的某个祖先」都能
|
|
1151
|
+
// 让源层继续看得见它;反过来(global → 某工作区、祖先 → 后代)就是缩小范围。
|
|
1152
|
+
const sourceStillSees = target.kind === 'global'
|
|
1153
|
+
|| (found.key !== 'global' && MemoryEngine.isAncestorOrSelf(target.key, found.key));
|
|
1154
|
+
const movedTo = target.kind === 'global' ? 'global' : target.key;
|
|
1155
|
+
this.ctx.emit('memory/changed', { operation: 'moved', id, from: found.key, to: movedTo });
|
|
1156
|
+
return { id, from: found.key, to: movedTo, sourceStillSees };
|
|
1157
|
+
}
|
|
1158
|
+
/** 按 id 定位记录**并带上它所在的层**(`locateRecord` 只返回表,而移动必须知道来源层)。 */
|
|
1159
|
+
async locateWithKey(id, projectCwd) {
|
|
1160
|
+
const global = this.requireTable('global');
|
|
1161
|
+
const globalBlock = global.get(id);
|
|
1162
|
+
if (globalBlock !== undefined)
|
|
1163
|
+
return { key: 'global', table: global, block: globalBlock };
|
|
1164
|
+
for (const { key, table } of await this.projectChainTables(projectCwd)) {
|
|
1165
|
+
const block = table.get(id);
|
|
1166
|
+
if (block !== undefined)
|
|
1167
|
+
return { key, table, block };
|
|
1168
|
+
}
|
|
1169
|
+
return undefined;
|
|
1170
|
+
}
|
|
1171
|
+
/**
|
|
1172
|
+
* 解析 `memory_move` 的目标:`global`、`self` / `.`(当前 cwd),或相对祖先(`..`、`../..`)。
|
|
1173
|
+
*
|
|
1174
|
+
* 用**相对深度**而不是绝对路径,是为了和来源标记 `scope`(`..` / `../..`)说同一套话。
|
|
1175
|
+
* 允许落在**还没有记忆文件**的祖先层——移动是显式动作,在那儿创建文件正是目的,
|
|
1176
|
+
* 不是 `projectChain` 要避免的那种副作用。
|
|
1177
|
+
*/
|
|
1178
|
+
static resolveTarget(to, projectCwd) {
|
|
1179
|
+
const raw = to.trim();
|
|
1180
|
+
if (raw === 'global')
|
|
1181
|
+
return { kind: 'global' };
|
|
1182
|
+
if (projectCwd === undefined || projectCwd === '') {
|
|
1183
|
+
throw new Error('moving a memory into a workspace layer needs a session cwd');
|
|
1184
|
+
}
|
|
1185
|
+
if (raw === 'self' || raw === '.')
|
|
1186
|
+
return { kind: 'workspace', key: join(projectCwd) };
|
|
1187
|
+
const parts = raw.split(/[\\/]/).filter(part => part !== '' && part !== '.');
|
|
1188
|
+
if (!parts.every(part => part === '..')) {
|
|
1189
|
+
throw new Error(`unknown move target '${to}' — use 'global', 'self', or a relative ancestor ('..', '../..')`);
|
|
1190
|
+
}
|
|
1191
|
+
let key = join(projectCwd);
|
|
1192
|
+
for (let i = 0; i < parts.length; i++) {
|
|
1193
|
+
const parent = dirname(key);
|
|
1194
|
+
if (parent === key)
|
|
1195
|
+
throw new Error(`move target '${to}' points above the filesystem root`);
|
|
1196
|
+
key = parent;
|
|
1197
|
+
}
|
|
1198
|
+
return { kind: 'workspace', key };
|
|
1199
|
+
}
|
|
1200
|
+
/** `ancestor` 是否就是 `node` 本身或它的祖先——用来判断移动后源层是否仍看得见。 */
|
|
1201
|
+
static isAncestorOrSelf(ancestor, node) {
|
|
1202
|
+
const want = join(ancestor).toLowerCase();
|
|
1203
|
+
let cur = join(node);
|
|
1204
|
+
for (;;) {
|
|
1205
|
+
if (cur.toLowerCase() === want)
|
|
1206
|
+
return true;
|
|
1207
|
+
const parent = dirname(cur);
|
|
1208
|
+
if (parent === cur)
|
|
1209
|
+
return false;
|
|
1210
|
+
cur = parent;
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1114
1213
|
async setStatus(id, status, projectCwd) {
|
|
1115
1214
|
return this.writeScope(projectCwd, () => this.setStatusInner(id, status, projectCwd));
|
|
1116
1215
|
}
|
package/dist/index.js
CHANGED
|
@@ -109,6 +109,11 @@ const GUIDANCE = 'Use memory tools for cross-session preferences, habits, and pr
|
|
|
109
109
|
+ 'architecture decisions, its pitfalls). '
|
|
110
110
|
+ '用一句话选 namespace——**换个项目,这条还成立吗?** 成立 → `global`(偏好、习惯、环境知识);'
|
|
111
111
|
+ '不成立 → `project`(本仓库自己的约定、架构决策、它踩过的坑)。'
|
|
112
|
+
+ 'Record where a conclusion came from, not just the conclusion: the reason it holds, and the situation '
|
|
113
|
+
+ 'that forced it out. A conclusion without its origin is a ruler with no provenance — it looks '
|
|
114
|
+
+ 'self-evident, and self-evident things get applied mechanically. '
|
|
115
|
+
+ '记结论时一并记下**它是怎么来的**——它为什么成立,以及是什么场景把它逼出来的。没有出处的结论像一把'
|
|
116
|
+
+ '没有来历的尺子:看起来天经地义,而天经地义的东西最容易被机械套用。'
|
|
112
117
|
+ 'Pass `injected: true` on memory_save / memory_update to pin a memory resident in every turn\'s context: '
|
|
113
118
|
+ 'the same switch the settings panel exposes, exempting the record from both automatic rules (promote on '
|
|
114
119
|
+ 'repeated hits, demote after long idle). Reserve it for rules, standing agreements and judgement criteria '
|
|
@@ -463,6 +468,31 @@ export async function apply(ctx, config) {
|
|
|
463
468
|
},
|
|
464
469
|
presentCall: args => present('Forget memory', 'other', args.id),
|
|
465
470
|
}));
|
|
471
|
+
ctx.tools.register(defineTool({
|
|
472
|
+
name: 'memory_move',
|
|
473
|
+
description: 'Move one memory to another layer: `global`, `self` (the current workspace), or a relative ancestor (`..` / `../..`). Use it when a memory sits at the wrong level — a project-specific rule parked in `global` that leaks into unrelated workspaces, or a fact that turned out to hold for sibling projects too. The record keeps its id, and the result reports whether the source layer still sees it. 把一条记忆移到另一层:`global`、`self`(当前工作区)或相对祖先(`..` / `../..`)。用于记忆待错了层——只属于某个项目的规矩躺在 `global` 里、漏进无关工作区,或某条事实其实对兄弟项目也成立。id 保持不变,结果里会报告移动后源层是否仍看得见它。',
|
|
474
|
+
parameters: {
|
|
475
|
+
id: { type: 'string', required: true, description: 'Exact memory id from memory_list or memory_search. 要移动的记忆 id。' },
|
|
476
|
+
to: { type: 'string', required: true, description: 'Target layer: `global`, `self` (current workspace), or a relative ancestor such as `..` / `../..`. 目标层:`global`、`self`(当前工作区),或 `..` / `../..` 这样的相对祖先。' },
|
|
477
|
+
},
|
|
478
|
+
output: {
|
|
479
|
+
schema: {
|
|
480
|
+
type: 'object',
|
|
481
|
+
additionalProperties: false,
|
|
482
|
+
properties: {
|
|
483
|
+
id: { type: 'string', required: true },
|
|
484
|
+
from: { type: 'string', required: true, description: '移动前所在的层(`global` 或工作区路径)。' },
|
|
485
|
+
to: { type: 'string', required: true, description: '移动后的层。' },
|
|
486
|
+
sourceStillSees: { type: 'boolean', required: true, description: '源层移动后是否仍看得见它。为假表示源层及其下层从此看不见这条记忆。' },
|
|
487
|
+
},
|
|
488
|
+
},
|
|
489
|
+
render: (_args, value) => renderJson(value),
|
|
490
|
+
},
|
|
491
|
+
execute(args, exec) {
|
|
492
|
+
return memory.move(args.id, args.to, execProjectCwd(exec));
|
|
493
|
+
},
|
|
494
|
+
presentCall: args => present('Move memory', 'other', `${args.id} → ${args.to}`),
|
|
495
|
+
}));
|
|
466
496
|
ctx.tools.register(defineTool({
|
|
467
497
|
name: 'memory_update',
|
|
468
498
|
description: 'Update the content, keywords or persistent-injection switch of one stored memory (e.g. correcting stale facts). The record keeps its review status; content that looks like a credential quarantines it instead. 修改一条记忆的内容、关键词或常驻注入开关(如修正过时信息)。改动后记忆保持原有审核状态;若新内容命中危险规则则转为隔离。',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@max-null/dsh-memory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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": {
|
|
@@ -24,7 +24,9 @@
|
|
|
24
24
|
"README.md",
|
|
25
25
|
"LICENSE",
|
|
26
26
|
"docs/shots",
|
|
27
|
-
"scripts/scan-orphans.mjs"
|
|
27
|
+
"scripts/scan-orphans.mjs",
|
|
28
|
+
"scripts/unzstd-frames.mjs",
|
|
29
|
+
"scripts/ancestor-probe.mjs"
|
|
28
30
|
],
|
|
29
31
|
"dsh": {
|
|
30
32
|
"bundle": {
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 祖先链探针——验证 dsh-memory「④-A 祖先链」在**运行时**是否生效。
|
|
4
|
+
*
|
|
5
|
+
* **为什么需要它**:`test/workspace-chain.spec.ts` 证明的是代码逻辑,证明不了
|
|
6
|
+
* 「装进 profile、跑在真实会话里」的行为。而运行时验证有个陷阱:`scope` 标记只在
|
|
7
|
+
* **跨级命中**时出现,得先有一个「带记忆的祖先层」——可这台机器上 `H:\MaxNull\.dsh`
|
|
8
|
+
* 这类祖先层往往**根本不存在**。于是「在子项目会话里搜父目录记忆」这件事,在祖先链
|
|
9
|
+
* 失效时表现**逐字相同**(零可观测差异的观测点)。所以要先**把前提造出来**。
|
|
10
|
+
*
|
|
11
|
+
* **能回答什么**:
|
|
12
|
+
* - `plan`:当前 cwd 的祖先链解析结果——链上有哪些层、每层期望的文件名、哪些真的存在。
|
|
13
|
+
* 这就是引擎 `projectChain()` 的 `existsSync` 判定,可在会话外独立复算。
|
|
14
|
+
* - `seed` / `clean`:造出 / 撤销「祖先层有记忆」这个前提。
|
|
15
|
+
* - `check`:探针是否被**跨链记账**——`markUsed` 跨链生效时,`hitCount` 会被写回
|
|
16
|
+
* **祖先层自己的文件**(这正是「祖先记录不会被 30 天规则误撤」的机制)。
|
|
17
|
+
*
|
|
18
|
+
* **判据怎么算**:
|
|
19
|
+
* - 链 = `projectChain()` 的同语义实现:沿 cwd 向上、depth 每步 +1、**只收文件已存在的层**。
|
|
20
|
+
* 本例内置 djb2 指纹自检(见 `FINGERPRINTS`)——算法漂移时报错退出,而不是**静默算错
|
|
21
|
+
* 文件名**(路径形式敏感,`H:\a\b` 与 `H:/a/b` 哈希不同,错了不报错,只是找不到)。
|
|
22
|
+
* - `check` 看三个数:`records`(该层记录总数)、`hitCount`、`lastUsedAt`。
|
|
23
|
+
* `hitCount > 0` 且 `lastUsedAt` 晚于 `createdAt` = 被跨链命中过。
|
|
24
|
+
*
|
|
25
|
+
* **哪些情况答不了**:
|
|
26
|
+
* - **只对「尚未打开过该层的会话」有效(最要紧的一条)**:引擎的工作区表是**打开一次就
|
|
27
|
+
* 缓存**的,而写路径的新鲜度门 `refreshForWrite()` 只比对 **global + 当前 cwd** 两个
|
|
28
|
+
* 文件——**不含祖先层**。于是同一个会话若此前打开过目标层(跑过 `check`、或搜过该层
|
|
29
|
+
* 的记忆),seed 出的探针**它看不到**;更糟的是该会话下一次对该层的命中记账会把磁盘
|
|
30
|
+
* **改写成它的内存态**,探针被直接抹掉。判据:`check` 报「没有探针记录」而文件里躺着的
|
|
31
|
+
* 是别的记录(往往是上一轮删掉的旧记录,`hitCount` 还在涨),那就是被覆盖了。
|
|
32
|
+
* 要用探针,就**换一个没打开过该层的会话**,或者重启会话。
|
|
33
|
+
* - **答不了「检索是否跨链」**:`memory_search` 是模型工具,脚本调不到。本脚本负责
|
|
34
|
+
* 「造前提」与「读记账」,中间那一步(在会话里搜 token、看结果有没有 `scope` 字段)
|
|
35
|
+
* 必须由调用它的会话完成——这是全流程里唯一不能脚本化的一步。
|
|
36
|
+
* - **不打开 DSH 存储设施**:只用 `fs` 直接读写那个 JSON 文件,不开句柄、不碰引擎缓存。
|
|
37
|
+
* 所以它给的是**文件层事实**;引擎内存态是否正确,由 `check` 之后的会话行为体现。
|
|
38
|
+
* - **只验「链只向上」的一半**:脚本能算出 cwd 的祖先链,但看不到子目录。
|
|
39
|
+
* 「父目录会话看不到子目录记忆」要另开会话验(判据:索引行条数不含子目录那些)。
|
|
40
|
+
* - 不判断记忆内容质量,不迁移、不合并、不触碰任何**非探针**记录。
|
|
41
|
+
*
|
|
42
|
+
* 用法:
|
|
43
|
+
* node scripts/ancestor-probe.mjs plan [--cwd <会话 cwd>]
|
|
44
|
+
* node scripts/ancestor-probe.mjs seed --layer <祖先层绝对路径> [--cwd <会话 cwd>] --apply
|
|
45
|
+
* node scripts/ancestor-probe.mjs check --layer <祖先层绝对路径>
|
|
46
|
+
* node scripts/ancestor-probe.mjs clean --layer <祖先层绝对路径> --apply
|
|
47
|
+
* node scripts/ancestor-probe.mjs --help
|
|
48
|
+
*
|
|
49
|
+
* 完整流程(五步):
|
|
50
|
+
* 1) plan 看链;若已有祖先层带记忆 → 直接用它,跳过 2
|
|
51
|
+
* 2) seed --layer <层> --apply 造探针(打印一个 token)
|
|
52
|
+
* 3) 在**同一 cwd 的会话里** memory_search 那个 token —— 命中且结果带 `scope` 即检索侧生效
|
|
53
|
+
* 4) check --layer <层> 读 hitCount:被记账 = 定位侧(markUsed 跨链)生效
|
|
54
|
+
* 5) clean --layer <层> --apply 撤销前提,恢复原状(空目录一并收回)
|
|
55
|
+
*
|
|
56
|
+
* 退出码:0 成功 / 1 前置条件不满足(拒绝执行)/ 2 用法错误 / 3 算法指纹不自检。
|
|
57
|
+
*/
|
|
58
|
+
import fs from 'node:fs'
|
|
59
|
+
import path from 'node:path'
|
|
60
|
+
import crypto from 'node:crypto'
|
|
61
|
+
|
|
62
|
+
const PROBE_MARKER = '[ancestor-chain-probe]'
|
|
63
|
+
|
|
64
|
+
// djb2 指纹:必须与 src/engine.ts 的 projectBackendName 逐字一致。写成常量表而非注释,
|
|
65
|
+
// 是为了在算法漂移时**报错**——哈希错了不会抛异常,只会静默指向另一个文件。
|
|
66
|
+
const FINGERPRINTS = [
|
|
67
|
+
['dsh-memory', 'oy76k6'],
|
|
68
|
+
['workstation', '3kjfw6'],
|
|
69
|
+
['a', '3t3a'],
|
|
70
|
+
['', '45h'], // 空串 = 5381 的 base36,即 djb2 初值
|
|
71
|
+
['h:\\X', 'ykhwcb'], // 与 'H:\\x' 同值——顺带验证 toLowerCase 也在算法里
|
|
72
|
+
['H:\\x', 'ykhwcb'],
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
const USAGE = `祖先链探针——验证 dsh-memory「④-A 祖先链」在运行时是否生效。
|
|
76
|
+
|
|
77
|
+
用法:
|
|
78
|
+
node scripts/ancestor-probe.mjs plan [--cwd <会话 cwd>]
|
|
79
|
+
node scripts/ancestor-probe.mjs seed --layer <祖先层绝对路径> [--cwd <会话 cwd>] --apply
|
|
80
|
+
node scripts/ancestor-probe.mjs check --layer <祖先层绝对路径>
|
|
81
|
+
node scripts/ancestor-probe.mjs clean --layer <祖先层绝对路径> --apply
|
|
82
|
+
node scripts/ancestor-probe.mjs --help
|
|
83
|
+
|
|
84
|
+
--cwd 默认取进程工作目录;脚本通常从仓库目录运行,**它未必等于你的会话 cwd**,
|
|
85
|
+
所以对不上时务必显式传。
|
|
86
|
+
seed / clean 必须带 --apply 才真的写盘;不带就是 dry-run(只打印将做什么)。
|
|
87
|
+
|
|
88
|
+
退出码:0 成功 / 1 前置条件不满足(拒绝执行)/ 2 用法错误 / 3 算法指纹不自检。`
|
|
89
|
+
|
|
90
|
+
/** 与 engine.ts:484-489 逐字对应(含 toLowerCase、|0 截断、abs 后取 base36)。 */
|
|
91
|
+
function projectBackendName(cwd) {
|
|
92
|
+
let h = 5381
|
|
93
|
+
const text = cwd.toLowerCase()
|
|
94
|
+
for (let i = 0; i < text.length; i++) h = ((h << 5) + h + text.charCodeAt(i)) | 0
|
|
95
|
+
return `memory_project_${Math.abs(h).toString(36)}`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const projectRootFor = (cwd) => path.join(cwd, '.dsh', 'storages')
|
|
99
|
+
const layerFile = (layer) => path.join(projectRootFor(path.join(layer)), `${projectBackendName(path.join(layer))}.json`)
|
|
100
|
+
|
|
101
|
+
/** cwd 的全部祖先层(含自身,depth 0 起)——与链无关,plan 用它展示「上面有什么」。 */
|
|
102
|
+
function ancestorLayers(cwd) {
|
|
103
|
+
const key = path.join(cwd)
|
|
104
|
+
const out = [{ key, depth: 0 }]
|
|
105
|
+
let cur = key
|
|
106
|
+
let depth = 0
|
|
107
|
+
for (;;) {
|
|
108
|
+
const parent = path.dirname(cur)
|
|
109
|
+
if (parent === cur) break // 到盘根
|
|
110
|
+
cur = parent
|
|
111
|
+
depth += 1
|
|
112
|
+
out.push({ key: cur, depth })
|
|
113
|
+
}
|
|
114
|
+
return out
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 引擎 projectChain() 的同语义实现:只收**记忆文件已存在**的层(depth 0 自身始终纳入)。 */
|
|
118
|
+
function projectChain(cwd) {
|
|
119
|
+
return ancestorLayers(cwd).filter((l) => l.depth === 0 || fs.existsSync(layerFile(l.key)))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function readDoc(file) {
|
|
123
|
+
if (!fs.existsSync(file)) return undefined
|
|
124
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function probeRecords(doc) {
|
|
128
|
+
const blocks = doc?.tables?.blocks
|
|
129
|
+
if (blocks === undefined) return []
|
|
130
|
+
return Object.entries(blocks).filter(([, b]) => typeof b.content === 'string' && b.content.includes(PROBE_MARKER))
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function selfCheck() {
|
|
134
|
+
const bad = []
|
|
135
|
+
for (const [input, expect] of FINGERPRINTS) {
|
|
136
|
+
const want = `memory_project_${expect}`
|
|
137
|
+
const got = projectBackendName(input)
|
|
138
|
+
if (got !== want) bad.push(`${JSON.stringify(input)}: 期望 ${want},实得 ${got}`)
|
|
139
|
+
}
|
|
140
|
+
if (bad.length > 0) {
|
|
141
|
+
console.error('算法指纹不自检——projectBackendName 与 src/engine.ts 已不一致:')
|
|
142
|
+
for (const line of bad) console.error(` ${line}`)
|
|
143
|
+
console.error(' → 先核对 src/engine.ts 的 projectBackendName,再更新本脚本的 FINGERPRINTS。')
|
|
144
|
+
process.exit(3)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function parseArgs(argv) {
|
|
149
|
+
const out = { command: undefined, apply: false, help: false, cwd: undefined, layer: undefined }
|
|
150
|
+
const rest = []
|
|
151
|
+
for (let i = 0; i < argv.length; i++) {
|
|
152
|
+
const a = argv[i]
|
|
153
|
+
if (a === '--apply') out.apply = true
|
|
154
|
+
else if (a === '--help' || a === '-h') out.help = true
|
|
155
|
+
else if (a === '--cwd' || a === '--layer') {
|
|
156
|
+
const v = argv[++i]
|
|
157
|
+
if (v === undefined || v.startsWith('--')) {
|
|
158
|
+
console.error(`参数 ${a} 缺值`)
|
|
159
|
+
process.exit(2)
|
|
160
|
+
}
|
|
161
|
+
out[a.slice(2)] = v
|
|
162
|
+
} else if (a.startsWith('-')) {
|
|
163
|
+
console.error(`未知参数:${a}\n\n${USAGE}`)
|
|
164
|
+
process.exit(2)
|
|
165
|
+
} else rest.push(a)
|
|
166
|
+
}
|
|
167
|
+
out.command = rest[0]
|
|
168
|
+
return out
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function cmdPlan(args) {
|
|
172
|
+
const cwd = path.join(args.cwd ?? process.cwd())
|
|
173
|
+
const layers = ancestorLayers(cwd)
|
|
174
|
+
const chain = projectChain(cwd)
|
|
175
|
+
|
|
176
|
+
console.log(`cwd = ${cwd}`)
|
|
177
|
+
if (args.cwd === undefined) {
|
|
178
|
+
console.log(' (取自进程工作目录;**若它不等于你的会话 cwd,请用 --cwd 重跑**——链是按会话 cwd 算的)')
|
|
179
|
+
}
|
|
180
|
+
console.log('\n祖先层(depth 0 = 自身):')
|
|
181
|
+
console.log(' depth 存在记忆文件 记录数 路径')
|
|
182
|
+
for (const l of layers) {
|
|
183
|
+
const file = layerFile(l.key)
|
|
184
|
+
const exists = fs.existsSync(file)
|
|
185
|
+
let count = '-'
|
|
186
|
+
if (exists) {
|
|
187
|
+
try {
|
|
188
|
+
count = String(Object.keys(readDoc(file)?.tables?.blocks ?? {}).length)
|
|
189
|
+
} catch {
|
|
190
|
+
count = '解析失败'
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
console.log(` ${String(l.depth).padEnd(6)} ${(exists ? '是' : '否').padEnd(14)} ${count.padEnd(8)} ${l.key}`)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const withMemory = chain.filter((l) => l.depth > 0)
|
|
197
|
+
console.log(`\n引擎会纳入的链:${chain.length} 级(自身 + ${withMemory.length} 个祖先层)`)
|
|
198
|
+
|
|
199
|
+
if (withMemory.length > 0) {
|
|
200
|
+
const nearest = withMemory[0]
|
|
201
|
+
const scopeText = Array.from({ length: nearest.depth }, () => '..').join('/')
|
|
202
|
+
console.log(`\n结论:**可以直接验证,不必造探针**——最近的带记忆祖先层是 ${nearest.key}`)
|
|
203
|
+
console.log(` 在会话里搜该层任一记忆的关键词,结果应带 scope="${scopeText}"。`)
|
|
204
|
+
} else {
|
|
205
|
+
const candidate = layers.find((l) => l.depth === 1)
|
|
206
|
+
if (candidate === undefined) {
|
|
207
|
+
console.log('\n结论:cwd 已在盘根,**没有祖先层可放探针** → 换一个更深的 cwd 再验。')
|
|
208
|
+
process.exit(1)
|
|
209
|
+
}
|
|
210
|
+
console.log('\n结论:**祖先链在当前 cwd 上是零可观测差异的**——以上祖先层都没有记忆文件,')
|
|
211
|
+
console.log(' 祖先链无论生效与否,行为逐字相同。要观测它,先造一层:')
|
|
212
|
+
console.log(`\n node scripts/ancestor-probe.mjs seed --layer "${candidate.key}" --cwd "${cwd}" --apply`)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function cmdSeed(args) {
|
|
217
|
+
if (args.layer === undefined) {
|
|
218
|
+
console.error(`seed 需要 --layer <祖先层绝对路径>\n\n${USAGE}`)
|
|
219
|
+
process.exit(2)
|
|
220
|
+
}
|
|
221
|
+
const cwd = path.join(args.cwd ?? process.cwd())
|
|
222
|
+
const layer = path.join(args.layer)
|
|
223
|
+
|
|
224
|
+
if (layer === cwd) {
|
|
225
|
+
console.error('拒绝:--layer 等于 cwd 自身。探针必须放在**祖先**层——同层命中证明不了跨链。')
|
|
226
|
+
process.exit(1)
|
|
227
|
+
}
|
|
228
|
+
const layers = ancestorLayers(cwd)
|
|
229
|
+
if (!layers.some((l) => l.key === layer)) {
|
|
230
|
+
console.error(`拒绝:${layer} 不是 ${cwd} 的祖先层(链只向上,放子目录无效)。`)
|
|
231
|
+
process.exit(1)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const file = layerFile(layer)
|
|
235
|
+
const existing = readDoc(file)
|
|
236
|
+
if (existing !== undefined) {
|
|
237
|
+
const blocks = existing.tables?.blocks ?? {}
|
|
238
|
+
const mine = probeRecords(existing)
|
|
239
|
+
if (mine.length > 0) {
|
|
240
|
+
console.error(`拒绝:${file} 里已经有探针记录(${mine.length} 条)。先 clean 再 seed。`)
|
|
241
|
+
process.exit(1)
|
|
242
|
+
}
|
|
243
|
+
if (Object.keys(blocks).length > 0) {
|
|
244
|
+
console.error(`拒绝:${file} 已含 ${Object.keys(blocks).length} 条真实记录,不去动它——`)
|
|
245
|
+
console.error(' 请换一层,或直接用它做验证(plan 会指出这种「祖先层本来就有记忆」的情况)。')
|
|
246
|
+
process.exit(1)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const token = `aprobe${crypto.randomBytes(4).toString('hex')}`
|
|
251
|
+
const depth = layers.find((l) => l.key === layer).depth
|
|
252
|
+
const scope = Array.from({ length: depth }, () => '..').join('/')
|
|
253
|
+
const now = Date.now()
|
|
254
|
+
const id = crypto.randomUUID()
|
|
255
|
+
|
|
256
|
+
if (!args.apply) {
|
|
257
|
+
console.log('[dry-run] 将写入(加 --apply 才真的写):')
|
|
258
|
+
console.log(` 文件 ${file}`)
|
|
259
|
+
console.log(` 记录 ${id}`)
|
|
260
|
+
console.log(` 令牌 ${token}`)
|
|
261
|
+
console.log(` 层深 depth ${depth}(对应 scope="${scope}")`)
|
|
262
|
+
process.exit(0)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const doc = existing ?? {
|
|
266
|
+
unit: { name: projectBackendName(layer), version: 1 },
|
|
267
|
+
global: null,
|
|
268
|
+
tables: { blocks: {} },
|
|
269
|
+
}
|
|
270
|
+
doc.tables ??= { blocks: {} }
|
|
271
|
+
doc.tables.blocks ??= {}
|
|
272
|
+
doc.tables.blocks[id] = {
|
|
273
|
+
namespace: 'project',
|
|
274
|
+
status: 'approved',
|
|
275
|
+
injected: false,
|
|
276
|
+
content:
|
|
277
|
+
`${PROBE_MARKER} 临时探针,验证完请删——用 scripts/ancestor-probe.mjs clean 收尾。\n\n` +
|
|
278
|
+
`由 ancestor-probe.mjs seed 写入祖先层 \`${layer}\`(depth ${depth}),` +
|
|
279
|
+
`目的是让会话 cwd \`${cwd}\` 拥有一个「带记忆的祖先层」,从而让 ④-A 祖先链可被观测。\n\n` +
|
|
280
|
+
`检索令牌:${token}`,
|
|
281
|
+
keywords: [token, '祖先链探针', 'ancestor-chain-probe', '④-A 验证'],
|
|
282
|
+
createdAt: now,
|
|
283
|
+
updatedAt: now,
|
|
284
|
+
hitCount: 0,
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
288
|
+
fs.writeFileSync(file, `${JSON.stringify(doc, null, 2)}\n`, 'utf8')
|
|
289
|
+
|
|
290
|
+
console.log(`已写入探针 → ${file}`)
|
|
291
|
+
console.log(` 记录 ${id}`)
|
|
292
|
+
console.log(` 令牌 ${token}`)
|
|
293
|
+
console.log(` 层深 depth ${depth}(结果里应出现 scope="${scope}")`)
|
|
294
|
+
console.log('\n下一步(脚本做不到,必须由会话完成):')
|
|
295
|
+
console.log(` 1) 在同一 cwd 的会话里 memory_search "${token}" —— 命中等且结果带 scope="${scope}" 即检索侧生效`)
|
|
296
|
+
console.log(` 2) node scripts/ancestor-probe.mjs check --layer "${layer}"`)
|
|
297
|
+
console.log('\n⚠️ 前提:目标层必须是**本会话此前没有打开过**的层。引擎的表打开即缓存,而写路径的')
|
|
298
|
+
console.log(' 新鲜度门(refreshForWrite)只比对 global + 当前 cwd,不含祖先层——已经被缓存过的')
|
|
299
|
+
console.log(' 层,探针不但搜不到,还会被该会话的内存态覆盖掉。第 1 步搜不到就先换会话。')
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function cmdCheck(args) {
|
|
303
|
+
if (args.layer === undefined) {
|
|
304
|
+
console.error(`check 需要 --layer <祖先层绝对路径>\n\n${USAGE}`)
|
|
305
|
+
process.exit(2)
|
|
306
|
+
}
|
|
307
|
+
const layer = path.join(args.layer)
|
|
308
|
+
const file = layerFile(layer)
|
|
309
|
+
const doc = readDoc(file)
|
|
310
|
+
if (doc === undefined) {
|
|
311
|
+
console.error(`该层没有记忆文件:${file}`)
|
|
312
|
+
process.exit(1)
|
|
313
|
+
}
|
|
314
|
+
const blocks = doc.tables?.blocks ?? {}
|
|
315
|
+
const mine = probeRecords(doc)
|
|
316
|
+
console.log(`层 ${layer}`)
|
|
317
|
+
console.log(`文件 ${file}`)
|
|
318
|
+
console.log(`记录总数 ${Object.keys(blocks).length},其中探针 ${mine.length} 条`)
|
|
319
|
+
if (mine.length === 0) {
|
|
320
|
+
console.log('\n该层没有探针记录。磁盘上现有的记录:')
|
|
321
|
+
const others = Object.entries(blocks)
|
|
322
|
+
if (others.length === 0) console.log(' (空表)')
|
|
323
|
+
for (const [id, b] of others) {
|
|
324
|
+
console.log(` ${id} hitCount=${b.hitCount ?? 0} keywords=${JSON.stringify((b.keywords ?? []).slice(0, 2))}`)
|
|
325
|
+
}
|
|
326
|
+
console.log('\n两种可能:① 这层本来就有真实记忆——用 plan 给出的关键词直接验证即可;')
|
|
327
|
+
console.log(' ② 探针被运行中会话的内存态覆盖了(该层此前被打开过)——换会话重试。')
|
|
328
|
+
process.exit(1)
|
|
329
|
+
}
|
|
330
|
+
for (const [id, b] of mine) {
|
|
331
|
+
const used = b.lastUsedAt === undefined ? '从未' : new Date(b.lastUsedAt).toISOString()
|
|
332
|
+
console.log(`\n探针 ${id}`)
|
|
333
|
+
console.log(` hitCount ${b.hitCount ?? 0}`)
|
|
334
|
+
console.log(` createdAt ${new Date(b.createdAt).toISOString()}`)
|
|
335
|
+
console.log(` lastUsedAt ${used}`)
|
|
336
|
+
if ((b.hitCount ?? 0) > 0 && b.lastUsedAt !== undefined && b.lastUsedAt > b.createdAt) {
|
|
337
|
+
console.log(' → **被跨链命中并记账了**:markUsed 路径生效,祖先记录不会被 30 天规则误撤。')
|
|
338
|
+
} else {
|
|
339
|
+
console.log(' → 还没被记账。若你已经在会话里搜过这个令牌并命中,说明检索侧通了但记账没回写;')
|
|
340
|
+
console.log(' 若根本没搜过,先做 seed 输出里的第 1 步。')
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function cmdClean(args) {
|
|
346
|
+
if (args.layer === undefined) {
|
|
347
|
+
console.error(`clean 需要 --layer <祖先层绝对路径>\n\n${USAGE}`)
|
|
348
|
+
process.exit(2)
|
|
349
|
+
}
|
|
350
|
+
const layer = path.join(args.layer)
|
|
351
|
+
const file = layerFile(layer)
|
|
352
|
+
const doc = readDoc(file)
|
|
353
|
+
if (doc === undefined) {
|
|
354
|
+
console.error(`该层没有记忆文件,无需清理:${file}`)
|
|
355
|
+
process.exit(1)
|
|
356
|
+
}
|
|
357
|
+
const mine = probeRecords(doc)
|
|
358
|
+
if (mine.length === 0) {
|
|
359
|
+
console.error(`拒绝:${file} 里没有探针记录(只有本脚本写的探针才会被删)。`)
|
|
360
|
+
process.exit(1)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const rmDirs = []
|
|
364
|
+
const storages = projectRootFor(layer)
|
|
365
|
+
const dshDir = path.join(layer, '.dsh')
|
|
366
|
+
|
|
367
|
+
if (!args.apply) {
|
|
368
|
+
console.log('[dry-run] 将删除(加 --apply 才真的删):')
|
|
369
|
+
console.log(` ${mine.length} 条探针记录,位于 ${file}`)
|
|
370
|
+
console.log(` 若文件因此变空 → 删文件;若上级目录变空 → 一并收回 ${storages}`)
|
|
371
|
+
process.exit(0)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
for (const [id] of mine) delete doc.tables.blocks[id]
|
|
375
|
+
if (Object.keys(doc.tables.blocks).length === 0) {
|
|
376
|
+
fs.rmSync(file)
|
|
377
|
+
console.log(`已删除探针文件 ${file}`)
|
|
378
|
+
for (const d of [storages, dshDir]) {
|
|
379
|
+
if (fs.existsSync(d) && fs.readdirSync(d).length === 0) {
|
|
380
|
+
fs.rmdirSync(d)
|
|
381
|
+
rmDirs.push(d)
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
for (const d of rmDirs) console.log(`已收回空目录 ${d}`)
|
|
385
|
+
} else {
|
|
386
|
+
fs.writeFileSync(file, `${JSON.stringify(doc, null, 2)}\n`, 'utf8')
|
|
387
|
+
console.log(`已从 ${file} 删除 ${mine.length} 条探针记录(该层还有 ${Object.keys(doc.tables.blocks).length} 条真实记录,文件保留)`)
|
|
388
|
+
}
|
|
389
|
+
console.log('\n若前面观察到索引行条数因探针 +1,下一轮注入应回落到原值——链是实时 existsSync 现算的。')
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const args = parseArgs(process.argv.slice(2))
|
|
393
|
+
if (args.help || args.command === undefined) {
|
|
394
|
+
console.log(USAGE)
|
|
395
|
+
process.exit(args.help ? 0 : 2)
|
|
396
|
+
}
|
|
397
|
+
selfCheck()
|
|
398
|
+
|
|
399
|
+
switch (args.command) {
|
|
400
|
+
case 'plan':
|
|
401
|
+
cmdPlan(args)
|
|
402
|
+
break
|
|
403
|
+
case 'seed':
|
|
404
|
+
cmdSeed(args)
|
|
405
|
+
break
|
|
406
|
+
case 'check':
|
|
407
|
+
cmdCheck(args)
|
|
408
|
+
break
|
|
409
|
+
case 'clean':
|
|
410
|
+
cmdClean(args)
|
|
411
|
+
break
|
|
412
|
+
default:
|
|
413
|
+
console.error(`未知子命令:${args.command}\n\n${USAGE}`)
|
|
414
|
+
process.exit(2)
|
|
415
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 多帧 zstd 解压——读 DSH 会话日志的必经一步。
|
|
4
|
+
*
|
|
5
|
+
* **为什么需要它**:DSH 的会话日志(`session.v3.jsonl.zstd`)不是单个 zstd 流,而是
|
|
6
|
+
* **多个独立帧拼接**的结果(一段事件写一帧)。`zlib.zstdDecompressSync()` 只解**第一帧**
|
|
7
|
+
* 就返回——于是 1.9 MB 的日志读出来只有头一行,看起来像「日志是空的」,极易误判。
|
|
8
|
+
* Node 的 zstd 绑定没有暴露「解全部帧」的选项,所以只能自己切帧。
|
|
9
|
+
*
|
|
10
|
+
* **能回答什么**:把 `*.jsonl.zstd` 还原成完整 jsonl 文本,交给后续分析——事件普查、
|
|
11
|
+
* 找某次工具调用、统计 token 压力、解剖一个会话到底做了什么。
|
|
12
|
+
*
|
|
13
|
+
* **判据怎么算**:zstd 帧的 magic number 是 `28 B5 2F FD`(小端);扫描它在文件中的
|
|
14
|
+
* 全部出现位置即为候选帧边界,再逐帧解压。输出行的三个数就是判据:
|
|
15
|
+
* `frames`(切出几帧)、`ok`(解出几帧)、`failed`(几帧失败)。**failed > 0 说明切帧
|
|
16
|
+
* 切错了**——多半是压缩数据内部恰好出现了那 4 个字节(magic 扫描法的已知局限)。
|
|
17
|
+
*
|
|
18
|
+
* **哪些情况答不了**:
|
|
19
|
+
* - 逐字节扫描 magic 会有**假阳性**。feral > 0 时真帧通常仍能解出,但**不保证完整**;
|
|
20
|
+
* 要 100% 可靠需解析 zstd 帧头(`Frame_Header_Descriptor` 的长度字段),本脚本没做到。
|
|
21
|
+
* - 只做**解压**,不解析 jsonl 语义——事件类型、token 统计属于调用方。
|
|
22
|
+
* - **明文 jsonl 不需要它**:DSH 从 GUI 导出的会话就是明文(`session.v3.jsonl`),
|
|
23
|
+
* 直接 `readFileSync` 即可,别绕这一道。
|
|
24
|
+
*
|
|
25
|
+
* 用法:
|
|
26
|
+
* node scripts/unzstd-frames.mjs <源 .zstd> <目标 .jsonl>
|
|
27
|
+
* node scripts/unzstd-frames.mjs --help
|
|
28
|
+
*
|
|
29
|
+
* 退出码:0 = 全部帧解出;1 = 有帧失败(结果可能不完整);2 = 用法错误。
|
|
30
|
+
*/
|
|
31
|
+
import fs from 'node:fs'
|
|
32
|
+
import zlib from 'node:zlib'
|
|
33
|
+
|
|
34
|
+
const USAGE = `多帧 zstd 解压——读 DSH 会话日志的必经一步。
|
|
35
|
+
|
|
36
|
+
用法:
|
|
37
|
+
node scripts/unzstd-frames.mjs <源 .zstd> <目标 .jsonl>
|
|
38
|
+
node scripts/unzstd-frames.mjs --help
|
|
39
|
+
|
|
40
|
+
退出码:0 = 全部帧解出;1 = 有帧失败(结果可能不完整);2 = 用法错误。`
|
|
41
|
+
|
|
42
|
+
const args = process.argv.slice(2)
|
|
43
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
44
|
+
console.log(USAGE)
|
|
45
|
+
process.exit(0)
|
|
46
|
+
}
|
|
47
|
+
if (args.length < 2) {
|
|
48
|
+
console.error(USAGE)
|
|
49
|
+
process.exit(2)
|
|
50
|
+
}
|
|
51
|
+
const [src, dst] = args
|
|
52
|
+
if (!fs.existsSync(src)) {
|
|
53
|
+
console.error(`源文件不存在:${src}`)
|
|
54
|
+
process.exit(2)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const buf = fs.readFileSync(src)
|
|
58
|
+
const MAGIC = [0x28, 0xb5, 0x2f, 0xfd] // zstd frame magic(小端)
|
|
59
|
+
const positions = []
|
|
60
|
+
for (let i = 0; i + 3 < buf.length; i++) {
|
|
61
|
+
if (buf[i] === MAGIC[0] && buf[i + 1] === MAGIC[1] && buf[i + 2] === MAGIC[2] && buf[i + 3] === MAGIC[3]) {
|
|
62
|
+
positions.push(i)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
positions.push(buf.length) // 末帧以文件长度收尾
|
|
66
|
+
|
|
67
|
+
const parts = []
|
|
68
|
+
let ok = 0
|
|
69
|
+
let failed = 0
|
|
70
|
+
for (let i = 0; i + 1 < positions.length; i++) {
|
|
71
|
+
const slice = buf.subarray(positions[i], positions[i + 1])
|
|
72
|
+
try {
|
|
73
|
+
parts.push(zlib.zstdDecompressSync(slice))
|
|
74
|
+
ok += 1
|
|
75
|
+
} catch (error) {
|
|
76
|
+
// 不在这里尝试 stream 容错:`createZstdDecompress()` 是异步的,在同步块里 end() 之后
|
|
77
|
+
// 立刻取 chunks 必然拿不到数据。宁可如实报告失败,也不给一个看似成功的空结果。
|
|
78
|
+
failed += 1
|
|
79
|
+
console.error(` 帧 ${i}(偏移 ${positions[i]})解压失败:${error instanceof Error ? error.message : String(error)}`)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const out = Buffer.concat(parts)
|
|
84
|
+
fs.writeFileSync(dst, out)
|
|
85
|
+
console.log(`frames=${positions.length - 1} ok=${ok} failed=${failed}`)
|
|
86
|
+
console.log(`输出 ${out.length} 字节 → ${dst}`)
|
|
87
|
+
if (failed > 0) {
|
|
88
|
+
console.error('⚠️ 有帧未解出,结果可能不完整(magic 扫描法的已知局限,见脚本头部注释)')
|
|
89
|
+
process.exit(1)
|
|
90
|
+
}
|