@kira905/dsh-memory-toolkit 0.0.0-stage → 0.1.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.
Files changed (132) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/LICENSE +661 -0
  3. package/README.en.md +110 -0
  4. package/README.md +102 -2
  5. package/dsh-memory-local/FORK-BASE.json +24 -0
  6. package/dsh-memory-local/README.md +113 -0
  7. package/dsh-memory-local/build.mjs +101 -0
  8. package/dsh-memory-local/cordis.patch.yml +20 -0
  9. package/dsh-memory-local/deploy.mjs +306 -0
  10. package/dsh-memory-local/entity-status.mjs +75 -0
  11. package/dsh-memory-local/lib/bridge.mjs +108 -0
  12. package/dsh-memory-local/lib/host-adapter.js +1171 -0
  13. package/dsh-memory-local/lib/index.d.ts +128 -0
  14. package/dsh-memory-local/lib/index.js +944 -0
  15. package/dsh-memory-local/lib/injector.mjs +400 -0
  16. package/dsh-memory-local/lib/memory-read.mjs +116 -0
  17. package/dsh-memory-local/lib/module-gate.mjs +68 -0
  18. package/dsh-memory-local/lib/pointer.mjs +152 -0
  19. package/dsh-memory-local/lib/prod-gate.mjs +121 -0
  20. package/dsh-memory-local/lib/recall-log.mjs +118 -0
  21. package/dsh-memory-local/lib/recall.mjs +192 -0
  22. package/dsh-memory-local/lib/schema-migrate.mjs +365 -0
  23. package/dsh-memory-local/lib/user-message.mjs +32 -0
  24. package/dsh-memory-local/lib/vectors-read.mjs +110 -0
  25. package/dsh-memory-local/package.json +51 -0
  26. package/dsh-memory-local/restore.mjs +259 -0
  27. package/dsh-memory-local/rollback-injector-fix.mjs +166 -0
  28. package/dsh-memory-local/src/bridge.mjs +108 -0
  29. package/dsh-memory-local/src/host-adapter.js +1171 -0
  30. package/dsh-memory-local/src/index.d.ts +128 -0
  31. package/dsh-memory-local/src/index.js +944 -0
  32. package/dsh-memory-local/src/injector.mjs +400 -0
  33. package/dsh-memory-local/src/memory-read.mjs +116 -0
  34. package/dsh-memory-local/src/module-gate.mjs +68 -0
  35. package/dsh-memory-local/src/pointer.mjs +152 -0
  36. package/dsh-memory-local/src/recall-log.mjs +118 -0
  37. package/dsh-memory-local/src/recall.mjs +192 -0
  38. package/dsh-memory-local/src/schema-migrate.mjs +365 -0
  39. package/dsh-memory-local/src/user-message.mjs +32 -0
  40. package/dsh-memory-local/src/vectors-read.mjs +110 -0
  41. package/dsh-memory-local/test/fixtures-s2b.mjs +216 -0
  42. package/dsh-memory-local/test/probe-entity.mjs +170 -0
  43. package/dsh-memory-local/test/regression.mjs +270 -0
  44. package/dsh-memory-local/test/s2b.test.mjs +568 -0
  45. package/dsh-memory-local/test/schema-migrate.test.mjs +334 -0
  46. package/dsh-memory-local/test/test-adapter-converge.mjs +127 -0
  47. package/dsh-memory-local/test/visible-injections.test.mjs +239 -0
  48. package/dsh-memory-local/verify-after-restart.mjs +181 -0
  49. package/lib/deploy-guard.mjs +391 -0
  50. package/lib/guard-http.mjs +93 -0
  51. package/lib/pitfall-form-map.mjs +130 -0
  52. package/lib/pitfall-score.mjs +185 -0
  53. package/lib/session-file-name.mjs +160 -0
  54. package/lib/zstd-frames.mjs +78 -0
  55. package/memory-db-tools/README.md +68 -0
  56. package/memory-db-tools/_test/test-memory-scope.mjs +302 -0
  57. package/memory-db-tools/audit-analyze.mjs +352 -0
  58. package/memory-db-tools/audit-dump-ids.mjs +245 -0
  59. package/memory-db-tools/audit-export.mjs +270 -0
  60. package/memory-db-tools/audit-hit-probe.mjs +227 -0
  61. package/memory-db-tools/audit-ledger-probe.mjs +236 -0
  62. package/memory-db-tools/audit-persona-overlap.mjs +277 -0
  63. package/memory-db-tools/audit-struct-probe.mjs +213 -0
  64. package/memory-db-tools/audit-verify-unchanged.mjs +276 -0
  65. package/memory-db-tools/check-memory-db.mjs +8 -0
  66. package/memory-db-tools/dump-memory-db.mjs +12 -0
  67. package/memory-db-tools/lib/memory-scope.mjs +446 -0
  68. package/memory-db-tools/list-memory-summary.mjs +477 -0
  69. package/memory-db-tools/memory-identity-check.mjs +101 -0
  70. package/memory-db-tools/memory-schema-migrate.mjs +290 -0
  71. package/memory-db-tools/memory-scope-fixture.mjs +197 -0
  72. package/memory-db-tools/read-memories.mjs +26 -0
  73. package/memory-db-tools/read-memory-db.mjs +20 -0
  74. package/memory-db-tools/refine-memories.mjs +247 -0
  75. package/memory-db-tools/show-mem-times.mjs +22 -0
  76. package/memory-engine/README.md +70 -0
  77. package/memory-engine/bench/bench.mjs +221 -0
  78. package/memory-engine/cli/distill-rollback.mjs +86 -0
  79. package/memory-engine/cli/distill.mjs +156 -0
  80. package/memory-engine/cli/doc-index-rebuild.mjs +155 -0
  81. package/memory-engine/cli/doc-index.mjs +48 -0
  82. package/memory-engine/cli/domain-tags.mjs +251 -0
  83. package/memory-engine/cli/pitfall-hook.mjs +91 -0
  84. package/memory-engine/cli/recall.mjs +132 -0
  85. package/memory-engine/cli/selfcheck.mjs +57 -0
  86. package/memory-engine/cli/serve-start.mjs +247 -0
  87. package/memory-engine/cli/sweep.mjs +58 -0
  88. package/memory-engine/cli/sync.mjs +69 -0
  89. package/memory-engine/lib/cli-util.mjs +197 -0
  90. package/memory-engine/lib/config.mjs +135 -0
  91. package/memory-engine/lib/dirty-ledger.mjs +174 -0
  92. package/memory-engine/lib/distill-rollback.mjs +178 -0
  93. package/memory-engine/lib/distill.mjs +898 -0
  94. package/memory-engine/lib/doc-index-store.mjs +325 -0
  95. package/memory-engine/lib/doc-index.mjs +529 -0
  96. package/memory-engine/lib/doc-scan.mjs +271 -0
  97. package/memory-engine/lib/doc-summarize.mjs +305 -0
  98. package/memory-engine/lib/domain-tags.mjs +429 -0
  99. package/memory-engine/lib/embedder.mjs +242 -0
  100. package/memory-engine/lib/hashes.mjs +12 -0
  101. package/memory-engine/lib/memory-source.mjs +127 -0
  102. package/memory-engine/lib/pitfall-source.mjs +170 -0
  103. package/memory-engine/lib/policy-source.mjs +371 -0
  104. package/memory-engine/lib/recall.mjs +331 -0
  105. package/memory-engine/lib/selfcheck.mjs +131 -0
  106. package/memory-engine/lib/sweep.mjs +159 -0
  107. package/memory-engine/lib/sync.mjs +180 -0
  108. package/memory-engine/lib/tick-hook.mjs +218 -0
  109. package/memory-engine/lib/vectors-store.mjs +292 -0
  110. package/memory-engine/package-lock.json +1086 -0
  111. package/memory-engine/package.json +22 -0
  112. package/memory-engine/serve.mjs +171 -0
  113. package/memory-engine/test/01-store.test.mjs +127 -0
  114. package/memory-engine/test/02-sync.test.mjs +196 -0
  115. package/memory-engine/test/03-sweep.test.mjs +145 -0
  116. package/memory-engine/test/04-recall.test.mjs +214 -0
  117. package/memory-engine/test/05-selfcheck.test.mjs +175 -0
  118. package/memory-engine/test/06-doc-index.test.mjs +928 -0
  119. package/memory-engine/test/07-domain-tags.test.mjs +298 -0
  120. package/memory-engine/test/08-distill.test.mjs +686 -0
  121. package/memory-engine/test/09-pitfall-source.test.mjs +365 -0
  122. package/memory-engine/test/10-tick-hook.test.mjs +252 -0
  123. package/memory-engine/test/11-policy-source.test.mjs +224 -0
  124. package/memory-engine/test/_tickhook-worker.mjs +56 -0
  125. package/memory-engine/test/fixtures.mjs +205 -0
  126. package/memory-engine/test/harness.mjs +73 -0
  127. package/memory-engine/test/run.mjs +35 -0
  128. package/memory-engine/tools/bridge-smoke.mjs +80 -0
  129. package/memory-engine/tools/fetch-model.mjs +128 -0
  130. package/memory-engine/tools/smoke-recall.mjs +76 -0
  131. package/package.json +53 -4
  132. package/persona-memory-audit.mjs +217 -0
@@ -0,0 +1,271 @@
1
+ // 记忆引擎 v2 · 离线层 —— 文档扫描面 + 结构解析 + 粒度切分(方案 §3.2)
2
+ //
3
+ // 口径(逐条照方案,勿自行发挥):
4
+ // · 扫描面 = `<docsRoot>/**/*.md`(默认 `<cwd>/docs`)
5
+ // · **排除** = `变更流水/`、`管家报告/`、`坑库/`、`_archive`(流水类噪音大且频繁变更)
6
+ // · `path` 列 = **相对 `dshRoot` 的路径**
7
+ // · 粒度:短文档(<2 万字)1 条;长文档按 `##` 小节聚合 **2~5 条**;长文档多条以 `path#§N` 作主键
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+
11
+ /** docs 根(可注入;不写死机器名,方案 §4 宿主迁移契约)。 */
12
+ export const DEFAULT_DOCS_ROOT = path.join(process.cwd(), 'docs');
13
+ /** `path` 列的前缀锚(默认 = 当前工作目录)。 */
14
+ export const DEFAULT_DSH_ROOT = process.cwd();
15
+
16
+ /** 排除目录名(按路径"目录段"匹配,不按子串——否则 `_archive` 会误伤文件名)。 */
17
+ export const EXCLUDE_DIRS = ['变更流水', '管家报告', '坑库', '_archive'];
18
+
19
+ /** 长/短文档分界(方案 §3.2:短文档 < 2 万字 1 条)。 */
20
+ export const LONG_DOC_CHARS = 20000;
21
+ /** 长文档分条数区间(方案 §3.2:2~5 条)。 */
22
+ export const MIN_LONG_ROWS = 2;
23
+ export const MAX_LONG_ROWS = 5;
24
+ /** 每条摘要上限(方案 §3.2:≤500 字)。 */
25
+ export const SUMMARY_MAX_CHARS = 500;
26
+
27
+ /**
28
+ * 首期 10 篇「最常用文档」——判据出处:`docs\文档总索引.md` §一「最常用的 15 份」**人工段**,
29
+ * 取该表前 10 条(1→10,顺序即优先级)。**不是随手挑的**:这一节由主人与本鱼共同维护,
30
+ * 是新会话的高频入口清单;首期先索引这 10 篇试水(方案 §3.2「中间态」+ §10 T2 全量触发器)。
31
+ * ⚠️ 全量 270+ 篇**不在本卡范围**(属 §10 T2 触发器事项,需主人拍)。
32
+ */
33
+ export const FIRST_BATCH = [
34
+ 'docs/主人使用手册.md',
35
+ 'docs/DSH目录规范.md',
36
+ 'docs/开发顺序统筹-20260914.md',
37
+ 'docs/大管家-主人视角-一页纸-20260913.md',
38
+ 'docs/大管家-开关矩阵与放行判据-20260913.md',
39
+ 'docs/插件升级决策表.md',
40
+ 'docs/插件魔改维护清单.md',
41
+ 'docs/升级方案-2026-09-10.md',
42
+ 'docs/自愈与回滚-验收缺口与整改方案-20260911.md',
43
+ 'docs/gitee备份使用说明.md',
44
+ ];
45
+
46
+ /** 首期清单的判据出处(写进 state 与交付报告,避免"这 10 篇哪来的"无处可查)。 */
47
+ export const FIRST_BATCH_SOURCE = 'docs/文档总索引.md §一「最常用的 15 份」(人工段)前 10 条';
48
+
49
+ /** 统一 `/` 分隔(Windows 反斜杠一律归一;`path` 列口径)。 */
50
+ export function toPosix(p) {
51
+ return String(p).replace(/\\/g, '/');
52
+ }
53
+
54
+ /** 相对 `dshRoot` 的 path 口径(扫描面在 docs 下 ⇒ 结果以 `docs/` 开头)。 */
55
+ export function toRelPath(absPath, dshRoot = DEFAULT_DSH_ROOT) {
56
+ return toPosix(path.relative(dshRoot, absPath));
57
+ }
58
+
59
+ /** 是否落在排除面内(只看**目录段**;顶层文件永不被排除)。 */
60
+ export function isExcludedRel(relPath) {
61
+ const parts = toPosix(relPath).split('/');
62
+ const dirs = parts.slice(0, -1); // 去掉文件名
63
+ return dirs.some((d) => EXCLUDE_DIRS.includes(d));
64
+ }
65
+
66
+ /**
67
+ * 递归扫描扫描面(只读;不做 mtime 过滤——过滤交给差分逻辑)。
68
+ * @param {string} docsRoot
69
+ * @param {{dshRoot?:string, maxFiles?:number}} [opts]
70
+ * @returns {Array<{rel:string, abs:string, mtime:number, size:number}>}
71
+ */
72
+ export function scanDocs(docsRoot = DEFAULT_DOCS_ROOT, opts = {}) {
73
+ const dshRoot = opts.dshRoot ?? DEFAULT_DSH_ROOT;
74
+ const out = [];
75
+ const walk = (dir) => {
76
+ let entries;
77
+ try {
78
+ entries = fs.readdirSync(dir, { withFileTypes: true });
79
+ } catch {
80
+ return; // 目录不可读 → 跳过(fail-open,不打断别的扫描)
81
+ }
82
+ for (const e of entries) {
83
+ const abs = path.join(dir, e.name);
84
+ if (e.isDirectory()) {
85
+ if (EXCLUDE_DIRS.includes(e.name)) continue; // 目录整棵排除
86
+ walk(abs);
87
+ } else if (e.isFile() && /\.md$/i.test(e.name)) {
88
+ let st;
89
+ try {
90
+ st = fs.statSync(abs);
91
+ } catch {
92
+ continue;
93
+ }
94
+ const rel = toRelPath(abs, dshRoot);
95
+ if (isExcludedRel(rel)) continue;
96
+ out.push({ rel, abs, mtime: Math.trunc(st.mtimeMs), size: st.size });
97
+ }
98
+ }
99
+ };
100
+ walk(docsRoot);
101
+ out.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
102
+ return opts.maxFiles ? out.slice(0, opts.maxFiles) : out;
103
+ }
104
+
105
+ /** 读文档并算字符数(用于粒度判定与摘要生成)。 */
106
+ export function readDoc(absPath) {
107
+ const text = fs.readFileSync(absPath, 'utf8');
108
+ return { text, chars: text.length };
109
+ }
110
+
111
+ // ---------------------------------------------------------------- 结构解析
112
+
113
+ /** 去掉 markdown 内联标记(摘要里不要出现 `**`/反引号/链接语法)。 */
114
+ export function stripMd(s) {
115
+ return String(s)
116
+ .replace(/```[\s\S]*?```/g, ' ')
117
+ .replace(/`([^`]*)`/g, '$1')
118
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
119
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
120
+ .replace(/\*\*([^*]*)\*\*/g, '$1')
121
+ .replace(/(^|\s)[*_](\S[^*_]*)[*_]/g, '$1$2')
122
+ .replace(/^\s*>\s?/gm, '')
123
+ .replace(/\s+/g, ' ')
124
+ .trim();
125
+ }
126
+
127
+ /** 取首句(中文句读 + 英文句点;上限 maxChars 字,超长按字截断)。
128
+ * ⚠️ 刻意**不含分号**:中文文档里分号常切出"主人 = 用户"这种半截短语,反而没信息量。 */
129
+ export function firstSentence(text, maxChars = 80) {
130
+ const t = stripMd(text);
131
+ if (!t) return '';
132
+ const m = /^(.{4,}?[。!?!?]|.{4,}?\.\s|.{4,})/.exec(t);
133
+ let s = (m ? m[1] : t).trim();
134
+ s = s.replace(/[。!?!?]\s*$/, '').trim();
135
+ if (s.length > maxChars) s = `${s.slice(0, maxChars - 1)}…`;
136
+ return s;
137
+ }
138
+
139
+ /** 引言/章节的头几行里,这类"模板头 / 元信息"不算正文(否则每篇索引条都长一个样)。 */
140
+ const META_LINE_RE = /^(?:\s*[>|])|^(?:维护人|创建|最近更新|最后更新|最后修订|更新时间|文件位置|路径口径|版本)\s*[::]/;
141
+
142
+ /**
143
+ * 取一节的正文首段(**跳过引用块 / 表格 / 标题 / 代码块**,去掉列表符号)。
144
+ * 为什么需要它:文档头部普遍是 `> ↑本主题总纲见 …` 这类模板引用块,
145
+ * 直接取首句会让所有索引条的"说明"完全雷同(实测),对召回毫无区分度。
146
+ */
147
+ export function pickParagraph(body, maxChars = 400) {
148
+ const buf = [];
149
+ for (const raw of String(body ?? '').split(/\r?\n/)) {
150
+ const l = raw.trim();
151
+ if (!l) {
152
+ if (buf.length) break; // 段落结束
153
+ continue;
154
+ }
155
+ if (/^```/.test(l)) break;
156
+ if (/^#{1,6}\s/.test(l)) continue;
157
+ if (/^[-*_]{3,}$/.test(l)) continue; // 分隔线(--- / *** / ___)不是正文
158
+ if (META_LINE_RE.test(l) && !buf.length) continue; // 元信息/引用块:跳过继续找真正文
159
+ if (/^[>|]/.test(l)) continue;
160
+ if (/^[-*+]\s/.test(l)) {
161
+ if (buf.length) break;
162
+ buf.push(l.replace(/^[-*+]\s+/, ''));
163
+ } else {
164
+ buf.push(l);
165
+ }
166
+ if (buf.join(' ').length > maxChars) break;
167
+ }
168
+ return buf.join(' ').trim();
169
+ }
170
+
171
+ /** 章节标题瘦身(摘要里每节只留辨识度最高的前 28 字)。 */
172
+ export function shortTitle(t, maxChars = 28) {
173
+ const s = stripMd(t);
174
+ return s.length > maxChars ? `${s.slice(0, maxChars - 1)}…` : s;
175
+ }
176
+
177
+ /**
178
+ * 解析 markdown 结构:`#` 标题、引言段、`##` 小节(含其下三级及正文)。
179
+ * 只认 `##`(方案 §3.2「按 `##` 小节聚合」);`###` 归入所属 `##`。
180
+ * @returns {{title:string, intro:string, sections:Array<{title:string,chars:number,body:string}>}}
181
+ */
182
+ export function parseStructure(text) {
183
+ const lines = String(text).split(/\r?\n/);
184
+ let title = '';
185
+ let introStart = 0;
186
+ let i = 0;
187
+ // 首个 H1 = 文档标题
188
+ for (; i < lines.length; i += 1) {
189
+ const m = /^#\s+(.+?)\s*$/.exec(lines[i]);
190
+ if (m) {
191
+ title = stripMd(m[1]);
192
+ introStart = i + 1;
193
+ break;
194
+ }
195
+ }
196
+ if (!title) {
197
+ // 无 H1:用第一行非空文本兜底(摘要头不至于空)
198
+ const firstLine = lines.find((l) => l.trim());
199
+ title = firstSentence(firstLine || '', 60);
200
+ introStart = 0;
201
+ }
202
+ const sections = [];
203
+ let introLines = [];
204
+ let cur = null;
205
+ for (let j = introStart; j < lines.length; j += 1) {
206
+ const line = lines[j];
207
+ const m2 = /^##\s+(.+?)\s*$/.exec(line);
208
+ if (m2) {
209
+ if (cur) sections.push(cur);
210
+ cur = { title: stripMd(m2[1]), body: [] };
211
+ continue;
212
+ }
213
+ if (cur) cur.body.push(line);
214
+ else introLines.push(line);
215
+ }
216
+ if (cur) sections.push(cur);
217
+ const normalized = sections.map((s) => {
218
+ const body = s.body.join('\n').trim();
219
+ return { title: s.title, chars: s.title.length + body.length, body };
220
+ });
221
+ return { title, intro: introLines.join('\n').trim(), sections: normalized };
222
+ }
223
+
224
+ /** 目标分条数:2 万字内 1 条;超出按字符规模升到 2~5 条,且不超过 `##` 小节数。 */
225
+ export function planRowCount(chars, sectionCount) {
226
+ if (chars < LONG_DOC_CHARS) return 1;
227
+ const bySize = Math.round(chars / LONG_DOC_CHARS) + 1;
228
+ const target = Math.max(MIN_LONG_ROWS, Math.min(MAX_LONG_ROWS, bySize));
229
+ if (sectionCount < MIN_LONG_ROWS) return 1; // 没有可聚合的小节 ⇒ 只能 1 条
230
+ return Math.min(target, sectionCount);
231
+ }
232
+
233
+ /** 按 section **个数**均分(确定性、可复现;组数恰为 k)。 */
234
+ export function splitSections(sections, k) {
235
+ const n = sections.length;
236
+ if (k <= 1 || n <= 1) return [sections];
237
+ const base = Math.floor(n / k);
238
+ const rem = n % k;
239
+ const groups = [];
240
+ let idx = 0;
241
+ for (let g = 0; g < k; g += 1) {
242
+ const take = base + (g < rem ? 1 : 0);
243
+ if (take === 0) continue;
244
+ groups.push(sections.slice(idx, idx + take));
245
+ idx += take;
246
+ }
247
+ if (idx < n) groups[groups.length - 1] = groups[groups.length - 1].concat(sections.slice(idx));
248
+ return groups;
249
+ }
250
+
251
+ /**
252
+ * 把一篇文档切成索引条计划。
253
+ * @param {{rel:string, chars:number, structure:ReturnType<typeof parseStructure>}} doc
254
+ * @returns {Array<{key:string, index:number, total:number, sections:Array, intro:string}>}
255
+ */
256
+ export function planRows(doc) {
257
+ const { rel, chars, structure } = doc;
258
+ const k = planRowCount(chars, structure.sections.length);
259
+ if (k <= 1) {
260
+ return [{ key: rel, index: 0, total: 1, sections: structure.sections, intro: structure.intro }];
261
+ }
262
+ const groups = splitSections(structure.sections, k);
263
+ return groups.map((sections, i) => ({
264
+ key: `${rel}#§${i + 1}`,
265
+ index: i,
266
+ total: groups.length,
267
+ sections,
268
+ // 文档引言只在第 1 段带(避免 2~5 条里重复同一段话)
269
+ intro: i === 0 ? structure.intro : '',
270
+ }));
271
+ }
@@ -0,0 +1,305 @@
1
+ // 记忆引擎 v2 · 离线层 —— 索引条摘要生成(方案 §3.2 粒度 + §3.5 成本闸)
2
+ //
3
+ // 两条通路,**默认 rule(确定性抽取式,零外部调用)**:
4
+ // · `rule` —— 从文档结构直接抽取(标题 + 关键章节 + 各节首句)。零成本、零外发、
5
+ // 可复现(同一文档两次生成逐字节相同)⇒ 满足本卡「重建到与首跑一致」与「幂等」两条验收。
6
+ // · `llm` —— 便宜模型批量润色(方案 §3.2 原语句「摘要生成:便宜模型批量(一次性成本)」)。
7
+ // **输入是压缩视图(骨架 + 各节首段截断),不是全文** ⇒ token 可控;受 §3.5 同一成本闸
8
+ // (调用 ≤20 次/日、token ≤150K/日、超限即停 + 日报一行)。
9
+ // ⚠️ 家人相关数据不进第三方(方案 §3.5/§3.6 订正注:按 D2 从严读)——见 `assertNoFamilyContent`。
10
+ import fs from 'node:fs';
11
+ import { textHash } from './hashes.mjs';
12
+ import { SUMMARY_MAX_CHARS, firstSentence, pickParagraph, shortTitle, stripMd } from './doc-scan.mjs';
13
+
14
+ /** §3.5 成本闸(与提炼 job 同一上限;先定,实测后调)。 */
15
+ export const LLM_LIMITS = { callsPerDay: 20, tokensPerDay: 150000 };
16
+
17
+ /** 压缩视图上限(喂 LLM 的骨架文本;不是全文)。 */
18
+ export const LLM_VIEW_MAX_CHARS = 4000;
19
+ /** 单节在压缩视图里的截断长度。 */
20
+ export const LLM_SECTION_PREVIEW_CHARS = 220;
21
+
22
+ /** 规则摘要的片段预算(合计 ≤ SUMMARY_MAX_CHARS)。 */
23
+ const BUDGET = {
24
+ head: 90,
25
+ chapters: 170,
26
+ points: 240,
27
+ };
28
+
29
+ // ---------------------------------------------------------------- 成本闸
30
+
31
+ /** 当日成本台账的空态。 */
32
+ export function emptyCost(date) {
33
+ return { date, calls: 0, tokens: 0 };
34
+ }
35
+
36
+ /** 跨日重置(口径:按**本机本地日期**分段)。 */
37
+ export function rolloverCost(cost, date) {
38
+ if (!cost || cost.date !== date) return emptyCost(date);
39
+ return { date: cost.date, calls: Number(cost.calls) || 0, tokens: Number(cost.tokens) || 0 };
40
+ }
41
+
42
+ /**
43
+ * 成本闸判定(超限即停,不重试——方案 §3.5)。
44
+ * @returns {{allowed:boolean, reason:string}}
45
+ */
46
+ export function checkCostGate(cost, limits = LLM_LIMITS) {
47
+ if (Number(cost?.calls) >= limits.callsPerDay) {
48
+ return { allowed: false, reason: `调用次数达上限 ${limits.callsPerDay}/日` };
49
+ }
50
+ if (Number(cost?.tokens) >= limits.tokensPerDay) {
51
+ return { allowed: false, reason: `token 达上限 ${limits.tokensPerDay}/日` };
52
+ }
53
+ return { allowed: true, reason: '' };
54
+ }
55
+
56
+ // ---------------------------------------------------------------- 规则摘要(默认)
57
+
58
+ /** 摘要片段拼装:按预算装,装不下就丢尾(保证 ≤500 字且截断可预期)。 */
59
+ function joinWithin(parts, maxChars) {
60
+ let out = '';
61
+ for (const p of parts) {
62
+ if (!p) continue;
63
+ const next = out ? `${out}${p}` : p;
64
+ if (next.length > maxChars) return out || next.slice(0, maxChars);
65
+ out = next;
66
+ }
67
+ return out;
68
+ }
69
+
70
+ /**
71
+ * 抽取式摘要(确定性:同一输入两次调用逐字节相同)。
72
+ * @param {{rel:string, structure:{title:string,intro:string,sections:Array}}} doc
73
+ * @param {{key:string,index:number,total:number,sections:Array, intro:string}} row
74
+ */
75
+ export function buildRuleSummary(doc, row) {
76
+ const title = doc.structure.title || doc.rel;
77
+ const head =
78
+ row.total > 1
79
+ ? `【${title}】(第 ${row.index + 1}/${row.total} 段)`
80
+ : `【${title}】`;
81
+
82
+ const introLine = row.intro ? firstSentence(pickParagraph(row.intro, 300), 110) : '';
83
+ const chapters = row.sections.map((s) => shortTitle(s.title)).filter(Boolean);
84
+ const chapterLine = chapters.length ? `关键章节:${chapters.join('、')}` : '';
85
+
86
+ // 要点:每节首句(上限 5 条,每条 ≤70 字)
87
+ const points = [];
88
+ for (const s of row.sections) {
89
+ if (points.length >= 5) break;
90
+ const p = firstSentence(pickParagraph(s.body, 260), 70);
91
+ if (p) points.push(`${shortTitle(s.title, 20)}:${p}`);
92
+ }
93
+
94
+ const parts = [head];
95
+ if (introLine) parts.push(`说明:${introLine}`);
96
+ if (chapterLine) parts.push(chapterLine);
97
+ let summary = joinWithin(parts, BUDGET.head + BUDGET.chapters + 40).trim();
98
+ // 要点:**逐条装**——预算是硬的,但装不下只丢尾,绝不因为"整段超限"把要点全删
99
+ //(实测踩过:6 个长章节名吃掉 142 字后,原先的整段装法直接 return,索引条里一条要点都没有)
100
+ const remain = SUMMARY_MAX_CHARS - summary.length - 5;
101
+ if (points.length && remain > 30) {
102
+ let acc = '';
103
+ for (const p of points) {
104
+ const piece = `${acc ? ';' : ''}· ${p}`;
105
+ if (acc.length + piece.length > remain) break;
106
+ acc += piece;
107
+ }
108
+ if (acc) summary += ` 要点:${acc}`;
109
+ }
110
+ if (summary.length > SUMMARY_MAX_CHARS) summary = `${summary.slice(0, SUMMARY_MAX_CHARS - 1)}…`;
111
+ return summary.replace(/】(说明|关键章节|要点):/g, '】 $1:');
112
+ }
113
+
114
+ // ---------------------------------------------------------------- LLM 通路(可选)
115
+
116
+ /** 家族/隐私哨兵:输入里出现这些标记 ⇒ 停手申报(D2 从严读)。 */
117
+ export const FAMILY_SENTINELS = ['家人档', '家人投喂', '家庭成员'];
118
+
119
+ /**
120
+ * 压缩视图(喂 LLM 的输入):标题 + 引言截断 + 各节标题 + 各节首段截断。
121
+ * **绝不送全文**(token 闸的物理前提)。
122
+ */
123
+ export function buildLlmView(doc, { maxChars = LLM_VIEW_MAX_CHARS, previewChars = LLM_SECTION_PREVIEW_CHARS } = {}) {
124
+ const { title, intro, sections } = doc.structure;
125
+ const lines = [`文档:${doc.rel}`, `标题:${title}`];
126
+ const introS = firstSentence(pickParagraph(intro, 600), 300);
127
+ if (introS) lines.push(`引言:${introS}`);
128
+ lines.push('章节:');
129
+ for (const s of sections) {
130
+ const prev = firstSentence(pickParagraph(s.body, previewChars * 2), previewChars);
131
+ lines.push(`- ${s.title}${prev ? `:${prev}` : ''}`);
132
+ }
133
+ const text = lines.join('\n');
134
+ return text.length > maxChars ? `${text.slice(0, maxChars)}\n…[截断]` : text;
135
+ }
136
+
137
+ /** 压缩视图里的家人哨兵扫描(命中的路径名单;非空 ⇒ 调用方必须停手申报)。 */
138
+ export function assertNoFamilyContent(doc) {
139
+ const hay = `${doc.rel}\n${doc.structure.title}\n${doc.structure.intro}\n${doc.structure.sections
140
+ .map((s) => `${s.title}\n${s.body}`)
141
+ .join('\n')}`;
142
+ return FAMILY_SENTINELS.filter((k) => hay.includes(k));
143
+ }
144
+
145
+ /** 提示词(要求 JSON 输出,逐条 ≤500 字)。 */
146
+ export function buildLlmPrompt(doc, rows) {
147
+ const view = buildLlmView(doc);
148
+ const ask = rows
149
+ .map((r) => ` {"index": ${r.index}, "summary": "…"}`)
150
+ .join(',\n');
151
+ return [
152
+ '你在为一个本地文档索引库生成检索摘要。只输出 JSON,不要解释、不要代码块围栏。',
153
+ `文档路径:${doc.rel}`,
154
+ `要求:为下面 ${rows.length} 个分段各写一条中文摘要,每条 ≤500 字,必须包含:这段覆盖的关键章节名 + 这些章节讲什么。`,
155
+ '不要复述整篇文档;不要编造未出现的内容;不要输出 markdown 标题记号。',
156
+ `输出格式:{"rows": [\n${ask}\n]}`,
157
+ '',
158
+ '=== 文档骨架(压缩视图,非全文)===',
159
+ view,
160
+ ].join('\n');
161
+ }
162
+
163
+ /** 从模型回复里稳健地抽出 JSON(容忍 ```json 围栏与前后废话)。 */
164
+ export function parseLlmRows(raw) {
165
+ const s = String(raw ?? '');
166
+ const fence = /```(?:json)?\s*([\s\S]*?)```/i.exec(s);
167
+ const body = fence ? fence[1] : s;
168
+ const start = body.indexOf('{');
169
+ const end = body.lastIndexOf('}');
170
+ if (start < 0 || end <= start) throw new Error('模型输出里找不到 JSON 对象');
171
+ const obj = JSON.parse(body.slice(start, end + 1));
172
+ const rows = Array.isArray(obj?.rows) ? obj.rows : null;
173
+ if (!rows) throw new Error('模型输出缺 rows 数组');
174
+ return rows.map((r) => ({ index: Number(r.index), summary: stripMd(String(r.summary ?? '')) }));
175
+ }
176
+
177
+ /** 读凭证(`DEEPSEEK_API_KEY` 环境变量 > `~/.dsh/.credentials.yaml` 的简单解析;不打印密钥)。 */
178
+ export function resolveApiKey({ env = process.env, credentialsFile } = {}) {
179
+ if (env.DEEPSEEK_API_KEY && String(env.DEEPSEEK_API_KEY).trim()) return String(env.DEEPSEEK_API_KEY).trim();
180
+ if (!credentialsFile) return null;
181
+ try {
182
+ const txt = fs.readFileSync(credentialsFile, 'utf8');
183
+ const m = /DEEPSEEK_API_KEY\s*:\s*["']?([^"'\r\n]+)["']?/.exec(txt);
184
+ return m ? m[1].trim() : null;
185
+ } catch {
186
+ return null;
187
+ }
188
+ }
189
+
190
+ /**
191
+ * 调一次便宜模型(一篇文档一次调用);返回 { ok, rows, tokens, model, error }。
192
+ * `deps.fetch` 可注入 ⇒ 单测零网络。
193
+ */
194
+ export async function callLlmOnce({ doc, rows, llm, deps = {} }) {
195
+ const doFetch = deps.fetch ?? globalThis.fetch;
196
+ const key = llm.apiKey;
197
+ if (!key) return { ok: false, error: '缺少 DEEPSEEK_API_KEY(凭证文件或环境变量)', rows: [], tokens: 0 };
198
+ const prompt = buildLlmPrompt(doc, rows);
199
+ let res;
200
+ try {
201
+ res = await doFetch(`${llm.baseUrl.replace(/\/$/, '')}/chat/completions`, {
202
+ method: 'POST',
203
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
204
+ body: JSON.stringify({
205
+ model: llm.model,
206
+ messages: [{ role: 'user', content: prompt }],
207
+ temperature: 0.2,
208
+ stream: false,
209
+ }),
210
+ signal: deps.signal,
211
+ });
212
+ } catch (e) {
213
+ return { ok: false, error: `请求失败:${String((e && e.message) || e)}`, rows: [], tokens: 0 };
214
+ }
215
+ let body;
216
+ try {
217
+ body = await res.json();
218
+ } catch (e) {
219
+ return { ok: false, error: `响应非 JSON(HTTP ${res.status})`, rows: [], tokens: 0 };
220
+ }
221
+ if (!res.ok) {
222
+ const msg = body?.error?.message || body?.message || `HTTP ${res.status}`;
223
+ return { ok: false, error: `模型报错:${msg}`, rows: [], tokens: 0 };
224
+ }
225
+ const raw = body?.choices?.[0]?.message?.content ?? '';
226
+ const usage = body?.usage ?? {};
227
+ const tokens = Number(usage.total_tokens ?? 0) || estimateTokens(prompt) + estimateTokens(raw);
228
+ try {
229
+ const parsed = parseLlmRows(raw);
230
+ return { ok: true, rows: parsed, tokens, model: body?.model ?? llm.model, usage };
231
+ } catch (e) {
232
+ return { ok: false, error: `解析失败:${String((e && e.message) || e)}`, rows: [], tokens, raw: String(raw).slice(0, 200) };
233
+ }
234
+ }
235
+
236
+ /** token 估算(模型未返回 usage 时的兜底记账;中文按 1 字 ≈ 1 token 保守估)。 */
237
+ export function estimateTokens(text) {
238
+ return Math.ceil(String(text ?? '').length);
239
+ }
240
+
241
+ /**
242
+ * 为一批文档计划生成摘要(逐篇调用;受成本闸约束)。
243
+ * @returns {Promise<{byKey:Map<string,{summary:string,textHash:string}>, cost:object,
244
+ * calls:number, tokens:number, stopped:string|null, failures:Array, skippedForFamily:string[]}>}
245
+ */
246
+ export async function summarizeAll({ docs, plans, mode = 'rule', cost, llm = null, deps = {}, date = '' }) {
247
+ const byKey = new Map();
248
+ const failures = [];
249
+ const skippedForFamily = [];
250
+ let calls = 0;
251
+ let tokens = 0;
252
+ let stopped = null;
253
+ let working = rolloverCost(cost, date);
254
+
255
+ for (const doc of docs) {
256
+ const rows = plans.get(doc.rel) ?? [];
257
+ if (mode !== 'llm') {
258
+ for (const r of rows) {
259
+ const summary = buildRuleSummary(doc, r);
260
+ byKey.set(r.key, { summary, textHash: textHash(summary) });
261
+ }
262
+ continue;
263
+ }
264
+ // LLM 通路:先过家人哨兵(D2 从严读),再过成本闸
265
+ const family = assertNoFamilyContent(doc);
266
+ if (family.length) {
267
+ skippedForFamily.push(doc.rel);
268
+ failures.push({ path: doc.rel, error: `家人相关标记命中(${family.join('/')})⇒ 按 D2 从严读不送第三方,改用规则摘要` });
269
+ for (const r of rows) {
270
+ const summary = buildRuleSummary(doc, r);
271
+ byKey.set(r.key, { summary, textHash: textHash(summary) });
272
+ }
273
+ continue;
274
+ }
275
+ const gate = checkCostGate(working, LLM_LIMITS);
276
+ if (!gate.allowed) {
277
+ stopped = gate.reason;
278
+ break; // 超限即停(不重试)
279
+ }
280
+ const res = await callLlmOnce({ doc, rows, llm, deps });
281
+ calls += 1;
282
+ tokens += Number(res.tokens) || 0;
283
+ working = { date, calls: working.calls + 1, tokens: working.tokens + (Number(res.tokens) || 0) };
284
+ if (!res.ok) {
285
+ failures.push({ path: doc.rel, error: res.error });
286
+ for (const r of rows) {
287
+ const summary = buildRuleSummary(doc, r); // 失败降级到规则摘要(不报错给主人)
288
+ byKey.set(r.key, { summary, textHash: textHash(summary) });
289
+ }
290
+ continue;
291
+ }
292
+ for (const r of rows) {
293
+ const hit = res.rows.find((x) => x.index === r.index);
294
+ const summary = clampSummary(hit?.summary || buildRuleSummary(doc, r));
295
+ byKey.set(r.key, { summary, textHash: textHash(summary) });
296
+ }
297
+ }
298
+ return { byKey, cost: working, calls, tokens, stopped, failures, skippedForFamily };
299
+ }
300
+
301
+ /** 摘要长度闸(≤500 字;超长按字截断)。 */
302
+ export function clampSummary(s) {
303
+ const t = String(s ?? '').trim();
304
+ return t.length > SUMMARY_MAX_CHARS ? `${t.slice(0, SUMMARY_MAX_CHARS - 1)}…` : t;
305
+ }