sage-mem 0.7.2 → 0.8.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.
@@ -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
+ }
@@ -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,81 @@
1
+ /**
2
+ * sage-mem 记忆层 —— 目录扫描:读回全部记忆文件,以及星图要的轻量元数据。
3
+ */
4
+
5
+ import { MEMORY_DIR } from './config.js'
6
+ import { parseBaseline, parseFrontmatter } from './frontmatter.js'
7
+ import { isMemoryEntry } from './naming.js'
8
+ import { warnLog } from './util.js'
9
+ import { readFile, readdir, stat } from 'node:fs/promises'
10
+ import { join } from 'node:path'
11
+
12
+ /** 扫 memory 目录,读回全部记忆文件(跳过索引与子目录),并解析 frontmatter 摘要。 */
13
+ export async function scanMemoryFiles(ctx, sessionId) {
14
+ try {
15
+ const entries = await readdir(MEMORY_DIR, { withFileTypes: true })
16
+ const names = entries
17
+ .filter(e => e.isFile() && isMemoryEntry(e.name))
18
+ .map(e => e.name)
19
+ const loaded = await Promise.all(names.map(async (name) => {
20
+ try {
21
+ const full = join(MEMORY_DIR, name)
22
+ // stat 与 readFile 并行 —— 多要一个 mtime 不该变成串行两跳。
23
+ const [content, info] = await Promise.all([readFile(full, 'utf8'), stat(full)])
24
+ const meta = parseFrontmatter(content)
25
+ return {
26
+ file: name,
27
+ content,
28
+ description: meta.description || '',
29
+ aliases: meta.aliases || [],
30
+ baseline: parseBaseline(content),
31
+ baselinePriority: meta.baselinePriority || 0,
32
+ // 新鲜度的两个来源:frontmatter 的 updated(权威)与文件 mtime(退路)。
33
+ updated: meta.updated || '',
34
+ mtimeMs: info.mtimeMs,
35
+ }
36
+ } catch (err) {
37
+ // 只有 ENOENT 是正常状态(扫到读之间被删)。其余——权限错、编码坏、盘掉线——
38
+ // 一律出声:这里的静默化就是把「记忆不再注入」变成无声事故的那一手。
39
+ if (err?.code !== 'ENOENT') warnLog(ctx, `cannot read ${name}: ${err?.message ?? err}`, sessionId)
40
+ return null
41
+ }
42
+ }))
43
+ return loaded.filter(Boolean)
44
+ } catch (err) {
45
+ if (err?.code !== 'ENOENT') warnLog(ctx, `cannot scan ${MEMORY_DIR}: ${err?.message ?? err}`, sessionId)
46
+ return []
47
+ }
48
+ }
49
+
50
+ /** 提取正文第一个 `# ` 标题作为展示标题。 */
51
+ export function extractTitle(content) {
52
+ const m = content.match(/^---\r?\n[\s\S]*?\r?\n---/)
53
+ const body = m ? content.slice(m[0].length) : content
54
+ const h1 = body.match(/^#\s+(.+)$/m)
55
+ return h1 ? h1[1].trim() : ''
56
+ }
57
+
58
+ /** 扫记忆目录,读全部 .md(跳过索引与 session-log),带体积与修改时间。 */
59
+ export async function scanStars(ctx) {
60
+ let entries
61
+ try {
62
+ entries = await readdir(MEMORY_DIR, { withFileTypes: true })
63
+ } catch (err) {
64
+ if (err?.code !== 'ENOENT') warnLog(ctx, `cannot scan ${MEMORY_DIR}: ${err?.message ?? err}`)
65
+ return []
66
+ }
67
+ const names = entries
68
+ .filter(e => e.isFile() && isMemoryEntry(e.name))
69
+ .map(e => e.name)
70
+ const loaded = await Promise.all(names.map(async (name) => {
71
+ try {
72
+ const full = join(MEMORY_DIR, name)
73
+ const [content, info] = await Promise.all([readFile(full, 'utf8'), stat(full)])
74
+ return { name, content, bytes: info.size, mtimeMs: info.mtimeMs }
75
+ } catch (err) {
76
+ if (err?.code !== 'ENOENT') warnLog(ctx, `cannot read ${name}: ${err?.message ?? err}`)
77
+ return null
78
+ }
79
+ }))
80
+ return loaded.filter(Boolean)
81
+ }
@@ -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
+ }