sage-mem 2.1.15 → 2.1.16

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
@@ -12,7 +12,7 @@
12
12
 
13
13
  **Cross-session memory for DeepSeek Harness — plain markdown files, no database, no worker.**
14
14
 
15
- **Current release: `2.1.15`** — the npm badge above is the dynamic source of truth. **Contract baseline: `1.0.0`** — the first stable release, whose public commitments, the capability boundaries and the list of things it explicitly cannot do are frozen in the
15
+ **Current release: `2.1.16`** — the npm badge above is the dynamic source of truth. **Contract baseline: `1.0.0`** — the first stable release, whose public commitments, the capability boundaries and the list of things it explicitly cannot do are frozen in the
16
16
  [frozen contract](https://github.com/gezi-wen/sage-mem/blob/main/docs/1.0.0-contract.md).
17
17
 
18
18
  If it is useful to you, a Star is the most direct feedback.
package/README.zh.md CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  > File-based cross-session memory for DeepSeek Harness — every memory is a plain markdown file you can open, edit and delete. No database, no worker, no port.
16
16
 
17
- **当前发布版本:`2.1.15`** —— 顶部 npm 徽章是动态权威。**契约基线:`1.0.0`** —— 第一个稳定版,公开承诺、能力边界与「明确做不到」的清单冻结在
17
+ **当前发布版本:`2.1.16`** —— 顶部 npm 徽章是动态权威。**契约基线:`1.0.0`** —— 第一个稳定版,公开承诺、能力边界与「明确做不到」的清单冻结在
18
18
  [契约冻结](https://github.com/gezi-wen/sage-mem/blob/main/docs/1.0.0-contract.md)。
19
19
 
20
20
  如果它对你有用,给个 Star 是最直接的反馈。
@@ -71,32 +71,43 @@ export const MIN_SUMMARY_EVIDENCE_RATIO = 0.6
71
71
  */
72
72
  export const MIN_GATE_CORPUS = 8
73
73
 
74
- /** 摘要面的检索文本:`description` + `aliases` + 文件名。**打分与语料统计必须用同一份**。 */
74
+ /**
75
+ * 摘要面的检索文本:`description` + `aliases` + 文件名。**打分与语料统计必须用同一份**。
76
+ *
77
+ * 相关性打分**只看 frontmatter 的 description + 文件名,不扫全文**。旧版拿 query 的
78
+ * 双字去全文里找,得分是「覆盖率」,分母是 query、不惩罚文件长度 —— 越长的记忆越容易
79
+ * 蒙中。实测:长记忆在全文匹配下对任何问题都能蒙中足量双字组,连寒暄问句都能注入上千
80
+ * token;而真正该命中的那条反被挤掉。description 是「一句话说清这条是什么」的精准摘要,
81
+ * 用它当检索信号,噪音大幅下降。
82
+ */
75
83
  export function summaryHay(f) {
76
84
  const aliasText = Array.isArray(f.aliases) && f.aliases.length ? ' ' + f.aliases.join(' ') : ''
77
85
  return f.description + aliasText + ' ' + f.file.replace(/\.md$/, '').replace(/[_-]/g, ' ')
78
86
  }
79
87
 
80
88
  /**
81
- * 相关性打分只用 frontmatter 的 description + 文件名,不扫全文。
89
+ * 摘要候选的**完整排序**(不施加注入名额)。
90
+ *
91
+ * 与 `selectRelevant` 的分工(2.1.16 / 外部审查 Q13):
92
+ * - 自动注入只要前 `MAX_RESULTS` 条 → 用 `selectRelevant`,名额在**消费端**切;
93
+ * - 主动检索(`search_memory` / `retrieve`)要按自己的 limit/cursor 翻页 →
94
+ * 用这个函数拿**完整候选集**,否则下一页无从取起。
82
95
  *
83
- * 旧版拿 query 的双字去全文里找,得分是「覆盖率」,分母是 query,不惩罚
84
- * 文件长度 —— 越长的记忆越容易蒙中。实测:长记忆在全文匹配下对任何问题都能
85
- * 蒙中足量双字组,连寒暄问句都能注入上千 token;而真正该命中的那条反被
86
- * 挤掉。description 是「一句话说清这条是什么」的精准摘要,用它当检索
87
- * 信号,噪音大幅下降。
96
+ * 旧版把 `MAX_RESULTS` 焊死在这条排序里:`SAGE_MEM_MAX_RESULTS=3` 时,主动检索
97
+ * `scope=summary, limit=10` 只看到 3 条、`nextCursor=null`,丢掉的候选翻页也取不回。
88
98
  *
89
99
  * @param {string} query
90
100
  * @param {Array} files
91
101
  * @param {{gate?: boolean, minRatio?: number}} [opts] — `gate:false` 关掉证据门槛
92
102
  * (测试「名额上限」这类与相关性无关的用例要它)
103
+ * @returns {Array} 按分数降序的**全部**命中(每项是原文件对象 + `score`)
93
104
  */
94
- export function selectRelevant(query, files, opts = {}) {
105
+ export function rankSummaries(query, files, opts = {}) {
95
106
  const list = Array.isArray(files) ? files : []
96
107
  const hay = list.map(summaryHay)
97
108
  const entries = list.map((f, i) => ({ f, i, score: score(query, hay[i]) }))
98
109
  const hit = entries.filter(e => e.score > 0).sort((a, b) => b.score - a.score)
99
- const keep = (arr) => arr.slice(0, MAX_RESULTS).map(e => ({ ...e.f, score: e.score }))
110
+ const keep = (arr) => arr.map(e => ({ ...e.f, score: e.score }))
100
111
  if (opts.gate === false || hit.length === 0) return keep(hit)
101
112
  // 语料太小就别用 IDF 判(见 MIN_GATE_CORPUS)。
102
113
  if (list.length < MIN_GATE_CORPUS) return keep(hit)
@@ -123,6 +134,20 @@ export function selectRelevant(query, files, opts = {}) {
123
134
  return keep(hit.filter(e => evidence(terms, tf[e.i], stats).sum >= minRatio * maxIdf))
124
135
  }
125
136
 
137
+ /**
138
+ * 自动注入用的摘要选择:完整排序之后**只取注入名额内的条数**。
139
+ *
140
+ * 名额(`MAX_RESULTS`)仍然守自己的配置 —— 2.1.16 只把「排序」与「名额」拆开,
141
+ * 没有放开注入上限(那是 Q13 的反向对照)。
142
+ *
143
+ * @param {string} query
144
+ * @param {Array} files
145
+ * @param {{gate?: boolean, minRatio?: number}} [opts]
146
+ */
147
+ export function selectRelevant(query, files, opts = {}) {
148
+ return rankSummaries(query, files, opts).slice(0, MAX_RESULTS)
149
+ }
150
+
126
151
  /**
127
152
  * 选 baseline 条目:按显式 `baselinePriority` 排,不按文件名。
128
153
  *
@@ -16,7 +16,7 @@
16
16
 
17
17
  import { chunkFile, revisionOf } from './chunks.js'
18
18
  import { buildStats, coverageOf, evidence, maxMatchedRun, queryTermsOf, scoreDoc, termFreq, tokenize, COVERAGE_WEIGHT } from './bm25.js'
19
- import { score as summaryScore, selectRelevant } from './retrieval.js'
19
+ import { rankSummaries, score as summaryScore } from './retrieval.js'
20
20
  import { extractTitle } from './scan.js'
21
21
 
22
22
  /** 工具响应的默认条数与上限(稿子 §5.2 的起始值)。 */
@@ -236,14 +236,18 @@ export function rankChunksInternal(query, files, opts = {}) {
236
236
  }
237
237
 
238
238
  /**
239
- * 摘要命中(复用 retrieval.js 的 `selectRelevant`,不另写一套打分)。
239
+ * 摘要命中(复用 retrieval.js 的摘要排序,不另写一套打分)。
240
+ *
241
+ * ⚠️ 用 `rankSummaries`(**不带注入名额**)而不是 `selectRelevant`:后者返回前
242
+ * `MAX_RESULTS` 条,而这里是主动检索的一页 —— 名额若先切一刀,本函数的 limit/cursor
243
+ * 就作用在**已被截断的集合**上,丢掉的候选翻页也取不回(外部审查 Q13)。
240
244
  *
241
245
  * 只额外做一件事:**说清是哪一栏命中的**。摘要命中与正文命中在展示层的处理不同
242
246
  * (前者给指针就够,后者才值得端正文),所以这个理由是行为契约的一部分。
243
247
  */
244
248
  export function summaryHits(query, files, limit = DEFAULT_LIMIT) {
245
249
  if (!String(query ?? '').trim()) return []
246
- return selectRelevant(query, files).slice(0, limit).map(f => {
250
+ return rankSummaries(query, files).slice(0, limit).map(f => {
247
251
  const fileBase = f.file.replace(/\.md$/, '').replace(/[_-]/g, ' ')
248
252
  const matchedBy = []
249
253
  if (f.description && summaryScore(query, f.description) > 0) matchedBy.push('description')
@@ -296,7 +300,8 @@ export function retrieve(input = {}) {
296
300
  const byFile = new Map()
297
301
  for (const hit of body) if (!byFile.has(hit.file)) byFile.set(hit.file, hit)
298
302
 
299
- // `Infinity` = 取满 `selectRelevant` 给的候选(它自己按 MAX_RESULTS 封顶)。
303
+ // `Infinity` = 取满摘要排序的**全部**候选(2.1.16 / Q13:注入名额不再焊在这条排序里,
304
+ // 所以这里拿到的是完整合格集,本函数的 limit/cursor 才真正能翻页)。
300
305
  // 曾经这里写的是 `limit + cursor` —— 池宽恰好等于本页窗口,于是 total 恒等于 cursor+limit、
301
306
  // `nextCursor` 恒为 null,翻页**根本翻不动**(只有第一页)。池宽必须大于窗口。
302
307
  const summary = scope === 'body' ? [] : summaryHits(query, files, Infinity)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sage-mem",
3
3
  "description": "Cross-session memory for DeepSeek Harness — plain markdown files, no database, no worker.",
4
- "version": "2.1.15",
4
+ "version": "2.1.16",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "icon": "./icon.png",