sage-mem 2.1.14 → 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.14`** — 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.14`** —— 顶部 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 是最直接的反馈。
@@ -497,26 +497,22 @@ export async function writeCheckpoint(taskId, text, opts) {
497
497
  /** 注入用的短句柄硬上限(字节)。它要跟正文抢上下文,不能自己长起来。 */
498
498
  const HANDLE_MAX_BYTES = 160
499
499
 
500
- /** 按**码点**截断到不超过 maxBytes 字节:不许把一个字符切成半个(注入文本里会变 U+FFFD)。 */
501
- function truncateByBytes(s, maxBytes) {
502
- if (Buffer.byteLength(s, 'utf8') <= maxBytes) return s
503
- let out = ''
504
- let used = 0
505
- for (const ch of s) {
506
- const b = Buffer.byteLength(ch, 'utf8')
507
- if (used + b > maxBytes) break
508
- out += ch
509
- used += b
510
- }
511
- return out
512
- }
500
+ /**
501
+ * 句柄里相对路径的**基准标记**:相对路径只说「相对谁」,不说清基准就是歧义的
502
+ * (去记忆目录里找?去状态根里找?)。生成与解析**共用这一个常量** ——
503
+ * 两边各写一份字符串,改了一边就会静默给出读不到的路径(Q12 的旧句柄正是这么坏的)。
504
+ */
505
+ const HANDLE_BASE_MARK = '状态根下'
513
506
 
514
507
  /**
515
- * 注入用的短句柄(一行,≤ 160 字节):说清「有这个检查点、revision 几、怎么读」。
508
+ * 注入用的短句柄(一行,≤ 160 字节):说清「有这个检查点、revision 几、去哪读」。
516
509
  *
517
- * 预算是硬的,所以按优先级退化:**id 与 revision 不可丢**(它们才是身份),项目是可选线索
518
- * (放不下就截短或整个丢掉),最后连「重复一次 id 的完整相对路径」放不下时(64 字符的
519
- * 极端 id),退成不含重复路径的说法。绝不把多字节字符截半个。taskId 非法返回 ''。
510
+ * 预算是硬的,按优先级退化:**可解析路径与 revision 不可丢**,项目名是可选线索
511
+ * (放不下就整个丢掉),「续做前先读」是纯提示(再放不下就丢它)。taskId 非法返回 ''。
512
+ *
513
+ * ⚠️ **id 只出现一次**:完整相对路径 `tasks/<id>/WORKING.md` 已经含它了。
514
+ * 旧版在头部又重复了一遍 id,预算被耗光后退化成一条**不存在的路径**
515
+ * `tasks/WORKING.md`(2026-10-31 外部审查 Q12)—— 短了,但不再是准确的读取指令。
520
516
  *
521
517
  * revision 缺省按 0(与 renderCheckpoint 的缺省一致)。
522
518
  */
@@ -526,21 +522,43 @@ export function checkpointHandleText({ taskId, revision, project } = {}) {
526
522
  const rev = Number.isInteger(revision) && revision >= 0 ? revision : 0
527
523
  const proj = String(project ?? '').replace(/\s+/g, ' ').trim()
528
524
 
529
- const head = `检查点 ${id}(revision ${rev}):`
530
- const detail = `读 tasks/${id}/WORKING.md`
525
+ // 身份只表达一次:id 在路径里,revision 在头部。
526
+ const head = `检查点 revision ${rev}:`
527
+ const rel = `${TASKS_DIR_NAME}/${id}/${CHECKPOINT_FILE_NAME}`
531
528
  const tail = ',续做前先读'
532
- const build = (p) => `${head}${p === '' ? '' : `项目 ${p},`}${detail}${tail}`
533
-
534
- let line = build(proj)
535
- if (Buffer.byteLength(line, 'utf8') > HANDLE_MAX_BYTES) {
536
- // 项目放不下:按剩余预算截断它,截不下就整个丢掉。
537
- const fixed = Buffer.byteLength(build(''), 'utf8')
538
- const room = HANDLE_MAX_BYTES - fixed - Buffer.byteLength('项目 ,', 'utf8')
539
- line = build(room > 0 ? truncateByBytes(proj, room) : '')
540
- }
541
- if (Buffer.byteLength(line, 'utf8') > HANDLE_MAX_BYTES) {
542
- // 到这儿只剩「id 太长」一种可能:完整相对路径(id 出现两次)放不下,退成不重复的写法。
543
- line = `${head}读 ${TASKS_DIR_NAME}/${CHECKPOINT_FILE_NAME}`
544
- }
529
+ const build = (p, withTail) =>
530
+ `${head}${HANDLE_BASE_MARK} ${rel}${withTail ? tail : ''}${p === '' ? '' : `(项目 ${p})`}`
531
+
532
+ let line = build(proj, true)
533
+ if (Buffer.byteLength(line, 'utf8') > HANDLE_MAX_BYTES) line = build('', true)
534
+ if (Buffer.byteLength(line, 'utf8') > HANDLE_MAX_BYTES) line = build('', false)
535
+ // 到这儿只剩「revision 是天文数字」一种可能,合法整数不会触发。宁可给一条超长句柄,
536
+ // 也不截断路径:截出来的路径定位不到真实文件,比多占几个字节更糟。
545
537
  return line
546
538
  }
539
+
540
+ /**
541
+ * 从短句柄里解析回检查点身份(生成规则见 `checkpointHandleText`)。
542
+ *
543
+ * 为什么需要它:句柄是**给模型执行的读取指令**,必须能被机器核对「它说的是不是真实文件」。
544
+ * 旧版退化成 `读 tasks/WORKING.md`,字节数达标、也含 id —— 只断言「≤160 字节 + 包含 id」
545
+ * 的测试正是这么放它过去的。断言要看的是**用同一解析规则能不能定位到真实文件**。
546
+ *
547
+ * 解析要求基准标记 + `tasks/<id>/WORKING.md`:没有基准的裸相对路径是歧义的,按失败处理。
548
+ *
549
+ * @param {string} text
550
+ * @returns {{taskId: string, revision: number} | null}
551
+ */
552
+ export function parseCheckpointHandle(text) {
553
+ if (typeof text !== 'string') return null
554
+ const file = CHECKPOINT_FILE_NAME.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
555
+ const re = new RegExp(
556
+ `检查点\\s+revision\\s+(\\d+)[::]\\s*${HANDLE_BASE_MARK}\\s+${TASKS_DIR_NAME}/([A-Za-z0-9._-]{1,64})/${file}`,
557
+ )
558
+ const m = text.match(re)
559
+ if (!m) return null
560
+ const id = normalizeTaskId(m[2])
561
+ if (id === null) return null
562
+ const revision = Number(m[1])
563
+ return Number.isSafeInteger(revision) ? { taskId: id, revision } : null
564
+ }
@@ -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.14",
4
+ "version": "2.1.16",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "icon": "./icon.png",