sage-mem 0.6.0 → 0.6.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.md +9 -8
- package/lib/client.js +8 -8
- package/lib/dream-audit.js +211 -211
- package/lib/dream-prompt.js +121 -123
- package/lib/dream-tools.js +365 -366
- package/lib/dream.js +886 -887
- package/lib/index.js +4 -3
- package/package.json +87 -86
package/README.md
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
记忆存在**本地 markdown 文件**里(frontmatter + 正文),不是数据库。透明、可检查、防膨胀。
|
|
6
6
|
|
|
7
|
-
> **DSH 版本兼容性(0.5.3 核对于 `0.1.7-rc.1`)**
|
|
7
|
+
> **DSH 版本兼容性(0.6.2 核对于 `0.2.0-rc.1`;0.5.3 核对于 `0.1.7-rc.1`)**
|
|
8
|
+
> `@deepseek-ai/dsh-typert-protocol` 与 `@deepseek-ai/dsh-client-locale` 的 `lib/` 在 `0.1.7-rc.2` → `0.2.0-rc.1` 之间**逐字节未变**(导出符号零增删、签名零变化),peer 范围随之放宽为 `^0.1.2-rc.1 || ^0.2.0-rc.1`,同时覆盖两条版本线;插件在 0.2.0 上无需改动即可工作。
|
|
8
9
|
> `session.deriveMessages()`(仍是同步)、`system-prompt/assemble` 事件、以及 `context.agent.session` 这条链,在 0.1.6 → 0.1.7 之间**均未改变**,本插件在 0.1.7 上无需改动即可工作。
|
|
9
10
|
> 0.5.3 另加了一层防御:`deriveMessages()` 若哪天被改成异步,或 `Session` 上的历史读取接口被改名(0.1.7 起 `snapshotEvents` / `eventAt` / `ownEvents` 已标记弃用),插件会在**日志里报警**,而不是像以前那样静默地不再注入记忆。0.1.6 上的行为与 0.5.2 完全一致。
|
|
10
11
|
|
|
@@ -37,30 +38,30 @@ sage-mem 换成文件式:每条记忆一个 markdown 文件,直接用编辑
|
|
|
37
38
|
- **Web 文件管理器**(v0.4):DSH 设置页「记忆管理」,浏览/查看/编辑/删除记忆文件,添加新记忆
|
|
38
39
|
- **类型与标签筛选**(v0.6):文件列表顶部一排 chip,按四类 + 自定义标签筛;自定义标签写在 frontmatter 的 `tags`(flow / block / 逗号三种写法都认),从全库自动聚合带计数;与搜索框是 AND 关系
|
|
39
40
|
- **记忆星图**(v0.4 加入,v0.6 重做):把记忆画成星空,按类型着色、按体积分级、按新鲜度分亮度,支持时间线与「诞生回放」。v0.6 换了渲染材质(中性深灰画布、锐利核心 + 收敛辉光、去掉衍射星芒与脉冲环),并把常驻满帧循环改成**按需重绘**:画面静止时不发起任何绘制
|
|
40
|
-
-
|
|
41
|
+
- **做梦 dream**(v0.6):可选的记忆整理功能,见下一节
|
|
41
42
|
|
|
42
|
-
##
|
|
43
|
+
## 做梦(dream)
|
|
43
44
|
|
|
44
|
-
|
|
45
|
+
把记忆目录通读一遍:合并重复的、删掉被推翻的、修索引与断链、把相对日期换成绝对日期,让下一次会话能快速对上位置。
|
|
45
46
|
|
|
46
|
-
**默认关闭**,在设置页「记忆管理 →
|
|
47
|
+
**默认关闭**,在设置页「记忆管理 → 做梦」里开。三件事各自可选:
|
|
47
48
|
|
|
48
49
|
| 选项 | 取值 | 说明 |
|
|
49
50
|
|---|---|---|
|
|
50
|
-
| 触发 | 只手动 / 满足条件自动跑 |
|
|
51
|
+
| 触发 | 只手动 / 满足条件自动跑 | 自动模式走三级门控,**最便宜的先查**:时间门(距上次 ≥ N 小时,一次读写)→ 会话门(期间 ≥ M 个会话有更新,一次目录扫描)→ 锁(防并发)。中间还夹一层 10 分钟扫描节流,免得「时间门过了、会话门没过」时每轮都重扫一遍
|
|
51
52
|
| 改动方式 | 只出报告 / 直接改写 | 「只出报告」模式下**写工具根本不会挂给模型** —— 不是靠一句「请不要写」约束,是它没有手
|
|
52
53
|
| 输入源 | 仅记忆目录 / 记忆 + 会话记录 | 后者会解压会话记录(zstd)做定向关键词搜索;代价是私密对话会进入模型上下文,token 成本也更高
|
|
53
54
|
|
|
54
55
|
模型可配(留空则跟随聊天页当前的默认模型),最多轮数、快照保留份数在「高级设置」里。
|
|
55
56
|
|
|
56
|
-
|
|
57
|
+
**两道额外的安全网:**
|
|
57
58
|
|
|
58
59
|
1. **改写前自动快照**整个记忆目录(只快照顶层 `.md`,不含 `archive/`),落在 `<memory 目录的上一层>/.sage-mem/snapshots/<时间戳>/`,保留最近 N 份。理由是 memory 目录**不在任何版本控制下**、删除没有回收站,「可回滚」得自己造。
|
|
59
60
|
2. **跑完自动审计**:六查(索引悬空 / 漏索引 / `type` 与文件名前缀不一致 / CRLF / 双链断链 / frontmatter 里被 YAML 当注释吞掉的「空格+#」),结果与前后对比写进报告。它只报不改。
|
|
60
61
|
|
|
61
62
|
报告落在 `memory/dream/YYYYMMDD-HHmmss.md` —— 子目录,不会被扫成记忆条目。
|
|
62
63
|
|
|
63
|
-
|
|
64
|
+
**安全边界在工具集上,不在提示词里**:做梦时宿主只把 `list_memory` / `read_memory` / `audit_memory`(+ 只读模式下的 `search_sessions`)交给模型;改写模式才多给 `write_memory` / `archive_memory`。而且没有「删除」这个能力 —— 归档是把文件移进 `archive/`,可人工捞回。
|
|
64
65
|
|
|
65
66
|
## 架构
|
|
66
67
|
|
package/lib/client.js
CHANGED
|
@@ -1462,7 +1462,7 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
1462
1462
|
result: { mode: "strict", typeSymbol: "sage-mem/types#StarmapRead", schema: starmapReadSchema, create: () => starmapReadSchema },
|
|
1463
1463
|
sourceLocation: { "file": "workspace/sage-mem/lib/index.js", "line": 1, "column": 1 },
|
|
1464
1464
|
},
|
|
1465
|
-
// ── dream
|
|
1465
|
+
// ── dream(做梦)──
|
|
1466
1466
|
// 2026-09-27 踩到的坑:namespace 不会自动出现。dsh-api-remotes 里那份
|
|
1467
1467
|
// remote 清单是**官方构建期生成的产物**,只有 memory / starmap 在里面;
|
|
1468
1468
|
// 后加的 dream 不在,于是 `ctx.get("remote.dream")` 永远 undefined ——
|
|
@@ -1604,7 +1604,7 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
1604
1604
|
// 高度预算压到 62vh(原 70vh):设置弹窗本身高度有限,
|
|
1605
1605
|
// 70vh 在 1050px 视口下会把星图底部的图例和计数挤出面板之外(实测截图被裁)。
|
|
1606
1606
|
".smem-starmap{display:flex;flex-direction:column;height:min(62vh,660px);min-height:400px;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;overflow:hidden;}",
|
|
1607
|
-
// ──
|
|
1607
|
+
// ── 「做梦」面板 ──
|
|
1608
1608
|
// 融入型:全部走宿主变量,控件复用已有的 chip / input / btn 语言,不另造一套。
|
|
1609
1609
|
".smem-dream{display:flex;flex-direction:column;gap:12px;}",
|
|
1610
1610
|
".smem-dream-sec{border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-layer-1);padding:10px 12px;display:flex;flex-direction:column;gap:9px;}",
|
|
@@ -1959,7 +1959,7 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
1959
1959
|
}
|
|
1960
1960
|
|
|
1961
1961
|
/**
|
|
1962
|
-
*
|
|
1962
|
+
* 「做梦」面板 —— 让记忆自己整理一遍(引擎在 host 半 lib/dream.js)。
|
|
1963
1963
|
*
|
|
1964
1964
|
* 数据全走 remote.dream(host 半的 DreamGateway)。两条实现约定:
|
|
1965
1965
|
* 1. **轮询而不是等待**。「立即整理」启动后台任务后立刻返回 —— 一趟可能跑
|
|
@@ -2037,15 +2037,15 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
2037
2037
|
type: "button", key: "sw", className: "smem-switch",
|
|
2038
2038
|
"data-on": c.enabled ? "1" : "0",
|
|
2039
2039
|
title: c.enabled ? "点击停用" : "点击启用",
|
|
2040
|
-
"aria-label": "
|
|
2040
|
+
"aria-label": "启用或停用做梦",
|
|
2041
2041
|
onClick: function () { patch({ enabled: !c.enabled }); },
|
|
2042
2042
|
}),
|
|
2043
|
-
h("span", { key: "t", style: { fontWeight: "600" } }, "
|
|
2043
|
+
h("span", { key: "t", style: { fontWeight: "600" } }, "做梦"),
|
|
2044
2044
|
h("span", { className: "smem-status", key: "s" },
|
|
2045
2045
|
running ? ("运行中 · " + String((st && st.phase) || "准备中")) : (c.enabled ? "已启用 · 空闲" : "未启用")),
|
|
2046
2046
|
]),
|
|
2047
2047
|
h("div", { className: "smem-dream-note", key: "n" },
|
|
2048
|
-
"
|
|
2048
|
+
"把记忆目录通读一遍:合并重复的、删掉被推翻的、修索引与断链、把相对日期换成绝对日期。"),
|
|
2049
2049
|
]),
|
|
2050
2050
|
h("div", { className: "smem-dream-sec", key: "mode" }, [
|
|
2051
2051
|
h("div", { className: "smem-dream-row", key: "a" }, [
|
|
@@ -2387,7 +2387,7 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
2387
2387
|
h("div", { className: "smem-tabs", key: "tabs" }, [
|
|
2388
2388
|
h("button", { type: "button", className: "smem-tab" + (tab === "files" ? " smem-tab--on" : ""), key: "f", onClick: function () { setTab("files"); } }, "文件列表"),
|
|
2389
2389
|
h("button", { type: "button", className: "smem-tab" + (tab === "star" ? " smem-tab--on" : ""), key: "s", onClick: function () { setTab("star"); } }, "记忆星图"),
|
|
2390
|
-
h("button", { type: "button", className: "smem-tab" + (tab === "dream" ? " smem-tab--on" : ""), key: "d", onClick: function () { setTab("dream"); } }, "
|
|
2390
|
+
h("button", { type: "button", className: "smem-tab" + (tab === "dream" ? " smem-tab--on" : ""), key: "d", onClick: function () { setTab("dream"); } }, "做梦"),
|
|
2391
2391
|
]),
|
|
2392
2392
|
h("div", { className: "smem-head", key: "head" }, [
|
|
2393
2393
|
h("span", { className: "smem-title", key: "t" }, "记忆文件(sage-mem)"),
|
|
@@ -2398,7 +2398,7 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
2398
2398
|
: "共 " + String(items.length) + " 个文件"),
|
|
2399
2399
|
]),
|
|
2400
2400
|
// 搜索 / 刷新 / 添加 / 筛选都只属于「文件列表」这个视图。
|
|
2401
|
-
// 放在 tab
|
|
2401
|
+
// 放在 tab 分支外面,会让星图和做梦页也顶着一排用不上的控件
|
|
2402
2402
|
// (2026-09-27 截图走查时发现:「+ 添加记忆」出现在星图页上)。
|
|
2403
2403
|
tab === "files" ? h("div", { className: "smem-toolbar", key: "bar" }, [
|
|
2404
2404
|
h("input", { className: "smem-search", key: "q", value: query, placeholder: "搜索文件名 / 描述 / 类型 / 标签…", onChange: function (e) { setQuery(e.target.value); } }),
|
package/lib/dream-audit.js
CHANGED
|
@@ -1,211 +1,211 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* sage-mem dream — 记忆目录审计(六查)。
|
|
3
|
-
*
|
|
4
|
-
* 把 `sage-memory` 技能里的审计清单搬到 host 侧,让 dream 每次跑完都能自证
|
|
5
|
-
* 没把记忆改坏:
|
|
6
|
-
* 1. 索引悬空 —— MEMORY.md 指向不存在的文件
|
|
7
|
-
* 2. 漏索引 —— 存在但没进索引的记忆文件
|
|
8
|
-
* 3. type 与前缀不一致(分类看 type,前缀骗不了它)
|
|
9
|
-
* 4. CRLF —— 「某个 Windows 编辑器碰过」的可疑信号
|
|
10
|
-
* 5. 双链断链 —— [[目标]] 找不到对应文件或 frontmatter name
|
|
11
|
-
* 6. 未加引号值里的「空格+#」—— 会被 YAML 当注释吞掉,解析不报错但描述残缺
|
|
12
|
-
*
|
|
13
|
-
* **纯只读**:不修任何东西。修哪条、怎么修,由 dream agent 或人决定。
|
|
14
|
-
*
|
|
15
|
-
* 这一份解析是 sage-mem host 半 parseFrontmatter 的同源精简版,故意不共用:
|
|
16
|
-
* 那边带着一长串历史坑的注释与行为(baseline、type 回落),审计只读不写,
|
|
17
|
-
* 重复二十行换零回归风险是划算的。
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import { readdir, readFile } from 'node:fs/promises'
|
|
21
|
-
import { join } from 'node:path'
|
|
22
|
-
|
|
23
|
-
/** 索引文件名。它本身不是一条记忆,是索引。 */
|
|
24
|
-
const INDEX_FILE = 'MEMORY.md'
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* 非记忆条目:不是四类记忆、也不该套「type 与前缀一致」这条规则。
|
|
28
|
-
*
|
|
29
|
-
* session-log.md 是追加型流水(本机已 390+ KB),逐会话的账本而不是一条记忆;
|
|
30
|
-
* 它没有 `user_/feedback_/project_/reference_` 前缀,扫进来只会白白读一大坨
|
|
31
|
-
* 再报一条假问题(2026-09-27 实测:报出 `session-log.md type=reference`)。
|
|
32
|
-
* 同理 MEMORY.md 是索引本身,只作为链接来源读,不作为被审对象。
|
|
33
|
-
*/
|
|
34
|
-
const NON_ENTRY_FILES = new Set(['session-log.md'])
|
|
35
|
-
|
|
36
|
-
/** YAML 行形态:`key: value`(顶层键,不含缩进的嵌套行)。 */
|
|
37
|
-
const YAML_LEAF_RE = /^[A-Za-z0-9_.-]+:\s*(.*)$/
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* 极简 frontmatter 解析,读法与 host 半一致:
|
|
41
|
-
* 顶层 `type:` 优先,没有才下钻 `metadata.type:`。
|
|
42
|
-
* @param {string} content — 文件全文
|
|
43
|
-
* @returns {{name: string, description: string, type: string, hasFrontmatter: boolean}}
|
|
44
|
-
*/
|
|
45
|
-
export function parseFrontmatterLite(content) {
|
|
46
|
-
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
|
47
|
-
if (!m) return { name: '', description: '', type: '', hasFrontmatter: false }
|
|
48
|
-
const body = m[1]
|
|
49
|
-
const grab = (key) => {
|
|
50
|
-
const hit = body.match(new RegExp(`^${key}:[ \\t]*(.+?)[ \\t\\r]*$`, 'm'))
|
|
51
|
-
return hit ? hit[1].replace(/^["']|["']$/g, '').trim() : ''
|
|
52
|
-
}
|
|
53
|
-
const nested = body.match(/^metadata:\s*\r?\n(?:\s+[^\r\n]+\r?\n)*?\s+type:\s*(\S+)/m)
|
|
54
|
-
const type = grab('type') || (nested ? nested[1] : '')
|
|
55
|
-
return {
|
|
56
|
-
name: grab('name'),
|
|
57
|
-
description: grab('description'),
|
|
58
|
-
type: String(type).trim(),
|
|
59
|
-
hasFrontmatter: true,
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* 抽 MEMORY.md 里的相对链接目标(`](file.md)` / `](file.md#L12)`)。
|
|
65
|
-
* @param {string} indexText
|
|
66
|
-
* @returns {string[]} 去重后的文件名列表
|
|
67
|
-
*/
|
|
68
|
-
export function indexTargets(indexText) {
|
|
69
|
-
const out = new Set()
|
|
70
|
-
for (const m of indexText.matchAll(/\]\(([^)]+\.md)(?:#[^)]*)?\)/g)) {
|
|
71
|
-
const raw = m[1].trim()
|
|
72
|
-
// 跳过外链与子目录路径:索引只指根目录下的记忆文件。
|
|
73
|
-
if (/^[a-z]+:\/\//i.test(raw) || raw.includes('/') || raw.includes('\\')) continue
|
|
74
|
-
out.add(raw)
|
|
75
|
-
}
|
|
76
|
-
return [...out]
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* 扫一个目录下的记忆文件(不含子目录 —— 归档区就该扫不到)。
|
|
81
|
-
* @param {string} dir
|
|
82
|
-
* @returns {Promise<{files: string[], indexText: string}>}
|
|
83
|
-
*/
|
|
84
|
-
async function readDirSnapshot(dir) {
|
|
85
|
-
let entries
|
|
86
|
-
entries = await readdir(dir, { withFileTypes: true })
|
|
87
|
-
const all = entries
|
|
88
|
-
.filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.md'))
|
|
89
|
-
.map((e) => e.name)
|
|
90
|
-
const files = all.filter((f) => !NON_ENTRY_FILES.has(f.toLowerCase()))
|
|
91
|
-
let indexText = ''
|
|
92
|
-
try {
|
|
93
|
-
indexText = await readFile(join(dir, INDEX_FILE), 'utf8')
|
|
94
|
-
} catch (err) {
|
|
95
|
-
if (err?.code !== 'ENOENT') throw err
|
|
96
|
-
}
|
|
97
|
-
return { files, all, skipped: all.length - files.length, indexText }
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* 六查主入口。
|
|
102
|
-
* @param {string} dir — 记忆根目录
|
|
103
|
-
* @returns {Promise<object>} 结构化审计结果(problems 是六项之和)
|
|
104
|
-
*/
|
|
105
|
-
export async function auditMemoryDir(dir) {
|
|
106
|
-
const { files, all, skipped, indexText } = await readDirSnapshot(dir)
|
|
107
|
-
const entries = files.filter((f) => f !== INDEX_FILE)
|
|
108
|
-
|
|
109
|
-
const targets = indexTargets(indexText)
|
|
110
|
-
const targetSet = new Set(targets.map((t) => t.toLowerCase()))
|
|
111
|
-
// 悬空判定用 `all`(含 session-log.md):索引万一真引用了它,那是索引的问题,
|
|
112
|
-
// 不该因为「审计不检查 session-log」而被顺手放过。
|
|
113
|
-
const allSet = new Set(all.map((f) => f.toLowerCase()))
|
|
114
|
-
|
|
115
|
-
const dangling = targets.filter((t) => !allSet.has(t.toLowerCase()))
|
|
116
|
-
const unlisted = entries.filter((f) => !targetSet.has(f.toLowerCase()))
|
|
117
|
-
|
|
118
|
-
const typeMismatch = []
|
|
119
|
-
const crlf = []
|
|
120
|
-
const hashHazards = []
|
|
121
|
-
const brokenLinks = []
|
|
122
|
-
const noFrontmatter = []
|
|
123
|
-
const linkKeys = new Set()
|
|
124
|
-
|
|
125
|
-
const contents = new Map()
|
|
126
|
-
for (const f of files) {
|
|
127
|
-
contents.set(f, await readFile(join(dir, f), 'utf8'))
|
|
128
|
-
}
|
|
129
|
-
for (const f of files) {
|
|
130
|
-
const meta = parseFrontmatterLite(contents.get(f))
|
|
131
|
-
if (meta.name) linkKeys.add(meta.name.trim())
|
|
132
|
-
}
|
|
133
|
-
for (const f of files) linkKeys.add(f)
|
|
134
|
-
for (const f of files) linkKeys.add(f.replace(/\.md$/, ''))
|
|
135
|
-
|
|
136
|
-
for (const f of entries) {
|
|
137
|
-
const raw = contents.get(f)
|
|
138
|
-
if (raw.includes('\r')) crlf.push(f)
|
|
139
|
-
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
|
140
|
-
if (!fmMatch) {
|
|
141
|
-
noFrontmatter.push(f)
|
|
142
|
-
} else {
|
|
143
|
-
const meta = parseFrontmatterLite(raw)
|
|
144
|
-
const prefix = f.split('_')[0]
|
|
145
|
-
const effective = meta.type || 'reference'
|
|
146
|
-
if (effective !== prefix) {
|
|
147
|
-
typeMismatch.push({ file: f, type: meta.type || '(缺失)', prefix, effective })
|
|
148
|
-
}
|
|
149
|
-
for (const line of fmMatch[1].split(/\r?\n/)) {
|
|
150
|
-
const mv = line.match(YAML_LEAF_RE)
|
|
151
|
-
if (mv && !/^["']/.test(mv[1]) && / #/.test(mv[1])) {
|
|
152
|
-
hashHazards.push({ file: f, line: line.trim().slice(0, 100) })
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
for (const m of raw.matchAll(/\[\[([^\]]+)\]\]/g)) {
|
|
157
|
-
const target = m[1].trim()
|
|
158
|
-
if (!linkKeys.has(target)) brokenLinks.push({ file: f, target })
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
const problems =
|
|
163
|
-
dangling.length +
|
|
164
|
-
unlisted.length +
|
|
165
|
-
typeMismatch.length +
|
|
166
|
-
crlf.length +
|
|
167
|
-
brokenLinks.length +
|
|
168
|
-
hashHazards.length +
|
|
169
|
-
noFrontmatter.length
|
|
170
|
-
|
|
171
|
-
return {
|
|
172
|
-
dir,
|
|
173
|
-
fileCount: entries.length,
|
|
174
|
-
skipped,
|
|
175
|
-
indexEntries: targets.length,
|
|
176
|
-
dangling: dangling.map((file) => ({ file })),
|
|
177
|
-
unlisted: unlisted.map((file) => ({ file })),
|
|
178
|
-
typeMismatch,
|
|
179
|
-
crlf: crlf.map((file) => ({ file })),
|
|
180
|
-
brokenLinks,
|
|
181
|
-
hashHazards,
|
|
182
|
-
noFrontmatter: noFrontmatter.map((file) => ({ file })),
|
|
183
|
-
problems,
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* 把审计结果压成给模型看的短文本(dream 工具返回值 / 报告附录都用它)。
|
|
189
|
-
* @param {object} report — auditMemoryDir 的返回值
|
|
190
|
-
* @param {number} [limit] — 每个分项最多列几条
|
|
191
|
-
* @returns {string}
|
|
192
|
-
*/
|
|
193
|
-
export function formatAudit(report, limit = 12) {
|
|
194
|
-
const lines = [
|
|
195
|
-
`扫描 ${report.fileCount} 个记忆文件 · 索引 ${report.indexEntries} 条 · 问题合计 ${report.problems}`,
|
|
196
|
-
]
|
|
197
|
-
const section = (title, rows, render) => {
|
|
198
|
-
if (!rows.length) return
|
|
199
|
-
lines.push(`${title} (${rows.length})`)
|
|
200
|
-
for (const r of rows.slice(0, limit)) lines.push(` - ${render(r)}`)
|
|
201
|
-
if (rows.length > limit) lines.push(` … 另有 ${rows.length - limit} 条`)
|
|
202
|
-
}
|
|
203
|
-
section('索引悬空', report.dangling, (r) => r.file)
|
|
204
|
-
section('漏索引', report.unlisted, (r) => r.file)
|
|
205
|
-
section('type 与前缀不一致', report.typeMismatch, (r) => `${r.file} type=${r.type} 前缀=${r.prefix}`)
|
|
206
|
-
section('CRLF', report.crlf, (r) => r.file)
|
|
207
|
-
section('双链断链', report.brokenLinks, (r) => `${r.file} -> [[${r.target}]]`)
|
|
208
|
-
section('未加引号的「空格+#」', report.hashHazards, (r) => `${r.file} :: ${r.line}`)
|
|
209
|
-
section('无 frontmatter', report.noFrontmatter, (r) => r.file)
|
|
210
|
-
return lines.join('\n')
|
|
211
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* sage-mem dream — 记忆目录审计(六查)。
|
|
3
|
+
*
|
|
4
|
+
* 把 `sage-memory` 技能里的审计清单搬到 host 侧,让 dream 每次跑完都能自证
|
|
5
|
+
* 没把记忆改坏:
|
|
6
|
+
* 1. 索引悬空 —— MEMORY.md 指向不存在的文件
|
|
7
|
+
* 2. 漏索引 —— 存在但没进索引的记忆文件
|
|
8
|
+
* 3. type 与前缀不一致(分类看 type,前缀骗不了它)
|
|
9
|
+
* 4. CRLF —— 「某个 Windows 编辑器碰过」的可疑信号
|
|
10
|
+
* 5. 双链断链 —— [[目标]] 找不到对应文件或 frontmatter name
|
|
11
|
+
* 6. 未加引号值里的「空格+#」—— 会被 YAML 当注释吞掉,解析不报错但描述残缺
|
|
12
|
+
*
|
|
13
|
+
* **纯只读**:不修任何东西。修哪条、怎么修,由 dream agent 或人决定。
|
|
14
|
+
*
|
|
15
|
+
* 这一份解析是 sage-mem host 半 parseFrontmatter 的同源精简版,故意不共用:
|
|
16
|
+
* 那边带着一长串历史坑的注释与行为(baseline、type 回落),审计只读不写,
|
|
17
|
+
* 重复二十行换零回归风险是划算的。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { readdir, readFile } from 'node:fs/promises'
|
|
21
|
+
import { join } from 'node:path'
|
|
22
|
+
|
|
23
|
+
/** 索引文件名。它本身不是一条记忆,是索引。 */
|
|
24
|
+
const INDEX_FILE = 'MEMORY.md'
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 非记忆条目:不是四类记忆、也不该套「type 与前缀一致」这条规则。
|
|
28
|
+
*
|
|
29
|
+
* session-log.md 是追加型流水(本机已 390+ KB),逐会话的账本而不是一条记忆;
|
|
30
|
+
* 它没有 `user_/feedback_/project_/reference_` 前缀,扫进来只会白白读一大坨
|
|
31
|
+
* 再报一条假问题(2026-09-27 实测:报出 `session-log.md type=reference`)。
|
|
32
|
+
* 同理 MEMORY.md 是索引本身,只作为链接来源读,不作为被审对象。
|
|
33
|
+
*/
|
|
34
|
+
const NON_ENTRY_FILES = new Set(['session-log.md'])
|
|
35
|
+
|
|
36
|
+
/** YAML 行形态:`key: value`(顶层键,不含缩进的嵌套行)。 */
|
|
37
|
+
const YAML_LEAF_RE = /^[A-Za-z0-9_.-]+:\s*(.*)$/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 极简 frontmatter 解析,读法与 host 半一致:
|
|
41
|
+
* 顶层 `type:` 优先,没有才下钻 `metadata.type:`。
|
|
42
|
+
* @param {string} content — 文件全文
|
|
43
|
+
* @returns {{name: string, description: string, type: string, hasFrontmatter: boolean}}
|
|
44
|
+
*/
|
|
45
|
+
export function parseFrontmatterLite(content) {
|
|
46
|
+
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
|
47
|
+
if (!m) return { name: '', description: '', type: '', hasFrontmatter: false }
|
|
48
|
+
const body = m[1]
|
|
49
|
+
const grab = (key) => {
|
|
50
|
+
const hit = body.match(new RegExp(`^${key}:[ \\t]*(.+?)[ \\t\\r]*$`, 'm'))
|
|
51
|
+
return hit ? hit[1].replace(/^["']|["']$/g, '').trim() : ''
|
|
52
|
+
}
|
|
53
|
+
const nested = body.match(/^metadata:\s*\r?\n(?:\s+[^\r\n]+\r?\n)*?\s+type:\s*(\S+)/m)
|
|
54
|
+
const type = grab('type') || (nested ? nested[1] : '')
|
|
55
|
+
return {
|
|
56
|
+
name: grab('name'),
|
|
57
|
+
description: grab('description'),
|
|
58
|
+
type: String(type).trim(),
|
|
59
|
+
hasFrontmatter: true,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 抽 MEMORY.md 里的相对链接目标(`](file.md)` / `](file.md#L12)`)。
|
|
65
|
+
* @param {string} indexText
|
|
66
|
+
* @returns {string[]} 去重后的文件名列表
|
|
67
|
+
*/
|
|
68
|
+
export function indexTargets(indexText) {
|
|
69
|
+
const out = new Set()
|
|
70
|
+
for (const m of indexText.matchAll(/\]\(([^)]+\.md)(?:#[^)]*)?\)/g)) {
|
|
71
|
+
const raw = m[1].trim()
|
|
72
|
+
// 跳过外链与子目录路径:索引只指根目录下的记忆文件。
|
|
73
|
+
if (/^[a-z]+:\/\//i.test(raw) || raw.includes('/') || raw.includes('\\')) continue
|
|
74
|
+
out.add(raw)
|
|
75
|
+
}
|
|
76
|
+
return [...out]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 扫一个目录下的记忆文件(不含子目录 —— 归档区就该扫不到)。
|
|
81
|
+
* @param {string} dir
|
|
82
|
+
* @returns {Promise<{files: string[], indexText: string}>}
|
|
83
|
+
*/
|
|
84
|
+
async function readDirSnapshot(dir) {
|
|
85
|
+
let entries
|
|
86
|
+
entries = await readdir(dir, { withFileTypes: true })
|
|
87
|
+
const all = entries
|
|
88
|
+
.filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.md'))
|
|
89
|
+
.map((e) => e.name)
|
|
90
|
+
const files = all.filter((f) => !NON_ENTRY_FILES.has(f.toLowerCase()))
|
|
91
|
+
let indexText = ''
|
|
92
|
+
try {
|
|
93
|
+
indexText = await readFile(join(dir, INDEX_FILE), 'utf8')
|
|
94
|
+
} catch (err) {
|
|
95
|
+
if (err?.code !== 'ENOENT') throw err
|
|
96
|
+
}
|
|
97
|
+
return { files, all, skipped: all.length - files.length, indexText }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 六查主入口。
|
|
102
|
+
* @param {string} dir — 记忆根目录
|
|
103
|
+
* @returns {Promise<object>} 结构化审计结果(problems 是六项之和)
|
|
104
|
+
*/
|
|
105
|
+
export async function auditMemoryDir(dir) {
|
|
106
|
+
const { files, all, skipped, indexText } = await readDirSnapshot(dir)
|
|
107
|
+
const entries = files.filter((f) => f !== INDEX_FILE)
|
|
108
|
+
|
|
109
|
+
const targets = indexTargets(indexText)
|
|
110
|
+
const targetSet = new Set(targets.map((t) => t.toLowerCase()))
|
|
111
|
+
// 悬空判定用 `all`(含 session-log.md):索引万一真引用了它,那是索引的问题,
|
|
112
|
+
// 不该因为「审计不检查 session-log」而被顺手放过。
|
|
113
|
+
const allSet = new Set(all.map((f) => f.toLowerCase()))
|
|
114
|
+
|
|
115
|
+
const dangling = targets.filter((t) => !allSet.has(t.toLowerCase()))
|
|
116
|
+
const unlisted = entries.filter((f) => !targetSet.has(f.toLowerCase()))
|
|
117
|
+
|
|
118
|
+
const typeMismatch = []
|
|
119
|
+
const crlf = []
|
|
120
|
+
const hashHazards = []
|
|
121
|
+
const brokenLinks = []
|
|
122
|
+
const noFrontmatter = []
|
|
123
|
+
const linkKeys = new Set()
|
|
124
|
+
|
|
125
|
+
const contents = new Map()
|
|
126
|
+
for (const f of files) {
|
|
127
|
+
contents.set(f, await readFile(join(dir, f), 'utf8'))
|
|
128
|
+
}
|
|
129
|
+
for (const f of files) {
|
|
130
|
+
const meta = parseFrontmatterLite(contents.get(f))
|
|
131
|
+
if (meta.name) linkKeys.add(meta.name.trim())
|
|
132
|
+
}
|
|
133
|
+
for (const f of files) linkKeys.add(f)
|
|
134
|
+
for (const f of files) linkKeys.add(f.replace(/\.md$/, ''))
|
|
135
|
+
|
|
136
|
+
for (const f of entries) {
|
|
137
|
+
const raw = contents.get(f)
|
|
138
|
+
if (raw.includes('\r')) crlf.push(f)
|
|
139
|
+
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
|
140
|
+
if (!fmMatch) {
|
|
141
|
+
noFrontmatter.push(f)
|
|
142
|
+
} else {
|
|
143
|
+
const meta = parseFrontmatterLite(raw)
|
|
144
|
+
const prefix = f.split('_')[0]
|
|
145
|
+
const effective = meta.type || 'reference'
|
|
146
|
+
if (effective !== prefix) {
|
|
147
|
+
typeMismatch.push({ file: f, type: meta.type || '(缺失)', prefix, effective })
|
|
148
|
+
}
|
|
149
|
+
for (const line of fmMatch[1].split(/\r?\n/)) {
|
|
150
|
+
const mv = line.match(YAML_LEAF_RE)
|
|
151
|
+
if (mv && !/^["']/.test(mv[1]) && / #/.test(mv[1])) {
|
|
152
|
+
hashHazards.push({ file: f, line: line.trim().slice(0, 100) })
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
for (const m of raw.matchAll(/\[\[([^\]]+)\]\]/g)) {
|
|
157
|
+
const target = m[1].trim()
|
|
158
|
+
if (!linkKeys.has(target)) brokenLinks.push({ file: f, target })
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const problems =
|
|
163
|
+
dangling.length +
|
|
164
|
+
unlisted.length +
|
|
165
|
+
typeMismatch.length +
|
|
166
|
+
crlf.length +
|
|
167
|
+
brokenLinks.length +
|
|
168
|
+
hashHazards.length +
|
|
169
|
+
noFrontmatter.length
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
dir,
|
|
173
|
+
fileCount: entries.length,
|
|
174
|
+
skipped,
|
|
175
|
+
indexEntries: targets.length,
|
|
176
|
+
dangling: dangling.map((file) => ({ file })),
|
|
177
|
+
unlisted: unlisted.map((file) => ({ file })),
|
|
178
|
+
typeMismatch,
|
|
179
|
+
crlf: crlf.map((file) => ({ file })),
|
|
180
|
+
brokenLinks,
|
|
181
|
+
hashHazards,
|
|
182
|
+
noFrontmatter: noFrontmatter.map((file) => ({ file })),
|
|
183
|
+
problems,
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* 把审计结果压成给模型看的短文本(dream 工具返回值 / 报告附录都用它)。
|
|
189
|
+
* @param {object} report — auditMemoryDir 的返回值
|
|
190
|
+
* @param {number} [limit] — 每个分项最多列几条
|
|
191
|
+
* @returns {string}
|
|
192
|
+
*/
|
|
193
|
+
export function formatAudit(report, limit = 12) {
|
|
194
|
+
const lines = [
|
|
195
|
+
`扫描 ${report.fileCount} 个记忆文件 · 索引 ${report.indexEntries} 条 · 问题合计 ${report.problems}`,
|
|
196
|
+
]
|
|
197
|
+
const section = (title, rows, render) => {
|
|
198
|
+
if (!rows.length) return
|
|
199
|
+
lines.push(`${title} (${rows.length})`)
|
|
200
|
+
for (const r of rows.slice(0, limit)) lines.push(` - ${render(r)}`)
|
|
201
|
+
if (rows.length > limit) lines.push(` … 另有 ${rows.length - limit} 条`)
|
|
202
|
+
}
|
|
203
|
+
section('索引悬空', report.dangling, (r) => r.file)
|
|
204
|
+
section('漏索引', report.unlisted, (r) => r.file)
|
|
205
|
+
section('type 与前缀不一致', report.typeMismatch, (r) => `${r.file} type=${r.type} 前缀=${r.prefix}`)
|
|
206
|
+
section('CRLF', report.crlf, (r) => r.file)
|
|
207
|
+
section('双链断链', report.brokenLinks, (r) => `${r.file} -> [[${r.target}]]`)
|
|
208
|
+
section('未加引号的「空格+#」', report.hashHazards, (r) => `${r.file} :: ${r.line}`)
|
|
209
|
+
section('无 frontmatter', report.noFrontmatter, (r) => r.file)
|
|
210
|
+
return lines.join('\n')
|
|
211
|
+
}
|