sage-mem 0.9.10 → 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.
- package/lib/autodream/settings.js +13 -0
- package/lib/client.js +32 -4
- package/lib/memory/archive.js +9 -2
- package/lib/memory/index-file.js +29 -0
- package/lib/typert.host.js +12 -0
- package/package.json +1 -1
|
@@ -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))
|
package/lib/client.js
CHANGED
|
@@ -2579,6 +2579,30 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
2579
2579
|
? h("button", { type: "button", className: "smem-btn", key: "close", onClick: props.onClose }, "收起")
|
|
2580
2580
|
: null);
|
|
2581
2581
|
|
|
2582
|
+
/**
|
|
2583
|
+
* 稳定的 remote 引用。
|
|
2584
|
+
*
|
|
2585
|
+
* 起因(issue #3,2026-10-10 外部报告):面板在**渲染期**取 `ctx.get("remote.x")`,
|
|
2586
|
+
* 又把结果放进 `useEffect` 的依赖数组 —— 而真实宿主里 `ctx.get()` 每次返回一个
|
|
2587
|
+
* **新的包装对象**,于是
|
|
2588
|
+
* 渲染 → effect 重跑 → 6 个 RPC + setState → 再渲染 → …
|
|
2589
|
+
* 滚成无限请求风暴(报告者实测每秒上千次;本机观察到的形态是「组件一直在 setState」)。
|
|
2590
|
+
*
|
|
2591
|
+
* 为什么本机测试一直没抓到:mock 的 `ctx.get` 每次返回**同一个**对象 —— 假的安全。
|
|
2592
|
+
* 测试里现在有 `unstableRemoteRefs` 开关来复现真实行为。
|
|
2593
|
+
*
|
|
2594
|
+
* 同一个 ctx 永远拿到同一个 remote。用 WeakMap 而不是普通 Map:ctx 是长生命周期对象,
|
|
2595
|
+
* WeakMap 不会因为这张缓存把它拖住。
|
|
2596
|
+
*/
|
|
2597
|
+
const REMOTE_CACHE = new WeakMap();
|
|
2598
|
+
function stableRemote(ctx, name) {
|
|
2599
|
+
if (!ctx || typeof ctx.get !== "function") return null;
|
|
2600
|
+
let byName = REMOTE_CACHE.get(ctx);
|
|
2601
|
+
if (!byName) { byName = new Map(); REMOTE_CACHE.set(ctx, byName); }
|
|
2602
|
+
if (!byName.has(name)) byName.set(name, ctx.get(name) || null);
|
|
2603
|
+
return byName.get(name);
|
|
2604
|
+
}
|
|
2605
|
+
|
|
2582
2606
|
/**
|
|
2583
2607
|
* 「体检」结果面板。
|
|
2584
2608
|
*
|
|
@@ -2818,9 +2842,11 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
2818
2842
|
* (比如「记忆 + 会话记录」会把私密对话送进模型),默认全是最保守的那一档。
|
|
2819
2843
|
*/
|
|
2820
2844
|
function AutodreamPanel(props) {
|
|
2821
|
-
|
|
2845
|
+
// ⚠️ 必须走 stableRemote:真实宿主里 `ctx.get()` 每次返回**新对象**,
|
|
2846
|
+
// 而下面那个 effect 依赖 remote —— 直接用会让 effect 无限重跑(issue #3)。
|
|
2847
|
+
const remote = stableRemote(props.ctx, "remote.autodream");
|
|
2822
2848
|
// 注入设置走 memory 那半边(autodream 只管整理那趟)
|
|
2823
|
-
const memRemote = props.ctx
|
|
2849
|
+
const memRemote = stableRemote(props.ctx, "remote.memory");
|
|
2824
2850
|
const sCfg = react.useState(null); const cfg = sCfg[0], setCfg = sCfg[1];
|
|
2825
2851
|
const sSt = react.useState(null); const st = sSt[0], setSt = sSt[1];
|
|
2826
2852
|
const sErr = react.useState(null); const err = sErr[0], setErr = sErr[1];
|
|
@@ -3483,7 +3509,7 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
3483
3509
|
* 保存后**一定回读**(`setConfig` 对非法值是「忽略并保留原值」,只信返回值会骗人)。
|
|
3484
3510
|
*/
|
|
3485
3511
|
function ArchiveSettingsPanel(props) {
|
|
3486
|
-
const remote = props.ctx
|
|
3512
|
+
const remote = stableRemote(props.ctx, "remote.autodream");
|
|
3487
3513
|
const sCfg = react.useState(null); const cfg = sCfg[0], setCfg = sCfg[1];
|
|
3488
3514
|
const sDraft = react.useState(null); const draft = sDraft[0], setDraft = sDraft[1];
|
|
3489
3515
|
const sErr = react.useState(null); const err = sErr[0], setErr = sErr[1];
|
|
@@ -3644,7 +3670,9 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
3644
3670
|
]);
|
|
3645
3671
|
}
|
|
3646
3672
|
function Section(props) {
|
|
3647
|
-
|
|
3673
|
+
// 走 stableRemote:真实宿主里 `ctx.get()` 每次返回新对象(issue #3 的成因)。
|
|
3674
|
+
// 这个面板的 effect 依赖是 `[]`、暂时不会爆,但引用稳定是**所有**面板的共同前提。
|
|
3675
|
+
const remote = stableRemote(props.ctx, "remote.memory");
|
|
3648
3676
|
const sItems = react.useState([]);
|
|
3649
3677
|
const items = sItems[0], setItems = sItems[1];
|
|
3650
3678
|
const sLoading = react.useState(false);
|
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 { 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
|
-
|
|
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
|
/**
|
package/lib/memory/index-file.js
CHANGED
|
@@ -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
|
+
}
|
package/lib/typert.host.js
CHANGED
|
@@ -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.
|
|
4
|
+
"version": "0.9.12",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|