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,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sage-mem 记忆层 —— 访问台账 + 自动归档候选。
|
|
3
|
+
*
|
|
4
|
+
* 台账 = `<状态根>/access.json`:`{ "<文件名>": <最近一次被注入的毫秒时间戳> }`。
|
|
5
|
+
*
|
|
6
|
+
* ⚠️ **只写状态目录,绝不往记忆文件里写 `last_accessed`。** 往记忆文件写等于在读路径上
|
|
7
|
+
* 写盘:只读模式、并发、mtime 抖动全会出问题 —— 而读路径必须无副作用。
|
|
8
|
+
*
|
|
9
|
+
* 它同时是「自动归档」那个 N 天阈值**唯一**的数据来源:没有台账,「多久没被用过」只能
|
|
10
|
+
* 看 frontmatter 的 updated 或文件 mtime,而这两个都不等于「没被用过」(手改一个字就刷新)。
|
|
11
|
+
*
|
|
12
|
+
* 候选计算是**纯确定性**的:不调模型、不看语义,只看「距上次被注入多少天」。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { MEMORY_DIR, STATE_ROOT } from './config.js'
|
|
16
|
+
import { ARCHIVE_DIR_NAME } from './scan.js'
|
|
17
|
+
import { isMemoryEntry } from './naming.js'
|
|
18
|
+
import { parseFrontmatter } from './frontmatter.js'
|
|
19
|
+
import { writeFileAtomic } from './util.js'
|
|
20
|
+
import { mkdir, readFile, readdir } from 'node:fs/promises'
|
|
21
|
+
import { dirname, join } from 'node:path'
|
|
22
|
+
|
|
23
|
+
/** 台账文件:状态根下,与记忆文件分开。 */
|
|
24
|
+
export const ACCESS_LEDGER_FILE = join(STATE_ROOT, 'access.json')
|
|
25
|
+
|
|
26
|
+
/** 落盘节流:内存先累计,最多每 30 秒写一次盘。 */
|
|
27
|
+
export const ACCESS_FLUSH_INTERVAL_MS = 30 * 1000
|
|
28
|
+
|
|
29
|
+
/** 开了才出声:台账写不进去是**静默降级**,没有这个开关就查不出「为什么阈值算不准」。 */
|
|
30
|
+
const DEBUG = process.env.SAGE_MEM_DEBUG === '1'
|
|
31
|
+
|
|
32
|
+
/** 「永不自动归档」的类型:feedback 是用户给的做事方式,退役它等于把人的交代扔掉。 */
|
|
33
|
+
export const NEVER_ARCHIVE_TYPES = new Set(['feedback'])
|
|
34
|
+
|
|
35
|
+
/** 内存累计(尚未落盘):文件名 → 时间戳,同名取最新。 */
|
|
36
|
+
let pending = new Map()
|
|
37
|
+
let flushTimer = null
|
|
38
|
+
let lastFlushAt = 0
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 记一次「这些文件真的被注入了」。
|
|
42
|
+
*
|
|
43
|
+
* 只进内存,落盘交给节流器 —— 注入链每步都跑,每步落盘就是拿读路径的稳定性换数据。
|
|
44
|
+
* **同步、绝不抛**:台账是观测数据,它的问题不许升级成「记忆不再注入」。
|
|
45
|
+
*/
|
|
46
|
+
export function recordAccess(names, at = Date.now()) {
|
|
47
|
+
for (const n of names ?? []) {
|
|
48
|
+
if (typeof n === 'string' && n) pending.set(n, at)
|
|
49
|
+
}
|
|
50
|
+
scheduleFlush()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function scheduleFlush() {
|
|
54
|
+
if (flushTimer) return
|
|
55
|
+
const wait = Math.max(0, ACCESS_FLUSH_INTERVAL_MS - (Date.now() - lastFlushAt))
|
|
56
|
+
flushTimer = setTimeout(() => {
|
|
57
|
+
flushTimer = null
|
|
58
|
+
flushAccess().catch(() => {})
|
|
59
|
+
}, wait)
|
|
60
|
+
// 不阻止进程退出:这只是「顺带记一笔」的台账。
|
|
61
|
+
if (typeof flushTimer.unref === 'function') flushTimer.unref()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 读盘上的台账。读不到 / JSON 坏 / 不是对象 / 值不是有限数 → 一律当空台账。 */
|
|
65
|
+
export async function readAccessLedger() {
|
|
66
|
+
try {
|
|
67
|
+
const parsed = JSON.parse(await readFile(ACCESS_LEDGER_FILE, 'utf8'))
|
|
68
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
|
|
69
|
+
const out = {}
|
|
70
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
71
|
+
if (typeof k === 'string' && Number.isFinite(v)) out[k] = v
|
|
72
|
+
}
|
|
73
|
+
return out
|
|
74
|
+
} catch {
|
|
75
|
+
return {}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 当前**真实存在**的记忆文件名(根目录 + `archive/`)。
|
|
81
|
+
*
|
|
82
|
+
* 归档区的名字要算:归档的记忆将来可能被恢复,它「上次被注入」的时间仍然有意义。
|
|
83
|
+
* 返回 `null` = 「这次扫不动」→ 调用方**保留全部键**,绝不把一次读盘失败当成
|
|
84
|
+
* 「文件都没了」而清空台账。
|
|
85
|
+
*
|
|
86
|
+
* 两类 ENOENT 必须分开看:**`archive/` 的 ENOENT 是正常状态**(没人归档前它一直不存在),
|
|
87
|
+
* 而**记忆根目录的 ENOENT 不是** —— 那说明这个路径根本不是记忆目录,按「空」回收就是
|
|
88
|
+
* 一次静默的数据清空。详见下面的注释。
|
|
89
|
+
*/
|
|
90
|
+
async function currentMemoryNames(memoryDir) {
|
|
91
|
+
const out = new Set()
|
|
92
|
+
let unknown = false
|
|
93
|
+
const collect = async (dir) => {
|
|
94
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
95
|
+
for (const e of entries) if (e.isFile() && isMemoryEntry(e.name)) out.add(e.name)
|
|
96
|
+
}
|
|
97
|
+
// 记忆根目录:**任何**读失败(含 ENOENT)都算「不知道」。
|
|
98
|
+
// 根目录不存在不等于「记忆都没了」,而是「这个路径根本不是记忆目录」——参数传错、
|
|
99
|
+
// 盘没挂上、目录被挪走。这时按「空」回收就是一次**静默的数据清空**:idleDays 会全体
|
|
100
|
+
// 回落到 updated/mtime,自动归档阈值从此算错,而且没有任何信号。
|
|
101
|
+
// 宁可留死键(下轮真扫到时自然会清),不可误清。
|
|
102
|
+
try {
|
|
103
|
+
await collect(memoryDir)
|
|
104
|
+
} catch {
|
|
105
|
+
unknown = true
|
|
106
|
+
}
|
|
107
|
+
// 归档区:ENOENT 是**正常状态**(没人归档前这个目录一直不存在)→ 算空;
|
|
108
|
+
// 其余读失败(权限、盘掉线、父路径不是目录)保留全部键。
|
|
109
|
+
try {
|
|
110
|
+
await collect(join(memoryDir, ARCHIVE_DIR_NAME))
|
|
111
|
+
} catch (err) {
|
|
112
|
+
if (err?.code !== 'ENOENT') unknown = true
|
|
113
|
+
}
|
|
114
|
+
return unknown ? null : out
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 把内存里的增量并到盘上(合并写:同一文件只留最新时间戳),**落盘前顺手回收死键**。
|
|
119
|
+
*
|
|
120
|
+
* 台账没有别的回收物:记忆被归档 / 改名 / 删除之后,它再也不会被注入,那条时间戳
|
|
121
|
+
* 就永远烂在盘上 —— autodream 的状态有 pruneSnapshots,台账也得有对应物。
|
|
122
|
+
* 回收**只跟着真正的写盘一起做**(batch 非空):一次纯粹的强刷不该去动一个没必要
|
|
123
|
+
* 动的文件,也避免「拿着别的记忆目录来刷」时误清。
|
|
124
|
+
*
|
|
125
|
+
* **强制 flush 的时机**:每次 autodream 运行前 —— 否则「这趟整理看到的台账」可能落后
|
|
126
|
+
* 若干步注入,阈值就会算偏。失败时把这一批放回内存,下次还有机会。
|
|
127
|
+
*
|
|
128
|
+
* @param {string} [memoryDir] — 记忆根目录(引擎必须传自己的,不许拿模块常量:它可能是别的目录)
|
|
129
|
+
*/
|
|
130
|
+
export async function flushAccess(memoryDir = MEMORY_DIR) {
|
|
131
|
+
if (pending.size === 0) return { ok: true, written: 0 }
|
|
132
|
+
const batch = pending
|
|
133
|
+
pending = new Map()
|
|
134
|
+
try {
|
|
135
|
+
const onDisk = await readAccessLedger()
|
|
136
|
+
for (const [name, at] of batch) {
|
|
137
|
+
if (!Number.isFinite(onDisk[name]) || at > onDisk[name]) onDisk[name] = at
|
|
138
|
+
}
|
|
139
|
+
const alive = await currentMemoryNames(memoryDir)
|
|
140
|
+
if (alive) {
|
|
141
|
+
for (const key of Object.keys(onDisk)) {
|
|
142
|
+
if (!alive.has(key)) delete onDisk[key]
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// 状态目录是插件自己的地盘,缺了可以建(autodream 的配置也存在这儿)。
|
|
146
|
+
// 建不出来(只读、父路径不是目录)就走 catch 静默降级。
|
|
147
|
+
await mkdir(dirname(ACCESS_LEDGER_FILE), { recursive: true })
|
|
148
|
+
await writeFileAtomic(ACCESS_LEDGER_FILE, JSON.stringify(onDisk, null, 2))
|
|
149
|
+
lastFlushAt = Date.now()
|
|
150
|
+
return { ok: true, written: batch.size, reclaimed: alive ? true : false }
|
|
151
|
+
} catch (err) {
|
|
152
|
+
for (const [name, at] of batch) if (!pending.has(name)) pending.set(name, at)
|
|
153
|
+
// 静默降级:注入是插件最核心的事,台账写不进去不许把它拖下水。开了 DEBUG 才出声。
|
|
154
|
+
if (DEBUG) console.warn(`[sage-mem][debug] access ledger flush failed: ${err?.message ?? err}`)
|
|
155
|
+
return { ok: false, error: err?.message ?? String(err) }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 清掉内存里没落盘的增量(只给测试用:一次进程里想从头验节流)。 */
|
|
160
|
+
export function __resetAccessPendingForTest() {
|
|
161
|
+
pending = new Map()
|
|
162
|
+
flushTimer = null
|
|
163
|
+
lastFlushAt = 0
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** 一条记忆的类型:frontmatter 的 `type` 优先,回落文件名前缀(`project_x.md` → `project`)。 */
|
|
167
|
+
export function fileType(f) {
|
|
168
|
+
const fromMeta = f?.content ? parseFrontmatter(f.content).type : ''
|
|
169
|
+
return String(fromMeta || f?.type || f?.file || '').split('_')[0].toLowerCase()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* 一条记忆「多久没被注入」(整数天;null = 无从判断)。
|
|
174
|
+
*
|
|
175
|
+
* 顺序不能反:**台账 → frontmatter 的 updated → 文件 mtime**。台账是「真的被用过」的
|
|
176
|
+
* 唯一证据;updated 是「人写过」;mtime 只是「文件被动过」——复制、checkout、备份还原
|
|
177
|
+
* 都会把它整批改掉。
|
|
178
|
+
*/
|
|
179
|
+
export function idleDays(f, ledger = {}, now = Date.now()) {
|
|
180
|
+
const seen = ledger?.[f?.file]
|
|
181
|
+
const fromMeta = Date.parse(f?.updated || '')
|
|
182
|
+
const base = Number.isFinite(seen)
|
|
183
|
+
? seen
|
|
184
|
+
: (Number.isFinite(fromMeta) ? fromMeta : (Number.isFinite(f?.mtimeMs) ? f.mtimeMs : NaN))
|
|
185
|
+
if (!Number.isFinite(base)) return null
|
|
186
|
+
const days = Math.floor((now - base) / 86400000)
|
|
187
|
+
return days > 0 ? days : 0
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* 算自动归档候选(确定性,不调模型)。
|
|
192
|
+
*
|
|
193
|
+
* @param {Array} files — `scanMemoryFiles` 的结果(**只有根目录**,所以归档区天然不在内)
|
|
194
|
+
* @param {Object} ledger — 访问台账
|
|
195
|
+
* @param {{thresholds?: Object}} opts — 按类型的阈值(天);缺阈值=该类型永不候选
|
|
196
|
+
* @returns {Array<{file:string,type:string,days:number,limit:number,reason:string}>} 按闲置天数降序
|
|
197
|
+
*/
|
|
198
|
+
export function pickArchiveCandidates(files, ledger = {}, opts = {}, now = Date.now()) {
|
|
199
|
+
const thresholds = opts.thresholds ?? {}
|
|
200
|
+
const out = []
|
|
201
|
+
for (const f of files ?? []) {
|
|
202
|
+
if (!f?.file || f.archived === true) continue
|
|
203
|
+
const type = fileType(f)
|
|
204
|
+
if (NEVER_ARCHIVE_TYPES.has(type)) continue
|
|
205
|
+
// baseline 是「每场会话必读」的那几条:它们本来就不靠检索命中,闲置天数没有意义。
|
|
206
|
+
if (f.baseline === true) continue
|
|
207
|
+
const limit = Number(thresholds[type])
|
|
208
|
+
if (!Number.isFinite(limit) || limit <= 0) continue
|
|
209
|
+
const days = idleDays(f, ledger, now)
|
|
210
|
+
if (days === null || days < limit) continue
|
|
211
|
+
out.push({
|
|
212
|
+
file: f.file,
|
|
213
|
+
type,
|
|
214
|
+
days,
|
|
215
|
+
limit,
|
|
216
|
+
reason: `已 ${days} 天未被注入(${type} 阈值 ${limit} 天)`,
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
return out.sort((a, b) => b.days - a.days || a.file.localeCompare(b.file))
|
|
220
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sage-mem 记忆层 —— 归档的**唯一实现**。
|
|
3
|
+
*
|
|
4
|
+
* 归档有三条入口:手动 remote(`memory.archive`)、模型工具(autodream 的
|
|
5
|
+
* `archive_memory`)、自动归档策略。三处都调这里 —— 之前「手动写留痕、工具只 rename」
|
|
6
|
+
* 是两份实现,已经漂移过一次:自动归档收起来的记忆在面板上显示「时间未知 / 没写理由」,
|
|
7
|
+
* 而档案馆的意义恰恰是回答「它为什么被收起来」。
|
|
8
|
+
*
|
|
9
|
+
* 语义(A 阶段冻结、三条路径共用):
|
|
10
|
+
* - **只移不删**:文件进 `archive/`,名字不变;
|
|
11
|
+
* - 移动前写 `archived_at`(本地时间 `YYYY-MM-DD HH:mm`)与 `archived_reason`;
|
|
12
|
+
* - 拒绝一律返回 `{ ok:false, code, error }`,由调用方决定抛还是回 `{ ok:false }`。
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ 记忆目录**必须由调用方传进来**:autodream 引擎的 memoryDir 可以是任意目录(测试里
|
|
15
|
+
* 就是临时目录),拿模块级常量会写错地方 —— 那是会碰到真实记忆的危险错误。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { ARCHIVE_DIR_NAME, addArchiveMeta, localStamp, stripArchiveMeta } from './scan.js'
|
|
19
|
+
import { isReserved, nameKey, safeName } from './naming.js'
|
|
20
|
+
import { writeFileAtomic } from './util.js'
|
|
21
|
+
import { mkdir, readFile, stat, unlink } from 'node:fs/promises'
|
|
22
|
+
import { join } from 'node:path'
|
|
23
|
+
|
|
24
|
+
/** 归档区绝对路径(按调用方给的记忆目录算)。 */
|
|
25
|
+
export const archiveDirOf = (memoryDir) => join(memoryDir, ARCHIVE_DIR_NAME)
|
|
26
|
+
|
|
27
|
+
/** 名字闸门:保留名 → 非 .md / 带路径成分。返回 { ok:true, safe } 或 { ok:false, code, error }。 */
|
|
28
|
+
function checkName(name, verb) {
|
|
29
|
+
if (isReserved(name)) {
|
|
30
|
+
return { ok: false, code: 'reserved', error: `sage-mem: reserved file, refusing to ${verb}: ${nameKey(name)}` }
|
|
31
|
+
}
|
|
32
|
+
const safe = safeName(name)
|
|
33
|
+
if (!safe) return { ok: false, code: 'invalid-name', error: `sage-mem: invalid file name: ${String(name)}` }
|
|
34
|
+
return { ok: true, safe }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 归档一条记忆:写留痕 + 移进 `archive/`(不删)。
|
|
39
|
+
*
|
|
40
|
+
* @param {string} memoryDir — 记忆根目录(调用方给,不许拿模块常量)
|
|
41
|
+
* @param {string} name — 文件名(根目录里必须已存在)
|
|
42
|
+
* @param {string} [reason] — 归档缘由;空/省略时写默认「手动归档」
|
|
43
|
+
* @param {string} [at] — 归档时间(默认本地时间 `YYYY-MM-DD HH:mm`)
|
|
44
|
+
* @returns {Promise<{ok:true,file:string,archivedReason:string}|{ok:false,code:string,error:string}>}
|
|
45
|
+
*/
|
|
46
|
+
export async function archiveMemory(memoryDir, name, reason, at = localStamp()) {
|
|
47
|
+
const gate = checkName(name, 'archive')
|
|
48
|
+
if (!gate.ok) return gate
|
|
49
|
+
const safe = gate.safe
|
|
50
|
+
const src = join(memoryDir, safe)
|
|
51
|
+
const dst = join(archiveDirOf(memoryDir), safe)
|
|
52
|
+
const info = await stat(src).catch(() => null)
|
|
53
|
+
// 根目录里没有 → 文件名写错,或者它已经是归档态(归档区那份不该再归档一次)。
|
|
54
|
+
if (!info?.isFile()) return { ok: false, code: 'not-found', error: `sage-mem: file not found: ${safe}` }
|
|
55
|
+
if (await stat(dst).catch(() => null)) {
|
|
56
|
+
// 归档区已有同名:不覆盖 —— 那份旧归档可能是用户想留的另一版。
|
|
57
|
+
return { ok: false, code: 'exists', error: `sage-mem: already archived: ${safe}` }
|
|
58
|
+
}
|
|
59
|
+
const content = await readFile(src, 'utf8')
|
|
60
|
+
// 空理由给默认值:手动 remote 允许省略(工具那侧自己会先挡掉空理由,那是它自己的语义)。
|
|
61
|
+
const why = String(reason ?? '').trim() || '手动归档'
|
|
62
|
+
const stamped = addArchiveMeta(content, why, at)
|
|
63
|
+
await mkdir(archiveDirOf(memoryDir), { recursive: true })
|
|
64
|
+
// 先落归档副本、再删原件:中途崩了最坏是两份都在(人工可收拾),不会一份都没有。
|
|
65
|
+
await writeFileAtomic(dst, stamped)
|
|
66
|
+
await unlink(src)
|
|
67
|
+
return { ok: true, file: safe, archivedReason: why }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 从归档区恢复:移回根目录并去掉两行留痕。
|
|
72
|
+
*
|
|
73
|
+
* 根目录已有同名时**拒绝**:根目录那份是正在用的记忆,归档这份只是退役版 ——
|
|
74
|
+
* 覆盖等于悄悄改掉一条活记忆。
|
|
75
|
+
*/
|
|
76
|
+
export async function restoreMemory(memoryDir, name) {
|
|
77
|
+
const gate = checkName(name, 'restore')
|
|
78
|
+
if (!gate.ok) return gate
|
|
79
|
+
const safe = gate.safe
|
|
80
|
+
const dst = join(memoryDir, safe)
|
|
81
|
+
const src = join(archiveDirOf(memoryDir), safe)
|
|
82
|
+
const info = await stat(src).catch(() => null)
|
|
83
|
+
if (!info?.isFile()) return { ok: false, code: 'not-archived', error: `sage-mem: not archived: ${safe}` }
|
|
84
|
+
if (await stat(dst).catch(() => null)) {
|
|
85
|
+
return { ok: false, code: 'target-exists', error: `sage-mem: already exists, refusing to overwrite: ${safe}` }
|
|
86
|
+
}
|
|
87
|
+
const content = await readFile(src, 'utf8')
|
|
88
|
+
await writeFileAtomic(dst, stripArchiveMeta(content))
|
|
89
|
+
await unlink(src)
|
|
90
|
+
return { ok: true, file: safe }
|
|
91
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sage-mem 记忆层 —— 目录位置、各项上限与引用纪律。
|
|
3
|
+
*
|
|
4
|
+
* 记忆目录 / 状态根怎么定、注入条数与体积上限是多少、`reserved.json` 保护名单怎么读,
|
|
5
|
+
* 全在这儿。常量值与拆分前逐字一致。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync } from 'node:fs'
|
|
9
|
+
import { homedir } from 'node:os'
|
|
10
|
+
import { dirname, join } from 'node:path'
|
|
11
|
+
|
|
12
|
+
export const MEMORY_DIR = process.env.SAGE_MEM_DIR || join(homedir(), '.sage-mem', 'memory')
|
|
13
|
+
/**
|
|
14
|
+
* DSH 会话记录根目录。autodream 的门控靠它数「上次整理之后有几个会话更新」,
|
|
15
|
+
* `memory+sessions` 输入源下也会在里面做定向搜索。取不到就是空串,
|
|
16
|
+
* 门控会退化成「会话数 0 → 不触发」,而不是报错。
|
|
17
|
+
*/
|
|
18
|
+
export const SESSIONS_ROOT = process.env.DSH_HOME ? join(process.env.DSH_HOME, 'sessions') : ''
|
|
19
|
+
|
|
20
|
+
/** 读一个整数型环境变量并在范围内收敛;取不到或非法就用默认值。 */
|
|
21
|
+
export function envInt(name, dflt, lo, hi) {
|
|
22
|
+
const raw = process.env[name]
|
|
23
|
+
if (raw === undefined || raw === '') return dflt
|
|
24
|
+
const n = Number(raw)
|
|
25
|
+
if (!Number.isFinite(n)) return dflt
|
|
26
|
+
return Math.max(lo, Math.min(hi, Math.round(n)))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 一次注入最多带几条记忆。
|
|
31
|
+
*
|
|
32
|
+
* 5 → 10:**记忆条数 ÷ 注入名额 ≈ 命中率上限**,名额 5 时那个上限低到
|
|
33
|
+
* 的根因就在这个比值上。名额翻倍是提升召回最直接的一手,代价是每轮多约 7.5K
|
|
34
|
+
* 字符的上下文。**这个数字该由实测决定**,所以留了 SAGE_MEM_MAX_RESULTS 覆写口
|
|
35
|
+
* (比较两档的命中率与 token 账单,别拍脑袋)。
|
|
36
|
+
*/
|
|
37
|
+
export const MAX_RESULTS = envInt('SAGE_MEM_MAX_RESULTS', 10, 1, 30)
|
|
38
|
+
|
|
39
|
+
/** 单条记忆注入的字符上限。 */
|
|
40
|
+
export const MAX_CHARS_PER_FILE = envInt('SAGE_MEM_MAX_CHARS', 1500, 200, 20000)
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* baseline 名额。
|
|
44
|
+
*
|
|
45
|
+
* baseline 条目通常不多,卡在 5 只会让靠后的条目永不出现——
|
|
46
|
+
* 而 baseline 恰恰是短问题、恢复会话时唯一的兜底。现在放到 8,
|
|
47
|
+
* 排序见下面「按显式优先级排,不按文件名」那段。
|
|
48
|
+
*/
|
|
49
|
+
export const MAX_BASELINE = envInt('SAGE_MEM_MAX_BASELINE', 8, 1, 50)
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 单会话累计注入字节上限 —— 到顶以后这个会话就不再注入新的记忆。
|
|
53
|
+
*
|
|
54
|
+
* 为什么要有:条数上限(MAX_RESULTS)管的是「一次」注入多少,管不了「一场会话注
|
|
55
|
+
* 多少次」。长会话里问题一直在换、检索签名一直在变,于是走一步注一批,累积没有
|
|
56
|
+
* 上限 —— 实测某个高重合的短问题一次就注 7,700 字符,八次就到 60 KB。
|
|
57
|
+
* Claude Code 有同一层闸门(`MAX_SESSION_BYTES = 60 * 1024`,到顶**彻底停止**预取),
|
|
58
|
+
* 本插件此前只有单条上限、没有会话上限。
|
|
59
|
+
*
|
|
60
|
+
* 60 KB 是与 CC 对齐的起点、不是实测出来的数字 —— 留了 SAGE_MEM_MAX_SESSION_BYTES
|
|
61
|
+
* 覆写口,量过再定。
|
|
62
|
+
*/
|
|
63
|
+
export const MAX_SESSION_BYTES = envInt('SAGE_MEM_MAX_SESSION_BYTES', 60 * 1024, 4096, 512 * 1024)
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 注入文本里标「保存于 N 天前」的门槛(天)。超了才标;0 = 每条都标。
|
|
67
|
+
*
|
|
68
|
+
* 为什么用「N 天前」而不是日期:模型不擅长日期算术,绝对时间戳几乎不触发陈旧
|
|
69
|
+
* 推理,相对天数才会。为什么需要它:陈旧记忆被当事实引用时,「引用」这个动作
|
|
70
|
+
* 本身会让它显得更权威、而不是更可疑。
|
|
71
|
+
*/
|
|
72
|
+
export const STALE_DAYS = envInt('SAGE_MEM_STALE_DAYS', 1, 0, 365)
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 注入文本开头的引用纪律 —— **必须独立成节,不能压成一条 bullet**。
|
|
76
|
+
*
|
|
77
|
+
* Claude Code 对这条做过 A/B:同一句内容埋成 bullet 命中 0/3,独立成节 3/3,
|
|
78
|
+
* 连标题措辞都验过。原因很实在:陈旧记忆被当事实引用时,「引用」这个动作本身
|
|
79
|
+
* 会让它显得更权威而不是更可疑 —— 规矩得在**读这批内容之前**先立。
|
|
80
|
+
*
|
|
81
|
+
* 每次注入都带(约 150 字符),不按会话只带一次:它约束的是「这一批」怎么用,
|
|
82
|
+
* 与批次绑定比与会话绑定更不容易失效。
|
|
83
|
+
*/
|
|
84
|
+
export const RECALL_DISCIPLINE = [
|
|
85
|
+
'> **引用纪律**:以下是过去某一刻写下的记录,不是当前事实。',
|
|
86
|
+
'> 提到文件路径先确认存在,提到函数 / 配置项先 grep,要据此动手前先核实当前代码。',
|
|
87
|
+
'> 标了「保存于 N 天前」的尤其注意:其中的 `文件:行号` 很可能已经过期。',
|
|
88
|
+
'> 「记忆里写着 X 存在」不等于「X 现在存在」。',
|
|
89
|
+
].join('\n')
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 结构性保留名:设置页的写/删方法一律不得触碰。
|
|
93
|
+
*
|
|
94
|
+
* 只列**结构决定必须保护**的两个:
|
|
95
|
+
* 1. `MEMORY.md` —— 索引。它被整份覆盖就等于索引没了(「+ 添加记忆」表单是照
|
|
96
|
+
* buildFrontmatter() 重建 frontmatter 的,原有内容一个字都留不下)。
|
|
97
|
+
* 2. `session-log.md` —— 追加型流水,体量最大,同样经不起一份重建。
|
|
98
|
+
*
|
|
99
|
+
* ⚠️ **除了这两个,别把任何具体记忆的文件名写死在这里。**
|
|
100
|
+
* 「哪几条记忆特别要紧」是**使用者的私事**——把它编进随包发布的代码,等于把作者的
|
|
101
|
+
* 记忆主题发给每一个装这个插件的人。
|
|
102
|
+
* 用户自己的保护名单放在 `<状态根>/reserved.json`(JSON 字符串数组),见下。
|
|
103
|
+
*
|
|
104
|
+
* ⚠️ 全部小写存放,比较一律走 nameKey()(NTFS 不区分大小写,集合里放 'MEMORY.md'
|
|
105
|
+
* 而拿 'memory.md' 去 has() 是查不到的 —— 曾经就是这个漏洞:表单里填 memory.md
|
|
106
|
+
* 就能整份覆盖索引,deleteFile 还能不可逆删掉它)。
|
|
107
|
+
*/
|
|
108
|
+
export const STATE_ROOT = process.env.SAGE_MEM_STATE_DIR || join(dirname(MEMORY_DIR), '.sage-mem')
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 读使用者自己声明的额外保护名单:`<状态根>/reserved.json`,形如 `["a.md","b.md"]`。
|
|
112
|
+
*
|
|
113
|
+
* 读不到、JSON 坏、不是数组 —— 一律当空数组返回。**一份可选的保护名单,
|
|
114
|
+
* 绝不该有能力让插件加载失败。**
|
|
115
|
+
*/
|
|
116
|
+
export function readExtraReserved() {
|
|
117
|
+
try {
|
|
118
|
+
const arr = JSON.parse(readFileSync(join(STATE_ROOT, 'reserved.json'), 'utf8'))
|
|
119
|
+
if (!Array.isArray(arr)) return []
|
|
120
|
+
return arr
|
|
121
|
+
.map((s) => String(s).trim().toLowerCase())
|
|
122
|
+
.filter((s) => s.endsWith('.md') && !s.includes('/') && !s.includes('\\') && !s.startsWith('..'))
|
|
123
|
+
} catch {
|
|
124
|
+
return []
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export const RESERVED_FILES = new Set(['memory.md', 'session-log.md', ...readExtraReserved()])
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 非记忆条目:不参与检索 / 不进星图 / 不出现在设置页列表里,读写也一律不通。
|
|
132
|
+
*
|
|
133
|
+
* 是 RESERVED_FILES 的子集,区别在「列不列出来」:MEMORY.md 是索引、
|
|
134
|
+
* session-log.md 是追加型流水,两者都不是一条记忆;
|
|
135
|
+
* 而使用者手写维护的重要记忆是正常记忆条目(它们该被检索到)。
|
|
136
|
+
* (要出现在列表与星图里、要能读),只是不许从表单写/删。
|
|
137
|
+
*/
|
|
138
|
+
export const NON_ENTRY_FILES = new Set([
|
|
139
|
+
'memory.md',
|
|
140
|
+
'session-log.md',
|
|
141
|
+
])
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 单文件体积上限(字节)。定 512 KB 的理由:
|
|
145
|
+
* - 单条正文通常在几十 KB 以内,索引与流水都排除在外,
|
|
146
|
+
* 512 KB 是它的 26 倍,任何正常记忆都够用;
|
|
147
|
+
* - 记忆是要塞进 system prompt 的,一条 512 KB 的记忆本身就等于把上下文撑爆——
|
|
148
|
+
* 到这个量级基本可以判定是写错了目标(比如把日志、代码贴进来)。
|
|
149
|
+
* 与 lib/typert.host.js 的 fileContentSchema.max(512 * 1024) 保持一致。
|
|
150
|
+
*/
|
|
151
|
+
export const MAX_FILE_BYTES = 512 * 1024
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sage-mem 记忆层 —— frontmatter 解析全家(tags / aliases / baseline / 摘要字段)。
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 解析 frontmatter 的 `tags`(自定义标签,用于列表页筛选)。
|
|
7
|
+
*
|
|
8
|
+
* 三种写法都收 —— 手写记忆时这三种都会自然出现,只认一种就等于「标签时灵时不灵」:
|
|
9
|
+
* tags: [a, b] (YAML flow)
|
|
10
|
+
* tags:\n - a\n - b (YAML block)
|
|
11
|
+
* tags: a, b (松散的逗号串)
|
|
12
|
+
*
|
|
13
|
+
* 上限 20 个、每个 40 字符:标签是给人点着筛的,超过这个量级已经不是标签,
|
|
14
|
+
* 而是一句话被塞进了 tags 字段。
|
|
15
|
+
*/
|
|
16
|
+
export function parseTags(body) {
|
|
17
|
+
const clean = (arr) =>
|
|
18
|
+
arr
|
|
19
|
+
.map((s) => String(s).trim().replace(/^["']|["']$/g, '').trim())
|
|
20
|
+
.filter(Boolean)
|
|
21
|
+
.slice(0, 20)
|
|
22
|
+
.map((s) => s.slice(0, 40))
|
|
23
|
+
const flow = body.match(/^tags:[ \t]*\[([^\]]*)\]/m)
|
|
24
|
+
if (flow) return clean(flow[1].split(','))
|
|
25
|
+
const block = body.match(/^tags:[ \t]*\r?\n((?:[ \t]+-[^\r\n]*\r?\n?)+)/m)
|
|
26
|
+
if (block) return clean(block[1].split(/\r?\n/).map((l) => l.replace(/^[ \t]*-[ \t]*/, '')))
|
|
27
|
+
const inline = body.match(/^tags:[ \t]*(.+?)[ \t\r]*$/m)
|
|
28
|
+
if (inline && inline[1].trim()) return clean(inline[1].split(','))
|
|
29
|
+
return []
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 解析 frontmatter 的 name/description/type(兼容顶层与 metadata 嵌套两种 type)。 */
|
|
33
|
+
export function parseFrontmatter(content) {
|
|
34
|
+
// 换行必须容忍 CRLF:任何用 Windows 编辑器碰过一次的文件都会被换成 \r\n,
|
|
35
|
+
// 而旧版这里写的是 /^---\n/ —— 匹配失败 → description/type 全空 →
|
|
36
|
+
// 「baseline 走 parseBaseline 那条带 \r?\n 的正则照旧命中,但这条记忆永远检索不到」,
|
|
37
|
+
// 症状极具欺骗性。下文定义的 parseBaseline 与 extractTitle 早已是 \r?\n。
|
|
38
|
+
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
|
39
|
+
if (!m) return { name: '', description: '', type: '', tags: [], aliases: [], updated: '', baselinePriority: 0 }
|
|
40
|
+
const body = m[1]
|
|
41
|
+
const grab = (key) => {
|
|
42
|
+
// [ \t] / [ \t\r] 而不是 \s:\s 会连 CRLF 的 \r 一起吃进捕获组,值尾留一个 \r
|
|
43
|
+
// 会让下面的引号剥离(["']$)失配,description 尾巴上挂一个残引号。
|
|
44
|
+
const hit = body.match(new RegExp(`^${key}:[ \\t]*(.+?)[ \\t\\r]*$`, 'm'))
|
|
45
|
+
if (!hit) return ''
|
|
46
|
+
return hit[1].replace(/^["']|["']$/g, '').trim()
|
|
47
|
+
}
|
|
48
|
+
let type = grab('type')
|
|
49
|
+
if (!type) {
|
|
50
|
+
const nested = body.match(/^metadata:\s*\r?\n(?:\s+[^\r\n]+\r?\n)*?\s+type:\s*(\S+)/m)
|
|
51
|
+
if (nested) type = nested[1]
|
|
52
|
+
}
|
|
53
|
+
// `updated` 踩的是与 `type` 同一个坑:它通常写在 `metadata:` 嵌套块里(缩进两格),
|
|
54
|
+
// 顶层 `^updated:` 抓不到 —— 于是「明明写了日期却永远显示不出来」。
|
|
55
|
+
// 抓不到就回落文件 mtime(见 ageDays):手写时间戳是权威的,文件系统时间只是退路。
|
|
56
|
+
let updated = grab('updated')
|
|
57
|
+
if (!updated) {
|
|
58
|
+
const nestedUpdated = body.match(/^metadata:\s*\r?\n(?:\s+[^\r\n]+\r?\n)*?\s+updated:\s*(\S+)/m)
|
|
59
|
+
if (nestedUpdated) updated = nestedUpdated[1]
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
name: grab('name'),
|
|
63
|
+
description: grab('description'),
|
|
64
|
+
type,
|
|
65
|
+
tags: parseTags(body),
|
|
66
|
+
aliases: parseAliases(body),
|
|
67
|
+
updated,
|
|
68
|
+
baselinePriority: parseBaselinePriority(body),
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 解析 frontmatter 的 `aliases`(别称 / 同义词 / 我可能用的另一种叫法)。
|
|
74
|
+
*
|
|
75
|
+
* 为什么要有它:检索是**词面重合**的,不是语义的——问「我有什么待办」而文件里
|
|
76
|
+
* 写的是「代办清单」,差一个字就完全不命中。`description` 回答「我会怎么问它」,
|
|
77
|
+
* `aliases` 补上「我还可能怎么问」。写法与 tags 完全一致(flow / block / 逗号串三种都收),
|
|
78
|
+
* 免得手写时「时灵时不灵」。
|
|
79
|
+
*/
|
|
80
|
+
export function parseAliases(body) {
|
|
81
|
+
const clean = (arr) =>
|
|
82
|
+
arr
|
|
83
|
+
.map((s) => String(s).trim().replace(/^["']|["']$/g, '').trim())
|
|
84
|
+
.filter(Boolean)
|
|
85
|
+
.slice(0, 24)
|
|
86
|
+
.map((s) => s.slice(0, 60))
|
|
87
|
+
const flow = body.match(/^aliases:[ \t]*\[([^\]]*)\]/m)
|
|
88
|
+
if (flow) return clean(flow[1].split(','))
|
|
89
|
+
const block = body.match(/^aliases:[ \t]*\r?\n((?:[ \t]+-[^\r\n]*\r?\n?)+)/m)
|
|
90
|
+
if (block) return clean(block[1].split(/\r?\n/).map((l) => l.replace(/^[ \t]*-[ \t]*/, '')))
|
|
91
|
+
const inline = body.match(/^aliases:[ \t]*(.+?)[ \t\r]*$/m)
|
|
92
|
+
if (inline && inline[1].trim()) return clean(inline[1].split(','))
|
|
93
|
+
return []
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 解析 frontmatter 的 `baselinePriority`(数字,越大越先注入)。
|
|
98
|
+
*
|
|
99
|
+
* 旧版 baseline 是 `MAX_BASELINE = 5` + **按文件名排序**——`feedback_*` 永远排在
|
|
100
|
+
* `user_*` 前面,与「哪条真的更该在每场会话开头出现」毫无关系。缺失当 0,
|
|
101
|
+
* 于是没写这个字段的老条目行为不变。
|
|
102
|
+
*/
|
|
103
|
+
export function parseBaselinePriority(body) {
|
|
104
|
+
const hit = body.match(/^baselinePriority:[ \t]*(.+?)[ \t\r]*$/m)
|
|
105
|
+
if (!hit) return 0
|
|
106
|
+
const n = Number(hit[1].replace(/^["']|["']$/g, '').trim())
|
|
107
|
+
return Number.isFinite(n) ? n : 0
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 解析 frontmatter 的 `baseline` 标记:会话第一回合无条件注入。 */
|
|
111
|
+
export function parseBaseline(content) {
|
|
112
|
+
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
|
113
|
+
if (!m) return false
|
|
114
|
+
const hit = m[1].match(/^baseline:\s*(.+)$/m)
|
|
115
|
+
if (!hit) return false
|
|
116
|
+
return /^(true|yes|1)$/i.test(hit[1].replace(/^["']|["']$/g, '').trim())
|
|
117
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sage-mem 记忆层 —— 文件名归一与保留名判定(**唯一**的名字比较基准)。
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { NON_ENTRY_FILES, RESERVED_FILES } from './config.js'
|
|
6
|
+
import { basename } from 'node:path'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 文件名归一:只取单段文件名再小写。
|
|
10
|
+
*
|
|
11
|
+
* 这是**唯一**的名字比较基准(保留名、非条目名、扫描过滤全走它)。
|
|
12
|
+
* 不能直接用原始字符串比:NTFS 不区分大小写,`memory.md` 与 `MEMORY.md`
|
|
13
|
+
* 是同一个文件;`basename` 还顺手挡掉 `..\..\MEMORY.md` 这类带目录成分的输入。
|
|
14
|
+
*/
|
|
15
|
+
export function nameKey(raw) {
|
|
16
|
+
return basename(String(raw ?? '')).toLowerCase()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 是否是保留名(写/删一律拒绝)。大小写不敏感。 */
|
|
20
|
+
export function isReserved(raw) {
|
|
21
|
+
return RESERVED_FILES.has(nameKey(raw))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 是否是非记忆条目(索引与流水):列出/检索/星图/读写全部排除。大小写不敏感。 */
|
|
25
|
+
export function isNonEntry(raw) {
|
|
26
|
+
return NON_ENTRY_FILES.has(nameKey(raw))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 目录扫描过滤:这个名字算不算一条记忆条目。 */
|
|
30
|
+
export function isMemoryEntry(name) {
|
|
31
|
+
return /\.md$/i.test(String(name ?? '')) && !isNonEntry(name)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 唯一的路径校验函数(防穿越 + 扩展名 + 排除非记忆条目)。
|
|
36
|
+
*
|
|
37
|
+
* MemoryGateway.readFile / StarmapGateway.readFile 都走这里,不再各写一套
|
|
38
|
+
* —— 旧版 starmap 那套内联校验只排除了 'MEMORY.md'(大小写敏感)且**完全不排除
|
|
39
|
+
* session-log.md**,于是 `starmap.readFile('session-log.md')` 能把整份流水
|
|
40
|
+
* 整个拉进浏览器。
|
|
41
|
+
*
|
|
42
|
+
* 不复用 nameKey 的原因:这里要保留磁盘上的原始大小写(返回值直接 join 进路径)。
|
|
43
|
+
* @param raw — 调用方传来的文件名(任意值)
|
|
44
|
+
* @returns 通过校验的原始文件名;非法返回 null
|
|
45
|
+
*/
|
|
46
|
+
export function safeName(raw) {
|
|
47
|
+
if (typeof raw !== 'string' || raw === '') return null
|
|
48
|
+
// basename 与原文不一致=含目录成分。旧版是「悄悄取 basename 继续」,
|
|
49
|
+
// 与 starmap 那套(要求 safe === raw)行为不一致;现在统一为拒绝。
|
|
50
|
+
if (basename(raw) !== raw) return null
|
|
51
|
+
if (!/\.md$/i.test(raw)) return null
|
|
52
|
+
if (isNonEntry(raw)) return null
|
|
53
|
+
return raw
|
|
54
|
+
}
|