sage-mem 2.9.0 → 2.10.2

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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * sage-mem —— 给模型的两个检索工具。
2
+ * sage-mem —— 给模型的三个工具(2.10.1 起多了 `trim_memory`)。
3
3
  *
4
4
  * 这是「主动记忆」的落点:注入链是插件**猜**模型想看什么,而工具让模型**自己**去翻。
5
5
  * 两者不是替代关系 —— 注入是零边际成本的(宿主反正每步组装 prompt),工具则有真实代价
@@ -14,13 +14,25 @@
14
14
  * 3. **如实区分「没找到」与「找到但没给」**。`retrieve()` 的 `coverage` 里带着
15
15
  * `filteredBelowFloor`(被相关性门槛滤掉几条),工具输出必须把它说出来 ——
16
16
  * 否则「不够格」会被读成「记忆里没有」,而这两件事对模型下一步的指示完全相反。
17
+ *
18
+ * 2.10.1 新增 `trim_memory`(体积治理的模型入口)。三条与之相关的取舍:
19
+ * - **它不自己拼写盘逻辑**:裁段规划与归档全在 `memory/trim-archive.js`(`trimArchive`),
20
+ * 这里只做「参数校验 + 把结果翻成人话」。写序是安全边界,入口多一份实现就多一条绕过它的路。
21
+ * - **回执是字符串**(契约 §4):成功报体积变化,失败给「错误:<code> — <一句处置建议>」。
22
+ * 只丢一个 code 的话,模型下一步只能瞎猜该换标题还是该改用压缩。
23
+ * - `read_memory` 在命中**超出体积线**的条目时多一行提示:读得到 ≠ 这份还该原样留着。
17
24
  */
18
25
 
26
+ import { join } from 'node:path'
19
27
  import { DEFAULT_MAX_BYTES, MAX_MAX_BYTES, readSlice } from '../memory/reader.js'
20
28
  import { DEFAULT_LIMIT, MAX_LIMIT, retrieve } from '../memory/search.js'
21
- import { scanMemoryFilesDetailed } from '../memory/scan.js'
29
+ import { inspectMemoryEntry, scanMemoryFilesDetailed } from '../memory/scan.js'
30
+ import { MEMORY_DIR } from '../memory/config.js'
31
+ import { safeName } from '../memory/naming.js'
22
32
  import { parseFrontmatter } from '../memory/frontmatter.js'
23
33
  import { describeValidity } from '../memory/validity.js'
34
+ import { classifySize, adviceText } from '../memory/size-policy.js'
35
+ import { trimArchive } from '../memory/trim-archive.js'
24
36
 
25
37
  /** 工具返回内容统一用 text block(宿主把 ContentBlock[] 交给模型)。 */
26
38
  const text = (s) => [{ type: 'text', text: String(s ?? '') }]
@@ -31,6 +43,24 @@ const renderClip = (s, n) => {
31
43
  return str.length > n ? `${str.slice(0, n)}…` : str
32
44
  }
33
45
 
46
+ /**
47
+ * 一份记忆在盘上的**字节数**(给 `read_memory` 的体积提示用)。
48
+ *
49
+ * 为什么再 stat 一次:`readSlice` 回的是**本次切片**的字节数,不是整份的 ——
50
+ * 而「这条一共多大」正是提示要说的事。读失败一律返回 null(提示是附加信息,
51
+ * 不该让一次成功的读变成失败):闸门本身照旧由 `readSlice` 走。
52
+ */
53
+ async function entryBytesOf(name) {
54
+ try {
55
+ const safe = safeName(name)
56
+ if (!safe) return null
57
+ const gate = await inspectMemoryEntry(join(MEMORY_DIR, safe))
58
+ return gate && gate.ok ? gate.size : null
59
+ } catch {
60
+ return null
61
+ }
62
+ }
63
+
34
64
  function renderSearchResult(args, value) {
35
65
  const v = value ?? {}
36
66
  if (v.ok === false) return text(`检索失败:${v.error ?? '未知错误'}`)
@@ -87,14 +117,72 @@ function renderReadResult(args, value) {
87
117
  const tail = v.nextStartLine != null
88
118
  ? `\n\n还有内容:startLine=${v.nextStartLine}${v.nextStartByte ? ` startByte=${v.nextStartByte}` : ''}`
89
119
  : '\n\n(已读到末尾)'
90
- return text(`${head}${flags.length ? `(${flags.join(';')})` : ''}\n\n${v.content ?? ''}${tail}`)
120
+ // 2.10.1:体积提示**追加在最后一行**,不混进页头那圈括号(那里说的是「这一页怎么读」)。
121
+ // 整句由 size-policy 的 adviceText 出 —— 回执 / 审计 / 做梦提示共用同一句,别各写一套。
122
+ const sizeHint = v.sizeHint
123
+ ? `\n\nℹ ${adviceText({ bytes: v.sizeHint.bytes, kind: v.sizeHint.kind })}`
124
+ : ''
125
+ return text(`${head}${flags.length ? `(${flags.join(';')})` : ''}\n\n${v.content ?? ''}${sizeHint}${tail}`)
126
+ }
127
+
128
+ /**
129
+ * 裁段的失败码 → **一句处置建议**(契约 §4:回执要能直接指导下一步动作)。
130
+ *
131
+ * 码出自 `memory/trim-plan.js` 与 `memory/trim-archive.js`;这里只翻译、不改判。
132
+ * 没登记过的码落到通用那一句 —— 宁可少说,也别编一个不存在的理由。
133
+ */
134
+ const TRIM_ADVICE = {
135
+ lock_unknown: '锁表读不出来(被占用或损坏),确认没有别的整理进程在跑再重试',
136
+ locked: '这条记忆被锁定了 —— 先在面板上解锁,锁定优先于体积治理',
137
+ reserved: '这是保留名(索引 / 流水 / 保护名单里的文件),体积治理不裁它们',
138
+ non_entry: '这不是一条普通记忆(可能是整理报告 / 快照),不在治理范围',
139
+ missing: '没有这份记忆 —— 用 search_memory 确认文件名',
140
+ not_found: '没有这份记忆 —— 用 search_memory 确认文件名',
141
+ too_big: '这份记忆本身超了单文件上限,先用 write_memory 压小它,再裁段',
142
+ symlink: '这个文件指向别处(符号链接 / 目录联接),出于安全不处理',
143
+ not_file: '这不是普通文件,不处理',
144
+ no_frontmatter: '正文没有 frontmatter —— 裁剪保证不了剩余正文仍带完整 frontmatter,用文件工具补上再裁',
145
+ invalid: '这次算出来的切法不安全(内部不变量没通过)—— 换个选择器再试,主文件没动',
146
+ selector_invalid: '行区间碰到了 frontmatter 或行号非法 —— 改用 heading 指小节更稳',
147
+ segment_too_small: '要裁的这段太小(不足 1 KB):小段不值得裁,就地压缩更合适',
148
+ remain_too_small: '裁完剩下的正文会不足 1 KB —— 换更短的一段,或改用压缩',
149
+ heading_not_found: '标题没找到 —— 标题写小节文字本身(不含 #),或改用 startLine/endLine',
150
+ heading_ambiguous: '同名标题不止一处 —— 改用 startLine/endLine 精确指定',
151
+ exists: '归档件重名(同一标题今天已经裁过)—— 换个标题,或今天就别再裁这段',
152
+ stale_revision: '读到之后文件被改过 —— 重新读一遍再裁(会按最新内容重算)',
153
+ invalid_name: '文件名不合法:要一个 .md 文件名,不能带路径',
154
+ reason_too_short: '缘由至少 4 个字 —— 档案馆要能回答「它为什么被收起来」',
155
+ io_error: '读写失败(权限 / 盘掉线 / 文件被占用),稍后重试',
156
+ }
157
+
158
+ /**
159
+ * `<code> — <建议>`;成功回执里也用它指归档件的位置。
160
+ *
161
+ * `extra` 是**阶段补充**(契约 §3 第 7 步):`stage === 'after_archive'` 表示归档件
162
+ * 已经写成、随后又被退役进 archive/ —— 那不是 `code`,不能塞进上面那张表。
163
+ * 这件事必须说出来:否则用户看到「失败」会以为旧内容没了。
164
+ */
165
+ function trimFailureText(code, error, extra) {
166
+ const advice = TRIM_ADVICE[code] || '这次没改成,主文件一字未动;换个选择器或稍后重试'
167
+ return `错误:${code} — ${advice}${extra ? `(${extra})` : ''}${error ? `(宿主原话:${renderClip(error, 200)})` : ''}`
168
+ }
169
+
170
+ /** 归档件的展示名:宿主可能给文件名、相对路径或绝对路径,统一显示成 `archive/<文件名>`。 */
171
+ function archiveLabel(p) {
172
+ const s = String(p ?? '')
173
+ if (!s) return 'archive/(未命名)'
174
+ const tail = s.split(/[\\/]/).filter(Boolean).pop() || s
175
+ return `archive/${tail}`
176
+ }function renderTrimResult(args, value) {
177
+ const v = value ?? {}
178
+ return text(v.text != null ? v.text : trimFailureText(v.code ?? 'unknown', v.error))
91
179
  }
92
180
 
93
181
  /**
94
- * 造两个工具定义。
182
+ * 造三个工具定义。
95
183
  *
96
184
  * @param {object} ctx — 插件上下文(`scanMemoryFiles` 要用它写警告日志)
97
- * @returns {{searchMemory: object, readMemory: object}}
185
+ * @returns {{searchMemory: object, readMemory: object, trimMemory: object}}
98
186
  */
99
187
  export function createMemoryTools(ctx) {
100
188
  const searchMemory = {
@@ -207,6 +295,15 @@ export function createMemoryTools(ctx) {
207
295
  result.supersededBy = v.supersededBy
208
296
  result.expired = v.expired
209
297
  }
298
+ // 2.10.1:整份体积过线时多带一行提示(渲染层决定怎么说)。
299
+ // 每一次读都算一次 stat —— 这是只读的、与内容读取同一条闸门,代价可忽略。
300
+ const bytes = await entryBytesOf(args?.name)
301
+ if (bytes != null) {
302
+ const c = classifySize({ bytes })
303
+ if (c.level === 'warn' || c.level === 'force') {
304
+ result.sizeHint = { bytes: c.bytes, kind: c.kind, level: c.level, limit: c.limit }
305
+ }
306
+ }
210
307
  }
211
308
  return result
212
309
  } catch (err) {
@@ -215,5 +312,117 @@ export function createMemoryTools(ctx) {
215
312
  },
216
313
  }
217
314
 
218
- return { searchMemory, readMemory }
315
+ /**
316
+ * 裁段归档(体积治理的模型入口,2.10.1)。
317
+ *
318
+ * **只做参数校验 + 结果翻译**:规划与写序全在 `memory/trim-archive.js`。
319
+ * 不复刻实现是有意的 —— 写序(先归档、再改主文件、失败退役)是安全边界,
320
+ * 多一个入口就多一条绕过它的路。
321
+ *
322
+ * 参数校验为什么放在这里而不是宿主 manifest:契约 §4 要求「缺两个或都给」时回一句
323
+ * 能照做的文案(说清 heading 与行区间怎么选),那是给人/模型看的话,不是 schema 能表达的。
324
+ */
325
+ const trimMemory = {
326
+ name: 'trim_memory',
327
+ description:
328
+ '把一份记忆里的一段旧内容**裁下来归档**到 archive/,主文件原位置只留一行指针 —— 不是删除,随时能捞回来。' +
329
+ '用在「这条记忆太大了、而其中某几节已经过时」的时候:拆得开就裁段,拆不开就 write_memory 压缩。' +
330
+ 'heading 与 startLine/endLine 二选一:给小节标题(不含 #),或给行区间(1-based,含两端)。' +
331
+ 'reason 必填(≥4 字),会写进归档件与主文件指针 —— 半年后要靠它看懂为什么裁。' +
332
+ '被裁段至少 1 KB、剩余正文至少 1 KB;MEMORY.md / session-log.md / 保护名单里的文件一律拒绝。',
333
+ parameters: {
334
+ type: 'object',
335
+ properties: {
336
+ file: { type: 'string', description: '记忆文件名(如 project_deploy.md)' },
337
+ heading: { type: 'string', description: '要裁的小节标题(不含 #,如「故障记录」)。与行区间二选一' },
338
+ startLine: { type: 'integer', minimum: 1, description: '行区间起点(1-based,含)。与 endLine 一起给' },
339
+ endLine: { type: 'integer', minimum: 1, description: '行区间终点(1-based,含)' },
340
+ reason: { type: 'string', description: '为什么裁(≥4 字,写进归档件与主文件指针)' },
341
+ },
342
+ required: ['file', 'reason'],
343
+ additionalProperties: false,
344
+ },
345
+ output: { schema: { type: 'object' }, render: renderTrimResult },
346
+ // 会写盘:两个裁段并行跑会互相踩 revision,串行更诚实(宿主据此排队)。
347
+ isConcurrencySafe: () => false,
348
+ async execute(args, exec) {
349
+ const name = String(args?.file ?? '').trim()
350
+ const reason = String(args?.reason ?? '').trim()
351
+ const headingRaw = args?.heading
352
+ const heading = typeof headingRaw === 'string' ? headingRaw.trim().replace(/^#+\s*/, '') : ''
353
+ const hasLines = args?.startLine != null || args?.endLine != null
354
+
355
+ if (!name) {
356
+ return {
357
+ ok: false,
358
+ code: 'invalid_name',
359
+ text: '错误:invalid_name — file 要给一个 .md 文件名(如 project_deploy.md),不能带路径',
360
+ }
361
+ }
362
+ if (reason.length < 4) {
363
+ return {
364
+ ok: false,
365
+ code: 'reason_too_short',
366
+ text: '错误:reason_too_short — reason 至少 4 个字,写清为什么裁(它会进归档件与主文件指针,半年后靠它看懂)',
367
+ }
368
+ }
369
+ if (heading && hasLines) {
370
+ return {
371
+ ok: false,
372
+ code: 'selector_conflict',
373
+ text: '错误:selector_conflict — heading 与 startLine/endLine 只能给一个:要裁整节就写 heading,要裁几行就写 startLine + endLine',
374
+ }
375
+ }
376
+ if (!heading && !hasLines) {
377
+ return {
378
+ ok: false,
379
+ code: 'selector_missing',
380
+ text: '错误:selector_missing — 没说裁哪一段:给 heading(小节标题,不含 #)或 startLine + endLine(1-based,含两端)',
381
+ }
382
+ }
383
+ let selector
384
+ if (heading) {
385
+ selector = { heading }
386
+ } else {
387
+ const startLine = Number(args?.startLine)
388
+ const endLine = Number(args?.endLine)
389
+ if (!Number.isInteger(startLine) || !Number.isInteger(endLine) || startLine < 1 || endLine < startLine) {
390
+ return {
391
+ ok: false,
392
+ code: 'selector_invalid',
393
+ text: '错误:selector_invalid — 行区间要写成 startLine ≥ 1 且 endLine ≥ startLine(1-based,含两端)',
394
+ }
395
+ }
396
+ selector = { startLine, endLine }
397
+ }
398
+
399
+ try {
400
+ const r = await trimArchive({
401
+ memoryDir: MEMORY_DIR,
402
+ name,
403
+ selector,
404
+ reason,
405
+ at: Date.now(),
406
+ ctx,
407
+ })
408
+ if (!r || r.ok !== true) {
409
+ const code = (r && r.code) || 'io_error'
410
+ // `stage` 不是 code:契约 §3 第 7 步说的「归档写成、主文件没改成」要单独讲清楚,
411
+ // 否则「失败」会被读成「旧内容没了」。
412
+ const extra = r && r.stage === 'after_archive'
413
+ ? '归档件已写成并退役进 archive/,没有丢;主文件一字未动,重试一次即可'
414
+ : null
415
+ return { ok: false, code, text: trimFailureText(code, r && r.error, extra) }
416
+ }
417
+ const out = `已裁剪 ${r.segmentBytes} 字节到 ${archiveLabel(r.archivedPath || r.archivedFile)},主文件 ${r.beforeBytes} → ${r.afterBytes} 字节`
418
+ return { ok: true, text: `${out}${r.pointer ? `\n\n主文件里留的指针:${r.pointer}` : ''}` }
419
+ } catch (err) {
420
+ // 工具**绝不抛**:一次裁段失败不该打断模型那一轮(主文件是否被改动由 trimArchive 保证,
421
+ // 它要么整段成功、要么在 after_archive 阶段留退役归档件)。
422
+ return { ok: false, code: 'io_error', text: trimFailureText('io_error', err?.message ?? String(err)) }
423
+ }
424
+ },
425
+ }
426
+
427
+ return { searchMemory, readMemory, trimMemory }
219
428
  }
@@ -195,6 +195,20 @@ const auditReportSchema = z.object({
195
195
  'field': z.string().readonly(),
196
196
  'reason': z.string().readonly(),
197
197
  }).readonly()).readonly(),
198
+ // 2.10.1 体积治理:超线条目(warn / force)与超长索引各一个数组。
199
+ // ⚠️ strict 模式会把**没声明的字段剥掉** —— 不在这里声明,面板与设置页就永远看不到
200
+ // 「哪几条超过 40 KB」,而模型面(audit_memory 工具)却看得到:两处口径会当场分叉。
201
+ // 这两项是**传输契约**,所以宁可放宽单文件行数上限(test/structure-test.mjs)也不搬走。
202
+ 'oversize': z.array(z.object({
203
+ 'file': z.string().readonly(),
204
+ 'bytes': z.number().readonly(),
205
+ 'level': z.string().readonly(),
206
+ }).readonly()).readonly().optional(),
207
+ 'indexOversize': z.array(z.object({
208
+ 'file': z.string().readonly(),
209
+ 'bytes': z.number().readonly(),
210
+ 'longLines': z.number().readonly(),
211
+ }).readonly()).readonly().optional(),
198
212
  'problems': z.number().readonly(),
199
213
  }).readonly()
200
214
 
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.9.0",
4
+ "version": "2.10.2",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "icon": "./icon.png",
@@ -43,7 +43,7 @@ metadata:
43
43
  - `name`:稳定标识(小写、连字符),改名等于换一条记忆。
44
44
  - `description`:**最重要的一行**,检索主要靠它(见第八节)。
45
45
  - `metadata.type`:四类之一(见第三节)。
46
- - 正文:随便写,但**前 1500 字以外基本不会被注入**,硬货放前面。
46
+ - 正文:随便写 —— 注入是**按 600 字一块挑最相关的那段**,尾部的内容照样可能被选中,不存在「写了等于没写」。但整份越长,读全文越贵、模型越容易失焦,所以硬货仍放前面。
47
47
 
48
48
  文件名同样参与检索打分,所以文件名要有实义词:`feedback_dangerous-ops.md` 远好于 `feedback_f1.md`。
49
49
 
@@ -87,7 +87,7 @@ metadata:
87
87
  5. **写正文**:结论在前,理由在后,风险写清楚。
88
88
  6. **更新 `updated:` 到今天就改(如果字段存在)** —— 注入时会显示「保存于 N 天前」,不改就永远显示旧日期,未来的你会误判它过期。
89
89
  7. **验证**:跑 `frontmatter-assert`(见第九节)。
90
- 8. **索引**:如果目录里有 `MEMORY.md`,给它加一行(见第七节)。
90
+ 8. **索引**:如果目录里有 `MEMORY.md`,给它加一行 —— **描述一句话就够**(见第七节)。
91
91
 
92
92
  ---
93
93
 
@@ -118,11 +118,46 @@ metadata:
118
118
 
119
119
  索引是**整份覆盖风险最高**的文件:永远用「改一行」而不是「重建全文」,否则原有内容一个字都留不下。
120
120
 
121
+ **索引里对每条记忆的描述必须短。** 一句话,够认出「这是哪一条、什么时候该点进去」就够了(经验值:一行的链接 + 描述控制在 100 字以内,整份索引控制在 24 KB 以内)。理由:
122
+
123
+ - 索引是**指针表,不是内容容器**。描述写长不会让检索更准(检索读的是记忆文件自己的 `description`,不读索引),只会让人工翻索引时更慢;
124
+ - 索引本身也在每次维护时被整份读入上下文,长一行就多占一行预算,一百多条加起来就是几万字符;
125
+ - 写长了几乎总是「把正文抄进索引」——于是同一件事有两个版本,迟早分叉。
126
+
127
+ **要写细节,写进那条记忆;索引只留钩子。** 超长时优先删掉重复的修饰、把「为什么」砍成「是什么」。
128
+
129
+ 跳过整理的文件(`session-log.md`)不是一条记忆,也不该进索引 —— 只记「流水在 `session-log.md`」这类事实时可归到别的记忆里。
130
+
121
131
  **归档** —— 不再成立但还有历史价值的记忆(被取代的决定、已完成的阶段),移进 `archive/` 子目录。
122
132
 
123
133
  - 归档的语义是「**还在,但不该进上下文**」:检索不扫子目录,所以归档 = 静默失效且可回退。
124
134
  - **别直接删**。删了就没人能回答「当时为什么那么决定」。
125
135
 
136
+ ### 超线了怎么办(体积治理)
137
+
138
+ 一条记忆会越写越长。**到线不处理 ≠ 只是占空间** —— 代价是另外三笔:读全文时整份进上下文(`read_memory` 没有分页)、自动做梦一轮要通读的条目越多越贵、条目越长裁段与压缩越容易改错地方。再加一条信号:**越长,越可能是没整理过的堆积。**
139
+
140
+ > 别被旧说法带偏:注入**不是**只读开头。普通条目按 600 字块选段,大文件里相关的那段照样会被选中 —— **体积不影响命中**。只有 `baseline` 条目(从开头读、受单条字数上限截断)和「只命中摘要、没命中正文」的那次注入,拿到的才是文件开头。
141
+
142
+ 插件有两条线(具体数值随版本变,**以插件回执 / 注入文本为准,别抄死**):提醒线(约 16 KB,写的时候回执会提示)与强制线(约 40 KB,审计会报「必须处置」)。到强制线,**必须二选一**:
143
+
144
+ 1. **裁段归档**(能拆就拆)—— 把已经终结的旧段整段挪进 `archive/`,主文件在原位置留**一行指针**。
145
+ - 归档件命名:`archive/<原名>-<片段>-<日期>.md`,frontmatter 里带 `archived_at` / `archived_reason` / `archived_from`;**缘由与日期是硬要求**,否则三个月后没人知道为什么裁。
146
+ - 主文件里的指针要带关键词(「旧版部署记录已归档到 archive/xxx-20260101.md」),否则未来检索得到那句话、却找不到细节在哪。
147
+ - 插件提供 `trim_memory` 工具时用它(它会按上面的顺序写:先归档件、再用版本守卫改主文件、失败不留半截);没有工具就手工照同一顺序做。
148
+ 2. **压缩**(拆不开就压)—— 适合成篇叙事、时间线、连贯的说理。
149
+ - 做法:结论前置 → 合并重复 → 删掉已被推翻的 → 保留仍在用的硬事实(路径、命令、约束、日期)。
150
+ - 留痕:frontmatter 加 `compressed_at` / `compressed_reason` / `compressed_from_bytes`(工具会自动补;手工改就自己补)。**没有留痕的压缩等于没人知道这条被动过。**
151
+ - 压缩**不许丢**仍在生效的事实;拿不准的宁可留着。
152
+
153
+ **三条铁律**:
154
+
155
+ - **`MEMORY.md` 与 `session-log.md` 不在治理范围**:索引只能瘦身(拆行、删冗余),流水有它自己的线。
156
+ - **保护名单里的文件一个字都不动**(设置页维护,自动整理与体积治理都跳过它们)。
157
+ - **永不删除**:裁下来的是归档、压缩掉的是字数,两者都可回退;`rm` 不是这里的选项。
158
+
159
+ 自动整理(做梦)跑完会给出「裁剪与压缩」的声明:逐条写明哪个文件、做了哪种处置、缘由、体积变化。看到自己没印象的裁剪,先翻这份声明,别当成「我写过」。
160
+
126
161
  ---
127
162
 
128
163
  ## 八、检索怎么工作(以及为什么你搜不到)