sage-mem 0.8.0 → 0.9.1
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.md +28 -0
- package/lib/autodream/config.js +13 -0
- package/lib/autodream/engine.js +78 -1
- package/lib/autodream/report.js +29 -0
- package/lib/autodream/settings.js +2 -0
- package/lib/autodream-audit.js +80 -14
- package/lib/autodream-tools.js +10 -11
- package/lib/client.js +1521 -100
- package/lib/index.js +436 -11
- package/lib/memory/access.js +220 -0
- package/lib/memory/archive.js +91 -0
- package/lib/memory/scan.js +130 -7
- package/lib/typert.host.js +287 -1
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -41,6 +41,34 @@ sage-mem 换一条路:**每条记忆就是一个 Markdown 文件**。用编辑
|
|
|
41
41
|
|
|
42
42
|
*记忆星图:每颗星是一条记忆 —— 颜色对应类型、大小对应体积、亮度对应新鲜度,底部是四类计数与库总体积。画布上只画关系与分布;悬停或点开某颗星时才会显示那一条的文件名。*
|
|
43
43
|
|
|
44
|
+
## 界面预览
|
|
45
|
+
|
|
46
|
+
下面每张图都是**从仓库里的界面代码渲染出来的**(同一份 CSS、同一棵组件树,数据是演示用的假数据,不是真实记忆目录)。
|
|
47
|
+
|
|
48
|
+

|
|
49
|
+
|
|
50
|
+
*文件列表 —— 头部带常显「体检 N 个问题」与「待重启生效」,不用点开就知道该不该处理;灰底条目是已归档,随时可以恢复。*
|
|
51
|
+
|
|
52
|
+

|
|
53
|
+
|
|
54
|
+
*工具抽屉 —— 关着时只占一个按钮,展开后三项各带状态:体检 4 个问题 / 归档候选 2 条 / 保留名 3 个。*
|
|
55
|
+
|
|
56
|
+

|
|
57
|
+
|
|
58
|
+
*体检 —— 索引悬空、漏索引、断链这类硬问题按类报红;「指向已归档」只作提示、不计入问题,也不提供一键修补按钮(改哪条由你决定)。*
|
|
59
|
+
|
|
60
|
+

|
|
61
|
+
|
|
62
|
+
*归档候选 —— 每条都写清「为什么建议归档」(闲置天数 vs 该类型阈值),逐条或全选后确认才动文件;归档是把文件移进 `archive/`,不删除。*
|
|
63
|
+
|
|
64
|
+

|
|
65
|
+
|
|
66
|
+
*记忆星图 —— 暗星是已归档的记忆;点开看归档时间、理由与「恢复到活动记忆」。*
|
|
67
|
+
|
|
68
|
+

|
|
69
|
+
|
|
70
|
+
*自动做梦 · 设置 —— 运行 / 设置 / 记录 二级分组;五个注入参数两列排开,并写清「当前生效」与「重启后」,不会让人误以为改完立刻生效。*
|
|
71
|
+
|
|
44
72
|
## 快速开始
|
|
45
73
|
|
|
46
74
|
需要 DSH `0.1.2-rc.1` 或更高(见[兼容性](#dsh-兼容性)),Node.js `>= 18`。
|
package/lib/autodream/config.js
CHANGED
|
@@ -22,6 +22,19 @@ export const DEFAULT_CONFIG = {
|
|
|
22
22
|
maxSnapshotKeep: 5,
|
|
23
23
|
/** 回滚按钮的默认范围:files = 只恢复该次运行动过的文件;all = 整目录恢复到该时点。 */
|
|
24
24
|
rollbackScope: 'files',
|
|
25
|
+
/**
|
|
26
|
+
* 归档策略:off = 什么都不做;report = 只把候选写进报告(**默认,一个文件都不动**);
|
|
27
|
+
* auto = 运行末尾真的把候选移进 `archive/`(只移不删)。
|
|
28
|
+
* 默认最保守:归档是不可逆的退役动作,先让人看见清单,再决定要不要自动。
|
|
29
|
+
*/
|
|
30
|
+
autoArchive: 'report',
|
|
31
|
+
/**
|
|
32
|
+
* 自动归档阈值(天,按类型差异化)。feedback 没有阈值 —— 它**永不进候选**。
|
|
33
|
+
* 保守默认:project 90 / reference 180 / user 365。
|
|
34
|
+
*/
|
|
35
|
+
archiveAfterDaysProject: 90,
|
|
36
|
+
archiveAfterDaysReference: 180,
|
|
37
|
+
archiveAfterDaysUser: 365,
|
|
25
38
|
}
|
|
26
39
|
|
|
27
40
|
/**
|
package/lib/autodream/engine.js
CHANGED
|
@@ -37,6 +37,9 @@ import { auditMemoryDir } from '../autodream-audit.js'
|
|
|
37
37
|
import { autodreamToolSchemas, createToolRunner } from '../autodream-tools.js'
|
|
38
38
|
import { CONFIG_VERSION, DEFAULT_CONFIG, DEFAULT_TOKEN_BUDGET, FAILURE_BACKOFF_MS, FEATURE_DIR, LEGACY_SNAPSHOT_DIR, LEGACY_STATE_FILE, SCHEDULE_INTERVAL_MS, STARTUP_CHECK_DELAY_MS, STATE_DIR } from './config.js'
|
|
39
39
|
import { countSessionsSince, newRunId, stampHuman } from './util.js'
|
|
40
|
+
import { flushAccess, pickArchiveCandidates, readAccessLedger } from '../memory/access.js'
|
|
41
|
+
import { archiveMemory } from '../memory/archive.js'
|
|
42
|
+
import { scanMemoryFiles } from '../memory/scan.js'
|
|
40
43
|
import { dirname, join } from 'node:path'
|
|
41
44
|
|
|
42
45
|
import * as gateMod from './gate.js'
|
|
@@ -147,6 +150,13 @@ export class AutodreamEngine {
|
|
|
147
150
|
|
|
148
151
|
await this.load()
|
|
149
152
|
const cfg = this.state.config
|
|
153
|
+
// 台账先落盘:这趟整理看到的「上次被注入」必须是新的,否则阈值按旧数据算。
|
|
154
|
+
// flushAccess 自己吞掉失败 —— 台账写不进去不该拦住一趟整理。
|
|
155
|
+
// 台账先落盘:这趟整理看到的「上次被注入」必须是新的,否则阈值按旧数据算。
|
|
156
|
+
// flushAccess 自己吞掉失败 —— 台账写不进去不该拦住一趟整理。
|
|
157
|
+
// ⚠️ 必须传 this.memoryDir:台账的键要跟**这个**记忆目录对表(引擎的目录可以是任意路径,
|
|
158
|
+
// 拿模块常量会把别的目录的键当成死的清掉)。
|
|
159
|
+
await flushAccess(this.memoryDir)
|
|
150
160
|
const startedAt = Date.now()
|
|
151
161
|
const runId = newRunId(startedAt)
|
|
152
162
|
|
|
@@ -244,6 +254,11 @@ export class AutodreamEngine {
|
|
|
244
254
|
sessionCount,
|
|
245
255
|
})
|
|
246
256
|
|
|
257
|
+
// 归档策略:在运行末尾、**审计之前**做 —— 归档过的东西要出现在「运行后」的审计里
|
|
258
|
+
// (指向它的 [[链接]] 会从「断链」变成「指向已归档」的提示)。
|
|
259
|
+
this.phase = '归档候选'
|
|
260
|
+
const policy = await this.archivePolicyPass(cfg)
|
|
261
|
+
|
|
247
262
|
this.phase = '运行后审计'
|
|
248
263
|
const auditAfter = await auditMemoryDir(this.memoryDir)
|
|
249
264
|
|
|
@@ -270,8 +285,15 @@ export class AutodreamEngine {
|
|
|
270
285
|
auditBefore,
|
|
271
286
|
auditAfter,
|
|
272
287
|
finalText: result.finalText,
|
|
288
|
+
archiveMode: policy.mode,
|
|
289
|
+
archiveRequested: policy.requested,
|
|
290
|
+
archiveCandidates: policy.candidates,
|
|
291
|
+
archivedByPolicy: policy.archived,
|
|
292
|
+
archiveDegraded: policy.degraded,
|
|
273
293
|
})
|
|
274
294
|
|
|
295
|
+
// 报告里的「改动的文件」要把策略归档也算上 —— 它们确实被移走了,漏掉就是报告在说谎。
|
|
296
|
+
const policyTouched = policy.archived.filter((a) => a.ok).map((a) => `${a.file} → archive/`)
|
|
275
297
|
const report = await this.writeReport({
|
|
276
298
|
runId,
|
|
277
299
|
startedAt,
|
|
@@ -285,7 +307,7 @@ export class AutodreamEngine {
|
|
|
285
307
|
hoursSince,
|
|
286
308
|
sessionCount,
|
|
287
309
|
snapshot,
|
|
288
|
-
touched: result.touched,
|
|
310
|
+
touched: [...result.touched, ...policyTouched],
|
|
289
311
|
auditBefore,
|
|
290
312
|
auditAfter,
|
|
291
313
|
finalText: result.finalText,
|
|
@@ -293,6 +315,11 @@ export class AutodreamEngine {
|
|
|
293
315
|
tokensIn: result.tokensIn,
|
|
294
316
|
tokensOut: result.tokensOut,
|
|
295
317
|
declarationMarkdown: declaration.markdown,
|
|
318
|
+
archiveMode: policy.mode,
|
|
319
|
+
archiveRequested: policy.requested,
|
|
320
|
+
archiveCandidates: policy.candidates,
|
|
321
|
+
archivedByPolicy: policy.archived,
|
|
322
|
+
archiveDegraded: policy.degraded,
|
|
296
323
|
})
|
|
297
324
|
|
|
298
325
|
this.state.lastRunAt = Date.now()
|
|
@@ -387,6 +414,56 @@ export class AutodreamEngine {
|
|
|
387
414
|
}
|
|
388
415
|
}
|
|
389
416
|
|
|
417
|
+
// ────────────────────────────── 归档策略 ──────────────────────────────
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* 自动归档一趟(**确定性,不调模型**)。
|
|
421
|
+
*
|
|
422
|
+
* 三档:`off` 什么也不做;`report` 只把候选交回上层写进报告(一个文件都不动);
|
|
423
|
+
* `auto` 在运行末尾真的把候选移进 `archive/`(只移不删)。
|
|
424
|
+
*
|
|
425
|
+
* ⚠️ **`apply: false` 是用户可见的绝对承诺**(「只读模式……不可能改到任何文件」):
|
|
426
|
+
* `auto` 遇到只读一律**降级为报告**并在报告里明说,绝不绕过那条承诺。
|
|
427
|
+
*
|
|
428
|
+
* 归档动作调 `memory/archive.js` 的共享实现 —— 与手动 remote、`archive_memory` 工具
|
|
429
|
+
* 是同一份代码,三条入口不可能再漂移出两种行为。
|
|
430
|
+
*/
|
|
431
|
+
async archivePolicyPass(cfg) {
|
|
432
|
+
const requested = cfg.autoArchive === 'auto' ? 'auto' : cfg.autoArchive === 'off' ? 'off' : 'report'
|
|
433
|
+
if (requested === 'off') return { mode: 'off', requested, candidates: [], archived: [], degraded: '' }
|
|
434
|
+
const [ledger, memoryFiles] = await Promise.all([readAccessLedger(), scanMemoryFiles(this.ctx, '')])
|
|
435
|
+
const candidates = pickArchiveCandidates(memoryFiles, ledger, {
|
|
436
|
+
thresholds: {
|
|
437
|
+
project: cfg.archiveAfterDaysProject,
|
|
438
|
+
reference: cfg.archiveAfterDaysReference,
|
|
439
|
+
user: cfg.archiveAfterDaysUser,
|
|
440
|
+
},
|
|
441
|
+
})
|
|
442
|
+
if (requested === 'auto' && !cfg.apply) {
|
|
443
|
+
return {
|
|
444
|
+
mode: 'report',
|
|
445
|
+
requested,
|
|
446
|
+
candidates,
|
|
447
|
+
archived: [],
|
|
448
|
+
degraded: 'autoArchive 已设为 auto,但自动做梦处于只读模式,本轮只出报告(一个文件都没动)',
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
if (requested !== 'auto' || candidates.length === 0) {
|
|
452
|
+
return { mode: requested, requested, candidates, archived: [], degraded: '' }
|
|
453
|
+
}
|
|
454
|
+
const archived = []
|
|
455
|
+
for (const c of candidates) {
|
|
456
|
+
const reason = `自动归档:${c.type} 类,超过 ${c.limit} 天未被注入`
|
|
457
|
+
const res = await archiveMemory(this.memoryDir, c.file, reason)
|
|
458
|
+
archived.push({
|
|
459
|
+
file: c.file,
|
|
460
|
+
ok: res.ok === true,
|
|
461
|
+
message: res.ok ? `已归档 ${res.file}(原因:${res.archivedReason})` : res.error,
|
|
462
|
+
})
|
|
463
|
+
}
|
|
464
|
+
return { mode: 'auto', requested, candidates, archived, degraded: '' }
|
|
465
|
+
}
|
|
466
|
+
|
|
390
467
|
// ────────────────────────────── 调度 ──────────────────────────────
|
|
391
468
|
|
|
392
469
|
/**
|
package/lib/autodream/report.js
CHANGED
|
@@ -43,6 +43,16 @@ export async function writeDeclaration(payload) {
|
|
|
43
43
|
changes: payload.changes,
|
|
44
44
|
warnings: payload.warnings,
|
|
45
45
|
notes: payload.notes ?? [],
|
|
46
|
+
// 自动归档策略那一段:候选清单是**确定性算出来的**(访问台账 → updated → mtime),
|
|
47
|
+
// 与模型的判断无关。report 模式下这里就是全部动作(一个文件都不动)。
|
|
48
|
+
archive: {
|
|
49
|
+
mode: payload.archiveMode ?? 'off',
|
|
50
|
+
requested: payload.archiveRequested ?? payload.archiveMode ?? 'off',
|
|
51
|
+
candidates: payload.archiveCandidates ?? [],
|
|
52
|
+
archived: payload.archivedByPolicy ?? [],
|
|
53
|
+
// 降级说明(例如 auto 撞上只读模式):非空就是「配置想要的没做成,原因在这儿」。
|
|
54
|
+
degraded: payload.archiveDegraded ?? '',
|
|
55
|
+
},
|
|
46
56
|
report: payload.report ?? null,
|
|
47
57
|
finalText: payload.finalText ?? '',
|
|
48
58
|
rolledBackAt: 0,
|
|
@@ -101,6 +111,25 @@ export function renderDeclaration(m, headingLevel = 1) {
|
|
|
101
111
|
)
|
|
102
112
|
}
|
|
103
113
|
}
|
|
114
|
+
if (m.archive && m.archive.mode !== 'off' && (m.archive.candidates.length || m.archive.degraded)) {
|
|
115
|
+
lines.push(`${H}# 自动归档候选(策略:${m.archive.mode})`, '')
|
|
116
|
+
// 降级必须**明说**:用户设了 auto 却什么都没动,原因不写在报告里就等于静默失灵。
|
|
117
|
+
if (m.archive.degraded) lines.push(`> ⚠ ${m.archive.degraded}`, '')
|
|
118
|
+
if (m.archive.mode === 'auto') {
|
|
119
|
+
const done = m.archive.archived.filter((a) => a.ok).length
|
|
120
|
+
lines.push(`- 已移入 \`archive/\`:${done} / ${m.archive.archived.length} 条(只移不删)`)
|
|
121
|
+
} else {
|
|
122
|
+
lines.push('- **本次只出清单,没有移动任何文件**(策略 `report`;想自动跑请把 `autoArchive` 改成 `auto`)')
|
|
123
|
+
}
|
|
124
|
+
lines.push('')
|
|
125
|
+
if (m.archive.candidates.length) {
|
|
126
|
+
lines.push('| 文件 | 类型 | 闲置 | 阈值 | 说明 |', '|---|---|---|---|---|')
|
|
127
|
+
for (const c of m.archive.candidates) {
|
|
128
|
+
lines.push(`| \`${c.file}\` | ${c.type} | ${c.days} 天 | ${c.limit} 天 | ${c.reason} |`)
|
|
129
|
+
}
|
|
130
|
+
lines.push('')
|
|
131
|
+
}
|
|
132
|
+
}
|
|
104
133
|
if (m.warnings?.length) {
|
|
105
134
|
lines.push(`${H}# 未能落地的改动(这些改动没有发生)`, '')
|
|
106
135
|
for (const w of m.warnings) lines.push(`- ${w}`)
|
|
@@ -84,6 +84,8 @@ export async function setConfig(patch) {
|
|
|
84
84
|
if (typeof p.apply === 'boolean') c.apply = p.apply
|
|
85
85
|
if (p.source === 'memory' || p.source === 'memory+sessions') c.source = p.source
|
|
86
86
|
if (p.rollbackScope === 'files' || p.rollbackScope === 'all') c.rollbackScope = p.rollbackScope
|
|
87
|
+
// 归档策略三档:与上面 trigger / rollbackScope 同一套写法 —— 非法值不写入,保留原值。
|
|
88
|
+
if (p.autoArchive === 'off' || p.autoArchive === 'report' || p.autoArchive === 'auto') c.autoArchive = p.autoArchive
|
|
87
89
|
if (p.minHours !== undefined) c.minHours = clampNum(Number(p.minHours), 1, 720, DEFAULT_CONFIG.minHours)
|
|
88
90
|
if (p.minSessions !== undefined) c.minSessions = Math.round(clampNum(Number(p.minSessions), 1, 200, DEFAULT_CONFIG.minSessions))
|
|
89
91
|
if (p.maxSteps !== undefined) c.maxSteps = Math.round(clampNum(Number(p.maxSteps), 1, 80, DEFAULT_CONFIG.maxSteps))
|
package/lib/autodream-audit.js
CHANGED
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
* 5. 双链断链 —— [[目标]] 找不到对应文件或 frontmatter name
|
|
11
11
|
* 6. 未加引号值里的「空格+#」—— 会被 YAML 当注释吞掉,解析不报错但描述残缺
|
|
12
12
|
*
|
|
13
|
+
* **归档区(`archive/`)不是审计对象**,只贡献「名字池」:正文里指向已归档记忆的
|
|
14
|
+
* `[[链接]]` 单列成 `archivedLinks`(提示),**不计入 problems** —— 归档是「静默失效
|
|
15
|
+
* 且可回退」,不是删除。不这么分,归档越多假红灯越多。
|
|
16
|
+
*
|
|
13
17
|
* **纯只读**:不修任何东西。修哪条、怎么修,由 autodream agent 或人决定。
|
|
14
18
|
*
|
|
15
19
|
* 这一份解析是 sage-mem 宿主侧 parseFrontmatter 的同源精简版,故意不共用:
|
|
@@ -24,14 +28,20 @@ import { join } from 'node:path'
|
|
|
24
28
|
const INDEX_FILE = 'MEMORY.md'
|
|
25
29
|
|
|
26
30
|
/**
|
|
27
|
-
*
|
|
31
|
+
* 审计**整个跳过**的文件(不读、不当被审对象)。
|
|
32
|
+
*
|
|
33
|
+
* session-log.md 是追加型流水,逐会话的账本而不是一条记忆;它没有
|
|
34
|
+
* `user_/feedback_/project_/reference_` 前缀,扫进来只会白白读一大坨再报假问题。
|
|
28
35
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
36
|
+
* ⚠️ 别和 `lib/memory/config.js` 的 `NON_ENTRY_FILES` 混为一谈 —— **同名不同义**:
|
|
37
|
+
* 那个管「列不列进设置页 / 星图 / 检索」,这个管「审计读不读」。它们的差集就是
|
|
38
|
+
* **索引 `MEMORY.md`**:它**要**被读(要当链接来源、要查行尾与链接),只是
|
|
39
|
+
* **不是一条记忆** —— 见下面 `f !== INDEX_FILE` 那两处。
|
|
33
40
|
*/
|
|
34
|
-
const
|
|
41
|
+
const AUDIT_SKIP_FILES = new Set(['session-log.md'])
|
|
42
|
+
|
|
43
|
+
/** 归档区目录名(与 autodream-tools.js 的 ARCHIVE_DIR、宿主侧 memory/scan.js 同名)。 */
|
|
44
|
+
const ARCHIVE_DIR = 'archive'
|
|
35
45
|
|
|
36
46
|
/** YAML 行形态:`key: value`(顶层键,不含缩进的嵌套行)。 */
|
|
37
47
|
const YAML_LEAF_RE = /^[A-Za-z0-9_.-]+:\s*(.*)$/
|
|
@@ -77,9 +87,10 @@ export function indexTargets(indexText) {
|
|
|
77
87
|
}
|
|
78
88
|
|
|
79
89
|
/**
|
|
80
|
-
* 扫一个目录下的记忆文件(不含子目录 ——
|
|
90
|
+
* 扫一个目录下的记忆文件(不含子目录 —— 归档区就该扫不到),
|
|
91
|
+
* 外加归档区的**名字池**(只有名字,不做检查)。
|
|
81
92
|
* @param {string} dir
|
|
82
|
-
* @returns {Promise<{files: string[], indexText: string}>}
|
|
93
|
+
* @returns {Promise<{files: string[], all: string[], skipped: number, indexText: string, archiveKeys: Set<string>, archiveCount: number}>}
|
|
83
94
|
*/
|
|
84
95
|
async function readDirSnapshot(dir) {
|
|
85
96
|
let entries
|
|
@@ -87,14 +98,35 @@ async function readDirSnapshot(dir) {
|
|
|
87
98
|
const all = entries
|
|
88
99
|
.filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.md'))
|
|
89
100
|
.map((e) => e.name)
|
|
90
|
-
const files = all.filter((f) => !
|
|
101
|
+
const files = all.filter((f) => !AUDIT_SKIP_FILES.has(f.toLowerCase()))
|
|
91
102
|
let indexText = ''
|
|
92
103
|
try {
|
|
93
104
|
indexText = await readFile(join(dir, INDEX_FILE), 'utf8')
|
|
94
105
|
} catch (err) {
|
|
95
106
|
if (err?.code !== 'ENOENT') throw err
|
|
96
107
|
}
|
|
97
|
-
|
|
108
|
+
// 归档区:文件名、去掉 .md 的名字、以及 frontmatter 里的 `name`。
|
|
109
|
+
// 内容读不动只少一个名字,绝不让整场审计失败 —— 归档区本来就不是被审对象。
|
|
110
|
+
const archiveKeys = new Set()
|
|
111
|
+
let archiveCount = 0
|
|
112
|
+
try {
|
|
113
|
+
const archived = await readdir(join(dir, ARCHIVE_DIR), { withFileTypes: true })
|
|
114
|
+
for (const e of archived) {
|
|
115
|
+
if (!e.isFile() || !e.name.toLowerCase().endsWith('.md')) continue
|
|
116
|
+
archiveCount++
|
|
117
|
+
archiveKeys.add(e.name)
|
|
118
|
+
archiveKeys.add(e.name.replace(/\.md$/i, ''))
|
|
119
|
+
try {
|
|
120
|
+
const meta = parseFrontmatterLite(await readFile(join(dir, ARCHIVE_DIR, e.name), 'utf8'))
|
|
121
|
+
if (meta.name) archiveKeys.add(meta.name.trim())
|
|
122
|
+
} catch {
|
|
123
|
+
/* 读不动:名字池少一条而已 */
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
} catch (err) {
|
|
127
|
+
if (err?.code !== 'ENOENT') throw err
|
|
128
|
+
}
|
|
129
|
+
return { files, all, skipped: all.length - files.length, indexText, archiveKeys, archiveCount }
|
|
98
130
|
}
|
|
99
131
|
|
|
100
132
|
/**
|
|
@@ -103,7 +135,7 @@ async function readDirSnapshot(dir) {
|
|
|
103
135
|
* @returns {Promise<object>} 结构化审计结果(problems 是六项之和)
|
|
104
136
|
*/
|
|
105
137
|
export async function auditMemoryDir(dir) {
|
|
106
|
-
const { files, all, skipped, indexText } = await readDirSnapshot(dir)
|
|
138
|
+
const { files, all, skipped, indexText, archiveKeys, archiveCount } = await readDirSnapshot(dir)
|
|
107
139
|
const entries = files.filter((f) => f !== INDEX_FILE)
|
|
108
140
|
|
|
109
141
|
const targets = indexTargets(indexText)
|
|
@@ -119,6 +151,7 @@ export async function auditMemoryDir(dir) {
|
|
|
119
151
|
const crlf = []
|
|
120
152
|
const hashHazards = []
|
|
121
153
|
const brokenLinks = []
|
|
154
|
+
const archivedLinks = []
|
|
122
155
|
const noFrontmatter = []
|
|
123
156
|
const linkKeys = new Set()
|
|
124
157
|
|
|
@@ -145,13 +178,28 @@ export async function auditMemoryDir(dir) {
|
|
|
145
178
|
}
|
|
146
179
|
for (const f of readable) linkKeys.add(f)
|
|
147
180
|
for (const f of readable) linkKeys.add(f.replace(/\.md$/, ''))
|
|
181
|
+
/**
|
|
182
|
+
* ⚠️ 链接目标池还必须含**非条目文件**(`session-log.md` / `MEMORY.md`)。
|
|
183
|
+
*
|
|
184
|
+
* 它们不在 `readable` 里(不参与检索),但 `[[session-log]]` 指向的是**真实存在的文件** ——
|
|
185
|
+
* 只因它不参与检索就报成断链,是假阳性。这与上面 `dangling` 用 `allSet` 而非 `files`
|
|
186
|
+
* 判定悬空是同一个理由,别只在一处想到。(2026-10-09 实测:健康记忆库被报 1 条假断链)
|
|
187
|
+
*/
|
|
188
|
+
for (const f of all) {
|
|
189
|
+
linkKeys.add(f)
|
|
190
|
+
linkKeys.add(f.replace(/\.md$/i, ''))
|
|
191
|
+
}
|
|
148
192
|
|
|
149
193
|
for (const f of readable) {
|
|
150
194
|
const raw = contents.get(f)
|
|
151
195
|
if (raw.includes('\r')) crlf.push(f)
|
|
152
196
|
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
|
153
197
|
if (!fmMatch) {
|
|
154
|
-
|
|
198
|
+
/**
|
|
199
|
+
* 索引 `MEMORY.md` 是**纯清单、不是一条记忆**(见 `lib/memory/config.js` 的
|
|
200
|
+
* `NON_ENTRY_FILES` 说明),不要求 frontmatter。报它等于给每一个健康的记忆库挂一盏常驻红灯。
|
|
201
|
+
*/
|
|
202
|
+
if (f !== INDEX_FILE) noFrontmatter.push(f)
|
|
155
203
|
} else {
|
|
156
204
|
const meta = parseFrontmatterLite(raw)
|
|
157
205
|
const prefix = f.split('_')[0]
|
|
@@ -166,9 +214,22 @@ export async function auditMemoryDir(dir) {
|
|
|
166
214
|
}
|
|
167
215
|
}
|
|
168
216
|
}
|
|
169
|
-
|
|
217
|
+
/**
|
|
218
|
+
* ⚠️ 只在**非代码**文本里找双链。
|
|
219
|
+
*
|
|
220
|
+
* `` `[[双链]]` `` 写在行内代码或围栏代码块里,是在**讲链接语法本身**,不是一条链接。
|
|
221
|
+
* 直接在原文上 `matchAll` 会把它报成断链(2026-10-09 实测的假阳性)。
|
|
222
|
+
*/
|
|
223
|
+
const prose = raw.replace(/```[\s\S]*?```/g, '').replace(/`+[^`]*`+/g, '')
|
|
224
|
+
for (const m of prose.matchAll(/\[\[([^\]]+)\]\]/g)) {
|
|
170
225
|
const target = m[1].trim()
|
|
171
|
-
if (
|
|
226
|
+
if (linkKeys.has(target)) continue
|
|
227
|
+
// 指向归档区的链接:单列提示、不计问题 —— 归档不是删除,目标还在库里躺着。
|
|
228
|
+
if (archiveKeys.has(target)) {
|
|
229
|
+
archivedLinks.push({ file: f, target })
|
|
230
|
+
continue
|
|
231
|
+
}
|
|
232
|
+
brokenLinks.push({ file: f, target })
|
|
172
233
|
}
|
|
173
234
|
}
|
|
174
235
|
|
|
@@ -186,12 +247,15 @@ export async function auditMemoryDir(dir) {
|
|
|
186
247
|
dir,
|
|
187
248
|
fileCount: entries.length,
|
|
188
249
|
skipped,
|
|
250
|
+
archivedCount: archiveCount,
|
|
189
251
|
indexEntries: targets.length,
|
|
190
252
|
dangling: dangling.map((file) => ({ file })),
|
|
191
253
|
unlisted: unlisted.map((file) => ({ file })),
|
|
192
254
|
typeMismatch,
|
|
193
255
|
crlf: crlf.map((file) => ({ file })),
|
|
194
256
|
brokenLinks,
|
|
257
|
+
// 指向归档的链接是提示,**不进 problems**(见文件头那段)。
|
|
258
|
+
archivedLinks,
|
|
195
259
|
hashHazards,
|
|
196
260
|
noFrontmatter: noFrontmatter.map((file) => ({ file })),
|
|
197
261
|
unreadable: unreadable.map((file) => ({ file })),
|
|
@@ -220,6 +284,8 @@ export function formatAudit(report, limit = 12) {
|
|
|
220
284
|
section('type 与前缀不一致', report.typeMismatch, (r) => `${r.file} type=${r.type} 前缀=${r.prefix}`)
|
|
221
285
|
section('CRLF', report.crlf, (r) => r.file)
|
|
222
286
|
section('双链断链', report.brokenLinks, (r) => `${r.file} -> [[${r.target}]]`)
|
|
287
|
+
// 指向归档单列且写明「不计问题」:否则模型会去「修」一条本来正确的链接。
|
|
288
|
+
section('指向已归档(提示,不计入问题)', report.archivedLinks ?? [], (r) => `${r.file} -> [[${r.target}]]`)
|
|
223
289
|
section('未加引号的「空格+#」', report.hashHazards, (r) => `${r.file} :: ${r.line}`)
|
|
224
290
|
section('无 frontmatter', report.noFrontmatter, (r) => r.file)
|
|
225
291
|
// 读不动的单列,且排最后:它不是「这条记忆写错了」,是「我没读到它」——
|
package/lib/autodream-tools.js
CHANGED
|
@@ -24,10 +24,11 @@
|
|
|
24
24
|
* `write_memory` 的 frontmatter 规范都沿用 sage-mem 的既有约定,不另造一套。
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
import { readFile, readdir, writeFile,
|
|
27
|
+
import { readFile, readdir, writeFile, stat } from 'node:fs/promises'
|
|
28
28
|
import { join, basename } from 'node:path'
|
|
29
29
|
import { createHash } from 'node:crypto'
|
|
30
30
|
import { auditMemoryDir, formatAudit, parseFrontmatterLite } from './autodream-audit.js'
|
|
31
|
+
import { archiveMemory } from './memory/archive.js'
|
|
31
32
|
|
|
32
33
|
/** 单次 read_memory 返回的正文上限(字符)。超过就截断并明确告诉模型。 */
|
|
33
34
|
const READ_LIMIT = 20000
|
|
@@ -35,8 +36,6 @@ const READ_LIMIT = 20000
|
|
|
35
36
|
const LIST_DESC_CHARS = 70
|
|
36
37
|
/** search_sessions 单次返回上限(字符)。 */
|
|
37
38
|
const SEARCH_LIMIT = 8000
|
|
38
|
-
/** 归档区目录名(相对 memory 根)。 */
|
|
39
|
-
const ARCHIVE_DIR = 'archive'
|
|
40
39
|
/** 索引文件名:它在列表里要单独标注,免得模型当成一条普通记忆去改。 */
|
|
41
40
|
const INDEX_FILE = 'memory.md'
|
|
42
41
|
/**
|
|
@@ -467,20 +466,20 @@ export function createToolRunner(opts) {
|
|
|
467
466
|
const validated = safeMemoryPath(memoryDir, a.file)
|
|
468
467
|
if (!validated) return `错误:文件名不合法:${String(a.file)}`
|
|
469
468
|
const full = join(memoryDir, await resolveRealName(memoryDir, basename(validated)))
|
|
469
|
+
// reason 必填这条语义由**工具自己**守(模型必须为不可逆动作说清为什么);
|
|
470
|
+
// 落盘动作则交给 memory/archive.js —— 与手动 remote、自动策略共用同一份。
|
|
470
471
|
const reason = typeof a.reason === 'string' ? a.reason.trim() : ''
|
|
471
472
|
if (reason.length < MIN_REASON_CHARS) {
|
|
472
473
|
warnings.push(`拒绝对 ${basename(full)} 的归档:模型没给 reason(或太短)`)
|
|
473
474
|
return '错误:archive_memory 必须给 reason —— 一句话说明为什么这条该退役。请带上 reason 重发。'
|
|
474
475
|
}
|
|
475
|
-
const exists = await stat(full).catch(() => null)
|
|
476
|
-
if (!exists) return `错误:没有这个文件:${basename(full)}`
|
|
477
|
-
const archiveDir = join(memoryDir, ARCHIVE_DIR)
|
|
478
|
-
await mkdir(archiveDir, { recursive: true })
|
|
479
|
-
const target = join(archiveDir, basename(full))
|
|
480
|
-
const clash = await stat(target).catch(() => null)
|
|
481
|
-
if (clash) return `错误:archive/ 里已有同名文件(${basename(full)}),请先人工处理,避免覆盖。`
|
|
482
476
|
const before = await fileFingerprint(full)
|
|
483
|
-
await
|
|
477
|
+
const res = await archiveMemory(memoryDir, basename(full), reason)
|
|
478
|
+
if (!res.ok) {
|
|
479
|
+
if (res.code === 'not-found') return `错误:没有这个文件:${basename(full)}`
|
|
480
|
+
if (res.code === 'exists') return `错误:archive/ 里已有同名文件(${basename(full)}),请先人工处理,避免覆盖。`
|
|
481
|
+
return `错误:${String(res.error).replace(/^sage-mem: /, '')}`
|
|
482
|
+
}
|
|
484
483
|
changeRecord('archive', basename(full), reason, before, { exist: false, bytes: 0, sha256: null })
|
|
485
484
|
touched.push(`${basename(full)} → archive/`)
|
|
486
485
|
listCache = null
|