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,401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sage-mem 记忆层 —— 正文检索:把「段落打分」收口成一个可复用、可评测的检索面。
|
|
3
|
+
*
|
|
4
|
+
* 两层分工(这是本模块最要紧的一条设计):
|
|
5
|
+
* - `rankChunks()` 返回**富对象**(段落 + 逐项分数 + 命中原因),给注入链和评测脚本用;
|
|
6
|
+
* - `retrieve()` 返回**线上形状**(wire shape),给 0.9.16 的 search_memory 工具用。
|
|
7
|
+
*
|
|
8
|
+
* 两者共用同一套排序,只是出口形状不同。**不要反过来让注入链去吃 wire shape** ——
|
|
9
|
+
* 那会把「响应契约」和「内部评分」焊在一起,以后改一个字段名就动到注入行为。
|
|
10
|
+
*
|
|
11
|
+
* 缓存:段落切分与词元化都很贵(中文双字词元在 900 KB 语料上是十万量级),
|
|
12
|
+
* 所以按**文件 revision 缓存**。文件没变就直接复用已切好的段落与词频表;
|
|
13
|
+
* 目录里任何一份文件变了,语料统计(df / avgdl)会跟着重算 —— 语料统计是全局量,
|
|
14
|
+
* 一篇文档的改动会影响所有词的 IDF。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { chunkFile, revisionOf } from './chunks.js'
|
|
18
|
+
import { buildStats, coverageOf, evidence, maxMatchedRun, queryTermsOf, scoreDoc, termFreq, tokenize, COVERAGE_WEIGHT } from './bm25.js'
|
|
19
|
+
import { score as summaryScore, selectRelevant } from './retrieval.js'
|
|
20
|
+
import { extractTitle } from './scan.js'
|
|
21
|
+
|
|
22
|
+
/** 工具响应的默认条数与上限(稿子 §5.2 的起始值)。 */
|
|
23
|
+
export const DEFAULT_LIMIT = 5
|
|
24
|
+
export const MAX_LIMIT = 10
|
|
25
|
+
|
|
26
|
+
/** 每个文件最多贡献几个段落 —— 不累加同一文件的所有段落得分,这是「长文不垄断」的关键。 */
|
|
27
|
+
export const PER_FILE_CHUNKS = 2
|
|
28
|
+
|
|
29
|
+
/** 单条片段的字符上限(稿子 §5.2 的起始值)。 */
|
|
30
|
+
export const EXCERPT_CHARS = 200
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 相关性门槛 —— **BM25 分数不是可用的判据**。
|
|
34
|
+
*
|
|
35
|
+
* 原因很实在:BM25 对任何问题都能给出非零分,只要有一个常用双字重合。中文语料里
|
|
36
|
+
* 「怎么」「什么」「一个」这类词元的文档频率极高,任何问句都能撞上几个 ——
|
|
37
|
+
* 于是「分数 > 0」等于「几乎总是有结果」,无关问题会从「什么都不注」变成「注一堆噪音」。
|
|
38
|
+
*
|
|
39
|
+
* 三个条件必须同时满足:
|
|
40
|
+
* - `ratio` ≥ 0.35:在「语料里存在过的 query 词元」里,这份文档覆盖了足够大的权重份额;
|
|
41
|
+
* - `sum` ≥ 1.5 :命中的词元绝对信息量够,不是「全是到处都有的词」凑出来的比值;
|
|
42
|
+
* - `rarestDfRatio` ≤ 0.10:至少有一个命中词元是**冷门**的。这条专治一种漏网:
|
|
43
|
+
* 问句里一半的词在语料里压根不存在(df=0,被剔出分母),剩下的通用词恰好都命中,
|
|
44
|
+
* 于是 ratio=1.0、sum 靠数量堆高,看起来证据很强 —— 实际命中的全是虚词。
|
|
45
|
+
* - **连续命中 ≥ 2 个词元**(= query 里至少 3 个字连着出现在文档里)。这条治的是
|
|
46
|
+
* 中文双字词元的**跨词边界假词元**:「明天会不会下雨」里 `天会` 来自
|
|
47
|
+
* 「那一天**会**缩短」,df=1、IDF 最大,单看「有个冷门词元命中」它反而最"强"。
|
|
48
|
+
* 巧合通常孤立,真实内容词通常连成片段。
|
|
49
|
+
*
|
|
50
|
+
* ⚠️ **这三个数是起始参数,不是实测结论**,由 `scripts/eval-retrieval.mjs` 的对照
|
|
51
|
+
* 结果来定(计划里阶段一的用途就是这个)。要放宽就用 `rankChunks(..., { minEvidence: false })`
|
|
52
|
+
* 拿掉门槛,别去改这里的常量。
|
|
53
|
+
*
|
|
54
|
+
* 0.10 这个值来自一次对抗性扫参(16 份合成语料,其中 4 份**故意塞满通用词**,
|
|
55
|
+
* 让无关问句一定能找到词面重合):
|
|
56
|
+
*
|
|
57
|
+
* | 门槛 | 相关题留住 | 无关题误召回 |
|
|
58
|
+
* |---|---|---|
|
|
59
|
+
* | 无门槛 | 12/12 | 4/12 |
|
|
60
|
+
* | 只 ratio | 12/12 | 4/12 |
|
|
61
|
+
* | ratio+sum | 12/12 | 1/12(`红烧肉怎么做才好吃`)|
|
|
62
|
+
* | +rarest ≤ 0.15 | 12/12 | 1/12 |
|
|
63
|
+
* | **+rarest ≤ 0.10** | **12/12** | **0/12** |
|
|
64
|
+
* | +rarest ≤ 0.05 | 0/12 | 0/12 |
|
|
65
|
+
*
|
|
66
|
+
* 相关题命中的最冷门词元都是 0.0625,硬负例是 0.125 —— 0.10 落在分离带里。
|
|
67
|
+
* ⚠️ 该值在**小语料**上定出;真实语料(107 份 / 1871 段落)的 df/N 量级不同,
|
|
68
|
+
* 阶段一的评测脚本要在真实量级上复核一次再定稿。
|
|
69
|
+
*/
|
|
70
|
+
export const MIN_EVIDENCE_RATIO = 0.35
|
|
71
|
+
export const MIN_EVIDENCE_SUM = 1.5
|
|
72
|
+
export const MAX_EVIDENCE_DF_RATIO = 0.1
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 最少要**连续命中**几个词元(默认 2 = query 里至少 3 个字连着出现在文档里)。
|
|
76
|
+
*
|
|
77
|
+
* 设为 1 就等于关掉这条判据。代价与理由见 `bm25.maxMatchedRun` 的注释。
|
|
78
|
+
*/
|
|
79
|
+
export const MIN_MATCHED_RUN = 2
|
|
80
|
+
|
|
81
|
+
/** 缓存文件数上限。语料只有 109 份时永不触发;它防的是目录被塞进成千上万份文件。 */
|
|
82
|
+
const MAX_CACHE_FILES = 1024
|
|
83
|
+
|
|
84
|
+
/** file → {revision, title, chunks:[{...,tokens,tf,dl}]} */
|
|
85
|
+
const chunkCache = new Map()
|
|
86
|
+
|
|
87
|
+
/** 语料统计缓存:`{sig, stats}`。sig 由参与的文件 revision 拼成,变了就重算。 */
|
|
88
|
+
let statsCache = { sig: '', stats: { N: 0, avgdl: 1, df: new Map() } }
|
|
89
|
+
|
|
90
|
+
/** 只给测试用:清掉进程内缓存,让「改了文件」的场景能从头验。 */
|
|
91
|
+
export function __resetSearchCacheForTest() {
|
|
92
|
+
chunkCache.clear()
|
|
93
|
+
statsCache = { sig: '', stats: { N: 0, avgdl: 1, df: new Map() } }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 取得全部段落与语料统计(带缓存)。
|
|
98
|
+
*
|
|
99
|
+
* @param {Array<{file:string,content:string,mtimeMs?:number}>} files
|
|
100
|
+
* @returns {{chunks:Array, stats:object, skipped:number, complete:boolean}}
|
|
101
|
+
*/
|
|
102
|
+
export function chunksOf(files) {
|
|
103
|
+
const list = Array.isArray(files) ? files : []
|
|
104
|
+
const chunks = []
|
|
105
|
+
const revisions = []
|
|
106
|
+
let skipped = 0
|
|
107
|
+
for (const f of list) {
|
|
108
|
+
const file = String(f?.file ?? '')
|
|
109
|
+
const content = String(f?.content ?? '')
|
|
110
|
+
if (!file) continue
|
|
111
|
+
const revision = revisionOf(content)
|
|
112
|
+
revisions.push(`${file}:${revision}`)
|
|
113
|
+
const hit = chunkCache.get(file)
|
|
114
|
+
if (hit && hit.revision === revision) {
|
|
115
|
+
for (const c of hit.chunks) chunks.push(c)
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
const title = extractTitle(content)
|
|
120
|
+
const built = chunkFile({ file, content }, revision).map(c => {
|
|
121
|
+
const tokens = tokenize(`${c.headingPath}\n${c.text}`)
|
|
122
|
+
return { ...c, title, tokens, tf: termFreq(tokens), dl: tokens.length }
|
|
123
|
+
})
|
|
124
|
+
chunkCache.set(file, { revision, title, chunks: built })
|
|
125
|
+
for (const c of built) chunks.push(c)
|
|
126
|
+
} catch {
|
|
127
|
+
// 切分失败的文件计入 skipped 并把 complete 置否 —— 检索结果必须能自报「我没查全」,
|
|
128
|
+
// 不能让一个坏文件静默地变成「这篇没命中」。
|
|
129
|
+
skipped++
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// LRU 也不做,只做整体清理:超限说明目录形态异常,重算比留着更安全。
|
|
133
|
+
if (chunkCache.size > MAX_CACHE_FILES) {
|
|
134
|
+
for (const key of [...chunkCache.keys()].slice(0, chunkCache.size - MAX_CACHE_FILES)) chunkCache.delete(key)
|
|
135
|
+
}
|
|
136
|
+
const sig = revisions.join('|')
|
|
137
|
+
if (statsCache.sig !== sig) {
|
|
138
|
+
statsCache = { sig, stats: buildStats(chunks) }
|
|
139
|
+
}
|
|
140
|
+
return { chunks, stats: statsCache.stats, skipped, complete: skipped === 0 }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 正文排序:段落打分 → 每文件限量 → 全局裁剪。
|
|
145
|
+
*
|
|
146
|
+
* @param {string} query
|
|
147
|
+
* @param {Array} files — `scanMemoryFiles` 的结果
|
|
148
|
+
* @param {{perFile?:number, limit?:number}} [opts]
|
|
149
|
+
* @returns {Array<{chunk:object, file:string, revision:string, bm25:number, coverage:number, score:number, matchedBy:string[]}>}
|
|
150
|
+
*/
|
|
151
|
+
export function rankChunks(query, files, opts = {}) {
|
|
152
|
+
return rankChunksInternal(query, files, opts).hits
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 与 `rankChunks` 同一套排序,但额外报告「被相关性门槛滤掉多少条」——
|
|
157
|
+
* `retrieve` 的 coverage 要如实说明「有候选但不够格」,而不是把它伪装成「没有候选」。
|
|
158
|
+
*
|
|
159
|
+
* `opts.minEvidence === false` 关掉门槛(评测脚本对照两组时要它)。
|
|
160
|
+
* `opts.limit` 只兜底、**不封顶**(`MAX_LIMIT` 是 `retrieve` 那一层的收敛口径):
|
|
161
|
+
* 内部调用要用 `Infinity` 拿完整排名。两个入口语义不同是有意的,别记混。
|
|
162
|
+
*/
|
|
163
|
+
export function rankChunksInternal(query, files, opts = {}) {
|
|
164
|
+
const perFile = Number.isFinite(opts.perFile) ? Math.max(1, opts.perFile) : PER_FILE_CHUNKS
|
|
165
|
+
// `Infinity` = 不切片,要完整排名(`retrieve` 用它算 `candidates` 与翻页)。
|
|
166
|
+
// 不能写成 `Number.isFinite(Infinity)` 那条分支:Infinity 不是有限数,会被当成「没传」回落默认值。
|
|
167
|
+
const limit = opts.limit === Infinity
|
|
168
|
+
? Infinity
|
|
169
|
+
: (Number.isFinite(opts.limit) ? Math.max(1, opts.limit) : DEFAULT_LIMIT)
|
|
170
|
+
const terms = queryTermsOf(query)
|
|
171
|
+
if (!terms.length) return { hits: [], filtered: 0, chunks: 0 }
|
|
172
|
+
// 有序词元(含重复)——「连续命中」判据要顺序,去重后的集合没有位置信息。
|
|
173
|
+
const queryTokens = tokenize(query)
|
|
174
|
+
const { chunks, stats } = chunksOf(files)
|
|
175
|
+
if (!chunks.length) return { hits: [], filtered: 0, chunks: 0 }
|
|
176
|
+
// `minEvidence` 三态:省略 → 用默认门槛;`false` → 完全不设门槛;
|
|
177
|
+
// 传对象 → 用自定义门槛(评测脚本扫参要用,免得为了换一个数字去改常量)。
|
|
178
|
+
const gate = opts.minEvidence === false
|
|
179
|
+
? null
|
|
180
|
+
: {
|
|
181
|
+
ratio: opts.minEvidence?.ratio ?? MIN_EVIDENCE_RATIO,
|
|
182
|
+
sum: opts.minEvidence?.sum ?? MIN_EVIDENCE_SUM,
|
|
183
|
+
maxDfRatio: opts.minEvidence?.maxDfRatio ?? MAX_EVIDENCE_DF_RATIO,
|
|
184
|
+
minRun: opts.minEvidence?.minRun ?? MIN_MATCHED_RUN,
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let filtered = 0
|
|
188
|
+
const scored = []
|
|
189
|
+
for (const c of chunks) {
|
|
190
|
+
const bm25 = scoreDoc(terms, c.tf, c.dl, stats)
|
|
191
|
+
if (bm25 <= 0) continue
|
|
192
|
+
const ev = evidence(terms, c.tf, stats)
|
|
193
|
+
// 冷门判据是**析取**:`df ≤ 1`(全库独一份)或 `df/N ≤ 门槛`。
|
|
194
|
+
// 只用比值会在小语料上把真命中全滤掉(5 份记忆的目录里,独一份的词元比值也是 0.2)。
|
|
195
|
+
const rare = ev.rarestDf <= 1 || ev.rarestDfRatio <= gate?.maxDfRatio
|
|
196
|
+
const run = maxMatchedRun(queryTokens, c.tf)
|
|
197
|
+
// `sum` 用的是 IDF,而 IDF **随语料规模对数增长** —— 绝对阈值同样不可迁移:
|
|
198
|
+
// 单段语料(N=1)里 df=1 的词元 idf 只有 0.288,一个原样命中的 `FS_STALE_VERSION`
|
|
199
|
+
// 四个词元加起来才 1.15,会被 `sum ≥ 1.5` 整条滤掉。
|
|
200
|
+
// 所以给 `sum` 一个**逃生口**:命中的是「全库独一份」的词元**且**连成片段(run 达标)时,
|
|
201
|
+
// 它就是强证据,不再看绝对量。这与 `rare` 的析取是同一个理由,只是换了个量。
|
|
202
|
+
const uniqueAndContiguous = ev.rarestDf <= 1 && run >= (gate?.minRun ?? 1)
|
|
203
|
+
if (gate && (ev.ratio < gate.ratio || (!uniqueAndContiguous && ev.sum < gate.sum) || !rare || run < gate.minRun)) {
|
|
204
|
+
filtered++
|
|
205
|
+
continue
|
|
206
|
+
}
|
|
207
|
+
const coverage = coverageOf(terms, c.tf)
|
|
208
|
+
scored.push({
|
|
209
|
+
chunk: c,
|
|
210
|
+
file: c.file,
|
|
211
|
+
revision: c.revision,
|
|
212
|
+
bm25,
|
|
213
|
+
coverage,
|
|
214
|
+
evidence: Number(ev.ratio.toFixed(4)),
|
|
215
|
+
evidenceSum: Number(ev.sum.toFixed(4)),
|
|
216
|
+
evidenceRarest: Number(ev.rarestDfRatio.toFixed(4)),
|
|
217
|
+
evidenceRarestDf: Number.isFinite(ev.rarestDf) ? ev.rarestDf : null,
|
|
218
|
+
evidenceRun: run,
|
|
219
|
+
score: bm25 + COVERAGE_WEIGHT * coverage,
|
|
220
|
+
matchedBy: exactTermHit(String(query), c.text) ? ['body', 'exact-term'] : ['body'],
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
scored.sort((a, b) => b.score - a.score)
|
|
224
|
+
|
|
225
|
+
// 每文件限量:同一份长文件用五个段落占满候选位,是「长文垄断」的另一种形态。
|
|
226
|
+
const perFileCount = new Map()
|
|
227
|
+
const kept = []
|
|
228
|
+
for (const hit of scored) {
|
|
229
|
+
const n = perFileCount.get(hit.file) ?? 0
|
|
230
|
+
if (n >= perFile) continue
|
|
231
|
+
perFileCount.set(hit.file, n + 1)
|
|
232
|
+
kept.push(hit)
|
|
233
|
+
if (kept.length >= limit) break
|
|
234
|
+
}
|
|
235
|
+
return { hits: kept, filtered, chunks: chunks.length }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* 摘要命中(复用 retrieval.js 的 `selectRelevant`,不另写一套打分)。
|
|
240
|
+
*
|
|
241
|
+
* 只额外做一件事:**说清是哪一栏命中的**。摘要命中与正文命中在展示层的处理不同
|
|
242
|
+
* (前者给指针就够,后者才值得端正文),所以这个理由是行为契约的一部分。
|
|
243
|
+
*/
|
|
244
|
+
export function summaryHits(query, files, limit = DEFAULT_LIMIT) {
|
|
245
|
+
if (!String(query ?? '').trim()) return []
|
|
246
|
+
return selectRelevant(query, files).slice(0, limit).map(f => {
|
|
247
|
+
const fileBase = f.file.replace(/\.md$/, '').replace(/[_-]/g, ' ')
|
|
248
|
+
const matchedBy = []
|
|
249
|
+
if (f.description && summaryScore(query, f.description) > 0) matchedBy.push('description')
|
|
250
|
+
if (Array.isArray(f.aliases) && f.aliases.length && summaryScore(query, f.aliases.join(' ')) > 0) matchedBy.push('alias')
|
|
251
|
+
if (summaryScore(query, fileBase) > 0) matchedBy.push('filename')
|
|
252
|
+
return {
|
|
253
|
+
file: f.file,
|
|
254
|
+
revision: revisionOf(String(f.content ?? '')),
|
|
255
|
+
score: f.score,
|
|
256
|
+
// 回落标签是 `summary` 而不是 `description`:`retrieval.js` 的打分是**三个字段去标点
|
|
257
|
+
// 拼成一串**之后再算双字,所以跨字段边界也能产生命中(`description: '收尾待'` +
|
|
258
|
+
// `aliases: ['办清单']` 会被 query「待办」选中)。逐字段复算时三个字段各自为 0,
|
|
259
|
+
// 回落成 `description` 就是在**谎报命中原因** —— 展示层会据此给出错的解释。
|
|
260
|
+
matchedBy: matchedBy.length ? matchedBy : ['summary'],
|
|
261
|
+
}
|
|
262
|
+
})
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* 检索。返回线上形状(稿子 §5.2)。
|
|
267
|
+
*
|
|
268
|
+
* scope 语义:
|
|
269
|
+
* - `summary` 只比摘要 —— 普通问题先走这条,最省;
|
|
270
|
+
* - `body` 只扫正文段落;
|
|
271
|
+
* - `all` 两者合并,**摘要优先、同文件不重复占位**。
|
|
272
|
+
*
|
|
273
|
+
* 「摘要优先」不是偏好,是成本:摘要命中的那条本来就要被注入,正文再给它一个位置等于
|
|
274
|
+
* 同一份证据占两个候选位。合并时同文件只保留一条,带上它最好的段落。
|
|
275
|
+
*/
|
|
276
|
+
export function retrieve(input = {}) {
|
|
277
|
+
const query = String(input.query ?? '')
|
|
278
|
+
const files = Array.isArray(input.files) ? input.files : []
|
|
279
|
+
const scope = normalizeScope(input.scope)
|
|
280
|
+
const limit = clampLimit(input.limit)
|
|
281
|
+
const perFile = Number.isFinite(input.perFile) ? Math.max(1, input.perFile) : PER_FILE_CHUNKS
|
|
282
|
+
const excerptChars = Number.isFinite(input.excerptChars) ? Math.max(40, input.excerptChars) : EXCERPT_CHARS
|
|
283
|
+
const cursor = Number.isFinite(input.cursor) && input.cursor > 0 ? Math.floor(input.cursor) : 0
|
|
284
|
+
|
|
285
|
+
const corpus = chunksOf(files)
|
|
286
|
+
const { skipped, complete } = corpus
|
|
287
|
+
const terms = queryTermsOf(query)
|
|
288
|
+
|
|
289
|
+
// ⚠️ 两路的候选池都必须**比本页窗口宽**,否则 `candidates` 是本页条数、`nextCursor` 永远是 null
|
|
290
|
+
// (summary 一路曾经就是这样:池宽 = `limit + cursor`,于是翻页根本翻不动)。
|
|
291
|
+
// 取满再切窗口,是本模块唯一正确的做法。
|
|
292
|
+
const bodyRanked = scope === 'summary'
|
|
293
|
+
? { hits: [], filtered: 0, chunks: 0 }
|
|
294
|
+
: rankChunksInternal(query, files, { perFile, limit: Infinity })
|
|
295
|
+
const body = bodyRanked.hits
|
|
296
|
+
const byFile = new Map()
|
|
297
|
+
for (const hit of body) if (!byFile.has(hit.file)) byFile.set(hit.file, hit)
|
|
298
|
+
|
|
299
|
+
// `Infinity` = 取满 `selectRelevant` 给的候选(它自己按 MAX_RESULTS 封顶)。
|
|
300
|
+
// 曾经这里写的是 `limit + cursor` —— 池宽恰好等于本页窗口,于是 total 恒等于 cursor+limit、
|
|
301
|
+
// `nextCursor` 恒为 null,翻页**根本翻不动**(只有第一页)。池宽必须大于窗口。
|
|
302
|
+
const summary = scope === 'body' ? [] : summaryHits(query, files, Infinity)
|
|
303
|
+
const summaryByFile = new Map()
|
|
304
|
+
for (const s of summary) if (!summaryByFile.has(s.file)) summaryByFile.set(s.file, s)
|
|
305
|
+
|
|
306
|
+
const ordered = []
|
|
307
|
+
for (const s of summaryByFile.values()) ordered.push(toHit(s.file, s, byFile.get(s.file), terms, excerptChars))
|
|
308
|
+
for (const [file, b] of byFile) {
|
|
309
|
+
if (summaryByFile.has(file)) continue
|
|
310
|
+
ordered.push(toHit(file, null, b, terms, excerptChars))
|
|
311
|
+
}
|
|
312
|
+
// 两类都命中 → 排前面(同一份证据被两条独立信号指向);其余按分数排。
|
|
313
|
+
ordered.sort((a, b) => Number(b.matchedBy.length > 1) - Number(a.matchedBy.length > 1) || b.score - a.score)
|
|
314
|
+
|
|
315
|
+
const total = ordered.length
|
|
316
|
+
const hits = ordered.slice(cursor, cursor + limit)
|
|
317
|
+
return {
|
|
318
|
+
ok: true,
|
|
319
|
+
scope,
|
|
320
|
+
cursor,
|
|
321
|
+
coverage: {
|
|
322
|
+
searched: files.length,
|
|
323
|
+
skipped,
|
|
324
|
+
complete,
|
|
325
|
+
chunks: corpus.chunks.length,
|
|
326
|
+
// 被相关性门槛滤掉的条数:必须如实报出来,否则「不够格」会被读成「没有候选」——
|
|
327
|
+
// 这两件事对模型下一步该干什么的指示完全不同。
|
|
328
|
+
filteredBelowFloor: bodyRanked.filtered,
|
|
329
|
+
candidates: total,
|
|
330
|
+
},
|
|
331
|
+
hits,
|
|
332
|
+
nextCursor: cursor + limit < total ? cursor + limit : null,
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function toHit(file, summary, body, terms, excerptChars) {
|
|
337
|
+
const score = (summary?.score ?? 0) + (body?.score ?? 0)
|
|
338
|
+
const matchedBy = [...(summary?.matchedBy ?? []), ...(body?.matchedBy ?? [])]
|
|
339
|
+
const text = body?.chunk?.text ?? ''
|
|
340
|
+
return {
|
|
341
|
+
id: body?.chunk?.id ?? `${file}#summary`,
|
|
342
|
+
file,
|
|
343
|
+
revision: body?.revision ?? summary?.revision ?? '',
|
|
344
|
+
title: body?.chunk?.title ?? '',
|
|
345
|
+
headingPath: body?.chunk?.headingPath ?? '',
|
|
346
|
+
lines: body?.chunk ? [body.chunk.startLine, body.chunk.endLine] : null,
|
|
347
|
+
excerpt: text ? excerptAround(text, terms, excerptChars) : '',
|
|
348
|
+
// 证据强度:调用方要能判别「这条凭什么算相关」,而不只看到一个分数。
|
|
349
|
+
evidence: body?.evidence ?? null,
|
|
350
|
+
evidenceSum: body?.evidenceSum ?? null,
|
|
351
|
+
evidenceRarest: body?.evidenceRarest ?? null,
|
|
352
|
+
evidenceRarestDf: body?.evidenceRarestDf ?? null,
|
|
353
|
+
evidenceRun: body?.evidenceRun ?? null,
|
|
354
|
+
score: Number(score.toFixed(4)),
|
|
355
|
+
matchedBy: matchedBy.length ? matchedBy : ['body'],
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** scope 归一:只认三个值,其余一律当 `all`(**不悄悄降级成 summary**,见稿子 §5.2)。 */
|
|
360
|
+
export function normalizeScope(raw) {
|
|
361
|
+
const s = String(raw ?? '').trim().toLowerCase()
|
|
362
|
+
return s === 'summary' || s === 'body' ? s : 'all'
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function clampLimit(raw) {
|
|
366
|
+
const n = Number(raw)
|
|
367
|
+
if (!Number.isFinite(n)) return DEFAULT_LIMIT
|
|
368
|
+
return Math.max(1, Math.min(MAX_LIMIT, Math.round(n)))
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* 片段:以**第一个命中位置**为中心截取,而不是永远取开头。
|
|
373
|
+
*
|
|
374
|
+
* 取开头是错的:一份 900 字的段落里第 600 字才提到那个错误码,给的片段里没有它 ——
|
|
375
|
+
* 模型看不到命中在哪,只能把整段读回来,正好抵消掉「先给片段」省下的钱。
|
|
376
|
+
*/
|
|
377
|
+
export function excerptAround(text, terms, maxChars) {
|
|
378
|
+
const s = String(text ?? '')
|
|
379
|
+
if (s.length <= maxChars) return s
|
|
380
|
+
// 小写化**可能改变字符串长度**(`İ` → `i̇` 这类少数语言环境相关字符)。长度一变,
|
|
381
|
+
// 在 lowercase 串上找到的偏移映射回原串就是错位的。宁可退回「取开头」,
|
|
382
|
+
// 也不要给出一个从错误位置切出来的片段——片段的价值全在「命中词在不在里面」。
|
|
383
|
+
const lower = s.toLowerCase()
|
|
384
|
+
const hay = lower.length === s.length ? lower : s
|
|
385
|
+
let at = -1
|
|
386
|
+
for (const t of terms ?? []) {
|
|
387
|
+
const i = hay.indexOf(t)
|
|
388
|
+
if (i >= 0 && (at < 0 || i < at)) at = i
|
|
389
|
+
}
|
|
390
|
+
if (at < 0) return s.slice(0, maxChars) + '…'
|
|
391
|
+
const lead = Math.floor(maxChars / 3)
|
|
392
|
+
const from = Math.max(0, at - lead)
|
|
393
|
+
return (from > 0 ? '…' : '') + s.slice(from, from + maxChars) + (from + maxChars < s.length ? '…' : '')
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** 整段 query 原样出现(去掉首尾空白、大小写归一)—— 错误码 / 路径 / 版本号的强信号。 */
|
|
397
|
+
function exactTermHit(query, text) {
|
|
398
|
+
const q = query.trim().toLowerCase().replace(/\s+/g, ' ')
|
|
399
|
+
if (q.length < 2) return false
|
|
400
|
+
return String(text ?? '').toLowerCase().replace(/\s+/g, ' ').includes(q)
|
|
401
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sage-mem",
|
|
3
3
|
"description": "File-based memory plugin for DeepSeek Harness (DSH) — cross-session memory in plain markdown files, with a memory star-map view, type/tag filtering, retrieval aliases, and an optional autodream pass that consolidates memories on demand or on a schedule — with per-run rollback points, a per-file change declaration (what changed and why), and a separately selectable model for the consolidation pass",
|
|
4
|
-
"version": "0.9.
|
|
4
|
+
"version": "0.9.15",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|