sage-mem 0.7.2 → 0.9.0
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/LICENSE +202 -202
- package/NOTICE +8 -8
- package/README.md +312 -284
- package/cordis.patch.yml +4 -4
- package/lib/autodream/config.js +83 -0
- package/lib/autodream/engine.js +524 -0
- package/lib/autodream/gate.js +78 -0
- package/lib/autodream/llm-loop.js +248 -0
- package/lib/autodream/report.js +285 -0
- package/lib/autodream/rollback.js +221 -0
- package/lib/autodream/settings.js +289 -0
- package/lib/autodream/snapshot.js +142 -0
- package/lib/autodream/util.js +112 -0
- package/lib/autodream-audit.js +270 -229
- package/lib/autodream-prompt.js +129 -129
- package/lib/autodream-tools.js +497 -498
- package/lib/autodream.js +8 -1722
- package/lib/client.js +4277 -2854
- package/lib/index.js +893 -951
- package/lib/memory/access.js +220 -0
- package/lib/memory/archive.js +91 -0
- package/lib/memory/config.js +151 -0
- package/lib/memory/frontmatter.js +117 -0
- package/lib/memory/naming.js +54 -0
- package/lib/memory/retrieval.js +86 -0
- package/lib/memory/scan.js +204 -0
- package/lib/memory/util.js +37 -0
- package/lib/typert.host.js +747 -461
- package/package.json +7 -2
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* autodream —— 通用小工具:数值收敛、时间戳、runId、安全文件名、JSON 读、
|
|
3
|
+
* 快照枚举、会话计数与增量描述。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { randomUUID } from 'node:crypto'
|
|
7
|
+
import { readFile, readdir, stat } from 'node:fs/promises'
|
|
8
|
+
import { join } from 'node:path'
|
|
9
|
+
|
|
10
|
+
export const clampNum = (v, lo, hi, dflt) => (Number.isFinite(v) ? Math.max(lo, Math.min(hi, v)) : dflt)
|
|
11
|
+
|
|
12
|
+
/** 本地时间戳,给文件名用。 */
|
|
13
|
+
export function stampCompact(ms) {
|
|
14
|
+
const d = new Date(ms)
|
|
15
|
+
const p = (n) => String(n).padStart(2, '0')
|
|
16
|
+
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 本地时间戳,给人看。 */
|
|
20
|
+
export function stampHuman(ms) {
|
|
21
|
+
const d = new Date(ms)
|
|
22
|
+
const p = (n) => String(n).padStart(2, '0')
|
|
23
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 运行 id:`YYYYMMDD-HHmmss-<4 位>`。
|
|
28
|
+
*
|
|
29
|
+
* 加后缀是因为**同一秒里跑第二趟**在手动连点时真会发生,而 runId 同时是
|
|
30
|
+
* 快照目录名、运行记录目录名和报告文件名——撞一次就是三个地方互相覆盖。
|
|
31
|
+
*/
|
|
32
|
+
export function newRunId(ms) {
|
|
33
|
+
return `${stampCompact(ms)}-${randomUUID().replace(/-/g, '').slice(0, 4)}`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 文件名/目录名的白名单校验(快照 id、运行 id、报告名都走这里,别让路径穿越进来)。 */
|
|
37
|
+
export function safeName(raw) {
|
|
38
|
+
const s = String(raw ?? '').trim()
|
|
39
|
+
if (!s || s.includes('/') || s.includes('\\') || s.includes('..')) return null
|
|
40
|
+
if (!/^[\w.-]+$/.test(s)) return null
|
|
41
|
+
return s
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 读一个 JSON,读不到/解析失败都返回 null(配置与清单文件损坏不该拖死流程)。 */
|
|
45
|
+
export async function readJson(file) {
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(await readFile(file, 'utf8'))
|
|
48
|
+
} catch {
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 快照里实际躺着哪些 .md(清单不可信时以目录为准)。 */
|
|
54
|
+
export async function snapshotEntries(dir) {
|
|
55
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
|
|
56
|
+
return entries.filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.md')).map((e) => e.name)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 精简版 chunk 组装器。
|
|
61
|
+
*
|
|
62
|
+
* 只保留 autodream 用得上的两种块,并**自己实现而不是依赖宿主内部包** ——
|
|
63
|
+
* 依赖漂移时挂的是「整个插件加载不了」这种级别的故障,而这里需要的逻辑只有三十行。
|
|
64
|
+
*
|
|
65
|
+
* 两条必须保证的行为:
|
|
66
|
+
* - delta-only 协议(没有 block-start/block-end)也要能组装
|
|
67
|
+
* - `finish.kind === 'max-tokens'` 时**丢掉 tool-call 块**:被截断的调用参数
|
|
68
|
+
* 是不完整的 JSON,拿去执行等于凭半句话动手
|
|
69
|
+
|
|
70
|
+
/** 数一下 `$DSH_HOME/sessions` 下 mtime 晚于 sinceMs 的会话(只看文件时间,不解压)。 */
|
|
71
|
+
export async function countSessionsSince(root, sinceMs) {
|
|
72
|
+
if (!root) return 0
|
|
73
|
+
let n = 0
|
|
74
|
+
let projects
|
|
75
|
+
try {
|
|
76
|
+
projects = await readdir(root, { withFileTypes: true })
|
|
77
|
+
} catch {
|
|
78
|
+
return 0
|
|
79
|
+
}
|
|
80
|
+
for (const p of projects) {
|
|
81
|
+
if (!p.isDirectory()) continue
|
|
82
|
+
const projPath = join(root, p.name)
|
|
83
|
+
let sessions
|
|
84
|
+
try {
|
|
85
|
+
sessions = await readdir(projPath, { withFileTypes: true })
|
|
86
|
+
} catch {
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
for (const s of sessions) {
|
|
90
|
+
if (!s.isDirectory()) continue
|
|
91
|
+
const file = join(projPath, s.name, 'session.v4.jsonl.zstd')
|
|
92
|
+
try {
|
|
93
|
+
const info = await stat(file)
|
|
94
|
+
if (info.mtimeMs >= sinceMs) n++
|
|
95
|
+
} catch {
|
|
96
|
+
// 目录里没有这个文件:不是每一代格式都叫这个名字,跳过即可。
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return n
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 人读的字节差:`12.0KB → 10.0KB(-2.0KB)`。 */
|
|
104
|
+
export function formatDelta(before, after) {
|
|
105
|
+
const kb = (n) => `${(n / 1024).toFixed(1)}KB`
|
|
106
|
+
const d = after.bytes - before.bytes
|
|
107
|
+
const sign = d > 0 ? '+' : ''
|
|
108
|
+
return `${kb(before.bytes)} → ${kb(after.bytes)}(${sign}${(d / 1024).toFixed(1)}KB)`
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 操作类型的中文名(声明表格里给人看)。 */
|
|
112
|
+
export const OP_LABEL = { create: '新建', update: '改写', archive: '归档' }
|
package/lib/autodream-audit.js
CHANGED
|
@@ -1,229 +1,270 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* sage-mem autodream — 记忆目录审计(七查)。
|
|
3
|
-
*
|
|
4
|
-
* 审计清单内置在 host 侧,让 autodream 每次跑完都能自证
|
|
5
|
-
* 没把记忆改坏(七项,外加「读不动」单列一类):
|
|
6
|
-
* 1. 索引悬空 —— MEMORY.md 指向不存在的文件
|
|
7
|
-
* 2. 漏索引 —— 存在但没进索引的记忆文件
|
|
8
|
-
* 3. type 与前缀不一致(分类看 type,前缀骗不了它)
|
|
9
|
-
* 4. CRLF —— 「某个 Windows 编辑器碰过」的可疑信号
|
|
10
|
-
* 5. 双链断链 —— [[目标]] 找不到对应文件或 frontmatter name
|
|
11
|
-
* 6. 未加引号值里的「空格+#」—— 会被 YAML 当注释吞掉,解析不报错但描述残缺
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
1
|
+
/**
|
|
2
|
+
* sage-mem autodream — 记忆目录审计(七查)。
|
|
3
|
+
*
|
|
4
|
+
* 审计清单内置在 host 侧,让 autodream 每次跑完都能自证
|
|
5
|
+
* 没把记忆改坏(七项,外加「读不动」单列一类):
|
|
6
|
+
* 1. 索引悬空 —— MEMORY.md 指向不存在的文件
|
|
7
|
+
* 2. 漏索引 —— 存在但没进索引的记忆文件
|
|
8
|
+
* 3. type 与前缀不一致(分类看 type,前缀骗不了它)
|
|
9
|
+
* 4. CRLF —— 「某个 Windows 编辑器碰过」的可疑信号
|
|
10
|
+
* 5. 双链断链 —— [[目标]] 找不到对应文件或 frontmatter name
|
|
11
|
+
* 6. 未加引号值里的「空格+#」—— 会被 YAML 当注释吞掉,解析不报错但描述残缺
|
|
12
|
+
*
|
|
13
|
+
* **归档区(`archive/`)不是审计对象**,只贡献「名字池」:正文里指向已归档记忆的
|
|
14
|
+
* `[[链接]]` 单列成 `archivedLinks`(提示),**不计入 problems** —— 归档是「静默失效
|
|
15
|
+
* 且可回退」,不是删除。不这么分,归档越多假红灯越多。
|
|
16
|
+
*
|
|
17
|
+
* **纯只读**:不修任何东西。修哪条、怎么修,由 autodream agent 或人决定。
|
|
18
|
+
*
|
|
19
|
+
* 这一份解析是 sage-mem 宿主侧 parseFrontmatter 的同源精简版,故意不共用:
|
|
20
|
+
* 那边带着一长串历史坑的注释与行为(baseline、type 回落),审计只读不写,
|
|
21
|
+
* 重复二十行换零回归风险是划算的。
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { readdir, readFile } from 'node:fs/promises'
|
|
25
|
+
import { join } from 'node:path'
|
|
26
|
+
|
|
27
|
+
/** 索引文件名。它本身不是一条记忆,是索引。 */
|
|
28
|
+
const INDEX_FILE = 'MEMORY.md'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 非记忆条目:不是四类记忆、也不该套「type 与前缀一致」这条规则。
|
|
32
|
+
*
|
|
33
|
+
* session-log.md 是追加型流水,逐会话的账本而不是一条记忆;
|
|
34
|
+
* 它没有 `user_/feedback_/project_/reference_` 前缀,扫进来只会白白读一大坨
|
|
35
|
+
* 再报一条假问题(实测:报出 `session-log.md type=reference`)。
|
|
36
|
+
* 同理 MEMORY.md 是索引本身,只作为链接来源读,不作为被审对象。
|
|
37
|
+
*/
|
|
38
|
+
const NON_ENTRY_FILES = new Set(['session-log.md'])
|
|
39
|
+
|
|
40
|
+
/** 归档区目录名(与 autodream-tools.js 的 ARCHIVE_DIR、宿主侧 memory/scan.js 同名)。 */
|
|
41
|
+
const ARCHIVE_DIR = 'archive'
|
|
42
|
+
|
|
43
|
+
/** YAML 行形态:`key: value`(顶层键,不含缩进的嵌套行)。 */
|
|
44
|
+
const YAML_LEAF_RE = /^[A-Za-z0-9_.-]+:\s*(.*)$/
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 极简 frontmatter 解析,读法与 宿主侧一致:
|
|
48
|
+
* 顶层 `type:` 优先,没有才下钻 `metadata.type:`。
|
|
49
|
+
* @param {string} content — 文件全文
|
|
50
|
+
* @returns {{name: string, description: string, type: string, hasFrontmatter: boolean}}
|
|
51
|
+
*/
|
|
52
|
+
export function parseFrontmatterLite(content) {
|
|
53
|
+
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
|
54
|
+
if (!m) return { name: '', description: '', type: '', hasFrontmatter: false }
|
|
55
|
+
const body = m[1]
|
|
56
|
+
const grab = (key) => {
|
|
57
|
+
const hit = body.match(new RegExp(`^${key}:[ \\t]*(.+?)[ \\t\\r]*$`, 'm'))
|
|
58
|
+
return hit ? hit[1].replace(/^["']|["']$/g, '').trim() : ''
|
|
59
|
+
}
|
|
60
|
+
const nested = body.match(/^metadata:\s*\r?\n(?:\s+[^\r\n]+\r?\n)*?\s+type:\s*(\S+)/m)
|
|
61
|
+
const type = grab('type') || (nested ? nested[1] : '')
|
|
62
|
+
return {
|
|
63
|
+
name: grab('name'),
|
|
64
|
+
description: grab('description'),
|
|
65
|
+
type: String(type).trim(),
|
|
66
|
+
hasFrontmatter: true,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 抽 MEMORY.md 里的相对链接目标(`](file.md)` / `](file.md#L12)`)。
|
|
72
|
+
* @param {string} indexText
|
|
73
|
+
* @returns {string[]} 去重后的文件名列表
|
|
74
|
+
*/
|
|
75
|
+
export function indexTargets(indexText) {
|
|
76
|
+
const out = new Set()
|
|
77
|
+
for (const m of indexText.matchAll(/\]\(([^)]+\.md)(?:#[^)]*)?\)/g)) {
|
|
78
|
+
const raw = m[1].trim()
|
|
79
|
+
// 跳过外链与子目录路径:索引只指根目录下的记忆文件。
|
|
80
|
+
if (/^[a-z]+:\/\//i.test(raw) || raw.includes('/') || raw.includes('\\')) continue
|
|
81
|
+
out.add(raw)
|
|
82
|
+
}
|
|
83
|
+
return [...out]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 扫一个目录下的记忆文件(不含子目录 —— 归档区就该扫不到),
|
|
88
|
+
* 外加归档区的**名字池**(只有名字,不做检查)。
|
|
89
|
+
* @param {string} dir
|
|
90
|
+
* @returns {Promise<{files: string[], all: string[], skipped: number, indexText: string, archiveKeys: Set<string>, archiveCount: number}>}
|
|
91
|
+
*/
|
|
92
|
+
async function readDirSnapshot(dir) {
|
|
93
|
+
let entries
|
|
94
|
+
entries = await readdir(dir, { withFileTypes: true })
|
|
95
|
+
const all = entries
|
|
96
|
+
.filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.md'))
|
|
97
|
+
.map((e) => e.name)
|
|
98
|
+
const files = all.filter((f) => !NON_ENTRY_FILES.has(f.toLowerCase()))
|
|
99
|
+
let indexText = ''
|
|
100
|
+
try {
|
|
101
|
+
indexText = await readFile(join(dir, INDEX_FILE), 'utf8')
|
|
102
|
+
} catch (err) {
|
|
103
|
+
if (err?.code !== 'ENOENT') throw err
|
|
104
|
+
}
|
|
105
|
+
// 归档区:文件名、去掉 .md 的名字、以及 frontmatter 里的 `name`。
|
|
106
|
+
// 内容读不动只少一个名字,绝不让整场审计失败 —— 归档区本来就不是被审对象。
|
|
107
|
+
const archiveKeys = new Set()
|
|
108
|
+
let archiveCount = 0
|
|
109
|
+
try {
|
|
110
|
+
const archived = await readdir(join(dir, ARCHIVE_DIR), { withFileTypes: true })
|
|
111
|
+
for (const e of archived) {
|
|
112
|
+
if (!e.isFile() || !e.name.toLowerCase().endsWith('.md')) continue
|
|
113
|
+
archiveCount++
|
|
114
|
+
archiveKeys.add(e.name)
|
|
115
|
+
archiveKeys.add(e.name.replace(/\.md$/i, ''))
|
|
116
|
+
try {
|
|
117
|
+
const meta = parseFrontmatterLite(await readFile(join(dir, ARCHIVE_DIR, e.name), 'utf8'))
|
|
118
|
+
if (meta.name) archiveKeys.add(meta.name.trim())
|
|
119
|
+
} catch {
|
|
120
|
+
/* 读不动:名字池少一条而已 */
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
} catch (err) {
|
|
124
|
+
if (err?.code !== 'ENOENT') throw err
|
|
125
|
+
}
|
|
126
|
+
return { files, all, skipped: all.length - files.length, indexText, archiveKeys, archiveCount }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 七查主入口。
|
|
131
|
+
* @param {string} dir — 记忆根目录
|
|
132
|
+
* @returns {Promise<object>} 结构化审计结果(problems 是六项之和)
|
|
133
|
+
*/
|
|
134
|
+
export async function auditMemoryDir(dir) {
|
|
135
|
+
const { files, all, skipped, indexText, archiveKeys, archiveCount } = await readDirSnapshot(dir)
|
|
136
|
+
const entries = files.filter((f) => f !== INDEX_FILE)
|
|
137
|
+
|
|
138
|
+
const targets = indexTargets(indexText)
|
|
139
|
+
const targetSet = new Set(targets.map((t) => t.toLowerCase()))
|
|
140
|
+
// 悬空判定用 `all`(含 session-log.md):索引万一真引用了它,那是索引的问题,
|
|
141
|
+
// 不该因为「审计不检查 session-log」而被顺手放过。
|
|
142
|
+
const allSet = new Set(all.map((f) => f.toLowerCase()))
|
|
143
|
+
|
|
144
|
+
const dangling = targets.filter((t) => !allSet.has(t.toLowerCase()))
|
|
145
|
+
const unlisted = entries.filter((f) => !targetSet.has(f.toLowerCase()))
|
|
146
|
+
|
|
147
|
+
const typeMismatch = []
|
|
148
|
+
const crlf = []
|
|
149
|
+
const hashHazards = []
|
|
150
|
+
const brokenLinks = []
|
|
151
|
+
const archivedLinks = []
|
|
152
|
+
const noFrontmatter = []
|
|
153
|
+
const linkKeys = new Set()
|
|
154
|
+
|
|
155
|
+
const contents = new Map()
|
|
156
|
+
/**
|
|
157
|
+
* 读不动的文件(权限、被占用、编码坏)。
|
|
158
|
+
*
|
|
159
|
+
* **绝不抛**:`run()` 把「运行前审计」当作开工前置,裸抛一个 EPERM 会让
|
|
160
|
+
* 「有个文件读不了」升级成「自动整理整个停摆」,而且报出来的是宿主内部的原文。
|
|
161
|
+
* 它们单列一类,**不参与**后面每一项检查 —— 免得被戴上「没 frontmatter」「CRLF」的帽子
|
|
162
|
+
*/
|
|
163
|
+
const unreadable = []
|
|
164
|
+
for (const f of files) {
|
|
165
|
+
try {
|
|
166
|
+
contents.set(f, await readFile(join(dir, f), 'utf8'))
|
|
167
|
+
} catch {
|
|
168
|
+
unreadable.push(f)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const readable = files.filter((f) => contents.has(f))
|
|
172
|
+
for (const f of readable) {
|
|
173
|
+
const meta = parseFrontmatterLite(contents.get(f))
|
|
174
|
+
if (meta.name) linkKeys.add(meta.name.trim())
|
|
175
|
+
}
|
|
176
|
+
for (const f of readable) linkKeys.add(f)
|
|
177
|
+
for (const f of readable) linkKeys.add(f.replace(/\.md$/, ''))
|
|
178
|
+
|
|
179
|
+
for (const f of readable) {
|
|
180
|
+
const raw = contents.get(f)
|
|
181
|
+
if (raw.includes('\r')) crlf.push(f)
|
|
182
|
+
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
|
183
|
+
if (!fmMatch) {
|
|
184
|
+
noFrontmatter.push(f)
|
|
185
|
+
} else {
|
|
186
|
+
const meta = parseFrontmatterLite(raw)
|
|
187
|
+
const prefix = f.split('_')[0]
|
|
188
|
+
const effective = meta.type || 'reference'
|
|
189
|
+
if (effective !== prefix) {
|
|
190
|
+
typeMismatch.push({ file: f, type: meta.type || '(缺失)', prefix, effective })
|
|
191
|
+
}
|
|
192
|
+
for (const line of fmMatch[1].split(/\r?\n/)) {
|
|
193
|
+
const mv = line.match(YAML_LEAF_RE)
|
|
194
|
+
if (mv && !/^["']/.test(mv[1]) && / #/.test(mv[1])) {
|
|
195
|
+
hashHazards.push({ file: f, line: line.trim().slice(0, 100) })
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
for (const m of raw.matchAll(/\[\[([^\]]+)\]\]/g)) {
|
|
200
|
+
const target = m[1].trim()
|
|
201
|
+
if (linkKeys.has(target)) continue
|
|
202
|
+
// 指向归档区的链接:单列提示、不计问题 —— 归档不是删除,目标还在库里躺着。
|
|
203
|
+
if (archiveKeys.has(target)) {
|
|
204
|
+
archivedLinks.push({ file: f, target })
|
|
205
|
+
continue
|
|
206
|
+
}
|
|
207
|
+
brokenLinks.push({ file: f, target })
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const problems =
|
|
212
|
+
dangling.length +
|
|
213
|
+
unlisted.length +
|
|
214
|
+
typeMismatch.length +
|
|
215
|
+
crlf.length +
|
|
216
|
+
brokenLinks.length +
|
|
217
|
+
hashHazards.length +
|
|
218
|
+
noFrontmatter.length +
|
|
219
|
+
unreadable.length
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
dir,
|
|
223
|
+
fileCount: entries.length,
|
|
224
|
+
skipped,
|
|
225
|
+
archivedCount: archiveCount,
|
|
226
|
+
indexEntries: targets.length,
|
|
227
|
+
dangling: dangling.map((file) => ({ file })),
|
|
228
|
+
unlisted: unlisted.map((file) => ({ file })),
|
|
229
|
+
typeMismatch,
|
|
230
|
+
crlf: crlf.map((file) => ({ file })),
|
|
231
|
+
brokenLinks,
|
|
232
|
+
// 指向归档的链接是提示,**不进 problems**(见文件头那段)。
|
|
233
|
+
archivedLinks,
|
|
234
|
+
hashHazards,
|
|
235
|
+
noFrontmatter: noFrontmatter.map((file) => ({ file })),
|
|
236
|
+
unreadable: unreadable.map((file) => ({ file })),
|
|
237
|
+
problems,
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* 把审计结果压成给模型看的短文本(autodream 工具返回值 / 报告附录都用它)。
|
|
243
|
+
* @param {object} report — auditMemoryDir 的返回值
|
|
244
|
+
* @param {number} [limit] — 每个分项最多列几条
|
|
245
|
+
* @returns {string}
|
|
246
|
+
*/
|
|
247
|
+
export function formatAudit(report, limit = 12) {
|
|
248
|
+
const lines = [
|
|
249
|
+
`扫描 ${report.fileCount} 个记忆文件 · 索引 ${report.indexEntries} 条 · 问题合计 ${report.problems}`,
|
|
250
|
+
]
|
|
251
|
+
const section = (title, rows, render) => {
|
|
252
|
+
if (!rows.length) return
|
|
253
|
+
lines.push(`${title} (${rows.length})`)
|
|
254
|
+
for (const r of rows.slice(0, limit)) lines.push(` - ${render(r)}`)
|
|
255
|
+
if (rows.length > limit) lines.push(` … 另有 ${rows.length - limit} 条`)
|
|
256
|
+
}
|
|
257
|
+
section('索引悬空', report.dangling, (r) => r.file)
|
|
258
|
+
section('漏索引', report.unlisted, (r) => r.file)
|
|
259
|
+
section('type 与前缀不一致', report.typeMismatch, (r) => `${r.file} type=${r.type} 前缀=${r.prefix}`)
|
|
260
|
+
section('CRLF', report.crlf, (r) => r.file)
|
|
261
|
+
section('双链断链', report.brokenLinks, (r) => `${r.file} -> [[${r.target}]]`)
|
|
262
|
+
// 指向归档单列且写明「不计问题」:否则模型会去「修」一条本来正确的链接。
|
|
263
|
+
section('指向已归档(提示,不计入问题)', report.archivedLinks ?? [], (r) => `${r.file} -> [[${r.target}]]`)
|
|
264
|
+
section('未加引号的「空格+#」', report.hashHazards, (r) => `${r.file} :: ${r.line}`)
|
|
265
|
+
section('无 frontmatter', report.noFrontmatter, (r) => r.file)
|
|
266
|
+
// 读不动的单列,且排最后:它不是「这条记忆写错了」,是「我没读到它」——
|
|
267
|
+
// 别让模型以为去改改 frontmatter 就能好。
|
|
268
|
+
section('读不动(权限/占用/编码,不参与上面各项检查)', report.unreadable ?? [], (r) => r.file)
|
|
269
|
+
return lines.join('\n')
|
|
270
|
+
}
|