sage-mem 2.1.2 → 2.1.4
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 +1 -1
- package/README.zh.md +1 -1
- package/lib/autodream/tools.js +78 -29
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
**Cross-session memory for DeepSeek Harness — plain markdown files, no database, no worker.**
|
|
14
14
|
|
|
15
|
-
**Current release: `2.1.
|
|
15
|
+
**Current release: `2.1.4`** — the npm badge above is the dynamic source of truth. **Contract baseline: `1.0.0`** — the first stable release, whose public commitments, the capability boundaries and the list of things it explicitly cannot do are frozen in the
|
|
16
16
|
[frozen contract](https://github.com/gezi-wen/sage-mem/blob/main/docs/1.0.0-contract.md).
|
|
17
17
|
|
|
18
18
|
If it is useful to you, a Star is the most direct feedback.
|
package/README.zh.md
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
> File-based cross-session memory for DeepSeek Harness — every memory is a plain markdown file you can open, edit and delete. No database, no worker, no port.
|
|
16
16
|
|
|
17
|
-
**当前发布版本:`2.1.
|
|
17
|
+
**当前发布版本:`2.1.4`** —— 顶部 npm 徽章是动态权威。**契约基线:`1.0.0`** —— 第一个稳定版,公开承诺、能力边界与「明确做不到」的清单冻结在
|
|
18
18
|
[契约冻结](https://github.com/gezi-wen/sage-mem/blob/main/docs/1.0.0-contract.md)。
|
|
19
19
|
|
|
20
20
|
如果它对你有用,给个 Star 是最直接的反馈。
|
package/lib/autodream/tools.js
CHANGED
|
@@ -36,7 +36,7 @@ import { writeFileAtomic, warnLog } from '../memory/util.js'
|
|
|
36
36
|
import { buildConflict, listConflicts, readConflict, sameConflict, writeConflict } from '../memory/conflicts.js'
|
|
37
37
|
import { memoryPathOf } from '../memory/paths.js'
|
|
38
38
|
import { decideWriteIntent } from '../memory/write-gate.js'
|
|
39
|
-
import { inspectMemoryEntry, reservedIdentitySet } from '../memory/scan.js'
|
|
39
|
+
import { inspectMemoryEntry, readVerified, reservedIdentitySet } from '../memory/scan.js'
|
|
40
40
|
|
|
41
41
|
/** 单次 read_memory 返回的正文上限(字符)。超过就截断并明确告诉模型。 */
|
|
42
42
|
const READ_LIMIT = 20000
|
|
@@ -121,7 +121,8 @@ async function scanEntries(memoryDir, ctx) {
|
|
|
121
121
|
continue
|
|
122
122
|
}
|
|
123
123
|
try {
|
|
124
|
-
|
|
124
|
+
// 同上:清单也要从闸门检查过的那个句柄读(N03 —— 检查后换文件不能把外部标题带进来)。
|
|
125
|
+
const content = await readVerified(full, gate, 'utf8')
|
|
125
126
|
const meta = parseFrontmatterLite(content)
|
|
126
127
|
const body = content.replace(/^---\r?\n[\s\S]*?\r?\n---/, '')
|
|
127
128
|
const h1 = body.match(/^#\s+(.+)$/m)
|
|
@@ -489,24 +490,72 @@ export function createToolRunner(opts) {
|
|
|
489
490
|
const readVersions = new Map()
|
|
490
491
|
|
|
491
492
|
/**
|
|
492
|
-
*
|
|
493
|
-
* 也不要拿一个来路不明的版本假装有守卫)。
|
|
493
|
+
* 把「这份内容对应磁盘上的哪个版本」记下来(写回时当守卫)。
|
|
494
494
|
*
|
|
495
|
-
*
|
|
496
|
-
*
|
|
495
|
+
* 取不到可信版本(`version` 为 null)就**什么都不记** —— 宁可让写入走「拒绝」,
|
|
496
|
+
* 也不要拿一个来路不明的版本假装有守卫。
|
|
497
|
+
*
|
|
498
|
+
* ⚠️ 这里只登记、**自己不 stat**。版本由 `readEntryWithVersion` 在**实际读取的前后**
|
|
499
|
+
* 各取一次得到;以前是在 `readFile` **之后**才 stat 两次,读 A、盘上已是 B 时两次 stat
|
|
500
|
+
* 都是 B,于是 A 的正文被绑到 B 的版本上(外部审查 2026-10-31,Q01)。
|
|
501
|
+
*/
|
|
502
|
+
function rememberReadVersion(fullPath, version, truncated) {
|
|
503
|
+
if (!version) return
|
|
504
|
+
const where = memoryPathOf(fullPath, memoryDir)
|
|
505
|
+
if (where.ok) readVersions.set(where.canon, { version, truncated: truncated === true })
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* 「读内容」与「取版本」共用的**唯一入口**(外部审查 2026-10-31,Q01)。
|
|
510
|
+
*
|
|
511
|
+
* 为什么必须是入口、不是两段调用:写回守卫要回答的是「**这份正文**对应哪一个版本」。
|
|
512
|
+
* 分开写时,调用方手里是 `readFile` 的返回值和两次 `stat` 的结果,把三者拼起来那一步最容易错
|
|
513
|
+
* —— 真实代码曾是 `readFile → stat → stat`:读的是 A,两次 stat 都读到 B,
|
|
514
|
+
* 于是 A 的正文被绑到 B 的版本,宿主的 CAS 即使完全正确也救不了这个错配。
|
|
515
|
+
*
|
|
516
|
+
* 顺序固定 **stat → read → stat**,两次版本**相同**才认这份内容就是那个版本的快照;
|
|
517
|
+
* 任何一次取不到(没有宿主 fs 服务 / stat 抛错 / 版本字段缺失)都按「版本未知」处理,
|
|
518
|
+
* 不拿当前版本补票 —— 补票等于替调用方担保一份它没读过的内容。
|
|
519
|
+
*
|
|
520
|
+
* 宿主 `fs` 契约里目前**没有**「原子 read-with-version」这一原语(`readText` 只回正文),
|
|
521
|
+
* 所以退到两次 stat 夹住实际读取。将来宿主提供这个原语,应当优先在 open 句柄上一次性拿到。
|
|
522
|
+
*
|
|
523
|
+
* @returns {Promise<{ok:false, gate:object}|{ok:true, gate:object, content:string, version:string|null}>}
|
|
524
|
+
* `gate` 是 `inspectMemoryEntry` 的结果(2.1.4 起内容读还用它做身份核对)
|
|
497
525
|
*/
|
|
498
|
-
async function
|
|
526
|
+
async function readEntryWithVersion(full) {
|
|
527
|
+
const gate = await inspectMemoryEntry(full)
|
|
528
|
+
if (!gate.ok) return { ok: false, gate }
|
|
529
|
+
const fs = hostFs()
|
|
530
|
+
const hostStat = async () => {
|
|
531
|
+
if (!fs || typeof fs.stat !== 'function') return null
|
|
532
|
+
try {
|
|
533
|
+
const info = await fs.stat(await fs.resolve(full))
|
|
534
|
+
return info?.version ?? null
|
|
535
|
+
} catch {
|
|
536
|
+
return null // 取不到就是未知:不要拿「现在」的版本顶替
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
const before = await hostStat()
|
|
540
|
+
let content
|
|
499
541
|
try {
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
542
|
+
/**
|
|
543
|
+
* 内容走**公共句柄读取入口**(外部审查 2026-10-31,N03):`inspectMemoryEntry` 是按路径
|
|
544
|
+
* `lstat`,`readFile(路径)` 是另一次按路径打开 —— 两次之间把普通文件换成符号链接 / 另一份
|
|
545
|
+
* 文件,读的就是没检查过的东西(审查用真符号链接复现出目录外内容)。`readVerified`
|
|
546
|
+
* 改成 open 句柄 + fstat 比对 `dev:ino` / 大小,只从那个句柄读;体积上限也在全文读入
|
|
547
|
+
* **之前**生效,不会先整份读进内存再判。
|
|
548
|
+
*/
|
|
549
|
+
content = await readVerified(full, gate, 'utf8')
|
|
550
|
+
} catch (err) {
|
|
551
|
+
// 扫到读之间被删是正常状态;其余读失败(含「检查后被换掉」)照旧抛给上层。
|
|
552
|
+
if (err?.code === 'ENOENT') return { ok: false, gate: { ok: false, code: 'missing' } }
|
|
553
|
+
throw err
|
|
554
|
+
}
|
|
555
|
+
const after = await hostStat()
|
|
556
|
+
// 两次版本相同才认「内容 = 版本」;只要中间可能被动过就当作未知。
|
|
557
|
+
const version = before && after && before === after ? before : null
|
|
558
|
+
return { ok: true, gate, content, version }
|
|
510
559
|
}
|
|
511
560
|
|
|
512
561
|
/**
|
|
@@ -800,22 +849,21 @@ export function createToolRunner(opts) {
|
|
|
800
849
|
* 过闸门再读:**名字在记忆目录里不等于内容住在记忆目录里**。
|
|
801
850
|
* 注入与面板两条腿都挡住了,这条腿(模型工具)也必须挡 ——
|
|
802
851
|
* 否则外部文件的内容照样会被读进来、送进模型上下文。
|
|
852
|
+
*
|
|
853
|
+
* 内容与版本一起取(`readEntryWithVersion`):守卫要的是「这份正文对应哪个版本」,
|
|
854
|
+
* 分开取会把 A 的正文绑到 B 的版本上(Q01)。
|
|
803
855
|
*/
|
|
804
|
-
const
|
|
805
|
-
if (!
|
|
806
|
-
return gate.code === 'missing'
|
|
856
|
+
const seen = await readEntryWithVersion(full)
|
|
857
|
+
if (!seen.ok) {
|
|
858
|
+
return seen.gate.code === 'missing'
|
|
807
859
|
? `错误:没有这个文件:${basename(full)}`
|
|
808
|
-
: `错误:拒绝读取 ${basename(full)}:${gate.error}`
|
|
860
|
+
: `错误:拒绝读取 ${basename(full)}:${seen.gate.error}`
|
|
809
861
|
}
|
|
810
|
-
const content =
|
|
811
|
-
|
|
812
|
-
throw err
|
|
813
|
-
})
|
|
814
|
-
if (content === null) return `错误:没有这个文件:${basename(full)}`
|
|
815
|
-
// 记下「这份内容对应磁盘上的哪个版本」——写回时用它当守卫(外部复审 R01)。
|
|
862
|
+
const content = seen.content
|
|
863
|
+
// 记下「这份内容对应磁盘上的哪个版本」——写回时用它当守卫(外部复审 R01 / Q01)。
|
|
816
864
|
// 截断的读也要标出来:截断内容不足以作为整份覆盖的依据。
|
|
817
865
|
const truncated = content.length > READ_LIMIT
|
|
818
|
-
|
|
866
|
+
rememberReadVersion(full, seen.version, truncated)
|
|
819
867
|
if (truncated) {
|
|
820
868
|
return `${content.slice(0, READ_LIMIT)}\n\n…(已截断,全文 ${content.length} 字符。注意:截断的读不能作为整份覆盖的依据)`
|
|
821
869
|
}
|
|
@@ -942,10 +990,11 @@ export function createToolRunner(opts) {
|
|
|
942
990
|
return '错误:report_contradiction 必须给 note —— 一句话说明这两条到底哪里互斥。'
|
|
943
991
|
}
|
|
944
992
|
// 两份正文都存进记录:面板上人要对照着判,只留一边等于没得判。
|
|
993
|
+
// 同样走句柄读取入口(N03):记录里的正文会被人和模型看到,不能来自目录外。
|
|
945
994
|
const readBody = async (full) => {
|
|
946
995
|
const gate = await inspectMemoryEntry(full)
|
|
947
996
|
if (!gate.ok) return null
|
|
948
|
-
return await
|
|
997
|
+
return await readVerified(full, gate, 'utf8').catch(() => null)
|
|
949
998
|
}
|
|
950
999
|
const onDisk = await readBody(oldFull)
|
|
951
1000
|
const incoming = await readBody(newFull)
|
package/package.json
CHANGED