sage-mem 0.9.20 → 1.0.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 +411 -218
- package/README.zh.md +566 -0
- package/lib/autodream-tools.js +86 -23
- package/lib/index.js +119 -67
- package/lib/memory/config.js +4 -1
- package/lib/memory/scan.js +131 -7
- package/lib/memory/watch.js +171 -0
- package/package.json +2 -2
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sage-mem 记忆层 —— 目录监视(**只为缓存失效服务**,不承载任何业务语义)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要它:记忆注入每走一步都要 `scanMemoryFiles` 全量列目录 + 逐份开文件读
|
|
5
|
+
* frontmatter,而成本几乎全在 `open` 这个系统调用上(1000 份合成记忆实测:只 `lstat`
|
|
6
|
+
* 不开文件 83 ms,`open`+`close` 只开不读 614 ms,`readFile` 全文 719 ms)。
|
|
7
|
+
* 也就是说「读的文件少一点」省不下来 —— 只有**不去打开没变过的文件**才省得下来。
|
|
8
|
+
*
|
|
9
|
+
* 那怎么知道「没变过」?本模块给出的信号是:**目录里发生过事件**。调用方(`scan.js`)
|
|
10
|
+
* 拿它当缓存的主判据,并且**只在 watcher 真的可用时才敢缓存** —— 拿不到信号就一律全扫,
|
|
11
|
+
* 与没有缓存时逐字一致。这是一条安全降级:性能优化不许以「看到的内容可能是旧的」为代价。
|
|
12
|
+
*
|
|
13
|
+
* ⚠️ 本模块的取舍(调用方必须知道的那条残余风险):
|
|
14
|
+
* `fs.watch` 是**通知**机制,不是**保证**机制。平台、网络盘、编辑器写文件的姿势(先写临时
|
|
15
|
+
* 文件再 rename / 直接覆写 / 只在内存里改)都可能让事件丢掉。丢掉一个「只改内容、不改文件名」
|
|
16
|
+
* 的事件时,缓存会在本进程内继续供旧内容,直到下一次**任何**失效(新增 / 删除 / 重命名,
|
|
17
|
+
* 或下一次侥幸收到的事件)—— 那时会退回全扫,正确性恢复。有意选这个取舍,而不是每次扫都
|
|
18
|
+
* 逐份 `lstat` 复核 mtime:后者在 1000 份上要 83 ms,正是这次要省掉的东西。
|
|
19
|
+
* 真丢事件的后果是**一次注入拿到旧内容**,不是永久错误 —— 调用方 `scan.js` 的兜底判据
|
|
20
|
+
* (每次仍做一次 `readdir` 比文件名集合)覆盖了「新增 / 删除 / 重命名」这一大类,
|
|
21
|
+
* 而「只改内容」这一类是**明知的、写在这里的**残余风险。
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { watch } from 'node:fs'
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 事件去抖窗口(毫秒)。
|
|
28
|
+
*
|
|
29
|
+
* 一次保存常常触发多个事件(临时文件 + rename + 内容写),去抖把一串合并成「两次通知」:
|
|
30
|
+
* 第一次**立刻**,最后一次在窗口结束时。为什么保留第一次而不只留末尾那次 —— 见 onEvent。
|
|
31
|
+
*/
|
|
32
|
+
export const WATCH_DEBOUNCE_MS = 50
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 事件洪流哨兵:窗口内超过这么多个事件,就认定这个句柄已经坏了,主动掐掉。
|
|
36
|
+
*
|
|
37
|
+
* ⚠️ 这不是防御性编程,是**实测踩到的坑**:Windows 上被监视的目录一旦被删(或删除后
|
|
38
|
+
* 用同名目录重建),底层句柄失效,`fs.watch` 会以**每秒十几万个** `'change'` 事件空转,
|
|
39
|
+
* 而且**不会**发 `'error'` —— 实测 2.9 秒收到 388,196 个事件。后果有两层:
|
|
40
|
+
* 1. 进程 100% CPU 空转、**永远不退出**(`persistent: false` 拦不住一个忙等的句柄);
|
|
41
|
+
* 仓库里 `scripts/perf-chain.mjs` 每档结束都会 `rmSync` 掉沙箱,于是子进程卡死、
|
|
42
|
+
* 父进程 `spawnSync` 无限等待 —— 整个脚本挂住(这次就是这么发现的)。
|
|
43
|
+
* 2. 每个事件都重置去抖定时器 → 定时器分配风暴,进一步烧 CPU。
|
|
44
|
+
* 真实变更不可能到这个量级(一次批量写 1000 个文件也就 1000 个事件),所以阈值取哨兵值即可;
|
|
45
|
+
* 万一误判,代价也只是「这个目录退回全扫」(正确性不受影响),下一轮扫描会重新起表。
|
|
46
|
+
*/
|
|
47
|
+
export const WATCH_FLOOD_WINDOW_MS = 1000
|
|
48
|
+
|
|
49
|
+
/** 见 WATCH_FLOOD_WINDOW_MS。 */
|
|
50
|
+
export const WATCH_FLOOD_MAX_EVENTS = 2000
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 起一个只在**失效信号**意义上使用的目录监视器。
|
|
54
|
+
*
|
|
55
|
+
* @param {string} dir — 要监视的目录(不存在也算「起不来」,不抛)
|
|
56
|
+
* @param {() => void} [onChange] — 目录有变更时的通知(去抖后 + 首次立即各一次)
|
|
57
|
+
* @returns {{ok:boolean, reason:string, dispose:() => void}}
|
|
58
|
+
* `ok` / `reason` 是**活的取值器**:起表成功之后如果 watcher 报错,`ok` 会变成 false、
|
|
59
|
+
* `reason` 会变成失败原因 —— 调用方每次用之前重新读一遍,就不会拿着一个已经死掉的
|
|
60
|
+
* watcher 继续相信缓存。**任何失败都不抛**,一律用 `ok: false` 表达。
|
|
61
|
+
*/
|
|
62
|
+
export function createDirWatcher(dir, onChange) {
|
|
63
|
+
const notify = typeof onChange === 'function' ? onChange : () => {}
|
|
64
|
+
/** 起表失败的原因;空串 = 还活着。 */
|
|
65
|
+
let failed = ''
|
|
66
|
+
|
|
67
|
+
let watcher
|
|
68
|
+
try {
|
|
69
|
+
// persistent: false —— 监视器不许拖住进程退出(测试里也会造一堆临时目录的 watcher)。
|
|
70
|
+
watcher = watch(dir, { persistent: false })
|
|
71
|
+
} catch (err) {
|
|
72
|
+
// 目录不存在 / 没有权限 / 平台不支持:都是「起不来」,调用方据此退回全扫。
|
|
73
|
+
return { ok: false, reason: `fs.watch 打不开(${err?.code ?? err?.message ?? err})`, dispose() {} }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let disposed = false
|
|
77
|
+
let timer = null
|
|
78
|
+
/** 本轮突发里「第一次通知」是否已经发过 —— 决定后面的还发不发。 */
|
|
79
|
+
let burstOpen = false
|
|
80
|
+
/** 洪流哨兵的固定窗口计数(见 WATCH_FLOOD_WINDOW_MS)。 */
|
|
81
|
+
let floodWindowStart = 0
|
|
82
|
+
let floodCount = 0
|
|
83
|
+
|
|
84
|
+
const fire = () => {
|
|
85
|
+
if (disposed || failed) return
|
|
86
|
+
try {
|
|
87
|
+
notify()
|
|
88
|
+
} catch {
|
|
89
|
+
// 通知是调用方的代码,它出错不该把 watcher 弄死;失效信号本身已经送到了。
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 第一条事件**立刻**透传,之后同一突发里的事件只在去抖窗口结束时再通知一次。
|
|
95
|
+
*
|
|
96
|
+
* 为什么不干脆等去抖结束:去抖的目的是**合并通知**,不是**延迟失效**。等 50 ms 才失效,
|
|
97
|
+
* 意味着这 50 ms 里来一次扫描就会喂旧内容 —— 而「每走一步扫一次」正是本插件的用法,
|
|
98
|
+
* 两步之间远小于 50 ms。所以:首个事件立刻失效(把窗口压到事件送达那一刻),
|
|
99
|
+
* 窗口结束时再补一次(合并掉这一串的其余事件)。
|
|
100
|
+
*/
|
|
101
|
+
const onEvent = () => {
|
|
102
|
+
const now = Date.now()
|
|
103
|
+
if (now - floodWindowStart > WATCH_FLOOD_WINDOW_MS) {
|
|
104
|
+
floodWindowStart = now
|
|
105
|
+
floodCount = 0
|
|
106
|
+
}
|
|
107
|
+
floodCount++
|
|
108
|
+
if (floodCount > WATCH_FLOOD_MAX_EVENTS) {
|
|
109
|
+
// 句柄已坏(多半是目录被删 / 被同名重建):报错这条路会顺手 close 掉,空转立刻停。
|
|
110
|
+
onError({ code: 'FLOOD', message: `${WATCH_FLOOD_WINDOW_MS}ms 内 ${floodCount} 个变更事件,句柄已不可信` })
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
if (!burstOpen) {
|
|
114
|
+
burstOpen = true
|
|
115
|
+
fire()
|
|
116
|
+
}
|
|
117
|
+
if (timer) clearTimeout(timer)
|
|
118
|
+
timer = setTimeout(() => {
|
|
119
|
+
timer = null
|
|
120
|
+
burstOpen = false
|
|
121
|
+
fire()
|
|
122
|
+
}, WATCH_DEBOUNCE_MS)
|
|
123
|
+
// 定时器也不许拖住进程退出(与 persistent: false 同一目的;老 Node 上没有 unref 就跳过)。
|
|
124
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 失败一律转成「这台 watcher 死了」,而不是抛出去。
|
|
129
|
+
* 调用方(scan.js)看到 `ok` 变 false 会丢掉缓存并退回全扫 —— 所以这里**必须**
|
|
130
|
+
* 把失败暴露出来,静默吞掉等于让它继续相信一份再也收不到失效信号的缓存。
|
|
131
|
+
*/
|
|
132
|
+
const onError = (err) => {
|
|
133
|
+
failed = `fs.watch 出错(${err?.code ?? err?.message ?? err})`
|
|
134
|
+
cleanup()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const cleanup = () => {
|
|
138
|
+
if (timer) {
|
|
139
|
+
clearTimeout(timer)
|
|
140
|
+
timer = null
|
|
141
|
+
}
|
|
142
|
+
burstOpen = false
|
|
143
|
+
try {
|
|
144
|
+
watcher.close()
|
|
145
|
+
} catch {
|
|
146
|
+
// 已经关掉了 / 底层句柄没了 —— dispose 必须幂等,不许在这里抛。
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const dispose = () => {
|
|
151
|
+
if (disposed) return
|
|
152
|
+
disposed = true
|
|
153
|
+
cleanup()
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
watcher.on('change', onEvent)
|
|
157
|
+
// 'error' 是失败的另一条路(句柄彻底失效时 Node 才报)。⚠️ **目录被删不走这条** ——
|
|
158
|
+
// 实测 Windows 上是「静默洪流」(见洪流哨兵),所以两条都得接;不接 'error' 会变成
|
|
159
|
+
// uncaughtException,不接洪流就是 100% CPU 空转。
|
|
160
|
+
watcher.on('error', onError)
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
get ok() {
|
|
164
|
+
return !disposed && failed === ''
|
|
165
|
+
},
|
|
166
|
+
get reason() {
|
|
167
|
+
return failed
|
|
168
|
+
},
|
|
169
|
+
dispose,
|
|
170
|
+
}
|
|
171
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sage-mem",
|
|
3
|
-
"description": "
|
|
4
|
-
"version": "0.
|
|
3
|
+
"description": "Cross-session memory for DeepSeek Harness — plain markdown files, no database, no worker.",
|
|
4
|
+
"version": "1.0.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|