sage-mem 2.1.12 → 2.1.14

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.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
15
+ **Current release: `2.1.14`** — 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.
@@ -420,6 +420,8 @@ The two things are moved **differently**, spelled out:
420
420
 
421
421
  Value semantics: **out-of-range values are clamped to the boundary, illegal values (including pure whitespace) fall back to the default.**
422
422
 
423
+ **Scope of the state root, and migration (spelled out since 2.1.13).** `SAGE_MEM_STATE_DIR` is the **single** state root: settings, autodream configuration and reports, the lock table, the access ledger, the conflict queue, task checkpoints, rollback snapshots and its index all live under it, and every module (including the autodream engine and the lock table) resolves it from the same value instead of re-deriving `<parent of the memory directory>/.sage-mem`. **Changing the state root does not migrate old data**: the new root wins, the old `.sage-mem` is left untouched but no longer read, and startup prints one line naming what is still in the old root. Move it by hand if you want to keep it.
424
+
423
425
  ### What the Settings Page Can Change
424
426
 
425
427
  The panel can change the five caps above (written to `<state directory>/settings.json`, precedence **settings page > environment variable > built-in default**), as well as reserved names (file names that are protected and that the settings page may not write or delete, written to `<state directory>/reserved.json`). **Some items take effect only after restarting DSH**, and the panel writes out both the value in effect now and the value after restart.
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.12`** —— 顶部 npm 徽章是动态权威。**契约基线:`1.0.0`** —— 第一个稳定版,公开承诺、能力边界与「明确做不到」的清单冻结在
17
+ **当前发布版本:`2.1.14`** —— 顶部 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 是最直接的反馈。
@@ -420,6 +420,8 @@ sage-mem 给记忆目录的写入加了**版本守卫**:写入前记下当前
420
420
 
421
421
  取值语义:**越界夹到边界,非法值(含纯空格)回落默认。**
422
422
 
423
+ **状态根的范围与迁移(2.1.13 起写清)**:`SAGE_MEM_STATE_DIR` 是**唯一的状态根** —— 设置、自动做梦配置与报告、锁表、访问台账、冲突队列、任务检查点、回滚快照、台账都在它下面,插件内所有模块(含自动做梦引擎与锁表)都从同一个值解析,不再各自按记忆目录的父目录去拼。**更改状态根不会自动迁移旧数据**:新根优先、旧的 `.sage-mem` 原样保留但不再读取,启动时会打印一行提示点名旧根里还有哪些数据;要保留请手动移动。
424
+
423
425
  ### 设置页里能改的
424
426
 
425
427
  面板可改上面五个上限(写入 `<状态目录>/settings.json`,优先级 **设置页 > 环境变量 > 内置默认**),以及保留名(受保护、设置页不许写删的文件名,写入 `<状态目录>/reserved.json`)。**部分项要重启 DSH 才生效**,面板上会写清「当前生效 / 重启后」。
@@ -6,11 +6,16 @@
6
6
  * 这是本插件唯一一处「降级而不是失败」的设计,理由就在这里。
7
7
  */
8
8
  import { warnLog } from '../memory/util.js'
9
+ import { resolvePaths } from '../memory/config.js'
9
10
 
10
11
  /** @param {object} ctx @param {{AutodreamEngine:Function, AutodreamGateway:Function, memoryDir:string, sessionsRoot:string, reservedFiles:string[]}} deps */
11
12
  export function mountAutodream(ctx, { AutodreamEngine, AutodreamGateway, memoryDir, sessionsRoot, reservedFiles }) {
12
13
  try {
13
- const engine = new AutodreamEngine(ctx, { memoryDir, sessionsRoot, reservedFiles })
14
+ // Q09:路径组在 `config.js` 里**一次**解析,这里显式把 stateRoot 交给引擎 ——
15
+ // 旧实现让引擎从 memoryDir 的父目录重拼 `.sage-mem`,于是 SAGE_MEM_STATE_DIR 只对
16
+ // 配置模块生效,面板与引擎各读一套(审查实测 engineRoot 指向默认根)。
17
+ const paths = resolvePaths({ memoryDir, sessionsRoot })
18
+ const engine = new AutodreamEngine(ctx, { ...paths, reservedFiles })
14
19
  new AutodreamGateway(ctx, engine)
15
20
  engine.start()
16
21
  // 卸载时把定时器与锁收干净。effect 拿不到就交给 unref 兜底(不阻止进程退出)。
@@ -40,7 +40,9 @@ import { countSessionsSince, newRunId, stampHuman } from './util.js'
40
40
  import { flushAccess, ledgerFileFor, pickArchiveCandidates, readAccessLedger } from '../memory/access.js'
41
41
  import { archiveMemory } from '../memory/archive.js'
42
42
  import { scanMemoryFiles } from '../memory/scan.js'
43
- import { dirname, join } from 'node:path'
43
+ import { resolveStateRoot } from '../memory/config.js'
44
+ import { basename, dirname, join } from 'node:path'
45
+ import { stat } from 'node:fs/promises'
44
46
 
45
47
  import * as gateMod from './gate.js'
46
48
  import * as llmloopMod from './llm-loop.js'
@@ -86,7 +88,18 @@ export class AutodreamEngine {
86
88
  this.version = opts.version
87
89
  /** 使用者自己声明的受保护文件名(`<状态根>/reserved.json`)——注入 prompt,绝不写死。 */
88
90
  this.reservedFiles = Array.isArray(opts.reservedFiles) ? opts.reservedFiles : []
89
- this.stateRoot = join(dirname(this.memoryDir), STATE_DIR)
91
+ /**
92
+ * 状态根(Q09):装配方**显式传入**(`config.js` 的 `resolvePaths()` 一次解析);
93
+ * 直接构造引擎(单测 / 旧调用方)时按 `resolveStateRoot()` 派生 —— 两条路同一判据,
94
+ * 不再在这里 `dirname(memoryDir)` 重拼 `.sage-mem`。
95
+ */
96
+ this.stateRoot = typeof opts.stateRoot === 'string' && opts.stateRoot !== ''
97
+ ? opts.stateRoot
98
+ : resolveStateRoot(this.memoryDir)
99
+ /** 旧状态根:只有「本次状态根 ≠ 按记忆目录派生的那个」时才存在(迁移提示用)。 */
100
+ const derivedStateRoot = join(dirname(this.memoryDir), STATE_DIR)
101
+ this.legacyStateRoot = this.stateRoot === derivedStateRoot ? '' : derivedStateRoot
102
+ this._legacyStateReported = false
90
103
  this.home = join(this.stateRoot, FEATURE_DIR)
91
104
  this.configPath = join(this.stateRoot, 'autodream.json')
92
105
  this.legacyConfigPath = join(this.stateRoot, LEGACY_STATE_FILE)
@@ -128,7 +141,37 @@ export class AutodreamEngine {
128
141
  // ────────────────────────────── 配置与状态 ──────────────────────────────
129
142
 
130
143
  // ── 配置与状态:实现在 lib/autodream/settings.js ──
131
- async load() { return settingsMod.load.call(this) }
144
+ async load() {
145
+ const state = await settingsMod.load.call(this)
146
+ // 启动检查 / 面板打开都会走到 load():在这里提示一次「旧状态根还有数据」。
147
+ await this.checkLegacyStateRoot()
148
+ return state
149
+ }
150
+
151
+ /**
152
+ * Q09 的旧目录规则:**不自动迁移 + 启动提示一次**。
153
+ *
154
+ * 为什么不做自动迁移:状态根下住着配置、锁表、台账、冲突、检查点、快照六类数据,
155
+ * 自动搬移任何一类出错都可能让「锁」或「回滚点」在用户不知情时消失 —— 那是比
156
+ * 「旧数据暂时看不见」严重得多的损失。所以规则写成:**新状态根优先、旧数据留在原位、
157
+ * 明确告诉用户手动搬**。README 的措辞由 Lead 统一改,G4 只在报告里给出建议口径。
158
+ */
159
+ async checkLegacyStateRoot() {
160
+ if (this._legacyStateReported || !this.legacyStateRoot) return
161
+ this._legacyStateReported = true
162
+ const legacy = this.legacyStateRoot
163
+ const markers = ['autodream.json', LEGACY_STATE_FILE, 'locked.json', 'access.json', 'settings.json', FEATURE_DIR, 'tasks', 'conflicts']
164
+ const found = []
165
+ for (const name of markers) {
166
+ if (await stat(join(legacy, name)).then(() => true, () => false)) found.push(basename(name))
167
+ }
168
+ if (found.length === 0) return
169
+ this.log('warn',
170
+ `检测到旧状态根 ${legacy} 仍有数据(${found.join(' / ')}),而本次状态根是 ${this.stateRoot}` +
171
+ '(SAGE_MEM_STATE_DIR 覆写)。本版不自动迁移:旧数据留在原位、不再读取 —— ' +
172
+ '需要保留请手动把要用的文件移到新状态根。')
173
+ }
174
+
132
175
  async persist() { return settingsMod.persist.call(this) }
133
176
  async getConfig() { return settingsMod.getConfig.call(this) }
134
177
  async setConfig(patch) { return settingsMod.setConfig.call(this, patch) }
@@ -22,11 +22,37 @@ export const SESSIONS_ROOT = process.env.DSH_HOME ? join(process.env.DSH_HOME, '
22
22
  * 状态根:配置、锁表、台账、冲突、检查点、快照都住在这里。
23
23
  *
24
24
  * `SAGE_MEM_STATE_DIR` 显式覆写优先;没设时跟着记忆目录走(它的上一层 + `.sage-mem`)。
25
- * ⚠️ Q09:**所有子模块都必须从这里取**,不许各自 `dirname(memoryDir)` + 拼 `.sage-mem` ——
26
- * 那样覆写只对一部分模块生效,面板与引擎会各读一套配置。
25
+ * ⚠️ Q09:**所有子模块都必须经 `resolveStateRoot()` / `resolvePaths()` 取**,不许各自
26
+ * `dirname(memoryDir)` + 拼 `.sage-mem` —— 那样覆写只对一部分模块生效,面板与引擎会各读一套。
27
27
  */
28
- export const STATE_ROOT = process.env.SAGE_MEM_STATE_DIR || join(dirname(MEMORY_DIR), '.sage-mem')
28
+ export function resolveStateRoot(memoryDir = MEMORY_DIR) {
29
+ // 默认记忆目录直接用装配期算好的 STATE_ROOT(含 SAGE_MEM_STATE_DIR 覆写);
30
+ // 调用方显式传了**别的**目录(autodream 单测 / 多库场景)时,状态跟着那个目录走 ——
31
+ // 否则一个全局环境变量会把互不相关的记忆库的锁表 / 快照全灌进同一个目录。
32
+ if (memoryDir === MEMORY_DIR) return process.env.SAGE_MEM_STATE_DIR || join(dirname(MEMORY_DIR), '.sage-mem')
33
+ return join(dirname(memoryDir), '.sage-mem')
34
+ }
35
+
36
+ /**
37
+ * **装配时一次解析**的路径组(Q09)。
38
+ *
39
+ * 三个路径只在这里算一次,然后显式传给子模块 —— 引擎、锁表、台账、冲突、检查点、快照
40
+ * 不能再各自重拼。可显式覆写(测试 / 多库):
41
+ * - `memoryDir` 给了就用它;没给用模块常量
42
+ * - `stateRoot` 给了就用它;否则按 `resolveStateRoot(memoryDir)` 派生
43
+ * - `sessionsRoot` 给了(含空串)就用它;否则用模块常量
44
+ */
45
+ export function resolvePaths(overrides = {}) {
46
+ const memoryDir = overrides.memoryDir || MEMORY_DIR
47
+ const stateRoot = typeof overrides.stateRoot === 'string' && overrides.stateRoot !== ''
48
+ ? overrides.stateRoot
49
+ : resolveStateRoot(memoryDir)
50
+ const sessionsRoot = overrides.sessionsRoot !== undefined ? overrides.sessionsRoot : SESSIONS_ROOT
51
+ return { memoryDir, stateRoot, sessionsRoot }
52
+ }
29
53
 
54
+ export const PATHS = Object.freeze(resolvePaths())
55
+ export const STATE_ROOT = PATHS.stateRoot
30
56
  /** 读一个整数型环境变量并在范围内收敛;取不到或非法就用默认值。 */
31
57
  export function envInt(name, dflt, lo, hi) {
32
58
  // ⚠️ 必须先 trim:`Number(' ')` 是 **0** 而不是 NaN,于是纯空格的值会被
@@ -22,11 +22,19 @@
22
22
  import { mkdir, readFile } from 'node:fs/promises'
23
23
  import { dirname, join } from 'node:path'
24
24
  import { nameKey } from './naming.js'
25
+ import { resolveStateRoot } from './config.js'
25
26
  import { createWriteQueue, writeFileAtomic } from './util.js'
26
27
 
27
- /** 锁表文件:状态根下,与记忆文件分开。 */
28
- export function lockFileOf(memoryDir) {
29
- return join(dirname(memoryDir), '.sage-mem', 'locked.json')
28
+ /**
29
+ * 锁表文件:状态根下,与记忆文件分开。
30
+ *
31
+ * ⚠️ Q09:状态根必须经 `config.js` 的 `resolveStateRoot()` 解析(尊重 `SAGE_MEM_STATE_DIR`),
32
+ * 不许在这里 `dirname(memoryDir)` + 拼 `.sage-mem` —— 旧写法让「状态目录覆写」只对一部分
33
+ * 模块生效,于是面板读的锁表与引擎写的是两份。装配期显式给了 `stateRoot` 就用它。
34
+ */
35
+ export function lockFileOf(memoryDir, stateRoot) {
36
+ const root = typeof stateRoot === 'string' && stateRoot !== '' ? stateRoot : resolveStateRoot(memoryDir)
37
+ return join(root, 'locked.json')
30
38
  }
31
39
 
32
40
  /**
@@ -110,9 +110,9 @@ export function createRecallInjector({ ctx, state, tools, taskIdOf }) {
110
110
  const files = await scanMemoryFiles(ctx, sessionId)
111
111
 
112
112
  // ── 代次:**先处理状态转换,再算正文与控制提示** ──
113
- // 压缩 / 清空(消息条数下降)在这一行里就让签名、两本账、卡签名、baseline 标记
114
- // 一起归零。放到后面(比如去重之后再算)就等于「拿上一代的账本判这一代」——
115
- // 复审 R03 的「同题压缩后仍被挡住」正是这么来的。
113
+ // `begin()` 会把「这一次观测到的消息条数」立刻落进状态(Q08)——去重 / 零命中 /
114
+ // 预算拒绝的轮次不会走到 commit,若不在这里落账,下一次 begin 就还拿旧基数比,
115
+ // 压缩就永远判不出来。条数下降时它同时把签名、两本账、卡签名、baseline 标记归零。
116
116
  const ledgerKey = session.id || 'no-session-id'
117
117
  const st = state.begin(ledgerKey, messages.length)
118
118
 
@@ -249,9 +249,10 @@ export function createRecallInjector({ ctx, state, tools, taskIdOf }) {
249
249
  return assembled
250
250
  }
251
251
 
252
- // 状态一次写回:签名、两本账、卡签名、缺口卡、条数、baseline 标记。
253
- // 放在这里(而不是更早)是刻意的 —— 上面每一步都可能没真注进去东西,
254
- // 那就不该让状态前进;一次失败把一条记忆永久挡在门外是最难查的一种。
252
+ // 投递账一次写回:签名、两本账、卡签名、缺口卡、baseline 标记,以及
253
+ // **delivered 条数**(`messageCount`)。放在这里(而不是更早)是刻意的 ——
254
+ // 上面每一步都可能没真注进去东西,那就不该让投递账前进;一次失败把一条记忆永久
255
+ // 挡在门外是最难查的一种。压缩判据用的**观测账**在 `begin()` 里已经落过了(Q08)。
255
256
  state.commit(ledgerKey, {
256
257
  signature,
257
258
  contentBytes,
@@ -6,9 +6,20 @@
6
6
  * 否则一次失败会把这条记忆永久挡在门外。唯一的例外是代次变化:那是**已经发生的事实**
7
7
  * (上下文真的被压缩了),必须在算任何东西之前生效,否则这一步会拿着上一代的账做判断。
8
8
  *
9
- * ⚠️ 诚实边界:压缩信号是「消息条数下降」这条启发式,它**覆盖不了条数不降的压缩**
10
- * (宿主原地改写历史,或截断后又在同一步补足条数)。那种情况下这里看不出代次变化,
11
- * 记忆仍会被去重挡住。要真正解决得等宿主给出可比的上下文代次标识(目前没有)。
9
+ * ── 2.1.14(外部审查 Q08):两本账拆开 ─────────────────────────────────
10
+ * 旧实现只有一个 `messageCount`,记的是「上一次**见效**时的条数」,只有真正提交才前进。
11
+ * 于是被去重 / 零命中 / 预算拒绝的那些轮次从不更新它 —— 2 条时注入过、涨到 20 条被去重、
12
+ * 压缩到 10 条再问时,压缩判定拿 10 比的是 2(投递账),不是 20(刚观测到的实况),
13
+ * 判不出压缩,签名没清,继续去重、不注入。
14
+ *
15
+ * 现在两本账:
16
+ * - `lastObservedMessageCount`:**每次 assemble 都推进**(观测是既成事实,与是否投递无关);
17
+ * - `messageCount`(delivered 账):只有成功投递才前进。
18
+ * 压缩判定只看观测账;`begin()` 会在返回前把观测写回状态,所以「不提交的轮次」也不会丢。
19
+ *
20
+ * ⚠️ 诚实边界(作者已声明,本版不越界):压缩信号是「消息条数下降」这条启发式,它
21
+ * **覆盖不了条数不降的压缩**(宿主原地改写历史,或截断后又在同一步补足条数)。那种情况下
22
+ * 这里看不出代次变化,记忆仍会被去重挡住。要真正解决得等宿主给出可比的上下文代次标识。
12
23
  */
13
24
  export function createSessionInjectState(maxSessions = 64) {
14
25
  const sessions = new Map()
@@ -18,27 +29,44 @@ export function createSessionInjectState(maxSessions = 64) {
18
29
  cardBytes: 0, // 本代状态卡累计字节(与正文分账,见 config.js)
19
30
  cardSig: '', // 本代已发过的那张卡(含检查点句柄的合成文本)
20
31
  gapCard: '', // 本代渲染过的缺口卡正文(签名没变时沿用,避免换个数字重发)
21
- messageCount: -1, // 上一次见效时的消息条数(压缩判据)
32
+ lastObservedMessageCount: -1, // 上一次 assemble **观测到**的消息条数(压缩判据的唯一基点)
33
+ messageCount: -1, // 上一次**成功投递**时的消息条数(delivered 账,只有 commit 推进)
22
34
  baselineDone: false, // 本代是否已经给过 baseline
23
35
  })
24
36
  return {
25
37
  /**
26
38
  * 处理代次变化,取回本会话这一代的起点。**必须在算正文与控制提示之前调用。**
27
39
  * 返回值是一份快照 —— 调用方只读,写回统一走 `commit()`。
40
+ *
41
+ * ⚠️ 与旧实现不同:这里会**立刻**把观测到的条数写回状态。观测是已经发生的事实,
42
+ * 而「去重 / 零命中 / 预算拒绝」的轮次不会调用 `commit()` —— 不在这里落账,
43
+ * 下一次 begin 就还拿着旧基数比(Q08)。
28
44
  */
29
45
  begin(key, messageCount) {
30
46
  const prev = sessions.get(key)
31
- const generationChanged = !!prev && messageCount < prev.messageCount
32
- // 条数下降 → 四样东西一起归零。少清任何一样都会留下一个「上一代的真相」:
47
+ const observed = Number.isFinite(messageCount) ? messageCount : -1
48
+ // 压缩判据对着**观测账**比,不是对着上一次投递比。
49
+ const generationChanged = !!prev && observed >= 0 && prev.lastObservedMessageCount >= 0 &&
50
+ observed < prev.lastObservedMessageCount
51
+ // 条数下降 → 除了观测基点,四样东西一起归零。少清任何一样都会留下一个「上一代的真相」:
33
52
  // 字节账残留 → 新内容注不进来;baseline 残留 → 它再也不出现;
34
- // 卡签名残留 → 恢复入口永久失效。
35
- if (generationChanged) sessions.set(key, { ...fresh(), messageCount })
36
- const cur = sessions.get(key) ?? fresh()
37
- return { ...cur, generationChanged }
53
+ // 卡签名残留 → 恢复入口永久失效;投递账残留 → 调试/诊断把旧代当新代。
54
+ const base = generationChanged ? fresh() : { ...(prev ?? fresh()) }
55
+ base.lastObservedMessageCount = observed
56
+ sessions.set(key, base)
57
+ // 只 begin 不提交的会话(全程被去重)也会进 Map —— 长驻宿主里必须同样按 FIFO 收敛。
58
+ if (sessions.size > maxSessions) sessions.delete(sessions.keys().next().value)
59
+ return { ...base, generationChanged }
38
60
  },
39
- /** 一次写回。只防长驻进程无限增长:被挤掉的老会话下次醒来至多把同一批记忆再注一次。 */
61
+ /**
62
+ * 一次写回。只防长驻进程无限增长:被挤掉的老会话下次醒来至多把同一批记忆再注一次。
63
+ *
64
+ * 合并(而不是整体替换)是刻意的:`begin()` 刚写下的观测账必须留在状态里 ——
65
+ * 调用方只回传投递账(签名 / 字节 / 卡 / baseline),不该关心、也不该覆盖观测账。
66
+ */
40
67
  commit(key, next) {
41
- sessions.set(key, { ...next })
68
+ const prev = sessions.get(key) ?? fresh()
69
+ sessions.set(key, { ...prev, ...(next && typeof next === 'object' ? next : {}) })
42
70
  // FIFO 而不是 LRU:Map 重写已存在的键不会挪动位置,与旧行为一致。
43
71
  if (sessions.size > maxSessions) sessions.delete(sessions.keys().next().value)
44
72
  },
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.12",
4
+ "version": "2.1.14",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "icon": "./icon.png",