sage-mem 0.9.17 → 0.9.19
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 +26 -0
- package/lib/autodream/engine.js +2 -2
- package/lib/autodream-tools.js +100 -7
- package/lib/index.js +151 -7
- package/lib/memory/access.js +139 -18
- package/lib/memory/checkpoint.js +546 -0
- package/lib/memory/conflicts.js +520 -0
- package/lib/memory/observe.js +235 -0
- package/lib/memory/paths.js +103 -0
- package/lib/memory/write-gate.js +193 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -182,6 +182,32 @@ SAGE_MEM_DIR=/path/to/your/memory
|
|
|
182
182
|
|
|
183
183
|
- **摘要侧也有相关性门槛**:中文里「怎么」「什么」这类词元在大量 `description` 里都有,一句无关的「今天天气怎么样」曾靠一个「怎么」命中十几份记忆。门槛按**语料自身的 IDF 尺度**归一,实测真实目录里无关问题的候选数降到约七分之一;离线评测集上还不只是没退化、而是更好了(Recall@5 84%→87%、误注入 14%→5.6%)—— 被清掉的弱摘要候选原先在挤占名额
|
|
184
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 某记忆` 认不出来,那就不认 —— 我们不会把「看不到」说成「没读过」。
|
|
185
211
|
- **每条记忆的标题带新鲜度**:`(保存于 N 天前)`,**优先读 frontmatter 的 `updated:`,缺失才回落文件 mtime** —— 手写的时间戳是权威,文件系统时间会被复制 / 备份还原 / checkout 整批改掉。理由是陈旧记忆被当事实引用时,「引用」这个动作本身会让它显得更权威而不是更可疑,所以注入文本开头还带一节**引用纪律**(提到路径先确认存在、提到函数先 grep、要动手先核实)。它**独立成节**而不是一条 bullet:Claude Code 实测同一句话埋成 bullet 命中 0/3、独立成节 3/3
|
|
186
212
|
- **`baseline: true` 的记忆每场会话第一步无条件注入**,最多 8 条——给「你是谁」「待办清单」这类每次开场都该在场的记忆用。多条 baseline 之间的先后由 `baselinePriority` 决定(数字越大越前,缺省 0),**不按文件名排**
|
|
187
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/autodream-tools.js
CHANGED
|
@@ -29,8 +29,9 @@ import { join, basename } from 'node:path'
|
|
|
29
29
|
import { createHash } from 'node:crypto'
|
|
30
30
|
import { auditMemoryDir, formatAudit, parseFrontmatterLite } from './autodream-audit.js'
|
|
31
31
|
import { archiveMemory } from './memory/archive.js'
|
|
32
|
-
import { MAX_FILE_BYTES } from './memory/config.js'
|
|
32
|
+
import { MAX_FILE_BYTES, STATE_ROOT } from './memory/config.js'
|
|
33
33
|
import { writeFileAtomic, warnLog } from './memory/util.js'
|
|
34
|
+
import { buildConflict, writeConflict } from './memory/conflicts.js'
|
|
34
35
|
import { inspectMemoryEntry, reservedIdentitySet } from './memory/scan.js'
|
|
35
36
|
|
|
36
37
|
/** 单次 read_memory 返回的正文上限(字符)。超过就截断并明确告诉模型。 */
|
|
@@ -459,7 +460,92 @@ export function createToolRunner(opts) {
|
|
|
459
460
|
* @param {'create'|'update'|'archive'} op
|
|
460
461
|
* @param {'model'|'auto'} reasonSource — model = 模型自述;auto = 宿主追溯(模型没给)
|
|
461
462
|
*/
|
|
462
|
-
|
|
463
|
+
/**
|
|
464
|
+
* 取宿主的 `fs` 服务,拿不到就返回 null。
|
|
465
|
+
*
|
|
466
|
+
* ⚠️ 必须用 `ctx.get('fs')` 而不是 `ctx.fs`:后者在插件没声明 `inject: ['fs']` 时会**抛**
|
|
467
|
+
* `cannot get property "fs" without inject`(实测,见 docs/host-facts.md)。
|
|
468
|
+
* 而 sage-mem 有意不声明这个硬依赖 —— 声明了它,没有 fs 服务的宿主上整个插件会一直待机。
|
|
469
|
+
*/
|
|
470
|
+
function hostFs() {
|
|
471
|
+
try {
|
|
472
|
+
const fs = typeof ctx?.get === 'function' ? ctx.get('fs') : undefined
|
|
473
|
+
return fs && typeof fs.resolve === 'function' && typeof fs.writeText === 'function' ? fs : null
|
|
474
|
+
} catch {
|
|
475
|
+
return null
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* 把一次「版本过期」落成一条**待人工判定**的冲突记录。
|
|
481
|
+
*
|
|
482
|
+
* 为什么不是「重试」也不是「放弃」:两者都是在替用户决定保留哪一边。
|
|
483
|
+
* 我们要做的是把这次失败从**一个报错**变成**一条可查询的记录** —— 盘上那份是什么、
|
|
484
|
+
* 我们想写的是什么,都留下来,让用户在面板里看到再选。
|
|
485
|
+
*
|
|
486
|
+
* ⚠️ 这里**吞掉一切异常**:它发生在「写入已经失败」的路径上,再抛一次只会把真正的原因
|
|
487
|
+
* (`FS_STALE_VERSION`)盖掉。留不下记录就只留日志,绝不改变向外抛的那个错误。
|
|
488
|
+
*/
|
|
489
|
+
async function recordStaleConflict(fullPath, incoming, err) {
|
|
490
|
+
try {
|
|
491
|
+
const fs = hostFs()
|
|
492
|
+
let onDisk = null
|
|
493
|
+
// 把盘上那一版也存下来 —— 冲突记录的价值就在「两份都在」,只有一边等于没得判。
|
|
494
|
+
try {
|
|
495
|
+
if (fs && typeof fs.readText === 'function') onDisk = await fs.readText(await fs.resolve(fullPath))
|
|
496
|
+
} catch { /* 读不到就只留我们这一版:宁缺一半,也不要为它把冲突记录整条丢掉 */ }
|
|
497
|
+
const record = buildConflict({
|
|
498
|
+
kind: 'stale_version',
|
|
499
|
+
file: basename(fullPath),
|
|
500
|
+
expectedVersion: 'unknown', // 守卫是我们自己按 stat 到的版本构造的,这里拿不到那个字符串
|
|
501
|
+
actualVersion: String(err?.code ?? ''),
|
|
502
|
+
incoming,
|
|
503
|
+
onDisk,
|
|
504
|
+
note: '自动做梦写入时版本已变化:有人在这份记忆被读入之后改过它',
|
|
505
|
+
tool: 'write_memory',
|
|
506
|
+
at: new Date().toISOString(),
|
|
507
|
+
})
|
|
508
|
+
await writeConflict(record, { stateRoot: STATE_ROOT })
|
|
509
|
+
} catch (conflictErr) {
|
|
510
|
+
warnLog(ctx, `冲突记录没写下来(写入失败的原因不受影响):${conflictErr?.message ?? conflictErr}`)
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* 把内容写进一条记忆文件。
|
|
516
|
+
*
|
|
517
|
+
* **优先走宿主的 `fs.writeText`**,理由是它同时吃到 `fs/write-intent` 那道**写入门**
|
|
518
|
+
* (见 `lib/memory/write-gate.js`):我们自己那套 `writeFileAtomic` **绕过它**。
|
|
519
|
+
* 绕过意味着 autodream 排在版本守卫之外 —— 而 autodream 恰恰是最可能与用户手动编辑
|
|
520
|
+
* 撞上的写入者(它在后台跑,用户看不见)。两套写入者之间还能静默互踩,就等于没修。
|
|
521
|
+
*
|
|
522
|
+
* ⚠️ **宿主写入失败时绝不回落。** 回落等于「版本守卫拒绝之后我们照样写进去」,
|
|
523
|
+
* 那把守卫整个作废了,还更糟:它会让失败看起来像成功。失败如实抛给调用方。
|
|
524
|
+
*
|
|
525
|
+
* 只有**宿主没有 fs 服务**时才回落(老宿主 / 测试里的假 ctx)——那条路径的行为与改动前
|
|
526
|
+
* 逐字一致,所以既有断言不受影响。
|
|
527
|
+
*
|
|
528
|
+
* @returns {Promise<object|null>} 宿主的 `FsWriteOutcome`;回落时返回 null
|
|
529
|
+
*/
|
|
530
|
+
async function writeMemoryText(fullPath, content) {
|
|
531
|
+
const fs = hostFs()
|
|
532
|
+
if (!fs) {
|
|
533
|
+
await writeFileAtomic(fullPath, content)
|
|
534
|
+
return null
|
|
535
|
+
}
|
|
536
|
+
const target = await fs.resolve(fullPath)
|
|
537
|
+
try {
|
|
538
|
+
return await fs.writeText(target, content)
|
|
539
|
+
} catch (err) {
|
|
540
|
+
// 版本过期不是「写失败」,是**一次待判定的冲突**:有人在我们读入之后改过这份记忆。
|
|
541
|
+
// 落一条记录(保留哪边由人决定),然后**照旧把错误抛出去** ——
|
|
542
|
+
// 静默重试或静默放弃都是在替用户做决定。
|
|
543
|
+
if (err?.code === 'FS_STALE_VERSION') await recordStaleConflict(fullPath, content, err)
|
|
544
|
+
throw err
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function changeRecord(op, file, reason, before, after, reasonSource = 'model', extra = null) {
|
|
463
549
|
seq += 1
|
|
464
550
|
const at = Date.now()
|
|
465
551
|
changes.push({
|
|
@@ -474,6 +560,9 @@ export function createToolRunner(opts) {
|
|
|
474
560
|
before,
|
|
475
561
|
after,
|
|
476
562
|
deltaBytes: after.bytes - before.bytes,
|
|
563
|
+
// 这一笔是走宿主 fs 写的、还是回落到本地原子写;走宿主时带**版本**。
|
|
564
|
+
// 回落时两者都如实写 null —— 不假装知道一个我们其实拿不到的版本。
|
|
565
|
+
...(extra && typeof extra === 'object' ? extra : {}),
|
|
477
566
|
})
|
|
478
567
|
}
|
|
479
568
|
|
|
@@ -572,10 +661,9 @@ export function createToolRunner(opts) {
|
|
|
572
661
|
const reason = typeof a.reason === 'string' ? a.reason.trim() : ''
|
|
573
662
|
const selfReported = reason.length >= MIN_REASON_CHARS
|
|
574
663
|
const before = await fileFingerprint(full)
|
|
575
|
-
//
|
|
576
|
-
//
|
|
577
|
-
|
|
578
|
-
await writeFileAtomic(full, a.content)
|
|
664
|
+
// 走宿主写入(有 fs 服务时):原子性由宿主保证,**并且吃到写入门**(见 writeMemoryText)。
|
|
665
|
+
// 内容闸门上面已按字节判过,这里只关心「写没写进去、写成了什么版本」。
|
|
666
|
+
const outcome = await writeMemoryText(full, a.content)
|
|
579
667
|
const after = await fileFingerprint(full)
|
|
580
668
|
if (!selfReported) {
|
|
581
669
|
// **不拒绝。** 一次改写缺了缘由,拒掉的代价是这条修正彻底丢失——而它可能
|
|
@@ -583,12 +671,17 @@ export function createToolRunner(opts) {
|
|
|
583
671
|
// 既保住了改动,也让声明里一眼看得出哪些缘由是模型自己说的。
|
|
584
672
|
notes.push(`${basename(full)} 的改动模型没有自述缘由,声明里已标「未自述」并附追溯信息`) }
|
|
585
673
|
changeRecord(
|
|
586
|
-
|
|
674
|
+
// 宿主给了 operation(create / update)就直接用 —— 它比「看写之前存不存在」更权威:
|
|
675
|
+
// 那个指纹是我们**自己**在写之前 stat 的,中间还有一道缝。回落时才由指纹推。
|
|
676
|
+
outcome?.operation === 'create' || outcome?.operation === 'update'
|
|
677
|
+
? outcome.operation
|
|
678
|
+
: (before.exist ? 'update' : 'create'),
|
|
587
679
|
basename(full),
|
|
588
680
|
selfReported ? reason : '',
|
|
589
681
|
before,
|
|
590
682
|
after,
|
|
591
683
|
selfReported ? 'model' : 'auto',
|
|
684
|
+
{ via: outcome ? 'fs' : 'atomic', version: outcome?.version ?? null },
|
|
592
685
|
)
|
|
593
686
|
touched.push(basename(full))
|
|
594
687
|
listCache = null
|
package/lib/index.js
CHANGED
|
@@ -27,9 +27,13 @@ import { rankChunks } from './memory/search.js'
|
|
|
27
27
|
import { renderStatusCard } from './memory/status.js'
|
|
28
28
|
import { detectIntent } from './memory/intent.js'
|
|
29
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 { memoryPathOf } from './memory/paths.js'
|
|
33
|
+
import { decideWriteIntent, decideEditGuard } from './memory/write-gate.js'
|
|
30
34
|
import { createMemoryTools } from './tools/memory-tools.js'
|
|
31
35
|
import { archiveMemory, restoreMemory } from './memory/archive.js'
|
|
32
|
-
import { flushAccess, pickArchiveCandidates, readAccessLedger, recordAccess } from './memory/access.js'
|
|
36
|
+
import { flushAccess, pickArchiveCandidates, readAccessLedger, recordAccess, recordRead } from './memory/access.js'
|
|
33
37
|
import { warnLog, writeFileAtomic } from './memory/util.js'
|
|
34
38
|
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
|
35
39
|
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'
|
|
@@ -238,6 +242,118 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
238
242
|
warnLog(ctx, `检索工具挂载失败(记忆注入不受影响):${err?.message ?? err}`)
|
|
239
243
|
}
|
|
240
244
|
|
|
245
|
+
// ── 观测:模型自己翻了记忆目录 ──
|
|
246
|
+
//
|
|
247
|
+
// 宿主的 `tools/result` 把**每一次**工具调用的 `exec`(含 name / arguments)交给我们,
|
|
248
|
+
// 于是「模型用内置 read/grep 读了哪份记忆」第一次变得可观测 —— 此前台账只记得下
|
|
249
|
+
// 「插件注入了什么」,记不下「模型主动翻了什么」。
|
|
250
|
+
//
|
|
251
|
+
// 三条纪律:
|
|
252
|
+
// 1. **只记能可靠判定的**(`observe.js` 认 read/grep/glob 的路径参数;`pwsh` 一律不认,
|
|
253
|
+
// 那是**已知观测盲区** —— 不许把「看不到」说成「没读」)。
|
|
254
|
+
// 2. **按 session × 文件去重**:翻页(read 的 offset 不同)不刷计数,那是同一个动作。
|
|
255
|
+
// 3. **失败绝不影响任何东西**:观测是副产品,它的问题不许冒泡到工具调用链上。
|
|
256
|
+
const observedReads = createDeduper()
|
|
257
|
+
try {
|
|
258
|
+
ctx.on('tools/result', (exec) => {
|
|
259
|
+
try {
|
|
260
|
+
const cls = classifyToolExecution(exec?.name, exec?.arguments, MEMORY_DIR)
|
|
261
|
+
if (cls.kind !== 'active_read' || !cls.file) return
|
|
262
|
+
const key = accessEventKey({
|
|
263
|
+
sessionId: exec?.agent?.id ?? '',
|
|
264
|
+
file: cls.file,
|
|
265
|
+
// revision 要读盘才算得出,这里拿不到 —— 所以去重粒度是「session × 文件」。
|
|
266
|
+
// 写明这条口径,免得以后有人以为它按内容去重。
|
|
267
|
+
revision: '',
|
|
268
|
+
kind: cls.kind,
|
|
269
|
+
})
|
|
270
|
+
if (!key || !observedReads.shouldCount(key)) return
|
|
271
|
+
recordRead(cls.file, '')
|
|
272
|
+
} catch { /* 观测失败静默:它不该有任何存在感 */ }
|
|
273
|
+
})
|
|
274
|
+
} catch { /* 老宿主没有这个事件:不影响注入 */ }
|
|
275
|
+
|
|
276
|
+
// ── 写入门:给记忆目录的写入补上版本守卫 ──
|
|
277
|
+
//
|
|
278
|
+
// 实测(`scripts/events-probe.mjs`,本机 headless,2026-10-15):这两个事件**只对
|
|
279
|
+
// 「工具发起」的写入**触发 —— 模型调内置 `write` / `edit` 打到记忆文件时会走这里;
|
|
280
|
+
// 而**插件自己**调 `ctx.fs.writeText` 时一次都不发(实测 0 次),那条路由调用方自己传
|
|
281
|
+
// `expected`(见 `lib/autodream-tools.js` 的 `writeMemoryText`)。
|
|
282
|
+
// 监听器**不需要声明 inject** 也能收到,所以这里不挂子 fiber。
|
|
283
|
+
//
|
|
284
|
+
// 它买到的东西:**模型的 `write` 没法在一个记忆文件被改过之后静默覆盖它** ——
|
|
285
|
+
// 守卫过不了,宿主会以 `FS_STALE_VERSION` 明确拒绝(实测确实会被拒)。
|
|
286
|
+
// 这属于「把静默的坏结果变成显式的失败」,不是「阻止写入」:这个事件**没有拒绝动作**,
|
|
287
|
+
// 我们也不发明一个(见 write-gate.js 的头注释)。
|
|
288
|
+
//
|
|
289
|
+
// 单槽纪律:返回 `undefined` 才是「不管」。目录外**必须**返回 undefined,否则会占掉槽位、
|
|
290
|
+
// 影响整块盘的写入(write-gate.js 的 `outside_memory` 判在 `caller_wins` 之前正是为此)。
|
|
291
|
+
/**
|
|
292
|
+
* 探目标的当前版本。**区分「不存在」与「不知道」**。
|
|
293
|
+
*
|
|
294
|
+
* 这个区分不是洁癖:`!!info` 会把「拿不到 fs 服务」也写成 `exists:false`,判定表据此给
|
|
295
|
+
* `createIfAbsent` —— 而目标**其实是存在的**记忆文件时,那是一次**误拦**(写入必然失败,
|
|
296
|
+
* 模型收到一句看不懂的错误)。拿不准就不干涉,比猜一个安全。
|
|
297
|
+
*/
|
|
298
|
+
// ⚠️⚠️ **这段注册必须在 `apply()` 里直接做,绝不能挪进 `ctx.inject(['fs'], …)`,也不能给
|
|
299
|
+
// 插件加 `static inject = ['fs']`。** 这不是风格偏好,是实测出来的硬约束:
|
|
300
|
+
// 这两个事件有一个**注册窗口**,窗口在 fs 服务就绪**之前**就关了。
|
|
301
|
+
// 不 inject、apply 里直接挂 → 收到 1 次 ✅
|
|
302
|
+
// 顶层 `inject:['fs']` → **0 次**
|
|
303
|
+
// 顶层 `inject:['tokenMeter']` → **0 次**(与 fs 无关,是时机问题)
|
|
304
|
+
// `ctx.inject(['fs'], sub)` 子 fiber → **0 次**
|
|
305
|
+
// 不 inject、但 apply 之后 2.5 秒再挂 → **0 次**(晚挂收不到)
|
|
306
|
+
// 证据:`scripts/host-facts-probe.mjs` 的七组对照 + `docs/host-facts.md` 3.3 节。
|
|
307
|
+
// 之所以特别标注:**按 D5 的规范直觉(可选服务要用 ctx.inject 挂子 fiber)改这里,
|
|
308
|
+
// 会让写门静默失效** —— 编译得过、测试可能也过,只是永远不触发。
|
|
309
|
+
// 而我们**不需要** inject:实测不声明 inject 时,处理器**内部** `ctx.get('fs')` 照样拿得到服务
|
|
310
|
+
// (`{hasFs:true, hasStat:true}`),取版本就靠它。
|
|
311
|
+
const probeTarget = async (target) => {
|
|
312
|
+
try {
|
|
313
|
+
const fs = typeof ctx.get === 'function' ? ctx.get('fs') : undefined
|
|
314
|
+
if (!fs || typeof fs.stat !== 'function' || !target) return { known: false }
|
|
315
|
+
const info = await fs.stat(target)
|
|
316
|
+
return { known: true, exists: !!info, version: info?.version ?? null }
|
|
317
|
+
} catch {
|
|
318
|
+
return { known: false }
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
if (typeof ctx.on === 'function') {
|
|
323
|
+
// 公共骨架:**先判位置、再决定要不要碰链子**。
|
|
324
|
+
//
|
|
325
|
+
// ⚠️ 三条纪律(都是踩出来的):
|
|
326
|
+
// 1. **目录外连 `next()` 都不调**。宿主对**任何**工具写入都发这个事件(实测:写一个
|
|
327
|
+
// 临时文件也发),无条件调 next() + stat 等于给整个系统的每次写文件都加一次系统调用,
|
|
328
|
+
// 而我们本来就什么都不该做。直接 `return next()` 把决策权原样交出去。
|
|
329
|
+
// 2. **一旦调了 `next()`,就必须把它给的东西交回去**。`next()` 返回的是下游(或调用方
|
|
330
|
+
// 自己)已经做出的决定;此时再 `return undefined` 会把这个决定**丢掉** ——
|
|
331
|
+
// 我们本想「不干涉」,实际却比干涉还糟。
|
|
332
|
+
// 3. **探不到版本就不碰**(见 probeTarget):把「不知道」当「不存在」会误拦真实存在的文件。
|
|
333
|
+
const gateEvent = (decide) => async (target, actor, next) => {
|
|
334
|
+
try {
|
|
335
|
+
const memoryPath = memoryPathOf(target?.displayPath, MEMORY_DIR)
|
|
336
|
+
if (!memoryPath.ok) return typeof next === 'function' ? next() : undefined
|
|
337
|
+
const caller = typeof next === 'function' ? await next() : undefined
|
|
338
|
+
const probed = await probeTarget(target)
|
|
339
|
+
if (!probed.known) return caller
|
|
340
|
+
const chosen = decide({
|
|
341
|
+
memoryPath,
|
|
342
|
+
exists: probed.exists,
|
|
343
|
+
version: probed.version,
|
|
344
|
+
caller,
|
|
345
|
+
})
|
|
346
|
+
// 我们给出决定就用我们的;决定不了(undefined)就把 `caller` 原样交回去,不吞。
|
|
347
|
+
return chosen === undefined ? caller : chosen
|
|
348
|
+
} catch {
|
|
349
|
+
return undefined /* 写门在宿主的写入链路上:抛出去会把一次正常写入炸掉 */
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
ctx.on('fs/write-intent', gateEvent((facts) => decideWriteIntent(facts).intent))
|
|
353
|
+
ctx.on('fs/edit-intent', gateEvent((facts) => decideEditGuard(facts).guard))
|
|
354
|
+
}
|
|
355
|
+
} catch { /* 老宿主没有这两个事件:写入按宿主默认语义走 */ }
|
|
356
|
+
|
|
241
357
|
// 同一轮对话里问题不变、注入内容也不变,但 DSH 每一步都会往 session 追加
|
|
242
358
|
// 一条 context 消息——不去重的话同一份记忆会被反复追加,撑爆上下文。
|
|
243
359
|
const recallCache = new Map()
|
|
@@ -340,6 +456,11 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
340
456
|
// 检索由这份签名保证同一问题不重复追加,两条线互不干扰。
|
|
341
457
|
const relevant = text && text.length >= 2 ? selectRelevant(text, files) : []
|
|
342
458
|
|
|
459
|
+
// ── 意图:先判,因为它要影响下面正文检索的**门槛松紧** ──
|
|
460
|
+
// 用户是不是在问自己的历史(上次 / 之前 / 更正 / 问依据 / 继续)。
|
|
461
|
+
// 判不出来就是 `none`,不猜 —— 误判的代价是把不该抑制的正文抑制掉。
|
|
462
|
+
const intentInfo = text ? detectIntent(text) : { intent: 'none', historyDependent: false, suppressible: true }
|
|
463
|
+
|
|
343
464
|
// ── 正文检索 ──
|
|
344
465
|
// 摘要(description + aliases + 文件名)是最省的检索面,代价是**正文里的东西天生检索不到**。
|
|
345
466
|
// 这里用它做两件事:
|
|
@@ -349,8 +470,17 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
349
470
|
//
|
|
350
471
|
// query 短于 4 个字符不做正文匹配:`继续` 这种两个字的问题拿去匹配正文,
|
|
351
472
|
// 命中的全是「继续」这个词本身,与用户问什么毫无关系。
|
|
473
|
+
//
|
|
474
|
+
// **门槛按意图放宽**:默认要求「连续命中 ≥ 2 个词元」(那条防的是跨词边界的假词元,
|
|
475
|
+
// 见 search.js)。代价是整句只对上一个两字专名时会被拦下 —— 那是 0.9.16 有意付的。
|
|
476
|
+
// 但用户**明确在问自己的历史**时,宁可多给一条也不该让他拿不到依据:这类问题的
|
|
477
|
+
// 代价不对称(少给 = 答错,多给 = 多花一点上下文)。所以这时把 `minRun` 放到 1。
|
|
352
478
|
const bodyRanked = typeof text === 'string' && text.length >= 4
|
|
353
|
-
? rankChunks(text, files, {
|
|
479
|
+
? rankChunks(text, files, {
|
|
480
|
+
limit: MAX_RESULTS,
|
|
481
|
+
perFile: 1,
|
|
482
|
+
...(intentInfo.historyDependent ? { minEvidence: { minRun: 1 } } : {}),
|
|
483
|
+
})
|
|
354
484
|
: []
|
|
355
485
|
const bestChunk = new Map()
|
|
356
486
|
for (const h of bodyRanked) if (!bestChunk.has(h.file)) bestChunk.set(h.file, h.chunk)
|
|
@@ -403,9 +533,6 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
403
533
|
let cardSig = carried?.cardSig ?? ''
|
|
404
534
|
const nowMs = Date.now()
|
|
405
535
|
|
|
406
|
-
// 用户这句话是不是在问自己的历史 —— 展示层与状态卡都要用它,所以在两处之前算一次。
|
|
407
|
-
const intentInfo = text ? detectIntent(text) : { intent: 'none', historyDependent: false, suppressible: true }
|
|
408
|
-
|
|
409
536
|
// ── 渲染候选,然后交给展示层决定「给正文 / 给指针 / 不给」──
|
|
410
537
|
// 两件事分开做:这一段只负责「每条候选长什么样」,`decideDelivery` 负责
|
|
411
538
|
// 「在预算内该给哪几条、为什么」。混在一起写就是上一版的样子 —— 预算、渲染、
|
|
@@ -491,8 +618,23 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
491
618
|
// 后者是计划里点名的一条:没有历史依赖时不要插「你可能遗漏记忆」,否则提示
|
|
492
619
|
// 每轮都出现,等真有缺口时它已经被当噪音忽略了。
|
|
493
620
|
const gapExists = unread > 0 || skippedForBudget > 0
|
|
494
|
-
|
|
495
|
-
|
|
621
|
+
|
|
622
|
+
// ── 任务检查点:压缩后的恢复入口 ──
|
|
623
|
+
// 宿主**什么时候**压缩上下文,插件控制不了 —— 所以这里**不承诺「压缩前保存」**,
|
|
624
|
+
// 只保证「存在检查点就给一条短句柄」。压缩后两本账与卡的签名会一起归零,
|
|
625
|
+
// 句柄因此会重新出现,这就是恢复路径(而不是靠某个压缩前钩子)。
|
|
626
|
+
//
|
|
627
|
+
// 任务 id:`SAGE_MEM_TASK_ID` 优先(给「跨会话的同一条任务」用),否则用 session id。
|
|
628
|
+
// **绝不用查询文本自定作用域** —— 那句话是模型或用户写的,不该决定去读哪个目录。
|
|
629
|
+
const taskId = process.env.SAGE_MEM_TASK_ID || session.id || ''
|
|
630
|
+
let checkpointLine = ''
|
|
631
|
+
try {
|
|
632
|
+
const cp = taskId ? await readCheckpoint(taskId, { stateRoot: STATE_ROOT }) : null
|
|
633
|
+
if (cp?.ok && cp.handle) checkpointLine = cp.handle
|
|
634
|
+
} catch { /* 读不到检查点就当没有:它是可选的接力棒,不是必需件 */ }
|
|
635
|
+
|
|
636
|
+
const wantCard = gapExists || intentInfo.historyDependent || !!checkpointLine
|
|
637
|
+
const cardBody = wantCard
|
|
496
638
|
? renderStatusCard({
|
|
497
639
|
scope: bodySearched ? 'all' : 'summary',
|
|
498
640
|
scanned: files.length,
|
|
@@ -505,6 +647,8 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
505
647
|
memoryDir: MEMORY_DIR,
|
|
506
648
|
})
|
|
507
649
|
: ''
|
|
650
|
+
// 句柄与卡**共用卡池**:它是同一个通道里的另一句提示,不该另开一份额度。
|
|
651
|
+
const cardText = [cardBody, checkpointLine].filter(Boolean).join('\n')
|
|
508
652
|
let cardPart = ''
|
|
509
653
|
// 卡按**内容签名**去重:签名没变就不重复发。否则每一步都追加一张一模一样的卡,
|
|
510
654
|
// 而每张卡都是落到会话里的持久消息 —— 那不叫提示,那叫刷屏。
|
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
|