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
package/lib/autodream/report.js
CHANGED
|
@@ -56,6 +56,15 @@ export async function writeDeclaration(payload) {
|
|
|
56
56
|
},
|
|
57
57
|
report: payload.report ?? null,
|
|
58
58
|
finalText: payload.finalText ?? '',
|
|
59
|
+
/**
|
|
60
|
+
* 体积治理(2.10.1,契约 §5.4)——**独立顶层键**。
|
|
61
|
+
*
|
|
62
|
+
* 为什么必须单开一个键,而不是并进 `changes` / `archive.archived`:
|
|
63
|
+
* 那两处是**动作记录**——`capFactsForManifest` 会按 64 KB 去裁它们的明细,回滚会把
|
|
64
|
+
* `archive.archived` 当作「这一趟被移走的文件」逐条搬回来。裁剪不是整条归档
|
|
65
|
+
* (文件还在顶层、还在检索面里),混进去会让回滚把同一个文件当两件事处理。
|
|
66
|
+
*/
|
|
67
|
+
trim: buildTrim(payload),
|
|
59
68
|
// 1.0.1:输出撞上单次上限这件事要进机读记录 —— 残篇在界面上和「话说一半」长得一模一样。
|
|
60
69
|
continuations: Number(payload.continuations) || 0,
|
|
61
70
|
// 1.9.2:零正文重试是另一本账 —— 「接着写过」与「根本没写出来」不能混成一个数字。
|
|
@@ -73,7 +82,55 @@ export async function writeDeclaration(payload) {
|
|
|
73
82
|
}
|
|
74
83
|
|
|
75
84
|
/**
|
|
76
|
-
*
|
|
85
|
+
* 从「这一趟的变更账」+「运行前后的审计」推出 manifest 的 `trim` 段(契约 §5.4)。
|
|
86
|
+
*
|
|
87
|
+
* 三个字段的口径,都写在能被人复核的地方:
|
|
88
|
+
* · `requested` = 运行**前**超过 40 KB 强制线的条数 —— 这是政策要求的量,不是模型报告的量;
|
|
89
|
+
* · `deferred` = 跑完再点一遍超线条数,**没下降**就是有该处置的没处置(计划 §6 的核对项)。
|
|
90
|
+
* 运行后审计没做(中途失败)时按「没下降」算:不确定就报出来,别报一个好看的数字;
|
|
91
|
+
* · `skipped` 两来源:超线但本趟没动的条目(deferred)、索引超长(protected ——
|
|
92
|
+
* `MEMORY.md` 在保护名单里,本版只报不改)。
|
|
93
|
+
*/
|
|
94
|
+
function buildTrim(payload) {
|
|
95
|
+
const changes = Array.isArray(payload?.changes) ? payload.changes : []
|
|
96
|
+
const applied = []
|
|
97
|
+
for (const c of changes) {
|
|
98
|
+
if (!c || typeof c.file !== 'string') continue
|
|
99
|
+
// `trim` 来自 archive_segment;`compress` 来自 write_memory 的压缩留痕(宿主按 ≥20% 缩水自动补)。
|
|
100
|
+
const op = c.op === 'trim' ? 'trim' : c.compressed === true ? 'compress' : ''
|
|
101
|
+
if (!op) continue
|
|
102
|
+
const item = {
|
|
103
|
+
file: c.file,
|
|
104
|
+
op,
|
|
105
|
+
reason: String(c.reason ?? ''),
|
|
106
|
+
beforeBytes: Number(c.before?.bytes) || 0,
|
|
107
|
+
afterBytes: Number(c.after?.bytes) || 0,
|
|
108
|
+
}
|
|
109
|
+
if (op === 'trim' && typeof c.archivedFile === 'string' && c.archivedFile) item.archivedFile = c.archivedFile
|
|
110
|
+
applied.push(item)
|
|
111
|
+
}
|
|
112
|
+
const forceOf = (audit) => (Array.isArray(audit?.oversize) ? audit.oversize.filter((o) => o.level === 'force') : [])
|
|
113
|
+
const beforeForced = forceOf(payload?.auditBefore)
|
|
114
|
+
const after = payload?.auditAfter ?? null
|
|
115
|
+
const afterForced = forceOf(after)
|
|
116
|
+
const appliedFiles = new Set(applied.map((a) => a.file))
|
|
117
|
+
const skipped = []
|
|
118
|
+
for (const o of beforeForced) {
|
|
119
|
+
if (appliedFiles.has(o.file)) continue
|
|
120
|
+
skipped.push({ file: o.file, bytes: Number(o.bytes) || 0, why: 'deferred' })
|
|
121
|
+
}
|
|
122
|
+
for (const o of Array.isArray(payload?.auditBefore?.indexOversize) ? payload.auditBefore.indexOversize : []) {
|
|
123
|
+
skipped.push({ file: o.file, bytes: Number(o.bytes) || 0, why: 'protected' })
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
requested: beforeForced.length,
|
|
127
|
+
applied,
|
|
128
|
+
skipped,
|
|
129
|
+
deferred: beforeForced.length > 0 && (!after || afterForced.length >= beforeForced.length),
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** 从一份 manifest 里收拢「被删掉的事实」,来源有两处:
|
|
77
134
|
* - `changes[]`:模型调用 `write_memory`(改写)与 `archive_memory`(整条退役)时宿主算的 diff;
|
|
78
135
|
* - `archive.archived[]`:自动归档策略收走的文件(模型没碰过,但事实同样从活动记忆里消失)。
|
|
79
136
|
*
|
|
@@ -227,6 +284,57 @@ function capFactsForManifest(manifest) {
|
|
|
227
284
|
}
|
|
228
285
|
}
|
|
229
286
|
|
|
287
|
+
/** 一行的竖线与换行会把 Markdown 表格撕坏 —— 缘由来自模型,必须先压平。 */
|
|
288
|
+
const flatCell = (s) => String(s ?? '').replace(/\|/g, '/').replace(/\s*\n\s*/g, ' ')
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* 渲染「裁剪与压缩」一节(契约 §5.4)。
|
|
292
|
+
*
|
|
293
|
+
* 逐条给出 `文件 / 操作 / 缘由 / 体积变化`:这一节存在的理由与「被删掉的事实」是同一个 ——
|
|
294
|
+
* **审计看不见体积这条线上的动作**。一条记忆被裁掉一半正文,断链 / 格式 / 索引全是绿的,
|
|
295
|
+
* 只有这里会写「谁把哪一段挪走了、为什么」。
|
|
296
|
+
*
|
|
297
|
+
* 没有裁剪/压缩、也没有待处置条目时**整节不出现**:一份什么都没做的声明不该多一节空话。
|
|
298
|
+
*/
|
|
299
|
+
function renderTrim(lines, trim, H) {
|
|
300
|
+
if (!trim) return
|
|
301
|
+
const applied = Array.isArray(trim.applied) ? trim.applied : []
|
|
302
|
+
const skipped = Array.isArray(trim.skipped) ? trim.skipped : []
|
|
303
|
+
if (!applied.length && !skipped.length && !trim.deferred) return
|
|
304
|
+
lines.push(`${H}# 裁剪与压缩`, '')
|
|
305
|
+
if (trim.deferred) {
|
|
306
|
+
lines.push(
|
|
307
|
+
'> ⚠ 这一趟开始时超强制线的条目数**没有下降**:有该处置的没处置。' +
|
|
308
|
+
'超线不是风格问题,它会让这条记忆挤掉别人的注入预算。',
|
|
309
|
+
'',
|
|
310
|
+
)
|
|
311
|
+
}
|
|
312
|
+
if (applied.length) {
|
|
313
|
+
lines.push('| 文件 | 操作 | 缘由 | 体积变化 |', '|---|---|---|---|')
|
|
314
|
+
for (const a of applied) {
|
|
315
|
+
const op = a.op === 'trim' ? '裁段归档' : '就地压缩'
|
|
316
|
+
const delta = formatDelta({ bytes: a.beforeBytes }, { bytes: a.afterBytes })
|
|
317
|
+
const archived = a.archivedFile ? `(旧段进 archive/${a.archivedFile})` : ''
|
|
318
|
+
lines.push(`| \`${a.file}\` | ${op} | ${flatCell(a.reason)} | ${delta}${archived} |`)
|
|
319
|
+
}
|
|
320
|
+
lines.push('')
|
|
321
|
+
} else {
|
|
322
|
+
lines.push('(本趟没有裁段或压缩动作)', '')
|
|
323
|
+
}
|
|
324
|
+
const protectedOnes = skipped.filter((s) => s.why === 'protected')
|
|
325
|
+
if (protectedOnes.length) {
|
|
326
|
+
// 「只报不改」必须写明白:不写的话,下一个看报告的人会去动索引。
|
|
327
|
+
lines.push(`- 只报不改(保护名单里的特殊文件):${protectedOnes.map((s) => `\`${s.file}\``).join('、')}`, '')
|
|
328
|
+
}
|
|
329
|
+
const deferredOnes = skipped.filter((s) => s.why !== 'protected')
|
|
330
|
+
if (deferredOnes.length) {
|
|
331
|
+
lines.push(
|
|
332
|
+
`- 超线但本趟没处置:${deferredOnes.map((s) => `\`${s.file}\`(${(s.bytes / 1024).toFixed(1)}KB)`).join('、')}`,
|
|
333
|
+
'',
|
|
334
|
+
)
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
230
338
|
/** 渲染人读声明(报告里内嵌,所以标题层级可以从外面压)。 */
|
|
231
339
|
export function renderDeclaration(m, headingLevel = 1) { const H = '#'.repeat(Math.max(1, Math.min(6, headingLevel)))
|
|
232
340
|
const lines = [
|
|
@@ -285,6 +393,9 @@ export function renderDeclaration(m, headingLevel = 1) { const H = '#'.repeat(M
|
|
|
285
393
|
// 「删了什么」紧跟在改动表之后:读完「改了哪几个文件」自然要问「丢了哪些事实」。
|
|
286
394
|
// 必须放在自动归档候选**之前** —— 那一节是「将要发生的清单」,混在一起会读成同一件事。
|
|
287
395
|
renderRemovedFacts(lines, collectFactGroups(m), H)
|
|
396
|
+
// 体积治理的那一节:紧跟「被删掉的事实」——两者都在回答「这一趟让什么消失了」,
|
|
397
|
+
// 区别只是事实是被删了、还是被裁进 archive/ 了(可捞回,但不在检索面里了)。
|
|
398
|
+
renderTrim(lines, m.trim, H)
|
|
288
399
|
if (m.archive && m.archive.mode !== 'off' && (m.archive.candidates.length || m.archive.degraded)) {
|
|
289
400
|
lines.push(`${H}# 自动归档候选(策略:${m.archive.mode})`, '')
|
|
290
401
|
// 降级必须**明说**:用户设了 auto 却什么都没动,原因不写在报告里就等于静默失灵。
|
|
@@ -364,6 +475,26 @@ export async function readDeclaration(runId) {
|
|
|
364
475
|
|
|
365
476
|
// ────────────────────────────── 报告 ──────────────────────────────
|
|
366
477
|
|
|
478
|
+
/**
|
|
479
|
+
* 报告里 `## 裁剪` 的几行(契约 §5.4)。
|
|
480
|
+
*
|
|
481
|
+
* 这里**不复述**逐条明细:同一份报告里内嵌着整份整理声明,明细在「裁剪与压缩」一节。
|
|
482
|
+
* 报告顶部的这一块只回答一个数:**超线的条数有没有降**。降了就是这趟没白跑。
|
|
483
|
+
*/
|
|
484
|
+
function trimReportLines(auditBefore, auditAfter) {
|
|
485
|
+
const forced = (audit) => (Array.isArray(audit?.oversize) ? audit.oversize.filter((o) => o.level === 'force').length : 0)
|
|
486
|
+
const before = forced(auditBefore)
|
|
487
|
+
const afterText = auditAfter ? `${forced(auditAfter)} 条` : '(未做运行后审计)'
|
|
488
|
+
const lines = [`- 超过 40 KB(必须处置):${before} → ${afterText}`]
|
|
489
|
+
const idx = Array.isArray(auditBefore?.indexOversize) ? auditBefore.indexOversize.length : 0
|
|
490
|
+
if (idx) lines.push(`- 索引超长(只报不改,MEMORY.md 不在整理范围内):${idx} 份`)
|
|
491
|
+
if (before > 0 && auditAfter && forced(auditAfter) >= before) {
|
|
492
|
+
lines.push('- ⚠ 超线条数没有下降:有该处置的没处置(逐条见下面声明的「裁剪与压缩」)')
|
|
493
|
+
}
|
|
494
|
+
lines.push('- 裁段 / 压缩的逐条明细见下方整理声明的「裁剪与压缩」一节')
|
|
495
|
+
return lines
|
|
496
|
+
}
|
|
497
|
+
|
|
367
498
|
/** 写一份报告到 `memory/autodream/<runId>.md`。 */
|
|
368
499
|
export async function writeReport(payload) {
|
|
369
500
|
const dir = join(this.memoryDir, REPORT_DIR)
|
|
@@ -421,6 +552,10 @@ export async function writeReport(payload) {
|
|
|
421
552
|
formatAudit(payload.auditAfter, 20),
|
|
422
553
|
'```',
|
|
423
554
|
'',
|
|
555
|
+
'## 裁剪',
|
|
556
|
+
'',
|
|
557
|
+
...trimReportLines(payload.auditBefore, payload.auditAfter),
|
|
558
|
+
'',
|
|
424
559
|
// 声明内嵌进报告:一个文件就能回答「这趟干了什么、为什么」,不用两头翻。
|
|
425
560
|
payload.declarationMarkdown,
|
|
426
561
|
'## 过程',
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sage-mem autodream — 体积治理的**模型面薄适配层**(2.10.1)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单开一个文件,而不是写进 `tools.js`:
|
|
5
|
+
* · `tools.js` 已经 1071/1100 行,塞不下一个工具 schema + 一个 case + 压缩留痕;
|
|
6
|
+
* · 更重要的是**分层**:这里只做三件事 —— 把模型给的中文参数整理成选择器、
|
|
7
|
+
* 转调 `memory/trim-archive.js`、把结果翻成一句人话回执。
|
|
8
|
+
* **一行写盘逻辑都不许有**(写序的安全性是 `trim-archive.js` 的契约,见
|
|
9
|
+
* `docs/size-governance-contract.md` §3:先写归档件、再拿 revision 守卫改主文件、
|
|
10
|
+
* 失败把归档件退役不删)。在这里「顺手补一个 mkdir / rename」就等于把那套顺序抄了第二份。
|
|
11
|
+
*
|
|
12
|
+
* 返回值**永远是字符串、永远不抛**:工具面的惯例 —— 模型拿到错误描述才能自己改下一步,
|
|
13
|
+
* 抛异常只会让整趟自动做梦崩在半夜。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { basename } from 'node:path'
|
|
17
|
+
import { isPlainMdName } from '../shared/md-name.js'
|
|
18
|
+
import { stampHuman } from '../shared/stamp.js'
|
|
19
|
+
import { trimArchive } from '../memory/trim-archive.js'
|
|
20
|
+
import { sizeText } from '../memory/size-policy.js'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 缘由的最短长度。与 `tools.js` 的 `MIN_REASON_CHARS` 同值,但这里**故意各写一份**:
|
|
24
|
+
* 那是「写记忆」的判据,这是「裁段」的判据,将来一个放宽一个收紧都不该牵连对方。
|
|
25
|
+
*/
|
|
26
|
+
const MIN_REASON_CHARS = 4
|
|
27
|
+
|
|
28
|
+
/** 压缩留痕的三个键(重写时要先把旧值摘掉,否则同一份文件会攒出两套 `compressed_at`)。 */
|
|
29
|
+
const COMPRESSED_KEY_RE = /^compressed_(?:at|reason|from_bytes)\s*:/i
|
|
30
|
+
|
|
31
|
+
/** 压缩留痕:模型没自述缘由时的兜底缘由(与契约 §5.2 逐字一致)。 */
|
|
32
|
+
const DEFAULT_COMPRESS_REASON = '整理时压缩'
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `archive_segment` 的参数面。
|
|
36
|
+
*
|
|
37
|
+
* 与 §契约 5.1 一字不差:`{file, heading?, startLine?, endLine?, reason}`。
|
|
38
|
+
* `heading` 与行区间**二选一**(两者都给时执行器会回文案拒绝 —— 猜「以哪个为准」
|
|
39
|
+
* 会让模型以为另一种写法也生效,下一次继续这么写)。
|
|
40
|
+
*/
|
|
41
|
+
export function archiveSegmentToolSchema() {
|
|
42
|
+
return {
|
|
43
|
+
name: 'archive_segment',
|
|
44
|
+
description:
|
|
45
|
+
'把一条记忆里的**一整段**裁下来(旧内容移进 archive/,主文件原位置留一行指针)。' +
|
|
46
|
+
'用于超过 40 KB、但能拆出一个完整小节的条目 —— 拆得开就别整体压缩,旧内容进档案馆还能人工捞回。' +
|
|
47
|
+
'参数给 heading(小节标题,不含 #)或 startLine/endLine(1-based 闭区间)**二选一**,reason 必填。' +
|
|
48
|
+
'归档件命名、frontmatter、指针都由宿主写,你只需要说清「裁哪段、为什么」。',
|
|
49
|
+
parameters: {
|
|
50
|
+
type: 'object',
|
|
51
|
+
properties: {
|
|
52
|
+
file: { type: 'string', description: '记忆文件名,例如 project_example.md(只能是 memory 目录顶层的 .md)' },
|
|
53
|
+
heading: {
|
|
54
|
+
type: 'string',
|
|
55
|
+
description: '要裁的小节标题,不含 #(例如「旧方案」)。与 startLine/endLine 二选一。',
|
|
56
|
+
},
|
|
57
|
+
startLine: { type: 'number', description: '行区间起点(1-based,含)。与 endLine 成对出现。' },
|
|
58
|
+
endLine: { type: 'number', description: '行区间终点(1-based,含)。与 startLine 成对出现。' },
|
|
59
|
+
reason: {
|
|
60
|
+
type: 'string',
|
|
61
|
+
description: '必填。一句话说清这段为什么该进档案馆(至少 4 个字)。会写进指针与归档件的 archived_reason,人靠它判断裁得对不对。',
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
required: ['file', 'reason'],
|
|
65
|
+
additionalProperties: false,
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 给要落盘的内容补上压缩留痕(`compressed_at` / `compressed_reason` / `compressed_from_bytes`)。
|
|
72
|
+
*
|
|
73
|
+
* 为什么由**宿主**补而不是让模型自己写(契约 §5.2):模型不用记三个字段名,也漏不了;
|
|
74
|
+
* 而人读声明时要能在文件本身看到「这条被压过、原来多大」—— 只记在运行记录里,
|
|
75
|
+
* 半年后翻到这份文件的人不会去翻那次运行记录。
|
|
76
|
+
*
|
|
77
|
+
* 三条边界,都是踩过才写下来的:
|
|
78
|
+
* · **没有 frontmatter 就原样返回**(`MEMORY.md` 是清单、不是一条记忆,不硬给它造一个头);
|
|
79
|
+
* · 已有 `compressed_*` 时**先摘掉再写**,否则反复压缩会攒出重复键(YAML 后者覆盖前者,
|
|
80
|
+
* 阅读时两份 `compressed_at` 却同时存在,看起来像两次互相矛盾的记录);
|
|
81
|
+
* · 值一律单引号 + 转义,理由里出现 `: ` 或 ` #` 时不会被 YAML 静默吞掉。
|
|
82
|
+
*
|
|
83
|
+
* @param {string} content — 模型给的完整内容(frontmatter + 正文)
|
|
84
|
+
* @param {{reason?:string, fromBytes?:number, at?:number}} info
|
|
85
|
+
* @returns {string} 补过留痕的内容(不合法输入原样返回,绝不抛)
|
|
86
|
+
*/
|
|
87
|
+
export function withCompressionNote(content, info = {}) {
|
|
88
|
+
const text = String(content ?? '')
|
|
89
|
+
const m = text.match(/^---(\r?\n)([\s\S]*?)\r?\n---/)
|
|
90
|
+
if (!m) return text
|
|
91
|
+
const eol = m[1]
|
|
92
|
+
const reason = String(info.reason ?? '').trim()
|
|
93
|
+
const fromBytes = Math.round(Number(info.fromBytes) || 0)
|
|
94
|
+
const q = (v) => `'${String(v).replace(/'/g, "''")}'`
|
|
95
|
+
const body = m[2].split(/\r?\n/).filter((line) => !COMPRESSED_KEY_RE.test(line))
|
|
96
|
+
body.push(
|
|
97
|
+
`compressed_at: ${q(stampHuman(Number.isFinite(info.at) ? info.at : Date.now()))}`,
|
|
98
|
+
`compressed_reason: ${q(reason.length >= MIN_REASON_CHARS ? reason : DEFAULT_COMPRESS_REASON)}`,
|
|
99
|
+
`compressed_from_bytes: ${fromBytes}`,
|
|
100
|
+
)
|
|
101
|
+
return `---${eol}${body.join(eol)}${eol}---${text.slice(m[0].length)}`
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 失败码 → 一句「下一步怎么做」(只说一条能立刻执行的处置,不让模型自己猜)。 */
|
|
105
|
+
const CODE_HINT = Object.freeze({
|
|
106
|
+
selector_invalid: '选择器不合法:行号不许触到文件头的 frontmatter,标题也不能为空。',
|
|
107
|
+
heading_not_found: '小节标题没找到:先 read_memory 核一遍标题原文(不含 #),或改用 startLine/endLine。',
|
|
108
|
+
heading_ambiguous: '这个标题在文件里出现了多次:改用 startLine/endLine 指明是哪一段。',
|
|
109
|
+
segment_too_small: '这一段太小(不足 1 KB):不值得单开一个归档件,直接把整条压缩更划算。',
|
|
110
|
+
remain_too_small: '裁完主文件会不足 1 KB:换一段更小的,或整条压缩。',
|
|
111
|
+
no_frontmatter: '文件头没有 frontmatter 起始:裁段要求 frontmatter 完整(`---` 开头),先 read_memory 看一眼原文。',
|
|
112
|
+
invalid_name: '文件名不合法(只能是 memory 目录顶层的 .md 文件名)。',
|
|
113
|
+
invalid: '选择器不合法:给 heading(小节标题,不含 #),或给 startLine/endLine(1-based 闭区间)。',
|
|
114
|
+
reserved: '这条在保护名单里(不许改):跳过它,别换个名字重试。',
|
|
115
|
+
non_entry: '它不是一条记忆(索引 / 流水):体积治理不管这两类,跳过。',
|
|
116
|
+
locked: '这条被人工锁定:本轮不要动它,如实回报「被锁定挡住」。',
|
|
117
|
+
not_found: '没有这个文件:用 list_memory 确认真实文件名。',
|
|
118
|
+
exists: 'archive/ 里已有同名归档件:不要覆盖,改裁另一段,或请人工处理。',
|
|
119
|
+
stale_revision: '这份记忆在你读到之后被人改过:重新 read_memory 再决定裁哪段(一个字节都没写)。',
|
|
120
|
+
lock_unknown: '锁状态读不出来,本轮不敢动记忆文件:如实回报「被锁挡住了」。',
|
|
121
|
+
io_error: '写盘出错(磁盘 / 权限):如实回报,不要拿同一段重试。',
|
|
122
|
+
after_archive: '归档件已写成、主文件没改成功,归档件已退役保留:把这条如实报给人,不要重试。',
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
/** 模型给的名字 → memory 目录顶层的一个合法 .md 名(null = 不合法)。 */
|
|
126
|
+
function targetName(raw) {
|
|
127
|
+
const s = String(raw ?? '').trim()
|
|
128
|
+
if (!s || !isPlainMdName(s)) return null
|
|
129
|
+
const base = basename(s)
|
|
130
|
+
// 只认顶层文件名:带目录成分的(`archive/x.md` / `../x.md`)一律拒绝,理由与 tools.js 同一条。
|
|
131
|
+
if (base !== s || base.startsWith('.')) return null
|
|
132
|
+
return base
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 数值参数宽容一点:模型把 12 写成 "12" 很常见,没必要为此拒一次正确的裁剪。 */
|
|
136
|
+
function asInt(v) {
|
|
137
|
+
if (typeof v === 'number') return Number.isFinite(v) ? Math.round(v) : NaN
|
|
138
|
+
if (typeof v === 'string' && v.trim() !== '') {
|
|
139
|
+
const n = Number(v)
|
|
140
|
+
return Number.isFinite(n) ? Math.round(n) : NaN
|
|
141
|
+
}
|
|
142
|
+
return NaN
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* `heading` 与 `startLine/endLine` 二选一 → 选择器对象。
|
|
147
|
+
* @returns {{heading:string}|{startLine:number,endLine:number}|string} 字符串 = 已写好的错误回执
|
|
148
|
+
*/
|
|
149
|
+
function pickSelector(a) {
|
|
150
|
+
const heading = typeof a.heading === 'string' ? a.heading.trim().replace(/^#+\s*/, '') : ''
|
|
151
|
+
const start = asInt(a.startLine)
|
|
152
|
+
const end = asInt(a.endLine)
|
|
153
|
+
const hasLines = Number.isFinite(start) || Number.isFinite(end)
|
|
154
|
+
if (heading && hasLines) {
|
|
155
|
+
return '错误:heading 与 startLine/endLine 只能给一种 —— 给小节标题,或给行区间(1-based 闭区间),不要都给。'
|
|
156
|
+
}
|
|
157
|
+
if (!heading && !hasLines) {
|
|
158
|
+
return '错误:必须说清裁哪一段:给 heading(小节标题,不含 #),或给 startLine 与 endLine(1-based 闭区间)。'
|
|
159
|
+
}
|
|
160
|
+
if (heading) return { heading }
|
|
161
|
+
if (!Number.isFinite(start) || !Number.isFinite(end)) {
|
|
162
|
+
return '错误:行区间要同时给 startLine 与 endLine(1-based,闭区间)。'
|
|
163
|
+
}
|
|
164
|
+
return { startLine: start, endLine: end }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 执行 `archive_segment`。
|
|
169
|
+
*
|
|
170
|
+
* @param {object} args — 模型给的参数
|
|
171
|
+
* @param {{memoryDir:string, ctx?:object, apply?:boolean,
|
|
172
|
+
* record?:(op:string, info:object)=>void}} deps
|
|
173
|
+
* `record` 由 `tools.js` 注入,内部即 `changeRecord` —— 本模块不直接碰变更账。
|
|
174
|
+
* @returns {Promise<string>} 回执(成功或错误,永不抛)
|
|
175
|
+
*/
|
|
176
|
+
export async function runArchiveSegment(args, deps = {}) {
|
|
177
|
+
try {
|
|
178
|
+
const { memoryDir, ctx, apply, record } = deps
|
|
179
|
+
if (!apply) {
|
|
180
|
+
return '错误:当前是「只出报告」模式,没有裁剪权限。请把超线条目与建议裁哪一段写进你的最终回复。'
|
|
181
|
+
}
|
|
182
|
+
const a = args && typeof args === 'object' ? args : {}
|
|
183
|
+
const name = targetName(a.file)
|
|
184
|
+
if (!name) return `错误:文件名不合法(只能是 memory 目录顶层的 .md 文件名):${String(a.file)}`
|
|
185
|
+
const reason = typeof a.reason === 'string' ? a.reason.trim() : ''
|
|
186
|
+
if (reason.length < MIN_REASON_CHARS) {
|
|
187
|
+
return '错误:archive_segment 必须给 reason —— 一句话说明这段为什么该进档案馆(至少 4 个字)。'
|
|
188
|
+
}
|
|
189
|
+
const selector = pickSelector(a)
|
|
190
|
+
// 字符串 = 已经写好的错误文案(二选一被违反),直接回给模型。
|
|
191
|
+
if (typeof selector === 'string') return selector
|
|
192
|
+
|
|
193
|
+
const res = await trimArchive({ memoryDir, name, selector, reason, at: Date.now(), ctx })
|
|
194
|
+
if (!res || res.ok !== true) {
|
|
195
|
+
const code = String(res?.code ?? 'unknown')
|
|
196
|
+
const hint = CODE_HINT[code] ?? '按 sage-mem 技能的「体积治理」换一条路处置(例如用 write_memory 就地压缩)。'
|
|
197
|
+
const detail = typeof res?.error === 'string' && res.error.trim() ? `:${res.error.trim()}` : ''
|
|
198
|
+
return `错误:${code} — ${hint}${detail}`
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* 变更记录**只记「主文件被改了」这一件事**。
|
|
202
|
+
*
|
|
203
|
+
* 归档件不是被删掉的记忆(它还在 archive/ 里、可人工捞回),把两条都记进 changes 会让
|
|
204
|
+
* 声明里同一件事出现两行;而回滚是按**文件**聚合的,主文件这一条就足以把它写回运行前那一版。
|
|
205
|
+
*/
|
|
206
|
+
record?.('trim', {
|
|
207
|
+
file: name,
|
|
208
|
+
reason,
|
|
209
|
+
before: { exist: true, bytes: res.beforeBytes, sha256: null },
|
|
210
|
+
after: { exist: true, bytes: res.afterBytes, sha256: null },
|
|
211
|
+
extra: { archivedFile: basename(String(res.archivedFile ?? '')), segmentBytes: res.segmentBytes },
|
|
212
|
+
})
|
|
213
|
+
const archived = basename(String(res.archivedFile ?? '')) || '(归档件)'
|
|
214
|
+
return (
|
|
215
|
+
`已裁剪 ${name}:${sizeText(res.segmentBytes)} 移进 archive/${archived},` +
|
|
216
|
+
`主文件 ${sizeText(res.beforeBytes)} → ${sizeText(res.afterBytes)},原位置已留一行指针。`
|
|
217
|
+
)
|
|
218
|
+
} catch (err) {
|
|
219
|
+
// 兜到底:这一层的返回值是工具面契约(字符串),抛出去会让整趟整理崩掉。
|
|
220
|
+
return `错误:archive_segment 执行失败 — ${err?.message ?? String(err)}`
|
|
221
|
+
}
|
|
222
|
+
}
|
package/lib/autodream/tools.js
CHANGED
|
@@ -38,6 +38,8 @@ import { buildConflict, listConflicts, readConflict, sameConflict, writeConflict
|
|
|
38
38
|
import { memoryPathOf } from '../memory/paths.js'
|
|
39
39
|
import { decideWriteIntent } from '../memory/write-gate.js'
|
|
40
40
|
import { inspectMemoryEntry, readVerified, reservedIdentitySet } from '../memory/scan.js'
|
|
41
|
+
// 2.10.1 体积治理:模型面的 `archive_segment` 与「压缩留痕」都在 size-tools.js(本文件已顶到行数上限)。
|
|
42
|
+
import { archiveSegmentToolSchema, runArchiveSegment, withCompressionNote } from './size-tools.js'
|
|
41
43
|
|
|
42
44
|
/** 单次 read_memory 返回的正文上限(字符)。超过就截断并明确告诉模型。 */
|
|
43
45
|
const READ_LIMIT = 20000
|
|
@@ -417,6 +419,8 @@ export function autodreamToolSchemas(opts) {
|
|
|
417
419
|
},
|
|
418
420
|
},
|
|
419
421
|
)
|
|
422
|
+
// 体积治理(2.10.1):只在改写模式注册 —— 「只出报告」那一趟不该有裁剪的手。
|
|
423
|
+
tools.push(archiveSegmentToolSchema())
|
|
420
424
|
}
|
|
421
425
|
return tools
|
|
422
426
|
}
|
|
@@ -921,9 +925,20 @@ export function createToolRunner(opts) {
|
|
|
921
925
|
// 改写前的正文要**在写之前**读到:声明里「哪几条事实没了」是拿它跟新内容逐行比出来的。
|
|
922
926
|
// 读不到就是 null,声明会如实标出来 —— 宁可不报,也不报一份假的对照。
|
|
923
927
|
const beforeText = before.exist ? await readTextForFacts(full) : null
|
|
928
|
+
/**
|
|
929
|
+
* 体积治理(2.10.1,契约 §5.2):整条缩水 ≥20% 时,宿主**在落盘前**往内容里补压缩留痕
|
|
930
|
+
* (`compressed_at` / `compressed_reason` / `compressed_from_bytes`)。
|
|
931
|
+
*
|
|
932
|
+
* 判据按**模型给的那份内容**算,不按磁盘上的 after:补留痕本身会加一百来字节,
|
|
933
|
+
* 拿增强后的体积去比 0.8 等于让留痕自己把判据推过线(越写越不像压缩)。
|
|
934
|
+
*/
|
|
935
|
+
const shrunk = before.exist && bytes <= before.bytes * 0.8
|
|
936
|
+
const content = shrunk
|
|
937
|
+
? withCompressionNote(a.content, { reason, fromBytes: before.bytes, at: Date.now() })
|
|
938
|
+
: a.content
|
|
924
939
|
// 走宿主写入(有 fs 服务时):原子性由宿主保证,**并且吃到写入门**(见 writeMemoryText)。
|
|
925
940
|
// 内容闸门上面已按字节判过,这里只关心「写没写进去、写成了什么版本」。
|
|
926
|
-
const outcome = await writeMemoryText(full,
|
|
941
|
+
const outcome = await writeMemoryText(full, content)
|
|
927
942
|
const after = await fileFingerprint(full)
|
|
928
943
|
const op =
|
|
929
944
|
// 宿主给了 operation(create / update)就直接用 —— 它比「看写之前存不存在」更权威:
|
|
@@ -948,6 +963,8 @@ export function createToolRunner(opts) {
|
|
|
948
963
|
{
|
|
949
964
|
via: outcome ? 'fs' : 'atomic',
|
|
950
965
|
version: outcome?.version ?? null,
|
|
966
|
+
// 压缩留痕是否由宿主补上(声明与报告据此把这条记成「压缩」而不是普通改写)。
|
|
967
|
+
...(shrunk ? { compressed: true, compressedFromBytes: before.bytes } : {}),
|
|
951
968
|
// 「删了什么」必须进机读记录:报告读不到它,人只能事后翻快照。
|
|
952
969
|
...rewriteFactExtra(op, beforeText, a.content),
|
|
953
970
|
},
|
|
@@ -955,7 +972,7 @@ export function createToolRunner(opts) {
|
|
|
955
972
|
touched.push(basename(full))
|
|
956
973
|
listCache = null
|
|
957
974
|
auditCache = null
|
|
958
|
-
return `已写入 ${basename(full)}(${
|
|
975
|
+
return `已写入 ${basename(full)}(${content.length} 字符)${before.exist ? '' : '(新建)'}${shrunk ? ',已补压缩留痕。' : '。'}`
|
|
959
976
|
}
|
|
960
977
|
case 'archive_memory': {
|
|
961
978
|
if (!apply) return '错误:当前是「只出报告」模式,没有归档权限。请把建议写进你的最终回复。'
|
|
@@ -1003,6 +1020,15 @@ export function createToolRunner(opts) {
|
|
|
1003
1020
|
}
|
|
1004
1021
|
return `已归档 ${basename(full)}(原因:${reason})。`
|
|
1005
1022
|
}
|
|
1023
|
+
case 'archive_segment': {
|
|
1024
|
+
// 体积治理(2.10.1):只转调 —— 写序在 memory/trim-archive.js,变更账(op:'trim')仍由这里注入。
|
|
1025
|
+
const out = await runArchiveSegment(a, {
|
|
1026
|
+
memoryDir, ctx, apply,
|
|
1027
|
+
record: (op, i) => changeRecord(op, i.file, i.reason, i.before, i.after, 'model', i.extra),
|
|
1028
|
+
})
|
|
1029
|
+
if (out.startsWith('已裁剪')) { touched.push(basename(String(a.file))); listCache = null; auditCache = null }
|
|
1030
|
+
return out
|
|
1031
|
+
}
|
|
1006
1032
|
case 'report_contradiction': {
|
|
1007
1033
|
// 2.0.0:**只落待裁决记录,不动记忆文件**。检测只发生在整理这条已有路径上
|
|
1008
1034
|
// (不做常驻扫描、不加公开工具面,D29 Q5);裁决永远由人做。
|
package/lib/autodream/util.js
CHANGED
|
@@ -101,4 +101,6 @@ export function formatDelta(before, after) {
|
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
/** 操作类型的中文名(声明表格里给人看)。 */
|
|
104
|
-
|
|
104
|
+
// 2.10.1:`trim` = 裁段归档(`archive_segment` 把一段移进 archive/、主文件留指针)。
|
|
105
|
+
// 与 `archive`(整条退役)不是一回事:文件还在顶层、还在检索面里,只是变短了。
|
|
106
|
+
export const OP_LABEL = { create: '新建', update: '改写', archive: '归档', trim: '裁段归档' }
|