sage-mem 2.1.8 → 2.1.10
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/client.js +15 -12
- package/lib/index.js +1 -1
- package/lib/memory/arbitrate.js +67 -4
- package/lib/memory/write-protocol.js +6 -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.10`** — 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.10`** —— 顶部 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/client.js
CHANGED
|
@@ -3223,6 +3223,8 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
|
|
|
3223
3223
|
malformed: "conflict.code.malformed",
|
|
3224
3224
|
invalid_transition: "conflict.code.invalidTransition",
|
|
3225
3225
|
stale_version: "conflict.code.staleVersion",
|
|
3226
|
+
// 2.1.9(N01):撤销时发现标记已被后来人重写 —— 不删别人的判断,交给用户比较。
|
|
3227
|
+
stale_decision: "conflict.code.staleDecision",
|
|
3226
3228
|
write_denied: "conflict.code.writeDenied",
|
|
3227
3229
|
unsupported: "conflict.code.unsupported",
|
|
3228
3230
|
io_error: "conflict.code.ioError",
|
|
@@ -3909,6 +3911,7 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
|
|
|
3909
3911
|
"conflict.code.malformed": "这条记录格式损坏:可以删除记录,或手工检查状态目录里的 JSON。",
|
|
3910
3912
|
"conflict.code.invalidTransition": "这条记录的状态已经变了:刷新列表后按当前状态再操作。",
|
|
3911
3913
|
"conflict.code.staleVersion": "记忆文件在你打开之后被改过:先刷新列表、确认最新内容再裁决(本次不会覆盖你的改动)。",
|
|
3914
|
+
"conflict.code.staleDecision": "文件上的失效标记已经被后来人重写过:撤销它等于删掉别人的判断,本次没有改动任何字节。请先比较记录里的 invalid_at / superseded_by 与盘上现值,确认后再决定。",
|
|
3912
3915
|
"conflict.code.writeDenied": "宿主拒绝了写入(只读 / 沙箱策略):把 DSH 的写入权限调宽后重试,或手工在记忆 frontmatter 里加 invalid_at。",
|
|
3913
3916
|
"conflict.code.unsupported": "宿主没有可用的写入接口:升级插件,或手工在记忆 frontmatter 里加 invalid_at。",
|
|
3914
3917
|
"conflict.code.ioError": "读写磁盘失败:稍后重试;记录仍是待裁决状态,不会留下半份文件。",
|
|
@@ -4387,6 +4390,7 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
|
|
|
4387
4390
|
"conflict.code.malformed": "This record is malformed: delete the record, or inspect the JSON in the state directory by hand.",
|
|
4388
4391
|
"conflict.code.invalidTransition": "This record already changed state: refresh the list and act on its current state.",
|
|
4389
4392
|
"conflict.code.staleVersion": "The memory file changed after you opened it: refresh the list and check the latest content before deciding (your changes are not overwritten).",
|
|
4393
|
+
"conflict.code.staleDecision": "The validity marker on disk was rewritten by someone else: undoing it would erase their decision, so nothing was changed. Compare invalid_at / superseded_by in the record with the current values before deciding.",
|
|
4390
4394
|
"conflict.code.writeDenied": "The host refused the write (read-only / sandbox policy): widen DSH write permissions and retry, or add invalid_at to the memory frontmatter by hand.",
|
|
4391
4395
|
"conflict.code.unsupported": "The host has no usable write API: upgrade the plugin, or add invalid_at to the memory frontmatter by hand.",
|
|
4392
4396
|
"conflict.code.ioError": "Disk read/write failed: retry later; the record stays pending and no half-written file is left behind.",
|
|
@@ -6047,23 +6051,22 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
|
|
|
6047
6051
|
function createIndexSkeleton() {
|
|
6048
6052
|
setError(null);
|
|
6049
6053
|
setNotice("");
|
|
6050
|
-
if (!remote || typeof remote.
|
|
6054
|
+
if (!remote || typeof remote.writeRaw !== "function") {
|
|
6051
6055
|
setError(t("error.noRaw"));
|
|
6052
6056
|
return;
|
|
6053
6057
|
}
|
|
6054
6058
|
setBusy(true);
|
|
6055
|
-
//
|
|
6056
|
-
|
|
6059
|
+
// 2.1.10(Q10):只调**一个意图接口**(writeRaw 的 createIfAbsent):
|
|
6060
|
+
// 服务端负责「全新安装、memory 目录还不存在」时先建父目录;
|
|
6061
|
+
// 已有索引 → 明确回 already_exists(逐字节不动)。绝不再「先读一次、读失败就当
|
|
6062
|
+
// 可以覆盖」—— 读失败也可能是权限错误,而那正是会顶掉一份真索引的路。
|
|
6063
|
+
remote.writeRaw("MEMORY.md", INDEX_SKELETON, { create: true }).then(function (res) {
|
|
6064
|
+
const out = unwrap(res);
|
|
6057
6065
|
setBusy(false);
|
|
6058
|
-
setNotice(t("notice.indexExists"));
|
|
6059
|
-
|
|
6060
|
-
|
|
6061
|
-
|
|
6062
|
-
setBusy(false);
|
|
6063
|
-
if (out && out.ok === false) { setError(out.error || t("error.skeletonFailed")); return; }
|
|
6064
|
-
setNotice(t("notice.indexCreated"));
|
|
6065
|
-
load();
|
|
6066
|
-
});
|
|
6066
|
+
if (out && out.code === "already_exists") { setNotice(t("notice.indexExists")); return; }
|
|
6067
|
+
if (out && out.ok === false) { setError(out.error || t("error.skeletonFailed")); return; }
|
|
6068
|
+
setNotice(t("notice.indexCreated"));
|
|
6069
|
+
load();
|
|
6067
6070
|
}).catch(function (e) {
|
|
6068
6071
|
setBusy(false);
|
|
6069
6072
|
setError(String((e && e.message) || e));
|
package/lib/index.js
CHANGED
|
@@ -825,7 +825,7 @@ export class ConflictGateway extends TypertRemoteService {
|
|
|
825
825
|
return { ok: false, code: 'invalid_transition', error: '这条记录没有标过失效,没有可撤销的标记' }
|
|
826
826
|
}
|
|
827
827
|
const target = record.invalidatedFile || record.file
|
|
828
|
-
const cleared = await undoMemoryInvalidation({ ctx: this.ctx, memoryDir: MEMORY_DIR }, target)
|
|
828
|
+
const cleared = await undoMemoryInvalidation({ ctx: this.ctx, memoryDir: MEMORY_DIR }, target, record.decidedAt ? { expectedInvalidAt: record.decidedAt, expectedSupersededBy: target === record.file ? record.otherFile : record.file } : undefined)
|
|
829
829
|
if (!cleared.ok) return { ok: false, code: cleared.code, error: cleared.error }
|
|
830
830
|
|
|
831
831
|
const patched = await patchConflict(
|
package/lib/memory/arbitrate.js
CHANGED
|
@@ -36,6 +36,12 @@ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/
|
|
|
36
36
|
/** 失效标记的两行(只认顶层键:`metadata:` 嵌套块里同名的键不是我们的)。 */
|
|
37
37
|
const VALIDITY_LINE_RE = /^(?:invalid_at|superseded_by):/
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* 2.1.9(外部审查 N01):撤销时发现「文件上的标记已经不是这条记录写的那份」。
|
|
41
|
+
* 固定字符串,界面与测试都只认它(不解析错误文案)。
|
|
42
|
+
*/
|
|
43
|
+
export const ERR_STALE_DECISION = 'stale_decision'
|
|
44
|
+
|
|
39
45
|
function eolOf(text) {
|
|
40
46
|
const src = String(text ?? '')
|
|
41
47
|
const i = src.indexOf('\r\n')
|
|
@@ -97,6 +103,36 @@ export function removeValidityLines(content) {
|
|
|
97
103
|
return { ok: true, content: next, changed: next !== content }
|
|
98
104
|
}
|
|
99
105
|
|
|
106
|
+
/** 裸值 / 单引号 / 双引号(`yamlScalar` 会按需加引号)→ 原始字符串。 */
|
|
107
|
+
function unquoteScalar(raw) {
|
|
108
|
+
const s = String(raw ?? '').trim()
|
|
109
|
+
if (s.length >= 2 && s[0] === "'" && s[s.length - 1] === "'") return s.slice(1, -1).replace(/''/g, "'")
|
|
110
|
+
if (s.length >= 2 && s[0] === '"' && s[s.length - 1] === '"') return s.slice(1, -1)
|
|
111
|
+
return s
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 读文件**当前**的失效标记(只认顶层两行)。没有 `invalid_at` 就返回 null。
|
|
116
|
+
*
|
|
117
|
+
* 2.1.9(N01):撤销要先知道「现在文件上写的是哪一次判断」。读回来的值可能是带引号的
|
|
118
|
+
* 形式(`yamlScalar` 对含 `: ` 之类的值会加引号),所以这里统一去引号后再比对。
|
|
119
|
+
*
|
|
120
|
+
* @returns {{invalidAt:string, supersededBy:string} | null}
|
|
121
|
+
*/
|
|
122
|
+
export function readValidityMarker(content) {
|
|
123
|
+
if (typeof content !== 'string') return null
|
|
124
|
+
const m = content.match(FRONTMATTER_RE)
|
|
125
|
+
if (!m) return null
|
|
126
|
+
const marker = { invalidAt: '', supersededBy: '' }
|
|
127
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
128
|
+
const hit = line.match(/^(invalid_at|superseded_by)\s*:\s*(.*)$/)
|
|
129
|
+
if (!hit) continue
|
|
130
|
+
if (hit[1] === 'invalid_at') marker.invalidAt = unquoteScalar(hit[2])
|
|
131
|
+
else marker.supersededBy = unquoteScalar(hit[2])
|
|
132
|
+
}
|
|
133
|
+
return marker.invalidAt ? marker : null
|
|
134
|
+
}
|
|
135
|
+
|
|
100
136
|
/** 从宿主的 `fs` 服务取版本(拿不到就是 undefined —— 那就**不写**,不拿「现在」冒充)。 */
|
|
101
137
|
function versionOf(info) {
|
|
102
138
|
return info && info.version !== undefined && info.version !== null ? info.version : undefined
|
|
@@ -224,17 +260,44 @@ async function invalidateMemoryLocked({ ctx, memoryDir }, input) {
|
|
|
224
260
|
/**
|
|
225
261
|
* 撤销失效:删掉 `invalid_at` / `superseded_by` 两行,其余字节不动。
|
|
226
262
|
*
|
|
263
|
+
* 2.1.9(N01):可选第三参 `opts` 是**这次撤销的操作身份**(来自冲突记录:
|
|
264
|
+
* `expectedInvalidAt = record.decidedAt`,`expectedSupersededBy` = 记录里的另一方)。
|
|
265
|
+
* 文件当前标记对不上 → `stale_decision`,**一个字节都不删**:后来人改过标记,那已经是
|
|
266
|
+
* 别人的判断,撤销它等于把别人的决定抹掉。正文变化不参与比对 —— 撤销只删两行标记,
|
|
267
|
+
* 正文照旧保留。
|
|
268
|
+
*
|
|
269
|
+
* @param {object} deps `{ctx, memoryDir}`
|
|
270
|
+
* @param {string} file
|
|
271
|
+
* @param {{expectedInvalidAt?:string, expectedSupersededBy?:string}} [opts]
|
|
227
272
|
* @returns {Promise<{ok:true, file:string, cleared:boolean, version:string}
|
|
228
|
-
* | {ok:false, code:'invalid'|'missing'|'stale_version'|'write_denied'|'unsupported'|'io_error', error:string}>}
|
|
273
|
+
* | {ok:false, code:'invalid'|'missing'|'stale_version'|'stale_decision'|'write_denied'|'unsupported'|'io_error', error:string}>}
|
|
229
274
|
*/
|
|
230
|
-
export async function undoMemoryInvalidation({ ctx, memoryDir }, file) {
|
|
275
|
+
export async function undoMemoryInvalidation({ ctx, memoryDir }, file, opts) {
|
|
231
276
|
// 2.1.8(N02):与 invalidateMemory 同一条文件级串行(撤销和标记可能同时发生)。
|
|
232
|
-
return withFileLock(String(safeName(file) ?? ''), () => undoMemoryInvalidationLocked({ ctx, memoryDir }, file))
|
|
277
|
+
return withFileLock(String(safeName(file) ?? ''), () => undoMemoryInvalidationLocked({ ctx, memoryDir }, file, opts))
|
|
233
278
|
}
|
|
234
279
|
|
|
235
|
-
async function undoMemoryInvalidationLocked({ ctx, memoryDir }, file) {
|
|
280
|
+
async function undoMemoryInvalidationLocked({ ctx, memoryDir }, file, opts) {
|
|
236
281
|
const read = await readForWrite(ctx, memoryDir, file)
|
|
237
282
|
if (!read.ok) return read
|
|
283
|
+
// 2.1.9(N01):先确认「现在文件上的标记仍是这条记录写的那一份」,再决定删不删。
|
|
284
|
+
const expect = opts && typeof opts === 'object' ? opts : null
|
|
285
|
+
const expectedInvalidAt = typeof expect?.expectedInvalidAt === 'string' ? expect.expectedInvalidAt : ''
|
|
286
|
+
if (expectedInvalidAt) {
|
|
287
|
+
const marker = readValidityMarker(read.content)
|
|
288
|
+
if (!marker) {
|
|
289
|
+
// 标记已经不在了(可能已撤销过 / 被别的写入清掉):不删任何东西,幂等返回。
|
|
290
|
+
return { ok: true, file: safeName(file), cleared: false, version: String(read.version ?? '') }
|
|
291
|
+
}
|
|
292
|
+
const expectedSupersededBy = typeof expect?.expectedSupersededBy === 'string' ? expect.expectedSupersededBy : ''
|
|
293
|
+
if (marker.invalidAt !== expectedInvalidAt || marker.supersededBy !== expectedSupersededBy) {
|
|
294
|
+
return {
|
|
295
|
+
ok: false,
|
|
296
|
+
code: ERR_STALE_DECISION,
|
|
297
|
+
error: `文件上的失效标记已不是这条记录写下的那一份(记录:invalid_at=${expectedInvalidAt} / superseded_by=${expectedSupersededBy};盘上:invalid_at=${marker.invalidAt} / superseded_by=${marker.supersededBy})—— 没有删除别人的判断,请先比较两边内容`,
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
238
301
|
const built = removeValidityLines(read.content)
|
|
239
302
|
if (!built.ok) return built
|
|
240
303
|
if (!built.changed) {
|
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import { createHash } from 'node:crypto'
|
|
25
|
+
import { mkdir } from 'node:fs/promises'
|
|
26
|
+
import { dirname } from 'node:path'
|
|
25
27
|
import { readVerified } from './scan.js'
|
|
26
28
|
import { warnLog, writeFileAtomic, writeFileExclusive } from './util.js'
|
|
27
29
|
|
|
@@ -70,6 +72,10 @@ export async function writeSnapshot(target, content, gate, opts, meta) {
|
|
|
70
72
|
if (o.create === true) {
|
|
71
73
|
if (gate?.ok) return rejected(ERR_ALREADY_EXISTS, `sage-mem: ${name} 已经存在,新建不会覆盖它(要改正文请打开它)`)
|
|
72
74
|
try {
|
|
75
|
+
// 2.1.10(Q10):createIfAbsent 也负责「全新安装、父目录还不存在」这一种:
|
|
76
|
+
// writeFileExclusive 的临时文件落在同一目录,目录不在就会 ENOENT(审查实测)。
|
|
77
|
+
// 只建父目录、绝不动目标文件 —— 「建立索引骨架」这个意图由调用方一次说清。
|
|
78
|
+
await mkdir(dirname(target), { recursive: true })
|
|
73
79
|
await writeFileExclusive(target, content)
|
|
74
80
|
} catch (err) {
|
|
75
81
|
if (err?.code === 'EEXIST') return rejected(ERR_ALREADY_EXISTS, `sage-mem: ${name} 刚刚被别的写入建出来了,新建没有覆盖它`)
|
package/package.json
CHANGED