sage-mem 2.1.10 → 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 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.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
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.10`** —— 顶部 npm 徽章是动态权威。**契约基线:`1.0.0`** —— 第一个稳定版,公开承诺、能力边界与「明确做不到」的清单冻结在
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
@@ -1,15 +1,13 @@
1
1
  /**
2
2
  * sage-mem — 文件式记忆插件。记忆存在本地 markdown 目录(frontmatter + 正文,4 类),不是数据库。
3
- * host 侧三件事:① 按问题检索注入:system-prompt/assemble 时扫 memory 目录、按双字匹配选相关文件,
4
- * 读全文注入 system prompt,让 agent 第一轮就「想起」;② TypertRemoteService:给设置页「记忆管理」
5
- * 提供文件列表/读/写/删;③ autodream(自动做梦):可选的记忆整理,见 lib/autodream.js。
6
- *
7
- * 写入靠 AGENTS.md 的「记忆使用」规则引导 agent 自己判断 + 用文件工具写 memory 目录——透明、可检查、
8
- * 防膨胀。无 worker、无 SQLite、无端口。旧 worker 版见 git 分支 `sqlite-worker`。
3
+ * host 侧三件事:① 注入(assemble 时扫目录、按双字匹配选文件,读全文注入 system prompt);
4
+ * ② TypertRemoteService(设置页 / 面板的文件读写删);③ autodream(可选的记忆整理,见 lib/autodream.js)。
5
+ * 写入靠 AGENTS.md 的「记忆使用」规则引导 agent 自己判断 + 用文件工具写;透明、可检查、防膨胀。
6
+ * 无 worker / 无 SQLite / 无端口;旧 worker 版见 git 分支 `sqlite-worker`。
9
7
  */
10
8
 
11
9
  import { AutodreamEngine, DEFAULT_CONFIG, auditMemoryDir } from './autodream.js'
12
- 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'
10
+ 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, STARTUP_LIMIT_DIAGNOSTICS, STATE_ROOT, resolveLimits } from './memory/config.js'
13
11
  import { INDEX_FILE_KEY, SESSION_LOG_KEY, isPlainMdName } from './shared/md-name.js'
14
12
  import { parseAliases, parseBaseline, parseBaselinePriority, parseFrontmatter, parseTags } from './memory/frontmatter.js'
15
13
  import { ageDays, bigrams, score, selectBaseline, selectRelevant } from './memory/retrieval.js'
@@ -21,13 +19,13 @@ import { readSnapshot, writeSnapshot } from './memory/write-protocol.js'
21
19
  import { archiveMemory, archivedRevisionOf, deleteArchivedMemory, restoreMemory } from './memory/archive.js'
22
20
  import { flushAccess, pickArchiveCandidates, readAccessLedger } from './memory/access.js'
23
21
  import { serializeByKey, warnLog, writeFileAtomic } from './memory/util.js'
24
- import { readLockSet, setLocked as setLockedImpl } from './memory/lock.js'
22
+ import { readLockState, setLocked as setLockedImpl } from './memory/lock.js'
25
23
  import { openMemoryFolder } from './memory/open-folder.js'
26
24
  import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
27
25
  import { mkdir, unlink, writeFile } from 'node:fs/promises'
28
26
  import { join } from 'node:path'
29
27
  import { checkUpdate as checkUpdateImpl } from './update.js'
30
- import { autodreamConfigSnapshot, clampInt, MAX_RAW_BYTES, parseUiPatch, pendingLimits, pendingUi, rawName, readReservedFile, readSettingsFile, RESERVED_FILE, SETTINGS_FILE } from './settings.js'
28
+ import { autodreamConfigSnapshot, clampInt, MAX_RAW_BYTES, parseUiPatch, pendingUi, rawName, readReservedFile, readSettingsFileDetailed, RESERVED_FILE, SETTINGS_FILE } from './settings.js'
31
29
  import { createRecallInjector, createSessionInjectState, readRecallDebug } from './recall/index.js'
32
30
  import { mountAutodream, mountMemoryTools, mountObserve, mountWriteGate } from './assembly/index.js'
33
31
 
@@ -81,10 +79,8 @@ export class MemoryGateway extends TypertRemoteService {
81
79
  }
82
80
 
83
81
  /**
84
- * 查有没有新版本(面板进入时静默调一次)。
85
- *
86
- * 实现全在 `lib/update.js`:**当前版本运行时读自己的 `package.json`**(不内联常量 ——
87
- * 内联会在「构建早于 bump」时停留在旧版本,插件于是永远对自己报「有新版本」);
82
+ * 查有没有新版本(面板进入时静默调一次)。实现全在 `lib/update.js`:版本运行时读自己的
83
+ * `package.json`(不内联常量 —— 内联会在「构建早于 bump」时停在旧版本、永远报有新版本);
88
84
  * 查不到一律降级为「不提示」,绝不在界面上冒红条。
89
85
  */
90
86
  async checkUpdate() {
@@ -92,11 +88,10 @@ export class MemoryGateway extends TypertRemoteService {
92
88
  }
93
89
 
94
90
  /**
95
- * 最近几次注入尝试的账(1.7.0 面板「工具 → 注入调试」的唯一数据源)。
96
- *
97
- * **只读、不落盘**:数据是注入链在 `SAGE_MEM_DEBUG=1` 时记进进程内存的环形缓冲
98
- * (见 `lib/recall/debug.js`);开关关着时返回 `{ enabled: false, records: [] }`,
99
- * 面板据此不显示入口。不提供「清空 / 修改」能力 —— 一份账记下来就是那一刻的事实。
91
+ * 最近几次注入尝试的账(1.7.0 面板「工具 → 注入调试」的唯一数据源)。**只读、不落盘**:
92
+ * 数据是注入链在 `SAGE_MEM_DEBUG=1` 时记进进程内存的环形缓冲(见 `lib/recall/debug.js`);
93
+ * 开关关着时返回 `{ enabled: false, records: [] }`,面板据此不显示入口。不提供清空 / 修改
94
+ * 能力 —— 一份账记下来就是那一刻的事实。
100
95
  */
101
96
  async recallDebug() {
102
97
  return readRecallDebug()
@@ -104,7 +99,9 @@ export class MemoryGateway extends TypertRemoteService {
104
99
 
105
100
  /** 列出 memory 目录全部记忆文件(文件名 + 类型 + 描述 + 大小 + 自定义标签 + 是否锁定)。 */
106
101
  async listFiles() {
107
- const [files, locked] = await Promise.all([scanMemoryFiles(this.ctx, ''), readLockSet(MEMORY_DIR)])
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}`)
108
105
  return files.map(f => {
109
106
  const meta = parseFrontmatter(f.content)
110
107
  return {
@@ -114,19 +111,15 @@ export class MemoryGateway extends TypertRemoteService {
114
111
  size: f.content.length,
115
112
  tags: meta.tags,
116
113
  // 锁定状态跟着每条一起给:卡片上的「锁定 / 已锁定」按钮要据此渲染
117
- locked: locked.has(nameKey(f.file)),
114
+ locked: lockState.status === 'unknown' ? false : lockState.locked.has(nameKey(f.file)),
118
115
  }
119
116
  })
120
117
  }
121
118
 
122
119
  /**
123
- * 读单个记忆文件全文。
124
- *
125
- * 过闸门再读:名字在记忆目录里**不等于**内容住在记忆目录里 —— 符号链接 / 目录联接 /
126
- * 硬链接都能让一次「读自己的记忆」变成读别人的文件,而读到的内容会被注入模型上下文。
127
- *
128
- * 2.1.2(Q02):返回值多一个 `revision`(正文字节的 sha256)。保存时把它带回来,
129
- * 盘上版本变了就拒绝覆盖(见 `memory/write-protocol.js`)。
120
+ * 读单个记忆文件全文。过闸门再读:名字在记忆目录里**不等于**内容住在记忆目录里 ——
121
+ * 符号链接 / 目录联接 / 硬链接都能让一次「读自己的记忆」变成读别人的文件,而内容会进模型上下文。
122
+ * 2.1.2(Q02):返回值多一个 `revision`(正文字节 sha256);保存时带回来,盘上版本变了就拒绝覆盖。
130
123
  */
131
124
  async readFile(name) {
132
125
  const safe = safeName(name)
@@ -143,18 +136,11 @@ export class MemoryGateway extends TypertRemoteService {
143
136
  }
144
137
 
145
138
  /**
146
- * 写(新增或覆盖)单个记忆文件。
147
- *
148
- * 三道闸:保留名不可写、内容必须是字符串、体积有上限;落盘走原子写。
139
+ * 写(新增或覆盖)单个记忆文件。三道闸:保留名不可写、内容必须是字符串、体积有上限;落盘走原子写。
149
140
  * 拒绝一律返回 { ok: false, error }(不是抛异常)——设置页的调用方按 ok 判定。
150
- *
151
- * 2.1.2(Q02):可选第三参 `opts`
152
- * · `{ expectedRevision }`:读快照时拿到的 revision;盘上对不上 → `{ok:false, code:'stale_revision'}`,
153
- * 当前内容逐字节不变(面板保留草稿,提示重新应用);
154
- * · `{ create: true }`:新建语义(createIfAbsent)。撞名 → `{ok:false, code:'already_exists'}`,
155
- * **绝不**覆盖已有正文;
156
- * · 不传 `opts`:旧调用方照旧覆盖,但会落一条 warn(additive 契约里只保留兼容,不保留静默)。
157
- * 成功时回 `revision`(落盘后的新版本),调用方可以直接拿它继续保存。
141
+ * 2.1.2(Q02)新增可选第三参 `opts`:`{ expectedRevision }` 对不上 → `stale_revision`(当前内容逐字节
142
+ * 不变,面板保留草稿);`{ create: true }` 撞名 → `already_exists`(绝不覆盖);不传 opts 走旧行为但落
143
+ * 一条 warn(additive 契约只保留兼容,不保留静默)。成功时回 `revision`(落盘后的新版本)。
158
144
  */
159
145
  async writeFile(name, content, opts) {
160
146
  // 保留名判定放在最前面、且以 nameKey 归一:'memory.md' / 'MEMORY.MD' /
@@ -227,9 +213,8 @@ export class MemoryGateway extends TypertRemoteService {
227
213
  const res = await archiveMemory(MEMORY_DIR, name, reason)
228
214
  if (!res.ok) return { ok: false, error: res.error }
229
215
  /**
230
- * ⚠️ `unindexed` / `indexError` 必须**原样透出去**(2026-10-12 复审 R11 的配套):
231
- * 归档本身成功、索引却没摘掉时,这正是用户该被告知的那件事 —— 底下那句
232
- * 「索引没摘」在面板上要看得见,不能只活在 host 的返回值里被 strict 剥掉。
216
+ * ⚠️ `unindexed` / `indexError` 必须**原样透出去**(2026-10-12 复审 R11 的配套):归档成功、
217
+ * 索引却没摘掉时,这正是用户该被告知的那件事 —— 面板上要看得见,不能只活在 host 返回值里被 strict 剥掉。
233
218
  */
234
219
  return {
235
220
  ok: true,
@@ -257,15 +242,11 @@ export class MemoryGateway extends TypertRemoteService {
257
242
  }
258
243
 
259
244
  /**
260
- * 列出归档区的全部记忆(面板的「已归档」区)。
261
- *
262
- * 与 `listFiles()` 分开、形状也不同:`listFiles` 只列活动记忆、多一个字段都会破坏
263
- * 界面与断言依赖的形状;归档条目额外带 `archivedAt` / `archivedReason` / `revision`。
264
- * 旧版 autodream 工具直接移文件、不写留痕,所以前两个字段可能是空串。
265
- *
266
- * `revision` 是**内容身份**(2026-10-31 外部审查 Q07):归档代次 + 正文 revision。
267
- * 删除确认必须把它交回来 —— `archivedAt` 只是展示时间,同一分钟内不可区分,
268
- * 拿它当版本会让「A 改成同时间戳的 B」和「同分钟重新归档」的旧确认删掉新内容。
245
+ * 列出归档区的全部记忆(面板的「已归档」区)。与 `listFiles()` 分开、形状也不同:那个只列
246
+ * 活动记忆(多一个字段都会破坏界面与断言依赖的形状);归档条目额外带 `archivedAt` /
247
+ * `archivedReason` / `revision`。旧版 autodream 工具直接移文件、不写留痕,所以前两个可能是空串。
248
+ * `revision` 是**内容身份**(2026-10-31 外部审查 Q07):归档代次 + 正文 revision,删除确认必须原样交回 ——
249
+ * `archivedAt` 只是展示时间,同一分钟内不可区分,拿它当版本会让旧确认删掉新内容。
269
250
  */
270
251
  async listArchived() {
271
252
  const files = await scanArchived(this.ctx)
@@ -422,11 +403,13 @@ export class MemoryGateway extends TypertRemoteService {
422
403
 
423
404
  async archiveCandidates() {
424
405
  await flushAccess(MEMORY_DIR)
425
- const [ledger, files, locked] = await Promise.all([
406
+ const [ledger, files, lockState] = await Promise.all([
426
407
  readAccessLedger(),
427
408
  scanMemoryFiles(this.ctx, ''),
428
- readLockSet(MEMORY_DIR),
409
+ readLockState(MEMORY_DIR),
429
410
  ])
411
+ // Q05:锁表不可读时如实标记(候选清单仍可看,自动归档由 archiveMemory 直接拒绝)。
412
+ if (lockState.status === 'unknown') warnLog(this.ctx, `锁表不可读,候选清单的锁定状态未知(自动归档会拒绝执行):${lockState.reason}`)
430
413
  const cfg = await autodreamConfigSnapshot()
431
414
  const thresholds = {
432
415
  project: cfg.archiveAfterDaysProject,
@@ -434,26 +417,27 @@ export class MemoryGateway extends TypertRemoteService {
434
417
  user: cfg.archiveAfterDaysUser,
435
418
  }
436
419
  // 锁表交给候选计算:锁上的记忆一个都不选(用户对**单条**记忆说的话优先于阈值)。
437
- const candidates = pickArchiveCandidates(files, ledger, { thresholds, locked })
420
+ const candidates = pickArchiveCandidates(files, ledger, { thresholds, locked: lockState.locked })
438
421
  return {
439
422
  count: candidates.length,
440
423
  candidates,
441
424
  autoArchive: cfg.autoArchive,
442
425
  thresholds,
443
426
  // 面板要显示「哪些锁着」,所以把锁表也带回去(已按 nameKey 归一)
444
- locked: [...locked],
427
+ locked: [...lockState.locked],
428
+ // Q05:保护状态未知时明确标出来,不把「不知道」渲染成「都没锁」。
429
+ lockedUnknown: lockState.status === 'unknown',
430
+ lockedError: lockState.reason,
445
431
  }
446
432
  }
447
433
 
448
434
  /**
449
- * 面板上的注入设置(5 项上限 + 保留名保护名单)。
450
- *
451
- * ⚠️ **这 5 个常量是模块加载时读的**:面板改完要**重启 DSH** 才生效。所以这里同时给出
452
- * 「当前生效值」`limits` 与「待生效值」`pendingLimits`,外加 `restartRequired` ——
453
- * 界面才能诚实地说「改好了,重启后生效」,而不是让用户以为立刻生效又看不到变化。
435
+ * 面板上的注入设置(5 项上限 + 保留名保护名单)。⚠️ **这 5 个常量是模块加载时读的**:面板改完
436
+ * 要**重启 DSH** 才生效。所以这里同时给出「当前生效值」`limits`、「待生效值」`pendingLimits` 与
437
+ * `restartRequired` —— 界面才能诚实地说「改好了,重启后生效」,而不是让用户以为立刻生效。
454
438
  */
455
439
  async getSettings() {
456
- const file = await readSettingsFile()
440
+ const fileRead = await readSettingsFileDetailed()
457
441
  const limits = {
458
442
  maxResults: MAX_RESULTS,
459
443
  maxChars: MAX_CHARS_PER_FILE,
@@ -461,19 +445,31 @@ export class MemoryGateway extends TypertRemoteService {
461
445
  maxSessionBytes: MAX_SESSION_BYTES,
462
446
  staleDays: STALE_DAYS,
463
447
  }
464
- const pending = pendingLimits(file)
448
+ // Q04:pending 与**启动解析**共用 `resolveLimits` 这一份判据(持久设置 > 环境变量 > 默认)。
449
+ const pending = resolveLimits(fileRead.data?.limits)
450
+ // 坏设置给明确诊断、不静默回默认:启动期那份 + 这次读文件的问题 + 逐项非法/越界说明,去重后一起给。
451
+ const limitDiagnostics = [...new Set([
452
+ ...STARTUP_LIMIT_DIAGNOSTICS,
453
+ ...(fileRead.error ? [fileRead.error] : []),
454
+ ...pending.diagnostics,
455
+ ])]
456
+ if (limitDiagnostics.length > 0) {
457
+ warnLog(this.ctx, `上限设置有问题(本次仍按环境变量/默认值生效):${limitDiagnostics.join(';')}`)
458
+ }
465
459
  const reservedExtra = await readReservedFile()
466
460
  // 模块加载时那份「用户声明的保护名」(RESERVED_FILES 里抠掉两个内置名)
467
461
  const activeExtra = [...RESERVED_FILES].filter((n) => n !== INDEX_FILE_KEY && n !== SESSION_LOG_KEY)
468
462
  const norm = (list) => JSON.stringify([...list].map((s) => String(s).toLowerCase()).sort())
469
463
  const restartRequired =
470
- JSON.stringify(limits) !== JSON.stringify(pending) || norm(activeExtra) !== norm(reservedExtra)
464
+ JSON.stringify(limits) !== JSON.stringify(pending.limits) || norm(activeExtra) !== norm(reservedExtra)
471
465
  return {
472
466
  limits,
473
- pendingLimits: pending,
467
+ pendingLimits: pending.limits,
474
468
  restartRequired,
469
+ // Q04 的诊断出口:面板据此说明「你保存的值没生效,原因是 X」;宿主侧先如实给出,别让问题只能靠猜。
470
+ limitDiagnostics,
475
471
  // 1.8.0:客户端行为(侧栏入口开关)。**不参与** restartRequired —— 改完立刻生效。
476
- ui: pendingUi(file),
472
+ ui: pendingUi(fileRead.data),
477
473
  // 生效的保护名单(含两个内置名)+ 用户声明的那部分原文(面板编辑的是后者)
478
474
  reserved: [...new Set([INDEX_FILE_KEY, SESSION_LOG_KEY, ...reservedExtra.map((s) => s.toLowerCase())])],
479
475
  reservedExtra,
@@ -526,7 +522,13 @@ export class MemoryGateway extends TypertRemoteService {
526
522
 
527
523
  await mkdir(STATE_ROOT, { recursive: true })
528
524
  if (limitsPatch || uiPatch !== null) {
529
- const current = await readSettingsFile()
525
+ const currentRead = await readSettingsFileDetailed()
526
+ // 坏文件上不写:没有可合并的旧值,整份重写会把用户手改的内容静默吞掉。
527
+ // 明确拒绝、让用户先修文件,比「保存成功但别的设置都没了」诚实。
528
+ if (currentRead.error) {
529
+ return { ok: false, error: `sage-mem: ${currentRead.error};请先修复或删除它再保存(不会静默覆盖)` }
530
+ }
531
+ const current = currentRead.data
530
532
  const mergedLimits = { ...(current?.limits ?? {}), ...nextLimits }
531
533
  // ui 只覆盖这次给出的键;没给就沿用文件里的有效值(坏值 / 缺失一律按默认「开」)
532
534
  const mergedUi = { ...pendingUi(current), ...(uiPatch ?? {}) }
@@ -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 { isLocked } from './lock.js'
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
- if (await isLocked(memoryDir, safe)) {
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)
@@ -18,6 +18,15 @@ export const MEMORY_DIR = process.env.SAGE_MEM_DIR || join(homedir(), '.sage-mem
18
18
  */
19
19
  export const SESSIONS_ROOT = process.env.DSH_HOME ? join(process.env.DSH_HOME, 'sessions') : ''
20
20
 
21
+ /**
22
+ * 状态根:配置、锁表、台账、冲突、检查点、快照都住在这里。
23
+ *
24
+ * `SAGE_MEM_STATE_DIR` 显式覆写优先;没设时跟着记忆目录走(它的上一层 + `.sage-mem`)。
25
+ * ⚠️ Q09:**所有子模块都必须从这里取**,不许各自 `dirname(memoryDir)` + 拼 `.sage-mem` ——
26
+ * 那样覆写只对一部分模块生效,面板与引擎会各读一套配置。
27
+ */
28
+ export const STATE_ROOT = process.env.SAGE_MEM_STATE_DIR || join(dirname(MEMORY_DIR), '.sage-mem')
29
+
21
30
  /** 读一个整数型环境变量并在范围内收敛;取不到或非法就用默认值。 */
22
31
  export function envInt(name, dflt, lo, hi) {
23
32
  // ⚠️ 必须先 trim:`Number(' ')` 是 **0** 而不是 NaN,于是纯空格的值会被
@@ -41,20 +50,110 @@ export const LIMIT_SPECS = {
41
50
  maxSessionBytes: { env: 'SAGE_MEM_MAX_SESSION_BYTES', dflt: 60 * 1024, lo: 4096, hi: 512 * 1024 },
42
51
  staleDays: { env: 'SAGE_MEM_STALE_DAYS', dflt: 1, lo: 0, hi: 365 },
43
52
  }
44
- const fromSpec = (spec) => envInt(spec.env, spec.dflt, spec.lo, spec.hi)
53
+ /**
54
+ * 单项上限的**唯一**校验:非法返回 `undefined`,合法返回收敛到 `[lo, hi]` 的整数。 *
55
+ * 与 `envInt()` 同一套语义,区别只在「非法」的出口由调用方决定(环境变量回落默认、
56
+ * 持久设置回落环境变量并留诊断)。**必须先 trim**:`Number(' ')` 是 0 而不是 NaN,
57
+ * 不 trim 的话纯空格会被当成「用户填了 0」。
58
+ */
59
+ export function parseLimitValue(raw, spec) {
60
+ if (typeof raw === 'number') {
61
+ return Number.isFinite(raw) ? Math.max(spec.lo, Math.min(spec.hi, Math.round(raw))) : undefined
62
+ }
63
+ if (typeof raw !== 'string') return undefined
64
+ const text = raw.trim()
65
+ if (text === '') return undefined
66
+ const n = Number(text)
67
+ if (!Number.isFinite(n)) return undefined
68
+ return Math.max(spec.lo, Math.min(spec.hi, Math.round(n)))
69
+ }
70
+
71
+ /**
72
+ * 五项上限的**单一解析器**:持久设置 > 环境变量 > 内置默认。
73
+ *
74
+ * 为什么必须只有这一份(Q04):旧实现里「启动生成的常量」只读环境变量,而面板的
75
+ * `pendingLimits` 另写一套读 settings.json —— 于是 UI 永远显示「待重启」,
76
+ * 重启却永远不会加载它。两处共用本函数后,优先级与校验不可能再各说一套。
77
+ *
78
+ * 诊断不静默:持久值不是数字 → 回落环境变量/默认并说明;越界 → 收敛到边界并说明。
79
+ * 缺失(`hasOwnProperty` 为假)是正常状态,不产生诊断。
80
+ *
81
+ * @param {object|null|undefined} saved — `settings.json` 的 `limits` 段
82
+ * @param {object} [env] — 默认 `process.env`(测试可注入,避免污染进程环境)
83
+ * @returns {{limits: Record<string, number>, diagnostics: string[]}}
84
+ */
85
+ export function resolveLimits(saved, env = process.env) {
86
+ const limits = {}
87
+ const diagnostics = []
88
+ const savedObj = saved && typeof saved === 'object' && !Array.isArray(saved) ? saved : null
89
+ for (const [key, spec] of Object.entries(LIMIT_SPECS)) {
90
+ const hasSaved = savedObj !== null && Object.prototype.hasOwnProperty.call(savedObj, key)
91
+ let note = ''
92
+ if (hasSaved) {
93
+ const value = parseLimitValue(savedObj[key], spec)
94
+ if (value !== undefined) {
95
+ if (String(savedObj[key]).trim() !== String(value)) {
96
+ note = `settings.json 的 ${key}=${JSON.stringify(savedObj[key])} 不是 [${spec.lo}, ${spec.hi}] 内的整数,已收敛为 ${value}`
97
+ }
98
+ limits[key] = value
99
+ if (note) diagnostics.push(note)
100
+ continue
101
+ }
102
+ note = `settings.json 的 ${key}=${JSON.stringify(savedObj[key])} 不是有效数字,已回落环境变量/默认值`
103
+ diagnostics.push(note)
104
+ }
105
+ const envValue = parseLimitValue(env?.[spec.env], spec)
106
+ limits[key] = envValue === undefined ? spec.dflt : envValue
107
+ }
108
+ return { limits, diagnostics }
109
+ }
110
+
111
+ /**
112
+ * 启动期同步读 `<状态根>/settings.json`(模块常量只能同步取)。
113
+ *
114
+ * 「文件不存在」是正常状态(还未从面板保存过);坏 JSON / 不是对象 / 读不动都返回
115
+ * 一条诊断 —— **不静默回落默认**是 Q04 的验收项之一。
116
+ */
117
+ export function readSettingsFileSync(stateRoot = STATE_ROOT) {
118
+ const file = join(stateRoot, 'settings.json')
119
+ let text
120
+ try {
121
+ text = readFileSync(file, 'utf8')
122
+ } catch (err) {
123
+ if (err?.code === 'ENOENT') return { file, data: null, diagnostics: [] }
124
+ return { file, data: null, diagnostics: [`读 ${file} 失败(${err?.code || err?.message}),本次按环境变量/默认值生效`] }
125
+ }
126
+ try {
127
+ const parsed = JSON.parse(text)
128
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
129
+ return { file, data: null, diagnostics: [`${file} 不是 JSON 对象,本次按环境变量/默认值生效`] }
130
+ }
131
+ return { file, data: parsed, diagnostics: [] }
132
+ } catch (err) {
133
+ return { file, data: null, diagnostics: [`${file} 解析失败(${err?.message ?? err}),本次按环境变量/默认值生效`] }
134
+ }
135
+ }
136
+
137
+ /**
138
+ * 启动期一次性解析的结果。**面板与引擎看到的是同一份**(`getSettings` 的 pending
139
+ * 走同一个 `resolveLimits`,只是输入换了下一次读到的文件内容)。
140
+ */
141
+ const STARTUP_SETTINGS = readSettingsFileSync()
142
+ const STARTUP_LIMITS = resolveLimits(STARTUP_SETTINGS.data?.limits)
143
+ export const STARTUP_LIMIT_DIAGNOSTICS = [...STARTUP_SETTINGS.diagnostics, ...STARTUP_LIMITS.diagnostics]
45
144
 
46
145
  /**
47
146
  * 一次注入最多带几条记忆。
48
147
  *
49
148
  * 5 → 10:**记忆条数 ÷ 注入名额 ≈ 命中率上限**,名额 5 时那个上限低到
50
149
  * 的根因就在这个比值上。名额翻倍是提升召回最直接的一手,代价是每轮多约 7.5K
51
- * 字符的上下文。**这个数字该由实测决定**,所以留了 SAGE_MEM_MAX_RESULTS 覆写口
52
- * (比较两档的命中率与 token 账单,别拍脑袋)。
150
+ * 字符的上下文。**这个数字该由实测决定**,所以留了设置页(`settings.json`)与
151
+ * `SAGE_MEM_MAX_RESULTS` 两个覆写口(优先级:设置页 > 环境变量 > 默认值)。
53
152
  */
54
- export const MAX_RESULTS = fromSpec(LIMIT_SPECS.maxResults)
153
+ export const MAX_RESULTS = STARTUP_LIMITS.limits.maxResults
55
154
 
56
155
  /** 单条记忆注入的字符上限。 */
57
- export const MAX_CHARS_PER_FILE = fromSpec(LIMIT_SPECS.maxChars)
156
+ export const MAX_CHARS_PER_FILE = STARTUP_LIMITS.limits.maxChars
58
157
 
59
158
  /**
60
159
  * baseline 名额。
@@ -63,7 +162,7 @@ export const MAX_CHARS_PER_FILE = fromSpec(LIMIT_SPECS.maxChars)
63
162
  * 而 baseline 恰恰是短问题、恢复会话时唯一的兜底。现在放到 8,
64
163
  * 排序见下面「按显式优先级排,不按文件名」那段。
65
164
  */
66
- export const MAX_BASELINE = fromSpec(LIMIT_SPECS.maxBaseline)
165
+ export const MAX_BASELINE = STARTUP_LIMITS.limits.maxBaseline
67
166
 
68
167
  /**
69
168
  * 单会话累计注入字节上限 —— 到顶以后这个会话就不再注入新的记忆。
@@ -77,7 +176,7 @@ export const MAX_BASELINE = fromSpec(LIMIT_SPECS.maxBaseline)
77
176
  * 60 KB 是与 CC 对齐的起点、不是实测出来的数字 —— 留了 SAGE_MEM_MAX_SESSION_BYTES
78
177
  * 覆写口,量过再定。
79
178
  */
80
- export const MAX_SESSION_BYTES = fromSpec(LIMIT_SPECS.maxSessionBytes)
179
+ export const MAX_SESSION_BYTES = STARTUP_LIMITS.limits.maxSessionBytes
81
180
 
82
181
  /**
83
182
  * 状态卡预留占总预算的比例,以及它的上下夹逼区间。
@@ -108,7 +207,7 @@ export const CONTENT_BUDGET = MAX_SESSION_BYTES - STATUS_RESERVE
108
207
  * 推理,相对天数才会。为什么需要它:陈旧记忆被当事实引用时,「引用」这个动作
109
208
  * 本身会让它显得更权威、而不是更可疑。
110
209
  */
111
- export const STALE_DAYS = fromSpec(LIMIT_SPECS.staleDays)
210
+ export const STALE_DAYS = STARTUP_LIMITS.limits.staleDays
112
211
 
113
212
  /**
114
213
  * 注入文本开头的引用纪律 —— **必须独立成节,不能压成一条 bullet**。
@@ -144,8 +243,6 @@ export const RECALL_DISCIPLINE = [
144
243
  * 而拿 'memory.md' 去 has() 是查不到的 —— 曾经就是这个漏洞:表单里填 memory.md
145
244
  * 就能整份覆盖索引,deleteFile 还能不可逆删掉它)。
146
245
  */
147
- export const STATE_ROOT = process.env.SAGE_MEM_STATE_DIR || join(dirname(MEMORY_DIR), '.sage-mem')
148
-
149
246
  /**
150
247
  * 2.0.0:有效性过滤(`invalid_at`)与矛盾抑制的总开关(设计稿 §11.2 Q8)。
151
248
  *
@@ -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
- * 读锁表。读不到 / JSON 坏 / 不是对象 / 值不是 `true` → 一律当空表。
25
- * **绝不抛**:锁表坏了最坏是「锁没生效」,不该升级成「读不了记忆」。
33
+ * 锁表的**进程内串行队列**,按锁表路径分键(不同记忆目录互不阻塞)。
34
+ *
35
+ * ⚠️ 边界与 `util.js` 的说明一致:进程内串行,不是跨进程文件锁。真实并发(面板 remote、
36
+ * 模型工具、归档策略)都在同一个插件进程里;同时开两个 DSH 实例写同一个记忆目录时仍可能
37
+ * 丢一次 —— 那要另加文件锁,本版明确不做。
26
38
  */
27
- export async function readLockSet(memoryDir) {
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
- const parsed = JSON.parse(await readFile(lockFileOf(memoryDir), 'utf8'))
30
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return new Set()
31
- const out = new Set()
32
- for (const [k, v] of Object.entries(parsed)) {
33
- if (v === true) out.add(nameKey(k))
34
- }
35
- return out
36
- } catch {
37
- return new Set()
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
- let table = {}
57
- try {
58
- const parsed = JSON.parse(await readFile(file, 'utf8'))
59
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) table = parsed
60
- } catch {
61
- table = {}
62
- }
63
- if (locked) table[key] = true
64
- else delete table[key]
65
- await mkdir(dirname(file), { recursive: true })
66
- await writeFileAtomic(file, JSON.stringify(table, null, 2) + '\n')
67
- return { ok: true, file: name, locked: !!locked }
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/lib/settings.js CHANGED
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import { readFile } from 'node:fs/promises'
8
8
  import { join } from 'node:path'
9
- import { LIMIT_SPECS, STATE_ROOT } from './memory/config.js'
9
+ import { LIMIT_SPECS, STATE_ROOT, resolveLimits } from './memory/config.js'
10
10
  import { MAX_RAW_BYTES } from './shared/limits.js'
11
11
  import { isPlainMdName } from './shared/md-name.js'
12
12
  export { LIMIT_SPECS, MAX_RAW_BYTES }
@@ -53,30 +53,46 @@ export function parseUiPatch(ui) {
53
53
  return { ok: true, patch: Object.keys(patch).length ? patch : null }
54
54
  }
55
55
 
56
- /** 读 `<状态根>/settings.json`;缺失、坏 JSON、不是对象都当「没设置」。 */
57
- export async function readSettingsFile() {
56
+ /**
57
+ * 读 `<状态根>/settings.json`,并把「为什么没读到」说清楚。
58
+ *
59
+ * 缺失是正常状态(`data:null, error:''`);坏 JSON / 不是对象返回 `error` ——
60
+ * Q04 要求坏设置给明确诊断,而不是让调用方一律看到 `null`、分不清「没设置过」
61
+ * 与「文件被我改坏了」。启动期的同步版本在 `memory/config.js`(语义一致)。
62
+ */
63
+ export async function readSettingsFileDetailed() {
64
+ let text
58
65
  try {
59
- const parsed = JSON.parse(await readFile(SETTINGS_FILE, 'utf8'))
60
- return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null
61
- } catch {
62
- return null
66
+ text = await readFile(SETTINGS_FILE, 'utf8')
67
+ } catch (err) {
68
+ if (err?.code === 'ENOENT') return { data: null, error: '' }
69
+ return { data: null, error: `读 settings.json 失败(${err?.code || err?.message})` }
70
+ }
71
+ let parsed
72
+ try {
73
+ parsed = JSON.parse(text)
74
+ } catch (err) {
75
+ return { data: null, error: `settings.json 解析失败(${err?.message ?? err})` }
76
+ }
77
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
78
+ return { data: null, error: 'settings.json 不是 JSON 对象' }
63
79
  }
80
+ return { data: parsed, error: '' }
64
81
  }
65
82
 
66
- /** 待生效的 5 项上限:`settings.json` > 环境变量 > 内置默认。 */
83
+ /** 读 `<状态根>/settings.json`;缺失、坏 JSON、不是对象都当「没设置」(要区分请用上面那个)。 */
84
+ export async function readSettingsFile() {
85
+ return (await readSettingsFileDetailed()).data
86
+ }
87
+
88
+ /**
89
+ * 待生效的 5 项上限:`settings.json` > 环境变量 > 内置默认。
90
+ *
91
+ * ⚠️ 判据**只有** `config.js` 的 `resolveLimits` 一份(Q04 的根因就是这里另写了一套):
92
+ * 启动生成常量与面板算 pending 必须共用同一份校验与优先级,否则 UI 会一直喊「待重启」。
93
+ */
67
94
  export function pendingLimits(fileObj) {
68
- const out = {}
69
- const saved = fileObj?.limits && typeof fileObj.limits === 'object' ? fileObj.limits : {}
70
- for (const [key, spec] of Object.entries(LIMIT_SPECS)) {
71
- const fromFile = Number(saved[key])
72
- if (Number.isFinite(fromFile)) {
73
- out[key] = clampInt(fromFile, spec)
74
- continue
75
- }
76
- const raw = process.env[spec.env]
77
- out[key] = clampInt(raw === undefined || raw === '' ? NaN : Number(raw), spec)
78
- }
79
- return out
95
+ return resolveLimits(fileObj?.limits).limits
80
96
  }
81
97
 
82
98
  /** 读 `<状态根>/reserved.json`(用户声明的保护名单)。**保留原始大小写**,好让 get 读回一致。 */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sage-mem",
3
3
  "description": "Cross-session memory for DeepSeek Harness — plain markdown files, no database, no worker.",
4
- "version": "2.1.10",
4
+ "version": "2.1.12",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "icon": "./icon.png",