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.
@@ -0,0 +1,331 @@
1
+ /**
2
+ * sage-mem 记忆层 —— **裁段归档的规划器**(纯函数,一个字节都不碰磁盘)。
3
+ *
4
+ * 为什么单独一个模块:「把哪一段搬走」与「怎么搬」是两件事,而这两件事的危险程度差一个量级。
5
+ * 切分逻辑长在 IO 里,就只能靠「跑一遍真实写入」来验它 —— 而这里改的是**用户记忆的正文**。
6
+ * 拆出来之后:选择器、体积下限、指针格式、归档名全部能对着字符串写断言(`test/trim-plan-test.mjs`),
7
+ * 不需要临时目录、不需要夹具文件,一次跑完几百条。
8
+ *
9
+ * 六条不变量(每条都有专属失败 code,调用方按 code 分支,不靠文案判断):
10
+ * 1. 正文必须以 frontmatter(`---` 开头、`---` 结尾)起始;`remaining` 里**仍带完整 frontmatter**
11
+ * —— 否则算出来的就不是「没有 frontmatter」而是「把一条记忆改坏了」。code: `no_frontmatter`
12
+ * 2. 行号选择器**不许**触到 frontmatter 区。code: `selector_invalid`(契约指定)
13
+ * 3. 被裁段 ≥ `TRIM_MIN_SEGMENT_BYTES`;剩余正文 ≥ `TRIM_MIN_REMAIN_BYTES`
14
+ * —— 太小没有收益,剪到只剩骨架就该走整文件归档那条路。code: `segment_too_small` / `remain_too_small`(契约指定)
15
+ * 4. 标题找不到 / 重名不猜。code: `heading_not_found` / `heading_ambiguous`(契约指定)
16
+ * 5. `archiveName` = `<原名去 .md>-<slug>-<YYYYMMDD>.md`,且必须过 `shared/md-name.js` 的
17
+ * `isPlainMdName`(归档名也是要落盘的名字,非法名会把归档件写到意想不到的地方)。code: `invalid_name`
18
+ * 6. `remaining` = 前缀 + `pointer` + 后缀,其中「前缀 + segment + 后缀 === 原文」**逐字节成立**。
19
+ * 这条就是往返断言:把 pointer 换回 segment,必须一字不差地还原原文件。
20
+ * code: `invalid`(内部不变量被破坏时的兜底,正常情况下永不出现)
21
+ *
22
+ * ⚠️ 所有 code 都登在 `lib/shared/codes.js`(2.10.1 的「裁段」一节)。新增码必须先去那里登记 ——
23
+ * `test/code-registry-test.mjs` 就是靠**字面量**扫这件事的,所以下面每个失败回执里的 `code`
24
+ * 都写成对象字面量,不许拐一层小工具函数(那正是让新码绕过登记守卫的写法)。
25
+ *
26
+ * 最坏情况与防线:
27
+ * - 最坏情况**不是**丢数据(本模块不写盘),而是「规划出一个看起来合法、实际把正文剪坏的结果」,
28
+ * 再由调用方原样写进真实记忆。防线:`remaining` 用**切片拼接**构造而不是按行重排
29
+ * (`prefix + pointer + suffix`),并在返回前把三者拼回去与原文做一次全等断言;
30
+ * 任何偏移算错都在这里被拦下,绝不给调用方一个坏结果。
31
+ * - 空行归属:段的首尾空行**不归段**(留在前后缀里),于是指针天然落在前后各一空行之间,
32
+ * 同时又不需要往正文里插任何字节 —— 「格式好看」与「逐字节还原」在这里不冲突。
33
+ * - 换行风格:段落偏移按**实际换行符长度**累加(`\r\n` 记 2),不按 `lines.join()` 重排,
34
+ * 所以 CRLF 文件裁完仍是 CRLF,混排文件也不会被悄悄归一。
35
+ *
36
+ * 纯函数的边界:`at` 不传时回落 `Date.now()`(只有这一处非确定性),
37
+ * 调用方(`trim-archive.js` / 工具层)**应当显式传**,测试一律传固定值。
38
+ */
39
+
40
+ import { isPlainMdName } from '../shared/md-name.js'
41
+ import { stampDate } from '../shared/stamp.js'
42
+ import { parseFrontmatter } from './frontmatter.js'
43
+
44
+ /** 被裁段的最小体积。太小的段搬进 archive/ 只是把垃圾换个地方放。 */
45
+ export const TRIM_MIN_SEGMENT_BYTES = 1024
46
+
47
+ /** 剩余正文的最小体积。剪到只剩骨架 = 整文件归档,那有另一条路(`archive.js`)。 */
48
+ export const TRIM_MIN_REMAIN_BYTES = 1024
49
+
50
+ /** 缘由最少几个字(去空白后数)。「整理」两个字说不清为什么要动用户的正文。 */
51
+ const MIN_REASON_CHARS = 4
52
+
53
+ /** 归档名里 slug 的长度上限(契约 §2 第 5 条)。 */
54
+ const MAX_SLUG_CHARS = 40
55
+
56
+ /** `title` 只是给人看的标签,别把整段正文塞进去。 */
57
+ const MAX_TITLE_CHARS = 80
58
+
59
+ /** slug 里不许出现的字符:Windows 文件名保留集 + 空白 + 控制字符(`isPlainMdName` 的子集)。 */
60
+ const SLUG_BAD_RE = /[\s\\/:*?"<>|\u0000-\u001f]/g
61
+
62
+ /**
63
+ * ATX 标题:`## 标题`。`#` 与标题之间**必须**有空白 —— 与 CommonMark 一致,
64
+ * 也让 `#标签` 这种正文里的井号写法不会被误当成标题。
65
+ */
66
+ const HEADING_RE = /^(#{1,6})[ \t]+(.*)$/
67
+
68
+ /**
69
+ * ⚠️ 失败回执一律写成**对象字面量**(`{ ok:false, code:'…', error }`),不许过一层
70
+ * `fail(code, error)` 之类的小工具函数:`test/code-registry-test.mjs` 是靠**字面量**
71
+ * 找 `code:` 的(正则扫 `code: '…'`),拐一道函数就等于让新码绕过「必须先写进
72
+ * `lib/shared/codes.js`」这条登记守卫 —— 那是一个不会被任何测试变红的静默漂移。
73
+ */
74
+
75
+ /** 按 code point 数(`.length` 数的是 UTF-16 单元,遇到表情符号会把一个字符算成两个)。 */
76
+ const charCount = (s) => Array.from(s).length
77
+
78
+ /** 按 code point 截断(不会把代理对劈成半个字符)。 */
79
+ const cutChars = (s, n) => Array.from(s).slice(0, n).join('')
80
+
81
+ /**
82
+ * 每行的**起始偏移**(按 `content.split(/\r?\n/)` 的行号口径,0-based 数组)。
83
+ *
84
+ * 不按 `join` 重排的原因:`split` 之后每行之间的换行符长度可能不同(混排文件),
85
+ * 重排会把它们统一成一种风格 —— 而这里要的是「除了被换掉的那一段,其余字节一个都不动」。
86
+ */
87
+ function lineOffsets(src, lines) {
88
+ const out = new Array(lines.length)
89
+ let p = 0
90
+ for (let i = 0; i < lines.length; i += 1) {
91
+ out[i] = p
92
+ p += lines[i].length
93
+ if (i < lines.length - 1) p += src.startsWith('\r\n', p) ? 2 : 1
94
+ }
95
+ return out
96
+ }
97
+
98
+ /**
99
+ * frontmatter 闭合行是第几行(1-based);没有 frontmatter 返回 0。
100
+ *
101
+ * 判据是**整行**等于 `---`,不跨行猜:`--- ===`、`---x` 这类半吊子写法不认 ——
102
+ * 这里要动的是用户正文,宁可报 `no_frontmatter` 让它走文件工具,也不要猜错边界。
103
+ * 行首 BOM 单独容忍(Windows 编辑器存出来的文件很常见),BOM 本身留在原文里不动。
104
+ */
105
+ function frontmatterEndLine(lines) {
106
+ const first = String(lines[0] ?? '').replace(/^\ufeff/, '').trimEnd()
107
+ if (first !== '---') return 0
108
+ for (let i = 1; i < lines.length; i += 1) {
109
+ if (lines[i].trimEnd() === '---') return i + 1
110
+ }
111
+ return 0
112
+ }
113
+
114
+ /** 一行的标题文字(去掉行首的 `#` 与空白);不是标题样式的行原样返回。 */
115
+ const headingTextOf = (line) => String(line ?? '').replace(/^#+[ \t]*/, '').trim()
116
+
117
+ /**
118
+ * 把段首尾的**空行**剔出去,只留真正有内容的行。
119
+ *
120
+ * 为什么剔:首尾空行属于「周围」而不属于「这一段」。留在前后缀里之后,
121
+ * `remaining` 里的指针天然前后各有一空行,而正文一个字节都不用插 ——
122
+ * 插字节会破坏「前缀 + segment + 后缀 === 原文」这条往返断言。
123
+ *
124
+ * @returns {{start:number,end:number}|null} 全是空行时返回 null
125
+ */
126
+ function trimBlankEdges(lines, start, end) {
127
+ let s = -1
128
+ let e = -1
129
+ for (let i = start - 1; i <= end - 1; i += 1) {
130
+ if (lines[i].trim() !== '') {
131
+ if (s < 0) s = i
132
+ e = i
133
+ }
134
+ }
135
+ if (s < 0) return null
136
+ return { start: s + 1, end: e + 1 }
137
+ }
138
+
139
+ /** 标题/正文 → slug:去掉空白与文件名保留字符,最长 40 个字符,空则 `section`。 */
140
+ function slugOf(text) {
141
+ const cleaned = cutChars(String(text ?? '').replace(SLUG_BAD_RE, ''), MAX_SLUG_CHARS)
142
+ return cleaned || 'section'
143
+ }
144
+
145
+ /**
146
+ * 指针那一行(**唯一定义处**):主文件里替掉旧段落的就是这一句。
147
+ *
148
+ * 单独导出而不是在 planTrim 里内联:工具层/报告要引用同一句话时,
149
+ * 抄第二份就会出现「主文件写的是一套、回执说的是另一套」。
150
+ *
151
+ * @param {string} archiveName — 归档件文件名(不含 `archive/` 前缀)
152
+ * @param {string} date — `YYYY-MM-DD`
153
+ * @param {string} reason — 已压成一行的缘由
154
+ */
155
+ export function trimPointerLine(archiveName, date, reason) {
156
+ return `> 这段旧内容已归档到 archive/${archiveName}(${date},缘由:${reason})`
157
+ }
158
+
159
+ /**
160
+ * 标题选择器:找到唯一那个标题小节,返回 1-based 闭区间的**行号范围**(未剔空行)。
161
+ *
162
+ * 收 1-6 级(契约只提二级/三级):把四级子小节排除在外只会逼人改用行号,
163
+ * 而 h1 通常覆盖全文,会被 `remain_too_small` 自然挡下 —— 用体积判据兜底比用级别判据更贴题。
164
+ */
165
+ function pickByHeading(lines, bodyFrom, wanted) {
166
+ const target = wanted.toLowerCase()
167
+ const hits = []
168
+ for (let i = bodyFrom; i < lines.length; i += 1) {
169
+ const m = lines[i].match(HEADING_RE)
170
+ if (m && m[2].trim().toLowerCase() === target) hits.push({ line: i + 1, level: m[1].length })
171
+ }
172
+ if (hits.length === 0) {
173
+ return { ok: false, code: 'heading_not_found', error: `找不到标题「${wanted}」(只认正文里的 # / ## / ### 小节标题,且不含 #)` }
174
+ }
175
+ if (hits.length > 1) {
176
+ return {
177
+ ok: false,
178
+ code: 'heading_ambiguous',
179
+ error: `标题「${wanted}」出现了 ${hits.length} 次(第 ${hits.map((h) => h.line).join('、')} 行)—— 不猜是哪一段,请改用 startLine/endLine 指定`,
180
+ }
181
+ }
182
+ const hit = hits[0]
183
+ // 小节到「下一个同级或更高级标题」的上一行为止;没有下一个则到文件末尾。
184
+ let end = lines.length
185
+ for (let i = hit.line; i < lines.length; i += 1) {
186
+ const m = lines[i].match(HEADING_RE)
187
+ if (m && m[1].length <= hit.level) {
188
+ end = i
189
+ break
190
+ }
191
+ }
192
+ return { ok: true, start: hit.line, end }
193
+ }
194
+
195
+ /**
196
+ * 把选择器翻译成行号区间。二选一(heading / startLine+endLine),都给或都不给都算 `selector_invalid`。
197
+ *
198
+ * @param {string[]} lines — `content.split(/\r?\n/)`
199
+ * @param {number} fmEnd — frontmatter 闭合行(1-based)
200
+ * @returns {{ok:true,start:number,end:number,title:string,sliceLabel:string}|{ok:false,code:string,error:string}}
201
+ */
202
+ function pickRange(lines, selector, fmEnd) {
203
+ const sel = selector && typeof selector === 'object' ? selector : null
204
+ if (!sel) {
205
+ return { ok: false, code: 'selector_invalid', error: '缺少选择器:给 heading(小节标题,不含 #)或 startLine + endLine(1-based 闭区间)' }
206
+ }
207
+ const hasHeading = typeof sel.heading === 'string' && sel.heading.trim() !== ''
208
+ const hasLines = sel.startLine !== undefined || sel.endLine !== undefined
209
+ if (hasHeading === hasLines) {
210
+ return { ok: false, code: 'selector_invalid', error: '选择器必须二选一:要么 heading,要么 startLine + endLine(不能既给又给、也不能都不给)' }
211
+ }
212
+
213
+ if (hasHeading) {
214
+ const wanted = String(sel.heading).replace(/^#+[ \t]*/, '').trim()
215
+ if (wanted === '') return { ok: false, code: 'selector_invalid', error: 'heading 是空的(给标题正文,不要带 #)' }
216
+ // 正文从闭合 --- 的下一行开始;frontmatter 里就算有像标题的行也不许当小节。
217
+ const hit = pickByHeading(lines, fmEnd, wanted)
218
+ if (!hit.ok) return hit
219
+ const trimmed = trimBlankEdges(lines, hit.start, hit.end)
220
+ if (!trimmed) return { ok: false, code: 'selector_invalid', error: `标题「${wanted}」的小节里没有正文(全是空行)` }
221
+ return { ok: true, start: trimmed.start, end: trimmed.end, title: cutChars(wanted, MAX_TITLE_CHARS), sliceLabel: cutChars(wanted, MAX_TITLE_CHARS) }
222
+ }
223
+
224
+ const s = sel.startLine
225
+ const e = sel.endLine
226
+ if (!Number.isInteger(s) || !Number.isInteger(e)) {
227
+ return { ok: false, code: 'selector_invalid', error: `startLine / endLine 必须是整数行号(1-based,收到 ${String(s)} / ${String(e)})` }
228
+ }
229
+ if (s < 1 || e < s) return { ok: false, code: 'selector_invalid', error: `行区间不成立:startLine=${s}、endLine=${e}(要求 1 ≤ startLine ≤ endLine)` }
230
+ if (e > lines.length) return { ok: false, code: 'selector_invalid', error: `endLine=${e} 超出行数 ${lines.length}(行号是 1-based,可能拿着过期游标)` }
231
+ if (s <= fmEnd) {
232
+ return { ok: false, code: 'selector_invalid', error: `startLine=${s} 落在 frontmatter 区(第 1-${fmEnd} 行)—— frontmatter 要用文件工具改,不参与裁剪` }
233
+ }
234
+ const trimmed = trimBlankEdges(lines, s, e)
235
+ if (!trimmed) return { ok: false, code: 'selector_invalid', error: `第 ${s}-${e} 行全是空行,没有可归档的正文` }
236
+ const firstText = cutChars(headingTextOf(lines[trimmed.start - 1]), MAX_TITLE_CHARS)
237
+ return { ok: true, start: trimmed.start, end: trimmed.end, title: firstText, sliceLabel: `第 ${trimmed.start}-${trimmed.end} 行` }
238
+ }
239
+
240
+ /**
241
+ * 规划一次裁段归档。**纯函数**:同样的入参(含 `at`)永远得到同样的结果。
242
+ *
243
+ * @param {object} p
244
+ * @param {string} p.content — 整份文件正文(含 frontmatter)
245
+ * @param {string} p.name — 记忆文件名(`.md`,与磁盘上的名字一致)
246
+ * @param {{heading?:string}|{startLine?:number,endLine?:number}} p.selector — 二选一的选择器
247
+ * @param {string} p.reason — 裁剪缘由(必填,≥4 字;换行会被压成空格)
248
+ * @param {number} [p.at] — 毫秒时间戳(决定归档名的日期与指针里的日期;不传用 `Date.now()`)
249
+ * @returns {{ok:true, archiveName:string, segment:string, remaining:string, pointer:string,
250
+ * title:string, range:{start:number,end:number}, sliceLabel:string, reason:string,
251
+ * sourceMeta:{name:string,description:string,type:string,tags:string[]}}
252
+ * |{ok:false, code:string, error:string}}
253
+ */
254
+ export function planTrim({ content, name, selector, reason, at } = {}) {
255
+ const src = typeof content === 'string' ? content : ''
256
+ const rawName = String(name ?? '')
257
+ // ① 名字:归档名由它派生,非法名会派生出一个非法归档名。
258
+ if (!isPlainMdName(rawName)) {
259
+ return { ok: false, code: 'invalid_name', error: `名字不是合法的 .md 文件名:${String(name)}(单段、无路径分隔符、无冒号)` }
260
+ }
261
+ // ② 缘由:必填、够长、且必须能压成一行(指针是一行,多行缘由会把它劈成两行)。
262
+ // 再截到 200 字:指针是给未来的人**一眼认路**的,不是档案全文。不加这条,一条 10 KB
263
+ // 的 reason 会把主文件里那行指针变成一整面墙 —— 而指针本来是为了让人愿意读它。
264
+ const WHY_MAX_CHARS = 200
265
+ const whyRaw = String(reason ?? '').replace(/[\r\n]+/g, ' ').trim()
266
+ const why = charCount(whyRaw) > WHY_MAX_CHARS
267
+ ? [...whyRaw].slice(0, WHY_MAX_CHARS).join('').trimEnd() + '…'
268
+ : whyRaw
269
+ if (charCount(why) < MIN_REASON_CHARS) {
270
+ return { ok: false, code: 'reason_too_short', error: `裁剪缘由必填且至少 ${MIN_REASON_CHARS} 个字(收到 ${JSON.stringify(String(reason ?? ''))})—— 档案馆要能回答「它为什么被收起来」` }
271
+ }
272
+
273
+ const lines = src.split(/\r?\n/)
274
+ // ③ frontmatter:没有它就没有「剩余正文仍带完整 frontmatter」这个保证。
275
+ const fmEnd = frontmatterEndLine(lines)
276
+ if (fmEnd === 0) {
277
+ return { ok: false, code: 'no_frontmatter', error: '这份文件没有 frontmatter(正文不是以单独的 --- 行开头、并以 --- 行闭合)—— 裁剪保证不了剩余正文仍带完整 frontmatter,拒绝执行(用文件工具改它)' }
278
+ }
279
+
280
+ // ④ 选择器 → 行号区间(契约里六条不变量的 2 与 4 都在这一步判)。
281
+ const picked = pickRange(lines, selector, fmEnd)
282
+ if (!picked.ok) return picked
283
+ const { start, end, title, sliceLabel } = picked
284
+
285
+ // ⑤ 归档名:<原名去 .md>-<slug>-<YYYYMMDD>.md
286
+ const when = Number.isFinite(at) ? at : Date.now()
287
+ const date = stampDate(when)
288
+ const archiveName = `${rawName.replace(/\.md$/i, '')}-${slugOf(title)}-${date.replace(/-/g, '')}.md`
289
+ if (!isPlainMdName(archiveName)) {
290
+ return { ok: false, code: 'invalid_name', error: `算出来的归档名不合法:${archiveName}` }
291
+ }
292
+
293
+ // ⑥ 切片。这三段拼回去必须与原文逐字节相同(不变量 6 的实现侧断言)。
294
+ const offsets = lineOffsets(src, lines)
295
+ const startOff = offsets[start - 1]
296
+ const endOff = offsets[end - 1] + lines[end - 1].length
297
+ const prefix = src.slice(0, startOff)
298
+ const segment = src.slice(startOff, endOff)
299
+ const suffix = src.slice(endOff)
300
+ if (prefix + segment + suffix !== src) {
301
+ return { ok: false, code: 'invalid', error: '内部不变量被破坏(行偏移切不出原文),拒绝给出结果' }
302
+ }
303
+ const pointer = trimPointerLine(archiveName, date, why)
304
+ const remaining = prefix + pointer + suffix
305
+
306
+ // ⑦ 体积:段太小没收益、剩余太小就该整份归档。
307
+ const segmentBytes = Buffer.byteLength(segment, 'utf8')
308
+ if (segmentBytes < TRIM_MIN_SEGMENT_BYTES) {
309
+ return { ok: false, code: 'segment_too_small', error: `被裁段只有 ${segmentBytes} 字节(下限 ${TRIM_MIN_SEGMENT_BYTES})—— 这么小的段不值得单开一个归档件,直接改正文更好` }
310
+ }
311
+ const remainingBytes = Buffer.byteLength(remaining, 'utf8')
312
+ if (remainingBytes < TRIM_MIN_REMAIN_BYTES) {
313
+ return { ok: false, code: 'remain_too_small', error: `裁完只剩 ${remainingBytes} 字节(下限 ${TRIM_MIN_REMAIN_BYTES})—— 整份搬走请用「归档这条记忆」(archive_memory)` }
314
+ }
315
+
316
+ const meta = parseFrontmatter(src)
317
+ return {
318
+ ok: true,
319
+ archiveName,
320
+ segment,
321
+ remaining,
322
+ pointer,
323
+ title,
324
+ range: { start, end },
325
+ sliceLabel,
326
+ // 归一后的缘由原样带出:落盘那一侧要写进归档件的 archived_reason,
327
+ // 两边各归一一次就会出现「主文件指针说 A、归档件说 B」。
328
+ reason: why,
329
+ sourceMeta: { name: meta.name, description: meta.description, type: meta.type, tags: meta.tags },
330
+ }
331
+ }
@@ -12,6 +12,8 @@
12
12
  * · 写:可选 `opts.create`(createIfAbsent)—— 新建撞名一律 `{ok:false, code:'already_exists'}`,
13
13
  * **绝不**走覆盖入口;
14
14
  * · 不传 `opts` 的旧调用方照旧覆盖,但会落一条 warn(静默无守卫覆盖不再允许)。
15
+ * · 2.10.1:**成功**回执可能多一个 `softLimit: {bytes, level}`(越过软线时;线在 2.10.2 起是 16 KB / 40 KB)。
16
+ * 只提醒、绝不拒写;判据在 `memory/size-policy.js`,这里不写阈值。
15
17
  *
16
18
  * 为什么单列一个模块而不是长在 `lib/index.js`:组合根有 ≤1000 行的结构守卫
17
19
  * (`test/structure-test.mjs`),而「读版本 → 比版本 → 写」这段顺序是**协议**不是组合根的接线。
@@ -25,6 +27,9 @@ import { createHash } from 'node:crypto'
25
27
  import { mkdir } from 'node:fs/promises'
26
28
  import { dirname, join } from 'node:path'
27
29
  import { readVerified } from './scan.js'
30
+ // 2.10.1:软线体积提醒。判据只有一份(`size-policy.js`),这里只负责「写成功之后
31
+ // 把等级放进回执、并落一行日志」,不自己写阈值 —— 阈值写两处,迟早两处对不上。
32
+ import { adviceText, classifySize, kindOfName } from './size-policy.js'
28
33
  import { warnLog, writeFileAtomic, writeFileExclusive } from './util.js'
29
34
 
30
35
  /** 冲突码:内容在「读快照」之后被改过。固定字符串,界面与测试都只认它。 */
@@ -66,6 +71,7 @@ const LEGACY_WARN = '没有带 expectedRevision,按旧协议直接覆盖(面
66
71
  * ④ 影子文件 / 8.3 短名(内存区专属,可选)
67
72
  * ⑤ 内容必须是字符串
68
73
  * ⑥ 体积上限
74
+ * ⑥.5 软线体积提醒(2.10.1,只提醒不拒写)
69
75
  * ⑦ 磁盘探针(链接 / 硬链接拒绝;`allowMissing` 决定「不存在」算不算合法)
70
76
  *
71
77
  * 失败一律返回 `{ok:false, code, file, error}` —— **不抛异常**(调用方按 ok 判定)。
@@ -84,8 +90,11 @@ const LEGACY_WARN = '没有带 expectedRevision,按旧协议直接覆盖(面
84
90
  * @param {string} spec.dir 目标目录
85
91
  * @param {(full:string, opts:object)=>Promise<object>} spec.inspectEntry
86
92
  * @param {boolean} [spec.allowMissing=true] 磁盘探针是否允许「不存在」
87
- * @returns {Promise<{ok:true, safe:string, target:string, gate:object}
93
+ * @param {object} [spec.ctx] 插件上下文(只用于软线提醒的 `warnLog`;不传则落 console.warn)
94
+ * @returns {Promise<{ok:true, safe:string, target:string, gate:object,
95
+ * softLimit?:{bytes:number, level:'warn'|'force'}}
88
96
  * | {ok:false, code:string, file?:string, error:string}>}
97
+ * `softLimit` 只在**成功**回执里出现,且只在越过提醒线时出现(拒绝路径一个字段都不加)。
89
98
  */
90
99
  export async function prepareWrite(spec) {
91
100
  const {
@@ -136,6 +145,25 @@ export async function prepareWrite(spec) {
136
145
  return { ok: false, code: 'too_big', file: safe, error: `sage-mem: content too large (${bytes} bytes > ${limitLabel || limitBytes})` }
137
146
  }
138
147
 
148
+ /**
149
+ * ⑥.5 软线(2.10.1 引入;2.10.2 起是 16 KB / 40 KB):过了硬闸门**不等于没话说**。这两条提醒线只提醒、
150
+ * 绝不拒写 —— 「你这条有点大」与「不许写」是两件事,混在一起就等于让治理变成丢数据。
151
+ *
152
+ * ⚠️ 字段为什么同时要塞进回执**又**落一行日志:`writeFile` / `writeRaw` 拿到的是
153
+ * `pre.target` / `pre.gate` / `pre.safe`,`pre.softLimit` **目前会被组合根丢掉**
154
+ * (组合根不归本模块改)。所以日志是这条提醒今天唯一一定看得见的出口;
155
+ * 回执字段留给后续把它透到工具面 / 面板的那一片。
156
+ *
157
+ * ⚠️ 日志放在 ⑦ 之后:⑦ 先把「链接 / 硬链接」这类拒绝挡掉。**没写成的文件不该收到
158
+ * 「体积提醒」**—— 那会让人以为已经写进去了、只是有点大。
159
+ * ⚠️ 拒绝路径一律不带这个字段:`{ok:false}` 的形状是契约,多一个未声明的 key 会被
160
+ * 更严的消费者(strict schema)挑出来。
161
+ */
162
+ const verdict = classifySize({ bytes, kind: kindOfName(safe) })
163
+ const softLimit = verdict.level === 'warn' || verdict.level === 'force'
164
+ ? { bytes, level: verdict.level }
165
+ : null
166
+
139
167
  // ⑦ 磁盘探针:目标已存在时若是链接 / 硬链接,一律拒绝 ——
140
168
  // 「改这条记忆」不该变成「把这个名字指向别处」。体积上面刚查过,这里不判。
141
169
  const gate = await inspectEntry(target, { limitBytes: null })
@@ -152,7 +180,8 @@ export async function prepareWrite(spec) {
152
180
  }
153
181
  }
154
182
 
155
- return { ok: true, safe, target, gate }
183
+ if (softLimit) warnLog(spec?.ctx, `${safe} 体积提醒:${adviceText({ bytes, kind: verdict.kind })}`)
184
+ return { ok: true, safe, target, gate, ...(softLimit ? { softLimit } : {}) }
156
185
  }
157
186
 
158
187
  /**
@@ -57,6 +57,19 @@ export const CODES = Object.freeze({
57
57
  lock_unknown: 'lock_unknown',
58
58
  lock_table_unknown: 'lock_table_unknown',
59
59
  unverified_overwrite: 'unverified_overwrite',
60
+ // ── 裁段(2.10.1 体积治理)──
61
+ // 「裁一段下来归档」是一条独立的写路径(trim-archive),它的失败码不能借用整文件归档那套:
62
+ // 归档失败后主文件还没动,与「归档成功、改主文件失败」是两种完全不同的现场。
63
+ non_entry: 'non_entry',
64
+ no_frontmatter: 'no_frontmatter',
65
+ selector_missing: 'selector_missing',
66
+ selector_conflict: 'selector_conflict',
67
+ selector_invalid: 'selector_invalid',
68
+ heading_not_found: 'heading_not_found',
69
+ heading_ambiguous: 'heading_ambiguous',
70
+ segment_too_small: 'segment_too_small',
71
+ remain_too_small: 'remain_too_small',
72
+ reason_too_short: 'reason_too_short',
60
73
  // ── 其它 ──
61
74
  memory_unavailable: 'memory_unavailable',
62
75
  flood: 'flood',
@@ -15,3 +15,50 @@ export const MAX_FILE_BYTES = 512 * 1024
15
15
  * `session-log.md` 是追加型流水,天然比单条记忆大得多,沿用小上限 = 用户永远存不回去。
16
16
  */
17
17
  export const MAX_RAW_BYTES = 4 * 1024 * 1024
18
+
19
+ /**
20
+ * 体积治理的四条线(2.10.1)。
21
+ *
22
+ * 为什么也放这个文件:`duplication-test.mjs` 的规矩是「同一个数只许一处定义,别处只
23
+ * import」,而这四条会被四片同时引用(判据 / 裁剪 / 审计 / 工具回执)。挨着 512 KB
24
+ * 那条摆,谁要改都只看一个文件。
25
+ *
26
+ * ⚠️ 与上面两条的关系:那是**硬闸门**(超了就拒绝写,行为不可变),这四条是**软线**
27
+ * (超了只提醒、只记一笔、审计里算一条问题)。两者绝不能合并 —— 硬闸门动一下就是
28
+ * 数据行为变更,软线调一下只影响文案。
29
+ */
30
+
31
+ /**
32
+ * **为什么是 16 KB / 40 KB(2.10.2 抬线,含一处依据更正)**
33
+ *
34
+ * 2.10.1 定线时的说法是「注入每条只读前 1500 字,所以大文件的后段对未来的自己静默失效」。
35
+ * 那句话只在两种情况下成立:① `baseline` 条目(不吃段落替换,仍读开头并受单条字数上限
36
+ * 截断,契约 2.12 标「尽力」);② 某次注入只命中摘要、没命中正文时,给的是文件开头。
37
+ *
38
+ * **普通条目是按 600 字块选段的**(`memory/chunks.js` → `recall/select.js` →
39
+ * `recall/compose.js`):一条 40 KB 的记忆里那段相关的内容照样会被选中注入 ——
40
+ * **体积不影响命中**。所以线要管的不是「让尾部能进上下文」,而是另外三笔:
41
+ * · 读全文的成本:`read_memory` 一次返回整份,没有分页;
42
+ * · 自动做梦的成本:一轮要通读的超线条目越多,token 越贵;
43
+ * · 编辑风险:裁段 / 压缩都是在一份长文上动手,越长越容易切错段、漏掉被推翻的旧说法。
44
+ * 再加上「一条记忆越长越可能是没整理的堆积」这个信号本身。
45
+ *
46
+ * 于是线的位置由「要不要催」决定,不由「注入覆盖到哪」决定:8 KB 时一条正常的长条目
47
+ * (带几段现状与历史)几乎每次写入都在提醒,那是噪音;16 KB 才是「长,但仍是单条」的
48
+ * 典型上限,40 KB 则基本是两三个主题叠在一起 —— 到那儿必须处置才说得过去。
49
+ */
50
+
51
+ /** 单条记忆的**提醒**线:到这个量级开始建议处置(裁段归档 / 压缩)。 */
52
+ export const TRIM_WARN_BYTES = 16 * 1024
53
+
54
+ /** 单条记忆的**强制**线:自动整理这一趟必须处置,否则审计算一条问题。 */
55
+ export const TRIM_FORCE_BYTES = 40 * 1024
56
+
57
+ /**
58
+ * 索引单行字符上限。超了基本等于「一行里塞了正文」—— 索引只该有一行一条的指针
59
+ * (`- [标题](文件名) —— 一句描述`),正文留在记忆正文里。
60
+ */
61
+ export const INDEX_LINE_CHARS = 240
62
+
63
+ /** 索引整份字节上限。到顶即 `force`;它的一半(12 KB)先给 `warn`。 */
64
+ export const INDEX_DOC_BYTES = 24 * 1024