sage-mem 2.2.0 → 2.2.1
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/engine.js +5 -5
- package/lib/client.js +11 -1
- package/lib/index.js +61 -105
- package/lib/memory/access.js +10 -3
- package/lib/memory/write-protocol.js +104 -1
- 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.2.
|
|
15
|
+
**Current release: `2.2.1`** — 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.2.
|
|
17
|
+
**当前发布版本:`2.2.1`** —— 顶部 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/engine.js
CHANGED
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
|
|
36
36
|
import { auditMemoryDir } from './audit.js'
|
|
37
37
|
import { autodreamToolSchemas, createToolRunner } from './tools.js'
|
|
38
|
+
import { warnLog } from '../memory/util.js'
|
|
38
39
|
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
40
|
import { countSessionsSince, newRunId, stampHuman } from './util.js'
|
|
40
41
|
import { flushAccess, ledgerFileFor, pickArchiveCandidates, readAccessLedger } from '../memory/access.js'
|
|
@@ -131,11 +132,10 @@ export class AutodreamEngine {
|
|
|
131
132
|
}
|
|
132
133
|
|
|
133
134
|
log(level, msg) {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
else console.warn(line)
|
|
135
|
+
// 2.2.1:统一走 warnLog —— 前缀 `sage-mem: `,子系统名进正文。原来这里手搓了第二套
|
|
136
|
+
// logger(`sage-mem autodream: ` 前缀 + 自己的 fallback),与 util.js「所有失败都要落到
|
|
137
|
+
// 日志」那条规则重复,而且日志里因此出现两种前缀。
|
|
138
|
+
warnLog(this.ctx, `autodream${level === 'error' ? ' [error]' : ''}: ${msg}`)
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
// ────────────────────────────── 配置与状态 ──────────────────────────────
|
package/lib/client.js
CHANGED
|
@@ -113,6 +113,11 @@ window.__ModuleLoader__.load({
|
|
|
113
113
|
* 那种情况下**不能**把目标当成「不存在」去覆盖写(2.1.10 / Q10 的教训)。
|
|
114
114
|
*/
|
|
115
115
|
function isNotFoundError(error) {
|
|
116
|
+
// 2.2.1:**优先看结构化码** —— 宿主读方法现在抛带 `.code` 的 Error
|
|
117
|
+
// (not_found / invalid_name / reserved / io_error…)。文案正则只留兜底:
|
|
118
|
+
// 换一句措辞就会让降级链走错边,那是 2026-11-01 真机踩过的形状。
|
|
119
|
+
const code = error && error.code;
|
|
120
|
+
if (code === "not_found" || code === "missing" || code === "ENOENT") return true;
|
|
116
121
|
return /ENOENT|no such file|not exist/i.test(String((error && error.message) || error));
|
|
117
122
|
}
|
|
118
123
|
|
|
@@ -137,7 +142,12 @@ window.__ModuleLoader__.load({
|
|
|
137
142
|
throw error;
|
|
138
143
|
};
|
|
139
144
|
return call.then(function (result) {
|
|
140
|
-
if (result && result.ok === false
|
|
145
|
+
if (result && result.ok === false) {
|
|
146
|
+
// 2.2.1:结构化码在**信封外层**(RemoteResult 的 `{ok:false, code, error}`)——
|
|
147
|
+
// 只看 error 里的文案会漏掉这一半形态。
|
|
148
|
+
if (result.code === "not_found" || result.code === "missing" || result.code === "ENOENT") return false;
|
|
149
|
+
if (result.error) return classify(result.error);
|
|
150
|
+
}
|
|
141
151
|
return true;
|
|
142
152
|
}, classify);
|
|
143
153
|
}
|
package/lib/index.js
CHANGED
|
@@ -15,7 +15,7 @@ import { isReserved, nameKey, safeName } from './memory/naming.js'
|
|
|
15
15
|
import { MEMORY_ARCHIVE_DIR, addArchiveMeta, extractTitle, inspectDir, inspectMemoryEntry, readArchivedMeta, readVerified, scanArchived, scanMemoryFiles, scanStars, shadowsReserved } from './memory/scan.js'
|
|
16
16
|
import { listConflicts, patchConflict, readConflict, removeConflict, summarizeConflict } from './memory/conflicts.js'
|
|
17
17
|
import { invalidateMemory, undoMemoryInvalidation } from './memory/arbitrate.js'
|
|
18
|
-
import { readSnapshot, writeSnapshot } from './memory/write-protocol.js'
|
|
18
|
+
import { prepareWrite, readSnapshot, writeSnapshot } from './memory/write-protocol.js'
|
|
19
19
|
import { archiveMemory, archivedRevisionOf, deleteArchivedMemory, restoreMemory } from './memory/archive.js'
|
|
20
20
|
import { flushAccess, pickArchiveCandidates, readAccessLedger } from './memory/access.js'
|
|
21
21
|
import { serializeByKey, warnLog, writeFileAtomic } from './memory/util.js'
|
|
@@ -29,6 +29,16 @@ import { autodreamConfigSnapshot, clampInt, MAX_RAW_BYTES, parseUiPatch, pending
|
|
|
29
29
|
import { createRecallInjector, createSessionInjectState, readRecallDebug } from './recall/index.js'
|
|
30
30
|
import { mountAutodream, mountMemoryTools, mountObserve, mountWriteGate } from './assembly/index.js'
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* 读方法的拒绝:**抛**一个带 `.code` 的 Error(写方法返回 `{ok:false, code}` 回执,见 prepareWrite)。
|
|
34
|
+
*
|
|
35
|
+
* 为什么读要抛:调用方拿不到内容就没法继续,异常语义比「回一个空结果」更难被静默忽略。
|
|
36
|
+
* 为什么必须带 code:客户端要靠结构化码判断「是不是真的不存在」——它以前只能正则匹配
|
|
37
|
+
* ENOENT 文案,宿主改一个措辞就会让陈旧宿主降级链走错边。
|
|
38
|
+
*/
|
|
39
|
+
function readReject(code, message) {
|
|
40
|
+
return Object.assign(new Error(message), { code })
|
|
41
|
+
}
|
|
32
42
|
/**
|
|
33
43
|
* 手动 @Remote marker 注册(Node 24 不解析装饰器语法,手写 decorator context)。
|
|
34
44
|
*/
|
|
@@ -123,62 +133,37 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
123
133
|
*/
|
|
124
134
|
async readFile(name) {
|
|
125
135
|
const safe = safeName(name)
|
|
126
|
-
if (!safe) throw
|
|
136
|
+
if (!safe) throw readReject('invalid_name', 'sage-mem: invalid file name')
|
|
127
137
|
const full = join(MEMORY_DIR, safe)
|
|
128
138
|
// 名字不是保留名 ≠ 文件不是保留名:8.3 短名(`MEMORY~1.MD`)文本上与索引毫无关系,
|
|
129
139
|
// 却是同一个文件。别把索引当普通记忆交出去。
|
|
130
140
|
if (await shadowsReserved(full)) {
|
|
131
|
-
throw
|
|
141
|
+
throw readReject('reserved', `sage-mem: refusing to read ${safe}: 这是某个保留名(索引 / 流水)的另一个名字`)
|
|
132
142
|
}
|
|
133
143
|
const gate = await inspectMemoryEntry(full)
|
|
134
|
-
if (!gate.ok) throw
|
|
144
|
+
if (!gate.ok) throw readReject(gate.code || 'io_error', `sage-mem: refusing to read ${safe}: ${gate.error}`)
|
|
135
145
|
return await readSnapshot(full, gate, safe)
|
|
136
146
|
}
|
|
137
147
|
|
|
138
148
|
/**
|
|
139
149
|
* 写(新增或覆盖)单个记忆文件。三道闸:保留名不可写、内容必须是字符串、体积有上限;落盘走原子写。
|
|
140
|
-
*
|
|
150
|
+
* **错误形态**:拒绝一律返回 `{ ok: false, code, error }`,**不抛异常**——设置页的调用方按 ok 判定。
|
|
151
|
+
* (2.2.1 起「名字不合法」也走回执:原来它是同方法里唯一的 throw,让这句话变成假话。)
|
|
141
152
|
* 2.1.2(Q02)新增可选第三参 `opts`:`{ expectedRevision }` 对不上 → `stale_revision`(当前内容逐字节
|
|
142
153
|
* 不变,面板保留草稿);`{ create: true }` 撞名 → `already_exists`(绝不覆盖);不传 opts 走旧行为但落
|
|
143
154
|
* 一条 warn(additive 契约只保留兼容,不保留静默)。成功时回 `revision`(落盘后的新版本)。
|
|
144
155
|
*/
|
|
145
156
|
async writeFile(name, content, opts) {
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
// 保留名保护必须落在**文件身份**上,否则「保留名不可写」这句承诺是假的。
|
|
157
|
-
if (await shadowsReserved(join(MEMORY_DIR, safe))) {
|
|
158
|
-
return { ok: false, file: safe, error: `sage-mem: reserved file (它的另一个名字是 ${safe}),请用文件工具改` }
|
|
159
|
-
}
|
|
160
|
-
// 不加这一层,传个对象进来会被 String() 写成 "[object Object]" 还回 { ok: true },
|
|
161
|
-
// 原文件就此被一行垃圾顶掉,且没有任何错误信号。
|
|
162
|
-
if (typeof content !== 'string') {
|
|
163
|
-
return { ok: false, file: safe, error: `sage-mem: content must be a string, got ${typeof content}` }
|
|
164
|
-
}
|
|
165
|
-
const bytes = Buffer.byteLength(content, 'utf8')
|
|
166
|
-
if (bytes > MAX_FILE_BYTES) {
|
|
167
|
-
return { ok: false, file: safe, error: `sage-mem: content too large (${bytes} bytes > ${MAX_FILE_BYTES})` }
|
|
168
|
-
}
|
|
169
|
-
/**
|
|
170
|
-
* 目标已存在时也要过闸门:链接 / 硬链接一律拒绝。
|
|
171
|
-
*
|
|
172
|
-
* 原子写本身不会写穿链接(rename 换掉的是链接本身),但那样「改这条记忆」就变成了
|
|
173
|
-
* 「把这个名字指向别处」—— 用户以为改的是记忆,实际磁盘上多了一个指向外部的名字。
|
|
174
|
-
* `limitBytes = 0` 表示不在这里判体积(上面刚按字符数查过)。
|
|
175
|
-
*/
|
|
176
|
-
const target = join(MEMORY_DIR, safe)
|
|
177
|
-
const existing = await inspectMemoryEntry(target, { limitBytes: null })
|
|
178
|
-
if (!existing.ok && existing.code !== 'missing') {
|
|
179
|
-
return { ok: false, file: safe, error: `sage-mem: refusing to write ${safe}: ${existing.error}` }
|
|
180
|
-
}
|
|
181
|
-
return await writeSnapshot(target, content, existing, opts, { ctx: this.ctx, name: safe, kind: 'writeFile' })
|
|
157
|
+
// 三道闸(保留名 / 名字合法 / 影子文件)在 prepareWrite 里,顺序与另外两个写入口一致。
|
|
158
|
+
const pre = await prepareWrite({
|
|
159
|
+
name, content, limitBytes: MAX_FILE_BYTES,
|
|
160
|
+
isReserved, nameKey, safeName,
|
|
161
|
+
shadowCheck: (full) => shadowsReserved(full),
|
|
162
|
+
dir: MEMORY_DIR,
|
|
163
|
+
inspectEntry: inspectMemoryEntry,
|
|
164
|
+
})
|
|
165
|
+
if (!pre.ok) return pre
|
|
166
|
+
return await writeSnapshot(pre.target, content, pre.gate, opts, { ctx: this.ctx, name: pre.safe, kind: "writeFile" })
|
|
182
167
|
}
|
|
183
168
|
|
|
184
169
|
/** 删除单个记忆文件:保留名一律拒绝(unlink 不可逆)。判定同 writeFile。 */
|
|
@@ -187,7 +172,7 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
187
172
|
return { ok: false, error: `sage-mem: reserved file, refusing to delete: ${nameKey(name)}` }
|
|
188
173
|
}
|
|
189
174
|
const safe = safeName(name)
|
|
190
|
-
if (!safe)
|
|
175
|
+
if (!safe) return { ok: false, code: 'invalid_name', error: 'sage-mem: invalid file name' }
|
|
191
176
|
// 同 writeFile:8.3 短名能绕过名字文本判据,删除保护也必须看文件身份。
|
|
192
177
|
if (await shadowsReserved(join(MEMORY_DIR, safe))) {
|
|
193
178
|
return { ok: false, error: `sage-mem: reserved file (它的另一个名字是 ${safe}),拒绝删除` }
|
|
@@ -275,10 +260,10 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
275
260
|
*/
|
|
276
261
|
async readArchived(name) {
|
|
277
262
|
const safe = safeName(name)
|
|
278
|
-
if (!safe) throw
|
|
263
|
+
if (!safe) throw readReject('invalid_name', 'sage-mem: invalid file name')
|
|
279
264
|
// 归档区本身先过目录闸门:它若是指向别处的联接,读归档就等于读外部的文件。
|
|
280
265
|
const dirGate = await inspectDir(MEMORY_ARCHIVE_DIR)
|
|
281
|
-
if (!dirGate.ok) throw
|
|
266
|
+
if (!dirGate.ok) throw readReject('io_error', `sage-mem: archive dir unavailable: ${dirGate.error}`)
|
|
282
267
|
const full = join(MEMORY_ARCHIVE_DIR, safe)
|
|
283
268
|
const gate = await inspectMemoryEntry(full)
|
|
284
269
|
// 找不到就抛:与 readFile 一致。静默返回空串会让面板显示一个「空的记忆」,
|
|
@@ -286,8 +271,8 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
286
271
|
if (!gate.ok) {
|
|
287
272
|
// ⚠️「形状不对」与「不存在」是两回事:把联接 / 超限都报成「文件不存在」,
|
|
288
273
|
// 会让用户去找一个明明躺在盘上的文件。
|
|
289
|
-
if (gate.code === 'missing') throw
|
|
290
|
-
throw
|
|
274
|
+
if (gate.code === 'missing') throw readReject('not_found', `sage-mem: archived file not found: ${safe}`)
|
|
275
|
+
throw readReject(gate.code || 'io_error', `sage-mem: refusing to read archived ${safe}: ${gate.error}`)
|
|
291
276
|
}
|
|
292
277
|
// 2.1.2(Q02):归档件与活动记忆同一套读快照协议,回 {name, content, revision}。
|
|
293
278
|
return await readSnapshot(full, gate, safe)
|
|
@@ -296,7 +281,8 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
296
281
|
/**
|
|
297
282
|
* 写一份**已归档**记忆的正文(覆盖整份文件,形状与 `writeFile` 一致)。
|
|
298
283
|
*
|
|
299
|
-
*
|
|
284
|
+
* 闸门与 `writeFile` 共用 `prepareWrite`(同一顺序):名字先过 safeName、内容必须是字符串、
|
|
285
|
+
* 体积上限、磁盘探针。**错误形态**:返回 `{ ok: false, code, error }`,**不抛异常**。
|
|
300
286
|
* 额外一条:**盘上那份的归档留痕(`archived_at` / `archived_reason`)不许因为改正文而丢**。
|
|
301
287
|
* 调用方若交回一份连留痕都没有的内容(「重建 frontmatter」式编辑器就是这样),
|
|
302
288
|
* 这里按盘上原来的值补回去 —— 归档凭据不该依赖调用方是否体贴。
|
|
@@ -305,33 +291,15 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
305
291
|
* 归档件在别处被改过就回 `{ok:false, code:'stale_revision'}`,正文逐字节不变。
|
|
306
292
|
*/
|
|
307
293
|
async writeArchived(name, content, opts) {
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
// 只改归档区里**已经存在**的文件:这条 remote 不是「把任意文件写进归档区」的后门。
|
|
318
|
-
if (!gate.ok) {
|
|
319
|
-
return {
|
|
320
|
-
ok: false,
|
|
321
|
-
file: safe,
|
|
322
|
-
error:
|
|
323
|
-
gate.code === 'missing'
|
|
324
|
-
? `sage-mem: archived file not found: ${safe}`
|
|
325
|
-
: `sage-mem: refusing to write archived ${safe}: ${gate.error}`,
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
if (typeof content !== 'string') {
|
|
329
|
-
return { ok: false, file: safe, error: `sage-mem: content must be a string, got ${typeof content}` }
|
|
330
|
-
}
|
|
331
|
-
const bytes = Buffer.byteLength(content, 'utf8')
|
|
332
|
-
if (bytes > MAX_FILE_BYTES) {
|
|
333
|
-
return { ok: false, file: safe, error: `sage-mem: content too large (${bytes} bytes > ${MAX_FILE_BYTES})` }
|
|
334
|
-
}
|
|
294
|
+
const pre = await prepareWrite({
|
|
295
|
+
name, content, limitBytes: MAX_FILE_BYTES,
|
|
296
|
+
isReserved, nameKey, safeName,
|
|
297
|
+
dirCheck: () => inspectDir(MEMORY_ARCHIVE_DIR),
|
|
298
|
+
dir: MEMORY_ARCHIVE_DIR,
|
|
299
|
+
inspectEntry: inspectMemoryEntry,
|
|
300
|
+
allowMissing: false,
|
|
301
|
+
})
|
|
302
|
+
if (!pre.ok) return pre
|
|
335
303
|
const given = readArchivedMeta(content)
|
|
336
304
|
let toWrite = content
|
|
337
305
|
/**
|
|
@@ -340,14 +308,14 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
340
308
|
* 丢了它删除确认的内容身份就少了一半)。任何一个缺了才重写整块 frontmatter。
|
|
341
309
|
*/
|
|
342
310
|
if (!given.archivedAt || !given.archivedReason || !given.archivedRev) {
|
|
343
|
-
const onDisk = readArchivedMeta(await readVerified(
|
|
311
|
+
const onDisk = readArchivedMeta(await readVerified(pre.target, pre.gate, "utf8"))
|
|
344
312
|
const at = given.archivedAt || onDisk.archivedAt
|
|
345
313
|
const reason = given.archivedReason || onDisk.archivedReason
|
|
346
314
|
const rev = given.archivedRev || onDisk.archivedRev
|
|
347
315
|
if (at || reason || rev) toWrite = addArchiveMeta(content, reason, at, rev)
|
|
348
316
|
}
|
|
349
317
|
// 2.1.2(Q02):落盘也走写协议 —— 带 opts 时先比盘上版本,对不上就拒绝(正文逐字节不变)。
|
|
350
|
-
return await writeSnapshot(
|
|
318
|
+
return await writeSnapshot(pre.target, toWrite, pre.gate, opts, { ctx: this.ctx, name: pre.safe, kind: "writeArchived" })
|
|
351
319
|
}
|
|
352
320
|
|
|
353
321
|
/**
|
|
@@ -563,7 +531,7 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
563
531
|
// 它有自己的上限(`MAX_RAW_BYTES`,见上面的注释)。
|
|
564
532
|
const full = join(MEMORY_DIR, safe)
|
|
565
533
|
const gate = await inspectMemoryEntry(full, { limitBytes: null })
|
|
566
|
-
if (!gate.ok) throw
|
|
534
|
+
if (!gate.ok) throw readReject(gate.code || 'io_error', `sage-mem: refusing to read ${safe}: ${gate.error}`)
|
|
567
535
|
// 读不到就抛(与 readFile 一致):静默返回空串会让面板显示一份「空的索引」,
|
|
568
536
|
// 用户一存就把真索引顶掉。
|
|
569
537
|
// 2.1.2(Q02):保留名与普通记忆同一套读快照协议 —— 回 {name, content, revision}。
|
|
@@ -576,36 +544,24 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
576
544
|
* 这就是 2026-09-25 那次 P0 数据丢失事故(表单重建 frontmatter 把 baseline /
|
|
577
545
|
* node_type / originSessionId 全抹掉)的解药:索引与流水只许原样进出。
|
|
578
546
|
*
|
|
547
|
+
* **错误形态**:返回 `{ ok: false, code, error }`,**不抛异常**(名字判据用 `rawName`,因为服务对象就是保留名)。
|
|
548
|
+
*
|
|
579
549
|
* 2.1.2(Q02):与 `writeFile` 同一套写协议 —— `opts.expectedRevision` 对不上则
|
|
580
550
|
* `{ok:false, code:'stale_revision'}`(索引逐字节不变);`opts.create` 用于「建立索引骨架」
|
|
581
551
|
* 这类 createIfAbsent 语义(撞名明确报 `already_exists`,绝不覆盖)。
|
|
582
552
|
*/
|
|
583
553
|
async writeRaw(name, content, opts) {
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
return {
|
|
596
|
-
ok: false,
|
|
597
|
-
file: safe,
|
|
598
|
-
error: `sage-mem: content too large (${bytes} bytes > ${MAX_RAW_BYTES / (1024 * 1024)} MB raw limit)`,
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
const target = join(MEMORY_DIR, safe)
|
|
602
|
-
// 与 `writeFile` 同一条理由:目标已存在时若是链接 / 硬链接,一律拒绝 ——
|
|
603
|
-
// 「改这份索引」不该变成「把这个名字指向别处」。体积由上面那道 `MAX_RAW_BYTES` 管。
|
|
604
|
-
const existing = await inspectMemoryEntry(target, { limitBytes: null })
|
|
605
|
-
if (!existing.ok && existing.code !== 'missing') {
|
|
606
|
-
return { ok: false, file: safe, error: `sage-mem: refusing to write ${safe}: ${existing.error}` }
|
|
607
|
-
}
|
|
608
|
-
return await writeSnapshot(target, content, existing, opts, { ctx: this.ctx, name: safe, kind: 'writeRaw' })
|
|
554
|
+
const pre = await prepareWrite({
|
|
555
|
+
name, content, limitBytes: MAX_RAW_BYTES, limitLabel: `${MAX_RAW_BYTES / (1024 * 1024)} MB raw limit`,
|
|
556
|
+
// ⚠️ writeRaw 服务的就是**保留名**,所以名字判据是 `rawName`(它接受索引 / 流水),
|
|
557
|
+
// 不是 `safeName`(那个会把保留名判成非法)。
|
|
558
|
+
isReserved, nameKey, safeName: rawName,
|
|
559
|
+
requireReserved: true,
|
|
560
|
+
dir: MEMORY_DIR,
|
|
561
|
+
inspectEntry: inspectMemoryEntry,
|
|
562
|
+
})
|
|
563
|
+
if (!pre.ok) return pre
|
|
564
|
+
return await writeSnapshot(pre.target, content, pre.gate, opts, { ctx: this.ctx, name: pre.safe, kind: "writeRaw" })
|
|
609
565
|
}
|
|
610
566
|
}
|
|
611
567
|
|
|
@@ -672,15 +628,15 @@ export class StarmapGateway extends TypertRemoteService {
|
|
|
672
628
|
// 与 MemoryGateway.readFile 收敛到同一个 safeName:它现在同时排除
|
|
673
629
|
// MEMORY.md 与 session-log.md,且大小写不敏感。
|
|
674
630
|
const safe = safeName(name)
|
|
675
|
-
if (!safe) throw
|
|
631
|
+
if (!safe) throw readReject('invalid_name', 'sage-starmap: invalid file name')
|
|
676
632
|
const full = join(MEMORY_DIR, safe)
|
|
677
633
|
// 2026-10-23 审查 R19:面板读原先只过 safeName,与注入链 / 工具读不是同一套闸门。
|
|
678
634
|
// 面板读不进模型上下文,但「同一个名字在不同入口有不同含义」本身就是缺陷面 —— 收口。
|
|
679
635
|
if (await shadowsReserved(full)) {
|
|
680
|
-
throw
|
|
636
|
+
throw readReject('reserved', `sage-starmap: refusing to read ${safe}: 这是某个保留名(索引 / 流水)的另一个名字`)
|
|
681
637
|
}
|
|
682
638
|
const gate = await inspectMemoryEntry(full)
|
|
683
|
-
if (!gate.ok) throw
|
|
639
|
+
if (!gate.ok) throw readReject(gate.code || 'io_error', `sage-starmap: refusing to read ${safe}: ${gate.error}`)
|
|
684
640
|
const content = await readVerified(full, gate, 'utf8')
|
|
685
641
|
return { name: safe, content }
|
|
686
642
|
}
|
package/lib/memory/access.js
CHANGED
|
@@ -76,8 +76,13 @@ export function injectedAt(value) {
|
|
|
76
76
|
/** 落盘节流:内存先累计,最多每 30 秒写一次盘。 */
|
|
77
77
|
export const ACCESS_FLUSH_INTERVAL_MS = 30 * 1000
|
|
78
78
|
|
|
79
|
-
/**
|
|
80
|
-
|
|
79
|
+
/**
|
|
80
|
+
* 开了才出声:台账写不进去是**静默降级**,没有这个开关就查不出「为什么阈值算不准」。
|
|
81
|
+
*
|
|
82
|
+
* 2.2.1:**每次现读**,不做模块级常量 —— 常量在「运行中打开开关」和「测试用 query 重载模块」
|
|
83
|
+
* 两种场景都会停在旧值(`recall/debug.js` 的头注记过同一个坑,那里也是现读)。
|
|
84
|
+
*/
|
|
85
|
+
const debugEnabled = () => process.env.SAGE_MEM_DEBUG === '1'
|
|
81
86
|
|
|
82
87
|
/** 「永不自动归档」的类型:feedback 是用户给的做事方式,退役它等于把人的交代扔掉。 */
|
|
83
88
|
export const NEVER_ARCHIVE_TYPES = new Set(['feedback'])
|
|
@@ -292,7 +297,9 @@ async function flushAccessLocked(memoryDir) {
|
|
|
292
297
|
})
|
|
293
298
|
}
|
|
294
299
|
// 静默降级:注入是插件最核心的事,台账写不进去不许把它拖下水。开了 DEBUG 才出声。
|
|
295
|
-
|
|
300
|
+
// 2.2.1:前缀归一(原来是 `[sage-mem][debug] `)。本函数签名里没有 ctx,
|
|
301
|
+
// 所以用 console.warn —— 与 warnLog 的 fallback 形态一致。
|
|
302
|
+
if (debugEnabled()) console.warn(`sage-mem: access ledger flush failed: ${err?.message ?? err}`)
|
|
296
303
|
return { ok: false, error: err?.message ?? String(err) }
|
|
297
304
|
}
|
|
298
305
|
}
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
import { createHash } from 'node:crypto'
|
|
25
25
|
import { mkdir } from 'node:fs/promises'
|
|
26
|
-
import { dirname } from 'node:path'
|
|
26
|
+
import { dirname, join } from 'node:path'
|
|
27
27
|
import { readVerified } from './scan.js'
|
|
28
28
|
import { warnLog, writeFileAtomic, writeFileExclusive } from './util.js'
|
|
29
29
|
|
|
@@ -52,6 +52,109 @@ export async function readSnapshot(full, gate, name) {
|
|
|
52
52
|
/** 旧调用方的兼容提示(一次写入一行,不做去重 —— 谁漏传一眼就能在日志里翻到)。 */
|
|
53
53
|
const LEGACY_WARN = '没有带 expectedRevision,按旧协议直接覆盖(面板必须带;旧调用方仅兼容,建议尽快带上)'
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* 写入前置闸门:**一套顺序**,三处写路径(`writeFile` / `writeArchived` / `writeRaw`)共用。
|
|
57
|
+
*
|
|
58
|
+
* 为什么要有它:这三处曾经各写一遍,闸门顺序还不一样 —— 内容类型检查分别排在
|
|
59
|
+
* 第 3 / 第 6 / 第 3 位,`writeRaw` 用的上限常量也不同。于同一个坏输入从三个入口进去
|
|
60
|
+
* 会报三个不同的「第一个错」,而这里正是历史事故最集中的地方(D4、2026-09-25 P0、R01)。
|
|
61
|
+
*
|
|
62
|
+
* 现在的顺序**固定为**:
|
|
63
|
+
* ① 保留名(`requireReserved` 时反向:只许保留名)
|
|
64
|
+
* ② 名字合法(唯一判据 `shared/md-name.js` 的 `safeName`)
|
|
65
|
+
* ③ 目录闸(归档区专属,可选)
|
|
66
|
+
* ④ 影子文件 / 8.3 短名(内存区专属,可选)
|
|
67
|
+
* ⑤ 内容必须是字符串
|
|
68
|
+
* ⑥ 体积上限
|
|
69
|
+
* ⑦ 磁盘探针(链接 / 硬链接拒绝;`allowMissing` 决定「不存在」算不算合法)
|
|
70
|
+
*
|
|
71
|
+
* 失败一律返回 `{ok:false, code, file, error}` —— **不抛异常**(调用方按 ok 判定)。
|
|
72
|
+
*
|
|
73
|
+
* @param {object} spec
|
|
74
|
+
* @param {string} spec.name 调用方给的名字(未归一)
|
|
75
|
+
* @param {unknown} spec.content 待写正文
|
|
76
|
+
* @param {number} spec.limitBytes 体积上限
|
|
77
|
+
* @param {string} [spec.limitLabel] 错误文案里的上限描述(默认字节数)
|
|
78
|
+
* @param {(n:string)=>boolean} spec.isReserved
|
|
79
|
+
* @param {(n:string)=>string} spec.nameKey
|
|
80
|
+
* @param {(n:string)=>string|null} spec.safeName
|
|
81
|
+
* @param {boolean} [spec.requireReserved=false]
|
|
82
|
+
* @param {((full:string)=>Promise<boolean>)} [spec.shadowCheck]
|
|
83
|
+
* @param {(()=>Promise<{ok:boolean,error?:string}>)} [spec.dirCheck]
|
|
84
|
+
* @param {string} spec.dir 目标目录
|
|
85
|
+
* @param {(full:string, opts:object)=>Promise<object>} spec.inspectEntry
|
|
86
|
+
* @param {boolean} [spec.allowMissing=true] 磁盘探针是否允许「不存在」
|
|
87
|
+
* @returns {Promise<{ok:true, safe:string, target:string, gate:object}
|
|
88
|
+
* | {ok:false, code:string, file?:string, error:string}>}
|
|
89
|
+
*/
|
|
90
|
+
export async function prepareWrite(spec) {
|
|
91
|
+
const {
|
|
92
|
+
name, content, limitBytes, limitLabel,
|
|
93
|
+
isReserved, nameKey, safeName,
|
|
94
|
+
requireReserved = false,
|
|
95
|
+
shadowCheck = null,
|
|
96
|
+
dirCheck = null,
|
|
97
|
+
dir,
|
|
98
|
+
inspectEntry,
|
|
99
|
+
allowMissing = true,
|
|
100
|
+
} = spec
|
|
101
|
+
|
|
102
|
+
// ① 保留名。writeRaw 是反向的:它**只**服务索引 / 流水。
|
|
103
|
+
const reserved = typeof isReserved === 'function' ? isReserved(name) : false
|
|
104
|
+
if (requireReserved ? !reserved : reserved) {
|
|
105
|
+
return requireReserved
|
|
106
|
+
? { ok: false, code: 'reserved', error: `sage-mem: ${String(name)} is not a reserved file; normal memories use the memory form or writeFile` }
|
|
107
|
+
: { ok: false, code: 'reserved', file: nameKey(name), error: `sage-mem: reserved file, edit it with the file tools: ${nameKey(name)}` }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ② 名字合法。这里**返回回执而不是抛** —— 同一方法里「保留名回执、非法名抛异常」曾经
|
|
111
|
+
// 让 writeFile 的 JSDoc(「拒绝一律返回 { ok: false }」)变成假话。
|
|
112
|
+
const safe = safeName(name)
|
|
113
|
+
if (!safe) return { ok: false, code: 'invalid_name', file: '', error: `sage-mem: invalid file name: ${String(name)}` }
|
|
114
|
+
|
|
115
|
+
// ③ 目录闸(归档区:目录读不了就别谈写)
|
|
116
|
+
if (dirCheck) {
|
|
117
|
+
const d = await dirCheck()
|
|
118
|
+
if (!d.ok) return { ok: false, code: 'io_error', file: safe, error: `sage-mem: archive dir unavailable: ${d.error}` }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const target = join(dir, safe)
|
|
122
|
+
|
|
123
|
+
// ④ 影子文件:名字文本不是保留名 ≠ 文件不是保留名(NTFS 8.3 短名能改写索引)
|
|
124
|
+
if (shadowCheck && (await shadowCheck(target))) {
|
|
125
|
+
return { ok: false, code: 'reserved', file: safe, error: `sage-mem: reserved file (它的另一个名字是 ${safe}),请用文件工具改` }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ⑤ 内容类型。不加这一层,传个对象进来会被 String() 写成 "[object Object]" 还回 ok:true。
|
|
129
|
+
if (typeof content !== 'string') {
|
|
130
|
+
return { ok: false, code: 'invalid', file: safe, error: `sage-mem: content must be a string, got ${typeof content}` }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ⑥ 体积
|
|
134
|
+
const bytes = Buffer.byteLength(content, 'utf8')
|
|
135
|
+
if (bytes > limitBytes) {
|
|
136
|
+
return { ok: false, code: 'too_big', file: safe, error: `sage-mem: content too large (${bytes} bytes > ${limitLabel || limitBytes})` }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ⑦ 磁盘探针:目标已存在时若是链接 / 硬链接,一律拒绝 ——
|
|
140
|
+
// 「改这条记忆」不该变成「把这个名字指向别处」。体积上面刚查过,这里不判。
|
|
141
|
+
const gate = await inspectEntry(target, { limitBytes: null })
|
|
142
|
+
if (!gate.ok && !(allowMissing && gate.code === 'missing')) {
|
|
143
|
+
return {
|
|
144
|
+
ok: false,
|
|
145
|
+
code: gate.code || 'io_error',
|
|
146
|
+
file: safe,
|
|
147
|
+
error: allowMissing
|
|
148
|
+
? `sage-mem: refusing to write ${safe}: ${gate.error}`
|
|
149
|
+
: gate.code === 'missing'
|
|
150
|
+
? `sage-mem: archived file not found: ${safe}`
|
|
151
|
+
: `sage-mem: refusing to write archived ${safe}: ${gate.error}`,
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { ok: true, safe, target, gate }
|
|
156
|
+
}
|
|
157
|
+
|
|
55
158
|
/**
|
|
56
159
|
* 整份覆盖写:`create` / `expectedRevision` / 旧调用方三种语义,一个出口。
|
|
57
160
|
*
|
package/package.json
CHANGED