sage-mem 0.9.16 → 0.9.18
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 +44 -1
- package/lib/autodream/engine.js +2 -2
- package/lib/index.js +225 -33
- package/lib/memory/access.js +139 -18
- package/lib/memory/checkpoint.js +546 -0
- package/lib/memory/config.js +22 -0
- package/lib/memory/deliver.js +225 -0
- package/lib/memory/intent.js +175 -0
- package/lib/memory/naming.js +7 -0
- package/lib/memory/observe.js +304 -0
- package/lib/memory/reader.js +289 -0
- package/lib/memory/retrieval.js +79 -11
- package/lib/memory/status.js +214 -0
- package/lib/tools/memory-tools.js +152 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -164,7 +164,50 @@ SAGE_MEM_DIR=/path/to/your/memory
|
|
|
164
164
|
- **换个说法也能命中**:打分是**词面重合**,不是语义的——问「我有什么待办」而文件里写的是「代办清单」,差一个字就完全不命中。给这类记忆加一行 `aliases: [待办, todo]` 就能补上
|
|
165
165
|
- **注入的是命中段落,不是文件开头**。命中段落标成 `### 项目_部署.md:40-52`,agent 据此能直接跳到那几行核对来源;段落前补一句该记忆的 `description`,补回「这段是从哪条记忆切出来的」。`baseline` 与没命中正文的文件仍注入开头。单条正文超 1500 字符截断并标 `…(截断)`
|
|
166
166
|
- **同一个问题不会重复注入**(按会话记签名),问第二遍不会又追加一遍
|
|
167
|
-
-
|
|
167
|
+
- **单场会话预算 60 KB,拆成两本账**(`MAX_SESSION_BYTES`):**正文池** = 总上限 − **状态卡预留**(5%,夹在 1–4 KB)。总上限的数字没变,变的是到顶之后的行为 —— 旧行为是「正文装不下就一个字都不注,连为什么都没有」;现在是「正文照旧不注,但缺口状态卡发得出来,告诉你漏了什么、下一步怎么查」。**压缩或清空之后两本账与卡的去重签名一起归零**,又能注进来
|
|
168
|
+
- **缺口状态卡只在真有缺口时说话**:有候选没读、或有候选被预算省略时才出现,说清范围、条数、最佳线索和**一个可做的动作**;一条都没命中时**只在你问自己的历史时**才说(否则提示会被当噪音忽略)。单张卡上限 512 字节,且按内容签名去重 —— 同一张卡不会每一步都追加一遍
|
|
169
|
+
|
|
170
|
+
### 模型也能自己翻:两个检索工具
|
|
171
|
+
|
|
172
|
+
到 0.9.17 为止,检索的触发权一直全在插件手里(它猜你想看什么)。现在**分一半给模型**:
|
|
173
|
+
|
|
174
|
+
| 工具 | 用途 |
|
|
175
|
+
|---|---|
|
|
176
|
+
| `search_memory(query, scope?, limit?, cursor?)` | 先比摘要、再按段落查正文;返回候选、命中片段、行号,以及**覆盖情况**(扫了几份、几段因相关性不够被滤掉、有没有下一页) |
|
|
177
|
+
| `read_memory(name, startLine?, startByte?, maxBytes?, revision?)` | 按行分页读正文。带上你上次读到的 `revision`,内容被人改过时会回 `revision_changed`,**并且不返回内容** —— 否则你会拿着过期行号读错东西 |
|
|
178
|
+
|
|
179
|
+
两个工具走的是**同一条**安全读路径(`safeName` → 符号链接与保留名身份检查 → 体积闸门),
|
|
180
|
+
没有为工具另开一条绕过闸门的入口。它们用 `ctx.inject(['tools'], …)` 挂子 fiber 注册 ——
|
|
181
|
+
**不是硬依赖**:宿主不提供工具服务时,插件照常工作,只是状态卡换一套「直接读记忆目录」的说法。
|
|
182
|
+
|
|
183
|
+
- **摘要侧也有相关性门槛**:中文里「怎么」「什么」这类词元在大量 `description` 里都有,一句无关的「今天天气怎么样」曾靠一个「怎么」命中十几份记忆。门槛按**语料自身的 IDF 尺度**归一,实测真实目录里无关问题的候选数降到约七分之一;离线评测集上还不只是没退化、而是更好了(Recall@5 84%→87%、误注入 14%→5.6%)—— 被清掉的弱摘要候选原先在挤占名额
|
|
184
|
+
- **意图识别绝不在四类显式请求上抑制重复**:「上次决定用 A 对吧」(核实)、「不用 A 了」(更正)、「之前为什么没选 A」(问依据)必须给原文;判不出来就不判,不猜
|
|
185
|
+
|
|
186
|
+
### 长任务的接力棒:任务检查点
|
|
187
|
+
|
|
188
|
+
宿主什么时候压缩上下文,插件控制不了 —— 所以**不承诺「压缩前保存」**。能做的是:如果
|
|
189
|
+
`<状态根>/tasks/<任务 id>/WORKING.md` 存在,注入末尾会给一条短句柄,说清任务、revision
|
|
190
|
+
与去哪读。**压缩后账本归零会连去重一起清掉**,句柄因此重新出现 —— 这就是恢复路径。
|
|
191
|
+
|
|
192
|
+
任务 id 取 `SAGE_MEM_TASK_ID`(给跨会话的同一条任务用),否则用会话 id。**绝不用你的问题
|
|
193
|
+
原文去决定读哪个目录。** 检查点里「已完成」的条目如果没有任何证据标记(路径、命令、测试结果),
|
|
194
|
+
解析时会如实报出来 —— 格式完美、内容错误的交接材料仍然是错的。
|
|
195
|
+
|
|
196
|
+
### 访问台账:三种「用过」分开记
|
|
197
|
+
|
|
198
|
+
`<状态根>/access.json` 记三件事:
|
|
199
|
+
|
|
200
|
+
| 字段 | 含义 |
|
|
201
|
+
|---|---|
|
|
202
|
+
| `injected` | 插件**被动**注入了这份记忆 |
|
|
203
|
+
| `read` | 模型**主动**读了它(用内置 `read` / `grep` 翻记忆目录时会记上) |
|
|
204
|
+
| `use` | 明确被采用的次数(自报) |
|
|
205
|
+
|
|
206
|
+
分开记是因为这三件事的含义完全不同:**被注入**只说明插件觉得相关,**被主动读**只说明模型
|
|
207
|
+
选了它,都不等于内容有用、更不等于内容正确。混成一个「热度」就再也分不开了。
|
|
208
|
+
|
|
209
|
+
**这张台账目前只用于诊断与自动归档阈值,不参与检索排序。** 观测有已知盲区:`pwsh` 里
|
|
210
|
+
`cat 某记忆` 认不出来,那就不认 —— 我们不会把「看不到」说成「没读过」。
|
|
168
211
|
- **每条记忆的标题带新鲜度**:`(保存于 N 天前)`,**优先读 frontmatter 的 `updated:`,缺失才回落文件 mtime** —— 手写的时间戳是权威,文件系统时间会被复制 / 备份还原 / checkout 整批改掉。理由是陈旧记忆被当事实引用时,「引用」这个动作本身会让它显得更权威而不是更可疑,所以注入文本开头还带一节**引用纪律**(提到路径先确认存在、提到函数先 grep、要动手先核实)。它**独立成节**而不是一条 bullet:Claude Code 实测同一句话埋成 bullet 命中 0/3、独立成节 3/3
|
|
169
212
|
- **`baseline: true` 的记忆每场会话第一步无条件注入**,最多 8 条——给「你是谁」「待办清单」这类每次开场都该在场的记忆用。多条 baseline 之间的先后由 `baselinePriority` 决定(数字越大越前,缺省 0),**不按文件名排**
|
|
170
213
|
- 记忆目录读不到、权限错、frontmatter 格式坏,这些都**降级为不注入并写日志**,不会静默失效
|
package/lib/autodream/engine.js
CHANGED
|
@@ -37,7 +37,7 @@ import { auditMemoryDir } from '../autodream-audit.js'
|
|
|
37
37
|
import { autodreamToolSchemas, createToolRunner } from '../autodream-tools.js'
|
|
38
38
|
import { CONFIG_VERSION, DEFAULT_CONFIG, DEFAULT_TOKEN_BUDGET, FAILURE_BACKOFF_MS, FEATURE_DIR, LEGACY_SNAPSHOT_DIR, LEGACY_STATE_FILE, SCHEDULE_INTERVAL_MS, STARTUP_CHECK_DELAY_MS, STATE_DIR } from './config.js'
|
|
39
39
|
import { countSessionsSince, newRunId, stampHuman } from './util.js'
|
|
40
|
-
import { flushAccess, pickArchiveCandidates, readAccessLedger } from '../memory/access.js'
|
|
40
|
+
import { flushAccess, ledgerFileFor, pickArchiveCandidates, readAccessLedger } from '../memory/access.js'
|
|
41
41
|
import { archiveMemory } from '../memory/archive.js'
|
|
42
42
|
import { scanMemoryFiles } from '../memory/scan.js'
|
|
43
43
|
import { dirname, join } from 'node:path'
|
|
@@ -436,7 +436,7 @@ export class AutodreamEngine {
|
|
|
436
436
|
async archivePolicyPass(cfg) {
|
|
437
437
|
const requested = cfg.autoArchive === 'auto' ? 'auto' : cfg.autoArchive === 'off' ? 'off' : 'report'
|
|
438
438
|
if (requested === 'off') return { mode: 'off', requested, candidates: [], archived: [], degraded: '' }
|
|
439
|
-
const [ledger, memoryFiles] = await Promise.all([readAccessLedger(), scanMemoryFiles(this.ctx, '')])
|
|
439
|
+
const [ledger, memoryFiles] = await Promise.all([readAccessLedger(ledgerFileFor(this.memoryDir)), scanMemoryFiles(this.ctx, '')])
|
|
440
440
|
const candidates = pickArchiveCandidates(memoryFiles, ledger, {
|
|
441
441
|
thresholds: {
|
|
442
442
|
project: cfg.archiveAfterDaysProject,
|
package/lib/index.js
CHANGED
|
@@ -18,14 +18,20 @@ import { AutodreamEngine } from './autodream.js'
|
|
|
18
18
|
import { auditMemoryDir } from './autodream-audit.js'
|
|
19
19
|
import { DEFAULT_CONFIG } from './autodream/config.js'
|
|
20
20
|
import { readJson } from './autodream/util.js'
|
|
21
|
-
import { MAX_BASELINE, MAX_CHARS_PER_FILE, MAX_FILE_BYTES, MAX_RESULTS, MAX_SESSION_BYTES, MEMORY_DIR, RECALL_DISCIPLINE, RESERVED_FILES, SESSIONS_ROOT, STALE_DAYS, STATE_ROOT } from './memory/config.js'
|
|
21
|
+
import { CONTENT_BUDGET, MAX_BASELINE, MAX_CHARS_PER_FILE, MAX_FILE_BYTES, MAX_RESULTS, MAX_SESSION_BYTES, MEMORY_DIR, RECALL_DISCIPLINE, RESERVED_FILES, SESSIONS_ROOT, STALE_DAYS, STATE_ROOT, STATUS_RESERVE } from './memory/config.js'
|
|
22
22
|
import { parseAliases, parseBaseline, parseBaselinePriority, parseFrontmatter, parseTags } from './memory/frontmatter.js'
|
|
23
23
|
import { isReserved, nameKey, safeName } from './memory/naming.js'
|
|
24
24
|
import { ageDays, bigrams, extractText, score, selectBaseline, selectRelevant } from './memory/retrieval.js'
|
|
25
25
|
import { MEMORY_ARCHIVE_DIR, addArchiveMeta, extractTitle, inspectDir, inspectMemoryEntry, readArchivedMeta, scanArchived, scanMemoryFiles, scanStars, shadowsReserved } from './memory/scan.js'
|
|
26
26
|
import { rankChunks } from './memory/search.js'
|
|
27
|
+
import { renderStatusCard } from './memory/status.js'
|
|
28
|
+
import { detectIntent } from './memory/intent.js'
|
|
29
|
+
import { decideDelivery } from './memory/deliver.js'
|
|
30
|
+
import { classifyToolExecution, accessEventKey, createDeduper } from './memory/observe.js'
|
|
31
|
+
import { readCheckpoint } from './memory/checkpoint.js'
|
|
32
|
+
import { createMemoryTools } from './tools/memory-tools.js'
|
|
27
33
|
import { archiveMemory, restoreMemory } from './memory/archive.js'
|
|
28
|
-
import { flushAccess, pickArchiveCandidates, readAccessLedger, recordAccess } from './memory/access.js'
|
|
34
|
+
import { flushAccess, pickArchiveCandidates, readAccessLedger, recordAccess, recordRead } from './memory/access.js'
|
|
29
35
|
import { warnLog, writeFileAtomic } from './memory/util.js'
|
|
30
36
|
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
|
31
37
|
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'
|
|
@@ -198,6 +204,73 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
198
204
|
console.error('[sage-mem] autodream init failed:', err && err.stack ? err.stack : err)
|
|
199
205
|
}
|
|
200
206
|
|
|
207
|
+
// ── 检索工具:`search_memory` / `read_memory` ──
|
|
208
|
+
//
|
|
209
|
+
// 工具是否注册成功。拿不到宿主的 `tools` 服务时为 false —— 状态卡据此换文案:
|
|
210
|
+
// 有工具就教它「用 search_memory 搜」,没有就退回「直接读记忆目录」。
|
|
211
|
+
//
|
|
212
|
+
// 为什么是个可变量:注册是**异步就绪**的(`ctx.inject` 要等依赖服务上线),而注入链
|
|
213
|
+
// 每步都跑 —— 前几步可能还没就绪、后几步才就绪,这是正常的。声明必须在这一块之前。
|
|
214
|
+
let toolsReady = false
|
|
215
|
+
//
|
|
216
|
+
// ⚠️ **用 `ctx.inject` 挂子 fiber,绝不给本类加 `static inject = ['tools']`。**
|
|
217
|
+
// 理由(`docs/decisions.md` D5,有官方依据):`inject` 是**硬依赖** —— 声明了它的插件
|
|
218
|
+
// 要等依赖就绪才会被加载。宿主没有 `tools` 服务时,整个 sage-mem 会一直待机,
|
|
219
|
+
// **记忆注入跟着一起消失**。而工具只是锦上添花,不值得拿核心功能去换。
|
|
220
|
+
// `ctx.inject` 只让这个子回调等依赖就绪,主插件照常工作;依赖永远不来就只有它不生效。
|
|
221
|
+
//
|
|
222
|
+
// 另一条实测事实(`docs/host-facts.md`):不声明 inject 时 `ctx.get('tools')` 只给
|
|
223
|
+
// `undefined`(那一刻服务还没被提供,而插件不会重跑),所以「先探测再注册」在这里
|
|
224
|
+
// 是行不通的 —— 必须走 `ctx.inject`。
|
|
225
|
+
try {
|
|
226
|
+
if (typeof ctx.inject === 'function') {
|
|
227
|
+
ctx.inject(['tools'], (inner) => {
|
|
228
|
+
try {
|
|
229
|
+
const { searchMemory, readMemory } = createMemoryTools(ctx)
|
|
230
|
+
inner.tools.register(searchMemory)
|
|
231
|
+
inner.tools.register(readMemory)
|
|
232
|
+
toolsReady = true
|
|
233
|
+
} catch (err) {
|
|
234
|
+
// 注册失败 ≠ 注入失败。出声一次,然后什么也不做。
|
|
235
|
+
warnLog(ctx, `检索工具注册失败(记忆注入不受影响):${err?.message ?? err}`)
|
|
236
|
+
}
|
|
237
|
+
})
|
|
238
|
+
}
|
|
239
|
+
} catch (err) {
|
|
240
|
+
warnLog(ctx, `检索工具挂载失败(记忆注入不受影响):${err?.message ?? err}`)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ── 观测:模型自己翻了记忆目录 ──
|
|
244
|
+
//
|
|
245
|
+
// 宿主的 `tools/result` 把**每一次**工具调用的 `exec`(含 name / arguments)交给我们,
|
|
246
|
+
// 于是「模型用内置 read/grep 读了哪份记忆」第一次变得可观测 —— 此前台账只记得下
|
|
247
|
+
// 「插件注入了什么」,记不下「模型主动翻了什么」。
|
|
248
|
+
//
|
|
249
|
+
// 三条纪律:
|
|
250
|
+
// 1. **只记能可靠判定的**(`observe.js` 认 read/grep/glob 的路径参数;`pwsh` 一律不认,
|
|
251
|
+
// 那是**已知观测盲区** —— 不许把「看不到」说成「没读」)。
|
|
252
|
+
// 2. **按 session × 文件去重**:翻页(read 的 offset 不同)不刷计数,那是同一个动作。
|
|
253
|
+
// 3. **失败绝不影响任何东西**:观测是副产品,它的问题不许冒泡到工具调用链上。
|
|
254
|
+
const observedReads = createDeduper()
|
|
255
|
+
try {
|
|
256
|
+
ctx.on('tools/result', (exec) => {
|
|
257
|
+
try {
|
|
258
|
+
const cls = classifyToolExecution(exec?.name, exec?.arguments, MEMORY_DIR)
|
|
259
|
+
if (cls.kind !== 'active_read' || !cls.file) return
|
|
260
|
+
const key = accessEventKey({
|
|
261
|
+
sessionId: exec?.agent?.id ?? '',
|
|
262
|
+
file: cls.file,
|
|
263
|
+
// revision 要读盘才算得出,这里拿不到 —— 所以去重粒度是「session × 文件」。
|
|
264
|
+
// 写明这条口径,免得以后有人以为它按内容去重。
|
|
265
|
+
revision: '',
|
|
266
|
+
kind: cls.kind,
|
|
267
|
+
})
|
|
268
|
+
if (!key || !observedReads.shouldCount(key)) return
|
|
269
|
+
recordRead(cls.file, '')
|
|
270
|
+
} catch { /* 观测失败静默:它不该有任何存在感 */ }
|
|
271
|
+
})
|
|
272
|
+
} catch { /* 老宿主没有这个事件:不影响注入 */ }
|
|
273
|
+
|
|
201
274
|
// 同一轮对话里问题不变、注入内容也不变,但 DSH 每一步都会往 session 追加
|
|
202
275
|
// 一条 context 消息——不去重的话同一份记忆会被反复追加,撑爆上下文。
|
|
203
276
|
const recallCache = new Map()
|
|
@@ -300,6 +373,11 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
300
373
|
// 检索由这份签名保证同一问题不重复追加,两条线互不干扰。
|
|
301
374
|
const relevant = text && text.length >= 2 ? selectRelevant(text, files) : []
|
|
302
375
|
|
|
376
|
+
// ── 意图:先判,因为它要影响下面正文检索的**门槛松紧** ──
|
|
377
|
+
// 用户是不是在问自己的历史(上次 / 之前 / 更正 / 问依据 / 继续)。
|
|
378
|
+
// 判不出来就是 `none`,不猜 —— 误判的代价是把不该抑制的正文抑制掉。
|
|
379
|
+
const intentInfo = text ? detectIntent(text) : { intent: 'none', historyDependent: false, suppressible: true }
|
|
380
|
+
|
|
303
381
|
// ── 正文检索 ──
|
|
304
382
|
// 摘要(description + aliases + 文件名)是最省的检索面,代价是**正文里的东西天生检索不到**。
|
|
305
383
|
// 这里用它做两件事:
|
|
@@ -309,8 +387,17 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
309
387
|
//
|
|
310
388
|
// query 短于 4 个字符不做正文匹配:`继续` 这种两个字的问题拿去匹配正文,
|
|
311
389
|
// 命中的全是「继续」这个词本身,与用户问什么毫无关系。
|
|
390
|
+
//
|
|
391
|
+
// **门槛按意图放宽**:默认要求「连续命中 ≥ 2 个词元」(那条防的是跨词边界的假词元,
|
|
392
|
+
// 见 search.js)。代价是整句只对上一个两字专名时会被拦下 —— 那是 0.9.16 有意付的。
|
|
393
|
+
// 但用户**明确在问自己的历史**时,宁可多给一条也不该让他拿不到依据:这类问题的
|
|
394
|
+
// 代价不对称(少给 = 答错,多给 = 多花一点上下文)。所以这时把 `minRun` 放到 1。
|
|
312
395
|
const bodyRanked = typeof text === 'string' && text.length >= 4
|
|
313
|
-
? rankChunks(text, files, {
|
|
396
|
+
? rankChunks(text, files, {
|
|
397
|
+
limit: MAX_RESULTS,
|
|
398
|
+
perFile: 1,
|
|
399
|
+
...(intentInfo.historyDependent ? { minEvidence: { minRun: 1 } } : {}),
|
|
400
|
+
})
|
|
314
401
|
: []
|
|
315
402
|
const bestChunk = new Map()
|
|
316
403
|
for (const h of bodyRanked) if (!bestChunk.has(h.file)) bestChunk.set(h.file, h.chunk)
|
|
@@ -346,18 +433,28 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
346
433
|
return assembled
|
|
347
434
|
}
|
|
348
435
|
|
|
349
|
-
// ──
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
//
|
|
436
|
+
// ── 单会话累计预算:**拆成两本账** ──
|
|
437
|
+
// 正文池 = CONTENT_BUDGET(总上限 − 状态卡预留)/卡池 = STATUS_RESERVE
|
|
438
|
+
// 拆的理由见 config.js:到顶之后旧行为是一个字都不注、连「为什么没注、还能怎么查」
|
|
439
|
+
// 都没有;而那个上限是**本插件自己定的**,完全可以给状态卡留一份固定额度。
|
|
440
|
+
// 账记在 session id 上;picked 里 baseline 永远排在检索结果之前,所以预算不够时
|
|
441
|
+
// 先被挤掉的是「更晚才想起来的」那些 —— baseline 有优先权。
|
|
353
442
|
const ledgerKey = session.id || 'no-session-id'
|
|
354
443
|
const prevLedger = injectLedger.get(ledgerKey)
|
|
355
|
-
|
|
444
|
+
// 压缩 / 清空 → **两本账与卡签名一起归零**。卡签名必须一起清:不然「本会话已经发过
|
|
445
|
+
// 这张卡」仍然成立,而那张卡早已随旧上下文消失 —— 恢复入口就此永久失效。
|
|
446
|
+
// (「本会话注入过」不等于「压缩后还看得见」,这条是计划里点名要处理的。)
|
|
447
|
+
const carried = prevLedger && messages.length >= prevLedger.messageCount ? prevLedger : null
|
|
448
|
+
let contentBytes = carried?.contentBytes ?? 0
|
|
449
|
+
let cardBytes = carried?.cardBytes ?? 0
|
|
450
|
+
let cardSig = carried?.cardSig ?? ''
|
|
356
451
|
const nowMs = Date.now()
|
|
357
452
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
453
|
+
// ── 渲染候选,然后交给展示层决定「给正文 / 给指针 / 不给」──
|
|
454
|
+
// 两件事分开做:这一段只负责「每条候选长什么样」,`decideDelivery` 负责
|
|
455
|
+
// 「在预算内该给哪几条、为什么」。混在一起写就是上一版的样子 —— 预算、渲染、
|
|
456
|
+
// 去重三件事挤在一个循环里,谁也说不清某条为什么没进去。
|
|
457
|
+
const candidates = []
|
|
361
458
|
for (const f of picked) {
|
|
362
459
|
// **baseline 不吃段落替换**:它的语义是「这条记忆每场会话都该完整在场」
|
|
363
460
|
// (你是谁、待办清单)。一旦用户的问句碰巧命中它,替换成单个段落就等于
|
|
@@ -376,44 +473,129 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
376
473
|
// - 摘要:段落本身不含 frontmatter,补回「这一段是从哪条记忆里切出来的」。
|
|
377
474
|
const loc = chunk ? `:${chunk.startLine}-${chunk.endLine}` : ''
|
|
378
475
|
const brief = chunk && f.description ? ` · ${f.description.slice(0, 80)}` : ''
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
476
|
+
candidates.push({
|
|
477
|
+
file: f.file,
|
|
478
|
+
id: chunk?.id ?? `${f.file}#head`,
|
|
479
|
+
revision: chunk?.revision ?? '',
|
|
480
|
+
score: f.score ?? 0,
|
|
481
|
+
baseline: f.baseline === true,
|
|
482
|
+
lines: chunk ? [chunk.startLine, chunk.endLine] : null,
|
|
483
|
+
title: f.description || '',
|
|
484
|
+
text: `### ${f.file}${loc}${stamp}${brief}\n${body}`,
|
|
485
|
+
})
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const delivery = decideDelivery(candidates, {
|
|
489
|
+
intent: intentInfo.intent,
|
|
490
|
+
// ⚠️ 这里**故意传空集**:按 revision 抑制重复对「同一份文件的不同段落」太粗 ——
|
|
491
|
+
// 换一个问题往往会命中同一份文件的**另一段**,按文件级 revision 抑制会把新相关
|
|
492
|
+
// 的那段一起挡掉。要启用它得先用「段落身份」而不是「文件 revision」做键,
|
|
493
|
+
// 并且单独评测一次。这条记在 docs/decisions.md 的 D15,不是遗漏。
|
|
494
|
+
visibleRevisions: new Set(),
|
|
495
|
+
scopeDenied: new Set(),
|
|
496
|
+
conflicts: new Set(),
|
|
497
|
+
expired: new Set(),
|
|
498
|
+
needle: text ? text.slice(0, 40) : '',
|
|
499
|
+
}, { bytes: Math.max(0, CONTENT_BUDGET - contentBytes) })
|
|
500
|
+
|
|
501
|
+
const parts = []
|
|
502
|
+
const injectedFiles = []
|
|
503
|
+
let skippedForBudget = 0
|
|
504
|
+
for (const item of delivery.items) {
|
|
505
|
+
if (item.kind === 'snippet') {
|
|
506
|
+
parts.push(item.text)
|
|
507
|
+
injectedFiles.push(item.file)
|
|
508
|
+
} else {
|
|
509
|
+
// pointer / suppress / unresolved 都**不进正文**。
|
|
510
|
+
// 「被省略几条」这件事由下面的状态卡统一说,正文里再逐条列一行
|
|
511
|
+
// `### 某某.md(未展开)` 是重复的信息,还白占上下文 —— 卡片本来就带着
|
|
512
|
+
// 「最佳线索 + 一个可做的动作」。
|
|
513
|
+
skippedForBudget++
|
|
514
|
+
}
|
|
385
515
|
}
|
|
516
|
+
// 按**实际进正文的字节**记账,而不是 delivery.totals —— 后者含指针的额度,
|
|
517
|
+
// 而指针没有真的进上下文。两本账要对得上,就不能把没花出去的钱记进去。
|
|
518
|
+
contentBytes += parts.reduce((n, p) => n + Buffer.byteLength(p, 'utf8'), 0)
|
|
386
519
|
|
|
387
520
|
// 进上下文的这些才算「被用过」—— 台账是自动归档阈值的唯一数据源,记错一步阈值就偏。
|
|
388
521
|
// recordAccess 同步、绝不抛:台账的问题不许升级成注入失败。
|
|
389
522
|
recordAccess(injectedFiles, nowMs)
|
|
390
523
|
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
|
|
524
|
+
// ── 缺口状态卡 ──
|
|
525
|
+
// 它**替换**原来那行状态文字,不是额外追加:同一件事不说两遍。
|
|
526
|
+
// 卡池与正文池互不借用 —— 正文满了照样发得出卡,这正是预留的意义(见 config.js)。
|
|
527
|
+
const bodySearched = typeof text === 'string' && text.length >= 4
|
|
528
|
+
const bodyOnly = bodySearched ? dedupeByFile(bodyRanked) : []
|
|
529
|
+
const unread = Math.max(0, selected.length - injectedFiles.length)
|
|
530
|
+
// 最佳线索取「正文命中里第一条没被注进去的」——那正是模型下一步最该去读的东西。
|
|
531
|
+
const beacon = bodyOnly.find(h => !injectedFiles.includes(h.file)) ?? bodyOnly[0] ?? null
|
|
532
|
+
// 什么时候该说话:
|
|
533
|
+
// - **有真缺口**(有候选没读、或有候选被预算省略)→ 说;
|
|
534
|
+
// - **没有缺口也没有命中** → 只在「用户这句话在问自己的历史」时说。
|
|
535
|
+
// 后者是计划里点名的一条:没有历史依赖时不要插「你可能遗漏记忆」,否则提示
|
|
536
|
+
// 每轮都出现,等真有缺口时它已经被当噪音忽略了。
|
|
537
|
+
const gapExists = unread > 0 || skippedForBudget > 0
|
|
538
|
+
|
|
539
|
+
// ── 任务检查点:压缩后的恢复入口 ──
|
|
540
|
+
// 宿主**什么时候**压缩上下文,插件控制不了 —— 所以这里**不承诺「压缩前保存」**,
|
|
541
|
+
// 只保证「存在检查点就给一条短句柄」。压缩后两本账与卡的签名会一起归零,
|
|
542
|
+
// 句柄因此会重新出现,这就是恢复路径(而不是靠某个压缩前钩子)。
|
|
543
|
+
//
|
|
544
|
+
// 任务 id:`SAGE_MEM_TASK_ID` 优先(给「跨会话的同一条任务」用),否则用 session id。
|
|
545
|
+
// **绝不用查询文本自定作用域** —— 那句话是模型或用户写的,不该决定去读哪个目录。
|
|
546
|
+
const taskId = process.env.SAGE_MEM_TASK_ID || session.id || ''
|
|
547
|
+
let checkpointLine = ''
|
|
548
|
+
try {
|
|
549
|
+
const cp = taskId ? await readCheckpoint(taskId, { stateRoot: STATE_ROOT }) : null
|
|
550
|
+
if (cp?.ok && cp.handle) checkpointLine = cp.handle
|
|
551
|
+
} catch { /* 读不到检查点就当没有:它是可选的接力棒,不是必需件 */ }
|
|
552
|
+
|
|
553
|
+
const wantCard = gapExists || intentInfo.historyDependent || !!checkpointLine
|
|
554
|
+
const cardBody = wantCard
|
|
555
|
+
? renderStatusCard({
|
|
556
|
+
scope: bodySearched ? 'all' : 'summary',
|
|
557
|
+
scanned: files.length,
|
|
558
|
+
injected: parts.length,
|
|
559
|
+
omitted: skippedForBudget,
|
|
560
|
+
unread,
|
|
561
|
+
best: beacon ? { file: beacon.file, title: beacon.chunk?.title || beacon.chunk?.headingPath || '' } : null,
|
|
562
|
+
needle: text ? text.slice(0, 40) : null,
|
|
563
|
+
toolsAvailable: toolsReady,
|
|
564
|
+
memoryDir: MEMORY_DIR,
|
|
565
|
+
})
|
|
566
|
+
: ''
|
|
567
|
+
// 句柄与卡**共用卡池**:它是同一个通道里的另一句提示,不该另开一份额度。
|
|
568
|
+
const cardText = [cardBody, checkpointLine].filter(Boolean).join('\n')
|
|
569
|
+
let cardPart = ''
|
|
570
|
+
// 卡按**内容签名**去重:签名没变就不重复发。否则每一步都追加一张一模一样的卡,
|
|
571
|
+
// 而每张卡都是落到会话里的持久消息 —— 那不叫提示,那叫刷屏。
|
|
572
|
+
if (cardText && cardText !== cardSig) {
|
|
573
|
+
const cardSize = Buffer.byteLength(cardText, 'utf8')
|
|
574
|
+
if (cardBytes + cardSize <= STATUS_RESERVE) {
|
|
575
|
+
cardBytes += cardSize
|
|
576
|
+
cardPart = cardText
|
|
577
|
+
cardSig = cardText
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// 正文与卡都没有 → 一个字都不注。注意这与旧行为不同:旧行为「正文池满」就到此为止,
|
|
582
|
+
// 现在只要卡池还有额度就会发出卡(说清范围、缺口与下一步动作)。
|
|
583
|
+
if (parts.length === 0 && !cardPart) {
|
|
395
584
|
if (DEBUG_RECALL) {
|
|
396
585
|
debugRecall({
|
|
397
586
|
scanned: files.length, hits: relevant.length,
|
|
398
587
|
scores: relevant.map(f => [f.file, Number(f.score.toFixed(4))]),
|
|
399
|
-
picked: picked.map(f => f.file), injected: [], chars:
|
|
588
|
+
picked: picked.map(f => f.file), injected: [], chars: contentBytes, skippedByBudget: skippedForBudget,
|
|
400
589
|
})
|
|
401
590
|
}
|
|
402
591
|
return assembled
|
|
403
592
|
}
|
|
404
593
|
|
|
405
|
-
injectLedger.set(ledgerKey, {
|
|
594
|
+
injectLedger.set(ledgerKey, { contentBytes, cardBytes, cardSig, messageCount: messages.length })
|
|
406
595
|
if (injectLedger.size > INJECT_LEDGER_MAX) {
|
|
407
596
|
injectLedger.delete(injectLedger.keys().next().value)
|
|
408
597
|
}
|
|
409
598
|
|
|
410
|
-
// 状态行:让「注了几条、花了多少、被挡掉几条」在上下文里可见 ——
|
|
411
|
-
// 「某条记忆没被注入」是最难查的一类故障,这里留一个可读的出口。
|
|
412
|
-
const status = `(本次注入 ${parts.length} 条;本会话累计 ${Math.round(usedBytes / 1024)} KB` +
|
|
413
|
-
` / 上限 ${Math.round(MAX_SESSION_BYTES / 1024)} KB` +
|
|
414
|
-
(skippedForBudget > 0 ? `;因预算省略 ${skippedForBudget} 条,需要时直接用文件工具读 ${MEMORY_DIR}` : '') +
|
|
415
|
-
')'
|
|
416
|
-
|
|
417
599
|
if (DEBUG_RECALL) {
|
|
418
600
|
debugRecall({
|
|
419
601
|
scanned: files.length,
|
|
@@ -421,18 +603,28 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
421
603
|
scores: relevant.map(f => [f.file, Number(f.score.toFixed(4))]),
|
|
422
604
|
picked: picked.map(f => f.file),
|
|
423
605
|
injected: injectedFiles,
|
|
424
|
-
chars:
|
|
606
|
+
chars: contentBytes,
|
|
425
607
|
skippedByBudget: skippedForBudget,
|
|
608
|
+
card: cardPart ? 'sent' : (cardText ? 'deduped-or-over-budget' : 'none'),
|
|
426
609
|
})
|
|
427
610
|
}
|
|
428
611
|
|
|
612
|
+
// 只有正文时:头部 + 引用纪律 + 正文 [+ 卡]
|
|
613
|
+
// 只有卡时:**就只发卡** —— 头部那句「以下是与你当前问题相关的历史记忆」在没有正文
|
|
614
|
+
// 的情况下是假陈述,而引用纪律管的是「别把记过的东西当当前事实」,没有正文就没有
|
|
615
|
+
// 需要约束的对象。
|
|
616
|
+
const recallText = parts.length
|
|
617
|
+
? `以下是与你当前问题相关的历史记忆(文件式,来自 ${MEMORY_DIR}):\n\n` +
|
|
618
|
+
`${RECALL_DISCIPLINE}\n\n${parts.join('\n\n')}` +
|
|
619
|
+
(cardPart ? `\n\n${cardPart}` : '')
|
|
620
|
+
: cardPart
|
|
621
|
+
|
|
429
622
|
return {
|
|
430
623
|
...assembled,
|
|
431
624
|
contexts: [...(assembled.contexts ?? []), {
|
|
432
625
|
name: 'sage-mem:recall',
|
|
433
626
|
order: 1000,
|
|
434
|
-
text:
|
|
435
|
-
`${RECALL_DISCIPLINE}\n\n${parts.join('\n\n')}\n\n${status}`,
|
|
627
|
+
text: recallText,
|
|
436
628
|
}],
|
|
437
629
|
}
|
|
438
630
|
} catch (err) {
|
package/lib/memory/access.js
CHANGED
|
@@ -21,9 +21,58 @@ import { mkdir, readFile, readdir } from 'node:fs/promises'
|
|
|
21
21
|
import { dirname, join } from 'node:path'
|
|
22
22
|
import { nameKey } from './naming.js'
|
|
23
23
|
|
|
24
|
-
/**
|
|
24
|
+
/** 台账文件:状态根下,与记忆文件分开。**用默认记忆目录时**就是它。 */
|
|
25
25
|
export const ACCESS_LEDGER_FILE = join(STATE_ROOT, 'access.json')
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* 某个记忆目录**对应**的台账文件路径。
|
|
29
|
+
*
|
|
30
|
+
* ⚠️ 为什么不能写死成模块常量:`flushAccess(memoryDir)` 是「一边按那个目录回收死键、
|
|
31
|
+
* 一边把结果写回台账」。若台账路径固定,就会出现**扫描 A 目录、把结果写进 B 台账** ——
|
|
32
|
+
* A 里不存在的名字在 B 台账里全被判成死键删掉。
|
|
33
|
+
*
|
|
34
|
+
* 这不是假想:2026-10-15 实测到一个用**临时**记忆目录跑的测试,因此把用户的**真实**台账
|
|
35
|
+
* 冲成了它的夹具文件名(`access.json` 从百来条真实记忆变成两条 `a.md` / `b.md`),
|
|
36
|
+
* 而自动归档阈值唯一的数据来源就是这份台账。
|
|
37
|
+
*
|
|
38
|
+
* 用默认目录时仍走 `STATE_ROOT`(尊重 `SAGE_MEM_STATE_DIR` 覆写);传了别的目录,
|
|
39
|
+
* 台账就跟着那个目录走(`<它的上一层>/.sage-mem/access.json`)。
|
|
40
|
+
*/
|
|
41
|
+
export function ledgerFileFor(memoryDir = MEMORY_DIR) {
|
|
42
|
+
if (!memoryDir || memoryDir === MEMORY_DIR) return ACCESS_LEDGER_FILE
|
|
43
|
+
return join(dirname(memoryDir), '.sage-mem', 'access.json')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 落盘格式版本。v1 是 `{ "文件.md": 时间戳 }`;v2 是 `{ v:2, files: { "文件.md": {...} } }`。 */
|
|
47
|
+
export const ACCESS_SCHEMA_VERSION = 2
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 台账条目归一:**v1 的裸数字与 v2 的对象都要吃**。
|
|
51
|
+
*
|
|
52
|
+
* 为什么读端必须兼容两代:台账是磁盘上的既有数据,用户升级插件时它就在那儿。
|
|
53
|
+
* 「只认新格式」等于把老用户的台账当成损坏数据丢掉 —— 而它是自动归档阈值唯一的数据源。
|
|
54
|
+
* 旧格式的裸数字语义是「最近一次被注入」,正好对应 v2 的 `injected`。
|
|
55
|
+
*/
|
|
56
|
+
export function ledgerEntry(value) {
|
|
57
|
+
if (Number.isFinite(value)) return { injected: value, read: 0, use: 0, readRev: '' }
|
|
58
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
59
|
+
return {
|
|
60
|
+
injected: Number.isFinite(value.injected) ? value.injected : 0,
|
|
61
|
+
// `read` / `use` / `readRev` 是 0.9.18 才有的字段:**只记不参与排序**(决定 D3)。
|
|
62
|
+
read: Number.isFinite(value.read) ? value.read : 0,
|
|
63
|
+
use: Number.isFinite(value.use) ? value.use : 0,
|
|
64
|
+
readRev: typeof value.readRev === 'string' ? value.readRev : '',
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return { injected: 0, read: 0, use: 0, readRev: '' }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 条目里「最近一次被注入」的时间戳(0 = 没有)。`idleDays` 与归档候选都走它。 */
|
|
71
|
+
export function injectedAt(value) {
|
|
72
|
+
const e = ledgerEntry(value)
|
|
73
|
+
return e.injected > 0 ? e.injected : null
|
|
74
|
+
}
|
|
75
|
+
|
|
27
76
|
/** 落盘节流:内存先累计,最多每 30 秒写一次盘。 */
|
|
28
77
|
export const ACCESS_FLUSH_INTERVAL_MS = 30 * 1000
|
|
29
78
|
|
|
@@ -46,11 +95,35 @@ export const NEVER_ARCHIVE_TYPES = new Set(['feedback'])
|
|
|
46
95
|
*/
|
|
47
96
|
const enqueueFlush = createWriteQueue()
|
|
48
97
|
|
|
49
|
-
/** 内存累计(尚未落盘):文件名 →
|
|
98
|
+
/** 内存累计(尚未落盘):文件名 → 各字段的增量(见 `record` 的合并语义)。 */
|
|
50
99
|
let pending = new Map()
|
|
51
100
|
let flushTimer = null
|
|
52
101
|
let lastFlushAt = 0
|
|
53
102
|
|
|
103
|
+
/**
|
|
104
|
+
* 把一次观测并进内存增量表。
|
|
105
|
+
*
|
|
106
|
+
* 为什么是「增量」而不是「整条时间戳」:v2 有四个字段,同一批里可能先记注入、再记读取。
|
|
107
|
+
* 直接 `set(name, at)` 会让后写的把先写的**冲掉** —— 那正是旧版台账丢计时的那类问题。
|
|
108
|
+
*
|
|
109
|
+
* 各字段的合并语义**不一样**,写错就会丢数据:
|
|
110
|
+
* - `injected` / `read`:取**较晚**的(它们是「最近一次」);
|
|
111
|
+
* - `use`:**累加**(它是「采用了几次」,不是时间戳);
|
|
112
|
+
* - `readRev`:跟着 `read` 走(谁把 `read` 推新,就用谁那份 revision)。
|
|
113
|
+
*/
|
|
114
|
+
function record(name, patch) {
|
|
115
|
+
if (typeof name !== 'string' || !name) return
|
|
116
|
+
const cur = pending.get(name) ?? {}
|
|
117
|
+
const read = Math.max(cur.read ?? 0, patch.read ?? 0)
|
|
118
|
+
pending.set(name, {
|
|
119
|
+
injected: Math.max(cur.injected ?? 0, patch.injected ?? 0),
|
|
120
|
+
read,
|
|
121
|
+
readRev: read > 0 && read === (patch.read ?? 0) && patch.readRev ? patch.readRev : (cur.readRev ?? ''),
|
|
122
|
+
use: (cur.use ?? 0) + (patch.use ?? 0),
|
|
123
|
+
})
|
|
124
|
+
scheduleFlush()
|
|
125
|
+
}
|
|
126
|
+
|
|
54
127
|
/**
|
|
55
128
|
* 记一次「这些文件真的被注入了」。
|
|
56
129
|
*
|
|
@@ -58,10 +131,22 @@ let lastFlushAt = 0
|
|
|
58
131
|
* **同步、绝不抛**:台账是观测数据,它的问题不许升级成「记忆不再注入」。
|
|
59
132
|
*/
|
|
60
133
|
export function recordAccess(names, at = Date.now()) {
|
|
61
|
-
for (const n of names ?? []) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
134
|
+
for (const n of names ?? []) record(n, { injected: at })
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 记一次「模型**主动**读了这份记忆」。
|
|
139
|
+
*
|
|
140
|
+
* 与 `recordAccess`(被动注入)**分开记**,是这个版本的重点:主动读取只说明模型**选了**它,
|
|
141
|
+
* 不等于内容有用、更不等于内容正确。两者混成一个「热度」就再也分不开了(决定 D3 的另一半)。
|
|
142
|
+
*/
|
|
143
|
+
export function recordRead(name, revision, at = Date.now()) {
|
|
144
|
+
record(name, { read: at, readRev: typeof revision === 'string' ? revision : '' })
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** 记一次「明确采用」的增量。自报数据,**只记不参与排序**。 */
|
|
148
|
+
export function recordUse(name) {
|
|
149
|
+
record(name, { use: 1 })
|
|
65
150
|
}
|
|
66
151
|
|
|
67
152
|
function scheduleFlush() {
|
|
@@ -75,14 +160,26 @@ function scheduleFlush() {
|
|
|
75
160
|
if (typeof flushTimer.unref === 'function') flushTimer.unref()
|
|
76
161
|
}
|
|
77
162
|
|
|
78
|
-
/**
|
|
79
|
-
|
|
163
|
+
/**
|
|
164
|
+
* 读盘上的台账,**归一成一种形状**:`{ 文件名 → {injected, read, use, readRev} }`。
|
|
165
|
+
*
|
|
166
|
+
* 读不到 / JSON 坏 / 不是对象 / 两代格式都不是 → 一律当空台账(降级成「不知道」,
|
|
167
|
+
* 而不是把已有数据判成损坏丢掉 —— 它是自动归档阈值唯一的数据源)。
|
|
168
|
+
* v1 的裸数字(= 被注入时间)由 `ledgerEntry` 升成 v2 条目,调用方不必分代。
|
|
169
|
+
*/
|
|
170
|
+
export async function readAccessLedger(file = ACCESS_LEDGER_FILE) {
|
|
80
171
|
try {
|
|
81
|
-
const parsed = JSON.parse(await readFile(
|
|
172
|
+
const parsed = JSON.parse(await readFile(file, 'utf8'))
|
|
82
173
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
|
|
174
|
+
// v2 = `{ v:2, files:{…} }`;v1 = 裸映射 `{ "文件.md": 时间戳 }`。
|
|
175
|
+
const src = parsed.v === ACCESS_SCHEMA_VERSION && parsed.files && typeof parsed.files === 'object' && !Array.isArray(parsed.files)
|
|
176
|
+
? parsed.files
|
|
177
|
+
: parsed
|
|
83
178
|
const out = {}
|
|
84
|
-
for (const [k, v] of Object.entries(
|
|
85
|
-
if (typeof k
|
|
179
|
+
for (const [k, v] of Object.entries(src)) {
|
|
180
|
+
if (typeof k !== 'string' || !k) continue
|
|
181
|
+
if (!Number.isFinite(v) && !(v && typeof v === 'object' && !Array.isArray(v))) continue
|
|
182
|
+
out[k] = ledgerEntry(v)
|
|
86
183
|
}
|
|
87
184
|
return out
|
|
88
185
|
} catch {
|
|
@@ -154,9 +251,20 @@ async function flushAccessLocked(memoryDir) {
|
|
|
154
251
|
const batch = pending
|
|
155
252
|
pending = new Map()
|
|
156
253
|
try {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
254
|
+
// 台账必须与**这次回收所依据的那个目录**配套(见 ledgerFileFor 的注释)。
|
|
255
|
+
const file = ledgerFileFor(memoryDir)
|
|
256
|
+
const onDisk = await readAccessLedger(file)
|
|
257
|
+
// 逐字段合并(`readAccessLedger` 已经归一成 v2 条目,不用再分代)。
|
|
258
|
+
// 语义与内存里的 `record` 一致:时间戳取较晚、`use` 累加、`readRev` 跟 `read` 走。
|
|
259
|
+
for (const [name, patch] of batch) {
|
|
260
|
+
const cur = onDisk[name] ?? { injected: 0, read: 0, use: 0, readRev: '' }
|
|
261
|
+
const read = Math.max(cur.read ?? 0, patch.read ?? 0)
|
|
262
|
+
onDisk[name] = {
|
|
263
|
+
injected: Math.max(cur.injected ?? 0, patch.injected ?? 0),
|
|
264
|
+
read,
|
|
265
|
+
readRev: read > 0 && read === (patch.read ?? 0) && patch.readRev ? patch.readRev : (cur.readRev ?? ''),
|
|
266
|
+
use: (cur.use ?? 0) + (patch.use ?? 0),
|
|
267
|
+
}
|
|
160
268
|
}
|
|
161
269
|
const alive = await currentMemoryNames(memoryDir)
|
|
162
270
|
if (alive) {
|
|
@@ -166,12 +274,23 @@ async function flushAccessLocked(memoryDir) {
|
|
|
166
274
|
}
|
|
167
275
|
// 状态目录是插件自己的地盘,缺了可以建(autodream 的配置也存在这儿)。
|
|
168
276
|
// 建不出来(只读、父路径不是目录)就走 catch 静默降级。
|
|
169
|
-
await mkdir(dirname(
|
|
170
|
-
|
|
277
|
+
await mkdir(dirname(file), { recursive: true })
|
|
278
|
+
// 落盘一律写 v2(哪怕读进来的是 v1):老格式只在读端兼容,写端不扩散。
|
|
279
|
+
await writeFileAtomic(file, JSON.stringify({ v: ACCESS_SCHEMA_VERSION, files: onDisk }, null, 2))
|
|
171
280
|
lastFlushAt = Date.now()
|
|
172
281
|
return { ok: true, written: batch.size, reclaimed: alive ? true : false }
|
|
173
282
|
} catch (err) {
|
|
174
|
-
|
|
283
|
+
// 放回内存时**逐字段并回去**,不能整条覆盖 —— 否则失败重试会把期间新攒的字段冲掉。
|
|
284
|
+
for (const [name, patch] of batch) {
|
|
285
|
+
const cur = pending.get(name) ?? {}
|
|
286
|
+
const read = Math.max(cur.read ?? 0, patch.read ?? 0)
|
|
287
|
+
pending.set(name, {
|
|
288
|
+
injected: Math.max(cur.injected ?? 0, patch.injected ?? 0),
|
|
289
|
+
read,
|
|
290
|
+
readRev: read > 0 && read === (patch.read ?? 0) && patch.readRev ? patch.readRev : (cur.readRev ?? ''),
|
|
291
|
+
use: (cur.use ?? 0) + (patch.use ?? 0),
|
|
292
|
+
})
|
|
293
|
+
}
|
|
175
294
|
// 静默降级:注入是插件最核心的事,台账写不进去不许把它拖下水。开了 DEBUG 才出声。
|
|
176
295
|
if (DEBUG) console.warn(`[sage-mem][debug] access ledger flush failed: ${err?.message ?? err}`)
|
|
177
296
|
return { ok: false, error: err?.message ?? String(err) }
|
|
@@ -199,7 +318,9 @@ export function fileType(f) {
|
|
|
199
318
|
* 都会把它整批改掉。
|
|
200
319
|
*/
|
|
201
320
|
export function idleDays(f, ledger = {}, now = Date.now()) {
|
|
202
|
-
|
|
321
|
+
// 走 `injectedAt` 而不是直接读值:手写的 v1 台账(裸数字)与 v2 条目都能收,
|
|
322
|
+
// 调用方不必分代 —— 归档候选的计算不该因为台账升了一代就整体失效。
|
|
323
|
+
const seen = injectedAt(ledger?.[f?.file])
|
|
203
324
|
const fromMeta = Date.parse(f?.updated || '')
|
|
204
325
|
const base = Number.isFinite(seen)
|
|
205
326
|
? seen
|