sage-mem 0.9.13 → 0.9.15
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 +4 -3
- package/lib/client.js +155 -92
- package/lib/index.js +72 -6
- package/lib/memory/bm25.js +208 -0
- package/lib/memory/chunks.js +217 -0
- package/lib/memory/search.js +401 -0
- package/package.json +1 -1
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sage-mem 记忆层 —— 词元化与 BM25 打分(纯函数,无 I/O)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么不是「双字覆盖率」:`retrieval.js` 那套是**覆盖率**——分母是 query,不惩罚文档长度。
|
|
5
|
+
* 它在摘要(一句话)上工作良好,正是因为它没机会被长文本稀释;一旦拿它扫正文,
|
|
6
|
+
* 长文件什么都能蒙中。BM25 换掉了那个分母:词频饱和(同一词出现 20 次不等于 20 倍相关)
|
|
7
|
+
* 加上长度归一(长文件不加权),再配 IDF 让「到处都有的词」自动失效。
|
|
8
|
+
*
|
|
9
|
+
* 中文没有分词器可用(本插件零外部依赖),所以沿用双字词元。这是有代价的:
|
|
10
|
+
* 「部署超时」会切出「部署」「署超」「超时」,「署超」是跨词边界的假词元。
|
|
11
|
+
* 它极少在文档里命中,所以贡献接近 0 —— 这也是为什么下面额外给了一个**覆盖率小项**:
|
|
12
|
+
* 命中越多的 query 词元越可能是真的要找的那份,用来打破近分平局。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** BM25 词频饱和系数。1.2 是文献里的常用起点。 */
|
|
16
|
+
export const BM25_K1 = 1.2
|
|
17
|
+
|
|
18
|
+
/** BM25 长度归一系数。0.75 同上。两个值都是起始参数,不是实测结论。 */
|
|
19
|
+
export const BM25_B = 0.75
|
|
20
|
+
|
|
21
|
+
/** 覆盖率在最终得分里的权重。刻意很小:它只该打破近分平局,不该压过 IDF。 */
|
|
22
|
+
export const COVERAGE_WEIGHT = 0.2
|
|
23
|
+
|
|
24
|
+
/** 汉字 / 假名 / 谚文的连续段。 */
|
|
25
|
+
const CJK_RUN = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]+/u
|
|
26
|
+
|
|
27
|
+
/** 一段连续的「标识符式」字符:`lib/index.js`、`SAGE_MEM_DEBUG`、`0.9.14`、`FS_STALE_VERSION`。 */
|
|
28
|
+
const ASCII_RUN = /[A-Za-z0-9][A-Za-z0-9_.\-/:\\]*/g
|
|
29
|
+
|
|
30
|
+
/** 标识符内部的分隔符 —— 切开后作为补充词元(`lib/index.js` 也要能被 `index` 搜到)。 */
|
|
31
|
+
const ASCII_SEP = /[_.\-/:\\]+/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 把文本切成词元数组(**保留重复**,BM25 要的是真实词频)。
|
|
35
|
+
*
|
|
36
|
+
* 两类文本走两条路:
|
|
37
|
+
* - CJK 连续段 → 双字词元;单字段落退化成那一个字。**按段切,不跨标点**,
|
|
38
|
+
* 否则「。我」这种跨句假词元也会进索引。
|
|
39
|
+
* - ASCII 串 → 整个原形 + 按分隔符切出的长度 ≥ 2 的片段。
|
|
40
|
+
* 原形保留是为了精确匹配错误码 / 路径 / 版本号;片段是为了「记得大概是哪个文件」也能搜到。
|
|
41
|
+
*/
|
|
42
|
+
export function tokenize(text) {
|
|
43
|
+
const s = String(text ?? '').toLowerCase()
|
|
44
|
+
if (!s) return []
|
|
45
|
+
const out = []
|
|
46
|
+
// CJK 段优先吃:ASCII 正则会绕过汉字,两者互不重叠。
|
|
47
|
+
const runs = s.match(/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]+|[A-Za-z0-9][A-Za-z0-9_.\-/:\\]*/gu) ?? []
|
|
48
|
+
for (const run of runs) {
|
|
49
|
+
if (CJK_RUN.test(run)) {
|
|
50
|
+
if (run.length === 1) out.push(run)
|
|
51
|
+
else for (let i = 0; i < run.length - 1; i++) out.push(run.slice(i, i + 2))
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
out.push(run)
|
|
55
|
+
for (const part of run.split(ASCII_SEP)) {
|
|
56
|
+
// 长度 1 的片段是噪音(`1.0.0` 会切出三个 `0`),它们 df 极高、IDF 近 0,纯属白占内存。
|
|
57
|
+
if (part.length >= 2 && part !== run) out.push(part)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return out
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 词元数组 → 词频表。 */
|
|
64
|
+
export function termFreq(tokens) {
|
|
65
|
+
const tf = new Map()
|
|
66
|
+
for (const t of tokens) tf.set(t, (tf.get(t) ?? 0) + 1)
|
|
67
|
+
return tf
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 语料统计:文档数 N、平均长度 avgdl、逐词元的文档频率 df。
|
|
72
|
+
*
|
|
73
|
+
* @param {Array<{tokens:string[]}>} docs
|
|
74
|
+
*/
|
|
75
|
+
export function buildStats(docs) {
|
|
76
|
+
const df = new Map()
|
|
77
|
+
let total = 0
|
|
78
|
+
for (const d of docs) {
|
|
79
|
+
total += d.tokens.length
|
|
80
|
+
// 同一文档里出现多次只算一次 —— df 的定义就是「多少篇文档含这个词元」。
|
|
81
|
+
for (const t of new Set(d.tokens)) df.set(t, (df.get(t) ?? 0) + 1)
|
|
82
|
+
}
|
|
83
|
+
const N = docs.length
|
|
84
|
+
return { N, avgdl: N > 0 ? total / N : 1, df }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 单篇文档的 BM25 得分。queryTerms 应先去重。 */
|
|
88
|
+
export function scoreDoc(queryTerms, tf, dl, stats) {
|
|
89
|
+
if (!stats || stats.N === 0) return 0
|
|
90
|
+
let score = 0
|
|
91
|
+
for (const t of queryTerms) {
|
|
92
|
+
const f = tf.get(t)
|
|
93
|
+
if (!f) continue
|
|
94
|
+
const idf = idfOf(stats.df.get(t) ?? 0, stats.N)
|
|
95
|
+
score += (idf * (f * (BM25_K1 + 1))) / (f + BM25_K1 * (1 - BM25_B + (BM25_B * dl) / stats.avgdl))
|
|
96
|
+
}
|
|
97
|
+
return score
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 词元的逆文档频率。加 1 的对数形式:df 等于 N 时(到处都是的词元)趋近 0 而不为负。
|
|
102
|
+
*
|
|
103
|
+
* 单独抽出来是因为「证据强度」判据(`evidence`)要用同一个口径 —— 两处各写一遍
|
|
104
|
+
* 迟早会漂。
|
|
105
|
+
*/
|
|
106
|
+
export function idfOf(df, N) {
|
|
107
|
+
if (!N) return 0
|
|
108
|
+
return Math.log(1 + (N - df + 0.5) / (df + 0.5))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 证据强度:这份文档到底**凭什么**被认为相关。返回两个数,缺一不可。
|
|
113
|
+
*
|
|
114
|
+
* 为什么需要它:BM25 对**任何**问题都能给出非零分——只要有一个常用双字重合。
|
|
115
|
+
* 「今天天气怎么样」在 900 KB 的中文语料上必然能找到含「怎么」的段落。所以
|
|
116
|
+
* 「分数 > 0」不是可用的相关性判据,必须有下面这条独立门槛。
|
|
117
|
+
*
|
|
118
|
+
* `ratio` = 命中的词元权重 ÷ **在语料里存在过的** query 词元总权重。
|
|
119
|
+
* ⚠️ 分母**必须剔除 df=0 的词元**:语料里根本没有的词(错别字、没记过的术语)
|
|
120
|
+
* 带着最大 IDF 进分母,会把真正相关的题也压到 0.07 这种地板分上。
|
|
121
|
+
* `sum` = 命中词元的权重绝对和。比值高但绝对量小,等于「匹配上的全是到处都有的词」,
|
|
122
|
+
* 仍然不算证据 —— 两个条件同时满足才算过门槛。
|
|
123
|
+
*/
|
|
124
|
+
export function evidence(queryTerms, tf, stats) {
|
|
125
|
+
let have = 0
|
|
126
|
+
let total = 0
|
|
127
|
+
let rarestRatio = 1
|
|
128
|
+
let rarestDf = Number.POSITIVE_INFINITY
|
|
129
|
+
let matched = 0
|
|
130
|
+
for (const t of queryTerms) {
|
|
131
|
+
const df = stats?.df?.get(t) ?? 0
|
|
132
|
+
if (df === 0) continue // 语料里不存在 → 不进分母,也不进分子
|
|
133
|
+
const w = idfOf(df, stats.N)
|
|
134
|
+
total += w
|
|
135
|
+
if (tf.has(t)) {
|
|
136
|
+
have += w
|
|
137
|
+
matched++
|
|
138
|
+
rarestRatio = Math.min(rarestRatio, df / stats.N)
|
|
139
|
+
rarestDf = Math.min(rarestDf, df)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
ratio: total > 0 ? have / total : 0,
|
|
144
|
+
sum: have,
|
|
145
|
+
/**
|
|
146
|
+
* 命中词元里**最冷门**那个的两个口径:文档频率占比,与绝对文档频率。
|
|
147
|
+
*
|
|
148
|
+
* 为什么两个都要:`df/N` 在小语料上会失真 —— 一个只有 5 份记忆的目录里,
|
|
149
|
+
* 「全库独一份」的词元算出来也是 0.2,于是真命中会被误判成「不够冷门」而整条被滤掉,
|
|
150
|
+
* 小目录的记忆检索直接失效。绝对 df 则在大语料上失真(同一个数字在 100 份与
|
|
151
|
+
* 2000 份语料里含义完全不同)。所以判据是**析取**:
|
|
152
|
+
* `df ≤ 1`(全库独一份,一定是有内容的)**或** `df/N ≤ 门槛`。
|
|
153
|
+
* 两个口径都由调用方拿到,怎么组合是策略层的事。
|
|
154
|
+
*
|
|
155
|
+
* 为什么单有 ratio/sum 不够:`红烧肉怎么做才好吃` 在中文语料上会命中 `怎么`/`么做`
|
|
156
|
+
* 这类到处都有的词元 —— 它们数量够多,`sum` 能堆到 4.8,而 `ratio` 会因为
|
|
157
|
+
* 「红烧/烧肉/好吃」在语料里压根不存在(df=0,被剔出分母)而等于 1.0。
|
|
158
|
+
* 两个数字都像是强证据,实际命中的**全是虚词**。内容词(连接池、温漂、
|
|
159
|
+
* FS_STALE_VERSION)天然冷门,虚词天然热门,区分度就在这里。
|
|
160
|
+
*/
|
|
161
|
+
rarestDfRatio: matched > 0 ? rarestRatio : 1,
|
|
162
|
+
rarestDf: matched > 0 ? rarestDf : Number.POSITIVE_INFINITY,
|
|
163
|
+
matched,
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** query 的独特词元里有多少比例出现在这篇文档里(0~1)。只用来打破近分平局。 */
|
|
168
|
+
export function coverageOf(queryTerms, tf) {
|
|
169
|
+
if (!queryTerms.length) return 0
|
|
170
|
+
let hit = 0
|
|
171
|
+
for (const t of queryTerms) if (tf.has(t)) hit++
|
|
172
|
+
return hit / queryTerms.length
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** query → 去重后的词元数组。 */
|
|
176
|
+
export function queryTermsOf(query) {
|
|
177
|
+
return [...new Set(tokenize(query))]
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* 最长「连续命中」的**词元个数**(0 = 一个都没命中)。
|
|
182
|
+
*
|
|
183
|
+
* 为什么必须单独看连续性:中文双字词元会把跨词边界的假词元也造出来。
|
|
184
|
+
* 「明天会不会下雨」里就有两个——
|
|
185
|
+
* - `不会`:真实虚词,df 高;
|
|
186
|
+
* - `天会`:来自「那一天**会**缩短」的跨词边界巧合,df=1,于是**IDF 最大**。
|
|
187
|
+
* 单独看「有没有命中一个冷门词元」,`天会` 这种巧合恰好满足,于是一句完全无关的
|
|
188
|
+
* 天气问题能把潮汐笔记拉进结果。
|
|
189
|
+
*
|
|
190
|
+
* 连续性治的就是它:连续命中 k 个词元 = query 里**至少 k+1 个字连着**出现在文档里。
|
|
191
|
+
* 巧合通常孤立(k=1),真实内容词通常连成片段(`连接池` k=2、`压测时连接池` k=4)。
|
|
192
|
+
*
|
|
193
|
+
* ⚠️ 它也有代价:一个两字专名(`灰云`)单独命中时 k 只有 1,会被判成不连续。
|
|
194
|
+
* 所以门槛里它是**析取**的一部分,不是唯一判据。
|
|
195
|
+
*/
|
|
196
|
+
export function maxMatchedRun(queryTokens, tf) {
|
|
197
|
+
let best = 0
|
|
198
|
+
let cur = 0
|
|
199
|
+
for (const t of queryTokens) {
|
|
200
|
+
if (tf.has(t)) {
|
|
201
|
+
cur++
|
|
202
|
+
if (cur > best) best = cur
|
|
203
|
+
} else {
|
|
204
|
+
cur = 0
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return best
|
|
208
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sage-mem 记忆层 —— 正文分段:把一份记忆切成可检索、可定位的段落。
|
|
3
|
+
*
|
|
4
|
+
* 为什么要有这一层:检索原先只比 `description` + `aliases` + 文件名(见 retrieval.js),
|
|
5
|
+
* 换来的是「噪音大幅下降」,代价是**摘要没写到的内容永远检索不到**。要补这个漏,
|
|
6
|
+
* 就得让正文参与匹配 —— 但**不能让整份文件参与匹配**:那会重演旧版的病
|
|
7
|
+
* (长文件什么都能蒙中)。所以改变评分单位:**用段落匹配问题,再把最佳段落归并回文件**,
|
|
8
|
+
* 而不是把一份文件的所有命中次数加起来。
|
|
9
|
+
*
|
|
10
|
+
* 三个必须守住的约束:
|
|
11
|
+
* 1. **行号是 1-based、相对整份文件**(含 frontmatter 那几行)。`read_memory` 的
|
|
12
|
+
* `startLine` 直接吃这个值,中间不许再换算一次 —— 换算是这类功能最常见的错位来源。
|
|
13
|
+
* 2. **revision 是内容 hash,不是路径或标题**。路径会改名、标题会改写,两者都不是身份。
|
|
14
|
+
* 3. **frontmatter 不进段落**。它的键名(`name` / `description` / `node_type`…)在每份
|
|
15
|
+
* 文件里都一样,进了正文索引就等于给每条记忆灌入同一批高频词,正好抬高通用词的 df。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createHash } from 'node:crypto'
|
|
19
|
+
|
|
20
|
+
/** 目标段落长度(字符)。低于它就与邻居合并,避免碎片段把候选表塞满。 */
|
|
21
|
+
export const CHUNK_TARGET_CHARS = 600
|
|
22
|
+
|
|
23
|
+
/** 硬上限:超过就切。定在目标的 1.5 倍,留出「一行稍长就整段爆掉」的余量。 */
|
|
24
|
+
export const CHUNK_MAX_CHARS = 900
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 独立成段的门槛(字符)。
|
|
28
|
+
*
|
|
29
|
+
* 低于它的段落优先并入相邻段:一条 `- 记完了` 这样的项目符号独立成段,既占一个候选位、
|
|
30
|
+
* 又几乎没有任何可匹配的词。合并是**就近向上**(并进已经攒好的上一段),因为 markdown
|
|
31
|
+
* 里短行通常是上一段同一主题的补充。
|
|
32
|
+
*/
|
|
33
|
+
export const CHUNK_MIN_CHARS = 60
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 内容 revision:`sha256:<前 12 位>`。
|
|
37
|
+
*
|
|
38
|
+
* 12 位十六进制 = 48 bit,对本场景(一份记忆目录内区分版本)绰绰有余,而短到能塞进
|
|
39
|
+
* 注入的状态卡与工具响应里不心疼。**它只表示「内容变了」,不表示「更新了」** ——
|
|
40
|
+
* 加一行注释也会换 revision,这不是错误信息,是「你之前读到的正文已经不是现在这份」。
|
|
41
|
+
*/
|
|
42
|
+
export function revisionOf(content) {
|
|
43
|
+
const hex = createHash('sha256').update(String(content ?? ''), 'utf8').digest('hex')
|
|
44
|
+
return `sha256:${hex.slice(0, 12)}`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* frontmatter 块的结束行(1-based);没有 frontmatter 返回 0。
|
|
49
|
+
*
|
|
50
|
+
* 判定刻意与 `frontmatter.js` 的正则同形(首行必须是整行 `---`,结束取**第一个**
|
|
51
|
+
* 行首 `---`),否则会出现「frontmatter 解析出来有 description,分段却把它当正文」
|
|
52
|
+
* 这种两边不一致的怪状态。
|
|
53
|
+
*/
|
|
54
|
+
export function frontmatterEndLine(lines) {
|
|
55
|
+
if (String(lines?.[0] ?? '').trim() !== '---') return 0
|
|
56
|
+
for (let i = 1; i < lines.length; i++) {
|
|
57
|
+
if (String(lines[i]).trim() === '---') return i + 1
|
|
58
|
+
}
|
|
59
|
+
return 0
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 把一份记忆切成段落。
|
|
64
|
+
*
|
|
65
|
+
* @param {{file:string, content:string}} f — `scanMemoryFiles` 的条目
|
|
66
|
+
* @param {string} [knownRevision] — 调用方已经算过 revision 时传进来,省一次对整份正文的 sha256
|
|
67
|
+
* @returns {Array<{id:string,file:string,revision:string,headingPath:string,startLine:number,endLine:number,text:string}>}
|
|
68
|
+
*/
|
|
69
|
+
export function chunkFile(f, knownRevision) {
|
|
70
|
+
const file = String(f?.file ?? '')
|
|
71
|
+
const content = String(f?.content ?? '')
|
|
72
|
+
const revision = typeof knownRevision === 'string' && knownRevision ? knownRevision : revisionOf(content)
|
|
73
|
+
const lines = content.split(/\r?\n/)
|
|
74
|
+
const from = frontmatterEndLine(lines)
|
|
75
|
+
const out = []
|
|
76
|
+
// id 去重表:**必须去重**,因为「单行超长」那条路径会把一行按字符切成多片,
|
|
77
|
+
// 而它们共享同一个行号 → 光用行号当 id 会撞车(实测一份含 5000 字符单行的文件
|
|
78
|
+
// 产出 10 个片段、去重后只剩 2 个)。撞车会让「按 id 取回某一段」取到错的那片。
|
|
79
|
+
// 常见情形(一行以内)保持 `file#起-止` 的可读形式,只有撞车时才追加 `~n`。
|
|
80
|
+
const idSeen = new Map()
|
|
81
|
+
for (const block of mergeBlocks(splitBlocks(lines, from))) {
|
|
82
|
+
for (const piece of splitOne(block)) {
|
|
83
|
+
const base = `${file}#${piece.startLine}-${piece.endLine}`
|
|
84
|
+
const n = idSeen.get(base) ?? 0
|
|
85
|
+
idSeen.set(base, n + 1)
|
|
86
|
+
out.push({
|
|
87
|
+
id: n === 0 ? base : `${base}~${n}`,
|
|
88
|
+
file,
|
|
89
|
+
revision,
|
|
90
|
+
headingPath: piece.headingPath,
|
|
91
|
+
startLine: piece.startLine,
|
|
92
|
+
endLine: piece.endLine,
|
|
93
|
+
text: piece.text,
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// 空段落(整块只有空白)不产出:它们没有可匹配的词,只会稀释 df。
|
|
98
|
+
return out.filter(c => c.text.length > 0)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 第一批:按标题与空行切出「原生块」。
|
|
103
|
+
*
|
|
104
|
+
* **每行带着自己的行号**(`{no, text}`),不是裸字符串 —— 合并与再切分之后行号仍要准确,
|
|
105
|
+
* 而「用内容反查行号」在有重复行的文件里必然出错(`---`、空行、重复的项目符号)。
|
|
106
|
+
*
|
|
107
|
+
* 围栏代码块里的空行不算分段点:一段代码被空行切成两半,两边都不再是完整的可读内容,
|
|
108
|
+
* 第二半的标题路径也指不对。
|
|
109
|
+
*/
|
|
110
|
+
function splitBlocks(lines, from) {
|
|
111
|
+
const blocks = []
|
|
112
|
+
const stack = []
|
|
113
|
+
let cur = null
|
|
114
|
+
let pendingHead = []
|
|
115
|
+
let inFence = false
|
|
116
|
+
const flush = () => {
|
|
117
|
+
if (cur && cur.raw.length) blocks.push(cur)
|
|
118
|
+
cur = null
|
|
119
|
+
}
|
|
120
|
+
for (let i = from; i < lines.length; i++) {
|
|
121
|
+
const line = lines[i]
|
|
122
|
+
if (/^\s*(```|~~~)/.test(line)) inFence = !inFence
|
|
123
|
+
if (!inFence) {
|
|
124
|
+
const h = line.match(/^(#{1,6})[ \t]+(.+?)[ \t]*$/)
|
|
125
|
+
if (h) {
|
|
126
|
+
flush()
|
|
127
|
+
const level = h[1].length
|
|
128
|
+
while (stack.length && stack[stack.length - 1].level >= level) stack.pop()
|
|
129
|
+
stack.push({ level, text: h[2] })
|
|
130
|
+
// **标题行本身要留在下一段的正文里**(不是只记进 headingPath)。
|
|
131
|
+
// 只记 headingPath 会丢字:一段横跨 `## 解决办法` 与 `## 无关小节` 的合并段落,
|
|
132
|
+
// 只有起始标题进索引,「解决办法」这四个字在检索面上等于不存在。
|
|
133
|
+
// 连续多个标题攒在一起,等下一条正文行来了再一起种进新块(避免产出只有标题的空段)。
|
|
134
|
+
pendingHead.push({ no: i + 1, text: line })
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
137
|
+
if (line.trim() === '') {
|
|
138
|
+
flush()
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (!cur) {
|
|
143
|
+
cur = {
|
|
144
|
+
startLine: pendingHead[0]?.no ?? i + 1,
|
|
145
|
+
endLine: i + 1,
|
|
146
|
+
raw: pendingHead.slice(),
|
|
147
|
+
headingPath: stack.map(s => s.text).join(' › '),
|
|
148
|
+
}
|
|
149
|
+
pendingHead = []
|
|
150
|
+
}
|
|
151
|
+
cur.endLine = i + 1
|
|
152
|
+
cur.raw.push({ no: i + 1, text: line })
|
|
153
|
+
}
|
|
154
|
+
flush()
|
|
155
|
+
return blocks
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** 第二批:过短的块往上一块合并,直到接近目标长度。 */
|
|
159
|
+
function mergeBlocks(rawBlocks) {
|
|
160
|
+
const out = []
|
|
161
|
+
for (const b of rawBlocks) {
|
|
162
|
+
const text = b.raw.map(l => l.text).join('\n').trim()
|
|
163
|
+
if (!text) continue
|
|
164
|
+
const prev = out[out.length - 1]
|
|
165
|
+
// 用 `\n\n` 而不是 `\n` 拼接:两块之间**原本就隔着空行**,用单换行拼起来会让
|
|
166
|
+
// 注入文本里两段粘成一整段。这条只在「合并」路径上出现;被切分的单块内部是连续行,
|
|
167
|
+
// 那边的 `\n` 才是对的 —— 两条路径互斥(合并只到 600,切分只对 > 900 的原始块)。
|
|
168
|
+
if (prev && text.length < CHUNK_MIN_CHARS && prev.text.length + text.length + 2 <= CHUNK_TARGET_CHARS) {
|
|
169
|
+
prev.text = `${prev.text}\n\n${text}`
|
|
170
|
+
prev.endLine = b.endLine
|
|
171
|
+
prev.raw = prev.raw.concat(b.raw)
|
|
172
|
+
continue
|
|
173
|
+
}
|
|
174
|
+
out.push({ startLine: b.startLine, endLine: b.endLine, headingPath: b.headingPath, raw: b.raw, text })
|
|
175
|
+
}
|
|
176
|
+
return out
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* 第三批:超过硬上限的块按行切成多个片段。
|
|
181
|
+
*
|
|
182
|
+
* **单行超长的情形单独处理**:一份把日志或代码贴成一行的记忆,按行切分永远切不动
|
|
183
|
+
* (一个片段 = 那一整行),于是「长度归一」失效、候选位被一行占满。这里按字符硬切,
|
|
184
|
+
* 该行的行号由所有片段共享 —— 段落级的行号本来就不承诺精确定位到字节。
|
|
185
|
+
*/
|
|
186
|
+
function splitOne(block) {
|
|
187
|
+
if (block.text.length <= CHUNK_MAX_CHARS) return [block]
|
|
188
|
+
const out = []
|
|
189
|
+
let buf = []
|
|
190
|
+
let start = 0
|
|
191
|
+
let end = 0
|
|
192
|
+
const emit = () => {
|
|
193
|
+
if (!buf.length) return
|
|
194
|
+
const text = buf.join('\n').trim()
|
|
195
|
+
if (text) out.push({ startLine: start, endLine: end, headingPath: block.headingPath, text })
|
|
196
|
+
buf = []
|
|
197
|
+
}
|
|
198
|
+
for (const { no, text: line } of block.raw) {
|
|
199
|
+
if (line.length > CHUNK_MAX_CHARS) {
|
|
200
|
+
emit()
|
|
201
|
+
for (let i = 0; i < line.length; i += CHUNK_TARGET_CHARS) {
|
|
202
|
+
out.push({ startLine: no, endLine: no, headingPath: block.headingPath, text: line.slice(i, i + CHUNK_TARGET_CHARS) })
|
|
203
|
+
}
|
|
204
|
+
continue
|
|
205
|
+
}
|
|
206
|
+
const pending = buf.length ? buf.join('\n').length + 1 : 0
|
|
207
|
+
if (pending + line.length > CHUNK_TARGET_CHARS) emit()
|
|
208
|
+
if (!buf.length) {
|
|
209
|
+
start = no
|
|
210
|
+
end = no
|
|
211
|
+
}
|
|
212
|
+
end = no
|
|
213
|
+
buf.push(line)
|
|
214
|
+
}
|
|
215
|
+
emit()
|
|
216
|
+
return out
|
|
217
|
+
}
|