sage-mem 2.1.1 → 2.1.3
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 +66 -26
- package/lib/autodream.js +5 -0
- package/lib/client.js +77 -8
- package/lib/index.js +32 -18
- package/lib/memory/write-protocol.js +111 -0
- package/lib/typert.host.js +17 -0
- 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.3`** — 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.3`** —— 顶部 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
|
@@ -489,24 +489,65 @@ export function createToolRunner(opts) {
|
|
|
489
489
|
const readVersions = new Map()
|
|
490
490
|
|
|
491
491
|
/**
|
|
492
|
-
*
|
|
493
|
-
* 也不要拿一个来路不明的版本假装有守卫)。
|
|
492
|
+
* 把「这份内容对应磁盘上的哪个版本」记下来(写回时当守卫)。
|
|
494
493
|
*
|
|
495
|
-
*
|
|
496
|
-
*
|
|
494
|
+
* 取不到可信版本(`version` 为 null)就**什么都不记** —— 宁可让写入走「拒绝」,
|
|
495
|
+
* 也不要拿一个来路不明的版本假装有守卫。
|
|
496
|
+
*
|
|
497
|
+
* ⚠️ 这里只登记、**自己不 stat**。版本由 `readEntryWithVersion` 在**实际读取的前后**
|
|
498
|
+
* 各取一次得到;以前是在 `readFile` **之后**才 stat 两次,读 A、盘上已是 B 时两次 stat
|
|
499
|
+
* 都是 B,于是 A 的正文被绑到 B 的版本上(外部审查 2026-10-31,Q01)。
|
|
497
500
|
*/
|
|
498
|
-
|
|
501
|
+
function rememberReadVersion(fullPath, version, truncated) {
|
|
502
|
+
if (!version) return
|
|
503
|
+
const where = memoryPathOf(fullPath, memoryDir)
|
|
504
|
+
if (where.ok) readVersions.set(where.canon, { version, truncated: truncated === true })
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* 「读内容」与「取版本」共用的**唯一入口**(外部审查 2026-10-31,Q01)。
|
|
509
|
+
*
|
|
510
|
+
* 为什么必须是入口、不是两段调用:写回守卫要回答的是「**这份正文**对应哪一个版本」。
|
|
511
|
+
* 分开写时,调用方手里是 `readFile` 的返回值和两次 `stat` 的结果,把三者拼起来那一步最容易错
|
|
512
|
+
* —— 真实代码曾是 `readFile → stat → stat`:读的是 A,两次 stat 都读到 B,
|
|
513
|
+
* 于是 A 的正文被绑到 B 的版本,宿主的 CAS 即使完全正确也救不了这个错配。
|
|
514
|
+
*
|
|
515
|
+
* 顺序固定 **stat → read → stat**,两次版本**相同**才认这份内容就是那个版本的快照;
|
|
516
|
+
* 任何一次取不到(没有宿主 fs 服务 / stat 抛错 / 版本字段缺失)都按「版本未知」处理,
|
|
517
|
+
* 不拿当前版本补票 —— 补票等于替调用方担保一份它没读过的内容。
|
|
518
|
+
*
|
|
519
|
+
* 宿主 `fs` 契约里目前**没有**「原子 read-with-version」这一原语(`readText` 只回正文),
|
|
520
|
+
* 所以退到两次 stat 夹住实际读取。将来宿主提供这个原语,应当优先在 open 句柄上一次性拿到。
|
|
521
|
+
*
|
|
522
|
+
* @returns {Promise<{ok:false, gate:object}|{ok:true, gate:object, content:string, version:string|null}>}
|
|
523
|
+
* `gate` 是 `inspectMemoryEntry` 的结果(2.1.4 起内容读还用它做身份核对)
|
|
524
|
+
*/
|
|
525
|
+
async function readEntryWithVersion(full) {
|
|
526
|
+
const gate = await inspectMemoryEntry(full)
|
|
527
|
+
if (!gate.ok) return { ok: false, gate }
|
|
528
|
+
const fs = hostFs()
|
|
529
|
+
const hostStat = async () => {
|
|
530
|
+
if (!fs || typeof fs.stat !== 'function') return null
|
|
531
|
+
try {
|
|
532
|
+
const info = await fs.stat(await fs.resolve(full))
|
|
533
|
+
return info?.version ?? null
|
|
534
|
+
} catch {
|
|
535
|
+
return null // 取不到就是未知:不要拿「现在」的版本顶替
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
const before = await hostStat()
|
|
539
|
+
let content
|
|
499
540
|
try {
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
541
|
+
content = await readFile(full, 'utf8')
|
|
542
|
+
} catch (err) {
|
|
543
|
+
// 扫到读之间被删是正常状态;其余读失败照旧抛给上层(外层会转成一次工具失败)。
|
|
544
|
+
if (err?.code === 'ENOENT') return { ok: false, gate: { ok: false, code: 'missing' } }
|
|
545
|
+
throw err
|
|
546
|
+
}
|
|
547
|
+
const after = await hostStat()
|
|
548
|
+
// 两次版本相同才认「内容 = 版本」;只要中间可能被动过就当作未知。
|
|
549
|
+
const version = before && after && before === after ? before : null
|
|
550
|
+
return { ok: true, gate, content, version }
|
|
510
551
|
}
|
|
511
552
|
|
|
512
553
|
/**
|
|
@@ -800,22 +841,21 @@ export function createToolRunner(opts) {
|
|
|
800
841
|
* 过闸门再读:**名字在记忆目录里不等于内容住在记忆目录里**。
|
|
801
842
|
* 注入与面板两条腿都挡住了,这条腿(模型工具)也必须挡 ——
|
|
802
843
|
* 否则外部文件的内容照样会被读进来、送进模型上下文。
|
|
844
|
+
*
|
|
845
|
+
* 内容与版本一起取(`readEntryWithVersion`):守卫要的是「这份正文对应哪个版本」,
|
|
846
|
+
* 分开取会把 A 的正文绑到 B 的版本上(Q01)。
|
|
803
847
|
*/
|
|
804
|
-
const
|
|
805
|
-
if (!
|
|
806
|
-
return gate.code === 'missing'
|
|
848
|
+
const seen = await readEntryWithVersion(full)
|
|
849
|
+
if (!seen.ok) {
|
|
850
|
+
return seen.gate.code === 'missing'
|
|
807
851
|
? `错误:没有这个文件:${basename(full)}`
|
|
808
|
-
: `错误:拒绝读取 ${basename(full)}:${gate.error}`
|
|
852
|
+
: `错误:拒绝读取 ${basename(full)}:${seen.gate.error}`
|
|
809
853
|
}
|
|
810
|
-
const content =
|
|
811
|
-
|
|
812
|
-
throw err
|
|
813
|
-
})
|
|
814
|
-
if (content === null) return `错误:没有这个文件:${basename(full)}`
|
|
815
|
-
// 记下「这份内容对应磁盘上的哪个版本」——写回时用它当守卫(外部复审 R01)。
|
|
854
|
+
const content = seen.content
|
|
855
|
+
// 记下「这份内容对应磁盘上的哪个版本」——写回时用它当守卫(外部复审 R01 / Q01)。
|
|
816
856
|
// 截断的读也要标出来:截断内容不足以作为整份覆盖的依据。
|
|
817
857
|
const truncated = content.length > READ_LIMIT
|
|
818
|
-
|
|
858
|
+
rememberReadVersion(full, seen.version, truncated)
|
|
819
859
|
if (truncated) {
|
|
820
860
|
return `${content.slice(0, READ_LIMIT)}\n\n…(已截断,全文 ${content.length} 字符。注意:截断的读不能作为整份覆盖的依据)`
|
|
821
861
|
}
|
package/lib/autodream.js
CHANGED
|
@@ -6,3 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
export { AutodreamEngine } from './autodream/engine.js'
|
|
9
|
+
// 组合根的本地 import 有 ≤20 条的条数护栏(`test/structure-test.mjs`「依赖面不许发散」)。
|
|
10
|
+
// 体检与默认配置本来就是 autodream 这一域的一部分,从这层转出(本文件的存在意义就是
|
|
11
|
+
// 让外部只认一个入口),组合根因此不必再多两条 import —— 不是给组合根找捷径。
|
|
12
|
+
export { auditMemoryDir } from './autodream/audit.js'
|
|
13
|
+
export { DEFAULT_CONFIG } from './autodream/config.js'
|
package/lib/client.js
CHANGED
|
@@ -1539,8 +1539,8 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
1539
1539
|
// 基元(parseSchema / isStr / isBool / isNum / strSchema)在 05-shared.js。
|
|
1540
1540
|
const fileSchema = parseSchema({ "file": isStr, "type": isStr, "description": isStr, "size": isNum });
|
|
1541
1541
|
const listSchema = { parse(v) { if (!Array.isArray(v)) throw new Error("expected array"); v.forEach((f) => fileSchema.parse(f)); return v; } };
|
|
1542
|
-
const readSchema = parseSchema({ "name": isStr, "content": isStr });
|
|
1543
|
-
const writeSchema = parseSchema({ "ok": isBool, "file": isStr });
|
|
1542
|
+
const readSchema = withOptional(parseSchema({ "name": isStr, "content": isStr }), ["revision"]);
|
|
1543
|
+
const writeSchema = withOptional(parseSchema({ "ok": isBool, "file": isStr }), ["code", "revision"]);
|
|
1544
1544
|
const deleteSchema = parseSchema({ "ok": isBool });
|
|
1545
1545
|
const starmapStarSchema = parseSchema({ "file": isStr, "kind": isStr, "title": isStr, "desc": isStr, "bytes": isNum, "mtimeMs": isNum });
|
|
1546
1546
|
const starmapListSchema = { parse(v) { if (!v || typeof v !== "object") throw new Error("expected object"); if (!Array.isArray(v.stars)) throw new Error("expected stars array"); v.stars.forEach((f) => starmapStarSchema.parse(f)); return v; } };
|
|
@@ -1565,12 +1565,40 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
1565
1565
|
},
|
|
1566
1566
|
};
|
|
1567
1567
|
// 归档文件的读 / 写(B1b):形状与活跃记忆那套的 readSchema / writeSchema 对称。
|
|
1568
|
-
const archivedReadSchema = parseSchema({ "name": isStr, "content": isStr });
|
|
1569
|
-
const archivedWriteSchema = parseSchema({ "ok": isBool, "file": isStr });
|
|
1568
|
+
const archivedReadSchema = withOptional(parseSchema({ "name": isStr, "content": isStr }), ["revision"]);
|
|
1569
|
+
const archivedWriteSchema = withOptional(parseSchema({ "ok": isBool, "file": isStr }), ["code", "revision"]);
|
|
1570
1570
|
// 「彻底删除」的入参:归档代次(`archivedAt`)。它不是装饰 —— 宿主拿它核对
|
|
1571
1571
|
// 「要删的是不是用户确认过的那一版」。老归档件没有留痕时传空串,所以只校类型。
|
|
1572
1572
|
const archiveStampSchema = strSchema;
|
|
1573
1573
|
|
|
1574
|
+
// ── 2.1.2(Q02)写协议:读快照 revision / 写回执的 additive 字段 / 写选项 ──
|
|
1575
|
+
/**
|
|
1576
|
+
* **可选**字段校型:新宿主一定给(读 revision、写 code / revision),旧宿主没有。
|
|
1577
|
+
* 混合版本环境里「旧宿主 + 新面板」必须还能用,所以这里不能当必填字段拒掉;
|
|
1578
|
+
* 但字段给了就必须是字符串 —— 否则面板拿到一个数字或对象会静默走错分支。
|
|
1579
|
+
*/
|
|
1580
|
+
function withOptional(base, keys) {
|
|
1581
|
+
return {
|
|
1582
|
+
parse(v) {
|
|
1583
|
+
base.parse(v);
|
|
1584
|
+
for (let i = 0; i < keys.length; i++) if (keys[i] in v) isStr(v[keys[i]]);
|
|
1585
|
+
return v;
|
|
1586
|
+
},
|
|
1587
|
+
};
|
|
1588
|
+
}
|
|
1589
|
+
/**
|
|
1590
|
+
* 写协议的第三参(`{ expectedRevision?, create? }`)——**两个字段都可选**。
|
|
1591
|
+
* 不传 = 旧调用方,行为与从前一致(宿主会打一条 warn);传了就必须是正确类型。
|
|
1592
|
+
*/
|
|
1593
|
+
const writeOptsSchema = {
|
|
1594
|
+
parse(v) {
|
|
1595
|
+
if (v === null || typeof v !== "object" || Array.isArray(v)) throw new Error("sage-mem: expected object");
|
|
1596
|
+
if ("expectedRevision" in v) isStr(v.expectedRevision);
|
|
1597
|
+
if ("create" in v) isBool(v.create);
|
|
1598
|
+
return v;
|
|
1599
|
+
},
|
|
1600
|
+
};
|
|
1601
|
+
|
|
1574
1602
|
// ── autodream 的 codec ──
|
|
1575
1603
|
// 刻意写得宽:这几个方法返回的是配置 / 状态对象,字段会随版本增删。
|
|
1576
1604
|
// 用 parseSchema 逐字段硬写一遍的代价是 —— host 那边加一个字段,client 的旧
|
|
@@ -1736,6 +1764,8 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
1736
1764
|
parameters: [
|
|
1737
1765
|
{ name: "name", wire: "name", source: "json", codec: { mode: "strict", typeSymbol: "sage-mem/types#FileName", schema: strSchema, create: () => strSchema } },
|
|
1738
1766
|
{ name: "content", wire: "content", source: "json", codec: { mode: "strict", typeSymbol: "sage-mem/types#FileContent", schema: strSchema, create: () => strSchema } },
|
|
1767
|
+
// 2.1.2(Q02):写协议第三参(expectedRevision / create),可选 —— 与宿主 manifest 同一条。
|
|
1768
|
+
{ name: "opts", wire: "opts", source: "json", acceptsUndefined: true, codec: { mode: "strict", typeSymbol: "sage-mem/types#WriteOpts", schema: writeOptsSchema, create: () => writeOptsSchema } },
|
|
1739
1769
|
],
|
|
1740
1770
|
result: { mode: "strict", typeSymbol: "sage-mem/types#WriteResult", schema: writeSchema, create: () => writeSchema },
|
|
1741
1771
|
sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
|
|
@@ -1808,6 +1838,7 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
1808
1838
|
parameters: [
|
|
1809
1839
|
{ name: "name", wire: "name", source: "json", codec: { mode: "strict", typeSymbol: "sage-mem/types#ArchivedFileName", schema: strSchema, create: () => strSchema } },
|
|
1810
1840
|
{ name: "content", wire: "content", source: "json", codec: { mode: "strict", typeSymbol: "sage-mem/types#ArchivedFileContent", schema: strSchema, create: () => strSchema } },
|
|
1841
|
+
{ name: "opts", wire: "opts", source: "json", acceptsUndefined: true, codec: { mode: "strict", typeSymbol: "sage-mem/types#WriteOpts", schema: writeOptsSchema, create: () => writeOptsSchema } },
|
|
1811
1842
|
],
|
|
1812
1843
|
result: { mode: "strict", typeSymbol: "sage-mem/types#ArchivedWriteResult", schema: archivedWriteSchema, create: () => archivedWriteSchema },
|
|
1813
1844
|
sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
|
|
@@ -1932,6 +1963,7 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
1932
1963
|
parameters: [
|
|
1933
1964
|
{ name: "name", wire: "name", source: "json", codec: { mode: "strict", typeSymbol: "sage-mem/types#ReservedFileName", schema: strSchema, create: () => strSchema } },
|
|
1934
1965
|
{ name: "content", wire: "content", source: "json", codec: { mode: "strict", typeSymbol: "sage-mem/types#RawFileContent", schema: strSchema, create: () => strSchema } },
|
|
1966
|
+
{ name: "opts", wire: "opts", source: "json", acceptsUndefined: true, codec: { mode: "strict", typeSymbol: "sage-mem/types#WriteOpts", schema: writeOptsSchema, create: () => writeOptsSchema } },
|
|
1935
1967
|
],
|
|
1936
1968
|
result: { mode: "strict", typeSymbol: "sage-mem/types#RawWriteResult", schema: objCodec, create: () => objCodec },
|
|
1937
1969
|
sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
|
|
@@ -5769,6 +5801,10 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
|
|
|
5769
5801
|
// 打开时展示给用户的类型(判断他是否真的改过类型)
|
|
5770
5802
|
const sFormBaseType = react.useState("");
|
|
5771
5803
|
const formBaseType = sFormBaseType[0], setFormBaseType = sFormBaseType[1];
|
|
5804
|
+
// 2.1.2(Q02):打开编辑时读到的**版本**(readFile 的 revision)。保存时原样带回;
|
|
5805
|
+
// 宿主发现盘上已变就回 stale_revision,编辑框与草稿原地保留,绝不自动重试覆盖。
|
|
5806
|
+
const sFormRevision = react.useState(null);
|
|
5807
|
+
const formRevision = sFormRevision[0], setFormRevision = sFormRevision[1];
|
|
5772
5808
|
const sBusy = react.useState(false);
|
|
5773
5809
|
const busy = sBusy[0], setBusy = sBusy[1];
|
|
5774
5810
|
const sOk = react.useState(false);
|
|
@@ -5797,6 +5833,9 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
|
|
|
5797
5833
|
const archEdit = sArchEdit[0], setArchEdit = sArchEdit[1];
|
|
5798
5834
|
const sArchText = react.useState("");
|
|
5799
5835
|
const archText = sArchText[0], setArchText = sArchText[1];
|
|
5836
|
+
// 2.1.2(Q02):这份归档件读到的版本(保存时带回;冲突时草稿保留)
|
|
5837
|
+
const sArchRevision = react.useState(null);
|
|
5838
|
+
const archRevision = sArchRevision[0], setArchRevision = sArchRevision[1];
|
|
5800
5839
|
const sArchLoad = react.useState(false);
|
|
5801
5840
|
const archLoad = sArchLoad[0], setArchLoad = sArchLoad[1];
|
|
5802
5841
|
const sArchSaving = react.useState(false);
|
|
@@ -5837,6 +5876,9 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
|
|
|
5837
5876
|
const rawText = sRawText[0], setRawText = sRawText[1];
|
|
5838
5877
|
const sRawIsNew = react.useState(false);
|
|
5839
5878
|
const rawIsNew = sRawIsNew[0], setRawIsNew = sRawIsNew[1];
|
|
5879
|
+
// 2.1.2(Q02):这份保留名文件读到的版本;null + rawIsNew = 走 createIfAbsent 新建
|
|
5880
|
+
const sRawRevision = react.useState(null);
|
|
5881
|
+
const rawRevision = sRawRevision[0], setRawRevision = sRawRevision[1];
|
|
5840
5882
|
const sRawBusy = react.useState(false);
|
|
5841
5883
|
const rawBusy = sRawBusy[0], setRawBusy = sRawBusy[1];
|
|
5842
5884
|
const sRawLoading = react.useState(false);
|
|
@@ -5971,6 +6013,7 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
|
|
|
5971
6013
|
function closeForm() {
|
|
5972
6014
|
setFormOpen(false); setEditingFile(null); setFormName(""); setFormDesc(""); setFormBody(""); setFormType("feedback");
|
|
5973
6015
|
setFormFm(null); setFormBaseType(""); setFormTags("");
|
|
6016
|
+
setFormRevision(null);
|
|
5974
6017
|
}
|
|
5975
6018
|
|
|
5976
6019
|
// 空记忆库时「建立索引骨架」写进去的引导文件。它只是给人看的起点:
|
|
@@ -6056,6 +6099,8 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
|
|
|
6056
6099
|
setFormTags(detectTags(parts.fm).join(", "));
|
|
6057
6100
|
// 关键:原 frontmatter 原文留着,保存时「就地合并」而不是重建
|
|
6058
6101
|
setFormFm(parts.hasFm ? parts.fm : null);
|
|
6102
|
+
// 2.1.2(Q02):把这一版的 revision 一起记住(保存时带回;旧宿主没有就是 null)
|
|
6103
|
+
setFormRevision(typeof data.revision === "string" ? data.revision : null);
|
|
6059
6104
|
setFormOpen(true);
|
|
6060
6105
|
})
|
|
6061
6106
|
.catch(function (e) { setError(String((e && e.message) || e)); })
|
|
@@ -6072,6 +6117,7 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
|
|
|
6072
6117
|
setFormBody("");
|
|
6073
6118
|
setFormFm(null);
|
|
6074
6119
|
setFormTags("");
|
|
6120
|
+
setFormRevision(null);
|
|
6075
6121
|
setFormOpen(true);
|
|
6076
6122
|
}
|
|
6077
6123
|
|
|
@@ -6098,9 +6144,16 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
|
|
|
6098
6144
|
tags: tags,
|
|
6099
6145
|
});
|
|
6100
6146
|
}
|
|
6101
|
-
|
|
6147
|
+
// 2.1.2(Q02):编辑已有文件时交回读到的 revision(盘上变了 → 宿主拒绝);
|
|
6148
|
+
// 新建走 createIfAbsent —— 撞上同名文件是**明确报已存在**,绝不覆盖入口。
|
|
6149
|
+
const writeOpts = editingFile
|
|
6150
|
+
? (formRevision ? { expectedRevision: formRevision } : undefined)
|
|
6151
|
+
: { create: true };
|
|
6152
|
+
remote.writeFile(safeName, content, writeOpts)
|
|
6102
6153
|
.then(function (r) {
|
|
6103
6154
|
// host 侧对保留名 / 非字符串内容 / 超限一律返回 { ok: false, error },不再抛异常。
|
|
6155
|
+
// 冲突看**结构化 code**(stale_revision / already_exists),不解析文案:
|
|
6156
|
+
// 编辑框不关、草稿原地保留,由用户看过最新内容后自己决定重新应用 —— 绝不自动重试覆盖。
|
|
6104
6157
|
if (r && r.ok === false) { setError(r.error || t("error.writeRejected")); return; }
|
|
6105
6158
|
closeForm();
|
|
6106
6159
|
setNotice(t(editingFile ? "notice.updated" : "notice.saved", { file: safeName }));
|
|
@@ -6233,11 +6286,14 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
|
|
|
6233
6286
|
setArchDelete(null);
|
|
6234
6287
|
setArchEdit(file);
|
|
6235
6288
|
setArchText("");
|
|
6289
|
+
setArchRevision(null);
|
|
6236
6290
|
setArchLoad(true);
|
|
6237
6291
|
remote.readArchived(file)
|
|
6238
6292
|
.then(function (r) {
|
|
6239
6293
|
const d = unwrap(r);
|
|
6240
6294
|
setArchText(d && typeof d.content === "string" ? d.content : "");
|
|
6295
|
+
// 2.1.2(Q02):记住这一版的 revision(保存时带回)
|
|
6296
|
+
setArchRevision(d && typeof d.revision === "string" ? d.revision : null);
|
|
6241
6297
|
setArchLoad(false);
|
|
6242
6298
|
})
|
|
6243
6299
|
.catch(function (e) {
|
|
@@ -6254,7 +6310,10 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
|
|
|
6254
6310
|
}
|
|
6255
6311
|
setArchSaving(true); setError(null); setNotice("");
|
|
6256
6312
|
// 原样交回:不 trim、不补 frontmatter、不做任何「智能」处理。
|
|
6257
|
-
|
|
6313
|
+
// 2.1.2(Q02):带上读到的 revision;归档件在别处被改过 → 宿主回 stale_revision,
|
|
6314
|
+
// 编辑框与文本原地保留(不自动重试覆盖)。
|
|
6315
|
+
const writeOpts = archRevision ? { expectedRevision: archRevision } : undefined;
|
|
6316
|
+
remote.writeArchived(file, archText, writeOpts)
|
|
6258
6317
|
.then(function (r) {
|
|
6259
6318
|
if (r && r.ok === false) { setError(r.error || t("error.saveRejected")); return; }
|
|
6260
6319
|
setArchEdit(null);
|
|
@@ -6265,7 +6324,7 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
|
|
|
6265
6324
|
.then(function () { setArchSaving(false); });
|
|
6266
6325
|
}
|
|
6267
6326
|
|
|
6268
|
-
function cancelArchEdit() { setArchEdit(null); setArchText(""); }
|
|
6327
|
+
function cancelArchEdit() { setArchEdit(null); setArchText(""); setArchRevision(null); }
|
|
6269
6328
|
|
|
6270
6329
|
// ── 工具面板的三个数据通道 ──────────────────────────────────────
|
|
6271
6330
|
|
|
@@ -6578,11 +6637,14 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
|
|
|
6578
6637
|
setRawName(name);
|
|
6579
6638
|
setRawText("");
|
|
6580
6639
|
setRawIsNew(false);
|
|
6640
|
+
setRawRevision(null);
|
|
6581
6641
|
setRawLoading(true);
|
|
6582
6642
|
remote.readRaw(name)
|
|
6583
6643
|
.then(function (r) {
|
|
6584
6644
|
const d = unwrap(r);
|
|
6585
6645
|
setRawText(d && typeof d.content === "string" ? d.content : "");
|
|
6646
|
+
// 2.1.2(Q02):记住这一版的 revision(保存时带回)
|
|
6647
|
+
setRawRevision(d && typeof d.revision === "string" ? d.revision : null);
|
|
6586
6648
|
setRawLoading(false);
|
|
6587
6649
|
})
|
|
6588
6650
|
.catch(function (e) {
|
|
@@ -6606,11 +6668,18 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
|
|
|
6606
6668
|
if (typeof remote.writeRaw !== "function") { setError(t("error.noWriteRaw")); return; }
|
|
6607
6669
|
setRawBusy(true); setError(null); setNotice("");
|
|
6608
6670
|
// 原样交回:不 trim、不补 frontmatter、不做任何「智能」处理。
|
|
6609
|
-
|
|
6671
|
+
// 2.1.2(Q02):新建走 createIfAbsent(撞名明确失败、不覆盖);编辑带读到的 revision
|
|
6672
|
+
// (盘上变了 → stale_revision,文本原地保留,绝不自动重试覆盖)。
|
|
6673
|
+
const writeOpts = rawIsNew
|
|
6674
|
+
? { create: true }
|
|
6675
|
+
: (rawRevision ? { expectedRevision: rawRevision } : undefined);
|
|
6676
|
+
remote.writeRaw(name, rawText, writeOpts)
|
|
6610
6677
|
.then(function (r) {
|
|
6611
6678
|
if (r && r.ok === false) { setError(r.error || t("error.saveRejected")); return; }
|
|
6612
6679
|
setNotice(t(rawIsNew ? "notice.rawNew" : "notice.saved", { file: name }));
|
|
6613
6680
|
setRawIsNew(false);
|
|
6681
|
+
// 保存成功后把版本换成刚写下的那一版,连续保存不用重读
|
|
6682
|
+
if (r && typeof r.revision === "string") setRawRevision(r.revision);
|
|
6614
6683
|
})
|
|
6615
6684
|
.catch(function (e) { setError(String((e && e.message) || e)); })
|
|
6616
6685
|
.then(function () { setRawBusy(false); });
|
package/lib/index.js
CHANGED
|
@@ -14,9 +14,7 @@
|
|
|
14
14
|
* 旧 worker 版见 git 分支 `sqlite-worker`。
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { AutodreamEngine } from './autodream.js'
|
|
18
|
-
import { auditMemoryDir } from './autodream/audit.js'
|
|
19
|
-
import { DEFAULT_CONFIG } from './autodream/config.js'
|
|
17
|
+
import { AutodreamEngine, DEFAULT_CONFIG, auditMemoryDir } from './autodream.js'
|
|
20
18
|
import { LIMIT_SPECS, MAX_BASELINE, MAX_CHARS_PER_FILE, MAX_FILE_BYTES, MAX_RESULTS, MAX_SESSION_BYTES, MEMORY_DIR, RECALL_DISCIPLINE, RESERVED_FILES, SESSIONS_ROOT, STALE_DAYS, STATE_ROOT } from './memory/config.js'
|
|
21
19
|
import { INDEX_FILE_KEY, SESSION_LOG_KEY, isPlainMdName } from './shared/md-name.js'
|
|
22
20
|
import { parseAliases, parseBaseline, parseBaselinePriority, parseFrontmatter, parseTags } from './memory/frontmatter.js'
|
|
@@ -25,6 +23,7 @@ import { isReserved, nameKey, safeName } from './memory/naming.js'
|
|
|
25
23
|
import { MEMORY_ARCHIVE_DIR, addArchiveMeta, extractTitle, inspectDir, inspectMemoryEntry, readArchivedMeta, readVerified, scanArchived, scanMemoryFiles, scanStars, shadowsReserved } from './memory/scan.js'
|
|
26
24
|
import { listConflicts, patchConflict, readConflict, removeConflict, summarizeConflict } from './memory/conflicts.js'
|
|
27
25
|
import { invalidateMemory, undoMemoryInvalidation } from './memory/arbitrate.js'
|
|
26
|
+
import { readSnapshot, writeSnapshot } from './memory/write-protocol.js'
|
|
28
27
|
import { archiveMemory, deleteArchivedMemory, restoreMemory } from './memory/archive.js'
|
|
29
28
|
import { flushAccess, pickArchiveCandidates, readAccessLedger } from './memory/access.js'
|
|
30
29
|
import { warnLog, writeFileAtomic } from './memory/util.js'
|
|
@@ -131,6 +130,9 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
131
130
|
*
|
|
132
131
|
* 过闸门再读:名字在记忆目录里**不等于**内容住在记忆目录里 —— 符号链接 / 目录联接 /
|
|
133
132
|
* 硬链接都能让一次「读自己的记忆」变成读别人的文件,而读到的内容会被注入模型上下文。
|
|
133
|
+
*
|
|
134
|
+
* 2.1.2(Q02):返回值多一个 `revision`(正文字节的 sha256)。保存时把它带回来,
|
|
135
|
+
* 盘上版本变了就拒绝覆盖(见 `memory/write-protocol.js`)。
|
|
134
136
|
*/
|
|
135
137
|
async readFile(name) {
|
|
136
138
|
const safe = safeName(name)
|
|
@@ -143,8 +145,7 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
143
145
|
}
|
|
144
146
|
const gate = await inspectMemoryEntry(full)
|
|
145
147
|
if (!gate.ok) throw new Error(`sage-mem: refusing to read ${safe}: ${gate.error}`)
|
|
146
|
-
|
|
147
|
-
return { name: safe, content }
|
|
148
|
+
return await readSnapshot(full, gate, safe)
|
|
148
149
|
}
|
|
149
150
|
|
|
150
151
|
/**
|
|
@@ -152,8 +153,16 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
152
153
|
*
|
|
153
154
|
* 三道闸:保留名不可写、内容必须是字符串、体积有上限;落盘走原子写。
|
|
154
155
|
* 拒绝一律返回 { ok: false, error }(不是抛异常)——设置页的调用方按 ok 判定。
|
|
156
|
+
*
|
|
157
|
+
* 2.1.2(Q02):可选第三参 `opts`
|
|
158
|
+
* · `{ expectedRevision }`:读快照时拿到的 revision;盘上对不上 → `{ok:false, code:'stale_revision'}`,
|
|
159
|
+
* 当前内容逐字节不变(面板保留草稿,提示重新应用);
|
|
160
|
+
* · `{ create: true }`:新建语义(createIfAbsent)。撞名 → `{ok:false, code:'already_exists'}`,
|
|
161
|
+
* **绝不**覆盖已有正文;
|
|
162
|
+
* · 不传 `opts`:旧调用方照旧覆盖,但会落一条 warn(additive 契约里只保留兼容,不保留静默)。
|
|
163
|
+
* 成功时回 `revision`(落盘后的新版本),调用方可以直接拿它继续保存。
|
|
155
164
|
*/
|
|
156
|
-
async writeFile(name, content) {
|
|
165
|
+
async writeFile(name, content, opts) {
|
|
157
166
|
// 保留名判定放在最前面、且以 nameKey 归一:'memory.md' / 'MEMORY.MD' /
|
|
158
167
|
// 'Session-Log.md' 在 NTFS 上都是同一个文件,必须一律走 { ok: false } 这条路
|
|
159
168
|
// (设置页按 ok 判定,见 client.js 的 submit())。
|
|
@@ -189,8 +198,7 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
189
198
|
if (!existing.ok && existing.code !== 'missing') {
|
|
190
199
|
return { ok: false, file: safe, error: `sage-mem: refusing to write ${safe}: ${existing.error}` }
|
|
191
200
|
}
|
|
192
|
-
await
|
|
193
|
-
return { ok: true, file: safe }
|
|
201
|
+
return await writeSnapshot(target, content, existing, opts, { ctx: this.ctx, name: safe, kind: 'writeFile' })
|
|
194
202
|
}
|
|
195
203
|
|
|
196
204
|
/** 删除单个记忆文件:保留名一律拒绝(unlink 不可逆)。判定同 writeFile。 */
|
|
@@ -301,8 +309,8 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
301
309
|
if (gate.code === 'missing') throw new Error(`sage-mem: archived file not found: ${safe}`)
|
|
302
310
|
throw new Error(`sage-mem: refusing to read archived ${safe}: ${gate.error}`)
|
|
303
311
|
}
|
|
304
|
-
|
|
305
|
-
return
|
|
312
|
+
// 2.1.2(Q02):归档件与活动记忆同一套读快照协议,回 {name, content, revision}。
|
|
313
|
+
return await readSnapshot(full, gate, safe)
|
|
306
314
|
}
|
|
307
315
|
|
|
308
316
|
/**
|
|
@@ -312,8 +320,11 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
312
320
|
* 额外一条:**盘上那份的归档留痕(`archived_at` / `archived_reason`)不许因为改正文而丢**。
|
|
313
321
|
* 调用方若交回一份连留痕都没有的内容(「重建 frontmatter」式编辑器就是这样),
|
|
314
322
|
* 这里按盘上原来的值补回去 —— 归档凭据不该依赖调用方是否体贴。
|
|
323
|
+
*
|
|
324
|
+
* 2.1.2(Q02):与 `writeFile` 同一套写协议 —— 可选 `opts.expectedRevision`,
|
|
325
|
+
* 归档件在别处被改过就回 `{ok:false, code:'stale_revision'}`,正文逐字节不变。
|
|
315
326
|
*/
|
|
316
|
-
async writeArchived(name, content) {
|
|
327
|
+
async writeArchived(name, content, opts) {
|
|
317
328
|
if (isReserved(name)) {
|
|
318
329
|
return { ok: false, file: nameKey(name), error: `sage-mem: reserved file, edit it with the file tools: ${nameKey(name)}` }
|
|
319
330
|
}
|
|
@@ -349,8 +360,8 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
349
360
|
toWrite = addArchiveMeta(content, onDisk.archivedReason, onDisk.archivedAt)
|
|
350
361
|
}
|
|
351
362
|
}
|
|
352
|
-
|
|
353
|
-
return {
|
|
363
|
+
// 2.1.2(Q02):落盘也走写协议 —— 带 opts 时先比盘上版本,对不上就拒绝(正文逐字节不变)。
|
|
364
|
+
return await writeSnapshot(full, toWrite, gate, opts, { ctx: this.ctx, name: safe, kind: 'writeArchived' })
|
|
354
365
|
}
|
|
355
366
|
|
|
356
367
|
/**
|
|
@@ -549,8 +560,8 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
549
560
|
if (!gate.ok) throw new Error(`sage-mem: refusing to read ${safe}: ${gate.error}`)
|
|
550
561
|
// 读不到就抛(与 readFile 一致):静默返回空串会让面板显示一份「空的索引」,
|
|
551
562
|
// 用户一存就把真索引顶掉。
|
|
552
|
-
|
|
553
|
-
return
|
|
563
|
+
// 2.1.2(Q02):保留名与普通记忆同一套读快照协议 —— 回 {name, content, revision}。
|
|
564
|
+
return await readSnapshot(full, gate, safe)
|
|
554
565
|
}
|
|
555
566
|
|
|
556
567
|
/**
|
|
@@ -558,8 +569,12 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
558
569
|
*
|
|
559
570
|
* 这就是 2026-09-25 那次 P0 数据丢失事故(表单重建 frontmatter 把 baseline /
|
|
560
571
|
* node_type / originSessionId 全抹掉)的解药:索引与流水只许原样进出。
|
|
572
|
+
*
|
|
573
|
+
* 2.1.2(Q02):与 `writeFile` 同一套写协议 —— `opts.expectedRevision` 对不上则
|
|
574
|
+
* `{ok:false, code:'stale_revision'}`(索引逐字节不变);`opts.create` 用于「建立索引骨架」
|
|
575
|
+
* 这类 createIfAbsent 语义(撞名明确报 `already_exists`,绝不覆盖)。
|
|
561
576
|
*/
|
|
562
|
-
async writeRaw(name, content) {
|
|
577
|
+
async writeRaw(name, content, opts) {
|
|
563
578
|
if (!isReserved(name)) {
|
|
564
579
|
return { ok: false, error: `sage-mem: ${String(name)} is not a reserved file; normal memories use the memory form or writeFile` }
|
|
565
580
|
}
|
|
@@ -584,8 +599,7 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
584
599
|
if (!existing.ok && existing.code !== 'missing') {
|
|
585
600
|
return { ok: false, file: safe, error: `sage-mem: refusing to write ${safe}: ${existing.error}` }
|
|
586
601
|
}
|
|
587
|
-
await
|
|
588
|
-
return { ok: true, file: safe }
|
|
602
|
+
return await writeSnapshot(target, content, existing, opts, { ctx: this.ctx, name: safe, kind: 'writeRaw' })
|
|
589
603
|
}
|
|
590
604
|
}
|
|
591
605
|
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sage-mem 记忆层 —— 写协议:**读快照带 revision、整份覆盖带守卫**(2.1.2 / 外部审查 Q02)。
|
|
3
|
+
*
|
|
4
|
+
* 问题:原来的 `readFile` 只交正文、不交「这份正文是哪一版」;`writeFile` / `writeArchived` /
|
|
5
|
+
* `writeRaw` 只管原子替换(不暴露半截内容),**不保证没有别的更新** —— 面板读 A、别处存 B、
|
|
6
|
+
* 面板再把基于 A 的 C 存回去,B 就没了,而两边都报成功。
|
|
7
|
+
*
|
|
8
|
+
* 契约(additive,旧调用方不受影响):
|
|
9
|
+
* · 读:结果多一个 `revision`(正文**字节**的 sha256);
|
|
10
|
+
* · 写:可选 `opts.expectedRevision`;对不上 → `{ok:false, code:'stale_revision'}`,
|
|
11
|
+
* 当前内容一个字节不动;调用方拿新 revision 重读、比较、重新应用。
|
|
12
|
+
* · 写:可选 `opts.create`(createIfAbsent)—— 新建撞名一律 `{ok:false, code:'already_exists'}`,
|
|
13
|
+
* **绝不**走覆盖入口;
|
|
14
|
+
* · 不传 `opts` 的旧调用方照旧覆盖,但会落一条 warn(静默无守卫覆盖不再允许)。
|
|
15
|
+
*
|
|
16
|
+
* 为什么单列一个模块而不是长在 `lib/index.js`:组合根有 ≤1000 行的结构守卫
|
|
17
|
+
* (`test/structure-test.mjs`),而「读版本 → 比版本 → 写」这段顺序是**协议**不是组合根的接线。
|
|
18
|
+
*
|
|
19
|
+
* ⚠️ 边界:这是**内容 hash 守卫**,不是跨进程锁。校验与落盘之间仍有一个极窄窗口
|
|
20
|
+
* (外部编辑器恰在此刻改文件仍会被覆盖);进程内并发由 `createWriteQueue` 那一层负责,
|
|
21
|
+
* 面板这条路上「读快照 → 用户思考 → 保存」的窗口正是 revision 覆盖的那一段。
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { createHash } from 'node:crypto'
|
|
25
|
+
import { readVerified } from './scan.js'
|
|
26
|
+
import { warnLog, writeFileAtomic, writeFileExclusive } from './util.js'
|
|
27
|
+
|
|
28
|
+
/** 冲突码:内容在「读快照」之后被改过。固定字符串,界面与测试都只认它。 */
|
|
29
|
+
export const ERR_STALE_REVISION = 'stale_revision'
|
|
30
|
+
/** 冲突码:`create`(createIfAbsent)撞上已有文件 —— 新建绝不覆盖已有内容。 */
|
|
31
|
+
export const ERR_ALREADY_EXISTS = 'already_exists'
|
|
32
|
+
|
|
33
|
+
/** 内容 revision:字节的 sha256。读与写必须用同一个算法,调用方原样回传。 */
|
|
34
|
+
export function revisionOf(bytes) {
|
|
35
|
+
return createHash('sha256').update(bytes).digest('hex')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 读一份**可信快照**:content 与 revision 在同一次读取里拿到。
|
|
40
|
+
*
|
|
41
|
+
* 调用方不能只拿一半 —— 「正文来自这次读、版本来自另一次读」正是 Q02 的成因。
|
|
42
|
+
*
|
|
43
|
+
* @returns {Promise<{name:string, content:string, revision:string}>}
|
|
44
|
+
*/
|
|
45
|
+
export async function readSnapshot(full, gate, name) {
|
|
46
|
+
const bytes = await readVerified(full, gate, false)
|
|
47
|
+
return { name, content: bytes.toString('utf8'), revision: revisionOf(bytes) }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 旧调用方的兼容提示(一次写入一行,不做去重 —— 谁漏传一眼就能在日志里翻到)。 */
|
|
51
|
+
const LEGACY_WARN = '没有带 expectedRevision,按旧协议直接覆盖(面板必须带;旧调用方仅兼容,建议尽快带上)'
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 整份覆盖写:`create` / `expectedRevision` / 旧调用方三种语义,一个出口。
|
|
55
|
+
*
|
|
56
|
+
* @param {string} target — 完整路径(名字闸门由调用方先过)
|
|
57
|
+
* @param {string} content — 要写的正文
|
|
58
|
+
* @param {object} gate — 调用方刚取到的 `inspectMemoryEntry` 结果(ok 或 code:'missing')
|
|
59
|
+
* @param {object} [opts] — `{ expectedRevision?, create? }`
|
|
60
|
+
* @param {{ctx:object, name:string, kind:string}} meta — 日志与回执用
|
|
61
|
+
* @returns {Promise<{ok:true, file:string, revision:string, created?:boolean}
|
|
62
|
+
* | {ok:false, code:string, file?:string, error:string}>}
|
|
63
|
+
*/
|
|
64
|
+
export async function writeSnapshot(target, content, gate, opts, meta) {
|
|
65
|
+
const o = opts && typeof opts === 'object' ? opts : {}
|
|
66
|
+
const name = meta?.name ?? ''
|
|
67
|
+
const rejected = (code, error) => ({ ok: false, code, file: name, error })
|
|
68
|
+
|
|
69
|
+
// ① createIfAbsent:新建只能「建出来」或「明确报已存在」,没有第三条路。
|
|
70
|
+
if (o.create === true) {
|
|
71
|
+
if (gate?.ok) return rejected(ERR_ALREADY_EXISTS, `sage-mem: ${name} 已经存在,新建不会覆盖它(要改正文请打开它)`)
|
|
72
|
+
try {
|
|
73
|
+
await writeFileExclusive(target, content)
|
|
74
|
+
} catch (err) {
|
|
75
|
+
if (err?.code === 'EEXIST') return rejected(ERR_ALREADY_EXISTS, `sage-mem: ${name} 刚刚被别的写入建出来了,新建没有覆盖它`)
|
|
76
|
+
return rejected('io_error', `sage-mem: 写入 ${name} 失败:${String(err?.message ?? err)}`)
|
|
77
|
+
}
|
|
78
|
+
return { ok: true, file: name, revision: revisionOf(Buffer.from(content, 'utf8')), created: true }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ② 带 expectedRevision:盘上版本与调用方读到的不一致 → 拒绝,内容一个字节不动。
|
|
82
|
+
if (o.expectedRevision !== undefined && o.expectedRevision !== null) {
|
|
83
|
+
if (!gate?.ok) {
|
|
84
|
+
// 读快照时还在、现在没了:这同样是「版本已变」,不能悄悄当成新建写回去。
|
|
85
|
+
return rejected(ERR_STALE_REVISION, `sage-mem: ${name} 已经不在盘上了(读取之后被删除或改名),没有写入`)
|
|
86
|
+
}
|
|
87
|
+
let current
|
|
88
|
+
try {
|
|
89
|
+
current = revisionOf(await readVerified(target, gate, false))
|
|
90
|
+
} catch (err) {
|
|
91
|
+
return rejected(ERR_STALE_REVISION, `sage-mem: 读不到 ${name} 的当前版本(${err?.code ?? err}),没有写入`)
|
|
92
|
+
}
|
|
93
|
+
if (current !== o.expectedRevision) {
|
|
94
|
+
// 错误正文是给用户看的:草稿还在、下一步该做什么,都要说清楚(界面直接显示它)。
|
|
95
|
+
return rejected(
|
|
96
|
+
ERR_STALE_REVISION,
|
|
97
|
+
`sage-mem: ${name} 在你保存之前已经被别的写入改过(当前版本 ${current.slice(0, 12)}…)。你的草稿还在,请先查看最新内容再决定如何重新应用 —— 没有自动覆盖`,
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
} else {
|
|
101
|
+
// ③ 旧调用方:additive 契约允许继续工作,但必须留痕(否则「静默覆盖」永远查不出来)。
|
|
102
|
+
warnLog(meta?.ctx, `${meta?.kind ?? 'write'}(${name}) ${LEGACY_WARN}`)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
await writeFileAtomic(target, content)
|
|
107
|
+
} catch (err) {
|
|
108
|
+
return rejected('io_error', `sage-mem: 写入 ${name} 失败:${String(err?.message ?? err)}`)
|
|
109
|
+
}
|
|
110
|
+
return { ok: true, file: name, revision: revisionOf(Buffer.from(content, 'utf8')) }
|
|
111
|
+
}
|
package/lib/typert.host.js
CHANGED
|
@@ -20,6 +20,10 @@ const fileNameSchema = z.string().min(1).max(255).regex(new RegExp(MD_NAME_PATTE
|
|
|
20
20
|
// 到半兆量级基本是写错了目标(贴了日志/代码进来)。与 lib/index.js 的 MAX_FILE_BYTES 一致。
|
|
21
21
|
const fileContentSchema = z.string().max(MAX_FILE_BYTES)
|
|
22
22
|
|
|
23
|
+
// 2.1.2(Q02):写协议第三参(expectedRevision / create)—— 字段与参数都 optional,旧调用方照旧过;
|
|
24
|
+
// 不声明它的话 strictObject 会把新面板传的 revision 整个拒掉(不是弱化,是完全不通)。
|
|
25
|
+
const writeOptsSchema = z.object({ 'expectedRevision': z.string().max(128).readonly().optional(), 'create': z.boolean().readonly().optional() }).readonly()
|
|
26
|
+
|
|
23
27
|
// autodream 报告引用:比 fileNameSchema 宽——旧版报告列表里的条目带 `dream/` 前缀
|
|
24
28
|
// (新版 `autodream/` 不加前缀只给裸文件名)。这里放行斜杠,真正的目录逃逸防护
|
|
25
29
|
// 在 host 侧 AutodreamEngine.readReport 里(先剥已知前缀、再对 basename 走 safeName)。
|
|
@@ -65,6 +69,8 @@ const listResultSchema = z.array(fileSchema)
|
|
|
65
69
|
const readResultSchema = z.object({
|
|
66
70
|
'name': z.string().readonly(),
|
|
67
71
|
'content': z.string().readonly(),
|
|
72
|
+
// 2.1.2(Q02):读快照的版本令牌(optional —— additive 契约只许加,不许把已有字段改成必填)。
|
|
73
|
+
'revision': z.string().readonly().optional(),
|
|
68
74
|
}).readonly()
|
|
69
75
|
// 写/删失败不再抛异常,走 { ok: false, error } 分支:error 必须声明,否则 strict
|
|
70
76
|
// 校验会把它当未知字段剥掉,设置页只能看到一个没有原因的 ok:false。
|
|
@@ -72,6 +78,8 @@ const writeResultSchema = z.object({
|
|
|
72
78
|
'ok': z.boolean().readonly(),
|
|
73
79
|
'file': z.string().readonly(),
|
|
74
80
|
'error': z.string().readonly().optional(),
|
|
81
|
+
// 2.1.2(Q02):成功回新 revision;冲突回固定错误码(stale_revision / already_exists)。都是 optional。
|
|
82
|
+
'revision': z.string().readonly().optional(), 'code': z.string().readonly().optional(),
|
|
75
83
|
}).readonly()
|
|
76
84
|
const deleteResultSchema = z.object({
|
|
77
85
|
'ok': z.boolean().readonly(),
|
|
@@ -129,6 +137,8 @@ const archiveResultSchema = z.object({
|
|
|
129
137
|
const archivedReadResultSchema = z.object({
|
|
130
138
|
'name': z.string().readonly(),
|
|
131
139
|
'content': z.string().readonly(),
|
|
140
|
+
// 2.1.2(Q02):与活跃记忆同一套读快照协议(optional,同 readResultSchema 的理由)。
|
|
141
|
+
'revision': z.string().readonly().optional(),
|
|
132
142
|
}).readonly()
|
|
133
143
|
// 写归档的结果与 writeResultSchema 同形,但**另立一个类型名**:strict 层要能区分
|
|
134
144
|
// 「写活跃记忆」与「写归档记忆」两条路径,将来两者形状分叉时也不必回头改协议。
|
|
@@ -136,6 +146,7 @@ const archivedWriteResultSchema = z.object({
|
|
|
136
146
|
'ok': z.boolean().readonly(),
|
|
137
147
|
'file': z.string().readonly(),
|
|
138
148
|
'error': z.string().readonly().optional(),
|
|
149
|
+
'revision': z.string().readonly().optional(), 'code': z.string().readonly().optional(),
|
|
139
150
|
}).readonly()
|
|
140
151
|
|
|
141
152
|
// ── 面板 API(v0.9.0 D1):体检 / 归档候选 / 设置 / 保留名原样读写 ──────────────
|
|
@@ -269,11 +280,14 @@ const rawReadResultSchema = z.object({
|
|
|
269
280
|
'content': z.string().readonly().optional(),
|
|
270
281
|
'ok': z.boolean().readonly().optional(),
|
|
271
282
|
'error': z.string().readonly().optional(),
|
|
283
|
+
// 2.1.2(Q02):保留名读快照的版本令牌(optional,同 readResultSchema 的理由)。
|
|
284
|
+
'revision': z.string().readonly().optional(),
|
|
272
285
|
}).readonly()
|
|
273
286
|
const rawWriteResultSchema = z.object({
|
|
274
287
|
'ok': z.boolean().readonly(),
|
|
275
288
|
'file': z.string().readonly().optional(),
|
|
276
289
|
'error': z.string().readonly().optional(),
|
|
290
|
+
'revision': z.string().readonly().optional(), 'code': z.string().readonly().optional(),
|
|
277
291
|
}).readonly()
|
|
278
292
|
// 保留名的体积上限比单条记忆大得多(session-log.md 天然 > 512 KB)—— 4 MB,独立于
|
|
279
293
|
// fileContentSchema,别把两者混用。
|
|
@@ -639,6 +653,7 @@ export const TYPERT = {
|
|
|
639
653
|
parameters: [
|
|
640
654
|
{ name: 'name', wire: 'name', source: 'json', codec: { mode: 'strict', typeSymbol: 'sage-mem/types#FileName', schema: fileNameSchema, create: () => fileNameSchema } },
|
|
641
655
|
{ name: 'content', wire: 'content', source: 'json', codec: { mode: 'strict', typeSymbol: 'sage-mem/types#FileContent', schema: fileContentSchema, create: () => fileContentSchema } },
|
|
656
|
+
{ name: 'opts', wire: 'opts', source: 'json', acceptsUndefined: true, codec: { mode: 'strict', typeSymbol: 'sage-mem/types#WriteOpts', schema: writeOptsSchema, create: () => writeOptsSchema } },
|
|
642
657
|
],
|
|
643
658
|
result: { mode: 'strict', typeSymbol: 'sage-mem/types#WriteResult', schema: writeResultSchema, create: () => writeResultSchema },
|
|
644
659
|
sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
|
|
@@ -715,6 +730,7 @@ export const TYPERT = {
|
|
|
715
730
|
// 正文与「写活跃记忆」共用同一个 512 KB 上限 schema;类型名沿用 #ArchivedFileContent
|
|
716
731
|
// 是为了与「读归档」那一侧对称(活跃记忆那套也是 FileContent 一符两用)。
|
|
717
732
|
{ name: 'content', wire: 'content', source: 'json', codec: { mode: 'strict', typeSymbol: 'sage-mem/types#ArchivedFileContent', schema: fileContentSchema, create: () => fileContentSchema } },
|
|
733
|
+
{ name: 'opts', wire: 'opts', source: 'json', acceptsUndefined: true, codec: { mode: 'strict', typeSymbol: 'sage-mem/types#WriteOpts', schema: writeOptsSchema, create: () => writeOptsSchema } },
|
|
718
734
|
],
|
|
719
735
|
result: { mode: 'strict', typeSymbol: 'sage-mem/types#ArchivedWriteResult', schema: archivedWriteResultSchema, create: () => archivedWriteResultSchema },
|
|
720
736
|
sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
|
|
@@ -835,6 +851,7 @@ export const TYPERT = {
|
|
|
835
851
|
{ name: 'name', wire: 'name', source: 'json', codec: { mode: 'strict', typeSymbol: 'sage-mem/types#ReservedFileName', schema: fileNameSchema, create: () => fileNameSchema } },
|
|
836
852
|
// 4 MB:与 fileContentSchema(512 KB)**不是同一个上限**,见 rawFileContentSchema 的注释。
|
|
837
853
|
{ name: 'content', wire: 'content', source: 'json', codec: { mode: 'strict', typeSymbol: 'sage-mem/types#RawFileContent', schema: rawFileContentSchema, create: () => rawFileContentSchema } },
|
|
854
|
+
{ name: 'opts', wire: 'opts', source: 'json', acceptsUndefined: true, codec: { mode: 'strict', typeSymbol: 'sage-mem/types#WriteOpts', schema: writeOptsSchema, create: () => writeOptsSchema } },
|
|
838
855
|
],
|
|
839
856
|
result: { mode: 'strict', typeSymbol: 'sage-mem/types#RawWriteResult', schema: rawWriteResultSchema, create: () => rawWriteResultSchema },
|
|
840
857
|
sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
|
package/package.json
CHANGED