sage-mem 0.9.19 → 0.9.21
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 +18 -2
- package/lib/autodream-tools.js +90 -5
- package/lib/index.js +119 -67
- package/lib/memory/scan.js +131 -7
- package/lib/memory/watch.js +171 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -163,7 +163,7 @@ SAGE_MEM_DIR=/path/to/your/memory
|
|
|
163
163
|
- **摘要没写到的,正文能找回来**。摘要是精准的一句话,代价是正文细节天生检索不到;摘要一条都没命中时改由正文检索兜底——此前这种情况直接等于「没有记忆可用」。正文检索只在问题**至少 4 个字符**时才跑:`继续` 这种两个字的问题拿去做正文匹配,命中的全是「继续」这个词本身
|
|
164
164
|
- **换个说法也能命中**:打分是**词面重合**,不是语义的——问「我有什么待办」而文件里写的是「代办清单」,差一个字就完全不命中。给这类记忆加一行 `aliases: [待办, todo]` 就能补上
|
|
165
165
|
- **注入的是命中段落,不是文件开头**。命中段落标成 `### 项目_部署.md:40-52`,agent 据此能直接跳到那几行核对来源;段落前补一句该记忆的 `description`,补回「这段是从哪条记忆切出来的」。`baseline` 与没命中正文的文件仍注入开头。单条正文超 1500 字符截断并标 `…(截断)`
|
|
166
|
-
-
|
|
166
|
+
- **相邻的重复问题不会重复注入**(按会话只留一个签名),连着问两遍不会又追加一遍。注意这**不是**「整个会话同题只给一次」:问 A → 问 B → 再问 A,第三次会重新注入 —— 再次要求回忆时重新给证据未必是坏事
|
|
167
167
|
- **单场会话预算 60 KB,拆成两本账**(`MAX_SESSION_BYTES`):**正文池** = 总上限 − **状态卡预留**(5%,夹在 1–4 KB)。总上限的数字没变,变的是到顶之后的行为 —— 旧行为是「正文装不下就一个字都不注,连为什么都没有」;现在是「正文照旧不注,但缺口状态卡发得出来,告诉你漏了什么、下一步怎么查」。**压缩或清空之后两本账与卡的去重签名一起归零**,又能注进来
|
|
168
168
|
- **缺口状态卡只在真有缺口时说话**:有候选没读、或有候选被预算省略时才出现,说清范围、条数、最佳线索和**一个可做的动作**;一条都没命中时**只在你问自己的历史时**才说(否则提示会被当噪音忽略)。单张卡上限 512 字节,且按内容签名去重 —— 同一张卡不会每一步都追加一遍
|
|
169
169
|
|
|
@@ -208,8 +208,24 @@ SAGE_MEM_DIR=/path/to/your/memory
|
|
|
208
208
|
|
|
209
209
|
**这张台账目前只用于诊断与自动归档阈值,不参与检索排序。** 观测有已知盲区:`pwsh` 里
|
|
210
210
|
`cat 某记忆` 认不出来,那就不认 —— 我们不会把「看不到」说成「没读过」。
|
|
211
|
+
|
|
212
|
+
### 两个写入者撞上时,不会互相覆盖
|
|
213
|
+
|
|
214
|
+
记忆是**文件**,所以「你正在编辑」和「agent 正在改」完全可能同时发生。默认行为是**后写的赢、
|
|
215
|
+
先写的悄悄消失** —— 那是最糟的一种,因为你不会发现。
|
|
216
|
+
|
|
217
|
+
sage-mem 给记忆目录的写入加了**版本守卫**:写入前记下当前版本,写入时要求版本没变过。
|
|
218
|
+
变了就**明确拒绝**,而不是盖掉。模型用内置 `write` / `edit` 改记忆时会自动带上这层守卫。
|
|
219
|
+
|
|
220
|
+
**被拒的那次会落成一条待处理的记录**(`<状态根>/conflicts/<id>.json`),里面留着
|
|
221
|
+
**盘上那一版**和**想要写的那一版** —— 因为只有一边等于没得判。
|
|
222
|
+
|
|
223
|
+
- **冲突不会自动合并**,保留哪边由你决定。静默重试和静默放弃都是在替你决定。
|
|
224
|
+
- 同一条冲突重复上报时 id 相同,队列里不会长出重复项。
|
|
225
|
+
- 已知边界:**在 shell 里改记忆不走这条路**(`pwsh` 里 `cat > 某个记忆` 我们认不出来)。
|
|
226
|
+
写门守的是「经过工具的记忆写入」,不是「所有对文件的修改」。
|
|
211
227
|
- **每条记忆的标题带新鲜度**:`(保存于 N 天前)`,**优先读 frontmatter 的 `updated:`,缺失才回落文件 mtime** —— 手写的时间戳是权威,文件系统时间会被复制 / 备份还原 / checkout 整批改掉。理由是陈旧记忆被当事实引用时,「引用」这个动作本身会让它显得更权威而不是更可疑,所以注入文本开头还带一节**引用纪律**(提到路径先确认存在、提到函数先 grep、要动手先核实)。它**独立成节**而不是一条 bullet:Claude Code 实测同一句话埋成 bullet 命中 0/3、独立成节 3/3
|
|
212
|
-
- **`baseline: true` 的记忆每场会话第一步无条件注入**,最多 8
|
|
228
|
+
- **`baseline: true` 的记忆每场会话第一步无条件注入**,最多 8 条——给「你是谁」「待办清单」这类每次开场都该在场的记忆用。⚠️ **「不吃段落替换」不等于整份**:它同样受单文件长度上限(默认 1500 字符)截断,**关键内容若落在尾部会看不到** —— baseline 适合放短摘要,完整正文留给工具去读。多条 baseline 之间的先后由 `baselinePriority` 决定(数字越大越前,缺省 0),**不按文件名排**
|
|
213
229
|
- 记忆目录读不到、权限错、frontmatter 格式坏,这些都**降级为不注入并写日志**,不会静默失效
|
|
214
230
|
|
|
215
231
|
单文件上限 512 KB,**读写两侧都校验**(读的时候先看体积,超了就不读进来);标签最多 20 个、每个 40 字符(再多就不是标签,是一句话被塞进 `tags` 了)。
|
package/lib/autodream-tools.js
CHANGED
|
@@ -32,6 +32,8 @@ import { archiveMemory } from './memory/archive.js'
|
|
|
32
32
|
import { MAX_FILE_BYTES, STATE_ROOT } from './memory/config.js'
|
|
33
33
|
import { writeFileAtomic, warnLog } from './memory/util.js'
|
|
34
34
|
import { buildConflict, writeConflict } from './memory/conflicts.js'
|
|
35
|
+
import { memoryPathOf } from './memory/paths.js'
|
|
36
|
+
import { decideWriteIntent } from './memory/write-gate.js'
|
|
35
37
|
import { inspectMemoryEntry, reservedIdentitySet } from './memory/scan.js'
|
|
36
38
|
|
|
37
39
|
/** 单次 read_memory 返回的正文上限(字符)。超过就截断并明确告诉模型。 */
|
|
@@ -460,6 +462,71 @@ export function createToolRunner(opts) {
|
|
|
460
462
|
* @param {'create'|'update'|'archive'} op
|
|
461
463
|
* @param {'model'|'auto'} reasonSource — model = 模型自述;auto = 宿主追溯(模型没给)
|
|
462
464
|
*/
|
|
465
|
+
/**
|
|
466
|
+
* 「这份文件我们读到时是什么版本」——**按规范化路径**记,写的时候拿它当守卫。
|
|
467
|
+
*
|
|
468
|
+
* 为什么必须有这张表(外部复审 R01,P1 数据丢失):守卫如果取**写的那一刻**的版本,
|
|
469
|
+
* 它只保护 `stat → writeText` 这一小段,而真实的危险窗口是 **`读 → 生成 → 写`** 整个周期。
|
|
470
|
+
* 复现:读 A → 人工改成 B → 自动整理基于 A 生成 C → 写时 stat 拿到 B → 守卫通过 → **B 被覆盖**。
|
|
471
|
+
* 单进程里顺序交错就能发生,不需要多进程竞争。
|
|
472
|
+
*
|
|
473
|
+
* 存的是**读到内容那一刻的版本**:C 是基于 A 生成的,就该拿 A 去比。
|
|
474
|
+
* 磁盘已经是 B 时,守卫会以 `FS_STALE_VERSION` 拒绝,内容不丢。
|
|
475
|
+
*/
|
|
476
|
+
const readVersions = new Map()
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* 记下一次读取对应的版本。取不到可信版本就**什么都不记**(宁可让写入走「拒绝」,
|
|
480
|
+
* 也不要拿一个来路不明的版本假装有守卫)。
|
|
481
|
+
*
|
|
482
|
+
* 一致性:宿主 fs 可用时用 `stat → 读 → stat`,**两次版本相同**才认这份内容是那个版本的快照。
|
|
483
|
+
* 中间被人改过就不记 —— 分开读拿不到原子快照,那就诚实地说「不知道」。
|
|
484
|
+
*/
|
|
485
|
+
async function rememberReadVersion(fullPath, content, truncated) {
|
|
486
|
+
try {
|
|
487
|
+
const fs = hostFs()
|
|
488
|
+
if (!fs || typeof fs.stat !== 'function') return
|
|
489
|
+
const target = await fs.resolve(fullPath)
|
|
490
|
+
const before = await fs.stat(target)
|
|
491
|
+
if (!before?.version) return
|
|
492
|
+
const after = await fs.stat(target)
|
|
493
|
+
if (!after?.version || after.version !== before.version) return
|
|
494
|
+
const where = memoryPathOf(fullPath, memoryDir)
|
|
495
|
+
if (where.ok) readVersions.set(where.canon, { version: before.version, truncated: truncated === true })
|
|
496
|
+
} catch { /* 记不下就不记:调用方会按「未知版本」处理 */ }
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* 取「这次写入该用哪个守卫」。
|
|
501
|
+
*
|
|
502
|
+
* 三种情形,**每一种都是有意的**:
|
|
503
|
+
* - 文件不存在 → `createIfAbsent`(新建不可能覆盖谁)
|
|
504
|
+
* - 文件存在 + 我们记过它被读到时是哪个版本 → `replaceIfVersion(那个版本)`
|
|
505
|
+
* - 文件存在 + **没记过**(没读过 / 版本取不到 / 读的是截断内容)→ **不给守卫,由调用方拒绝**
|
|
506
|
+
*
|
|
507
|
+
* 第三种是 R01 的要害:以前这里退化成「用当前版本」,等于**替调用方担保了一份它没读过的内容**。
|
|
508
|
+
* 现在返回 `{refuse, why}`,让调用方把这次写入转成一条待人工处理的冲突,而不是盲写。
|
|
509
|
+
*/
|
|
510
|
+
async function guardForWrite(fullPath) {
|
|
511
|
+
const fs = hostFs()
|
|
512
|
+
if (!fs || typeof fs.stat !== 'function') return { expected: undefined, known: false }
|
|
513
|
+
const target = await fs.resolve(fullPath)
|
|
514
|
+
let info
|
|
515
|
+
try {
|
|
516
|
+
info = await fs.stat(target)
|
|
517
|
+
} catch (err) {
|
|
518
|
+
// stat 抛错 = **不知道盘上是什么**。不知道就不写,而不是「不传守卫让宿主随便写」。
|
|
519
|
+
return { expected: undefined, known: false, refuse: true, why: `取不到当前版本(${err?.code ?? err?.message ?? err}),不能替你覆盖` }
|
|
520
|
+
}
|
|
521
|
+
// 「明确不存在」这一支仍走写门的判定表(`write-gate.js`)—— 能共用的就别写第二份。
|
|
522
|
+
if (!info) return { expected: decideWriteIntent({ memoryPath: memoryPathOf(fullPath, memoryDir), exists: false, version: null, caller: undefined }).intent, known: true }
|
|
523
|
+
const where = memoryPathOf(fullPath, memoryDir)
|
|
524
|
+
const seen = where.ok ? readVersions.get(where.canon) : undefined
|
|
525
|
+
if (!seen?.version) return { expected: undefined, known: false, refuse: true, why: '这份记忆我们没有读过(或版本取不到),不能替你覆盖它' }
|
|
526
|
+
if (seen.truncated) return { expected: undefined, known: false, refuse: true, why: '上次读到的是截断内容,不能拿它作为整份覆盖的依据' }
|
|
527
|
+
return { expected: { kind: 'replaceIfVersion', version: seen.version }, known: true }
|
|
528
|
+
}
|
|
529
|
+
|
|
463
530
|
/**
|
|
464
531
|
* 取宿主的 `fs` 服务,拿不到就返回 null。
|
|
465
532
|
*
|
|
@@ -486,7 +553,7 @@ export function createToolRunner(opts) {
|
|
|
486
553
|
* ⚠️ 这里**吞掉一切异常**:它发生在「写入已经失败」的路径上,再抛一次只会把真正的原因
|
|
487
554
|
* (`FS_STALE_VERSION`)盖掉。留不下记录就只留日志,绝不改变向外抛的那个错误。
|
|
488
555
|
*/
|
|
489
|
-
async function recordStaleConflict(fullPath, incoming, err) {
|
|
556
|
+
async function recordStaleConflict(fullPath, incoming, err, kind = 'stale_version') {
|
|
490
557
|
try {
|
|
491
558
|
const fs = hostFs()
|
|
492
559
|
let onDisk = null
|
|
@@ -495,7 +562,7 @@ export function createToolRunner(opts) {
|
|
|
495
562
|
if (fs && typeof fs.readText === 'function') onDisk = await fs.readText(await fs.resolve(fullPath))
|
|
496
563
|
} catch { /* 读不到就只留我们这一版:宁缺一半,也不要为它把冲突记录整条丢掉 */ }
|
|
497
564
|
const record = buildConflict({
|
|
498
|
-
kind
|
|
565
|
+
kind,
|
|
499
566
|
file: basename(fullPath),
|
|
500
567
|
expectedVersion: 'unknown', // 守卫是我们自己按 stat 到的版本构造的,这里拿不到那个字符串
|
|
501
568
|
actualVersion: String(err?.code ?? ''),
|
|
@@ -534,8 +601,22 @@ export function createToolRunner(opts) {
|
|
|
534
601
|
return null
|
|
535
602
|
}
|
|
536
603
|
const target = await fs.resolve(fullPath)
|
|
604
|
+
// ⚠️ **守卫必须由我们自己传。** `fs/write-intent` 只为「工具发起」的写入派发,
|
|
605
|
+
// 插件自己调 `writeText` 时一次都不发(实测)—— 不自己传,这次写入就是**无守卫**的。
|
|
606
|
+
//
|
|
607
|
+
// ⚠️⚠️ **而且必须是「读到内容那一刻」的版本,不是「现在」的版本。** 拿现在这个版本当守卫
|
|
608
|
+
// 只保护 `stat → writeText` 这一小段;真正的危险窗口是 `读 → 生成 → 写`。
|
|
609
|
+
// 外部复审 R01 就是这样复现出数据丢失的(读 A → 人工改 B → 基于 A 生成 C → 写时拿到 B 的版本
|
|
610
|
+
// → 守卫通过 → B 没了)。守卫的版本来自 `readVersions`,见 guardForWrite。
|
|
611
|
+
const guard = await guardForWrite(fullPath)
|
|
612
|
+
if (guard.refuse) {
|
|
613
|
+
// **不知道就不写。** 以前这条路会退化成「用当前版本当守卫」,等于替调用方担保一份它没读过的
|
|
614
|
+
// 内容 —— 那比「什么都不做」危险得多。转成一条待人工处理的冲突,让人来判。
|
|
615
|
+
await recordStaleConflict(fullPath, content, { code: 'UNVERIFIED_OVERWRITE', message: guard.why }, 'external_change')
|
|
616
|
+
throw Object.assign(new Error(guard.why), { code: 'SAGE_MEM_UNVERIFIED_OVERWRITE' })
|
|
617
|
+
}
|
|
537
618
|
try {
|
|
538
|
-
return await fs.writeText(target, content)
|
|
619
|
+
return await fs.writeText(target, content, guard.expected)
|
|
539
620
|
} catch (err) {
|
|
540
621
|
// 版本过期不是「写失败」,是**一次待判定的冲突**:有人在我们读入之后改过这份记忆。
|
|
541
622
|
// 落一条记录(保留哪边由人决定),然后**照旧把错误抛出去** ——
|
|
@@ -621,8 +702,12 @@ export function createToolRunner(opts) {
|
|
|
621
702
|
throw err
|
|
622
703
|
})
|
|
623
704
|
if (content === null) return `错误:没有这个文件:${basename(full)}`
|
|
624
|
-
|
|
625
|
-
|
|
705
|
+
// 记下「这份内容对应磁盘上的哪个版本」——写回时用它当守卫(外部复审 R01)。
|
|
706
|
+
// 截断的读也要标出来:截断内容不足以作为整份覆盖的依据。
|
|
707
|
+
const truncated = content.length > READ_LIMIT
|
|
708
|
+
await rememberReadVersion(full, content, truncated)
|
|
709
|
+
if (truncated) {
|
|
710
|
+
return `${content.slice(0, READ_LIMIT)}\n\n…(已截断,全文 ${content.length} 字符。注意:截断的读不能作为整份覆盖的依据)`
|
|
626
711
|
}
|
|
627
712
|
return content
|
|
628
713
|
}
|
package/lib/index.js
CHANGED
|
@@ -75,6 +75,59 @@ function dedupeByFile(hits) {
|
|
|
75
75
|
return out
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* 会话级注入状态:**一处持有、一处提交**。
|
|
80
|
+
*
|
|
81
|
+
* 为什么必须集中:原先这是三个散落的容器(检索签名 `recallCache`、baseline 标记
|
|
82
|
+
* `baselineInjected`、注入账本 `injectLedger`),而代次变化本该让它们**同时**归零 ——
|
|
83
|
+
* 旧代码把重置写在去重**之后**,于是同一问题在压缩后仍被上一代的检索签名挡住,
|
|
84
|
+
* 记忆再也回不来(外部复审 R03)。集中之后,顺序是能被读出来的:
|
|
85
|
+
*
|
|
86
|
+
* begin() 处理代次变化 → 调用方算正文与控制提示 → commit() 一次写回
|
|
87
|
+
*
|
|
88
|
+
* 为什么提交放最后:中途抛错时状态不该前进 —— 没注进去的东西不能记成「已注过」,
|
|
89
|
+
* 否则一次失败会把这条记忆永久挡在门外。唯一的例外是代次变化:那是**已经发生的事实**
|
|
90
|
+
* (上下文真的被压缩了),必须在算任何东西之前生效,否则这一步会拿着上一代的账做判断。
|
|
91
|
+
*
|
|
92
|
+
* ⚠️ 诚实边界:压缩信号是「消息条数下降」这条启发式,它**覆盖不了条数不降的压缩**
|
|
93
|
+
* (宿主原地改写历史,或截断后又在同一步补足条数)。那种情况下这里看不出代次变化,
|
|
94
|
+
* 记忆仍会被去重挡住。要真正解决得等宿主给出可比的上下文代次标识(目前没有)。
|
|
95
|
+
*/
|
|
96
|
+
function createSessionInjectState(maxSessions = 64) {
|
|
97
|
+
const sessions = new Map()
|
|
98
|
+
const fresh = () => ({
|
|
99
|
+
signature: null, // 已注入过的检索签名(问题 + 命中文件集)
|
|
100
|
+
contentBytes: 0, // 本代正文累计字节
|
|
101
|
+
cardBytes: 0, // 本代状态卡累计字节(与正文分账,见 config.js)
|
|
102
|
+
cardSig: '', // 本代已发过的那张卡(含检查点句柄的合成文本)
|
|
103
|
+
gapCard: '', // 本代渲染过的缺口卡正文(签名没变时沿用,避免换个数字重发)
|
|
104
|
+
messageCount: -1, // 上一次见效时的消息条数(压缩判据)
|
|
105
|
+
baselineDone: false, // 本代是否已经给过 baseline
|
|
106
|
+
})
|
|
107
|
+
return {
|
|
108
|
+
/**
|
|
109
|
+
* 处理代次变化,取回本会话这一代的起点。**必须在算正文与控制提示之前调用。**
|
|
110
|
+
* 返回值是一份快照 —— 调用方只读,写回统一走 `commit()`。
|
|
111
|
+
*/
|
|
112
|
+
begin(key, messageCount) {
|
|
113
|
+
const prev = sessions.get(key)
|
|
114
|
+
const generationChanged = !!prev && messageCount < prev.messageCount
|
|
115
|
+
// 条数下降 → 四样东西一起归零。少清任何一样都会留下一个「上一代的真相」:
|
|
116
|
+
// 字节账残留 → 新内容注不进来;baseline 残留 → 它再也不出现;
|
|
117
|
+
// 卡签名残留 → 恢复入口永久失效。
|
|
118
|
+
if (generationChanged) sessions.set(key, { ...fresh(), messageCount })
|
|
119
|
+
const cur = sessions.get(key) ?? fresh()
|
|
120
|
+
return { ...cur, generationChanged }
|
|
121
|
+
},
|
|
122
|
+
/** 一次写回。只防长驻进程无限增长:被挤掉的老会话下次醒来至多把同一批记忆再注一次。 */
|
|
123
|
+
commit(key, next) {
|
|
124
|
+
sessions.set(key, { ...next })
|
|
125
|
+
// FIFO 而不是 LRU:Map 重写已存在的键不会挪动位置,与旧行为一致。
|
|
126
|
+
if (sessions.size > maxSessions) sessions.delete(sessions.keys().next().value)
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
78
131
|
/**
|
|
79
132
|
* 面板可改的 5 项注入上限 —— 与 `memory/config.js` 里的 `SAGE_MEM_*` 一一对应。
|
|
80
133
|
* 边界值照抄那边的 envInt 调用(lo/hi 不许在这里另立一套)。
|
|
@@ -354,31 +407,17 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
354
407
|
}
|
|
355
408
|
} catch { /* 老宿主没有这两个事件:写入按宿主默认语义走 */ }
|
|
356
409
|
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
const recallCache = new Map()
|
|
360
|
-
|
|
361
|
-
// 「本会话是否已注入过 baseline」——会话级状态,按 session id 记。
|
|
362
|
-
//
|
|
363
|
-
// 为什么不是消息条数:见下面 baseline 那段注释(恢复会话的历史消息早就在
|
|
364
|
-
// session 里,条数永不为 0)。会话 id 在整场会话里稳定,与 assemble 时
|
|
365
|
-
// 当前用户消息有没有落盘完全无关,所以新会话、恢复会话两种时序都成立。
|
|
366
|
-
// 容量 64 与 recallCache 同理,只防长驻进程无限增长:要挤掉一个 id 得先有
|
|
367
|
-
// 63 场别的会话开起来,那场老会话再被唤醒时最多是把同样的 baseline 再注入一次。
|
|
368
|
-
const BASELINE_CACHE_MAX = 64
|
|
369
|
-
const baselineInjected = new Set()
|
|
370
|
-
|
|
371
|
-
// 「本会话已经注入掉多少字节」—— 会话级账本,按 session id 记。
|
|
410
|
+
// 会话级注入状态(检索签名 / baseline 标记 / 两本账 / 卡签名)——
|
|
411
|
+
// 一处持有、一处提交,理由与顺序见 `createSessionInjectState` 的注释。
|
|
372
412
|
//
|
|
373
|
-
//
|
|
374
|
-
// user/message
|
|
375
|
-
//
|
|
413
|
+
// 为什么不扫会话记录去算「已经注掉多少字节」:同一轮 assemble 出来的内容会被宿主
|
|
414
|
+
// 合进**同一条** user/message(运行时上下文、工作区指令、插件注入共用一条),
|
|
415
|
+
// 按消息字节统计会把别人的内容算进本插件的账;按文本找标记又太脆。自己记账确定性最高。
|
|
376
416
|
//
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
const
|
|
381
|
-
const injectLedger = new Map()
|
|
417
|
+
// baseline 为什么按会话记而不是按消息条数:恢复会话的历史消息早就在 session 里,
|
|
418
|
+
// 条数永不为 0。会话 id 在整场会话里稳定,与 assemble 时当前用户消息有没有落盘无关,
|
|
419
|
+
// 所以新会话、恢复会话两种时序都成立。
|
|
420
|
+
const injectState = createSessionInjectState()
|
|
382
421
|
|
|
383
422
|
// 按问题检索注入:system-prompt/assemble 是异步 waterfall,监听器可 await。
|
|
384
423
|
// 从 agent.session 拿当前 user message,扫 memory 目录按摘要匹配选相关
|
|
@@ -410,6 +449,14 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
410
449
|
const text = extractText(userMessages[userMessages.length - 1]?.content)
|
|
411
450
|
|
|
412
451
|
const files = await scanMemoryFiles(ctx, sessionId)
|
|
452
|
+
|
|
453
|
+
// ── 代次:**先处理状态转换,再算正文与控制提示** ──
|
|
454
|
+
// 压缩 / 清空(消息条数下降)在这一行里就让签名、两本账、卡签名、baseline 标记
|
|
455
|
+
// 一起归零。放到后面(比如去重之后再算)就等于「拿上一代的账本判这一代」——
|
|
456
|
+
// 复审 R03 的「同题压缩后仍被挡住」正是这么来的。
|
|
457
|
+
const ledgerKey = session.id || 'no-session-id'
|
|
458
|
+
const st = injectState.begin(ledgerKey, messages.length)
|
|
459
|
+
|
|
413
460
|
const picked = []
|
|
414
461
|
const seen = new Set()
|
|
415
462
|
const take = (f) => {
|
|
@@ -431,22 +478,17 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
431
478
|
// 命中 → 永不注入。而「继续吧」这类短问题在 description 里找不到匹配、检索
|
|
432
479
|
// 返回 0 条,baseline 恰恰是那时唯一的兜底。这是本会话级状态要修的东西。
|
|
433
480
|
let baselineTaken = 0
|
|
434
|
-
|
|
435
|
-
if (!baselineInjected.has(baselineKey)) {
|
|
481
|
+
if (!st.baselineDone) {
|
|
436
482
|
selectBaseline(files)
|
|
437
483
|
.forEach(f => {
|
|
438
484
|
const before = picked.length
|
|
439
485
|
take(f)
|
|
440
486
|
if (picked.length > before) baselineTaken++
|
|
441
487
|
})
|
|
442
|
-
//
|
|
488
|
+
// 只有真取到才算「给过」:首步若正好撞上目录读不到(scanMemoryFiles 降级返回 []),
|
|
443
489
|
// 整场会话就再也没机会补 baseline 了。宁可下一步重算一次。
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
if (baselineInjected.size > BASELINE_CACHE_MAX) {
|
|
447
|
-
baselineInjected.delete(baselineInjected.values().next().value)
|
|
448
|
-
}
|
|
449
|
-
}
|
|
490
|
+
// 标记本身随这一步一起在 commit() 落 —— 与「提交放最后」同一条理由:
|
|
491
|
+
// 没真注进去的东西不能记成「已经给过」。
|
|
450
492
|
}
|
|
451
493
|
|
|
452
494
|
// 检索签名只覆盖「检索出来的那批」,baseline 不再掺进这个签名。
|
|
@@ -505,16 +547,15 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
505
547
|
}
|
|
506
548
|
|
|
507
549
|
const signature = text + '\u0000' + selected.map(f => f.file).join(',')
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
550
|
+
// 检索签名没变 = 这一代里这条问题已经处理过 → 不再重复追加同一份记忆。
|
|
551
|
+
// ⚠️ 这个判断必须排在代次处理**之后**:压缩后的同题恢复,靠的就是上面 `begin()`
|
|
552
|
+
// 把签名一起清掉(复审 R03)。
|
|
553
|
+
const deduped = st.signature === signature
|
|
554
|
+
if (!deduped) selected.forEach(take)
|
|
513
555
|
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
}
|
|
556
|
+
// ⚠️ 这里**不能**再写「picked 为空就返回」:检查点句柄与未命中提示都不依赖候选,
|
|
557
|
+
// 提前返回会让它们在「目录为空」或「零命中」时永远不可达(复审 R02)。
|
|
558
|
+
// 统一的出口在下面 —— 只有**正文与控制提示都为空**才返回。
|
|
518
559
|
|
|
519
560
|
// ── 单会话累计预算:**拆成两本账** ──
|
|
520
561
|
// 正文池 = CONTENT_BUDGET(总上限 − 状态卡预留)/卡池 = STATUS_RESERVE
|
|
@@ -522,15 +563,10 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
522
563
|
// 都没有;而那个上限是**本插件自己定的**,完全可以给状态卡留一份固定额度。
|
|
523
564
|
// 账记在 session id 上;picked 里 baseline 永远排在检索结果之前,所以预算不够时
|
|
524
565
|
// 先被挤掉的是「更晚才想起来的」那些 —— baseline 有优先权。
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
// (「本会话注入过」不等于「压缩后还看得见」,这条是计划里点名要处理的。)
|
|
530
|
-
const carried = prevLedger && messages.length >= prevLedger.messageCount ? prevLedger : null
|
|
531
|
-
let contentBytes = carried?.contentBytes ?? 0
|
|
532
|
-
let cardBytes = carried?.cardBytes ?? 0
|
|
533
|
-
let cardSig = carried?.cardSig ?? ''
|
|
566
|
+
// 起点来自 `begin()`:它可能刚因压缩把两本账连同卡签名一起归零过。
|
|
567
|
+
let contentBytes = st.contentBytes
|
|
568
|
+
let cardBytes = st.cardBytes
|
|
569
|
+
let cardSig = st.cardSig
|
|
534
570
|
const nowMs = Date.now()
|
|
535
571
|
|
|
536
572
|
// ── 渲染候选,然后交给展示层决定「给正文 / 给指针 / 不给」──
|
|
@@ -634,19 +670,27 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
634
670
|
} catch { /* 读不到检查点就当没有:它是可选的接力棒,不是必需件 */ }
|
|
635
671
|
|
|
636
672
|
const wantCard = gapExists || intentInfo.historyDependent || !!checkpointLine
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
673
|
+
// 签名没变时沿用上一步渲染好的那份缺口卡,而不是重渲染一张。
|
|
674
|
+
// 两条理由:其一,那一步与这一步的检索面**逐字相同**,缺口也相同,重渲染只会把
|
|
675
|
+
// 同一件事换个数字再说一遍 —— 而局部变量算出来的数字还是错的(上一步注了 N 条,
|
|
676
|
+
// 这一步按本轮 parts 算是 0 条);其二,文本一变 `cardSig` 去重就失效,同一张语义
|
|
677
|
+
// 的卡会被反复追加进会话。
|
|
678
|
+
// 检查点句柄不在此列:它是可能刚刚才出现的接力棒,所以始终参与合成。
|
|
679
|
+
const cardBody = !wantCard
|
|
680
|
+
? ''
|
|
681
|
+
: deduped
|
|
682
|
+
? st.gapCard
|
|
683
|
+
: renderStatusCard({
|
|
684
|
+
scope: bodySearched ? 'all' : 'summary',
|
|
685
|
+
scanned: files.length,
|
|
686
|
+
injected: parts.length,
|
|
687
|
+
omitted: skippedForBudget,
|
|
688
|
+
unread,
|
|
689
|
+
best: beacon ? { file: beacon.file, title: beacon.chunk?.title || beacon.chunk?.headingPath || '' } : null,
|
|
690
|
+
needle: text ? text.slice(0, 40) : null,
|
|
691
|
+
toolsAvailable: toolsReady,
|
|
692
|
+
memoryDir: MEMORY_DIR,
|
|
693
|
+
})
|
|
650
694
|
// 句柄与卡**共用卡池**:它是同一个通道里的另一句提示,不该另开一份额度。
|
|
651
695
|
const cardText = [cardBody, checkpointLine].filter(Boolean).join('\n')
|
|
652
696
|
let cardPart = ''
|
|
@@ -674,10 +718,18 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
674
718
|
return assembled
|
|
675
719
|
}
|
|
676
720
|
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
721
|
+
// 状态一次写回:签名、两本账、卡签名、缺口卡、条数、baseline 标记。
|
|
722
|
+
// 放在这里(而不是更早)是刻意的 —— 上面每一步都可能没真注进去东西,
|
|
723
|
+
// 那就不该让状态前进;一次失败把一条记忆永久挡在门外是最难查的一种。
|
|
724
|
+
injectState.commit(ledgerKey, {
|
|
725
|
+
signature,
|
|
726
|
+
contentBytes,
|
|
727
|
+
cardBytes,
|
|
728
|
+
cardSig,
|
|
729
|
+
gapCard: cardBody,
|
|
730
|
+
messageCount: messages.length,
|
|
731
|
+
baselineDone: st.baselineDone || baselineTaken > 0,
|
|
732
|
+
})
|
|
681
733
|
|
|
682
734
|
if (DEBUG_RECALL) {
|
|
683
735
|
debugRecall({
|
package/lib/memory/scan.js
CHANGED
|
@@ -14,6 +14,7 @@ import { MAX_FILE_BYTES, MEMORY_DIR } from './config.js'
|
|
|
14
14
|
import { parseBaseline, parseFrontmatter } from './frontmatter.js'
|
|
15
15
|
import { isMemoryEntry, isReserved } from './naming.js'
|
|
16
16
|
import { warnLog } from './util.js'
|
|
17
|
+
import { createDirWatcher } from './watch.js'
|
|
17
18
|
import { lstat, readFile, readdir } from 'node:fs/promises'
|
|
18
19
|
import { join } from 'node:path'
|
|
19
20
|
|
|
@@ -235,12 +236,135 @@ export async function inspectDir(dir) {
|
|
|
235
236
|
return { ok: true }
|
|
236
237
|
}
|
|
237
238
|
|
|
238
|
-
/**
|
|
239
|
+
/**
|
|
240
|
+
* 进程内扫描缓存,**按目录分账**。
|
|
241
|
+
*
|
|
242
|
+
* 为什么按目录分:`scanMemoryFiles` 用的是模块级 `MEMORY_DIR`,但「扫一个目录」这件事本身
|
|
243
|
+
* 与目录无关(`scanMemoryDir` 允许显式给一个)。两个目录的失效信号、文件名集合、文件内容
|
|
244
|
+
* 都不该互相污染 —— 与 D18「台账跟着它依据的目录走」同一条道理。串了账的缓存比没有缓存
|
|
245
|
+
* 更危险:它会拿 A 目录的内容去回答关于 B 目录的问题。
|
|
246
|
+
*
|
|
247
|
+
* 每条:`{generation, watcher, entry}`
|
|
248
|
+
* - `generation` —— watcher 送来的失效信号计数,只增不减。
|
|
249
|
+
* - `watcher` —— `createDirWatcher` 的句柄;**`ok === false` 时一律不缓存**。
|
|
250
|
+
* - `entry` —— `{generation, namesKey, files}`,命中要同时满足代次相同与文件名集合相同。
|
|
251
|
+
*/
|
|
252
|
+
const scanStates = new Map()
|
|
253
|
+
|
|
254
|
+
/** 目录名集合的指纹:排序后拼接。新增 / 删除 / 重命名都会换指纹。 */
|
|
255
|
+
function namesKeyOf(names) {
|
|
256
|
+
return [...names].sort().join('\n')
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** 取(必要时建)某个目录的缓存账与它的失效信号源。 */
|
|
260
|
+
function scanStateOf(dir) {
|
|
261
|
+
let st = scanStates.get(dir)
|
|
262
|
+
if (!st) {
|
|
263
|
+
st = { generation: 0, watcher: null, entry: null }
|
|
264
|
+
scanStates.set(dir, st)
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* watcher 起不来时**每轮再试一次**,而不是永久禁用:记忆目录可能在插件加载时还不存在
|
|
268
|
+
* (首扫落空)、之后才被建出来,永久禁用会让这一整个进程都拿不到加速。失败一次的代价
|
|
269
|
+
* 只是一次系统调用级的失败(相对全量扫描可忽略)。
|
|
270
|
+
*/
|
|
271
|
+
if (!st.watcher || !st.watcher.ok) {
|
|
272
|
+
if (st.watcher) st.watcher.dispose()
|
|
273
|
+
/**
|
|
274
|
+
* 换失效信号源时把缓存一起丢掉。旧 watcher 死掉(报错 / 洪流哨兵)这件事本身说明
|
|
275
|
+
* 「它最后一次送信号之后的这段时间里目录可能变过」,留着旧条目就是拿一段无据可依的
|
|
276
|
+
* 历史继续供内容。代价只是多一次全扫,而换 watcher 是罕见事件。
|
|
277
|
+
*/
|
|
278
|
+
st.entry = null
|
|
279
|
+
// 失效信号只做一件事:代次 +1。去抖在 watcher 内部完成(首个事件立刻、窗口末尾再一次)。
|
|
280
|
+
st.watcher = createDirWatcher(dir, () => {
|
|
281
|
+
st.generation++
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
return st
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* 扫 memory 目录,读回全部记忆文件(跳过索引与子目录),并解析 frontmatter 摘要。
|
|
289
|
+
*
|
|
290
|
+
* A2 之后本函数**可能命中进程内缓存**:目录真的没变时,直接把上一次的结果还给你,
|
|
291
|
+
* 一份文件都不打开。失效判据与残余风险见 `scanMemoryDir`。
|
|
292
|
+
*/
|
|
239
293
|
export async function scanMemoryFiles(ctx, sessionId) {
|
|
294
|
+
return scanMemoryDir(ctx, sessionId, MEMORY_DIR)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* 同 `scanMemoryFiles`,但目录可显式给(缓存按目录分账;测试用它证明两个目录不互相污染)。
|
|
299
|
+
*
|
|
300
|
+
* 失效判据**两层**,缺一不可:
|
|
301
|
+
* 1. **主判据 —— watcher 代次**:目录里发生过事件就重扫。它也是「只改内容、不改名」这类
|
|
302
|
+
* 变更**唯一**的失效来源 —— 文件名集合看不出内容变没变。
|
|
303
|
+
* 2. **兜底判据 —— 文件名集合**:每次仍做一次 `readdir`(1000 份实测 0.7 ms)比集合。
|
|
304
|
+
* 它防的是 watcher 最可能漏的那一类:**新增 / 删除 / 重命名**(漏事件时它仍然准确)。
|
|
305
|
+
*
|
|
306
|
+
* ⚠️ **残余风险(有意的取舍,不是疏忽)**:watcher 漏掉「只改内容、不改文件名」的事件时,
|
|
307
|
+
* 本进程会继续供旧内容,直到下一次任何失效。为什么接受:想独立复核内容变没变,只能逐份
|
|
308
|
+
* `lstat` 比 mtime,而那是这次要省掉的主要成本(1000 份实测 83 ms)—— 要复核就等于放弃加速。
|
|
309
|
+
* 后果是**一次注入拿到旧内容**,不是永久错误:文件一旦被增 / 删 / 改名,兜底判据立刻恢复正确,
|
|
310
|
+
* 下一次侥幸收到的事件也会。漏事件概率主要落在「外部程序原地覆写文件」这类姿势上:
|
|
311
|
+
* 插件自己的写入路径(设置页 / 工具)走的是原子写(临时名 + rename),必然触发事件。
|
|
312
|
+
*
|
|
313
|
+
* watcher 不可用(起不来 / 报错)时**一律全扫**,与加缓存之前逐字一致 —— 拿不到失效信号
|
|
314
|
+
* 就不许缓存,这是这条优化的安全底线。
|
|
315
|
+
*/
|
|
316
|
+
export async function scanMemoryDir(ctx, sessionId, dir = MEMORY_DIR) {
|
|
317
|
+
const st = scanStateOf(dir)
|
|
318
|
+
// ── 命中检查:代次 + 文件名集合,两道都对上才算「目录真的没变」──
|
|
319
|
+
if (st.watcher.ok && st.entry) {
|
|
320
|
+
const g0 = st.generation
|
|
321
|
+
if (st.entry.generation === g0) {
|
|
322
|
+
const names = await readdir(dir).catch(() => null)
|
|
323
|
+
/**
|
|
324
|
+
* 校验期间若又来了事件(代次变了)就当没命中。与落缓存时同一条理由:我无法证明
|
|
325
|
+
* 「刚读到的这份指纹」与「刚比过的那份缓存」描述的是同一瞬间的那个目录。
|
|
326
|
+
*/
|
|
327
|
+
if (names && namesKeyOf(names) === st.entry.namesKey && st.generation === g0) {
|
|
328
|
+
// 还一个新数组:缓存是共享的,调用方就地排序 / splice 不该改到别人的那一份。
|
|
329
|
+
return st.entry.files.slice()
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
// ── 未命中:全扫(与加缓存之前逐字一致),只在「扫描期间没有任何变更」时才落缓存 ──
|
|
334
|
+
const gStart = st.generation
|
|
335
|
+
const scanned = await fullScanMemoryDir(ctx, sessionId, dir)
|
|
336
|
+
const gEnd = st.generation
|
|
337
|
+
st.entry = null
|
|
338
|
+
/**
|
|
339
|
+
* 连目录都列不出来(不存在 / 无权限)→ 这个失效信号源已经不可信,就地关掉。
|
|
340
|
+
* 为什么必须这么干:句柄在目录消失后会变成**空转的**(每秒十几万个 'change',
|
|
341
|
+
* 且不发 'error',见 watch.js 的洪流哨兵)—— 主动关掉比等哨兵兜底更早、也更直白。
|
|
342
|
+
* 关掉之后下一轮扫描会重新起表:目录回来了就继续用,回不来就是 fs.watch 抛错 → 全扫。
|
|
343
|
+
*/
|
|
344
|
+
if (scanned.namesKey === null && st.watcher) {
|
|
345
|
+
st.watcher.dispose()
|
|
346
|
+
st.watcher = null
|
|
347
|
+
}
|
|
348
|
+
if (st.watcher && st.watcher.ok && scanned.namesKey !== null && gStart === gEnd) {
|
|
349
|
+
st.entry = { generation: gEnd, namesKey: scanned.namesKey, files: scanned.files }
|
|
350
|
+
}
|
|
351
|
+
// 也还新数组:上面刚把同一份引用存进了缓存,交出去就等于把缓存的所有权给了调用方。
|
|
352
|
+
return scanned.files.slice()
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* 真的走一遍磁盘。返回 `{files, namesKey}`。
|
|
357
|
+
*
|
|
358
|
+
* `namesKey === null` 表示目录根本没列出来(不存在 / 无权限)→ 这一轮**不落缓存**:
|
|
359
|
+
* 把一次失败当成「目录是空的」存下来,会让之后每一次读取都命中这份假结果。
|
|
360
|
+
*/
|
|
361
|
+
async function fullScanMemoryDir(ctx, sessionId, dir) {
|
|
240
362
|
try {
|
|
241
|
-
const entries = await readdir(
|
|
363
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
364
|
+
// 目录名集合的指纹:作为缓存的**兜底判据**(watcher 漏掉增 / 删 / 改名时的第二道闸)。
|
|
365
|
+
const namesKey = namesKeyOf(entries.map(e => e.name))
|
|
242
366
|
/** 保留名的文件身份:8.3 短名与硬链接都得靠它才认得出(名字文本上它们毫无关系)。 */
|
|
243
|
-
const reservedIds = await reservedIdentitySet()
|
|
367
|
+
const reservedIds = await reservedIdentitySet(dir)
|
|
244
368
|
/**
|
|
245
369
|
* 链接条目在 `isFile()` 这一步就被滤掉了(`readdir` 的 Dirent 报的是链接本身),
|
|
246
370
|
* 也就是说**闸门根本没机会看到它们** —— 不在这里出声,用户只会看到「这条记忆不见了」。
|
|
@@ -254,7 +378,7 @@ export async function scanMemoryFiles(ctx, sessionId) {
|
|
|
254
378
|
.filter(e => e.isFile() && isMemoryEntry(e.name))
|
|
255
379
|
.map(e => e.name)
|
|
256
380
|
const loaded = await Promise.all(names.map(async (name) => {
|
|
257
|
-
const full = join(
|
|
381
|
+
const full = join(dir, name)
|
|
258
382
|
// 先过闸门(lstat + 体积),再读:链接与超大文件都在**全量读之前**被挡下,
|
|
259
383
|
// 而不是读进来之后才发现不该读。mtime 由闸门一并给出,比原来少一跳 stat。
|
|
260
384
|
const gate = await inspectMemoryEntry(full)
|
|
@@ -295,10 +419,10 @@ export async function scanMemoryFiles(ctx, sessionId) {
|
|
|
295
419
|
return null
|
|
296
420
|
}
|
|
297
421
|
}))
|
|
298
|
-
return loaded.filter(Boolean)
|
|
422
|
+
return { files: loaded.filter(Boolean), namesKey }
|
|
299
423
|
} catch (err) {
|
|
300
|
-
if (err?.code !== 'ENOENT') warnLog(ctx, `cannot scan ${
|
|
301
|
-
return []
|
|
424
|
+
if (err?.code !== 'ENOENT') warnLog(ctx, `cannot scan ${dir}: ${err?.message ?? err}`, sessionId)
|
|
425
|
+
return { files: [], namesKey: null }
|
|
302
426
|
}
|
|
303
427
|
}
|
|
304
428
|
|
|
@@ -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
3
|
"description": "File-based memory plugin for DeepSeek Harness (DSH) — cross-session memory in plain markdown files, with a memory star-map view, type/tag filtering, retrieval aliases, and an optional autodream pass that consolidates memories on demand or on a schedule — with per-run rollback points, a per-file change declaration (what changed and why), and a separately selectable model for the consolidation pass",
|
|
4
|
-
"version": "0.9.
|
|
4
|
+
"version": "0.9.21",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|