sage-mem 2.1.9 → 2.1.11

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.9`** — 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.11`** — 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.9`** —— 顶部 npm 徽章是动态权威。**契约基线:`1.0.0`** —— 第一个稳定版,公开承诺、能力边界与「明确做不到」的清单冻结在
17
+ **当前发布版本:`2.1.11`** —— 顶部 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
@@ -6051,23 +6051,22 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
6051
6051
  function createIndexSkeleton() {
6052
6052
  setError(null);
6053
6053
  setNotice("");
6054
- if (!remote || typeof remote.readRaw !== "function" || typeof remote.writeRaw !== "function") {
6054
+ if (!remote || typeof remote.writeRaw !== "function") {
6055
6055
  setError(t("error.noRaw"));
6056
6056
  return;
6057
6057
  }
6058
6058
  setBusy(true);
6059
- // 先读再写:**已有索引绝不覆盖**。readRaw 对不存在的文件会 reject,正好当探针。
6060
- remote.readRaw("MEMORY.md").then(function () {
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);
6061
6065
  setBusy(false);
6062
- setNotice(t("notice.indexExists"));
6063
- }, function () {
6064
- return remote.writeRaw("MEMORY.md", INDEX_SKELETON).then(function (res) {
6065
- const out = unwrap(res);
6066
- setBusy(false);
6067
- if (out && out.ok === false) { setError(out.error || t("error.skeletonFailed")); return; }
6068
- setNotice(t("notice.indexCreated"));
6069
- load();
6070
- });
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();
6071
6070
  }).catch(function (e) {
6072
6071
  setBusy(false);
6073
6072
  setError(String((e && e.message) || e));
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'
@@ -27,7 +25,7 @@ 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()
@@ -120,13 +115,9 @@ export class MemoryGateway extends TypertRemoteService {
120
115
  }
121
116
 
122
117
  /**
123
- * 读单个记忆文件全文。
124
- *
125
- * 过闸门再读:名字在记忆目录里**不等于**内容住在记忆目录里 —— 符号链接 / 目录联接 /
126
- * 硬链接都能让一次「读自己的记忆」变成读别人的文件,而读到的内容会被注入模型上下文。
127
- *
128
- * 2.1.2(Q02):返回值多一个 `revision`(正文字节的 sha256)。保存时把它带回来,
129
- * 盘上版本变了就拒绝覆盖(见 `memory/write-protocol.js`)。
118
+ * 读单个记忆文件全文。过闸门再读:名字在记忆目录里**不等于**内容住在记忆目录里 ——
119
+ * 符号链接 / 目录联接 / 硬链接都能让一次「读自己的记忆」变成读别人的文件,而内容会进模型上下文。
120
+ * 2.1.2(Q02):返回值多一个 `revision`(正文字节 sha256);保存时带回来,盘上版本变了就拒绝覆盖。
130
121
  */
131
122
  async readFile(name) {
132
123
  const safe = safeName(name)
@@ -227,9 +218,8 @@ export class MemoryGateway extends TypertRemoteService {
227
218
  const res = await archiveMemory(MEMORY_DIR, name, reason)
228
219
  if (!res.ok) return { ok: false, error: res.error }
229
220
  /**
230
- * ⚠️ `unindexed` / `indexError` 必须**原样透出去**(2026-10-12 复审 R11 的配套):
231
- * 归档本身成功、索引却没摘掉时,这正是用户该被告知的那件事 —— 底下那句
232
- * 「索引没摘」在面板上要看得见,不能只活在 host 的返回值里被 strict 剥掉。
221
+ * ⚠️ `unindexed` / `indexError` 必须**原样透出去**(2026-10-12 复审 R11 的配套):归档成功、
222
+ * 索引却没摘掉时,这正是用户该被告知的那件事 —— 面板上要看得见,不能只活在 host 返回值里被 strict 剥掉。
233
223
  */
234
224
  return {
235
225
  ok: true,
@@ -257,15 +247,11 @@ export class MemoryGateway extends TypertRemoteService {
257
247
  }
258
248
 
259
249
  /**
260
- * 列出归档区的全部记忆(面板的「已归档」区)。
261
- *
262
- * 与 `listFiles()` 分开、形状也不同:`listFiles` 只列活动记忆、多一个字段都会破坏
263
- * 界面与断言依赖的形状;归档条目额外带 `archivedAt` / `archivedReason` / `revision`。
264
- * 旧版 autodream 工具直接移文件、不写留痕,所以前两个字段可能是空串。
265
- *
266
- * `revision` 是**内容身份**(2026-10-31 外部审查 Q07):归档代次 + 正文 revision。
267
- * 删除确认必须把它交回来 —— `archivedAt` 只是展示时间,同一分钟内不可区分,
268
- * 拿它当版本会让「A 改成同时间戳的 B」和「同分钟重新归档」的旧确认删掉新内容。
250
+ * 列出归档区的全部记忆(面板的「已归档」区)。与 `listFiles()` 分开、形状也不同:那个只列
251
+ * 活动记忆(多一个字段都会破坏界面与断言依赖的形状);归档条目额外带 `archivedAt` /
252
+ * `archivedReason` / `revision`。旧版 autodream 工具直接移文件、不写留痕,所以前两个可能是空串。
253
+ * `revision` 是**内容身份**(2026-10-31 外部审查 Q07):归档代次 + 正文 revision,删除确认必须原样交回 ——
254
+ * `archivedAt` 只是展示时间,同一分钟内不可区分,拿它当版本会让旧确认删掉新内容。
269
255
  */
270
256
  async listArchived() {
271
257
  const files = await scanArchived(this.ctx)
@@ -446,14 +432,12 @@ export class MemoryGateway extends TypertRemoteService {
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 ?? {}) }
@@ -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
  *
@@ -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/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.9",
4
+ "version": "2.1.11",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "icon": "./icon.png",