@max-null/dsh-memory 0.11.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -150,6 +150,7 @@ node scripts/verify-loader.mjs # 用 Loader 端到端验证插件可加载
150
150
  node scripts/scan-orphans.mjs [根目录] # 清点「当前代码打不开」的记忆文件(只读)
151
151
  node scripts/unzstd-frames.mjs <源> <目标> # 解多帧 zstd(读 DSH 会话日志用)
152
152
  node scripts/ancestor-probe.mjs plan [--cwd <cwd>] # 复算祖先链(只读);seed/check/clean 验 ④-A
153
+ node scripts/analyze-cooccurrence.mjs # 关键词共现分析(记忆之间有没有关系网,只读)
153
154
  ```
154
155
 
155
156
  ## 依赖(peerDependencies,由宿主提供)
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ import { MemoryEngine } from "./engine.js";
9
9
  import { MemoryGateway } from "./remote.js";
10
10
  import { mountMemoryApi } from "./routes.js";
11
11
  import { SELF_DESCRIPTION } from "./self.js";
12
- import { DEFAULT_INJECTION_BUDGET, DEFAULT_SUMMARY_CHARS, indexNotice, neighborNotice, omittedNotice, renderInjection } from "./injection.js";
12
+ import { DEFAULT_INJECTION_BUDGET, DEFAULT_SUMMARY_CHARS, indexNotice, neighborNotice, neutralizeBraces, omittedNotice, renderInjection } from "./injection.js";
13
13
  export { MemoryEngine } from "./engine.js";
14
14
  export { MemoryId } from "./engine.js";
15
15
  export { bm25FieldScores, bm25Scores, cosineSimilarity, rrfFuse, tokenize } from "./bm25.js";
@@ -135,6 +135,8 @@ const GUIDANCE = 'Use memory tools for cross-session preferences, habits, and pr
135
135
  // 0.5.2:注入经摘要化 + 预算截断(renderInjection,纯函数)防上下文膨胀。
136
136
  // 0.9.0:装不下的条目不再是无声丢弃——末尾追加一行预算诊断(omittedNotice),
137
137
  // 报出被挡在外面的 id;它只在有出局时出现,清理干净即消失。
138
+ // 0.11.1:返回值经 neutralizeBraces 中和字面 `{{`——严格插值下它会抛错,让该会话的
139
+ // 全部模型请求一起失败(机制见 injection.ts 的 neutralizeBraces)。
138
140
  function recallText(memory, projectCwd, budget, summaryChars) {
139
141
  const rendered = renderInjection(memory.recallRecords(projectCwd), budget, summaryChars);
140
142
  const parts = [];
@@ -153,7 +155,10 @@ function recallText(memory, projectCwd, budget, summaryChars) {
153
155
  const neighbors = neighborNotice(memory.neighborsOf(projectCwd));
154
156
  if (neighbors !== '')
155
157
  parts.push(neighbors);
156
- return parts.join('\n');
158
+ // 中和整个注入副本,而不是在各子行内部:四条子路径(注入行 / 预算诊断 / 索引行 /
159
+ // 子项目索引)在这里唯一汇合,一处即全覆盖,将来新增子行也不必记得各自中和。
160
+ // 这四行都承载模型或用户可写的文本——记忆正文、关键词、子项目目录名。
161
+ return neutralizeBraces(parts.join('\n'));
157
162
  }
158
163
  /** 从组装上下文取当前 agent 会话的工作区 cwd(AssembleContext.agent 由 dsh-agent 声明合并提供)。 */
159
164
  function assemblyProjectCwd(assembly) {
@@ -18,6 +18,19 @@ export declare const OMITTED_SUMMARY_CHARS = 24;
18
18
  * 空内容/空首行回退为去掉换行后的前 maxChars(保证 any 内容有摘要)。
19
19
  */
20
20
  export declare function deriveSummary(content: string, maxChars: number): string;
21
+ /**
22
+ * 中和注入副本里的字面 `{{`。DSH 的系统提示词走**严格插值**:section/context 的
23
+ * 文本里出现 `{{name}}` 会当场抛错(未注册 → `unknown`;名字不合
24
+ * `/^[a-z][a-z0-9_]*$/` → `malformed`),而组装发生在模型调用之前——该会话的
25
+ * 全部模型请求一起失败,模型无从自救(它改不了自己的记忆)。
26
+ *
27
+ * 循环替换直至稳定:单遍 `replaceAll` 处理不了 3 连以上左花括号——`{{{{a}}}}`
28
+ * 单遍得 `{ {{ {a}}}}`,仍含 `{{`。实测 1..1000 连最多 2 轮收敛。
29
+ *
30
+ * 只施加于**注入副本**:存储与面板预览都保留原文。记忆是给人看的审计窗口,
31
+ * 显示被改写过的内容会让人以为记忆本身被改过。
32
+ */
33
+ export declare function neutralizeBraces(text: string): string;
21
34
  /** 一条注入行的渲染文本(与面板预览完全一致)。 */
22
35
  export declare function injectionLine(record: MemoryRecord, summaryChars: number): string;
23
36
  export interface InjectionRender {
package/dist/injection.js CHANGED
@@ -28,6 +28,24 @@ export function deriveSummary(content, maxChars) {
28
28
  return content;
29
29
  return collapsed.length <= maxChars ? collapsed : `${collapsed.slice(0, maxChars)}…`;
30
30
  }
31
+ /**
32
+ * 中和注入副本里的字面 `{{`。DSH 的系统提示词走**严格插值**:section/context 的
33
+ * 文本里出现 `{{name}}` 会当场抛错(未注册 → `unknown`;名字不合
34
+ * `/^[a-z][a-z0-9_]*$/` → `malformed`),而组装发生在模型调用之前——该会话的
35
+ * 全部模型请求一起失败,模型无从自救(它改不了自己的记忆)。
36
+ *
37
+ * 循环替换直至稳定:单遍 `replaceAll` 处理不了 3 连以上左花括号——`{{{{a}}}}`
38
+ * 单遍得 `{ {{ {a}}}}`,仍含 `{{`。实测 1..1000 连最多 2 轮收敛。
39
+ *
40
+ * 只施加于**注入副本**:存储与面板预览都保留原文。记忆是给人看的审计窗口,
41
+ * 显示被改写过的内容会让人以为记忆本身被改过。
42
+ */
43
+ export function neutralizeBraces(text) {
44
+ let result = text;
45
+ while (result.includes('{{'))
46
+ result = result.replaceAll('{{', '{ {');
47
+ return result;
48
+ }
31
49
  /** 一条注入行的渲染文本(与面板预览完全一致)。 */
32
50
  export function injectionLine(record, summaryChars) {
33
51
  // ④-A:跨工作区召回时带来源标记(`..` / `../..`);当前工作区不带,保持既有格式
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@max-null/dsh-memory",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "description": "Cross-session plaintext memory plugin for the DeepSeek Harness — deterministic BM25 recall, writes take effect immediately, signal-driven injection, no embeddings",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -26,7 +26,8 @@
26
26
  "docs/shots",
27
27
  "scripts/scan-orphans.mjs",
28
28
  "scripts/unzstd-frames.mjs",
29
- "scripts/ancestor-probe.mjs"
29
+ "scripts/ancestor-probe.mjs",
30
+ "scripts/analyze-cooccurrence.mjs"
30
31
  ],
31
32
  "dsh": {
32
33
  "bundle": {
@@ -58,6 +59,7 @@
58
59
  "build": "tsc -p tsconfig.json && node build-client.mjs",
59
60
  "typecheck": "tsc --noEmit -p tsconfig.json",
60
61
  "test": "vitest run",
62
+ "mutate:check": "node scripts/mutate-check.mjs",
61
63
  "prepublishOnly": "npm run build"
62
64
  },
63
65
  "dependencies": {
@@ -0,0 +1,209 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 关键词共现分析——回答「记忆之间有没有关系网」。
4
+ *
5
+ * **为什么需要它**:记忆系统有两个方向可选——「报规模与主题」(我们现在的做法)与
6
+ * 「给记忆建关系」(图 / 相关推荐)。选哪个不该靠直觉,而该看数据里到底有没有关系。
7
+ * 本脚本就是量这件事的:把两条记忆共享的关键词个数当关联强度,统计多少对有关联、
8
+ * 它们连成几个簇、多少条是孤立点。
9
+ *
10
+ * **能回答什么**:
11
+ * - 孤立点占比——**关系维度值不值得做**的第一判据。孤立率高(比如 >50%)说明记忆
12
+ * 本质是「一条条独立事实」,图与相关推荐都没有数据支撑。
13
+ * - 簇的规模与主题——若确实成簇,簇内高频词就是「成片的主题」,比全局词频 top-N 更聚焦。
14
+ * - 阈值敏感度——共享 1 个词 vs 2 个词的对数比,决定「关联」的门槛该定在哪。
15
+ *
16
+ * **判据怎么算**:
17
+ * - 只统计 approved、非隔离、非模板(`kind !== 'prompt'`)的记录——与 `indexNotice` /
18
+ * `recallRecords` 的候选口径一致,否则会把不该参与的东西算进来。
19
+ * - 关联门槛默认「共享 ≥2 个关键词」。**不要降到 1**:实测共享 1 个的有 273 对、
20
+ * 共享 ≥2 的只有 38 对(7 倍差),降到 1 等于把「都含 dsh」也算成关联。
21
+ * - 连通分量用洪水填充;**孤立点 = 自己成一个分量的记录**。
22
+ *
23
+ * **哪些情况答不了**:
24
+ * - 只做**词面**共现,不理解语义:「pnpm」与「包管理器」不共享词就是孤立点,哪怕它们
25
+ * 讲的是同一件事。跨过这一层需要语义检索(见 `docs/决策/2026-09-18-语义检索通道启用判据.md`)。
26
+ * - 结论只在「关键词是多角度的」这个前提下成立:若写入时关键词给得敷衍(比如都只给一个
27
+ * 项目名),脚本会报出虚高的关联。
28
+ * - **只读**:不写任何存储文件,可随时重跑。
29
+ *
30
+ * 用法:
31
+ * node scripts/analyze-cooccurrence.mjs [--workspace <路径>] [--min-shared N] [--top N] [--help]
32
+ *
33
+ * 退出码:0 = 正常;2 = 用法错误。
34
+ */
35
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
36
+ import { homedir } from 'node:os'
37
+ import { dirname, join } from 'node:path'
38
+
39
+ const USAGE = `关键词共现分析——回答「记忆之间有没有关系网」。
40
+
41
+ 用法:
42
+ node scripts/analyze-cooccurrence.mjs [--workspace <路径>] [--min-shared N] [--top N] [--help]
43
+
44
+ --workspace P 工作区根(默认从 cwd 起向上找第一个含 .dsh/storages 的目录)
45
+ --min-shared N 关联门槛:共享几个关键词才算有关联(默认 2,不建议降到 1)
46
+ --top N 展示最大的几个簇(默认 5)
47
+
48
+ 只读,不写任何文件。
49
+ 退出码:0 = 正常;2 = 用法错误。`
50
+
51
+ const argv = process.argv.slice(2)
52
+ if (argv.includes('--help') || argv.includes('-h')) {
53
+ console.log(USAGE)
54
+ process.exit(0)
55
+ }
56
+ const readString = (name) => {
57
+ const index = argv.indexOf(name)
58
+ return index === -1 ? undefined : argv[index + 1]
59
+ }
60
+ const readNumber = (name, fallback) => {
61
+ const raw = readString(name)
62
+ if (raw === undefined) return fallback
63
+ const value = Number(raw)
64
+ if (!Number.isInteger(value) || value < 0) {
65
+ console.error(`${name} 需要一个非负整数,收到:${raw}`)
66
+ process.exit(2)
67
+ }
68
+ return value
69
+ }
70
+ const MIN_SHARED = readNumber('--min-shared', 2)
71
+ const TOP = readNumber('--top', 5)
72
+
73
+ /** 从 cwd 起向上找第一个含 `.dsh/storages` 的目录——工作区根通常就是它。 */
74
+ function findWorkspace(start) {
75
+ let current = start
76
+ for (;;) {
77
+ if (existsSync(join(current, '.dsh', 'storages'))) return current
78
+ const parent = dirname(current)
79
+ if (parent === current) return start
80
+ current = parent
81
+ }
82
+ }
83
+
84
+ const workspace = readString('--workspace') ?? findWorkspace(process.cwd())
85
+ const dshHome = process.env.DSH_HOME ?? join(homedir(), '.dsh')
86
+
87
+ /**
88
+ * 两个存储:`project` 在工作区下(随 git 分享),`global` 在 DSH_HOME 下。
89
+ * project 的文件名带 cwd 哈希后缀,所以扫目录而不是拼固定名;双重前缀的历史残骸
90
+ * (`memory_project_memory_project_*`)不是当前代码认得的名字,排除掉。
91
+ */
92
+ const sources = []
93
+ const projectStorages = join(workspace, '.dsh', 'storages')
94
+ let projectNames = []
95
+ try {
96
+ projectNames = readdirSync(projectStorages)
97
+ } catch {
98
+ projectNames = [] // 工作区没有 project 存储:只分析 global
99
+ }
100
+ for (const name of projectNames) {
101
+ if (!name.startsWith('memory_project') || !name.endsWith('.json')) continue
102
+ if (name.startsWith('memory_project_memory_project')) continue
103
+ sources.push(['project', join(projectStorages, name)])
104
+ }
105
+ sources.push(['global', join(dshHome, 'storages', 'memory.json')])
106
+
107
+ console.log(`工作区:${workspace}`)
108
+ console.log(`DSH_HOME:${dshHome}\n`)
109
+
110
+ const records = []
111
+ for (const [namespace, file] of sources) {
112
+ let doc
113
+ try {
114
+ doc = JSON.parse(readFileSync(file, 'utf8'))
115
+ } catch {
116
+ continue // 存储不存在或读不动:跳过,不报错
117
+ }
118
+ for (const [id, block] of Object.entries(doc.tables?.blocks ?? {})) {
119
+ if (block === null || typeof block !== 'object' || typeof block.content !== 'string') continue
120
+ if (block.quarantined === true) continue
121
+ if ((block.status ?? 'approved') !== 'approved') continue
122
+ if ((block.kind ?? 'fact') === 'prompt') continue
123
+ records.push({
124
+ id: String(id),
125
+ namespace,
126
+ keywords: new Set((block.keywords ?? []).map(keyword => String(keyword).toLowerCase())),
127
+ title: block.content.split('\n')[0].slice(0, 46),
128
+ })
129
+ }
130
+ }
131
+
132
+ if (records.length === 0) {
133
+ console.log('没有可分析的记忆(未找到 project / global 存储,或库里没有 approved 记录)。')
134
+ process.exit(0)
135
+ }
136
+
137
+ console.log(`候选记忆:${records.length} 条(approved、非隔离、非模板)`)
138
+ const sizes = records.map(record => record.keywords.size)
139
+ console.log(`关键词密度:平均 ${(sizes.reduce((a, b) => a + b, 0) / sizes.length).toFixed(1)} / 最少 ${Math.min(...sizes)} / 最多 ${Math.max(...sizes)}`)
140
+ console.log(`关联门槛:共享 ≥${MIN_SHARED} 个关键词\n`)
141
+
142
+ const histogram = new Map()
143
+ const adjacency = new Map()
144
+ const link = (a, b) => {
145
+ if (!adjacency.has(a)) adjacency.set(a, new Set())
146
+ if (!adjacency.has(b)) adjacency.set(b, new Set())
147
+ adjacency.get(a).add(b)
148
+ adjacency.get(b).add(a)
149
+ }
150
+ for (let i = 0; i < records.length; i += 1) {
151
+ for (let j = i + 1; j < records.length; j += 1) {
152
+ let shared = 0
153
+ for (const keyword of records[i].keywords) if (records[j].keywords.has(keyword)) shared += 1
154
+ if (shared === 0) continue
155
+ histogram.set(shared, (histogram.get(shared) ?? 0) + 1)
156
+ if (shared >= MIN_SHARED) link(records[i].id, records[j].id)
157
+ }
158
+ }
159
+
160
+ console.log('共享关键词个数的分布(对数):')
161
+ for (const [shared, count] of [...histogram.entries()].sort((a, b) => a[0] - b[0])) {
162
+ const noise = shared === 1 ? ' ← 噪声级,通常不计入关联' : ''
163
+ console.log(` ${String(shared).padStart(2)} 个 → ${String(count).padStart(4)} 对${noise}`)
164
+ }
165
+
166
+ const seen = new Set()
167
+ const clusters = []
168
+ for (const record of records) {
169
+ if (seen.has(record.id)) continue
170
+ const stack = [record.id]
171
+ const component = []
172
+ seen.add(record.id)
173
+ while (stack.length > 0) {
174
+ const current = stack.pop()
175
+ component.push(current)
176
+ for (const neighbor of adjacency.get(current) ?? []) {
177
+ if (!seen.has(neighbor)) {
178
+ seen.add(neighbor)
179
+ stack.push(neighbor)
180
+ }
181
+ }
182
+ }
183
+ clusters.push(component)
184
+ }
185
+ clusters.sort((a, b) => b.length - a.length)
186
+ const isolated = clusters.filter(cluster => cluster.length === 1).length
187
+
188
+ console.log(`\n连通分量:${clusters.length} 个`)
189
+ console.log(` 规模:${clusters.map(cluster => cluster.length).slice(0, 12).join(' / ')}${clusters.length > 12 ? ' …' : ''}`)
190
+ console.log(` 孤立点:${isolated} 个(${((isolated / records.length) * 100).toFixed(0)}%) | 参与成簇:${records.length - isolated} 个`)
191
+
192
+ const byId = new Map(records.map(record => [record.id, record]))
193
+ console.log(`\n最大的 ${TOP} 个簇(簇内高频词 = 成片的主题):`)
194
+ let shown = 0
195
+ for (const component of clusters) {
196
+ if (component.length === 1 || shown >= TOP) break
197
+ shown += 1
198
+ const frequency = new Map()
199
+ for (const id of component) {
200
+ for (const keyword of byId.get(id).keywords) frequency.set(keyword, (frequency.get(keyword) ?? 0) + 1)
201
+ }
202
+ const top = [...frequency.entries()]
203
+ .filter(([, count]) => count >= 2)
204
+ .sort((a, b) => b[1] - a[1])
205
+ .slice(0, 6)
206
+ console.log(`\n ● ${component.length} 条:${top.map(([keyword, count]) => `${keyword}(${count})`).join('、') || '(无重复词)'}`)
207
+ for (const id of component.slice(0, 5)) console.log(` · ${byId.get(id).title}`)
208
+ if (component.length > 5) console.log(` … 另 ${component.length - 5} 条`)
209
+ }