sage-mem 0.9.11 → 0.9.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.
@@ -83,6 +83,19 @@ export async function setConfig(patch) {
83
83
  if (p.rollbackScope === 'files' || p.rollbackScope === 'all') c.rollbackScope = p.rollbackScope
84
84
  // 归档策略三档:与上面 trigger / rollbackScope 同一套写法 —— 非法值不写入,保留原值。
85
85
  if (p.autoArchive === 'off' || p.autoArchive === 'report' || p.autoArchive === 'auto') c.autoArchive = p.autoArchive
86
+ /**
87
+ * 自动归档的三个阈值(天)。
88
+ *
89
+ * ⚠️ 2026-10-11 补:v0.9.9 加了界面却**漏了这里**(以及 typert 那层的 schema)——
90
+ * 于是界面改了值、宿主静默丢弃、回读报「保存没生效」。
91
+ * 范围与界面一致(1~3650 天);越界收敛到默认值而不是写进一份坏配置。
92
+ */
93
+ if (p.archiveAfterDaysProject !== undefined)
94
+ c.archiveAfterDaysProject = Math.round(clampNum(Number(p.archiveAfterDaysProject), 1, 3650, DEFAULT_CONFIG.archiveAfterDaysProject))
95
+ if (p.archiveAfterDaysReference !== undefined)
96
+ c.archiveAfterDaysReference = Math.round(clampNum(Number(p.archiveAfterDaysReference), 1, 3650, DEFAULT_CONFIG.archiveAfterDaysReference))
97
+ if (p.archiveAfterDaysUser !== undefined)
98
+ c.archiveAfterDaysUser = Math.round(clampNum(Number(p.archiveAfterDaysUser), 1, 3650, DEFAULT_CONFIG.archiveAfterDaysUser))
86
99
  if (p.minHours !== undefined) c.minHours = clampNum(Number(p.minHours), 1, 720, DEFAULT_CONFIG.minHours)
87
100
  if (p.minSessions !== undefined) c.minSessions = Math.round(clampNum(Number(p.minSessions), 1, 200, DEFAULT_CONFIG.minSessions))
88
101
  if (p.maxSteps !== undefined) c.maxSteps = Math.round(clampNum(Number(p.maxSteps), 1, 80, DEFAULT_CONFIG.maxSteps))
@@ -19,7 +19,7 @@ import { ARCHIVE_DIR_NAME, addArchiveMeta, inspectDir, inspectMemoryEntry, local
19
19
  import { isReserved, nameKey, safeName } from './naming.js'
20
20
  import { writeFileAtomic } from './util.js'
21
21
  import { isLocked } from './lock.js'
22
- import { ensureIndexEntry } from './index-file.js'
22
+ import { ensureIndexEntry, dropIndexEntry } from './index-file.js'
23
23
  import { mkdir, readFile, stat, unlink } from 'node:fs/promises'
24
24
  import { join } from 'node:path'
25
25
 
@@ -90,7 +90,14 @@ export async function archiveMemory(memoryDir, name, reason, at = localStamp())
90
90
  // 先落归档副本、再删原件:中途崩了最坏是两份都在(人工可收拾),不会一份都没有。
91
91
  await writeFileAtomic(dst, stamped)
92
92
  await unlink(src)
93
- return { ok: true, file: safe, archivedReason: why }
93
+ /**
94
+ * **从索引里摘掉**(与恢复时补索引对称):索引是「活动记忆的清单」,
95
+ * 记忆归档了就该从清单里退出 —— 不摘的话审计会报「索引悬空」。
96
+ * 2026-10-11 在真实库里撞到过:归档两条,体检立刻从 0 问题变 2 问题。
97
+ * 摘失败**不算归档失败**:文件已经移走了,那才是主要结果。
98
+ */
99
+ const unindexed = await dropIndexEntry(memoryDir, safe).catch(() => null)
100
+ return { ok: true, file: safe, archivedReason: why, unindexed: !!(unindexed && unindexed.removed) }
94
101
  }
95
102
 
96
103
  /**
@@ -59,3 +59,32 @@ export async function ensureIndexEntry(memoryDir, name) {
59
59
  await writeFileAtomic(indexPath, head + line + '\n')
60
60
  return { ok: true, added: true, file: name, line }
61
61
  }
62
+
63
+ /**
64
+ * 把指向 `name` 的索引行**摘掉**(归档时用)。与 `ensureIndexEntry` 对称:
65
+ * 索引是**活动记忆的清单**,记忆归档了就该从清单里退出。
66
+ *
67
+ * 为什么必须做(2026-10-11 在真实库里撞到):v0.9.8 只做了「恢复时补索引」那一半,
68
+ * 归档时没摘 —— 于是每归档一条,索引里就多一条指向「已不在顶层」的条目,审计报「索引悬空」。
69
+ * 我归档两条时当场看到体检从 0 问题变 2 问题。
70
+ *
71
+ * 只删**指向它的那一行**,别的一律不动(索引是用户手写的)。
72
+ * 匹配与 `hasEntry` 同一套:大小写不敏感、容忍 `#L12` 锚点;因为锚定了 `](`,
73
+ * 所以 `a.md` 不会误伤 `x_a.md`。幂等:本来就没有这一行时一个字节都不改。
74
+ *
75
+ * @returns {Promise<{ok:boolean, removed:boolean, error?:string}>}
76
+ */
77
+ export async function dropIndexEntry(memoryDir, name) {
78
+ const indexName = await findIndexFile(memoryDir)
79
+ // 没有索引是正常状态:归档照常进行,只是没有清单要维护。
80
+ if (!indexName) return { ok: true, removed: false, error: 'no-index' }
81
+ const indexPath = join(memoryDir, indexName)
82
+ const indexText = await readFile(indexPath, 'utf8').catch(() => '')
83
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
84
+ const lineRe = new RegExp(`\\]\\(\\s*${escaped}(?:#[^)]*)?\\s*\\)`, 'i')
85
+ const lines = indexText.split('\n')
86
+ const kept = lines.filter((l) => !lineRe.test(l))
87
+ if (kept.length === lines.length) return { ok: true, removed: false }
88
+ await writeFileAtomic(indexPath, kept.join('\n'))
89
+ return { ok: true, removed: true }
90
+ }
@@ -342,6 +342,18 @@ const autodreamPatchSchema = z.object({
342
342
  'maxSteps': z.number().optional(),
343
343
  'maxSnapshotKeep': z.number().optional(),
344
344
  'rollbackScope': z.string().optional(),
345
+ /**
346
+ * ⚠️ 这一组是 2026-10-11 补的 —— 之前**漏了**,而 strict codec 会把**未声明的字段整个剥掉**,
347
+ * 所以 patch 还没到宿主侧就已经少了一半:
348
+ * · `autoArchive` 缺席 = **三档策略从 v0.9.0 起一直是摆设**(界面点了、值没落盘)
349
+ * · 三个 `archiveAfterDays*` 缺席 = v0.9.9 新加的阈值界面必然报「保存没生效」
350
+ * 断言在 `test/autodream-config-test.mjs`,它**先过 schema 再调 setConfig**,
351
+ * 走的才是界面的真实路径(直接调 setConfig 会绕过这一层,测不出剥字段)。
352
+ */
353
+ 'autoArchive': z.string().optional(),
354
+ 'archiveAfterDaysProject': z.number().optional(),
355
+ 'archiveAfterDaysReference': z.number().optional(),
356
+ 'archiveAfterDaysUser': z.number().optional(),
345
357
  }).readonly()
346
358
 
347
359
  const reportListSchema = z.array(z.object({
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sage-mem",
3
3
  "description": "File-based memory plugin for DeepSeek Harness (DSH) — cross-session memory in plain markdown files, with a memory star-map view, type/tag filtering, retrieval aliases, and an optional autodream pass that consolidates memories on demand or on a schedule — with per-run rollback points, a per-file change declaration (what changed and why), and a separately selectable model for the consolidation pass",
4
- "version": "0.9.11",
4
+ "version": "0.9.12",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {