sage-mem 0.8.0 → 0.9.1
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 +28 -0
- package/lib/autodream/config.js +13 -0
- package/lib/autodream/engine.js +78 -1
- package/lib/autodream/report.js +29 -0
- package/lib/autodream/settings.js +2 -0
- package/lib/autodream-audit.js +80 -14
- package/lib/autodream-tools.js +10 -11
- package/lib/client.js +1521 -100
- package/lib/index.js +436 -11
- package/lib/memory/access.js +220 -0
- package/lib/memory/archive.js +91 -0
- package/lib/memory/scan.js +130 -7
- package/lib/typert.host.js +287 -1
- package/package.json +4 -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
|
+
}
|
package/lib/memory/scan.js
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* sage-mem 记忆层 ——
|
|
2
|
+
* sage-mem 记忆层 —— 目录扫描 + 归档区的读写形状。
|
|
3
|
+
*
|
|
4
|
+
* 归档区(`memory/archive/`)**不参与检索**:`scanMemoryFiles` 只扫根目录 ——
|
|
5
|
+
* 这就是「归档 = 静默失效且可回退」的语义,不是漏扫。归档只在两个地方现身:
|
|
6
|
+
* 星图(`scanStars(ctx, true)`)与归档列表(`scanArchived`)—— 让**用户**看得见,
|
|
7
|
+
* 而不是让**模型**想起来。
|
|
8
|
+
*
|
|
9
|
+
* 归档留痕写在文件自己的 frontmatter 里(`archived_at` / `archived_reason`):
|
|
10
|
+
* 文件可能被手工搬来搬去,随文件走的元数据才不会与文件脱节。
|
|
3
11
|
*/
|
|
4
12
|
|
|
5
13
|
import { MEMORY_DIR } from './config.js'
|
|
@@ -9,6 +17,101 @@ import { warnLog } from './util.js'
|
|
|
9
17
|
import { readFile, readdir, stat } from 'node:fs/promises'
|
|
10
18
|
import { join } from 'node:path'
|
|
11
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
|
+
|
|
12
115
|
/** 扫 memory 目录,读回全部记忆文件(跳过索引与子目录),并解析 frontmatter 摘要。 */
|
|
13
116
|
export async function scanMemoryFiles(ctx, sessionId) {
|
|
14
117
|
try {
|
|
@@ -55,13 +158,13 @@ export function extractTitle(content) {
|
|
|
55
158
|
return h1 ? h1[1].trim() : ''
|
|
56
159
|
}
|
|
57
160
|
|
|
58
|
-
/**
|
|
59
|
-
|
|
161
|
+
/** 扫一个目录,读回星图要的轻量元数据(正文 + 体积 + 修改时间)。 */
|
|
162
|
+
async function readStarDir(dir, ctx) {
|
|
60
163
|
let entries
|
|
61
164
|
try {
|
|
62
|
-
entries = await readdir(
|
|
165
|
+
entries = await readdir(dir, { withFileTypes: true })
|
|
63
166
|
} catch (err) {
|
|
64
|
-
if (err?.code !== 'ENOENT') warnLog(ctx, `cannot scan ${
|
|
167
|
+
if (err?.code !== 'ENOENT') warnLog(ctx, `cannot scan ${dir}: ${err?.message ?? err}`)
|
|
65
168
|
return []
|
|
66
169
|
}
|
|
67
170
|
const names = entries
|
|
@@ -69,13 +172,33 @@ export async function scanStars(ctx) {
|
|
|
69
172
|
.map(e => e.name)
|
|
70
173
|
const loaded = await Promise.all(names.map(async (name) => {
|
|
71
174
|
try {
|
|
72
|
-
const full = join(
|
|
175
|
+
const full = join(dir, name)
|
|
73
176
|
const [content, info] = await Promise.all([readFile(full, 'utf8'), stat(full)])
|
|
74
177
|
return { name, content, bytes: info.size, mtimeMs: info.mtimeMs }
|
|
75
178
|
} catch (err) {
|
|
76
|
-
if (err?.code !== 'ENOENT') warnLog(ctx, `cannot read ${
|
|
179
|
+
if (err?.code !== 'ENOENT') warnLog(ctx, `cannot read ${full}: ${err?.message ?? err}`)
|
|
77
180
|
return null
|
|
78
181
|
}
|
|
79
182
|
}))
|
|
80
183
|
return loaded.filter(Boolean)
|
|
81
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
|
+
}
|