sage-mem 2.1.11 → 2.1.12
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/index.js +18 -18
- package/lib/memory/archive.js +14 -2
- package/lib/memory/lock.js +103 -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.12`** — 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.12`** —— 顶部 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/index.js
CHANGED
|
@@ -19,7 +19,7 @@ import { 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'
|
|
22
|
-
import {
|
|
22
|
+
import { readLockState, setLocked as setLockedImpl } from './memory/lock.js'
|
|
23
23
|
import { openMemoryFolder } from './memory/open-folder.js'
|
|
24
24
|
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
|
25
25
|
import { mkdir, unlink, writeFile } from 'node:fs/promises'
|
|
@@ -99,7 +99,9 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
99
99
|
|
|
100
100
|
/** 列出 memory 目录全部记忆文件(文件名 + 类型 + 描述 + 大小 + 自定义标签 + 是否锁定)。 */
|
|
101
101
|
async listFiles() {
|
|
102
|
-
const [files,
|
|
102
|
+
const [files, lockState] = await Promise.all([scanMemoryFiles(this.ctx, ''), readLockState(MEMORY_DIR)])
|
|
103
|
+
// Q05:锁表坏了也照样列文件,但不假装「都没锁」;逐条 locked 只有布尔,未知只能靠日志。
|
|
104
|
+
if (lockState.status === 'unknown') warnLog(this.ctx, `锁表不可读,列表里的锁定状态未知(不是「都没锁」):${lockState.reason}`)
|
|
103
105
|
return files.map(f => {
|
|
104
106
|
const meta = parseFrontmatter(f.content)
|
|
105
107
|
return {
|
|
@@ -109,7 +111,7 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
109
111
|
size: f.content.length,
|
|
110
112
|
tags: meta.tags,
|
|
111
113
|
// 锁定状态跟着每条一起给:卡片上的「锁定 / 已锁定」按钮要据此渲染
|
|
112
|
-
locked: locked.has(nameKey(f.file)),
|
|
114
|
+
locked: lockState.status === 'unknown' ? false : lockState.locked.has(nameKey(f.file)),
|
|
113
115
|
}
|
|
114
116
|
})
|
|
115
117
|
}
|
|
@@ -134,18 +136,11 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
134
136
|
}
|
|
135
137
|
|
|
136
138
|
/**
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
* 三道闸:保留名不可写、内容必须是字符串、体积有上限;落盘走原子写。
|
|
139
|
+
* 写(新增或覆盖)单个记忆文件。三道闸:保留名不可写、内容必须是字符串、体积有上限;落盘走原子写。
|
|
140
140
|
* 拒绝一律返回 { ok: false, error }(不是抛异常)——设置页的调用方按 ok 判定。
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
* 当前内容逐字节不变(面板保留草稿,提示重新应用);
|
|
145
|
-
* · `{ create: true }`:新建语义(createIfAbsent)。撞名 → `{ok:false, code:'already_exists'}`,
|
|
146
|
-
* **绝不**覆盖已有正文;
|
|
147
|
-
* · 不传 `opts`:旧调用方照旧覆盖,但会落一条 warn(additive 契约里只保留兼容,不保留静默)。
|
|
148
|
-
* 成功时回 `revision`(落盘后的新版本),调用方可以直接拿它继续保存。
|
|
141
|
+
* 2.1.2(Q02)新增可选第三参 `opts`:`{ expectedRevision }` 对不上 → `stale_revision`(当前内容逐字节
|
|
142
|
+
* 不变,面板保留草稿);`{ create: true }` 撞名 → `already_exists`(绝不覆盖);不传 opts 走旧行为但落
|
|
143
|
+
* 一条 warn(additive 契约只保留兼容,不保留静默)。成功时回 `revision`(落盘后的新版本)。
|
|
149
144
|
*/
|
|
150
145
|
async writeFile(name, content, opts) {
|
|
151
146
|
// 保留名判定放在最前面、且以 nameKey 归一:'memory.md' / 'MEMORY.MD' /
|
|
@@ -408,11 +403,13 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
408
403
|
|
|
409
404
|
async archiveCandidates() {
|
|
410
405
|
await flushAccess(MEMORY_DIR)
|
|
411
|
-
const [ledger, files,
|
|
406
|
+
const [ledger, files, lockState] = await Promise.all([
|
|
412
407
|
readAccessLedger(),
|
|
413
408
|
scanMemoryFiles(this.ctx, ''),
|
|
414
|
-
|
|
409
|
+
readLockState(MEMORY_DIR),
|
|
415
410
|
])
|
|
411
|
+
// Q05:锁表不可读时如实标记(候选清单仍可看,自动归档由 archiveMemory 直接拒绝)。
|
|
412
|
+
if (lockState.status === 'unknown') warnLog(this.ctx, `锁表不可读,候选清单的锁定状态未知(自动归档会拒绝执行):${lockState.reason}`)
|
|
416
413
|
const cfg = await autodreamConfigSnapshot()
|
|
417
414
|
const thresholds = {
|
|
418
415
|
project: cfg.archiveAfterDaysProject,
|
|
@@ -420,14 +417,17 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
420
417
|
user: cfg.archiveAfterDaysUser,
|
|
421
418
|
}
|
|
422
419
|
// 锁表交给候选计算:锁上的记忆一个都不选(用户对**单条**记忆说的话优先于阈值)。
|
|
423
|
-
const candidates = pickArchiveCandidates(files, ledger, { thresholds, locked })
|
|
420
|
+
const candidates = pickArchiveCandidates(files, ledger, { thresholds, locked: lockState.locked })
|
|
424
421
|
return {
|
|
425
422
|
count: candidates.length,
|
|
426
423
|
candidates,
|
|
427
424
|
autoArchive: cfg.autoArchive,
|
|
428
425
|
thresholds,
|
|
429
426
|
// 面板要显示「哪些锁着」,所以把锁表也带回去(已按 nameKey 归一)
|
|
430
|
-
locked: [...locked],
|
|
427
|
+
locked: [...lockState.locked],
|
|
428
|
+
// Q05:保护状态未知时明确标出来,不把「不知道」渲染成「都没锁」。
|
|
429
|
+
lockedUnknown: lockState.status === 'unknown',
|
|
430
|
+
lockedError: lockState.reason,
|
|
431
431
|
}
|
|
432
432
|
}
|
|
433
433
|
|
package/lib/memory/archive.js
CHANGED
|
@@ -19,7 +19,7 @@ import { ARCHIVE_DIR_NAME, addArchiveMeta, inspectDir, inspectMemoryEntry, local
|
|
|
19
19
|
import { isReserved, nameKey, safeName } from './naming.js'
|
|
20
20
|
import { revisionOf } from './chunks.js'
|
|
21
21
|
import { retireTaken, returnSource, takeOverSource, writeFileAtomic, writeFileExclusive } from './util.js'
|
|
22
|
-
import {
|
|
22
|
+
import { readLockState } from './lock.js'
|
|
23
23
|
import { ensureIndexEntry, dropIndexEntry } from './index-file.js'
|
|
24
24
|
import { mkdir, readFile, rm, stat } from 'node:fs/promises'
|
|
25
25
|
import { basename, join } from 'node:path'
|
|
@@ -54,8 +54,20 @@ export async function archiveMemory(memoryDir, name, reason, at = localStamp())
|
|
|
54
54
|
/**
|
|
55
55
|
* 「锁定」优先于一切:锁上的记忆**自动与手动都不许归档**。
|
|
56
56
|
* 放在最前面判 —— 名字合法之后立刻拒绝,不读目录、不算理由。
|
|
57
|
+
*
|
|
58
|
+
* ⚠️ 2.1.12(Q05):锁表读不出来时**不猜**。旧实现把「保护状态未知」当成空表,
|
|
59
|
+
* 于是坏表会让受保护的记忆照常被移走。现在未知 = 拒绝归档(危险操作暂停),
|
|
60
|
+
* 错误码 `lock-unknown` 让调用方/报告能说清是「锁表坏了」而不是「文件有问题」。
|
|
57
61
|
*/
|
|
58
|
-
|
|
62
|
+
const lock = await readLockState(memoryDir)
|
|
63
|
+
if (lock.status === 'unknown') {
|
|
64
|
+
return {
|
|
65
|
+
ok: false,
|
|
66
|
+
code: 'lock-unknown',
|
|
67
|
+
error: `sage-mem: 锁表不可读(${lock.reason}),无法确认这条记忆是否受保护,拒绝归档:${safe}`,
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (lock.locked.has(nameKey(safe))) {
|
|
59
71
|
return { ok: false, code: 'locked', error: `sage-mem: locked, refusing to archive: ${safe}(先在界面里解锁)` }
|
|
60
72
|
}
|
|
61
73
|
const src = join(memoryDir, safe)
|
package/lib/memory/lock.js
CHANGED
|
@@ -9,11 +9,20 @@
|
|
|
9
9
|
*
|
|
10
10
|
* 锁表按 `nameKey()` 归一存放:NTFS 不区分大小写,`User_Persona.MD` 与 `user_persona.md`
|
|
11
11
|
* 是同一个文件 —— 锁必须认得出这一点(与保留名保护同一套判据)。
|
|
12
|
+
*
|
|
13
|
+
* ── 2.1.12(外部审查 Q05)──────────────────────────────────────────────
|
|
14
|
+
* 旧实现两处硬伤,都已修:
|
|
15
|
+
* 1. 整文件 read-modify-write **没有排队**:两个并发上锁各自读到旧表,后写的把先写的
|
|
16
|
+
* 盖掉,而两边都报成功。原子 rename 只保证「不写半截」,不负责「合并两处修改」。
|
|
17
|
+
* 现在按锁表路径分键复用 `util.js` 的 `createWriteQueue()` 串行 RMW。
|
|
18
|
+
* 2. 读任何异常都返回空 Set —— 「保护状态未知」与「确实没有锁」因此无法区分,
|
|
19
|
+
* 自动归档会把**受保护**的记忆当成没锁继续删。现在只有 ENOENT 算「确实没有锁」;
|
|
20
|
+
* 坏 JSON / 权限失败 / 路径是目录……一律 `status:'unknown'`,危险操作据此暂停。
|
|
12
21
|
*/
|
|
13
22
|
import { mkdir, readFile } from 'node:fs/promises'
|
|
14
23
|
import { dirname, join } from 'node:path'
|
|
15
24
|
import { nameKey } from './naming.js'
|
|
16
|
-
import { writeFileAtomic } from './util.js'
|
|
25
|
+
import { createWriteQueue, writeFileAtomic } from './util.js'
|
|
17
26
|
|
|
18
27
|
/** 锁表文件:状态根下,与记忆文件分开。 */
|
|
19
28
|
export function lockFileOf(memoryDir) {
|
|
@@ -21,24 +30,83 @@ export function lockFileOf(memoryDir) {
|
|
|
21
30
|
}
|
|
22
31
|
|
|
23
32
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
33
|
+
* 锁表的**进程内串行队列**,按锁表路径分键(不同记忆目录互不阻塞)。
|
|
34
|
+
*
|
|
35
|
+
* ⚠️ 边界与 `util.js` 的说明一致:进程内串行,不是跨进程文件锁。真实并发(面板 remote、
|
|
36
|
+
* 模型工具、归档策略)都在同一个插件进程里;同时开两个 DSH 实例写同一个记忆目录时仍可能
|
|
37
|
+
* 丢一次 —— 那要另加文件锁,本版明确不做。
|
|
26
38
|
*/
|
|
27
|
-
|
|
39
|
+
const lockQueues = new Map()
|
|
40
|
+
function lockQueueFor(file) {
|
|
41
|
+
let queue = lockQueues.get(file)
|
|
42
|
+
if (!queue) {
|
|
43
|
+
queue = createWriteQueue()
|
|
44
|
+
lockQueues.set(file, queue)
|
|
45
|
+
}
|
|
46
|
+
return queue
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 读锁表**原始对象**,区分「确实没有」与「不知道」。
|
|
51
|
+
*
|
|
52
|
+
* @returns {Promise<{status:'ok'|'missing'|'unknown', table:object, reason:string}>}
|
|
53
|
+
*/
|
|
54
|
+
async function readLockTable(memoryDir) {
|
|
55
|
+
let text
|
|
28
56
|
try {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
57
|
+
text = await readFile(lockFileOf(memoryDir), 'utf8')
|
|
58
|
+
} catch (err) {
|
|
59
|
+
// 只有 ENOENT 可以当空表:文件不存在 = 一次锁都没上过。
|
|
60
|
+
if (err?.code === 'ENOENT') return { status: 'missing', table: {}, reason: '' }
|
|
61
|
+
return { status: 'unknown', table: {}, reason: `读锁表失败(${err?.code || err?.message})` }
|
|
62
|
+
}
|
|
63
|
+
let parsed
|
|
64
|
+
try {
|
|
65
|
+
parsed = JSON.parse(text)
|
|
66
|
+
} catch (err) {
|
|
67
|
+
return { status: 'unknown', table: {}, reason: `锁表不是合法 JSON(${err?.message ?? err})` }
|
|
38
68
|
}
|
|
69
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
70
|
+
return { status: 'unknown', table: {}, reason: '锁表不是 JSON 对象' }
|
|
71
|
+
}
|
|
72
|
+
return { status: 'ok', table: parsed, reason: '' }
|
|
39
73
|
}
|
|
40
74
|
|
|
41
|
-
/**
|
|
75
|
+
/**
|
|
76
|
+
* 读锁状态 —— **安全路径唯一该用的读口**。
|
|
77
|
+
*
|
|
78
|
+
* - `{status:'ok', locked:Set}`:读到有效表(可能为空)
|
|
79
|
+
* - `{status:'missing', locked:Set()}`:锁表不存在 = 确实没有锁
|
|
80
|
+
* - `{status:'unknown', locked:Set(), reason}`:保护状态**未知**,自动归档必须暂停
|
|
81
|
+
*
|
|
82
|
+
* ⚠️ `unknown` 时返回的 `locked` 是**空集合**,但它不表示「没有锁」;调用方必须先看
|
|
83
|
+
* `status`,拿它当空表用就是把 Q05 原样放回去。
|
|
84
|
+
*/
|
|
85
|
+
export async function readLockState(memoryDir) {
|
|
86
|
+
const read = await readLockTable(memoryDir)
|
|
87
|
+
if (read.status === 'unknown') return { status: 'unknown', locked: new Set(), reason: read.reason }
|
|
88
|
+
const out = new Set()
|
|
89
|
+
for (const [k, v] of Object.entries(read.table)) {
|
|
90
|
+
if (v === true) out.add(nameKey(k))
|
|
91
|
+
}
|
|
92
|
+
return { status: read.status, locked: out, reason: '' }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 兼容出口:只要集合的调用方用这个。
|
|
97
|
+
*
|
|
98
|
+
* 与 `readLockState` 的差别只有一条:`unknown` 时**抛** `LOCK_TABLE_UNKNOWN`,而不是
|
|
99
|
+
* 返回空 Set —— 返回空 Set 正是「坏表当全解锁」这个 bug 的入口。缺文件仍返回空 Set。
|
|
100
|
+
*/
|
|
101
|
+
export async function readLockSet(memoryDir) {
|
|
102
|
+
const state = await readLockState(memoryDir)
|
|
103
|
+
if (state.status === 'unknown') {
|
|
104
|
+
throw Object.assign(new Error(`sage-mem: ${state.reason}`), { code: 'LOCK_TABLE_UNKNOWN', reason: state.reason })
|
|
105
|
+
}
|
|
106
|
+
return state.locked
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 这条记忆锁上了吗。`unknown` 时抛错(调用方不能把「不知道」当成「没锁」)。 */
|
|
42
110
|
export async function isLocked(memoryDir, name) {
|
|
43
111
|
return (await readLockSet(memoryDir)).has(nameKey(name))
|
|
44
112
|
}
|
|
@@ -46,23 +114,29 @@ export async function isLocked(memoryDir, name) {
|
|
|
46
114
|
/**
|
|
47
115
|
* 上锁 / 解锁。
|
|
48
116
|
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
117
|
+
* RMW 全程在**按锁表路径分键的进程内队列**里:读最新表 → 改一项 → 原子写回。
|
|
118
|
+
* 并发的不同文件因此逐条合并,不再互相覆盖。
|
|
119
|
+
*
|
|
120
|
+
* 坏表上**拒绝写入**(`{ok:false, code:'lock-table-unknown'}`),而不是把表重置成空表 ——
|
|
121
|
+
* 后者会静默抹掉用户的全部保护状态,比「这次没锁上」严重得多。
|
|
52
122
|
*/
|
|
53
123
|
export async function setLocked(memoryDir, name, locked) {
|
|
54
124
|
const key = nameKey(name)
|
|
55
125
|
const file = lockFileOf(memoryDir)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
126
|
+
return lockQueueFor(file)(async () => {
|
|
127
|
+
const read = await readLockTable(memoryDir)
|
|
128
|
+
if (read.status === 'unknown') {
|
|
129
|
+
return {
|
|
130
|
+
ok: false,
|
|
131
|
+
code: 'lock-table-unknown',
|
|
132
|
+
error: `sage-mem: 锁表不可读(${read.reason}),本次没有写入,也不会把它重置成空表:${name}`,
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const table = { ...read.table }
|
|
136
|
+
if (locked) table[key] = true
|
|
137
|
+
else delete table[key]
|
|
138
|
+
await mkdir(dirname(file), { recursive: true })
|
|
139
|
+
await writeFileAtomic(file, JSON.stringify(table, null, 2) + '\n')
|
|
140
|
+
return { ok: true, file: name, locked: !!locked }
|
|
141
|
+
})
|
|
68
142
|
}
|
package/package.json
CHANGED