sage-mem 0.9.17 → 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 +26 -0
- package/lib/autodream/engine.js +2 -2
- package/lib/index.js +68 -7
- package/lib/memory/access.js +139 -18
- package/lib/memory/checkpoint.js +546 -0
- package/lib/memory/observe.js +304 -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/index.js
CHANGED
|
@@ -27,9 +27,11 @@ 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'
|
|
30
32
|
import { createMemoryTools } from './tools/memory-tools.js'
|
|
31
33
|
import { archiveMemory, restoreMemory } from './memory/archive.js'
|
|
32
|
-
import { flushAccess, pickArchiveCandidates, readAccessLedger, recordAccess } from './memory/access.js'
|
|
34
|
+
import { flushAccess, pickArchiveCandidates, readAccessLedger, recordAccess, recordRead } from './memory/access.js'
|
|
33
35
|
import { warnLog, writeFileAtomic } from './memory/util.js'
|
|
34
36
|
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
|
35
37
|
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'
|
|
@@ -238,6 +240,37 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
238
240
|
warnLog(ctx, `检索工具挂载失败(记忆注入不受影响):${err?.message ?? err}`)
|
|
239
241
|
}
|
|
240
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
|
+
|
|
241
274
|
// 同一轮对话里问题不变、注入内容也不变,但 DSH 每一步都会往 session 追加
|
|
242
275
|
// 一条 context 消息——不去重的话同一份记忆会被反复追加,撑爆上下文。
|
|
243
276
|
const recallCache = new Map()
|
|
@@ -340,6 +373,11 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
340
373
|
// 检索由这份签名保证同一问题不重复追加,两条线互不干扰。
|
|
341
374
|
const relevant = text && text.length >= 2 ? selectRelevant(text, files) : []
|
|
342
375
|
|
|
376
|
+
// ── 意图:先判,因为它要影响下面正文检索的**门槛松紧** ──
|
|
377
|
+
// 用户是不是在问自己的历史(上次 / 之前 / 更正 / 问依据 / 继续)。
|
|
378
|
+
// 判不出来就是 `none`,不猜 —— 误判的代价是把不该抑制的正文抑制掉。
|
|
379
|
+
const intentInfo = text ? detectIntent(text) : { intent: 'none', historyDependent: false, suppressible: true }
|
|
380
|
+
|
|
343
381
|
// ── 正文检索 ──
|
|
344
382
|
// 摘要(description + aliases + 文件名)是最省的检索面,代价是**正文里的东西天生检索不到**。
|
|
345
383
|
// 这里用它做两件事:
|
|
@@ -349,8 +387,17 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
349
387
|
//
|
|
350
388
|
// query 短于 4 个字符不做正文匹配:`继续` 这种两个字的问题拿去匹配正文,
|
|
351
389
|
// 命中的全是「继续」这个词本身,与用户问什么毫无关系。
|
|
390
|
+
//
|
|
391
|
+
// **门槛按意图放宽**:默认要求「连续命中 ≥ 2 个词元」(那条防的是跨词边界的假词元,
|
|
392
|
+
// 见 search.js)。代价是整句只对上一个两字专名时会被拦下 —— 那是 0.9.16 有意付的。
|
|
393
|
+
// 但用户**明确在问自己的历史**时,宁可多给一条也不该让他拿不到依据:这类问题的
|
|
394
|
+
// 代价不对称(少给 = 答错,多给 = 多花一点上下文)。所以这时把 `minRun` 放到 1。
|
|
352
395
|
const bodyRanked = typeof text === 'string' && text.length >= 4
|
|
353
|
-
? rankChunks(text, files, {
|
|
396
|
+
? rankChunks(text, files, {
|
|
397
|
+
limit: MAX_RESULTS,
|
|
398
|
+
perFile: 1,
|
|
399
|
+
...(intentInfo.historyDependent ? { minEvidence: { minRun: 1 } } : {}),
|
|
400
|
+
})
|
|
354
401
|
: []
|
|
355
402
|
const bestChunk = new Map()
|
|
356
403
|
for (const h of bodyRanked) if (!bestChunk.has(h.file)) bestChunk.set(h.file, h.chunk)
|
|
@@ -403,9 +450,6 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
403
450
|
let cardSig = carried?.cardSig ?? ''
|
|
404
451
|
const nowMs = Date.now()
|
|
405
452
|
|
|
406
|
-
// 用户这句话是不是在问自己的历史 —— 展示层与状态卡都要用它,所以在两处之前算一次。
|
|
407
|
-
const intentInfo = text ? detectIntent(text) : { intent: 'none', historyDependent: false, suppressible: true }
|
|
408
|
-
|
|
409
453
|
// ── 渲染候选,然后交给展示层决定「给正文 / 给指针 / 不给」──
|
|
410
454
|
// 两件事分开做:这一段只负责「每条候选长什么样」,`decideDelivery` 负责
|
|
411
455
|
// 「在预算内该给哪几条、为什么」。混在一起写就是上一版的样子 —— 预算、渲染、
|
|
@@ -491,8 +535,23 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
491
535
|
// 后者是计划里点名的一条:没有历史依赖时不要插「你可能遗漏记忆」,否则提示
|
|
492
536
|
// 每轮都出现,等真有缺口时它已经被当噪音忽略了。
|
|
493
537
|
const gapExists = unread > 0 || skippedForBudget > 0
|
|
494
|
-
|
|
495
|
-
|
|
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
|
|
496
555
|
? renderStatusCard({
|
|
497
556
|
scope: bodySearched ? 'all' : 'summary',
|
|
498
557
|
scanned: files.length,
|
|
@@ -505,6 +564,8 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
505
564
|
memoryDir: MEMORY_DIR,
|
|
506
565
|
})
|
|
507
566
|
: ''
|
|
567
|
+
// 句柄与卡**共用卡池**:它是同一个通道里的另一句提示,不该另开一份额度。
|
|
568
|
+
const cardText = [cardBody, checkpointLine].filter(Boolean).join('\n')
|
|
508
569
|
let cardPart = ''
|
|
509
570
|
// 卡按**内容签名**去重:签名没变就不重复发。否则每一步都追加一张一模一样的卡,
|
|
510
571
|
// 而每张卡都是落到会话里的持久消息 —— 那不叫提示,那叫刷屏。
|
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
|
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sage-mem 记忆层 —— 任务检查点(`<状态根>/tasks/<taskId>/WORKING.md`)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么要有这一层:宿主什么时候压缩上下文,插件说了不算 —— 所以**不能承诺
|
|
5
|
+
* 「压缩前一定保存」**。能做的只有两件事:任务走到里程碑时把状态写下来,
|
|
6
|
+
* 压缩之后再用一个短句柄把它读回来。检查点与长期记忆刻意分开:长期记忆答
|
|
7
|
+
* 「我记住的事」,检查点答「这件任务走到哪了」,两者的生命周期、检索方式、
|
|
8
|
+
* 失效条件都不一样,混在一起会让两边都不可信。
|
|
9
|
+
*
|
|
10
|
+
* 这个模块存在的理由**不是格式检查**:固定标题 + 最小长度只能验证格式,而一份格式完美、
|
|
11
|
+
* 内容错误的交接材料仍然是错的。所以 `parseCheckpoint` 除了报缺节,还报
|
|
12
|
+
* `claimsDoneWithoutEvidence` —— 「已完成」里没有任何证据标记的条目。判据的取向是刻意
|
|
13
|
+
* 严格的:**「测试过了」不是证据,「结果:12 passed, 0 failed」才是**。没有证据,
|
|
14
|
+
* 不许把「准备做」写成「已经完成」。
|
|
15
|
+
*
|
|
16
|
+
* 并发:本模块**不实现锁**。同一个 taskId 的并发写由调用方串行(进程内用 `util.js` 的
|
|
17
|
+
* `createWriteQueue`,跨进程本模块明确不管)—— 本模块只负责把每一份写原子化。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { mkdir, readFile } from 'node:fs/promises'
|
|
21
|
+
import { dirname, join } from 'node:path'
|
|
22
|
+
import { STATE_ROOT } from './config.js'
|
|
23
|
+
import { writeFileAtomic } from './util.js'
|
|
24
|
+
|
|
25
|
+
/** 检查点都放在状态根的这个子目录下,一个任务一个目录。 */
|
|
26
|
+
export const TASKS_DIR_NAME = 'tasks'
|
|
27
|
+
|
|
28
|
+
/** 检查点文件名。固定不变,短句柄里要引用它。 */
|
|
29
|
+
export const CHECKPOINT_FILE_NAME = 'WORKING.md'
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 单份检查点的体积上限(字节)。
|
|
33
|
+
*
|
|
34
|
+
* 与记忆的 512 KB 上限分开:检查点是**要反复注入**的东西,一份 64 KB 的交接材料
|
|
35
|
+
* 等于把上下文又用掉一大块,那正好抵消了压缩腾出来的空间。到 64 KB 说明写成了流水账,
|
|
36
|
+
* 该拆成多份(或把稳定结论移进长期记忆)。
|
|
37
|
+
*/
|
|
38
|
+
export const CHECKPOINT_MAX_BYTES = 64 * 1024
|
|
39
|
+
|
|
40
|
+
/** 必需的节标题(顺序即模板顺序)。 */
|
|
41
|
+
export const CHECKPOINT_SECTIONS = Object.freeze([
|
|
42
|
+
'目标与约束',
|
|
43
|
+
'已完成与证据',
|
|
44
|
+
'未完成',
|
|
45
|
+
'关键决定',
|
|
46
|
+
'记忆引用',
|
|
47
|
+
'待核实',
|
|
48
|
+
])
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 缺字段的占位文本。
|
|
52
|
+
*
|
|
53
|
+
* 为什么用占位符而不是空行:空行在 markdown 里与排版空白没有区别,「这里少东西」这件事
|
|
54
|
+
* 在渲染结果里看不见;而且 `parseCheckpoint` 需要能区分「有标题」与「有内容」——
|
|
55
|
+
* 占位符是「有标题、没内容」在文本里的可判定形态。
|
|
56
|
+
*/
|
|
57
|
+
const PLACEHOLDER = '(待补)'
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 任务 id 允许的字符集:单段、`[A-Za-z0-9._-]`、1..64 字符。
|
|
61
|
+
*
|
|
62
|
+
* **刻意不含** `/`、`\`、`:`:三个都是路径语义的元字符(`:` 在 NTFS 上是交替数据流的分隔符)。
|
|
63
|
+
* 判定走纯字符类、**不走 `path.basename`** —— `\` 在 Windows 是分隔符、在 Linux 只是普通字符,
|
|
64
|
+
* 用平台 API 判会得出「同一份输入在两个平台上合法性相反」的结论(本仓库刚被 Linux CI 抓过
|
|
65
|
+
* 同类缺陷)。这里的判据在两个平台上逐字一致。
|
|
66
|
+
*/
|
|
67
|
+
const TASK_ID_RE = /^[A-Za-z0-9._-]{1,64}$/
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Windows 保留设备名。在这个平台上 `tasks/CON` 这类目录**建不出来**(报错或行为未定义),
|
|
71
|
+
* 而 Linux 上完全正常 —— 同一份状态目录同步过去就会「在那边能写、在这边写不了」。
|
|
72
|
+
*/
|
|
73
|
+
const WINDOWS_RESERVED = new Set([
|
|
74
|
+
'con', 'prn', 'aux', 'nul',
|
|
75
|
+
...Array.from({ length: 9 }, (_, i) => `com${i + 1}`),
|
|
76
|
+
...Array.from({ length: 9 }, (_, i) => `lpt${i + 1}`),
|
|
77
|
+
])
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 任务 id 归一:合法原样返回,非法返回 null。
|
|
81
|
+
*
|
|
82
|
+
* 只收字符串,也不 trim:路径两端的空格在 Windows 上会被静默裁掉、在 Linux 上不会,
|
|
83
|
+
* 「帮忙归一化」本身就会造出两个平台指向不同文件的输入 —— 那正是要防的东西。
|
|
84
|
+
*/
|
|
85
|
+
export function normalizeTaskId(raw) {
|
|
86
|
+
if (typeof raw !== 'string') return null
|
|
87
|
+
if (!TASK_ID_RE.test(raw)) return null
|
|
88
|
+
// `.` / `..` 单看是合法的一段字符,但 join 会把它们折掉:`..` 让路径跳出 tasks 目录、
|
|
89
|
+
// 落到状态根上(`<状态根>/WORKING.md`)。「字符合法、语义是穿越」的输入必须单独拒。
|
|
90
|
+
if (/^\.+$/.test(raw)) return null
|
|
91
|
+
// 尾随点在 Windows 上被静默吃掉(`a.` 与 `a` 是**同一个**目录项),Linux 上是两个不同目录。
|
|
92
|
+
// 「同一个 id 在两个平台指向不同文件夹」与本仓库刚修掉的那类平台差异同源,所以无条件拒。
|
|
93
|
+
if (raw.endsWith('.')) return null
|
|
94
|
+
// Windows 保留设备名同理(见上):那个平台上建不出来,Linux 上可以。
|
|
95
|
+
if (WINDOWS_RESERVED.has(raw.split('.')[0].toLowerCase())) return null
|
|
96
|
+
return raw
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** stateRoot 的归一:非空字符串才用,否则回落 config.js 的 STATE_ROOT。 */
|
|
100
|
+
function rootOf(stateRoot) {
|
|
101
|
+
return typeof stateRoot === 'string' && stateRoot !== '' ? stateRoot : STATE_ROOT
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 检查点文件路径;taskId 非法返回 null(调用方据此在写之前就拦住,而不是等 IO 报错)。
|
|
106
|
+
*/
|
|
107
|
+
export function checkpointPathFor(taskId, stateRoot) {
|
|
108
|
+
const id = normalizeTaskId(taskId)
|
|
109
|
+
if (id === null) return null
|
|
110
|
+
return join(rootOf(stateRoot), TASKS_DIR_NAME, id, CHECKPOINT_FILE_NAME)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 缺的字段渲染成「(待补)」而不是空行。 */
|
|
114
|
+
function orPlaceholder(value) {
|
|
115
|
+
const s = value === undefined || value === null ? '' : String(value).trim()
|
|
116
|
+
return s === '' ? PLACEHOLDER : s
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** 列表字段渲染成 bullet;空列表给一条占位 bullet(保证节里不是空的)。 */
|
|
120
|
+
function bulletBlock(list) {
|
|
121
|
+
const items = (Array.isArray(list) ? list : list === undefined || list === null ? [] : [list])
|
|
122
|
+
.map((x) => String(x ?? '').trim())
|
|
123
|
+
.filter((x) => x !== '')
|
|
124
|
+
if (items.length === 0) return `- ${PLACEHOLDER}`
|
|
125
|
+
return items.map((x) => `- ${x}`).join('\n')
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 渲染一份检查点(模板)。字段都可以缺,缺的渲染成「(待补)」。
|
|
130
|
+
*
|
|
131
|
+
* 刻意**不做校验**:渲染是给人补写的起点,格式问题由 `parseCheckpoint` 在读的那一端报出,
|
|
132
|
+
* 这样「写进去」和「读回来判它」是两条独立的路径,不会互相掩盖。
|
|
133
|
+
*
|
|
134
|
+
* @param {{taskId?, project?, revision?, goal?, constraints?, done?, todo?, decisions?, refs?, toVerify?}} input
|
|
135
|
+
*/
|
|
136
|
+
export function renderCheckpoint(input) {
|
|
137
|
+
const src = input && typeof input === 'object' ? input : {}
|
|
138
|
+
const taskId = orPlaceholder(src.taskId)
|
|
139
|
+
const revision = Number.isInteger(src.revision) && src.revision >= 0 ? src.revision : 0
|
|
140
|
+
const S = CHECKPOINT_SECTIONS
|
|
141
|
+
const lines = [
|
|
142
|
+
`# 任务检查点 ${taskId}`,
|
|
143
|
+
'',
|
|
144
|
+
`- 任务:${taskId}`,
|
|
145
|
+
`- 项目:${orPlaceholder(src.project)}`,
|
|
146
|
+
`- revision:${revision}`,
|
|
147
|
+
'',
|
|
148
|
+
`## ${S[0]}`,
|
|
149
|
+
'',
|
|
150
|
+
`目标:${orPlaceholder(src.goal)}`,
|
|
151
|
+
'',
|
|
152
|
+
'约束:',
|
|
153
|
+
bulletBlock(src.constraints),
|
|
154
|
+
'',
|
|
155
|
+
`## ${S[1]}`,
|
|
156
|
+
'',
|
|
157
|
+
bulletBlock(src.done),
|
|
158
|
+
'',
|
|
159
|
+
`## ${S[2]}`,
|
|
160
|
+
'',
|
|
161
|
+
bulletBlock(src.todo),
|
|
162
|
+
'',
|
|
163
|
+
`## ${S[3]}`,
|
|
164
|
+
'',
|
|
165
|
+
bulletBlock(src.decisions),
|
|
166
|
+
'',
|
|
167
|
+
`## ${S[4]}`,
|
|
168
|
+
'',
|
|
169
|
+
bulletBlock(src.refs),
|
|
170
|
+
'',
|
|
171
|
+
`## ${S[5]}`,
|
|
172
|
+
'',
|
|
173
|
+
bulletBlock(src.toVerify),
|
|
174
|
+
'',
|
|
175
|
+
]
|
|
176
|
+
return lines.join('\n')
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* 「证据标记」数据表 —— **数据而不是 if 链**,因为面板与文档要能展示「凭什么算证据」,
|
|
181
|
+
* 断言也要能逐条核对它。
|
|
182
|
+
*
|
|
183
|
+
* 取证的取向:一条「已完成」条目必须**指向一个可以去核对的东西**(命令、路径、行号、
|
|
184
|
+
* 测试结果、提交哈希、外部链接、带单位的测量值)。所以刻意**不**把「测试过了」「已验证」
|
|
185
|
+
* 这类**关于测试的说法**当证据 —— 那正是这个模块要抓的谎报形态:它没有让任何人能去核对。
|
|
186
|
+
*/
|
|
187
|
+
export const EVIDENCE_MARKERS = Object.freeze([
|
|
188
|
+
Object.freeze({
|
|
189
|
+
id: 'code_span',
|
|
190
|
+
label: '反引号包裹的命令 / 路径 / 标识符',
|
|
191
|
+
pattern: /`[^`\n]+`/,
|
|
192
|
+
}),
|
|
193
|
+
Object.freeze({
|
|
194
|
+
id: 'path_with_dir',
|
|
195
|
+
label: '带目录成分且带扩展名的路径',
|
|
196
|
+
pattern: /\b[\w-]+(?:\/[\w.-]+)+\.[A-Za-z]{1,6}\b/,
|
|
197
|
+
}),
|
|
198
|
+
Object.freeze({
|
|
199
|
+
id: 'file_with_ext',
|
|
200
|
+
label: '带扩展名的文件名(可去核对的东西)',
|
|
201
|
+
pattern: /[\w-]+\.(?:md|mjs|cjs|js|json|jsonl|yml|yaml|toml|ts|py|ps1|sh|txt|log)\b/i,
|
|
202
|
+
}),
|
|
203
|
+
Object.freeze({
|
|
204
|
+
id: 'line_ref',
|
|
205
|
+
label: '文件:行号(精确到行的定位)',
|
|
206
|
+
pattern: /(?:[\w-]+\/[\w./-]*|\b[\w-]+\.[A-Za-z0-9]+):\d+(?:-\d+)?/,
|
|
207
|
+
}),
|
|
208
|
+
Object.freeze({
|
|
209
|
+
id: 'test_result',
|
|
210
|
+
label: '测试 / 断言的具体结果(带数字、PASS/FAIL 或退出码)',
|
|
211
|
+
pattern:
|
|
212
|
+
/\b\d+\s*(?:passed|failed)\b|\b(?:PASS|FAIL)\b|\bexit\s*code\s*[::]?\s*\d|\d+\s*(?:个|项|条)?(?:断言|用例|测试)?\s*通过|退出码\s*[::]?\s*\d|结果\s*[::]\s*\d/,
|
|
213
|
+
}),
|
|
214
|
+
Object.freeze({
|
|
215
|
+
id: 'commit_ref',
|
|
216
|
+
label: 'commit / revision / sha256 后跟具体值',
|
|
217
|
+
pattern: /(?:\bcommit\b|\brevision\b|\bsha256\b)[\s::]*[0-9a-z]/i,
|
|
218
|
+
}),
|
|
219
|
+
Object.freeze({
|
|
220
|
+
id: 'hex_ref',
|
|
221
|
+
label: '看起来是提交哈希的十六进制串(含字母、至少 7 位)',
|
|
222
|
+
pattern: /\b(?=[0-9a-f]*[a-f])[0-9a-f]{7,40}\b/i,
|
|
223
|
+
}),
|
|
224
|
+
Object.freeze({
|
|
225
|
+
id: 'link',
|
|
226
|
+
label: '可核对的外部链接',
|
|
227
|
+
pattern: /https?:\/\/\S+/,
|
|
228
|
+
}),
|
|
229
|
+
Object.freeze({
|
|
230
|
+
id: 'measurement',
|
|
231
|
+
label: '带单位的具体测量值',
|
|
232
|
+
pattern: /[::]\s*\d+(?:\.\d+)?\s*(?:字节|KB|MB|GB|ms|毫秒|秒|条|项|处|个)/,
|
|
233
|
+
}),
|
|
234
|
+
])
|
|
235
|
+
|
|
236
|
+
/** 一条条目里有没有**可以去核对**的东西。任一标记命中即算有。 */
|
|
237
|
+
export function hasEvidence(item) {
|
|
238
|
+
const s = String(item ?? '')
|
|
239
|
+
return EVIDENCE_MARKERS.some((m) => m.pattern.test(s))
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** 子标签行(`目标:` / `约束:`):它们只是排版,不是内容。 */
|
|
243
|
+
const SUB_LABEL_RE = /^(?:目标|约束|限制|备注)\s*[::]?\s*$/
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* 两个有语义的节名:解析时按**名字**取,不用数组下标 —— 顺序虽然冻结,名字更经得起改。
|
|
247
|
+
* 下标只在渲染模板(顺序即模板顺序)里用。
|
|
248
|
+
*/
|
|
249
|
+
const DONE_SECTION = '已完成与证据'
|
|
250
|
+
const TODO_SECTION = '未完成'
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* 节内容的「实义行」:剥掉列表标记与子标签,再滤掉空行与占位符。
|
|
254
|
+
*
|
|
255
|
+
* 这是「有标题 ≠ 有内容」的落点:一个节只要还有一条实义行才算存在;只有一个标题、
|
|
256
|
+
* 或者标题下全是「(待补)」,都算缺。
|
|
257
|
+
*/
|
|
258
|
+
function significantLines(text) {
|
|
259
|
+
return String(text ?? '')
|
|
260
|
+
.split(/\r?\n/)
|
|
261
|
+
.map((line) => line.trim())
|
|
262
|
+
.map((line) => line.replace(/^[-*+]\s+/, '').replace(/^\d+[.)]\s+/, '').trim())
|
|
263
|
+
.map((line) => line.replace(/^(?:目标|约束|限制|备注)\s*[::]\s*/, '').trim())
|
|
264
|
+
.filter((line) => line !== '' && line !== PLACEHOLDER && !SUB_LABEL_RE.test(line))
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* 把节内容切成条目:bullet(`-` / `*` / `1.`)各算一条,**不以 bullet 开头的行并入上一条**
|
|
269
|
+
* (同一件事的证据常常写在换行的续行里,切开会把证据判丢)。节内的小标题不算条目。
|
|
270
|
+
*/
|
|
271
|
+
function itemsOf(text) {
|
|
272
|
+
const items = []
|
|
273
|
+
for (const raw of String(text ?? '').split(/\r?\n/)) {
|
|
274
|
+
const line = raw.trim()
|
|
275
|
+
if (line === '' || /^#{1,6}\s/.test(line)) continue
|
|
276
|
+
const bullet = line.match(/^(?:[-*+]|\d+[.)])\s+(.*)$/)
|
|
277
|
+
if (bullet) {
|
|
278
|
+
items.push(bullet[1].trim())
|
|
279
|
+
continue
|
|
280
|
+
}
|
|
281
|
+
if (items.length === 0) items.push(line)
|
|
282
|
+
else items[items.length - 1] = `${items[items.length - 1]} ${line}`.trim()
|
|
283
|
+
}
|
|
284
|
+
return items.filter((item) => item !== '' && item !== PLACEHOLDER)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** 「没有剩余工作」的声明:它不是下一件事。只认这几种明确的收尾说法,不猜别的。 */
|
|
288
|
+
function isNoWork(item) {
|
|
289
|
+
const s = String(item ?? '').replace(/[\s。.!!,,;;::、]/g, '')
|
|
290
|
+
if (/^(?:无|没有|暂无|尚无|无剩余|无待办|无未完成|均已?完成|全部完成|none|nothing|n\/?a)$/i.test(s)) return true
|
|
291
|
+
return /^(?:无|没有|暂无|尚无)/.test(s) && s.length <= 8
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* 解析检查点。
|
|
296
|
+
*
|
|
297
|
+
* 三件事分开报,因为它们对应三种不同的错:
|
|
298
|
+
* · `missing` —— 缺节,或**只有标题没有内容**(占位符不算内容);
|
|
299
|
+
* · `claimsDoneWithoutEvidence` —— 声称完成却没有任何证据标记(本模块的主要理由);
|
|
300
|
+
* · `malformedReason` —— 连检查点的骨架都读不出来(不是文本 / 空 / 没有任务标记 /
|
|
301
|
+
* revision 不是非负整数)。**缺节不算坏文件**:那是不完整,不是不可读。
|
|
302
|
+
*
|
|
303
|
+
* 绝不抛:任何输入都返回同一形状的对象。
|
|
304
|
+
*
|
|
305
|
+
* @returns {{taskId: string, project: string, revision: number, sections: Object,
|
|
306
|
+
* missing: string[], claimsDoneWithoutEvidence: string[],
|
|
307
|
+
* hasNextStep: boolean, malformedReason: string}}
|
|
308
|
+
*/
|
|
309
|
+
export function parseCheckpoint(text) {
|
|
310
|
+
const out = {
|
|
311
|
+
taskId: '',
|
|
312
|
+
project: '',
|
|
313
|
+
revision: 0,
|
|
314
|
+
sections: {},
|
|
315
|
+
missing: [],
|
|
316
|
+
claimsDoneWithoutEvidence: [],
|
|
317
|
+
hasNextStep: false,
|
|
318
|
+
malformedReason: '',
|
|
319
|
+
}
|
|
320
|
+
// 六个键先铺满:调用方不必自己判 undefined,`missing` 才是「哪几节没内容」的答案。
|
|
321
|
+
for (const name of CHECKPOINT_SECTIONS) out.sections[name] = ''
|
|
322
|
+
|
|
323
|
+
if (typeof text !== 'string') {
|
|
324
|
+
out.malformedReason = '内容不是文本'
|
|
325
|
+
out.missing = [...CHECKPOINT_SECTIONS]
|
|
326
|
+
return out
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const lines = text.split(/\r?\n/)
|
|
330
|
+
|
|
331
|
+
// 元信息只认**第一个二级及更深标题之前**的行:一级标题是本模板的文档标题(`# 任务检查点 x`),
|
|
332
|
+
// 而节正文里的 `- 任务:…` 是内容、不是元信息 —— 用「二级标题」当元信息区的结束,两者都不误伤。
|
|
333
|
+
let metaEnd = lines.length
|
|
334
|
+
for (let i = 0; i < lines.length; i++) {
|
|
335
|
+
if (/^\s{0,3}#{2,}\s+\S/.test(lines[i])) {
|
|
336
|
+
metaEnd = i
|
|
337
|
+
break
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
let revisionRaw = null
|
|
342
|
+
for (let i = 0; i < metaEnd; i++) {
|
|
343
|
+
const line = lines[i]
|
|
344
|
+
if (out.taskId === '') {
|
|
345
|
+
const m = line.match(/^[-*+]?\s*任务\s*[::]\s*(.*)$/)
|
|
346
|
+
if (m) {
|
|
347
|
+
out.taskId = m[1].trim()
|
|
348
|
+
continue
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (out.project === '') {
|
|
352
|
+
const m = line.match(/^[-*+]?\s*项目\s*[::]\s*(.*)$/)
|
|
353
|
+
if (m) {
|
|
354
|
+
out.project = m[1].trim()
|
|
355
|
+
continue
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
if (revisionRaw === null) {
|
|
359
|
+
const m = line.match(/^[-*+]?\s*revision\s*[::]\s*(\S*)\s*$/i)
|
|
360
|
+
if (m) revisionRaw = m[1]
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
// 渲染出的占位符不是值 —— 回了占位符等于没写。
|
|
364
|
+
if (out.taskId === PLACEHOLDER) out.taskId = ''
|
|
365
|
+
if (out.project === PLACEHOLDER) out.project = ''
|
|
366
|
+
|
|
367
|
+
let revReason = ''
|
|
368
|
+
if (revisionRaw !== null) {
|
|
369
|
+
if (!/^\d+$/.test(revisionRaw)) {
|
|
370
|
+
revReason = `revision 不是非负整数:${revisionRaw}`
|
|
371
|
+
} else {
|
|
372
|
+
const n = Number(revisionRaw)
|
|
373
|
+
if (!Number.isSafeInteger(n)) revReason = `revision 超出安全整数范围:${revisionRaw}`
|
|
374
|
+
else out.revision = n
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const bodies = new Map(CHECKPOINT_SECTIONS.map((name) => [name, []]))
|
|
379
|
+
let current = null
|
|
380
|
+
for (const line of lines) {
|
|
381
|
+
const heading = line.match(/^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/)
|
|
382
|
+
if (heading) {
|
|
383
|
+
const level = heading[1].length
|
|
384
|
+
const title = heading[2].replace(/\s*[::]\s*$/, '').trim()
|
|
385
|
+
if (CHECKPOINT_SECTIONS.includes(title)) {
|
|
386
|
+
current = title
|
|
387
|
+
continue
|
|
388
|
+
}
|
|
389
|
+
// 六节固定是二级标题:别的二级及更浅标题是节边界;`###` 更深的是节内小标题,
|
|
390
|
+
// 落到下面照样进正文(它是一个节的排版结构,不是新节)。
|
|
391
|
+
if (level <= 2) {
|
|
392
|
+
current = null
|
|
393
|
+
continue
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
if (current !== null) bodies.get(current).push(line)
|
|
397
|
+
}
|
|
398
|
+
for (const name of CHECKPOINT_SECTIONS) out.sections[name] = bodies.get(name).join('\n').trim()
|
|
399
|
+
|
|
400
|
+
out.missing = CHECKPOINT_SECTIONS.filter((name) => significantLines(out.sections[name]).length === 0)
|
|
401
|
+
out.claimsDoneWithoutEvidence = itemsOf(out.sections[DONE_SECTION]).filter((item) => !hasEvidence(item))
|
|
402
|
+
out.hasNextStep = itemsOf(out.sections[TODO_SECTION]).some((item) => !isNoWork(item))
|
|
403
|
+
|
|
404
|
+
if (out.taskId === '') out.malformedReason = '缺少任务标记(- 任务:…)'
|
|
405
|
+
else if (revReason !== '') out.malformedReason = revReason
|
|
406
|
+
|
|
407
|
+
return out
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* 读一份检查点。**所有失败都返回对象,绝不抛**(注入链上不许因为一次读失败炸掉)。
|
|
412
|
+
*
|
|
413
|
+
* @param {string} taskId
|
|
414
|
+
* @param {{stateRoot?: string}} [opts]
|
|
415
|
+
* @returns {Promise<{ok:true, path, bytes, text, taskId, project, revision, sections, missing,
|
|
416
|
+
* claimsDoneWithoutEvidence, hasNextStep, malformedReason, declaredTaskId, warnings, handle}
|
|
417
|
+
* | {ok:false, code:'invalid'|'missing'|'io_error'|'malformed', path?, error, revision?}>}
|
|
418
|
+
*/
|
|
419
|
+
export async function readCheckpoint(taskId, opts) {
|
|
420
|
+
const id = normalizeTaskId(taskId)
|
|
421
|
+
if (id === null) return { ok: false, code: 'invalid', error: `任务 id 非法:${JSON.stringify(taskId)}` }
|
|
422
|
+
|
|
423
|
+
const path = checkpointPathFor(id, opts && typeof opts === 'object' ? opts.stateRoot : undefined)
|
|
424
|
+
let text
|
|
425
|
+
try {
|
|
426
|
+
text = await readFile(path, 'utf8')
|
|
427
|
+
} catch (err) {
|
|
428
|
+
if (err?.code === 'ENOENT') return { ok: false, code: 'missing', path, error: '检查点文件不存在' }
|
|
429
|
+
return { ok: false, code: 'io_error', path, error: String(err?.message ?? err) }
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const parsed = parseCheckpoint(text)
|
|
433
|
+
if (parsed.malformedReason !== '') {
|
|
434
|
+
// 坏文件**不把正文交出去**:一份读不出来的东西被当成检查点用,比读不到更危险。
|
|
435
|
+
return { ok: false, code: 'malformed', path, error: parsed.malformedReason, revision: parsed.revision }
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// 目录名才是我们请求的身份;文件里写的 id 只当**声明**核对,不一致要说出来而不是静默采信。
|
|
439
|
+
const warnings = []
|
|
440
|
+
if (parsed.taskId !== id) warnings.push(`文件内声明的任务 id(${parsed.taskId})与所在目录(${id})不一致`)
|
|
441
|
+
|
|
442
|
+
return {
|
|
443
|
+
ok: true,
|
|
444
|
+
path,
|
|
445
|
+
bytes: Buffer.byteLength(text, 'utf8'),
|
|
446
|
+
text,
|
|
447
|
+
...parsed,
|
|
448
|
+
taskId: id,
|
|
449
|
+
declaredTaskId: parsed.taskId,
|
|
450
|
+
warnings,
|
|
451
|
+
handle: checkpointHandleText({ taskId: id, revision: parsed.revision, project: parsed.project }),
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* 写一份检查点(原子写,覆盖旧版本)。
|
|
457
|
+
*
|
|
458
|
+
* 复用 `util.js` 的 `writeFileAtomic`:直写崩在中途会留下半截检查点 —— 而半截的交接材料
|
|
459
|
+
* 比没有更糟(它看起来像一份状态,实际是错的)。这里**不校验内容**:校验的落点在读/解析
|
|
460
|
+
* 那一端,写路径只守体积与原子性。写进去的东西至少是完整的一份,能被读出来判它坏。
|
|
461
|
+
*
|
|
462
|
+
* ⚠️ 本模块不实现锁:并发写同一个 taskId 由**调用方串行**(进程内用 createWriteQueue)。
|
|
463
|
+
*
|
|
464
|
+
* @param {string} taskId
|
|
465
|
+
* @param {string} text
|
|
466
|
+
* @param {{stateRoot?: string}} [opts]
|
|
467
|
+
* @returns {Promise<{ok:true, bytes:number, path:string} | {ok:false, code:'invalid'|'too_big'|'io_error', ...}>}
|
|
468
|
+
*/
|
|
469
|
+
export async function writeCheckpoint(taskId, text, opts) {
|
|
470
|
+
const id = normalizeTaskId(taskId)
|
|
471
|
+
if (id === null) return { ok: false, code: 'invalid', error: `任务 id 非法:${JSON.stringify(taskId)}` }
|
|
472
|
+
if (typeof text !== 'string') return { ok: false, code: 'invalid', error: '检查点正文必须是字符串' }
|
|
473
|
+
|
|
474
|
+
// 按 UTF-8 字节算,不是字符数 —— 一份中文检查点的字符数只有字节数的三分之一,
|
|
475
|
+
// 用字符数当闸门等于把上限放大三倍。
|
|
476
|
+
const bytes = Buffer.byteLength(text, 'utf8')
|
|
477
|
+
if (bytes > CHECKPOINT_MAX_BYTES) {
|
|
478
|
+
return {
|
|
479
|
+
ok: false,
|
|
480
|
+
code: 'too_big',
|
|
481
|
+
bytes,
|
|
482
|
+
limit: CHECKPOINT_MAX_BYTES,
|
|
483
|
+
error: `检查点 ${bytes} 字节,超过上限 ${CHECKPOINT_MAX_BYTES} 字节`,
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const path = checkpointPathFor(id, opts && typeof opts === 'object' ? opts.stateRoot : undefined)
|
|
488
|
+
try {
|
|
489
|
+
await mkdir(dirname(path), { recursive: true })
|
|
490
|
+
await writeFileAtomic(path, text)
|
|
491
|
+
} catch (err) {
|
|
492
|
+
return { ok: false, code: 'io_error', path, error: String(err?.message ?? err) }
|
|
493
|
+
}
|
|
494
|
+
return { ok: true, bytes, path }
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** 注入用的短句柄硬上限(字节)。它要跟正文抢上下文,不能自己长起来。 */
|
|
498
|
+
const HANDLE_MAX_BYTES = 160
|
|
499
|
+
|
|
500
|
+
/** 按**码点**截断到不超过 maxBytes 字节:不许把一个字符切成半个(注入文本里会变 U+FFFD)。 */
|
|
501
|
+
function truncateByBytes(s, maxBytes) {
|
|
502
|
+
if (Buffer.byteLength(s, 'utf8') <= maxBytes) return s
|
|
503
|
+
let out = ''
|
|
504
|
+
let used = 0
|
|
505
|
+
for (const ch of s) {
|
|
506
|
+
const b = Buffer.byteLength(ch, 'utf8')
|
|
507
|
+
if (used + b > maxBytes) break
|
|
508
|
+
out += ch
|
|
509
|
+
used += b
|
|
510
|
+
}
|
|
511
|
+
return out
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* 注入用的短句柄(一行,≤ 160 字节):说清「有这个检查点、revision 几、怎么读」。
|
|
516
|
+
*
|
|
517
|
+
* 预算是硬的,所以按优先级退化:**id 与 revision 不可丢**(它们才是身份),项目是可选线索
|
|
518
|
+
* (放不下就截短或整个丢掉),最后连「重复一次 id 的完整相对路径」放不下时(64 字符的
|
|
519
|
+
* 极端 id),退成不含重复路径的说法。绝不把多字节字符截半个。taskId 非法返回 ''。
|
|
520
|
+
*
|
|
521
|
+
* revision 缺省按 0(与 renderCheckpoint 的缺省一致)。
|
|
522
|
+
*/
|
|
523
|
+
export function checkpointHandleText({ taskId, revision, project } = {}) {
|
|
524
|
+
const id = normalizeTaskId(taskId)
|
|
525
|
+
if (id === null) return ''
|
|
526
|
+
const rev = Number.isInteger(revision) && revision >= 0 ? revision : 0
|
|
527
|
+
const proj = String(project ?? '').replace(/\s+/g, ' ').trim()
|
|
528
|
+
|
|
529
|
+
const head = `检查点 ${id}(revision ${rev}):`
|
|
530
|
+
const detail = `读 tasks/${id}/WORKING.md`
|
|
531
|
+
const tail = ',续做前先读'
|
|
532
|
+
const build = (p) => `${head}${p === '' ? '' : `项目 ${p},`}${detail}${tail}`
|
|
533
|
+
|
|
534
|
+
let line = build(proj)
|
|
535
|
+
if (Buffer.byteLength(line, 'utf8') > HANDLE_MAX_BYTES) {
|
|
536
|
+
// 项目放不下:按剩余预算截断它,截不下就整个丢掉。
|
|
537
|
+
const fixed = Buffer.byteLength(build(''), 'utf8')
|
|
538
|
+
const room = HANDLE_MAX_BYTES - fixed - Buffer.byteLength('项目 ,', 'utf8')
|
|
539
|
+
line = build(room > 0 ? truncateByBytes(proj, room) : '')
|
|
540
|
+
}
|
|
541
|
+
if (Buffer.byteLength(line, 'utf8') > HANDLE_MAX_BYTES) {
|
|
542
|
+
// 到这儿只剩「id 太长」一种可能:完整相对路径(id 出现两次)放不下,退成不重复的写法。
|
|
543
|
+
line = `${head}读 ${TASKS_DIR_NAME}/${CHECKPOINT_FILE_NAME}`
|
|
544
|
+
}
|
|
545
|
+
return line
|
|
546
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sage-mem 记忆层 —— 工具调用观测:这一次调用是不是在读记忆目录。
|
|
3
|
+
*
|
|
4
|
+
* 宿主的 `tools/result` 会把**每一次**工具调用的 `exec`(含 `name` 与 `arguments`)交给我们,
|
|
5
|
+
* 于是「模型自己用内置工具翻了记忆」这件事第一次能被记进台账(0.9.16 之前只能记「注入过什么」)。
|
|
6
|
+
* 本模块只做**判定**:写台账是 access.js 的事 —— 这里不落盘、不看时钟、不 stat。
|
|
7
|
+
*
|
|
8
|
+
* ## 三条边界(都是「宁可漏记,绝不误记」)
|
|
9
|
+
*
|
|
10
|
+
* 1. **只认能可靠判定的形状。**
|
|
11
|
+
* `read` 的 `file_path`、`grep` / `glob` 的 `path` 是结构化参数,能可靠判定;而 `pwsh` 的
|
|
12
|
+
* `command` 是一段程序 —— `cat 某记忆`、`Get-Content`、`type`、`Select-String`、变量拼接、
|
|
13
|
+
* 管道、重定向……认不全。认一半的代价是把「读的是别的东西」记成「读了记忆」,所以执行类
|
|
14
|
+
* 工具一律 `kind:'unobservable'`:**这是已知观测盲区,不是「这一步没有读取」**。
|
|
15
|
+
* 同理不猜的还有:`grep` / `glob` 缺 `path` 时不拿 `pattern` 反推目录(pattern 里可以有
|
|
16
|
+
* `*` / `{a,b}` / `**`);`read` 拿到相对路径时不猜 cwd(cwd 是运行时状态,纯函数看不到)。
|
|
17
|
+
*
|
|
18
|
+
* 2. **`atime` 永远不许当证据。** 索引器、备份程序、挂载选项都会改它,它既不等于「模型读过」,
|
|
19
|
+
* 也不等于「用户用过」。本模块只用传进来的参数判定,一个文件系统调用都不发。
|
|
20
|
+
*
|
|
21
|
+
* 3. **路径判定与平台无关。** `\` 在 Windows 是分隔符、在 Linux 只是普通字符:同一份输入在
|
|
22
|
+
* 两个平台上会被 `path` 的默认语义解析成不同目录(`naming.js` 的 `safeName` 刚被 Linux CI
|
|
23
|
+
* 用同一类缺陷抓过一次)。所以这里**不用** `path.resolve` / `path.relative` / `path.sep`,
|
|
24
|
+
* 按下面这套固定规则自己归一 —— 语义只由规则决定,不由运行平台决定。
|
|
25
|
+
*
|
|
26
|
+
* ## 归一规则(两个平台完全一致,实现见 canonicalPathOf)
|
|
27
|
+
*
|
|
28
|
+
* - 只接受**绝对路径**(`/…`、`//server/share…`、`X:/…`);相对路径不认。
|
|
29
|
+
* - `\` 与 `/` **一律**当分隔符(显式同时处理两者,而不是交给 `path.sep`)。
|
|
30
|
+
* - 折叠 `.`、连续分隔符,逐段回退 `..`(绝对路径在根上的 `..` 是空操作)。
|
|
31
|
+
* - 盘符统一小写;其余部分**大小写敏感**。NTFS 不敏感、ext4 敏感 —— 取交集(敏感)只会漏记,
|
|
32
|
+
* 取并集(不敏感)会误记,观测层宁可漏记。
|
|
33
|
+
* - 包含关系按**段边界**判定(父目录 + 分隔符),所以 `记忆目录-evil/x.md` 不会被当成
|
|
34
|
+
* `记忆目录/x.md` —— 字符串前缀比较的经典错。
|
|
35
|
+
*
|
|
36
|
+
* ## 已知残留边界(写清楚,不假装没有)
|
|
37
|
+
*
|
|
38
|
+
* - POSIX 上 `\` 可以是文件名里的普通字符,本模块把它当分隔符:那种畸形名字可能被归到隔壁。
|
|
39
|
+
* 方向只会是**漏记**(或把一个本来就在目录内的名字算成目录内的另一个名字),且 `safeName`
|
|
40
|
+
* 本来就禁止记忆条目名里出现 `\`。
|
|
41
|
+
* - 符号链接 / 8.3 短名 / 目录联接不做解析(纯函数不读盘)。路径里先有指向外部的链接时,
|
|
42
|
+
* `link/../x` 这类写法按字面归一 —— 这是观测层能做到的上限,宁可少记。
|
|
43
|
+
*
|
|
44
|
+
* ## 字段口径
|
|
45
|
+
*
|
|
46
|
+
* - `file` = **记忆文件名**,不是路径。与 `scanMemoryFiles` / `hits[].file` / 台账
|
|
47
|
+
* `access.json` 的键同一口径:调用方拿到就能直接进 `accessEventKey` 与 `recordAccess`,
|
|
48
|
+
* 不必再 basename 一次 —— 每多一次转换就多一个「键域搞错」的机会(D18 就是这么来的)。
|
|
49
|
+
* - `dir` = **规范化后的绝对路径**,只在「值指向记忆目录里的一个目录(含记忆目录本身)」时给出。
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
/** 认得的读工具 → 取路径的参数名。表是**数据**:调用方与测试都直接读它,不写第二份分支。 */
|
|
53
|
+
export const READ_TOOLS = Object.freeze({ read: 'file_path', grep: 'path', glob: 'path' })
|
|
54
|
+
|
|
55
|
+
/** 判为「写」的工具。列出来是为了可解释:它们改记忆,不是读记忆。 */
|
|
56
|
+
export const WRITE_TOOLS = Object.freeze(['write', 'edit'])
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 判为「看不见」的工具。
|
|
60
|
+
*
|
|
61
|
+
* 单列出来就是为了可解释:台账里出现 `unobservable` 才说明「这一步**可能**有读取但我们没记」,
|
|
62
|
+
* 而不是「这一步没有读取」。两者的区别决定了排查方向。
|
|
63
|
+
*/
|
|
64
|
+
export const UNOBSERVABLE_TOOLS = Object.freeze(['pwsh', 'bash', 'shell'])
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 记忆条目一律是 `.md`(`naming.js` 的唯一判据)。这里**不** import `isMemoryEntry`:
|
|
68
|
+
* 观测层判的是「这次调用碰的是不是记忆文件」,不该跟着扫描层的排除名单(索引 / 流水)一起变
|
|
69
|
+
* —— 读 `MEMORY.md` 同样是读了记忆目录,该不该记进台账由调用方决定。
|
|
70
|
+
*/
|
|
71
|
+
const MEMORY_FILE_RE = /\.md$/i
|
|
72
|
+
|
|
73
|
+
/** 写工具取路径的参数名(读工具的对应项在 READ_TOOLS 里)。 */
|
|
74
|
+
const WRITE_PATH_ARG = 'file_path'
|
|
75
|
+
|
|
76
|
+
/** 只有这两种事件有「哪个文件」这个键,才进得了以文件名为键的台账。 */
|
|
77
|
+
const RECORDABLE_KINDS = new Set(['active_read', 'write'])
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 把路径归一成规范形式;**不是绝对路径就返回 null**(调用方一律按「不认」处理)。
|
|
81
|
+
*
|
|
82
|
+
* 为什么自己写而不用 `path.resolve`:`path.resolve` 在 Windows 上把 `\` 当分隔符、在 Linux 上
|
|
83
|
+
* 不当,于是同一份输入两个平台给出不同结果 —— 而记忆目录会被同步 / 拷贝 / 备份还原,判据必须
|
|
84
|
+
* 跨平台一致。这里的规则只依赖输入的**形状**,不依赖 `path.sep`。
|
|
85
|
+
*
|
|
86
|
+
* `..` 到根为止(绝对路径在根上的 `..` 是空操作,POSIX 与 Windows 同义);不作磁盘解析,
|
|
87
|
+
* 所以符号链接意义上的「真实位置」不保证被还原 —— 见文件头的残留边界。
|
|
88
|
+
*/
|
|
89
|
+
function canonicalPathOf(raw) {
|
|
90
|
+
if (typeof raw !== 'string' || raw === '') return null
|
|
91
|
+
const isDrive = /^[A-Za-z]:[\\/]/.test(raw)
|
|
92
|
+
// UNC(`\\server\share…` / `//server/share…`)保留双斜杠前缀:它和「根下的同名目录」不是
|
|
93
|
+
// 一回事,合并成一个前缀会把两个不同的位置判成同一个(那才是误记)。
|
|
94
|
+
const isUnc = !isDrive && (raw.startsWith('//') || raw.startsWith('\\\\'))
|
|
95
|
+
let prefix
|
|
96
|
+
let rest
|
|
97
|
+
if (isDrive) {
|
|
98
|
+
// 盘符统一小写:Windows 上 `C:` 与 `c:` 必然同义;POSIX 上这种形状不是绝对路径,到不了这里。
|
|
99
|
+
prefix = `${raw[0].toLowerCase()}:`
|
|
100
|
+
rest = raw.slice(2)
|
|
101
|
+
} else if (isUnc) {
|
|
102
|
+
prefix = '//'
|
|
103
|
+
rest = raw.slice(2)
|
|
104
|
+
} else if (raw.startsWith('/')) {
|
|
105
|
+
prefix = '/'
|
|
106
|
+
rest = raw.slice(1)
|
|
107
|
+
} else {
|
|
108
|
+
// `\x`(Windows 当前盘的根)、`a/b`(相对 cwd)、`C:foo`(盘符相对)都在这里被拒:
|
|
109
|
+
// 它们的真实落点取决于运行时状态,纯函数看不到,猜一个基准就是误记。
|
|
110
|
+
return null
|
|
111
|
+
}
|
|
112
|
+
const segs = []
|
|
113
|
+
for (const seg of rest.split(/[\\/]+/)) {
|
|
114
|
+
if (seg === '' || seg === '.') continue
|
|
115
|
+
if (seg === '..') {
|
|
116
|
+
if (segs.length) segs.pop()
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
segs.push(seg)
|
|
120
|
+
}
|
|
121
|
+
if (prefix === '/') return segs.length ? `/${segs.join('/')}` : '/'
|
|
122
|
+
if (prefix === '//') return segs.length ? `//${segs.join('/')}` : '//'
|
|
123
|
+
return segs.length ? `${prefix}/${segs.join('/')}` : `${prefix}/`
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 段边界包含:`/mem` 含 `/mem/x.md` 与它自己,**不含** `/memories/x.md`。
|
|
128
|
+
*
|
|
129
|
+
* 判据是「父目录 + 分隔符」,不是裸字符串前缀 —— 裸前缀正是那个经典陷阱。
|
|
130
|
+
*/
|
|
131
|
+
function isSameOrInside(parent, child) {
|
|
132
|
+
if (parent === child) return true
|
|
133
|
+
const boundary = parent.endsWith('/') ? parent : `${parent}/`
|
|
134
|
+
return child.startsWith(boundary)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 规范路径的最后一段(`file` 字段要的是记忆文件名,不是路径)。 */
|
|
138
|
+
function fileNameOf(canon) {
|
|
139
|
+
const cut = canon.lastIndexOf('/')
|
|
140
|
+
return cut < 0 ? canon : canon.slice(cut + 1)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 根目录不算记忆目录。
|
|
145
|
+
*
|
|
146
|
+
* 拿根(`/`、`//`、`X:/`)做包含判定,等于把整块盘都算成记忆 —— 一条配置写错就能把台账
|
|
147
|
+
* 灌满「读了别处」的记录。宁可不判。
|
|
148
|
+
*/
|
|
149
|
+
function isRootLike(canon) {
|
|
150
|
+
return canon === '/' || canon === '//' || /^[a-z]:\/$/.test(canon)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** 结果工厂:保证四个字段恒在,调用方不必到处判 undefined。 */
|
|
154
|
+
function verdict(kind, why) {
|
|
155
|
+
return { kind, file: null, dir: null, why }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** 安全取参:参数不是对象就当作没给;getter 抛错由 classifyToolExecution 的外层兜住。 */
|
|
159
|
+
function argOf(args, key) {
|
|
160
|
+
if (!args || typeof args !== 'object') return undefined
|
|
161
|
+
return args[key]
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** 写工具:是写就不是读,但仍要如实说出「写的是不是记忆文件」。 */
|
|
165
|
+
function classifyWrite(args, memoryDir) {
|
|
166
|
+
const canon = canonicalPathOf(argOf(args, WRITE_PATH_ARG))
|
|
167
|
+
if (!memoryDir) return verdict('write', '写操作;记忆目录不可判定,不判断是否落在记忆里')
|
|
168
|
+
if (!canon) return verdict('write', '写操作;没有可判定的绝对文件路径')
|
|
169
|
+
if (!isSameOrInside(memoryDir, canon)) return verdict('write', '写操作,但目标不在记忆目录内')
|
|
170
|
+
if (!MEMORY_FILE_RE.test(canon)) return verdict('write', '写操作,目标在记忆目录内但不是 .md 记忆条目')
|
|
171
|
+
return { kind: 'write', file: fileNameOf(canon), dir: null, why: '写记忆文件(不是读取)' }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 读工具(`read` / `grep` / `glob`)。
|
|
176
|
+
*
|
|
177
|
+
* `grep` / `glob` 的 `path` 既可能是目录也可能是文件,纯函数不 stat,所以按**值的形状**分:
|
|
178
|
+
* `.md` 结尾当文件(仓库里记忆条目一律 `.md`),否则当目录。`read` 按语义必须落在文件上,
|
|
179
|
+
* 非 `.md` 一律不认。
|
|
180
|
+
*/
|
|
181
|
+
function classifyRead(toolName, args, memoryDir) {
|
|
182
|
+
const argName = READ_TOOLS[toolName]
|
|
183
|
+
const raw = argOf(args, argName)
|
|
184
|
+
if (typeof raw !== 'string' || raw === '') {
|
|
185
|
+
return verdict('other', `没有可用的 ${argName} 字符串,不猜这次读的是哪`)
|
|
186
|
+
}
|
|
187
|
+
const canon = canonicalPathOf(raw)
|
|
188
|
+
if (!canon) {
|
|
189
|
+
return verdict('other', '不是绝对路径(相对路径的基准是运行时的 cwd,纯函数看不到),不猜')
|
|
190
|
+
}
|
|
191
|
+
if (!isSameOrInside(memoryDir, canon)) return verdict('other', '路径不在记忆目录内')
|
|
192
|
+
|
|
193
|
+
if (toolName === 'read') {
|
|
194
|
+
if (!MEMORY_FILE_RE.test(canon)) return verdict('other', '在记忆目录内,但不是 .md 记忆条目')
|
|
195
|
+
return { kind: 'active_read', file: fileNameOf(canon), dir: null, why: '读了记忆目录里的记忆文件' }
|
|
196
|
+
}
|
|
197
|
+
// glob 只列清单(不算读正文);grep 会读正文,所以是 active_read。两者都不刷「读了多少」,
|
|
198
|
+
// 交给台账按文件名去重。
|
|
199
|
+
const kind = toolName === 'glob' ? 'list_only' : 'active_read'
|
|
200
|
+
if (MEMORY_FILE_RE.test(canon)) {
|
|
201
|
+
return {
|
|
202
|
+
kind,
|
|
203
|
+
file: fileNameOf(canon),
|
|
204
|
+
dir: null,
|
|
205
|
+
why: toolName === 'glob' ? '列了记忆目录里的一个文件(只列清单,不算读正文)' : '在记忆目录里按模式检索这个文件',
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
kind,
|
|
210
|
+
file: null,
|
|
211
|
+
dir: canon,
|
|
212
|
+
why: toolName === 'glob' ? '列了记忆目录(只列清单,不算读正文)' : '在记忆目录里按模式检索(读的是记忆正文)',
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* 判定一次工具调用。**纯函数**:不读盘、不看时钟、不抛。
|
|
218
|
+
*
|
|
219
|
+
* @param {string} toolName
|
|
220
|
+
* @param {object} args — 原始 arguments
|
|
221
|
+
* @param {string} memoryDir — 记忆根目录(绝对路径)
|
|
222
|
+
* @returns {{kind:'active_read'|'list_only'|'write'|'unobservable'|'other',
|
|
223
|
+
* file:string|null, dir:string|null, why:string}}
|
|
224
|
+
* `file`(记忆文件名)只在确实指向记忆目录里的一个记忆文件时给出;`dir`(规范绝对路径)只在
|
|
225
|
+
* 其值指向记忆目录里的一个目录(含记忆目录本身)时给出。**指向别处的路径一律不认**
|
|
226
|
+
* (`kind:'other'`)—— 观测层不许把「读了别的东西」记成「读了记忆」。
|
|
227
|
+
*/
|
|
228
|
+
export function classifyToolExecution(toolName, args, memoryDir) {
|
|
229
|
+
try {
|
|
230
|
+
const name = typeof toolName === 'string' ? toolName : ''
|
|
231
|
+
if (!name) return verdict('other', '工具名缺失或不合法,不猜')
|
|
232
|
+
|
|
233
|
+
// 看不见的工具先判:它和参数能不能解析无关 —— 「我们看不见」本身就是结论。
|
|
234
|
+
if (UNOBSERVABLE_TOOLS.includes(name)) {
|
|
235
|
+
return verdict('unobservable', `${name} 的命令行无法可靠认出读了哪个文件 —— 已知观测盲区,不是「没有读取」`)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const dir = canonicalPathOf(memoryDir)
|
|
239
|
+
// 相对 / 空的记忆目录不猜(猜了就是拿一个不知道的基准做包含判定);根目录拒掉的原因见 isRootLike。
|
|
240
|
+
const usableDir = dir && !isRootLike(dir) ? dir : null
|
|
241
|
+
|
|
242
|
+
if (WRITE_TOOLS.includes(name)) return classifyWrite(args, usableDir)
|
|
243
|
+
|
|
244
|
+
if (Object.prototype.hasOwnProperty.call(READ_TOOLS, name)) {
|
|
245
|
+
if (!usableDir) {
|
|
246
|
+
return verdict('other', dir ? '记忆目录是根目录,用它判定会把整块盘算成记忆,不判' : '记忆目录不可判定(不是绝对路径),不判')
|
|
247
|
+
}
|
|
248
|
+
return classifyRead(name, args, usableDir)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return verdict('other', `不认得的工具(${name}),不猜它的参数形状`)
|
|
252
|
+
} catch {
|
|
253
|
+
// 观测层是**顺带**记一笔,绝不许有能力把调用方拖下水:任何意外都退化成「没看见」。
|
|
254
|
+
return verdict('other', '判定过程出错,按不认处理')
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* 去重键:同一 session × 文件 × revision × kind 只算一次。
|
|
260
|
+
*
|
|
261
|
+
* 翻页(`read` 的 `offset` / `limit` 不同)不在这里出现 —— 它们根本不在入参里,所以
|
|
262
|
+
* 「翻页不刷计数」是**结构上**成立的,不靠调用方自觉。换了 revision 就是另一份内容,
|
|
263
|
+
* 要重新计一次。
|
|
264
|
+
*
|
|
265
|
+
* ⚠️ **它不是台账键**:台账 `access.json` 的键是文件名,这个键是四元组。别混用。
|
|
266
|
+
*
|
|
267
|
+
* @returns {string|null} null = 不该记(没有文件,或者不是一次访问事件)
|
|
268
|
+
*/
|
|
269
|
+
export function accessEventKey(input = {}) {
|
|
270
|
+
const a = input && typeof input === 'object' ? input : {}
|
|
271
|
+
const file = typeof a.file === 'string' ? a.file : ''
|
|
272
|
+
if (file === '') return null
|
|
273
|
+
if (!RECORDABLE_KINDS.has(a.kind)) return null
|
|
274
|
+
// 缺 session 时退化成「整个进程一个会话」的去重域:宁可少记重复的,也不因为拿不到 session
|
|
275
|
+
// 就一条都不记。revision 缺失同理(空串),它有值时自然与空串区分开。
|
|
276
|
+
const session = typeof a.sessionId === 'string' ? a.sessionId : ''
|
|
277
|
+
const revision = typeof a.revision === 'string' ? a.revision : ''
|
|
278
|
+
return `${session}\u0000${file}\u0000${revision}\u0000${a.kind}`
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* 已经记过的键的容器(纯内存,调用方持有)。
|
|
283
|
+
*
|
|
284
|
+
* 为什么由调用方持有而不是模块级单例:模块级单例会在热加载 / 测试之间串味,而这个集合的
|
|
285
|
+
* 生命周期本来就该等于「一场会话」。调用方拿同一份实例跑完会话,`clear()` 就能重来。
|
|
286
|
+
*/
|
|
287
|
+
export function createDeduper() {
|
|
288
|
+
const seen = new Set()
|
|
289
|
+
return {
|
|
290
|
+
/** @returns {boolean} true = 这次该记(第一次见到这个键) */
|
|
291
|
+
shouldCount(key) {
|
|
292
|
+
if (typeof key !== 'string' || key === '') return false
|
|
293
|
+
if (seen.has(key)) return false
|
|
294
|
+
seen.add(key)
|
|
295
|
+
return true
|
|
296
|
+
},
|
|
297
|
+
get size() {
|
|
298
|
+
return seen.size
|
|
299
|
+
},
|
|
300
|
+
clear() {
|
|
301
|
+
seen.clear()
|
|
302
|
+
},
|
|
303
|
+
}
|
|
304
|
+
}
|
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.18",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|