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,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sage-mem 记忆层 —— 检索:双字打分、名额裁剪、baseline 排序、新鲜度换算。
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { MAX_BASELINE, MAX_RESULTS } from './config.js'
|
|
6
|
+
|
|
7
|
+
/** 提取消息文本,兼容 content 为字符串或 text-block 数组两种形态。 */
|
|
8
|
+
export function extractText(content) {
|
|
9
|
+
if (typeof content === 'string') return content.trim()
|
|
10
|
+
if (!Array.isArray(content)) return ''
|
|
11
|
+
return content
|
|
12
|
+
.filter(b => b && b.type === 'text' && typeof b.text === 'string')
|
|
13
|
+
.map(b => b.text)
|
|
14
|
+
.join('\n')
|
|
15
|
+
.trim()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** 提取双字(2-gram)集合,中英文数字通用。 */
|
|
19
|
+
export function bigrams(s) {
|
|
20
|
+
const clean = String(s).toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '')
|
|
21
|
+
const set = new Set()
|
|
22
|
+
for (let i = 0; i < clean.length - 1; i++) set.add(clean.slice(i, i + 2))
|
|
23
|
+
return set
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** query 的双字在 text 中的覆盖率(0~1),作为相关性得分。 */
|
|
27
|
+
export function score(query, text) {
|
|
28
|
+
const q = bigrams(query)
|
|
29
|
+
if (q.size === 0) return 0
|
|
30
|
+
const t = bigrams(text)
|
|
31
|
+
let hit = 0
|
|
32
|
+
for (const g of q) if (t.has(g)) hit++
|
|
33
|
+
return hit / q.size
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 相关性打分只用 frontmatter 的 description + 文件名,不扫全文。
|
|
38
|
+
*
|
|
39
|
+
* 旧版拿 query 的双字去全文里找,得分是「覆盖率」,分母是 query,不惩罚
|
|
40
|
+
* 文件长度 —— 越长的记忆越容易蒙中。实测:长记忆在全文匹配下对任何问题都能
|
|
41
|
+
* 蒙中足量双字组,连寒暄问句都能注入上千 token;而真正该命中的那条反被
|
|
42
|
+
* 挤掉。description 是「一句话说清这条是什么」的精准摘要,用它当检索
|
|
43
|
+
* 信号,噪音大幅下降。
|
|
44
|
+
*/
|
|
45
|
+
export function selectRelevant(query, files) {
|
|
46
|
+
return files
|
|
47
|
+
.map(f => {
|
|
48
|
+
// aliases 一并进检索面:它是专门为「换个说法也能命中」准备的字段。
|
|
49
|
+
const aliasText = Array.isArray(f.aliases) && f.aliases.length ? ' ' + f.aliases.join(' ') : ''
|
|
50
|
+
const hay = f.description + aliasText + ' ' + f.file.replace(/\.md$/, '').replace(/[_-]/g, ' ')
|
|
51
|
+
return { ...f, score: score(query, hay) }
|
|
52
|
+
})
|
|
53
|
+
.filter(f => f.score > 0)
|
|
54
|
+
.sort((a, b) => b.score - a.score)
|
|
55
|
+
.slice(0, MAX_RESULTS)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 选 baseline 条目:按显式 `baselinePriority` 排,不按文件名。
|
|
60
|
+
*
|
|
61
|
+
* 文件名排序等于让 `feedback_*` 无条件压过 `user_*`,与「哪条更该出现在每场
|
|
62
|
+
* 会话开头」毫无关系。没写这个字段的老条目一律算 0,行为与从前一致。
|
|
63
|
+
*/
|
|
64
|
+
export function selectBaseline(files) {
|
|
65
|
+
return files
|
|
66
|
+
.filter(f => f.baseline)
|
|
67
|
+
.sort((a, b) => (b.baselinePriority || 0) - (a.baselinePriority || 0) || a.file.localeCompare(b.file))
|
|
68
|
+
.slice(0, MAX_BASELINE)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 一条记忆「多久以前写下的」(整数天;null = 无从判断)。
|
|
73
|
+
*
|
|
74
|
+
* **优先 frontmatter 的 `updated`,缺失才回落文件 mtime —— 顺序不能反。**
|
|
75
|
+
* 文件系统时间会被复制、checkout、备份还原整批改掉,它表示的是「这个文件什么时候
|
|
76
|
+
* 被动过」,不是「这条记忆什么时候写的」。手写的时间戳才是权威。
|
|
77
|
+
*
|
|
78
|
+
* 未来日期(时钟偏差 / 手写错)一律当 0:宁可标「今天」也不要标负数。
|
|
79
|
+
*/
|
|
80
|
+
export function ageDays(f, nowMs) {
|
|
81
|
+
const fromMeta = Date.parse(f?.updated || '')
|
|
82
|
+
const base = Number.isFinite(fromMeta) ? fromMeta : (Number.isFinite(f?.mtimeMs) ? f.mtimeMs : NaN)
|
|
83
|
+
if (!Number.isFinite(base)) return null
|
|
84
|
+
const days = Math.floor((nowMs - base) / 86400000)
|
|
85
|
+
return days > 0 ? days : 0
|
|
86
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sage-mem 记忆层 —— 目录扫描 + 归档区的读写形状。
|
|
3
|
+
*
|
|
4
|
+
* 归档区(`memory/archive/`)**不参与检索**:`scanMemoryFiles` 只扫根目录 ——
|
|
5
|
+
* 这就是「归档 = 静默失效且可回退」的语义,不是漏扫。归档只在两个地方现身:
|
|
6
|
+
* 星图(`scanStars(ctx, true)`)与归档列表(`scanArchived`)—— 让**用户**看得见,
|
|
7
|
+
* 而不是让**模型**想起来。
|
|
8
|
+
*
|
|
9
|
+
* 归档留痕写在文件自己的 frontmatter 里(`archived_at` / `archived_reason`):
|
|
10
|
+
* 文件可能被手工搬来搬去,随文件走的元数据才不会与文件脱节。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { MEMORY_DIR } from './config.js'
|
|
14
|
+
import { parseBaseline, parseFrontmatter } from './frontmatter.js'
|
|
15
|
+
import { isMemoryEntry } from './naming.js'
|
|
16
|
+
import { warnLog } from './util.js'
|
|
17
|
+
import { readFile, readdir, stat } from 'node:fs/promises'
|
|
18
|
+
import { join } from 'node:path'
|
|
19
|
+
|
|
20
|
+
/** 归档区目录名。与 autodream 工具里的 ARCHIVE_DIR 同名同义(同一个目录)。 */
|
|
21
|
+
export const ARCHIVE_DIR_NAME = 'archive'
|
|
22
|
+
|
|
23
|
+
/** 归档区绝对路径。文件名不变,只换目录。 */
|
|
24
|
+
export const MEMORY_ARCHIVE_DIR = join(MEMORY_DIR, ARCHIVE_DIR_NAME)
|
|
25
|
+
|
|
26
|
+
/** 本地时间 `YYYY-MM-DD HH:mm` —— 归档留痕是给人看的,不用 ISO / UTC。 */
|
|
27
|
+
export function localStamp(ms = Date.now()) {
|
|
28
|
+
const d = new Date(ms)
|
|
29
|
+
const p = (n) => String(n).padStart(2, '0')
|
|
30
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 把一个值写成 YAML 标量。
|
|
35
|
+
*
|
|
36
|
+
* 三层处理,少一层就是一个「不报错、值悄悄变形」的陷阱:
|
|
37
|
+
* 1. **换行折成一个空格**(有意的规范化,不是截断):裸写出去的话,后半行会变成
|
|
38
|
+
* **另一个 YAML 键**(`理由\narchived_reason: 假的` 会写出两个 `archived_reason`),
|
|
39
|
+
* 或者让 `---` 提前闭合 frontmatter;而 YAML 的多行引号标量读回来又会把换行折成
|
|
40
|
+
* 空格 —— 两种含糊都不如显式折叠。带过换行的值额外加引号,让读者看得出这行被
|
|
41
|
+
* 规范化过。
|
|
42
|
+
* 2. `: `(冒号+空格 → 被当成嵌套映射)与 ` #`(空格+井号 → 后半句被当注释吞掉)
|
|
43
|
+
* 必须给整值加单引号。
|
|
44
|
+
* 3. 空值写 `''`(裸写会变 YAML null)。
|
|
45
|
+
* 单引号按 YAML 规矩翻倍转义。`reason` 来自用户输入,指望人「别写冒号、别写换行」不现实。
|
|
46
|
+
*/
|
|
47
|
+
export function yamlScalar(value) {
|
|
48
|
+
const raw = String(value ?? '')
|
|
49
|
+
const hadNewline = /[\r\n]/.test(raw)
|
|
50
|
+
const s = raw.replace(/[\r\n]+/g, ' ').trim()
|
|
51
|
+
if (s === '') return "''"
|
|
52
|
+
if (hadNewline || /:(?:\s|$)| #/.test(s) || /^[-?#&*!|>%@`{}[\],'"]/.test(s)) {
|
|
53
|
+
return `'${s.replace(/'/g, "''")}'`
|
|
54
|
+
}
|
|
55
|
+
return s
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** frontmatter 块(含两侧 `---`)的匹配;没有 frontmatter 返回 null。 */
|
|
59
|
+
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/
|
|
60
|
+
|
|
61
|
+
/** 文件用的是哪种换行:回写沿用原风格,别把用户文件的 CRLF 悄悄换成 LF。 */
|
|
62
|
+
const eolOf = (content) => (content.includes('\r\n') ? '\r\n' : '\n')
|
|
63
|
+
|
|
64
|
+
/** 剥掉 YAML 标量两侧的引号(单引号的 `''` 还原成一个 `'`)。 */
|
|
65
|
+
function unquoteYaml(raw) {
|
|
66
|
+
const s = String(raw ?? '').trim()
|
|
67
|
+
if (s.length >= 2 && s.startsWith("'") && s.endsWith("'")) return s.slice(1, -1).replace(/''/g, "'")
|
|
68
|
+
if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) return s.slice(1, -1).replace(/\\"/g, '"')
|
|
69
|
+
return s
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 读 frontmatter 里的归档留痕。没有 frontmatter / 没有这两个字段都回空串。 */
|
|
73
|
+
export function readArchivedMeta(content) {
|
|
74
|
+
const m = String(content ?? '').match(FRONTMATTER_RE)
|
|
75
|
+
if (!m) return { archivedAt: '', archivedReason: '' }
|
|
76
|
+
const grab = (key) => {
|
|
77
|
+
const hit = m[1].match(new RegExp(`^${key}:[ \\t]*(.*?)[ \\t\\r]*$`, 'm'))
|
|
78
|
+
return hit ? unquoteYaml(hit[1]) : ''
|
|
79
|
+
}
|
|
80
|
+
return { archivedAt: grab('archived_at'), archivedReason: grab('archived_reason') }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 去掉归档留痕两行(恢复时用)。没有 frontmatter 就原样返回。 */
|
|
84
|
+
export function stripArchiveMeta(content) {
|
|
85
|
+
const src = String(content ?? '')
|
|
86
|
+
const m = src.match(FRONTMATTER_RE)
|
|
87
|
+
if (!m) return src
|
|
88
|
+
const eol = eolOf(src)
|
|
89
|
+
const body = m[1]
|
|
90
|
+
.split(/\r?\n/)
|
|
91
|
+
.filter((line) => !/^[ \t]*archived_(at|reason)[ \t]*:/.test(line))
|
|
92
|
+
const block = ['---', ...body, '---'].join(eol)
|
|
93
|
+
return block + src.slice(m.index + m[0].length)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 写入归档留痕:`archived_at` + `archived_reason` 插在 frontmatter 末尾。
|
|
98
|
+
*
|
|
99
|
+
* 已经是归档态的文件再归档没有意义(上层会先拒绝),所以这里只做 upsert:
|
|
100
|
+
* 先剥掉旧的两行再插,免得同一份文件里出现两个 `archived_at`。
|
|
101
|
+
* 没有 frontmatter 的文件补一个最小块 —— 归档信息必须有地方放。
|
|
102
|
+
*/
|
|
103
|
+
export function addArchiveMeta(content, reason, at) {
|
|
104
|
+
const src = String(content ?? '')
|
|
105
|
+
const eol = eolOf(src)
|
|
106
|
+
const lines = [`archived_at: ${yamlScalar(at)}`, `archived_reason: ${yamlScalar(reason)}`]
|
|
107
|
+
const stripped = stripArchiveMeta(src)
|
|
108
|
+
const m = stripped.match(FRONTMATTER_RE)
|
|
109
|
+
if (!m) return ['---', ...lines, '---', '', stripped].join(eol)
|
|
110
|
+
const block = m[0].split(/\r?\n/)
|
|
111
|
+
block.splice(block.length - 1, 0, ...lines)
|
|
112
|
+
return stripped.slice(0, m.index) + block.join(eol) + stripped.slice(m.index + m[0].length)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 扫 memory 目录,读回全部记忆文件(跳过索引与子目录),并解析 frontmatter 摘要。 */
|
|
116
|
+
export async function scanMemoryFiles(ctx, sessionId) {
|
|
117
|
+
try {
|
|
118
|
+
const entries = await readdir(MEMORY_DIR, { withFileTypes: true })
|
|
119
|
+
const names = entries
|
|
120
|
+
.filter(e => e.isFile() && isMemoryEntry(e.name))
|
|
121
|
+
.map(e => e.name)
|
|
122
|
+
const loaded = await Promise.all(names.map(async (name) => {
|
|
123
|
+
try {
|
|
124
|
+
const full = join(MEMORY_DIR, name)
|
|
125
|
+
// stat 与 readFile 并行 —— 多要一个 mtime 不该变成串行两跳。
|
|
126
|
+
const [content, info] = await Promise.all([readFile(full, 'utf8'), stat(full)])
|
|
127
|
+
const meta = parseFrontmatter(content)
|
|
128
|
+
return {
|
|
129
|
+
file: name,
|
|
130
|
+
content,
|
|
131
|
+
description: meta.description || '',
|
|
132
|
+
aliases: meta.aliases || [],
|
|
133
|
+
baseline: parseBaseline(content),
|
|
134
|
+
baselinePriority: meta.baselinePriority || 0,
|
|
135
|
+
// 新鲜度的两个来源:frontmatter 的 updated(权威)与文件 mtime(退路)。
|
|
136
|
+
updated: meta.updated || '',
|
|
137
|
+
mtimeMs: info.mtimeMs,
|
|
138
|
+
}
|
|
139
|
+
} catch (err) {
|
|
140
|
+
// 只有 ENOENT 是正常状态(扫到读之间被删)。其余——权限错、编码坏、盘掉线——
|
|
141
|
+
// 一律出声:这里的静默化就是把「记忆不再注入」变成无声事故的那一手。
|
|
142
|
+
if (err?.code !== 'ENOENT') warnLog(ctx, `cannot read ${name}: ${err?.message ?? err}`, sessionId)
|
|
143
|
+
return null
|
|
144
|
+
}
|
|
145
|
+
}))
|
|
146
|
+
return loaded.filter(Boolean)
|
|
147
|
+
} catch (err) {
|
|
148
|
+
if (err?.code !== 'ENOENT') warnLog(ctx, `cannot scan ${MEMORY_DIR}: ${err?.message ?? err}`, sessionId)
|
|
149
|
+
return []
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** 提取正文第一个 `# ` 标题作为展示标题。 */
|
|
154
|
+
export function extractTitle(content) {
|
|
155
|
+
const m = content.match(/^---\r?\n[\s\S]*?\r?\n---/)
|
|
156
|
+
const body = m ? content.slice(m[0].length) : content
|
|
157
|
+
const h1 = body.match(/^#\s+(.+)$/m)
|
|
158
|
+
return h1 ? h1[1].trim() : ''
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** 扫一个目录,读回星图要的轻量元数据(正文 + 体积 + 修改时间)。 */
|
|
162
|
+
async function readStarDir(dir, ctx) {
|
|
163
|
+
let entries
|
|
164
|
+
try {
|
|
165
|
+
entries = await readdir(dir, { withFileTypes: true })
|
|
166
|
+
} catch (err) {
|
|
167
|
+
if (err?.code !== 'ENOENT') warnLog(ctx, `cannot scan ${dir}: ${err?.message ?? err}`)
|
|
168
|
+
return []
|
|
169
|
+
}
|
|
170
|
+
const names = entries
|
|
171
|
+
.filter(e => e.isFile() && isMemoryEntry(e.name))
|
|
172
|
+
.map(e => e.name)
|
|
173
|
+
const loaded = await Promise.all(names.map(async (name) => {
|
|
174
|
+
try {
|
|
175
|
+
const full = join(dir, name)
|
|
176
|
+
const [content, info] = await Promise.all([readFile(full, 'utf8'), stat(full)])
|
|
177
|
+
return { name, content, bytes: info.size, mtimeMs: info.mtimeMs }
|
|
178
|
+
} catch (err) {
|
|
179
|
+
if (err?.code !== 'ENOENT') warnLog(ctx, `cannot read ${full}: ${err?.message ?? err}`)
|
|
180
|
+
return null
|
|
181
|
+
}
|
|
182
|
+
}))
|
|
183
|
+
return loaded.filter(Boolean)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 扫归档区 `memory/archive/`。返回形状与 `readStarDir` 一致(多了 `archived: true`)。
|
|
188
|
+
* 归档区不存在是正常状态(没人归档过)→ 空数组,不是错误。
|
|
189
|
+
*/
|
|
190
|
+
export async function scanArchived(ctx) {
|
|
191
|
+
const rows = await readStarDir(MEMORY_ARCHIVE_DIR, ctx)
|
|
192
|
+
return rows.map(f => ({ ...f, archived: true }))
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* 扫记忆目录,读全部 .md(跳过索引与 session-log),带体积与修改时间。
|
|
197
|
+
*
|
|
198
|
+
* @param {boolean} [includeArchived] — 带上归档区的条目(默认不带:星图默认只看活着的)
|
|
199
|
+
*/
|
|
200
|
+
export async function scanStars(ctx, includeArchived = false) {
|
|
201
|
+
const active = (await readStarDir(MEMORY_DIR, ctx)).map(f => ({ ...f, archived: false }))
|
|
202
|
+
if (includeArchived !== true) return active
|
|
203
|
+
return [...active, ...(await scanArchived(ctx))]
|
|
204
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sage-mem 记忆层 —— 通用小工具:统一日志出口与原子写。
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { rename, unlink, writeFile } from 'node:fs/promises'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 统一告警出口。所有非 ENOENT 的失败都必须落到日志,绝不静默
|
|
9
|
+
* (历史坑见 assemble 监听器里那段注释:表现是「插件在、记忆不再注入」,日志一字没有)。
|
|
10
|
+
* @param ctx — 插件上下文,取 ctx.logger.warn
|
|
11
|
+
* @param msg — 消息正文,调用方负责带上文件名 / 目录名
|
|
12
|
+
* @param sessionId — 可选 session id,有就带
|
|
13
|
+
*/
|
|
14
|
+
export function warnLog(ctx, msg, sessionId) {
|
|
15
|
+
const line = `sage-mem: ${msg}${sessionId ? ` (session ${sessionId})` : ''}`
|
|
16
|
+
if (ctx?.logger?.warn) ctx.logger.warn(line)
|
|
17
|
+
else console.warn(line)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 原子写:先写同目录临时文件,再 rename 覆盖目标。
|
|
22
|
+
*
|
|
23
|
+
* 直写的风险是崩在中途(断电、进程被杀、磁盘满)留下半截文件 —— frontmatter
|
|
24
|
+
* 缺半边,这条记忆从此解析不出来、检索不到,而文件看上去还在。
|
|
25
|
+
* 同卷 rename 是原子的:读方要么看到旧全文、要么看到新全文,不存在中间态。
|
|
26
|
+
* 任何一步失败都不碰目标文件,临时文件收尾清掉。
|
|
27
|
+
*/
|
|
28
|
+
export async function writeFileAtomic(target, content) {
|
|
29
|
+
const tmp = `${target}.${process.pid}.tmp`
|
|
30
|
+
try {
|
|
31
|
+
await writeFile(tmp, content, 'utf8')
|
|
32
|
+
await rename(tmp, target)
|
|
33
|
+
} finally {
|
|
34
|
+
// rename 成功后 tmp 已不存在(ENOENT),失败时把它清掉不留垃圾。
|
|
35
|
+
await unlink(tmp).catch(() => {})
|
|
36
|
+
}
|
|
37
|
+
}
|