@max-null/dsh-memory 0.9.2 → 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.
@@ -54,3 +54,33 @@ export declare function renderInjection(records: readonly MemoryRecord[], budget
54
54
  * {@link OMITTED_NOTICE_LIMIT} 只列前 N 个。
55
55
  */
56
56
  export declare function omittedNotice(rendered: InjectionRender): string;
57
+ /** 子项目索引行列出的条数上限(④-B):超出以 `…` 结尾,防这行本身失控。 */
58
+ export declare const NEIGHBOR_NOTICE_LIMIT = 5;
59
+ /**
60
+ * 子项目索引行(④-B 2026-09-19):告诉模型「当前工作区下面还有什么」——它补的是
61
+ * 「不知道存在」这个检索的结构性盲区,而**不搬运内容**(正文照旧走 memory_search)。
62
+ *
63
+ * 与预算诊断行同待遇:**不占注入预算**、自消除(没有子项目记忆时返回空串)。
64
+ * 只报条数不报 id——这一行的用途是给出**规模与入口**,不是当检索结果用。
65
+ */
66
+ export declare function neighborNotice(neighbors: ReadonlyArray<{
67
+ name: string;
68
+ count: number;
69
+ }>): string;
70
+ /** 索引行列出的主题数上限(① 2026-09-19):常数级——这行不随库增长。 */
71
+ export declare const INDEX_NOTICE_TOPICS = 5;
72
+ /**
73
+ * 记忆索引行(① 2026-09-19):给出「库里还有什么、按什么去搜」。
74
+ *
75
+ * 它补的是**检索的结构性盲区**——检索是有意图的动作,不知道某条记忆存在就搜不出它。
76
+ * 注入行每轮只装得下约 11 条,其余上百条对模型完全不可见;这一行报出**规模与入口**,
77
+ * 让「不在场」变成「知道存在、需要时去取」。
78
+ *
79
+ * 为什么不做成一条常驻记忆(用户最初的设想):那样它会自己占约 135 字符预算、参与零和
80
+ * 竞争、可能自己也被挤掉,而且记忆增删后**不会自动更新**(目录与内容不一致且无人察觉)。
81
+ * 做成系统生成的行则:不占预算、不可能腐化、永远反映当前状态。
82
+ *
83
+ * 主题取自各条的 `keywords` 词频——那是记忆写入时被要求「给多角度」的字段,**与检索用的
84
+ * 是同一套词汇**,所以索引里出现的词就是能搜到的词。同频时按字典序排,保证输出确定。
85
+ */
86
+ export declare function indexNotice(records: ReadonlyArray<MemoryRecord>): string;
package/dist/injection.js CHANGED
@@ -30,7 +30,9 @@ export function deriveSummary(content, maxChars) {
30
30
  }
31
31
  /** 一条注入行的渲染文本(与面板预览完全一致)。 */
32
32
  export function injectionLine(record, summaryChars) {
33
- return `- [memory:${String(record.id).slice(0, 8)}:${record.namespace}] ${deriveSummary(record.content, summaryChars)}`;
33
+ // ④-A:跨工作区召回时带来源标记(`..` / `../..`);当前工作区不带,保持既有格式
34
+ const scope = record.scope === undefined ? '' : `@${record.scope}`;
35
+ return `- [memory:${String(record.id).slice(0, 8)}:${record.namespace}${scope}] ${deriveSummary(record.content, summaryChars)}`;
34
36
  }
35
37
  /**
36
38
  * 由候选记录渲染注入行:按使用序降序(lastUsedAt 优先、缺省回退
@@ -75,3 +77,56 @@ export function omittedNotice(rendered) {
75
77
  const truncated = rendered.omittedRecords.length > shown.length ? '…' : '';
76
78
  return `(另有 ${rendered.omitted} 条常驻因预算未注入:${labels.join('、')}${truncated})`;
77
79
  }
80
+ /** 子项目索引行列出的条数上限(④-B):超出以 `…` 结尾,防这行本身失控。 */
81
+ export const NEIGHBOR_NOTICE_LIMIT = 5;
82
+ /**
83
+ * 子项目索引行(④-B 2026-09-19):告诉模型「当前工作区下面还有什么」——它补的是
84
+ * 「不知道存在」这个检索的结构性盲区,而**不搬运内容**(正文照旧走 memory_search)。
85
+ *
86
+ * 与预算诊断行同待遇:**不占注入预算**、自消除(没有子项目记忆时返回空串)。
87
+ * 只报条数不报 id——这一行的用途是给出**规模与入口**,不是当检索结果用。
88
+ */
89
+ export function neighborNotice(neighbors) {
90
+ if (neighbors.length === 0)
91
+ return '';
92
+ const shown = neighbors.slice(0, NEIGHBOR_NOTICE_LIMIT);
93
+ const labels = shown.map(neighbor => `${neighbor.name}(${neighbor.count} 条)`);
94
+ const truncated = neighbors.length > shown.length ? '…' : '';
95
+ return `(本工作区下另有 ${neighbors.length} 个子项目带记忆:${labels.join('、')}${truncated}——用 memory_search 检索)`;
96
+ }
97
+ /** 索引行列出的主题数上限(① 2026-09-19):常数级——这行不随库增长。 */
98
+ export const INDEX_NOTICE_TOPICS = 5;
99
+ /**
100
+ * 记忆索引行(① 2026-09-19):给出「库里还有什么、按什么去搜」。
101
+ *
102
+ * 它补的是**检索的结构性盲区**——检索是有意图的动作,不知道某条记忆存在就搜不出它。
103
+ * 注入行每轮只装得下约 11 条,其余上百条对模型完全不可见;这一行报出**规模与入口**,
104
+ * 让「不在场」变成「知道存在、需要时去取」。
105
+ *
106
+ * 为什么不做成一条常驻记忆(用户最初的设想):那样它会自己占约 135 字符预算、参与零和
107
+ * 竞争、可能自己也被挤掉,而且记忆增删后**不会自动更新**(目录与内容不一致且无人察觉)。
108
+ * 做成系统生成的行则:不占预算、不可能腐化、永远反映当前状态。
109
+ *
110
+ * 主题取自各条的 `keywords` 词频——那是记忆写入时被要求「给多角度」的字段,**与检索用的
111
+ * 是同一套词汇**,所以索引里出现的词就是能搜到的词。同频时按字典序排,保证输出确定。
112
+ */
113
+ export function indexNotice(records) {
114
+ const usable = records.filter(record => record.status === 'approved'
115
+ && record.quarantined !== true
116
+ && (record.kind ?? 'fact') !== 'prompt');
117
+ if (usable.length === 0)
118
+ return '';
119
+ const counts = new Map();
120
+ for (const record of usable) {
121
+ for (const keyword of record.keywords) {
122
+ counts.set(keyword, (counts.get(keyword) ?? 0) + 1);
123
+ }
124
+ }
125
+ if (counts.size === 0)
126
+ return '';
127
+ const topics = [...counts.entries()]
128
+ .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
129
+ .slice(0, INDEX_NOTICE_TOPICS);
130
+ const labels = topics.map(([topic, count]) => `${topic}(${count})`);
131
+ return `(索引:当前可见 ${usable.length} 条记忆,主题集中在 ${labels.join('、')}——用 memory_search 检索)`;
132
+ }
package/dist/self.js CHANGED
@@ -3,6 +3,8 @@
3
3
  * 0.3.2 起:LLM 每轮知道本环境有记忆机制;不落用户存储,随发版更新,
4
4
  * 版本号动态读取 package.json("只跟随 dsh-memory 组件发版变动")。
5
5
  * 0.9.2:补齐常驻注入机制——`injected` 参数、注入预算上限、预算诊断行的含义。
6
+ * 0.10.0:修正工具清单(6 → 10,补提示词模板工具)与两层记忆说明——原清单漏了
7
+ * `prompt_*` 系列,照它读会以为本插件只有记忆工具。
6
8
  */
7
9
  import { createRequire } from 'node:module';
8
10
  export const SELF_VERSION = (() => {
@@ -17,7 +19,8 @@ export const SELF_VERSION = (() => {
17
19
  }
18
20
  })();
19
21
  export const SELF_DESCRIPTION = `[记忆系统自述] 本环境内置跨会话记忆服务(dsh-memory v${SELF_VERSION})。`
20
- + '你有 6 个记忆工具:memory_save / memory_search / memory_list / memory_update / memory_confirm / memory_forget。'
22
+ + '你有 10 个工具:**记忆工具** memory_save / memory_search / memory_list / memory_update / memory_confirm / memory_forget(`memory_confirm` 仅在用户明确要求放行隔离记录时用);**提示词模板工具** prompt_search / prompt_get / prompt_list / prompt_add(模板是 md 文件、**永不注入**,走这条通道,不会混进记忆检索)。'
23
+ + '记忆分两层:`global` 跨项目(偏好 / 习惯 / 环境知识),`project` 跟随工作区(落在 `<cwd>/.dsh/storages/`,随 git 提交分享)。'
21
24
  + '**何时写入**(不必刻意找机会,遇到下面这些就动手):'
22
25
  + '① 用户表达长期偏好或约定(「以后都…」「记住…」「我们的规矩是…」);'
23
26
  + '② 用户纠正了你的做法或理解——把正确结论记下来;'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@max-null/dsh-memory",
3
- "version": "0.9.2",
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": {
@@ -23,7 +23,10 @@
23
23
  "cordis.patch.yml",
24
24
  "README.md",
25
25
  "LICENSE",
26
- "docs/shots"
26
+ "docs/shots",
27
+ "scripts/scan-orphans.mjs",
28
+ "scripts/unzstd-frames.mjs",
29
+ "scripts/ancestor-probe.mjs"
27
30
  ],
28
31
  "dsh": {
29
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,110 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 孤儿记忆文件清点(④-C 2026-09-19)——**只读,不做任何处置**。
4
+ *
5
+ * 背景:project 记忆的文件名是 `memory_project_<djb2(工作区路径)>.json`,而代码只按这个
6
+ * 名字去找。历史上换过命名(早期无哈希后缀、有过双重 `memory_project_` 前缀),那些旧文件
7
+ * 因此**永远不会被打开**——不是「等人放行」,是代码已经不认识这个文件名了。实测
8
+ * (2026-09-19)全工作区 136 条 project 记忆里有 33 条处于这个状态。
9
+ *
10
+ * 本脚本回答一个问题:**哪些文件当前代码打不开**。它不迁移、不删除、不修改任何东西。
11
+ * 处置(归档 / 选择性导入 / 删除)是一次显式的人工动作,理由见设计文档 §3.4:
12
+ * 旧文件可能含 0.7.0 之前未过 `detectSensitive` 的内容,自动捞出来等于绕过隔离检查。
13
+ *
14
+ * 用法:node scripts/scan-orphans.mjs [根目录](缺省为当前目录)
15
+ */
16
+ import { readdirSync, existsSync, readFileSync, statSync } from 'node:fs'
17
+ import { dirname, join } from 'node:path'
18
+
19
+ /** 与 engine.ts 的 projectBackendName 逐字一致:djb2 over 小写路径,36 进制。 */
20
+ function projectBackendName(cwd) {
21
+ let h = 5381
22
+ const text = cwd.toLowerCase()
23
+ for (let i = 0; i < text.length; i++) h = ((h << 5) + h + text.charCodeAt(i)) | 0
24
+ return `memory_project_${Math.abs(h).toString(36)}`
25
+ }
26
+
27
+ /** 递归找出所有 `.dsh/storages` 目录;跳过 node_modules / .git,限深防跑飞。 */
28
+ function findStorageDirs(root, depth = 0, out = []) {
29
+ if (depth > 4) return out
30
+ let entries = []
31
+ try {
32
+ entries = readdirSync(root, { withFileTypes: true })
33
+ } catch {
34
+ return out
35
+ }
36
+ for (const entry of entries) {
37
+ if (!entry.isDirectory() || entry.name === 'node_modules' || entry.name === '.git') continue
38
+ const full = join(root, entry.name)
39
+ if (entry.name === '.dsh') {
40
+ const storages = join(full, 'storages')
41
+ if (existsSync(storages)) out.push(storages)
42
+ continue
43
+ }
44
+ findStorageDirs(full, depth + 1, out)
45
+ }
46
+ return out
47
+ }
48
+
49
+ /** 数一个记忆文件里的记录条数;读不出来返回 null(诚实报告「读不了」而不是 0)。 */
50
+ function countRecords(file) {
51
+ try {
52
+ const blocks = JSON.parse(readFileSync(file, 'utf8'))?.tables?.blocks ?? {}
53
+ return Object.values(blocks).filter(block => block && block.content).length
54
+ } catch {
55
+ return null
56
+ }
57
+ }
58
+
59
+ const root = process.argv[2] ?? process.cwd()
60
+ const storageDirs = findStorageDirs(root)
61
+
62
+ console.log(`扫描根:${root}`)
63
+ console.log(`发现 ${storageDirs.length} 个 storages 目录\n`)
64
+
65
+ let orphans = 0
66
+ let orphanRecords = 0
67
+ let healthy = 0
68
+
69
+ for (const storages of storageDirs) {
70
+ const workspace = dirname(dirname(storages))
71
+ const expected = `${projectBackendName(workspace)}.json`
72
+ let files = []
73
+ try {
74
+ files = readdirSync(storages).filter(name => name.startsWith('memory_project') && name.endsWith('.json'))
75
+ } catch {
76
+ continue
77
+ }
78
+ if (files.length === 0) continue
79
+
80
+ const rows = files.map(name => {
81
+ const file = join(storages, name)
82
+ return {
83
+ name,
84
+ ok: name === expected,
85
+ count: countRecords(file),
86
+ size: statSync(file).size,
87
+ mtime: new Date(statSync(file).mtimeMs).toISOString().slice(0, 10),
88
+ }
89
+ })
90
+ const bad = rows.filter(row => !row.ok)
91
+ const good = rows.filter(row => row.ok)
92
+
93
+ console.log(`── ${workspace}`)
94
+ console.log(` 期望文件名:${expected}`)
95
+ for (const row of good) {
96
+ healthy += 1
97
+ console.log(` ✅ ${row.name} ${row.count ?? '?'} 条 ${row.size}B ${row.mtime}`)
98
+ }
99
+ for (const row of bad) {
100
+ orphans += 1
101
+ orphanRecords += row.count ?? 0
102
+ console.log(` ⚠️ ${row.name} ${row.count ?? '?'} 条 ${row.size}B ${row.mtime} ← 代码打不开`)
103
+ }
104
+ console.log('')
105
+ }
106
+
107
+ console.log('── 汇总 ──')
108
+ console.log(`能被代码打开:${healthy} 个文件`)
109
+ console.log(`打不开(孤儿):${orphans} 个文件,共 ${orphanRecords} 条记录`)
110
+ console.log('\n本脚本只清点。处置(归档 / 选择性导入 / 删除)见设计文档 §3.4,需人工拍板后单独执行。')