sage-mem 0.5.4 → 0.6.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/README.md +26 -0
- package/lib/client.js +632 -66
- package/lib/dream-audit.js +211 -0
- package/lib/dream-prompt.js +123 -0
- package/lib/dream-tools.js +366 -0
- package/lib/dream.js +887 -0
- package/lib/index.js +123 -3
- package/lib/typert.host.js +196 -0
- package/package.json +18 -5
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sage-mem dream — 提示词。
|
|
3
|
+
*
|
|
4
|
+
* 移植自 Claude Code 的 `services/autoDream/consolidationPrompt.ts`
|
|
5
|
+
* (档案馆 restored-src,四阶段结构照搬),做了两处本机化的改写:
|
|
6
|
+
*
|
|
7
|
+
* 1. **补上 sage-mem 自己的写记忆规范。** CC 的版本把格式交给它的系统提示
|
|
8
|
+
* (「use the memory file format from your system prompt's auto-memory section」),
|
|
9
|
+
* 但 sage-mem 的规范在 `sage-memory` 技能里,而 dream 用的是独立的一次调用、
|
|
10
|
+
* 不一定带着那份技能。所以四类前缀、type 一致性、YAML 两个静默陷阱、
|
|
11
|
+
* 保留名这些**必须**出现在 prompt 里,否则模型会照着「看起来像」的方式写坏。
|
|
12
|
+
* 2. **按模式分叉。** 「只出报告」不是靠一句「请不要写」约束的——那一趟根本
|
|
13
|
+
* 没有写工具(见 dream-tools.js)。所以 prompt 只说它有什么、该产出什么形状。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** 让 prompt 里的日期对人可读:模型算相对日期时不会写错。 */
|
|
17
|
+
function today(ms) {
|
|
18
|
+
const d = new Date(ms)
|
|
19
|
+
const p = (n) => String(n).padStart(2, '0')
|
|
20
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 系统提示:身份 + 记忆系统的约定 + 这一趟的权限边界。
|
|
25
|
+
* @param {{apply:boolean, withSessions:boolean}} opts
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
export function buildDreamSystemPrompt(opts) {
|
|
29
|
+
const { apply, withSessions } = opts
|
|
30
|
+
const modeLine = apply
|
|
31
|
+
? '本次是**改写模式**:你有写工具,可以直接改记忆文件。运行前已经把整个 memory 目录快照了一份,改坏能整体回退——但这只保证「可恢复」,不保证「改得对」。宁可少改,不要乱动。'
|
|
32
|
+
: '本次是**只读模式**:你没有任何写工具。把所有发现写成建议清单,写进你最后的回复里——要具体到「哪个文件、改成什么」,不要泛泛而谈。'
|
|
33
|
+
|
|
34
|
+
return `你是 sage-mem(一个文件式跨会话记忆系统)的整理进程。你的记忆不是数据库,是一个装着 markdown 文件的目录,靠 frontmatter 分类、靠双字组匹配检索。你要做的是一次反思性通读,把最近的经历沉淀成耐久、有条理的记忆。
|
|
35
|
+
|
|
36
|
+
${modeLine}
|
|
37
|
+
|
|
38
|
+
## 记忆的约定(必须遵守,违反了会静默损坏记忆)
|
|
39
|
+
|
|
40
|
+
**分四类,靠文件名前缀 + frontmatter 的 \`type\` 双重标记:**
|
|
41
|
+
|
|
42
|
+
| 前缀 | type | 装什么 |
|
|
43
|
+
|---|---|---|
|
|
44
|
+
| \`user_\` | \`user\` | 用户是谁:角色、背景、偏好、习惯 |
|
|
45
|
+
| \`feedback_\` | \`feedback\` | 用户给的工作方式指导。**正文必须带 \`**Why:**\` 与 \`**How to apply:**\` 两段** |
|
|
46
|
+
| \`project_\` | \`project\` | 进行中的工作与状态。**相对日期一律转成绝对日期** |
|
|
47
|
+
| \`reference_\` | \`reference\` | 外部信息在哪找:服务器、路径、端点、命令 |
|
|
48
|
+
|
|
49
|
+
**分类只看 \`type\`,不看前缀** —— 两者不一致时以 \`type\` 为准,而那种不一致本身就是该修的毛病。\`type\` 缺失会被兜底成 \`reference\`(静默归错类)。
|
|
50
|
+
|
|
51
|
+
**\`description\` 是检索的唯一入口。** 检索机制是把提问和「\`description\` + 文件名」都切成双字组比覆盖率,命中靠词面重合、不靠语义。所以 description 要写「将来我会怎么问它」时会出现的词,而不是一句抽象的概括。
|
|
52
|
+
|
|
53
|
+
**frontmatter 是 YAML,两个元字符会静默出事:**
|
|
54
|
+
- 未加引号的值里有 \`: \`(半角冒号+空格)→ 解析失败,整条描述与类型全空
|
|
55
|
+
- 未加引号的值里有 \` #\`(空格+井号)→ **解析成功但后半句被当注释吞掉**,页面与日志都没有任何提示
|
|
56
|
+
|
|
57
|
+
修法:短的值直接加单引号,长文本避开这两个形态。
|
|
58
|
+
|
|
59
|
+
**几个特殊文件:**
|
|
60
|
+
- \`MEMORY.md\` —— 索引。它是一份索引用,每条一行 \`- [标题](文件.md) — 一句话\`,**不要把记忆内容写进去**
|
|
61
|
+
- \`session-log.md\` —— 追加型流水账,**不是一条记忆**,不进检索、不进列表、不要当记忆改
|
|
62
|
+
- \`project_heartscape.md\` / \`project_self-cognition.md\` —— 手写单副本、人工维护,改动它们要格外小心
|
|
63
|
+
- \`archive/\` —— 归档区,不在检索范围内。归档 = 把文件移进去,**可人工捞回**;这里没有真删除
|
|
64
|
+
|
|
65
|
+
**硬标准:靠读代码 / git / 系统提示能推出来的东西不要存。** 存的是推不出来的上下文。写之前先问一句「下一个我会真的用得上这条吗」。${withSessions ? '' : '\n\n**本次输入源不含会话记录**,所以你没有 search_sessions 工具。手上的材料就是记忆目录本身。'}`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 首条用户消息:四阶段流程 + 这一趟的实时上下文。
|
|
70
|
+
*
|
|
71
|
+
* 阶段结构来自 CC 的 buildConsolidationPrompt(Orient → Gather → Consolidate →
|
|
72
|
+
* Prune and index),阶段名与顺序保留,内容按上面的约定改写。
|
|
73
|
+
*
|
|
74
|
+
* @param {{memoryDir:string, apply:boolean, hoursSince:number, sessionCount:number, now:number}} opts
|
|
75
|
+
* @returns {string}
|
|
76
|
+
*/
|
|
77
|
+
export function buildDreamUserPrompt(opts) {
|
|
78
|
+
const { memoryDir, apply, hoursSince, sessionCount, now } = opts
|
|
79
|
+
const stage4 = apply
|
|
80
|
+
? `- 更新 \`MEMORY.md\` 索引,让它保持是索引而不是内容堆:
|
|
81
|
+
- 每条一行 \`- [标题](文件.md) — 一句话\`
|
|
82
|
+
- 移除指向已归档 / 已不存在文件的指针
|
|
83
|
+
- 补上新写的重要记忆
|
|
84
|
+
- 两条记忆互相矛盾时,把错的那条改对`
|
|
85
|
+
: `- 给出 \`MEMORY.md\` 该增删哪些行的**具体清单**(照抄要改成的那行文本)`
|
|
86
|
+
|
|
87
|
+
return `# Dream:一次记忆整理
|
|
88
|
+
|
|
89
|
+
今天:${today(now)}
|
|
90
|
+
记忆目录:\`${memoryDir}\`
|
|
91
|
+
距上次整理:${hoursSince === 0 ? '(没有记录,当成第一次)' : hoursSince.toFixed(1) + ' 小时'}
|
|
92
|
+
期间有更新的会话数:${sessionCount}
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## 阶段 1 — 定位
|
|
97
|
+
|
|
98
|
+
- 先用 \`list_memory\` 看清现在有什么
|
|
99
|
+
- 读 \`MEMORY.md\` 理解当前索引结构
|
|
100
|
+
- 挑几条与最近改动最相关的记忆读一读,**改进已有文件而不是造近似重复**
|
|
101
|
+
|
|
102
|
+
## 阶段 2 — 收集近期信号
|
|
103
|
+
|
|
104
|
+
值得留存的新信息,大致优先级:
|
|
105
|
+
|
|
106
|
+
1. **结构性毛病** —— 直接跑 \`audit_memory\`,它会把索引悬空、漏索引、type 与前缀不一致、双链断链、YAML 陷阱都列出来。这些是确定要做的事。
|
|
107
|
+
2. **漂移的旧记忆** —— 描述与内容已经对不上的、日期还是相对写法的、被后续进展推翻的。
|
|
108
|
+
3. **(仅在需要具体细节时)** 用 \`search_sessions\` 定向搜一个具体词。不要通读会话记录。
|
|
109
|
+
|
|
110
|
+
## 阶段 3 — 整合
|
|
111
|
+
|
|
112
|
+
对每件值得记住的事,改写或新建一条记忆。**合并新信息进已有文件**,不要为一点新东西新开一个文件。被推翻的事实改在源头,而不是在后面追加一句「其实不是这样」。
|
|
113
|
+
|
|
114
|
+
${apply ? '你有写工具,直接落盘。' : '你没有写工具,把每条改动写成「文件 → 具体改成什么」的形式。'}
|
|
115
|
+
|
|
116
|
+
## 阶段 4 — 修剪与索引
|
|
117
|
+
|
|
118
|
+
${stage4}
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
结束时,用几句话回报你整合、修改、修剪了什么。如果什么都没改(记忆本来就很紧),就直说。`
|
|
123
|
+
}
|