sage-mem 0.9.16 → 0.9.18

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,546 @@
1
+ /**
2
+ * sage-mem 记忆层 —— 任务检查点(`<状态根>/tasks/<taskId>/WORKING.md`)。
3
+ *
4
+ * 为什么要有这一层:宿主什么时候压缩上下文,插件说了不算 —— 所以**不能承诺
5
+ * 「压缩前一定保存」**。能做的只有两件事:任务走到里程碑时把状态写下来,
6
+ * 压缩之后再用一个短句柄把它读回来。检查点与长期记忆刻意分开:长期记忆答
7
+ * 「我记住的事」,检查点答「这件任务走到哪了」,两者的生命周期、检索方式、
8
+ * 失效条件都不一样,混在一起会让两边都不可信。
9
+ *
10
+ * 这个模块存在的理由**不是格式检查**:固定标题 + 最小长度只能验证格式,而一份格式完美、
11
+ * 内容错误的交接材料仍然是错的。所以 `parseCheckpoint` 除了报缺节,还报
12
+ * `claimsDoneWithoutEvidence` —— 「已完成」里没有任何证据标记的条目。判据的取向是刻意
13
+ * 严格的:**「测试过了」不是证据,「结果:12 passed, 0 failed」才是**。没有证据,
14
+ * 不许把「准备做」写成「已经完成」。
15
+ *
16
+ * 并发:本模块**不实现锁**。同一个 taskId 的并发写由调用方串行(进程内用 `util.js` 的
17
+ * `createWriteQueue`,跨进程本模块明确不管)—— 本模块只负责把每一份写原子化。
18
+ */
19
+
20
+ import { mkdir, readFile } from 'node:fs/promises'
21
+ import { dirname, join } from 'node:path'
22
+ import { STATE_ROOT } from './config.js'
23
+ import { writeFileAtomic } from './util.js'
24
+
25
+ /** 检查点都放在状态根的这个子目录下,一个任务一个目录。 */
26
+ export const TASKS_DIR_NAME = 'tasks'
27
+
28
+ /** 检查点文件名。固定不变,短句柄里要引用它。 */
29
+ export const CHECKPOINT_FILE_NAME = 'WORKING.md'
30
+
31
+ /**
32
+ * 单份检查点的体积上限(字节)。
33
+ *
34
+ * 与记忆的 512 KB 上限分开:检查点是**要反复注入**的东西,一份 64 KB 的交接材料
35
+ * 等于把上下文又用掉一大块,那正好抵消了压缩腾出来的空间。到 64 KB 说明写成了流水账,
36
+ * 该拆成多份(或把稳定结论移进长期记忆)。
37
+ */
38
+ export const CHECKPOINT_MAX_BYTES = 64 * 1024
39
+
40
+ /** 必需的节标题(顺序即模板顺序)。 */
41
+ export const CHECKPOINT_SECTIONS = Object.freeze([
42
+ '目标与约束',
43
+ '已完成与证据',
44
+ '未完成',
45
+ '关键决定',
46
+ '记忆引用',
47
+ '待核实',
48
+ ])
49
+
50
+ /**
51
+ * 缺字段的占位文本。
52
+ *
53
+ * 为什么用占位符而不是空行:空行在 markdown 里与排版空白没有区别,「这里少东西」这件事
54
+ * 在渲染结果里看不见;而且 `parseCheckpoint` 需要能区分「有标题」与「有内容」——
55
+ * 占位符是「有标题、没内容」在文本里的可判定形态。
56
+ */
57
+ const PLACEHOLDER = '(待补)'
58
+
59
+ /**
60
+ * 任务 id 允许的字符集:单段、`[A-Za-z0-9._-]`、1..64 字符。
61
+ *
62
+ * **刻意不含** `/`、`\`、`:`:三个都是路径语义的元字符(`:` 在 NTFS 上是交替数据流的分隔符)。
63
+ * 判定走纯字符类、**不走 `path.basename`** —— `\` 在 Windows 是分隔符、在 Linux 只是普通字符,
64
+ * 用平台 API 判会得出「同一份输入在两个平台上合法性相反」的结论(本仓库刚被 Linux CI 抓过
65
+ * 同类缺陷)。这里的判据在两个平台上逐字一致。
66
+ */
67
+ const TASK_ID_RE = /^[A-Za-z0-9._-]{1,64}$/
68
+
69
+ /**
70
+ * Windows 保留设备名。在这个平台上 `tasks/CON` 这类目录**建不出来**(报错或行为未定义),
71
+ * 而 Linux 上完全正常 —— 同一份状态目录同步过去就会「在那边能写、在这边写不了」。
72
+ */
73
+ const WINDOWS_RESERVED = new Set([
74
+ 'con', 'prn', 'aux', 'nul',
75
+ ...Array.from({ length: 9 }, (_, i) => `com${i + 1}`),
76
+ ...Array.from({ length: 9 }, (_, i) => `lpt${i + 1}`),
77
+ ])
78
+
79
+ /**
80
+ * 任务 id 归一:合法原样返回,非法返回 null。
81
+ *
82
+ * 只收字符串,也不 trim:路径两端的空格在 Windows 上会被静默裁掉、在 Linux 上不会,
83
+ * 「帮忙归一化」本身就会造出两个平台指向不同文件的输入 —— 那正是要防的东西。
84
+ */
85
+ export function normalizeTaskId(raw) {
86
+ if (typeof raw !== 'string') return null
87
+ if (!TASK_ID_RE.test(raw)) return null
88
+ // `.` / `..` 单看是合法的一段字符,但 join 会把它们折掉:`..` 让路径跳出 tasks 目录、
89
+ // 落到状态根上(`<状态根>/WORKING.md`)。「字符合法、语义是穿越」的输入必须单独拒。
90
+ if (/^\.+$/.test(raw)) return null
91
+ // 尾随点在 Windows 上被静默吃掉(`a.` 与 `a` 是**同一个**目录项),Linux 上是两个不同目录。
92
+ // 「同一个 id 在两个平台指向不同文件夹」与本仓库刚修掉的那类平台差异同源,所以无条件拒。
93
+ if (raw.endsWith('.')) return null
94
+ // Windows 保留设备名同理(见上):那个平台上建不出来,Linux 上可以。
95
+ if (WINDOWS_RESERVED.has(raw.split('.')[0].toLowerCase())) return null
96
+ return raw
97
+ }
98
+
99
+ /** stateRoot 的归一:非空字符串才用,否则回落 config.js 的 STATE_ROOT。 */
100
+ function rootOf(stateRoot) {
101
+ return typeof stateRoot === 'string' && stateRoot !== '' ? stateRoot : STATE_ROOT
102
+ }
103
+
104
+ /**
105
+ * 检查点文件路径;taskId 非法返回 null(调用方据此在写之前就拦住,而不是等 IO 报错)。
106
+ */
107
+ export function checkpointPathFor(taskId, stateRoot) {
108
+ const id = normalizeTaskId(taskId)
109
+ if (id === null) return null
110
+ return join(rootOf(stateRoot), TASKS_DIR_NAME, id, CHECKPOINT_FILE_NAME)
111
+ }
112
+
113
+ /** 缺的字段渲染成「(待补)」而不是空行。 */
114
+ function orPlaceholder(value) {
115
+ const s = value === undefined || value === null ? '' : String(value).trim()
116
+ return s === '' ? PLACEHOLDER : s
117
+ }
118
+
119
+ /** 列表字段渲染成 bullet;空列表给一条占位 bullet(保证节里不是空的)。 */
120
+ function bulletBlock(list) {
121
+ const items = (Array.isArray(list) ? list : list === undefined || list === null ? [] : [list])
122
+ .map((x) => String(x ?? '').trim())
123
+ .filter((x) => x !== '')
124
+ if (items.length === 0) return `- ${PLACEHOLDER}`
125
+ return items.map((x) => `- ${x}`).join('\n')
126
+ }
127
+
128
+ /**
129
+ * 渲染一份检查点(模板)。字段都可以缺,缺的渲染成「(待补)」。
130
+ *
131
+ * 刻意**不做校验**:渲染是给人补写的起点,格式问题由 `parseCheckpoint` 在读的那一端报出,
132
+ * 这样「写进去」和「读回来判它」是两条独立的路径,不会互相掩盖。
133
+ *
134
+ * @param {{taskId?, project?, revision?, goal?, constraints?, done?, todo?, decisions?, refs?, toVerify?}} input
135
+ */
136
+ export function renderCheckpoint(input) {
137
+ const src = input && typeof input === 'object' ? input : {}
138
+ const taskId = orPlaceholder(src.taskId)
139
+ const revision = Number.isInteger(src.revision) && src.revision >= 0 ? src.revision : 0
140
+ const S = CHECKPOINT_SECTIONS
141
+ const lines = [
142
+ `# 任务检查点 ${taskId}`,
143
+ '',
144
+ `- 任务:${taskId}`,
145
+ `- 项目:${orPlaceholder(src.project)}`,
146
+ `- revision:${revision}`,
147
+ '',
148
+ `## ${S[0]}`,
149
+ '',
150
+ `目标:${orPlaceholder(src.goal)}`,
151
+ '',
152
+ '约束:',
153
+ bulletBlock(src.constraints),
154
+ '',
155
+ `## ${S[1]}`,
156
+ '',
157
+ bulletBlock(src.done),
158
+ '',
159
+ `## ${S[2]}`,
160
+ '',
161
+ bulletBlock(src.todo),
162
+ '',
163
+ `## ${S[3]}`,
164
+ '',
165
+ bulletBlock(src.decisions),
166
+ '',
167
+ `## ${S[4]}`,
168
+ '',
169
+ bulletBlock(src.refs),
170
+ '',
171
+ `## ${S[5]}`,
172
+ '',
173
+ bulletBlock(src.toVerify),
174
+ '',
175
+ ]
176
+ return lines.join('\n')
177
+ }
178
+
179
+ /**
180
+ * 「证据标记」数据表 —— **数据而不是 if 链**,因为面板与文档要能展示「凭什么算证据」,
181
+ * 断言也要能逐条核对它。
182
+ *
183
+ * 取证的取向:一条「已完成」条目必须**指向一个可以去核对的东西**(命令、路径、行号、
184
+ * 测试结果、提交哈希、外部链接、带单位的测量值)。所以刻意**不**把「测试过了」「已验证」
185
+ * 这类**关于测试的说法**当证据 —— 那正是这个模块要抓的谎报形态:它没有让任何人能去核对。
186
+ */
187
+ export const EVIDENCE_MARKERS = Object.freeze([
188
+ Object.freeze({
189
+ id: 'code_span',
190
+ label: '反引号包裹的命令 / 路径 / 标识符',
191
+ pattern: /`[^`\n]+`/,
192
+ }),
193
+ Object.freeze({
194
+ id: 'path_with_dir',
195
+ label: '带目录成分且带扩展名的路径',
196
+ pattern: /\b[\w-]+(?:\/[\w.-]+)+\.[A-Za-z]{1,6}\b/,
197
+ }),
198
+ Object.freeze({
199
+ id: 'file_with_ext',
200
+ label: '带扩展名的文件名(可去核对的东西)',
201
+ pattern: /[\w-]+\.(?:md|mjs|cjs|js|json|jsonl|yml|yaml|toml|ts|py|ps1|sh|txt|log)\b/i,
202
+ }),
203
+ Object.freeze({
204
+ id: 'line_ref',
205
+ label: '文件:行号(精确到行的定位)',
206
+ pattern: /(?:[\w-]+\/[\w./-]*|\b[\w-]+\.[A-Za-z0-9]+):\d+(?:-\d+)?/,
207
+ }),
208
+ Object.freeze({
209
+ id: 'test_result',
210
+ label: '测试 / 断言的具体结果(带数字、PASS/FAIL 或退出码)',
211
+ pattern:
212
+ /\b\d+\s*(?:passed|failed)\b|\b(?:PASS|FAIL)\b|\bexit\s*code\s*[::]?\s*\d|\d+\s*(?:个|项|条)?(?:断言|用例|测试)?\s*通过|退出码\s*[::]?\s*\d|结果\s*[::]\s*\d/,
213
+ }),
214
+ Object.freeze({
215
+ id: 'commit_ref',
216
+ label: 'commit / revision / sha256 后跟具体值',
217
+ pattern: /(?:\bcommit\b|\brevision\b|\bsha256\b)[\s::]*[0-9a-z]/i,
218
+ }),
219
+ Object.freeze({
220
+ id: 'hex_ref',
221
+ label: '看起来是提交哈希的十六进制串(含字母、至少 7 位)',
222
+ pattern: /\b(?=[0-9a-f]*[a-f])[0-9a-f]{7,40}\b/i,
223
+ }),
224
+ Object.freeze({
225
+ id: 'link',
226
+ label: '可核对的外部链接',
227
+ pattern: /https?:\/\/\S+/,
228
+ }),
229
+ Object.freeze({
230
+ id: 'measurement',
231
+ label: '带单位的具体测量值',
232
+ pattern: /[::]\s*\d+(?:\.\d+)?\s*(?:字节|KB|MB|GB|ms|毫秒|秒|条|项|处|个)/,
233
+ }),
234
+ ])
235
+
236
+ /** 一条条目里有没有**可以去核对**的东西。任一标记命中即算有。 */
237
+ export function hasEvidence(item) {
238
+ const s = String(item ?? '')
239
+ return EVIDENCE_MARKERS.some((m) => m.pattern.test(s))
240
+ }
241
+
242
+ /** 子标签行(`目标:` / `约束:`):它们只是排版,不是内容。 */
243
+ const SUB_LABEL_RE = /^(?:目标|约束|限制|备注)\s*[::]?\s*$/
244
+
245
+ /**
246
+ * 两个有语义的节名:解析时按**名字**取,不用数组下标 —— 顺序虽然冻结,名字更经得起改。
247
+ * 下标只在渲染模板(顺序即模板顺序)里用。
248
+ */
249
+ const DONE_SECTION = '已完成与证据'
250
+ const TODO_SECTION = '未完成'
251
+
252
+ /**
253
+ * 节内容的「实义行」:剥掉列表标记与子标签,再滤掉空行与占位符。
254
+ *
255
+ * 这是「有标题 ≠ 有内容」的落点:一个节只要还有一条实义行才算存在;只有一个标题、
256
+ * 或者标题下全是「(待补)」,都算缺。
257
+ */
258
+ function significantLines(text) {
259
+ return String(text ?? '')
260
+ .split(/\r?\n/)
261
+ .map((line) => line.trim())
262
+ .map((line) => line.replace(/^[-*+]\s+/, '').replace(/^\d+[.)]\s+/, '').trim())
263
+ .map((line) => line.replace(/^(?:目标|约束|限制|备注)\s*[::]\s*/, '').trim())
264
+ .filter((line) => line !== '' && line !== PLACEHOLDER && !SUB_LABEL_RE.test(line))
265
+ }
266
+
267
+ /**
268
+ * 把节内容切成条目:bullet(`-` / `*` / `1.`)各算一条,**不以 bullet 开头的行并入上一条**
269
+ * (同一件事的证据常常写在换行的续行里,切开会把证据判丢)。节内的小标题不算条目。
270
+ */
271
+ function itemsOf(text) {
272
+ const items = []
273
+ for (const raw of String(text ?? '').split(/\r?\n/)) {
274
+ const line = raw.trim()
275
+ if (line === '' || /^#{1,6}\s/.test(line)) continue
276
+ const bullet = line.match(/^(?:[-*+]|\d+[.)])\s+(.*)$/)
277
+ if (bullet) {
278
+ items.push(bullet[1].trim())
279
+ continue
280
+ }
281
+ if (items.length === 0) items.push(line)
282
+ else items[items.length - 1] = `${items[items.length - 1]} ${line}`.trim()
283
+ }
284
+ return items.filter((item) => item !== '' && item !== PLACEHOLDER)
285
+ }
286
+
287
+ /** 「没有剩余工作」的声明:它不是下一件事。只认这几种明确的收尾说法,不猜别的。 */
288
+ function isNoWork(item) {
289
+ const s = String(item ?? '').replace(/[\s。.!!,,;;::、]/g, '')
290
+ if (/^(?:无|没有|暂无|尚无|无剩余|无待办|无未完成|均已?完成|全部完成|none|nothing|n\/?a)$/i.test(s)) return true
291
+ return /^(?:无|没有|暂无|尚无)/.test(s) && s.length <= 8
292
+ }
293
+
294
+ /**
295
+ * 解析检查点。
296
+ *
297
+ * 三件事分开报,因为它们对应三种不同的错:
298
+ * · `missing` —— 缺节,或**只有标题没有内容**(占位符不算内容);
299
+ * · `claimsDoneWithoutEvidence` —— 声称完成却没有任何证据标记(本模块的主要理由);
300
+ * · `malformedReason` —— 连检查点的骨架都读不出来(不是文本 / 空 / 没有任务标记 /
301
+ * revision 不是非负整数)。**缺节不算坏文件**:那是不完整,不是不可读。
302
+ *
303
+ * 绝不抛:任何输入都返回同一形状的对象。
304
+ *
305
+ * @returns {{taskId: string, project: string, revision: number, sections: Object,
306
+ * missing: string[], claimsDoneWithoutEvidence: string[],
307
+ * hasNextStep: boolean, malformedReason: string}}
308
+ */
309
+ export function parseCheckpoint(text) {
310
+ const out = {
311
+ taskId: '',
312
+ project: '',
313
+ revision: 0,
314
+ sections: {},
315
+ missing: [],
316
+ claimsDoneWithoutEvidence: [],
317
+ hasNextStep: false,
318
+ malformedReason: '',
319
+ }
320
+ // 六个键先铺满:调用方不必自己判 undefined,`missing` 才是「哪几节没内容」的答案。
321
+ for (const name of CHECKPOINT_SECTIONS) out.sections[name] = ''
322
+
323
+ if (typeof text !== 'string') {
324
+ out.malformedReason = '内容不是文本'
325
+ out.missing = [...CHECKPOINT_SECTIONS]
326
+ return out
327
+ }
328
+
329
+ const lines = text.split(/\r?\n/)
330
+
331
+ // 元信息只认**第一个二级及更深标题之前**的行:一级标题是本模板的文档标题(`# 任务检查点 x`),
332
+ // 而节正文里的 `- 任务:…` 是内容、不是元信息 —— 用「二级标题」当元信息区的结束,两者都不误伤。
333
+ let metaEnd = lines.length
334
+ for (let i = 0; i < lines.length; i++) {
335
+ if (/^\s{0,3}#{2,}\s+\S/.test(lines[i])) {
336
+ metaEnd = i
337
+ break
338
+ }
339
+ }
340
+
341
+ let revisionRaw = null
342
+ for (let i = 0; i < metaEnd; i++) {
343
+ const line = lines[i]
344
+ if (out.taskId === '') {
345
+ const m = line.match(/^[-*+]?\s*任务\s*[::]\s*(.*)$/)
346
+ if (m) {
347
+ out.taskId = m[1].trim()
348
+ continue
349
+ }
350
+ }
351
+ if (out.project === '') {
352
+ const m = line.match(/^[-*+]?\s*项目\s*[::]\s*(.*)$/)
353
+ if (m) {
354
+ out.project = m[1].trim()
355
+ continue
356
+ }
357
+ }
358
+ if (revisionRaw === null) {
359
+ const m = line.match(/^[-*+]?\s*revision\s*[::]\s*(\S*)\s*$/i)
360
+ if (m) revisionRaw = m[1]
361
+ }
362
+ }
363
+ // 渲染出的占位符不是值 —— 回了占位符等于没写。
364
+ if (out.taskId === PLACEHOLDER) out.taskId = ''
365
+ if (out.project === PLACEHOLDER) out.project = ''
366
+
367
+ let revReason = ''
368
+ if (revisionRaw !== null) {
369
+ if (!/^\d+$/.test(revisionRaw)) {
370
+ revReason = `revision 不是非负整数:${revisionRaw}`
371
+ } else {
372
+ const n = Number(revisionRaw)
373
+ if (!Number.isSafeInteger(n)) revReason = `revision 超出安全整数范围:${revisionRaw}`
374
+ else out.revision = n
375
+ }
376
+ }
377
+
378
+ const bodies = new Map(CHECKPOINT_SECTIONS.map((name) => [name, []]))
379
+ let current = null
380
+ for (const line of lines) {
381
+ const heading = line.match(/^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/)
382
+ if (heading) {
383
+ const level = heading[1].length
384
+ const title = heading[2].replace(/\s*[::]\s*$/, '').trim()
385
+ if (CHECKPOINT_SECTIONS.includes(title)) {
386
+ current = title
387
+ continue
388
+ }
389
+ // 六节固定是二级标题:别的二级及更浅标题是节边界;`###` 更深的是节内小标题,
390
+ // 落到下面照样进正文(它是一个节的排版结构,不是新节)。
391
+ if (level <= 2) {
392
+ current = null
393
+ continue
394
+ }
395
+ }
396
+ if (current !== null) bodies.get(current).push(line)
397
+ }
398
+ for (const name of CHECKPOINT_SECTIONS) out.sections[name] = bodies.get(name).join('\n').trim()
399
+
400
+ out.missing = CHECKPOINT_SECTIONS.filter((name) => significantLines(out.sections[name]).length === 0)
401
+ out.claimsDoneWithoutEvidence = itemsOf(out.sections[DONE_SECTION]).filter((item) => !hasEvidence(item))
402
+ out.hasNextStep = itemsOf(out.sections[TODO_SECTION]).some((item) => !isNoWork(item))
403
+
404
+ if (out.taskId === '') out.malformedReason = '缺少任务标记(- 任务:…)'
405
+ else if (revReason !== '') out.malformedReason = revReason
406
+
407
+ return out
408
+ }
409
+
410
+ /**
411
+ * 读一份检查点。**所有失败都返回对象,绝不抛**(注入链上不许因为一次读失败炸掉)。
412
+ *
413
+ * @param {string} taskId
414
+ * @param {{stateRoot?: string}} [opts]
415
+ * @returns {Promise<{ok:true, path, bytes, text, taskId, project, revision, sections, missing,
416
+ * claimsDoneWithoutEvidence, hasNextStep, malformedReason, declaredTaskId, warnings, handle}
417
+ * | {ok:false, code:'invalid'|'missing'|'io_error'|'malformed', path?, error, revision?}>}
418
+ */
419
+ export async function readCheckpoint(taskId, opts) {
420
+ const id = normalizeTaskId(taskId)
421
+ if (id === null) return { ok: false, code: 'invalid', error: `任务 id 非法:${JSON.stringify(taskId)}` }
422
+
423
+ const path = checkpointPathFor(id, opts && typeof opts === 'object' ? opts.stateRoot : undefined)
424
+ let text
425
+ try {
426
+ text = await readFile(path, 'utf8')
427
+ } catch (err) {
428
+ if (err?.code === 'ENOENT') return { ok: false, code: 'missing', path, error: '检查点文件不存在' }
429
+ return { ok: false, code: 'io_error', path, error: String(err?.message ?? err) }
430
+ }
431
+
432
+ const parsed = parseCheckpoint(text)
433
+ if (parsed.malformedReason !== '') {
434
+ // 坏文件**不把正文交出去**:一份读不出来的东西被当成检查点用,比读不到更危险。
435
+ return { ok: false, code: 'malformed', path, error: parsed.malformedReason, revision: parsed.revision }
436
+ }
437
+
438
+ // 目录名才是我们请求的身份;文件里写的 id 只当**声明**核对,不一致要说出来而不是静默采信。
439
+ const warnings = []
440
+ if (parsed.taskId !== id) warnings.push(`文件内声明的任务 id(${parsed.taskId})与所在目录(${id})不一致`)
441
+
442
+ return {
443
+ ok: true,
444
+ path,
445
+ bytes: Buffer.byteLength(text, 'utf8'),
446
+ text,
447
+ ...parsed,
448
+ taskId: id,
449
+ declaredTaskId: parsed.taskId,
450
+ warnings,
451
+ handle: checkpointHandleText({ taskId: id, revision: parsed.revision, project: parsed.project }),
452
+ }
453
+ }
454
+
455
+ /**
456
+ * 写一份检查点(原子写,覆盖旧版本)。
457
+ *
458
+ * 复用 `util.js` 的 `writeFileAtomic`:直写崩在中途会留下半截检查点 —— 而半截的交接材料
459
+ * 比没有更糟(它看起来像一份状态,实际是错的)。这里**不校验内容**:校验的落点在读/解析
460
+ * 那一端,写路径只守体积与原子性。写进去的东西至少是完整的一份,能被读出来判它坏。
461
+ *
462
+ * ⚠️ 本模块不实现锁:并发写同一个 taskId 由**调用方串行**(进程内用 createWriteQueue)。
463
+ *
464
+ * @param {string} taskId
465
+ * @param {string} text
466
+ * @param {{stateRoot?: string}} [opts]
467
+ * @returns {Promise<{ok:true, bytes:number, path:string} | {ok:false, code:'invalid'|'too_big'|'io_error', ...}>}
468
+ */
469
+ export async function writeCheckpoint(taskId, text, opts) {
470
+ const id = normalizeTaskId(taskId)
471
+ if (id === null) return { ok: false, code: 'invalid', error: `任务 id 非法:${JSON.stringify(taskId)}` }
472
+ if (typeof text !== 'string') return { ok: false, code: 'invalid', error: '检查点正文必须是字符串' }
473
+
474
+ // 按 UTF-8 字节算,不是字符数 —— 一份中文检查点的字符数只有字节数的三分之一,
475
+ // 用字符数当闸门等于把上限放大三倍。
476
+ const bytes = Buffer.byteLength(text, 'utf8')
477
+ if (bytes > CHECKPOINT_MAX_BYTES) {
478
+ return {
479
+ ok: false,
480
+ code: 'too_big',
481
+ bytes,
482
+ limit: CHECKPOINT_MAX_BYTES,
483
+ error: `检查点 ${bytes} 字节,超过上限 ${CHECKPOINT_MAX_BYTES} 字节`,
484
+ }
485
+ }
486
+
487
+ const path = checkpointPathFor(id, opts && typeof opts === 'object' ? opts.stateRoot : undefined)
488
+ try {
489
+ await mkdir(dirname(path), { recursive: true })
490
+ await writeFileAtomic(path, text)
491
+ } catch (err) {
492
+ return { ok: false, code: 'io_error', path, error: String(err?.message ?? err) }
493
+ }
494
+ return { ok: true, bytes, path }
495
+ }
496
+
497
+ /** 注入用的短句柄硬上限(字节)。它要跟正文抢上下文,不能自己长起来。 */
498
+ const HANDLE_MAX_BYTES = 160
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
+ }
513
+
514
+ /**
515
+ * 注入用的短句柄(一行,≤ 160 字节):说清「有这个检查点、revision 几、怎么读」。
516
+ *
517
+ * 预算是硬的,所以按优先级退化:**id 与 revision 不可丢**(它们才是身份),项目是可选线索
518
+ * (放不下就截短或整个丢掉),最后连「重复一次 id 的完整相对路径」放不下时(64 字符的
519
+ * 极端 id),退成不含重复路径的说法。绝不把多字节字符截半个。taskId 非法返回 ''。
520
+ *
521
+ * revision 缺省按 0(与 renderCheckpoint 的缺省一致)。
522
+ */
523
+ export function checkpointHandleText({ taskId, revision, project } = {}) {
524
+ const id = normalizeTaskId(taskId)
525
+ if (id === null) return ''
526
+ const rev = Number.isInteger(revision) && revision >= 0 ? revision : 0
527
+ const proj = String(project ?? '').replace(/\s+/g, ' ').trim()
528
+
529
+ const head = `检查点 ${id}(revision ${rev}):`
530
+ const detail = `读 tasks/${id}/WORKING.md`
531
+ 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
+ }
545
+ return line
546
+ }
@@ -62,6 +62,28 @@ export const MAX_BASELINE = envInt('SAGE_MEM_MAX_BASELINE', 8, 1, 50)
62
62
  */
63
63
  export const MAX_SESSION_BYTES = envInt('SAGE_MEM_MAX_SESSION_BYTES', 60 * 1024, 4096, 512 * 1024)
64
64
 
65
+ /**
66
+ * 状态卡预留占总预算的比例,以及它的上下夹逼区间。
67
+ *
68
+ * **为什么要把 60 KB 拆成两本账**:到顶之后旧行为是 `parts.length === 0` → 一个字都不注,
69
+ * 连「为什么没注、还能怎么查」都没有。而那个上限是本插件**自己定的**(不是宿主约束),
70
+ * 所以完全可以给它留一份固定额度。
71
+ *
72
+ * 5% 与 [1 KB, 4 KB] 的取值:状态卡单张上限 512 字节,4 KB 够发 8 张;而 4 KB 在
73
+ * 60 KB 里占 6.7%,不至于挤掉正文。**总上限数字保持不变** —— 面板滑杆、既有文档、
74
+ * 用户对「60 KB」的直觉都不用改。
75
+ */
76
+ export const STATUS_RESERVE_RATIO = 0.05
77
+ export const STATUS_RESERVE = Math.max(1024, Math.min(4096, Math.round(MAX_SESSION_BYTES * STATUS_RESERVE_RATIO)))
78
+
79
+ /**
80
+ * 正文可用额度 = 总上限 − 状态卡预留。
81
+ *
82
+ * 两本账**互不借用**:正文池满了仍然发得出卡(卡的额度本来就留着,这正是预留的意义),
83
+ * 而卡池满了不会再从正文池借 —— 借了等于把「预留」这件事作废。
84
+ */
85
+ export const CONTENT_BUDGET = MAX_SESSION_BYTES - STATUS_RESERVE
86
+
65
87
  /**
66
88
  * 注入文本里标「保存于 N 天前」的门槛(天)。超了才标;0 = 每条都标。
67
89
  *