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.
- package/README.en.md +59 -11
- package/README.md +59 -11
- package/lib/assembly/tools-mount.js +5 -2
- package/lib/autodream/audit.js +58 -1
- package/lib/autodream/prompt.js +30 -2
- package/lib/autodream/report.js +136 -1
- package/lib/autodream/size-tools.js +222 -0
- package/lib/autodream/tools.js +28 -2
- package/lib/autodream/util.js +3 -1
- package/lib/client.js +401 -66
- package/lib/memory/scan.js +6 -1
- package/lib/memory/size-policy.js +218 -0
- package/lib/memory/trim-archive.js +251 -0
- package/lib/memory/trim-plan.js +331 -0
- package/lib/memory/write-protocol.js +31 -2
- package/lib/shared/codes.js +13 -0
- package/lib/shared/limits.js +47 -0
- package/lib/tools/memory-tools.js +215 -6
- package/lib/typert.host.js +14 -0
- package/package.json +1 -1
- package/skills/sage-mem/SKILL.md +37 -2
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 体积治理的**判据**(纯函数,零 IO)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么要有这个模块:2.10.1 之前「一条记忆算不算太大」这件事根本没有判据 ——
|
|
5
|
+
* 阈值只以数字的形式散在文案与提示词里(`read_memory` 的一句提醒、自动整理的
|
|
6
|
+
* 「超过 40 KB 必须处置」、审计的统计),谁也没法把它们对同一份内容问一遍。
|
|
7
|
+
* 结果是同一份 20 KB 的记忆,工具面说「没超」,审计说「超了」—— 这种自相矛盾
|
|
8
|
+
* 最容易被读成「插件坏了」。判据收到这里,别处只调用,不再自己写数字。
|
|
9
|
+
*
|
|
10
|
+
* ⚠️ 这是**软线**判定,不是写闸门。512 KB 那条硬线在 `memory/write-protocol.js`
|
|
11
|
+
* 的 `prepareWrite`(超了直接拒写、行为不可变);本模块只回答「该不该提醒」
|
|
12
|
+
* 「该不该强制处置」「该不该只报不改」。两条线混在一起,体积治理就会变成
|
|
13
|
+
* 「写到一半突然被拒」,那是最坏的一种改动。
|
|
14
|
+
*
|
|
15
|
+
* 依赖方向(`test/structure-test.mjs` 盯着):只认 `shared/limits.js`(数字)与
|
|
16
|
+
* `./naming.js`(名字归属)。名字与保留名判据必须与写路径同一份 —— 各写一套的话,
|
|
17
|
+
* 「这个文件算不算一条记忆」在两处会给出相反答案,而其中一处是写闸门。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { INDEX_DOC_BYTES, INDEX_LINE_CHARS, TRIM_FORCE_BYTES, TRIM_WARN_BYTES } from '../shared/limits.js'
|
|
21
|
+
// 索引 / 流水的规范名只在 `shared/md-name.js` 有(`duplication-test.mjs` 会红掉
|
|
22
|
+
// 别处的字面量副本)。这里按 key 认名字,而不是自己拼字符串。
|
|
23
|
+
import { INDEX_FILE_KEY, SESSION_LOG_KEY } from '../shared/md-name.js'
|
|
24
|
+
import { isNonEntry, isReserved, nameKey } from './naming.js'
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 索引的**提醒线** = 整份上限的一半。
|
|
28
|
+
*
|
|
29
|
+
* 为什么不直接等到 24 KB 才说:索引被整份顶到上限时,摘掉条目这件事已经要手工
|
|
30
|
+
* 做很久了;12 KB 时提醒一句,代价只是几行文字。
|
|
31
|
+
*/
|
|
32
|
+
const INDEX_WARN_BYTES = INDEX_DOC_BYTES / 2
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 名字 → 类别:`'entry' | 'index' | 'sessionLog' | 'reserved'`。
|
|
36
|
+
*
|
|
37
|
+
* 顺序**不能换**:索引与流水也都在保护名单里(`RESERVED_FILES` 是它们的超集),
|
|
38
|
+
* 先认它们、剩下的保留名才是「使用者自己声明的保护名单」—— 两者的处置完全不同
|
|
39
|
+
* (一个是「该瘦身」,一个是「一个字都不许动」)。
|
|
40
|
+
*
|
|
41
|
+
* 比较走 `nameKey()`:NTFS / APFS 不区分大小写,`MEMORY.MD` 与 `MEMORY.md` 是同一个
|
|
42
|
+
* 文件(这条洞在设置页上真实地漏过一次整份索引覆盖)。
|
|
43
|
+
*
|
|
44
|
+
* 非字符串 / 空值一律当普通条目:本函数是**分类器**不是校验器,名字合法性归
|
|
45
|
+
* `shared/md-name.js` 的 `isPlainMdName`(写路径用 `safeName` 复核)。
|
|
46
|
+
*/
|
|
47
|
+
export function kindOfName(name) {
|
|
48
|
+
const key = nameKey(name)
|
|
49
|
+
if (key === INDEX_FILE_KEY) return 'index'
|
|
50
|
+
if (key === SESSION_LOG_KEY) return 'sessionLog'
|
|
51
|
+
// 兜底:将来若再加一个「非条目名」,一律按「不许动」处理(skip 档),而不是当普通条目
|
|
52
|
+
// 去算体积。今天这段走不到 —— 两个非条目名上面已经认掉了,而且 `NON_ENTRY_FILES` 是
|
|
53
|
+
// `RESERVED_FILES` 的子集、下面那条迟早也会接住它。写出来是把「非条目永不进体积账」
|
|
54
|
+
// 变成一条**显式规则**,不靠子集关系的巧合;少报一条永远比多报一条安全。
|
|
55
|
+
if (isNonEntry(name)) return 'reserved'
|
|
56
|
+
if (isReserved(name)) return 'reserved'
|
|
57
|
+
return 'entry'
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 单条正文判定。`bytes` 用 **UTF-8 字节**,不是字符数。
|
|
62
|
+
*
|
|
63
|
+
* 为什么按字节:中文一个字 3 字节,同样「8000 字」的中文正文比英文大三倍;
|
|
64
|
+
* 按字符数判会让一条实际上三倍大的记忆看起来「还没到线」。
|
|
65
|
+
*
|
|
66
|
+
* @param {{bytes:number, kind?:string}} p
|
|
67
|
+
* @returns {{kind:string, bytes:number, level:'ok'|'warn'|'force'|'skip',
|
|
68
|
+
* limit:number, advice:'trim-or-compress'|'index-slim'|'none'}}
|
|
69
|
+
* `limit` = 当前这一档对照的那条线(`ok` 档给「下一条会响的线」,好让调用方说出
|
|
70
|
+
* 「离哪条线还有多远」;`skip` 档没有线,给 0)。
|
|
71
|
+
* `advice` 是给调用方做分支的**稳定枚举**;人读的那句话在 `adviceText()` 里
|
|
72
|
+
* —— 回执与审计共用同一句,别各写一套。
|
|
73
|
+
*/
|
|
74
|
+
export function classifySize({ bytes, kind = 'entry' } = {}) {
|
|
75
|
+
// 非法输入一律当 0:这个函数会被审计与工具回执直接叫,抛异常等于「体积治理把
|
|
76
|
+
// 整趟整理弄挂了」。负数 / NaN / 字符串数字都在这里归一。
|
|
77
|
+
const n = Number.isFinite(bytes) && bytes > 0 ? Math.floor(bytes) : 0
|
|
78
|
+
const k = kind === 'index' || kind === 'sessionLog' || kind === 'reserved' ? kind : 'entry'
|
|
79
|
+
|
|
80
|
+
// 流水与保护名单**永不计入问题**(契约 §1):流水只会越来越长(它是追加型流水),
|
|
81
|
+
// 保护名单里的条目是使用者明说「别动」的东西 —— 拿体积去催它们,等于把「保护」
|
|
82
|
+
// 变成一句空话。
|
|
83
|
+
if (k === 'sessionLog' || k === 'reserved') {
|
|
84
|
+
return { kind: k, bytes: n, level: 'skip', limit: 0, advice: 'none' }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (k === 'index') {
|
|
88
|
+
// 索引这里只看**整份体积**;单行超长要读正文才知道,归 `indexFindings()`。
|
|
89
|
+
// 两处判据共用同一条线(`INDEX_DOC_BYTES`),别在别处再写一遍数字。
|
|
90
|
+
if (n >= INDEX_DOC_BYTES) return { kind: k, bytes: n, level: 'force', limit: INDEX_DOC_BYTES, advice: 'index-slim' }
|
|
91
|
+
if (n >= INDEX_WARN_BYTES) return { kind: k, bytes: n, level: 'warn', limit: INDEX_WARN_BYTES, advice: 'index-slim' }
|
|
92
|
+
return { kind: k, bytes: n, level: 'ok', limit: INDEX_WARN_BYTES, advice: 'none' }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (n >= TRIM_FORCE_BYTES) return { kind: k, bytes: n, level: 'force', limit: TRIM_FORCE_BYTES, advice: 'trim-or-compress' }
|
|
96
|
+
if (n >= TRIM_WARN_BYTES) return { kind: k, bytes: n, level: 'warn', limit: TRIM_WARN_BYTES, advice: 'trim-or-compress' }
|
|
97
|
+
return { kind: k, bytes: n, level: 'ok', limit: TRIM_WARN_BYTES, advice: 'none' }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 围栏标记:行首(去缩进后)三个及以上的反引号或波浪号。返回标记字符,不是围栏返回空串。 */
|
|
101
|
+
function fenceMark(trimmed) {
|
|
102
|
+
const m = /^(`{3,}|~{3,})/.exec(trimmed)
|
|
103
|
+
return m ? m[1][0] : ''
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* `MEMORY.md` 专用:逐行长度 + 整份体积。
|
|
108
|
+
*
|
|
109
|
+
* 三件容易做错的事,都在这里一次性判掉:
|
|
110
|
+
* 1. **CRLF**:先按 `\r?\n` 切,行尾那个 `\r` 不进字符数。不这么做的话,Windows
|
|
111
|
+
* 上写出来的每一行都凭空多一个字 —— 240 字的边界整体偏一位,而「恰好 240 字」
|
|
112
|
+
* 与「241 字」正是这里唯一要区分的两件事。
|
|
113
|
+
* 2. **代码围栏**:索引里偶尔会嵌一段示例(``` 开头到 ``` 结尾)。围栏里的长行是
|
|
114
|
+
* 内容,不是「索引塞了正文」,一律不算 —— 反过来,把围栏里的行算进去会让一份
|
|
115
|
+
* 完全正常的索引年年报「有超长行」。
|
|
116
|
+
* 3. **空行**:不算长行(长度 0),也不需要为它单开分支。
|
|
117
|
+
*
|
|
118
|
+
* `chars` 按 **Unicode 码点**数(`[...line].length`),不是 UTF-16 单元:一个补充平面
|
|
119
|
+
* 字符(emoji / 生僻字)在 `.length` 里算 2,用户看到的是 1 个字。
|
|
120
|
+
*
|
|
121
|
+
* @param {string} text — 索引全文(可为空 / 非法值,非法值按空串算)
|
|
122
|
+
* @returns {{bytes:number, lines:number, longLines:{line:number, chars:number}[],
|
|
123
|
+
* overDoc:boolean, level:'ok'|'warn'|'force', advice:'index-slim'|'none'}}
|
|
124
|
+
* `line` 是 **1-based** 行号,与 `text.split(/\r?\n/)` 的下标 +1 一致。
|
|
125
|
+
*/
|
|
126
|
+
export function indexFindings(text) {
|
|
127
|
+
const src = typeof text === 'string' ? text : ''
|
|
128
|
+
const bytes = Buffer.byteLength(src, 'utf8')
|
|
129
|
+
const lines = src.split(/\r?\n/)
|
|
130
|
+
const longLines = []
|
|
131
|
+
/** 当前打开的围栏标记字符(空串 = 不在围栏里)。 */
|
|
132
|
+
let fence = ''
|
|
133
|
+
for (let i = 0; i < lines.length; i++) {
|
|
134
|
+
const line = lines[i]
|
|
135
|
+
const mark = fenceMark(line.trimStart())
|
|
136
|
+
if (fence) {
|
|
137
|
+
// 围栏里的一切都不算长行;同种标记(``` 或 ~~~)再来一次就是关闭。
|
|
138
|
+
if (mark === fence) fence = ''
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
if (mark) {
|
|
142
|
+
fence = mark
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
const chars = [...line].length
|
|
146
|
+
if (chars > INDEX_LINE_CHARS) longLines.push({ line: i + 1, chars })
|
|
147
|
+
}
|
|
148
|
+
const overDoc = bytes >= INDEX_DOC_BYTES
|
|
149
|
+
// 有一行超长就与「整份超上限」同档(都是 force):这两种都说明索引里塞了不该在
|
|
150
|
+
// 索引里的东西,处置方式一模一样(瘦身),分成两档只会让审计多一个没人看的数字。
|
|
151
|
+
const level = overDoc || longLines.length > 0 ? 'force' : bytes >= INDEX_WARN_BYTES ? 'warn' : 'ok'
|
|
152
|
+
return { bytes, lines: lines.length, longLines, overDoc, level, advice: level === 'ok' ? 'none' : 'index-slim' }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 去掉 `toFixed` 留下的 `.0`:`16 KB` 比 `16.0 KB` 更像人话(文案里就是「超过 40 KB」)。 */
|
|
156
|
+
function trimZero(x) {
|
|
157
|
+
return String(Number(x.toFixed(1)))
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* 人读的体积:`'26.4 KB'` / `'812 B'`。
|
|
162
|
+
*
|
|
163
|
+
* 规则有意写得简单,因为界面、工具回执、审计文案都靠它:
|
|
164
|
+
* - `< 1 KB` → 整数字节 + `B`;
|
|
165
|
+
* - `< 1 MB` → KB,保留一位小数,`.0` 去掉(`16 KB`,不是 `16.0 KB`);
|
|
166
|
+
* - 再往上 → MB,同一套进位。
|
|
167
|
+
*
|
|
168
|
+
* 非法输入(undefined / NaN / 负数)一律当 0:**文案里绝不能出现 `undefined KB`**
|
|
169
|
+
* —— 这类字符串一旦落到回执上,用户第一反应是「插件报错了」。
|
|
170
|
+
*/
|
|
171
|
+
export function sizeText(bytes) {
|
|
172
|
+
const n = Number.isFinite(bytes) && bytes > 0 ? bytes : 0
|
|
173
|
+
if (n < 1024) return `${Math.round(n)} B`
|
|
174
|
+
const kb = n / 1024
|
|
175
|
+
if (kb < 1024) return `${trimZero(kb)} KB`
|
|
176
|
+
return `${trimZero(n / (1024 * 1024))} MB`
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* 一行中文建议(回执与审计共用同一句,别各写一套)。
|
|
181
|
+
*
|
|
182
|
+
* 为什么不是 `classifySize().advice` 那句枚举:枚举是给代码做分支的(`'index-slim'`
|
|
183
|
+
* 对模型没有信息量),而模型与用户读到的是这一句 —— 它必须同时说清**多少、超了哪条
|
|
184
|
+
* 线、下一步怎么做**。`read_memory` 回执里那句「ℹ 这条 43.1 KB,超过 40 KB 强制线:
|
|
185
|
+
* 按 sage-mem 技能的「体积治理」处置(裁段归档 / 压缩)」就是本函数的产物。
|
|
186
|
+
*
|
|
187
|
+
* 阈值一律经 `sizeText()` 生成(`40 KB` 而不是 `40960`):一句话里混两种单位,
|
|
188
|
+
* 读的人要多做一次除法。
|
|
189
|
+
*
|
|
190
|
+
* `longLines` 是**可选**补充:索引「单行超长」光看字节数看不出来(一行 500 字的索引
|
|
191
|
+
* 可能总共才 1 KB),所以给索引写建议时把 `indexFindings().longLines`(数组或条数)
|
|
192
|
+
* 带进来 —— 否则这边会对着一条超长行说「体积正常」。普通记忆不传。
|
|
193
|
+
*/
|
|
194
|
+
export function adviceText({ bytes, kind, longLines } = {}) {
|
|
195
|
+
const v = classifySize({ bytes, kind })
|
|
196
|
+
const size = sizeText(v.bytes)
|
|
197
|
+
const long = Array.isArray(longLines)
|
|
198
|
+
? longLines.length
|
|
199
|
+
: Number.isFinite(longLines) && longLines > 0 ? Math.floor(longLines) : 0
|
|
200
|
+
|
|
201
|
+
if (v.kind === 'sessionLog') return `流水不算体积问题(${size}),跳过`
|
|
202
|
+
if (v.kind === 'reserved') return `保护名单里的文件不算体积问题(${size}),跳过`
|
|
203
|
+
|
|
204
|
+
if (v.kind === 'index') {
|
|
205
|
+
if (v.level === 'force' || long > 0) {
|
|
206
|
+
const why = []
|
|
207
|
+
if (v.bytes >= INDEX_DOC_BYTES) why.push(`超过 ${sizeText(INDEX_DOC_BYTES)} 整份上限`)
|
|
208
|
+
if (long > 0) why.push(`有 ${long} 行超过 ${INDEX_LINE_CHARS} 字`)
|
|
209
|
+
return `索引 ${size},${why.join('、')}:按 sage-mem 技能的「索引瘦身」只留一行一条指针,细节写进正文`
|
|
210
|
+
}
|
|
211
|
+
if (v.level === 'warn') return `索引 ${size},接近 ${sizeText(INDEX_DOC_BYTES)} 上限:按 sage-mem 技能的「索引瘦身」删掉过期条目,把细节留给正文`
|
|
212
|
+
return `索引 ${size},体积正常(上限 ${sizeText(INDEX_DOC_BYTES)}、单行 ${INDEX_LINE_CHARS} 字)`
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (v.level === 'force') return `这条 ${size},超过 ${sizeText(TRIM_FORCE_BYTES)} 强制线:按 sage-mem 技能的「体积治理」处置(裁段归档 / 压缩)`
|
|
216
|
+
if (v.level === 'warn') return `这条 ${size},超过 ${sizeText(TRIM_WARN_BYTES)} 提醒线:能拆成完整小节就裁段归档,拆不开就压缩`
|
|
217
|
+
return `这条 ${size},体积正常(提醒线 ${sizeText(TRIM_WARN_BYTES)}、强制线 ${sizeText(TRIM_FORCE_BYTES)})`
|
|
218
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sage-mem 记忆层 —— **裁段归档**:把一个完整小节搬进 `archive/`,主文件原位留一行指针。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独一个模块:这是全仓**唯一会改写一条活记忆正文**的写入路径(压缩走
|
|
5
|
+
* `write_memory`、整份归档走 `archive.js`,都不是它)。它的危险不在「算错段」——
|
|
6
|
+
* 那件事由纯函数 `trim-plan.js` 负责,能对着字符串验 —— 而在**写序**:
|
|
7
|
+
* 两个落点(归档件、主文件)之间没有事务,任何一步崩了都会留下「两份都有」或「两份都不对」。
|
|
8
|
+
* 拆成一个模块,才能把每一步的失败单独造出来验(撞名 / 版本变了 / 写不下去),
|
|
9
|
+
* 也才能让 `test/trim-archive-test.mjs` 直接对着这个文件说「不许换顺序」。
|
|
10
|
+
*
|
|
11
|
+
* ── 写序(**顺序即安全**,一步都不许换,见 `docs/size-governance-contract.md` §3)──────
|
|
12
|
+
* 1. `readLockState`:锁表 `unknown` 一律拒 —— 「保护状态未知」不等于「没锁」。
|
|
13
|
+
* 2. `isReserved` → 拒;`safeName` → 非条目名 / 带路径成分的名字拒。
|
|
14
|
+
* 3. `inspectMemoryEntry(full, {limitBytes:null})` → `readSnapshot`:正文与 revision **同一次读**。
|
|
15
|
+
* 4. 在这**份正文**上 `planTrim` 重算 —— 从选段到落盘中间隔着若干次 IO,用「上一次读到的
|
|
16
|
+
* 正文」算出来的段,可能已经不是盘上那一份了(TOCTOU)。
|
|
17
|
+
* 5. **先**排他写归档件(`writeFileExclusive`:撞名 EEXIST,不覆盖、不改名)。
|
|
18
|
+
* 6. **再**用 `writeSnapshot(..., { expectedRevision })` 改主文件。
|
|
19
|
+
* 7. 第 6 步失败 → 归档件**退役**成 `archive/.retired-<stamp>-<hex>.md`(移动、不删除),
|
|
20
|
+
* 回执带 `stage:'after_archive'`,错误文案里点名退役后的文件。
|
|
21
|
+
*
|
|
22
|
+
* 为什么「先归档件、后主文件」不能反过来:反过来的话,主文件改完、归档件还没落,
|
|
23
|
+
* 崩在中间就是**旧段落彻底消失**——正文被指针替换掉了,而被替换的内容没有任何副本。
|
|
24
|
+
* 现在这个顺序的最坏结果是「两份都有」:内容一个字都不丢,退役一步还会让下一次重试不撞名。
|
|
25
|
+
*
|
|
26
|
+
* 与整文件归档(`archive.js`)的**两处刻意差异**(最容易抄错的地方):
|
|
27
|
+
* - **不摘索引**:文件还在根目录、还在被检索,索引里那一行必须留着。摘掉等于告诉
|
|
28
|
+
* 体检「这条悬空」——2026-10-11 在真实库里撞过一次的那种问题。
|
|
29
|
+
* - **不写访问台账**:裁剪不改「这条记忆最近被用到过吗」这个事实(台账的消费者是
|
|
30
|
+
* 自动归档策略);顺手写一笔会让「刚剪过的文件」在策略眼里变成刚用过的。
|
|
31
|
+
*
|
|
32
|
+
* 最坏情况与防线:
|
|
33
|
+
* - 「归档件写了、主文件没改成」→ 退役件留在 `archive/` 里(隐藏名,不是活记忆)。
|
|
34
|
+
* - 「归档区被换成联接」→ `inspectDir` 在写之前挡下(否则归档件会落到记忆目录之外)。
|
|
35
|
+
* - 「中途被别人改写主文件」→ `expectedRevision` 版本守卫拒绝,一个字节不写。
|
|
36
|
+
* - 「归档件撞名」→ 直接拒,绝不覆盖:那一份可能是用户上一次裁的同名段落。
|
|
37
|
+
* - 明确**不做**的:跨进程文件锁(与 `util.js` 的边界一致)、自动清理退役件
|
|
38
|
+
* (它只是改了名字,人工可捞;自动删就违背「只移不删」这条底线)。
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { archiveDirOf } from './archive.js'
|
|
42
|
+
import { addArchiveMeta, inspectDir, inspectMemoryEntry, yamlScalar } from './scan.js'
|
|
43
|
+
import { moveFileExclusive, writeFileExclusive } from './util.js'
|
|
44
|
+
import { readLockState } from './lock.js'
|
|
45
|
+
import { readSnapshot, writeSnapshot } from './write-protocol.js'
|
|
46
|
+
import { isNonEntry, isReserved, nameKey, safeName } from './naming.js'
|
|
47
|
+
import { planTrim } from './trim-plan.js'
|
|
48
|
+
import { stampCompact, stampHuman } from '../shared/stamp.js'
|
|
49
|
+
import { mkdir } from 'node:fs/promises'
|
|
50
|
+
import { join } from 'node:path'
|
|
51
|
+
import { randomUUID } from 'node:crypto'
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 失败回执拼装。`stage` 说明「停在哪一步」——排错时它比 code 更有信息量。
|
|
55
|
+
*
|
|
56
|
+
* ⚠️ 入参是**对象**而不是三个位置参数:`code` 必须在调用点写成 `code: '…'` 字面量,
|
|
57
|
+
* 因为 `test/code-registry-test.mjs` 是拿正则扫字面量来查「新码有没有登记进
|
|
58
|
+
* `lib/shared/codes.js`」的。`bail('新码', …)` 这种写法会让新码从守卫眼皮底下溜过去。
|
|
59
|
+
*/
|
|
60
|
+
const bail = (spec) => ({ ok: false, code: spec.code, error: spec.error, stage: spec.stage })
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 归档件的全文:源 frontmatter 三件(`name` / `description` / `type`,有 tags 也带上)
|
|
64
|
+
* + `archived_from` + `archived_slice`,再补 `archived_at` / `archived_reason`。
|
|
65
|
+
*
|
|
66
|
+
* 为什么用 `addArchiveMeta` 补时间与缘由而不是自己拼 YAML:那两行是**归档留痕的既有形状**
|
|
67
|
+
* (`readArchivedMeta` 读它、恢复时 `stripArchiveMeta` 剥它)。自己拼一份,字段名或引号规则
|
|
68
|
+
* 一漂移,归档件在面板上就会显示「时间未知 / 没写理由」。
|
|
69
|
+
*
|
|
70
|
+
* 值一律过 `yamlScalar`:源文件的 description 里带冒号、井号、换行的都不罕见,
|
|
71
|
+
* 裸写出去会把半个值变成另一个 YAML 键(`scan.js` 头注里有完整推演)。
|
|
72
|
+
*
|
|
73
|
+
* `eol` 单独传:归档件是**新写的文件**,但它的正文是从 CRLF 源文件里切出来的。
|
|
74
|
+
* frontmatter 用 LF 拼、正文是 CRLF,会造出一个混排文件(读得回来,但看 dif 很难受);
|
|
75
|
+
* 统一跟源文件的换行风格走,也让 `addArchiveMeta` 内部的 `eolOf` 只有一种答案。
|
|
76
|
+
*/
|
|
77
|
+
function archiveDocFor(plan, safe, at, eol = '\n') {
|
|
78
|
+
const meta = plan.sourceMeta ?? {}
|
|
79
|
+
const head = [
|
|
80
|
+
'---',
|
|
81
|
+
`name: ${yamlScalar(meta.name)}`,
|
|
82
|
+
`description: ${yamlScalar(meta.description)}`,
|
|
83
|
+
`type: ${yamlScalar(meta.type)}`,
|
|
84
|
+
]
|
|
85
|
+
const tags = Array.isArray(meta.tags) ? meta.tags : []
|
|
86
|
+
if (tags.length > 0) head.push(`tags: [${tags.map((t) => yamlScalar(t)).join(', ')}]`)
|
|
87
|
+
head.push(`archived_from: ${yamlScalar(safe)}`, `archived_slice: ${yamlScalar(plan.sliceLabel)}`, '---')
|
|
88
|
+
// frontmatter 与正文之间固定一个空行;正文以换行收尾(与源文件的常见形态一致)。
|
|
89
|
+
const doc = `${head.join(eol)}${eol}${eol}${plan.segment}${eol}`
|
|
90
|
+
return addArchiveMeta(doc, plan.reason, stampHuman(at))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 把刚写好的归档件**退役**:改成 `archive/.retired-<stamp>-<hex>.md`,**移动、不删除**。
|
|
95
|
+
*
|
|
96
|
+
* 命名模板借自 `lib/autodream/rollback.js:258-278` 的 `park()`(`<name>.retired-<stamp>-<hex>.md`)。
|
|
97
|
+
* ⚠️ 这里**不能** import rollback.js:`memory/**` 不许 import `autodream/**`
|
|
98
|
+
* (`test/structure-test.mjs:64-93` 会红)。所以模板只留这一行,出处写在这里。
|
|
99
|
+
*
|
|
100
|
+
* 为什么要退役而不是留在原名上:原名是「裁到这一段的归档件」,留在那儿会让**下一次**裁
|
|
101
|
+
* 同一段撞名(`exists`),而这一次主文件其实没改成 —— 相当于把一次失败变成永久的占位。
|
|
102
|
+
* 退役之后名字唯一,内容一个字节没丢,人工随时能从 `archive/.retired-*` 里捞回来。
|
|
103
|
+
*
|
|
104
|
+
* @returns {Promise<string|null>} 退役后的文件名;移不动(被占用 / 名字都被占了)返回 null
|
|
105
|
+
*/
|
|
106
|
+
async function retireArchiveFile(archiveDir, archivedFile, at) {
|
|
107
|
+
const stamp = stampCompact(at)
|
|
108
|
+
const retiredNameOf = () => `.retired-${stamp}-${randomUUID().replace(/-/g, '').slice(0, 6)}.md`
|
|
109
|
+
const from = join(archiveDir, archivedFile)
|
|
110
|
+
for (let i = 0; i < 3; i += 1) {
|
|
111
|
+
const candidate = retiredNameOf()
|
|
112
|
+
try {
|
|
113
|
+
// 排他移动:撞名 EEXIST → 换下一个名字。绝不 rename 覆盖(`rename` 会静默盖掉别人的文件)。
|
|
114
|
+
await moveFileExclusive(from, join(archiveDir, candidate))
|
|
115
|
+
return candidate
|
|
116
|
+
} catch (err) {
|
|
117
|
+
if (err?.code === 'EEXIST') continue
|
|
118
|
+
return null
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 裁段归档:把 `selector` 选中的那一段搬进 `archive/`,主文件原位留一行指针。
|
|
126
|
+
*
|
|
127
|
+
* @param {object} p
|
|
128
|
+
* @param {string} p.memoryDir — 记忆根目录(调用方给,不许拿模块常量)
|
|
129
|
+
* @param {string} p.name — 记忆文件名(必须是根目录里的一条条目)
|
|
130
|
+
* @param {{heading?:string}|{startLine?:number,endLine?:number}} p.selector — 二选一
|
|
131
|
+
* @param {string} p.reason — 裁剪缘由(必填,≥4 字)
|
|
132
|
+
* @param {number} [p.at] — 毫秒时间戳(归档名日期 / 留痕时间;不传用 `Date.now()`)
|
|
133
|
+
* @param {object} [p.ctx] — 插件上下文(只用于写协议里的告警日志)
|
|
134
|
+
* @returns {Promise<
|
|
135
|
+
* {ok:true, archivedFile:string, archivedPath:string, beforeBytes:number, afterBytes:number,
|
|
136
|
+
* segmentBytes:number, pointer:string, title:string, range:{start:number,end:number}} |
|
|
137
|
+
* {ok:false, code:string, error:string, stage:string}>}
|
|
138
|
+
*/
|
|
139
|
+
export async function trimArchive(p = {}) {
|
|
140
|
+
const memoryDir = typeof p?.memoryDir === 'string' ? p.memoryDir : ''
|
|
141
|
+
const rawName = String(p?.name ?? '')
|
|
142
|
+
const at = Number.isFinite(p?.at) ? p.at : Date.now()
|
|
143
|
+
if (memoryDir === '') {
|
|
144
|
+
return bail({ code: 'invalid', error: 'trimArchive 需要 memoryDir(显式传入;拿模块级常量会写到真实记忆目录)', stage: 'params' })
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── 1. 锁表:未知 = 拒绝 ──────────────────────────────────────────────────
|
|
148
|
+
const lock = await readLockState(memoryDir)
|
|
149
|
+
if (lock.status === 'unknown') {
|
|
150
|
+
return bail({
|
|
151
|
+
code: 'lock_unknown',
|
|
152
|
+
error: `sage-mem: 锁表不可读(${lock.reason}),无法确认这条记忆是否受保护,拒绝裁剪:${nameKey(rawName)}`,
|
|
153
|
+
stage: 'lock',
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
// 锁定 = 「这条别动」。裁剪改的是正文,比整份归档更该尊重这把锁。
|
|
157
|
+
if (lock.locked.has(nameKey(rawName))) {
|
|
158
|
+
return bail({ code: 'locked', error: `sage-mem: ${nameKey(rawName)} 已锁定(受保护),拒绝裁剪 —— 要裁先在界面里解锁`, stage: 'lock' })
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── 2. 名字:保留名 / 非条目名 / 带路径成分 ────────────────────────────────
|
|
162
|
+
if (isReserved(rawName)) {
|
|
163
|
+
return bail({ code: 'reserved', error: `sage-mem: 保留名不参与体积治理,拒绝裁剪:${nameKey(rawName)}`, stage: 'name' })
|
|
164
|
+
}
|
|
165
|
+
const safe = safeName(rawName)
|
|
166
|
+
if (!safe) {
|
|
167
|
+
// 契约点名的两个码分开报:`non_entry` = 索引 / 流水(不参与治理),
|
|
168
|
+
// `invalid_name` = 名字本身不合法(带路径 / 不是 .md)——后者是「你写错名字了」。
|
|
169
|
+
if (isNonEntry(rawName)) {
|
|
170
|
+
return bail({ code: 'non_entry', error: `sage-mem: 索引 / 流水不是一条记忆,不参与体积治理:${nameKey(rawName)}`, stage: 'name' })
|
|
171
|
+
}
|
|
172
|
+
return bail({ code: 'invalid_name', error: `sage-mem: 文件名不合法(单段 .md、无路径分隔符、无冒号):${String(rawName)}`, stage: 'name' })
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── 3. 读:形状闸门 + 正文与 revision 同一次读取 ────────────────────────────
|
|
176
|
+
const full = join(memoryDir, safe)
|
|
177
|
+
const gate = await inspectMemoryEntry(full, { limitBytes: null })
|
|
178
|
+
if (!gate.ok) {
|
|
179
|
+
// 体积不在这里判(`limitBytes:null`):超线正是要裁它的原因。
|
|
180
|
+
const code = gate.code === 'missing' ? 'not_found' : gate.code
|
|
181
|
+
return bail({ code, error: `sage-mem: 读不到 ${safe}:${gate.error}`, stage: 'read' })
|
|
182
|
+
}
|
|
183
|
+
let snap
|
|
184
|
+
try {
|
|
185
|
+
snap = await readSnapshot(full, gate, safe)
|
|
186
|
+
} catch (err) {
|
|
187
|
+
const code = err?.code === 'ENOENT' ? 'not_found' : err?.code === 'changed' ? 'changed' : 'io_error'
|
|
188
|
+
return bail({ code, error: `sage-mem: 读 ${safe} 失败(${err?.code ?? err}):${err?.message ?? err}`, stage: 'read' })
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── 4. 在**这份正文**上重算(防 TOCTOU)──────────────────────────────────
|
|
192
|
+
const plan = planTrim({ content: snap.content, name: safe, selector: p?.selector, reason: p?.reason, at })
|
|
193
|
+
if (!plan.ok) return bail({ code: plan.code, error: plan.error, stage: 'plan' })
|
|
194
|
+
|
|
195
|
+
// ── 5. 先落归档件:排他、撞名即拒 ─────────────────────────────────────────
|
|
196
|
+
const archiveDir = archiveDirOf(memoryDir)
|
|
197
|
+
const dirGate = await inspectDir(archiveDir)
|
|
198
|
+
if (!dirGate.ok && dirGate.code !== 'missing') {
|
|
199
|
+
// 归档区还不存在是正常状态(下面 mkdir 建);是联接 / 不是目录就绝不能往里写。
|
|
200
|
+
return bail({ code: dirGate.code, error: `sage-mem: 归档区不可用:${dirGate.error}`, stage: 'archive' })
|
|
201
|
+
}
|
|
202
|
+
const archivedFile = plan.archiveName
|
|
203
|
+
// 归档件的换行风格跟源文件走(`scan.js` 的 `eolOf` 同一套判据):新文件里混排最难读。
|
|
204
|
+
const eol = snap.content.includes('\r\n') ? '\r\n' : '\n'
|
|
205
|
+
try {
|
|
206
|
+
await mkdir(archiveDir, { recursive: true })
|
|
207
|
+
await writeFileExclusive(join(archiveDir, archivedFile), archiveDocFor(plan, safe, at, eol))
|
|
208
|
+
} catch (err) {
|
|
209
|
+
if (err?.code === 'EEXIST') {
|
|
210
|
+
return bail({
|
|
211
|
+
code: 'exists',
|
|
212
|
+
error: `sage-mem: archive/${archivedFile} 已经存在 —— 不覆盖、也不改名(那一份可能是你上次裁的同名段落),请换一个标题或用 startLine/endLine 另选一段`,
|
|
213
|
+
stage: 'archive',
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
return bail({ code: 'io_error', error: `sage-mem: 写归档件 archive/${archivedFile} 失败(${err?.code ?? err}):${err?.message ?? err}`, stage: 'archive' })
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── 6. 再改主文件:revision 守卫,版本变了就一个字节都不写 ──────────────────
|
|
220
|
+
const written = await writeSnapshot(full, plan.remaining, gate, { expectedRevision: snap.revision }, { ctx: p?.ctx, name: safe, kind: 'trim' })
|
|
221
|
+
if (!written.ok) {
|
|
222
|
+
/**
|
|
223
|
+
* ── 7. 失败收尾:归档件退役(不删)────────────────────────────────────
|
|
224
|
+
* `stale_revision` 原样透出(它是写协议定义的码,别在这里改名 —— 改名等于让上层
|
|
225
|
+
* 分不清「别人改过」和「写坏了」,那两条的重试动作完全不同)。
|
|
226
|
+
*/
|
|
227
|
+
const retired = await retireArchiveFile(archiveDir, archivedFile, at)
|
|
228
|
+
const where = retired
|
|
229
|
+
? `归档件已退役为 archive/${retired}`
|
|
230
|
+
: `归档件仍在 archive/${archivedFile}(退役没成功,文件名被占用)`
|
|
231
|
+
return bail({
|
|
232
|
+
code: written.code,
|
|
233
|
+
error: `sage-mem: 裁段没改成主文件(${written.code}:${written.error})—— ${where},内容一个字节都没丢;主文件保持原样,请刷新后重试`,
|
|
234
|
+
stage: 'after_archive',
|
|
235
|
+
})
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
ok: true,
|
|
240
|
+
archivedFile,
|
|
241
|
+
// 相对路径是给回执用的便捷字段(工具面的文案要写「归档到 archive/…」);
|
|
242
|
+
// `archivedFile` 本身保持**裸文件名**,与 `planTrim` 的 `archiveName`、`archive.js` 的 `file` 同一种形状。
|
|
243
|
+
archivedPath: `archive/${archivedFile}`,
|
|
244
|
+
beforeBytes: Buffer.byteLength(snap.content, 'utf8'),
|
|
245
|
+
afterBytes: Buffer.byteLength(plan.remaining, 'utf8'),
|
|
246
|
+
segmentBytes: Buffer.byteLength(plan.segment, 'utf8'),
|
|
247
|
+
pointer: plan.pointer,
|
|
248
|
+
title: plan.title,
|
|
249
|
+
range: plan.range,
|
|
250
|
+
}
|
|
251
|
+
}
|