@kira905/dsh-memory-toolkit 0.1.3 → 0.2.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.
Files changed (75) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/README.en.md +225 -2
  3. package/README.md +180 -2
  4. package/lib/deploy-guard.mjs +463 -275
  5. package/lib/guard-http.mjs +59 -62
  6. package/lib/pitfall-form-map.mjs +94 -84
  7. package/lib/pitfall-score.mjs +262 -185
  8. package/lib/session-file-name.mjs +116 -100
  9. package/lib/zstd-frames.mjs +88 -67
  10. package/memory-db-tools/README.md +29 -1
  11. package/memory-db-tools/audit-analyze.mjs +1 -1
  12. package/memory-db-tools/audit-export.mjs +60 -16
  13. package/memory-db-tools/audit-struct-probe.mjs +29 -8
  14. package/memory-db-tools/lib/known-devices.mjs +137 -0
  15. package/memory-engine/README.md +58 -4
  16. package/memory-engine/bench/bench.mjs +1 -1
  17. package/memory-engine/cli/band-sentinel.mjs +164 -0
  18. package/memory-engine/cli/distill-rollback.mjs +112 -44
  19. package/memory-engine/cli/distill-sessions.mjs +237 -0
  20. package/memory-engine/cli/distill.mjs +196 -95
  21. package/memory-engine/cli/doc-index-rebuild.mjs +142 -108
  22. package/memory-engine/cli/doc-index.mjs +47 -38
  23. package/memory-engine/cli/domain-tags.mjs +246 -162
  24. package/memory-engine/cli/layer-migrate.mjs +176 -0
  25. package/memory-engine/cli/layer-move.mjs +189 -0
  26. package/memory-engine/cli/pitfall-hook.mjs +136 -70
  27. package/memory-engine/cli/recall.mjs +243 -110
  28. package/memory-engine/cli/selfcheck.mjs +93 -44
  29. package/memory-engine/cli/serve-start.mjs +221 -140
  30. package/memory-engine/cli/sweep.mjs +83 -44
  31. package/memory-engine/cli/sync.mjs +93 -57
  32. package/memory-engine/config/audit-devices.json +6 -0
  33. package/memory-engine/config/bands.json +7 -0
  34. package/memory-engine/config/layers.json +7 -0
  35. package/memory-engine/dict/LICENSE-/350/257/215/345/205/270.md +53 -0
  36. package/memory-engine/dict/lexicon-config.json +11 -0
  37. package/memory-engine/dict//350/257/215/345/205/270.txt +109750 -0
  38. package/memory-engine/dict//350/257/215/351/242/221.txt +20000 -0
  39. package/memory-engine/lib/band-sentinel.mjs +417 -0
  40. package/memory-engine/lib/cli-strict.mjs +128 -0
  41. package/memory-engine/lib/cli-util.mjs +147 -125
  42. package/memory-engine/lib/config.mjs +83 -96
  43. package/memory-engine/lib/dirty-ledger.mjs +127 -94
  44. package/memory-engine/lib/distill-rollback.mjs +173 -133
  45. package/memory-engine/lib/distill.mjs +878 -585
  46. package/memory-engine/lib/doc-index-store.mjs +208 -235
  47. package/memory-engine/lib/doc-index.mjs +351 -350
  48. package/memory-engine/lib/doc-scan.mjs +178 -156
  49. package/memory-engine/lib/doc-summarize.mjs +220 -205
  50. package/memory-engine/lib/domain-tags.mjs +358 -277
  51. package/memory-engine/lib/embedder.mjs +175 -194
  52. package/memory-engine/lib/hashes.mjs +22 -7
  53. package/memory-engine/lib/layer-migrate.mjs +330 -0
  54. package/memory-engine/lib/layer-move.mjs +307 -0
  55. package/memory-engine/lib/layers.mjs +191 -0
  56. package/memory-engine/lib/lexicon-recall.mjs +473 -0
  57. package/memory-engine/lib/memory-source.mjs +78 -80
  58. package/memory-engine/lib/pitfall-source.mjs +115 -114
  59. package/memory-engine/lib/policy-source.mjs +279 -261
  60. package/memory-engine/lib/recall-log.mjs +285 -0
  61. package/memory-engine/lib/recall.mjs +565 -240
  62. package/memory-engine/lib/selfcheck.mjs +142 -106
  63. package/memory-engine/lib/session-distill.mjs +603 -0
  64. package/memory-engine/lib/session-source.mjs +421 -0
  65. package/memory-engine/lib/sweep.mjs +107 -130
  66. package/memory-engine/lib/sync.mjs +110 -140
  67. package/memory-engine/lib/tick-hook.mjs +123 -148
  68. package/memory-engine/lib/vectors-store.mjs +213 -188
  69. package/memory-engine/test/12-layering.test.mjs +539 -0
  70. package/memory-engine/test/13-session-distill.test.mjs +616 -0
  71. package/memory-engine/tools/bridge-smoke.mjs +94 -55
  72. package/memory-engine/tools/fetch-model.mjs +167 -87
  73. package/memory-engine/tools/smoke-recall.mjs +94 -38
  74. package/memory-engine/tools/verify-recall-chain.mjs +413 -0
  75. package/package.json +1 -1
@@ -1,108 +1,107 @@
1
- // 记忆引擎 v2 · 离线层 —— 文档扫描面 + 结构解析 + 粒度切分(方案 §3.2)
1
+ // 离线层:文档扫描面 + markdown 结构解析 + 索引条切分(方案 §3.2)
2
2
  //
3
- // 口径(逐条照方案,勿自行发挥):
4
- // · 扫描面 = `<docsRoot>/**/*.md`(默认 `<cwd>/docs`)
5
- // · **排除** = `变更流水/`、`管家报告/`、`坑库/`、`_archive`(流水类噪音大且频繁变更)
6
- // · `path` 列 = **相对 `dshRoot` 的路径**
7
- // · 粒度:短文档(<2 万字)1 条;长文档按 `##` 小节聚合 **2~5 条**;长文档多条以 `path#§N` 作主键
3
+ // 口径(照方案,别自己发挥):
4
+ // · 扫描面 = `<docsRoot>/**/*.md`,缺省 `<cwd>/docs`
5
+ // · 排除四类目录:技术变更流水 / 日报报告 / 坑目录 / `_archive`(流水类噪音大且天天变)
6
+ // · `path` 列一律是**相对 dshRoot** 的路径
7
+ // · 粒度:短文档(不到 2 万字)1 条;长文档按 `##` 小节聚成 2~5 条,多条时主键写 `path#§N`
8
8
  import fs from 'node:fs';
9
9
  import path from 'node:path';
10
10
 
11
- /** docs 根(可注入;不写死机器名,方案 §4 宿主迁移契约)。 */
11
+ // docs 根(可注入;不写死机器名 —— 换宿主只改调用方)
12
12
  export const DEFAULT_DOCS_ROOT = path.join(process.cwd(), 'docs');
13
- /** `path` 列的前缀锚(默认 = 当前工作目录)。 */
13
+ // `path` 列的前缀锚(缺省就是当前工作目录)
14
14
  export const DEFAULT_DSH_ROOT = process.cwd();
15
15
 
16
- /** 排除目录名(按路径"目录段"匹配,不按子串——否则 `_archive` 会误伤文件名)。 */
17
- export const EXCLUDE_DIRS = ['变更流水', '管家报告', '坑库', '_archive'];
16
+ // 排除目录按「路径段」匹配,不按子串 —— 否则文件名里带这四个字会被误伤
17
+ export const EXCLUDE_DIRS = '变更流水,管家报告,坑库,_archive'.split(',');
18
18
 
19
- /** 长/短文档分界(方案 §3.2:短文档 < 2 万字 1 条)。 */
19
+ // 长/短文档分界:短文档不到 2 万字 ⇒ 1 条
20
20
  export const LONG_DOC_CHARS = 20000;
21
- /** 长文档分条数区间(方案 §3.2:2~5 条)。 */
21
+ // 长文档分条区间 2~5
22
22
  export const MIN_LONG_ROWS = 2;
23
23
  export const MAX_LONG_ROWS = 5;
24
- /** 每条摘要上限(方案 §3.2:≤500 字)。 */
24
+ // 每条摘要上限 500 字
25
25
  export const SUMMARY_MAX_CHARS = 500;
26
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
- ];
27
+ // 首期要索引的 10 篇常用文档:判据是文档总索引里「最常用的 15 份」那张人工表的头 10 条
28
+ // (顺序即优先级,由主人与本鱼共同维护)。全量两百多篇不在这一批范围内。
29
+ const FIRST_BATCH_TEXT = `docs/主人使用手册.md
30
+ docs/DSH目录规范.md
31
+ docs/开发顺序统筹-20260914.md
32
+ docs/大管家-主人视角-一页纸-20260913.md
33
+ docs/大管家-开关矩阵与放行判据-20260913.md
34
+ docs/插件升级决策表.md
35
+ docs/插件魔改维护清单.md
36
+ docs/升级方案-2026-09-10.md
37
+ docs/自愈与回滚-验收缺口与整改方案-20260911.md
38
+ docs/gitee备份使用说明.md`;
45
39
 
46
- /** 首期清单的判据出处(写进 state 与交付报告,避免"这 10 篇哪来的"无处可查)。 */
40
+ export const FIRST_BATCH = FIRST_BATCH_TEXT.split('\n').map((line) => line.trim()).filter(Boolean);
41
+
42
+ // 首期清单的出处(写进 state 与报告,省得日后没人知道这 10 篇哪来的)
47
43
  export const FIRST_BATCH_SOURCE = 'docs/文档总索引.md §一「最常用的 15 份」(人工段)前 10 条';
48
44
 
49
- /** 统一 `/` 分隔(Windows 反斜杠一律归一;`path` 列口径)。 */
45
+ /** 反斜杠一律归一成 `/`(`path` 列口径) */
50
46
  export function toPosix(p) {
51
47
  return String(p).replace(/\\/g, '/');
52
48
  }
53
49
 
54
- /** 相对 `dshRoot` 的 path 口径(扫描面在 docs 下 ⇒ 结果以 `docs/` 开头)。 */
50
+ /** 相对 dshRoot 的 path 口径(扫描面在 docs 下 ⇒ 结果以 `docs/` 打头) */
55
51
  export function toRelPath(absPath, dshRoot = DEFAULT_DSH_ROOT) {
56
52
  return toPosix(path.relative(dshRoot, absPath));
57
53
  }
58
54
 
59
- /** 是否落在排除面内(只看**目录段**;顶层文件永不被排除)。 */
55
+ /** 是否落在排除面内:只看「目录段」,顶层文件永远不算排除 */
60
56
  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));
57
+ const segs = toPosix(relPath).split('/');
58
+ segs.pop(); // 去掉文件名那一段
59
+ return segs.some((seg) => EXCLUDE_DIRS.includes(seg));
64
60
  }
65
61
 
66
62
  /**
67
- * 递归扫描扫描面(只读;不做 mtime 过滤——过滤交给差分逻辑)。
63
+ * 递归扫描扫描面(只读;mtime 过滤交给差分逻辑,这里不过滤)。
68
64
  * @param {string} docsRoot
69
65
  * @param {{dshRoot?:string, maxFiles?:number}} [opts]
70
66
  * @returns {Array<{rel:string, abs:string, mtime:number, size:number}>}
71
67
  */
72
68
  export function scanDocs(docsRoot = DEFAULT_DOCS_ROOT, opts = {}) {
73
69
  const dshRoot = opts.dshRoot ?? DEFAULT_DSH_ROOT;
74
- const out = [];
75
- const walk = (dir) => {
70
+ const collected = [];
71
+ const todo = [docsRoot];
72
+
73
+ while (todo.length > 0) {
74
+ const dir = todo.pop();
76
75
  let entries;
77
76
  try {
78
77
  entries = fs.readdirSync(dir, { withFileTypes: true });
79
78
  } catch {
80
- return; // 目录不可读 → 跳过(fail-open,不打断别的扫描)
79
+ continue; // 目录读不动就跳过:fail-open,别拖垮整轮扫描
81
80
  }
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 });
81
+ for (const entry of entries) {
82
+ const abs = path.join(dir, entry.name);
83
+ if (entry.isDirectory()) {
84
+ if (!EXCLUDE_DIRS.includes(entry.name)) todo.push(abs); // 整棵剪掉
85
+ continue;
86
+ }
87
+ if (!entry.isFile() || !/\.md$/i.test(entry.name)) continue;
88
+ let stat;
89
+ try {
90
+ stat = fs.statSync(abs);
91
+ } catch {
92
+ continue;
97
93
  }
94
+ const rel = toRelPath(abs, dshRoot);
95
+ if (isExcludedRel(rel)) continue;
96
+ collected.push({ rel, abs, mtime: Math.trunc(stat.mtimeMs), size: stat.size });
98
97
  }
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;
98
+ }
99
+
100
+ collected.sort((left, right) => (left.rel < right.rel ? -1 : left.rel > right.rel ? 1 : 0));
101
+ return opts.maxFiles ? collected.slice(0, opts.maxFiles) : collected;
103
102
  }
104
103
 
105
- /** 读文档并算字符数(用于粒度判定与摘要生成)。 */
104
+ /** 读文档并算字符数(粒度判定与摘要生成都要它) */
106
105
  export function readDoc(absPath) {
107
106
  const text = fs.readFileSync(absPath, 'utf8');
108
107
  return { text, chars: text.length };
@@ -110,118 +109,138 @@ export function readDoc(absPath) {
110
109
 
111
110
  // ---------------------------------------------------------------- 结构解析
112
111
 
113
- /** 去掉 markdown 内联标记(摘要里不要出现 `**`/反引号/链接语法)。 */
112
+ // 清洗流水线:顺序即语义(先去代码块,再去内联标记,最后压空白)
113
+ const MD_CLEANUP = [
114
+ [/```[\s\S]*?```/g, ' '],
115
+ [/`([^`]*)`/g, '$1'],
116
+ [/!\[[^\]]*\]\([^)]*\)/g, ' '],
117
+ [/\[([^\]]*)\]\([^)]*\)/g, '$1'],
118
+ [/\*\*([^*]*)\*\*/g, '$1'],
119
+ [/(^|\s)[*_](\S[^*_]*)[*_]/g, '$1$2'],
120
+ [/^\s*>\s?/gm, ''],
121
+ [/\s+/g, ' '],
122
+ ];
123
+
124
+ /** 去掉 markdown 内联标记(摘要里不要留 `**`、反引号、链接语法) */
114
125
  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();
126
+ let text = String(s);
127
+ for (const [pattern, replacement] of MD_CLEANUP) text = text.replace(pattern, replacement);
128
+ return text.trim();
125
129
  }
126
130
 
127
- /** 取首句(中文句读 + 英文句点;上限 maxChars 字,超长按字截断)。
128
- * ⚠️ 刻意**不含分号**:中文文档里分号常切出"主人 = 用户"这种半截短语,反而没信息量。 */
131
+ // 首句候选:中文句读 / 英文句点后跟空白 / 实在没有就整段。
132
+ // ⚠ 刻意不认分号:中文文档里分号常切出「主人 = 用户」这种半截短语,反而丢信息。
133
+ const FIRST_SENTENCE_RE = /^(.{4,}?[。!?!?]|.{4,}?\.\s|.{4,})/;
134
+ const TRAILING_STOP_RE = /[。!?!?]\s*$/;
135
+
136
+ /** 取首句(上限 maxChars 字,超长按字截断并加省略号) */
129
137
  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;
138
+ const plain = stripMd(text);
139
+ if (!plain) return '';
140
+ const hit = FIRST_SENTENCE_RE.exec(plain);
141
+ let sentence = (hit ? hit[1] : plain).trim();
142
+ sentence = sentence.replace(TRAILING_STOP_RE, '').trim();
143
+ return sentence.length > maxChars ? `${sentence.slice(0, maxChars - 1)}…` : sentence;
137
144
  }
138
145
 
139
- /** 引言/章节的头几行里,这类"模板头 / 元信息"不算正文(否则每篇索引条都长一个样)。 */
140
- const META_LINE_RE = /^(?:\s*[>|])|^(?:维护人|创建|最近更新|最后更新|最后修订|更新时间|文件位置|路径口径|版本)\s*[::]/;
146
+ // 正文里这几类行不算正文:章节标题、分隔线、代码围栏、引用块/表格、以及模板头(维护人/更新日期…)
147
+ const FENCE_LINE_RE = /^```/;
148
+ const HEADING_LINE_RE = /^#{1,6}\s/;
149
+ const RULE_LINE_RE = /^[-*_]{3,}$/;
150
+ const QUOTE_LINE_RE = /^[>|]/;
151
+ const BULLET_LINE_RE = /^[-*+]\s/;
152
+ const META_PREFIX_RE = /^(?:维护人|创建|最近更新|最后更新|最后修订|更新时间|文件位置|路径口径|版本)\s*[::]/;
141
153
 
142
154
  /**
143
- * 取一节的正文首段(**跳过引用块 / 表格 / 标题 / 代码块**,去掉列表符号)。
144
- * 为什么需要它:文档头部普遍是 `> ↑本主题总纲见 …` 这类模板引用块,
145
- * 直接取首句会让所有索引条的"说明"完全雷同(实测),对召回毫无区分度。
155
+ * 取一节的正文首段:跳过引用块 / 表格 / 标题 / 代码块,去掉列表符号。
156
+ * 为什么非跳不可:文档头部普遍是 `> ↑本主题总纲见 …` 这类模板引用块,
157
+ * 直接取首句会让每篇索引条的说明完全雷同(实测过),对召回毫无区分度。
146
158
  */
147
159
  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; // 段落结束
160
+ const picked = [];
161
+ for (const rawLine of String(body ?? '').split(/\r?\n/)) {
162
+ const line = rawLine.trim();
163
+ if (!line) {
164
+ if (picked.length > 0) break; // 空行 = 段落收尾
153
165
  continue;
154
166
  }
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+/, ''));
167
+ if (FENCE_LINE_RE.test(line)) break;
168
+ if (HEADING_LINE_RE.test(line)) continue;
169
+ if (RULE_LINE_RE.test(line)) continue;
170
+ if (picked.length === 0 && META_PREFIX_RE.test(line)) continue; // 模板头/元信息:往后找真正文
171
+ if (QUOTE_LINE_RE.test(line)) continue;
172
+ if (BULLET_LINE_RE.test(line)) {
173
+ if (picked.length > 0) break;
174
+ picked.push(line.replace(/^[-*+]\s+/, ''));
163
175
  } else {
164
- buf.push(l);
176
+ picked.push(line);
165
177
  }
166
- if (buf.join(' ').length > maxChars) break;
178
+ if (picked.join(' ').length > maxChars) break;
167
179
  }
168
- return buf.join(' ').trim();
180
+ return picked.join(' ').trim();
169
181
  }
170
182
 
171
- /** 章节标题瘦身(摘要里每节只留辨识度最高的前 28 字)。 */
183
+ /** 章节标题瘦身(摘要里每节只留辨识度最高的前 maxChars 字) */
172
184
  export function shortTitle(t, maxChars = 28) {
173
- const s = stripMd(t);
174
- return s.length > maxChars ? `${s.slice(0, maxChars - 1)}…` : s;
185
+ const plain = stripMd(t);
186
+ return plain.length > maxChars ? `${plain.slice(0, maxChars - 1)}…` : plain;
175
187
  }
176
188
 
189
+ const H1_RE = /^#\s+(.+?)\s*$/;
190
+ const H2_RE = /^##\s+(.+?)\s*$/;
191
+
177
192
  /**
178
- * 解析 markdown 结构:`#` 标题、引言段、`##` 小节(含其下三级及正文)。
179
- * 只认 `##`(方案 §3.2「按 `##` 小节聚合」);`###` 归入所属 `##`。
193
+ * 解析 markdown 结构:H1 标题、引言段、`##` 小节(其下的 `###` 归入所属小节)。
194
+ * 只认 `##`(方案:按 `##` 小节聚合)。
180
195
  * @returns {{title:string, intro:string, sections:Array<{title:string,chars:number,body:string}>}}
181
196
  */
182
197
  export function parseStructure(text) {
183
198
  const lines = String(text).split(/\r?\n/);
184
- let title = '';
185
- let introStart = 0;
186
- let i = 0;
199
+
187
200
  // 首个 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
- }
201
+ let title = '';
202
+ let introFrom = 0;
203
+ for (let i = 0; i < lines.length; i += 1) {
204
+ const hit = H1_RE.exec(lines[i]);
205
+ if (!hit) continue;
206
+ title = stripMd(hit[1]);
207
+ introFrom = i + 1;
208
+ break;
195
209
  }
196
210
  if (!title) {
197
- // 无 H1:用第一行非空文本兜底(摘要头不至于空)
198
- const firstLine = lines.find((l) => l.trim());
199
- title = firstSentence(firstLine || '', 60);
200
- introStart = 0;
211
+ // 没有 H1:拿第一行非空文本兜底,摘要头不至于开天窗
212
+ const headLine = lines.find((line) => line.trim());
213
+ title = firstSentence(headLine || '', 60);
214
+ introFrom = 0;
201
215
  }
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: [] };
216
+
217
+ const raw = [];
218
+ const introLines = [];
219
+ let openSection = null;
220
+ for (let i = introFrom; i < lines.length; i += 1) {
221
+ const line = lines[i];
222
+ const head = H2_RE.exec(line);
223
+ if (head) {
224
+ if (openSection) raw.push(openSection);
225
+ openSection = { title: stripMd(head[1]), body: [] };
211
226
  continue;
212
227
  }
213
- if (cur) cur.body.push(line);
228
+ if (openSection) openSection.body.push(line);
214
229
  else introLines.push(line);
215
230
  }
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 };
231
+ if (openSection) raw.push(openSection);
232
+
233
+ return {
234
+ title,
235
+ intro: introLines.join('\n').trim(),
236
+ sections: raw.map((section) => {
237
+ const bodyText = section.body.join('\n').trim();
238
+ return { title: section.title, chars: section.title.length + bodyText.length, body: bodyText };
239
+ }),
240
+ };
222
241
  }
223
242
 
224
- /** 目标分条数:2 万字内 1 条;超出按字符规模升到 2~5 条,且不超过 `##` 小节数。 */
243
+ /** 目标分条数:2 万字以内 1 条;超出按规模升到 2~5 条,且不超过 `##` 小节数 */
225
244
  export function planRowCount(chars, sectionCount) {
226
245
  if (chars < LONG_DOC_CHARS) return 1;
227
246
  const bySize = Math.round(chars / LONG_DOC_CHARS) + 1;
@@ -230,21 +249,24 @@ export function planRowCount(chars, sectionCount) {
230
249
  return Math.min(target, sectionCount);
231
250
  }
232
251
 
233
- /** 按 section **个数**均分(确定性、可复现;组数恰为 k)。 */
252
+ /** 按 section 个数均分(确定性、可复现;组数恰为 k,多出来的并进最后一组) */
234
253
  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;
254
+ const total = sections.length;
255
+ if (k <= 1 || total <= 1) return [sections];
256
+
257
+ const base = Math.floor(total / k);
258
+ const extra = total % k;
239
259
  const groups = [];
240
- let idx = 0;
241
- for (let g = 0; g < k; g += 1) {
242
- const take = base + (g < rem ? 1 : 0);
260
+ let cursor = 0;
261
+ let group = 0;
262
+ while (group < k) {
263
+ const take = base + (group < extra ? 1 : 0);
264
+ group += 1;
243
265
  if (take === 0) continue;
244
- groups.push(sections.slice(idx, idx + take));
245
- idx += take;
266
+ groups.push(sections.slice(cursor, cursor + take));
267
+ cursor += take;
246
268
  }
247
- if (idx < n) groups[groups.length - 1] = groups[groups.length - 1].concat(sections.slice(idx));
269
+ if (cursor < total) groups[groups.length - 1] = groups[groups.length - 1].concat(sections.slice(cursor));
248
270
  return groups;
249
271
  }
250
272
 
@@ -255,17 +277,17 @@ export function splitSections(sections, k) {
255
277
  */
256
278
  export function planRows(doc) {
257
279
  const { rel, chars, structure } = doc;
258
- const k = planRowCount(chars, structure.sections.length);
259
- if (k <= 1) {
280
+ const wanted = planRowCount(chars, structure.sections.length);
281
+ if (wanted <= 1) {
260
282
  return [{ key: rel, index: 0, total: 1, sections: structure.sections, intro: structure.intro }];
261
283
  }
262
- const groups = splitSections(structure.sections, k);
263
- return groups.map((sections, i) => ({
284
+ const groups = splitSections(structure.sections, wanted);
285
+ return groups.map((group, i) => ({
264
286
  key: `${rel}#§${i + 1}`,
265
287
  index: i,
266
288
  total: groups.length,
267
- sections,
268
- // 文档引言只在第 1 段带(避免 2~5 条里重复同一段话)
289
+ sections: group,
290
+ // 文档引言只挂在第 1 段(免得 2~5 条里重复同一段话)
269
291
  intro: i === 0 ? structure.intro : '',
270
292
  }));
271
293
  }