sage-mem 0.9.6 → 0.9.9
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 +16 -4
- package/lib/client.js +378 -77
- package/lib/index.js +45 -5
- package/lib/memory/access.js +6 -1
- package/lib/memory/archive.js +18 -1
- package/lib/memory/index-file.js +61 -0
- package/lib/memory/lock.js +68 -0
- package/lib/typert.host.js +46 -0
- package/lib/update.js +103 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -49,13 +49,21 @@ sage-mem 换一条路:**每条记忆就是一个 Markdown 文件**。用编辑
|
|
|
49
49
|
|
|
50
50
|
*文件列表 —— 头部带常显「体检 N 个问题」与「待重启生效」,不用点开就知道该不该处理;灰底条目是已归档,随时可以恢复。*
|
|
51
51
|
|
|
52
|
-

|
|
53
53
|
|
|
54
|
-
*工具抽屉 ——
|
|
54
|
+
*工具抽屉 —— 关着时只占一个按钮,展开后两项各带状态:归档候选 2 条 / 保留名 3 个。(体检不在这里:它自己有一页。)*
|
|
55
55
|
|
|
56
|
-

|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
*锁定 —— 重要记忆(人设、偏好这类)点一下就锁上:**自动归档的候选里不再出现它,手动点归档也会被宿主拒绝**。锁存在状态目录里,不写进记忆自己的 frontmatter —— 那会被自动整理重写掉,锁一丢它又会回到候选里。*
|
|
59
|
+
|
|
60
|
+

|
|
61
|
+
|
|
62
|
+
*体检(独立成一个页签)—— 索引悬空、漏索引、断链这类硬问题按类报红;「指向已归档」只作提示、不计入问题,也不提供一键修补按钮(改哪条由你决定)。从档案馆恢复一条记忆时,插件会顺手把它补回索引 —— 以前只搬文件、不补条目,于是顶层有文件、`MEMORY.md` 里没有,体检一直报「漏索引」。*
|
|
63
|
+
|
|
64
|
+

|
|
65
|
+
|
|
66
|
+
*更新提示 —— 只在**真有新版本**时出现一条横幅,写明新版本与当前版本;「看更新内容」开新标签页,「×」只是这次不看。查不到 / 被限流 / 网络不通一律静默,不会因为查版本失败而冒红条。*
|
|
59
67
|
|
|
60
68
|

|
|
61
69
|
|
|
@@ -69,6 +77,10 @@ sage-mem 换一条路:**每条记忆就是一个 Markdown 文件**。用编辑
|
|
|
69
77
|
|
|
70
78
|
*自动做梦 · 设置 —— 运行 / 设置 / 记录 二级分组;五个注入参数两列排开,并写清「当前生效」与「重启后」,不会让人误以为改完立刻生效。*
|
|
71
79
|
|
|
80
|
+

|
|
81
|
+
|
|
82
|
+
*自动归档(独立成一个页签,排在「自动做梦」与「体检」之间)—— 它和自动做梦不是一件事:归档是**确定性规则**(按访问台账的闲置天数算,不调模型),混在模型那套设置里既难找、也容易让人以为归档会花钱。这一页决定「多久算闲置」,以及要不要真的动手;阈值**保存即生效**,不用重启。*
|
|
83
|
+
|
|
72
84
|
## 快速开始
|
|
73
85
|
|
|
74
86
|
需要 DSH `0.1.2-rc.1` 或更高(见[兼容性](#dsh-兼容性)),Node.js `>= 18`。
|
package/lib/client.js
CHANGED
|
@@ -1684,6 +1684,30 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
1684
1684
|
result: { mode: "strict", typeSymbol: "sage-mem/types#AuditReport", schema: objCodec, create: () => objCodec },
|
|
1685
1685
|
sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
|
|
1686
1686
|
},
|
|
1687
|
+
{
|
|
1688
|
+
id: "sage-mem#memory/checkUpdate",
|
|
1689
|
+
service: "memory",
|
|
1690
|
+
namespace: "memory",
|
|
1691
|
+
method: "checkUpdate",
|
|
1692
|
+
invocation: { kind: "direct" },
|
|
1693
|
+
parameters: [],
|
|
1694
|
+
result: { mode: "strict", typeSymbol: "sage-mem/types#UpdateCheck", schema: objCodec, create: () => objCodec },
|
|
1695
|
+
sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
|
|
1696
|
+
},
|
|
1697
|
+
{
|
|
1698
|
+
// 上锁 / 解锁一条记忆:锁上的记忆拒绝归档(自动与手动都不行)。
|
|
1699
|
+
id: "sage-mem#memory/setLocked",
|
|
1700
|
+
service: "memory",
|
|
1701
|
+
namespace: "memory",
|
|
1702
|
+
method: "setLocked",
|
|
1703
|
+
invocation: { kind: "direct" },
|
|
1704
|
+
parameters: [
|
|
1705
|
+
{ name: "name", wire: "name", source: "json", codec: { mode: "strict", typeSymbol: "sage-mem/types#FileName", schema: strSchema, create: () => strSchema } },
|
|
1706
|
+
{ name: "locked", wire: "locked", source: "json", codec: { mode: "strict", typeSymbol: "sage-mem/types#LockFlag", schema: boolSchema, create: () => boolSchema } },
|
|
1707
|
+
],
|
|
1708
|
+
result: { mode: "strict", typeSymbol: "sage-mem/types#LockResult", schema: objCodec, create: () => objCodec },
|
|
1709
|
+
sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
|
|
1710
|
+
},
|
|
1687
1711
|
{
|
|
1688
1712
|
id: "sage-mem#memory/archiveCandidates",
|
|
1689
1713
|
service: "memory",
|
|
@@ -1976,6 +2000,15 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
1976
2000
|
".smem-head-job{font-size:12.5px;font-weight:600;}",
|
|
1977
2001
|
".smem-head-actions{margin-left:auto;display:flex;align-items:center;gap:6px;}",
|
|
1978
2002
|
".smem-tool-panel{border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-layer-1);padding:10px 12px;display:flex;flex-direction:column;gap:8px;}",
|
|
2003
|
+
// 独立成屏的工具面板(现在的「体检」):显式占满**可用宽度**。
|
|
2004
|
+
// 整个面板的宽度上限在 `.smem-root`(760px)上,这里只要确保它在任何父容器里
|
|
2005
|
+
// 都吃满那个上限 —— 抽屉里的小面板没有这个问题(它外面还套着一层),
|
|
2006
|
+
// 而独立成屏后如果父容器不是 column flex,`align-items:stretch` 就不会发生。
|
|
2007
|
+
".smem-tool-panel--full{width:100%;align-self:stretch;box-sizing:border-box;}",
|
|
2008
|
+
// 已锁定的记忆:卡片上那个按钮要一眼看出「这条现在拒绝归档」,用警示色
|
|
2009
|
+
".smem-btn-locked{border-color:var(--dsw-alias-state-warning-primary,#d97706);color:var(--dsw-alias-state-warning-primary,#d97706);}",
|
|
2010
|
+
// 「自动归档」页签里的天数输入框:窄一点,别让三个数字各占一整行
|
|
2011
|
+
".smem-num{width:88px;}",
|
|
1979
2012
|
// 体检 / 候选是「翻清单」的面板:定高 + 内部滚动,列表不被顶出首屏。
|
|
1980
2013
|
// 原样编辑器(.smem-ar-editor)不设上限 —— 那是专心改一份文件的界面,给它完整高度。
|
|
1981
2014
|
".smem-tool-panel--cap{max-height:320px;overflow-y:auto;}",
|
|
@@ -2074,6 +2107,13 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
2074
2107
|
".smem-ad-warn{color:var(--dsw-alias-state-warning-primary,#d97706);}",
|
|
2075
2108
|
".smem-chip--fixed{opacity:.72;cursor:default;}",
|
|
2076
2109
|
".smem-chip-x{border:0;background:transparent;color:inherit;cursor:pointer;font-size:11px;font-family:inherit;padding:0 0 0 5px;}",
|
|
2110
|
+
// ── 更新提示(有新版本才出现的一条窄横幅)──
|
|
2111
|
+
".smem-update{display:flex;align-items:center;gap:10px;flex-wrap:wrap;border:1px solid var(--dsw-alias-brand-primary,#4d76e6);border-radius:8px;padding:6px 10px;font-size:12px;background:var(--dsw-alias-bg-layer-1);}",
|
|
2112
|
+
".smem-update-link{color:var(--dsw-alias-brand-primary,#4d76e6);text-decoration:underline;}",
|
|
2113
|
+
".smem-update-x{margin-left:auto;border:0;background:transparent;color:inherit;cursor:pointer;font-size:14px;line-height:1;padding:0 2px;}",
|
|
2114
|
+
// ── 底部一行仓库链接(引流,不抢视线)──
|
|
2115
|
+
".smem-star{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap;font-size:12px;color:var(--dsw-alias-label-tertiary,#8b93a1);padding:2px 2px 0;}",
|
|
2116
|
+
".smem-star-link{color:var(--dsw-alias-brand-primary,#4d76e6);text-decoration:underline;}",
|
|
2077
2117
|
].join("\n");
|
|
2078
2118
|
|
|
2079
2119
|
const TYPE_META = {
|
|
@@ -2408,8 +2448,33 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
2408
2448
|
]
|
|
2409
2449
|
: [
|
|
2410
2450
|
h("button", { type: "button", className: "smem-btn", key: "edit", onClick: function () { props.onEdit(item.file); } }, "编辑"),
|
|
2411
|
-
|
|
2412
|
-
|
|
2451
|
+
/**
|
|
2452
|
+
* 锁定排在「编辑」与「归档」之间(用户 2026-10-11 指定):
|
|
2453
|
+
* 它是「别归档这条」的开关,紧挨着归档才说得通。
|
|
2454
|
+
*
|
|
2455
|
+
* 锁上的记忆**自动与手动都不许归档** —— 候选计算会跳过它,
|
|
2456
|
+
* 手动点归档会被宿主拒绝。图标用 🔒 / 🔓 之外还留着文字,
|
|
2457
|
+
* 因为「锁」在这里是**拒绝归档**,不是「只读」。
|
|
2458
|
+
*/
|
|
2459
|
+
h("button", {
|
|
2460
|
+
type: "button",
|
|
2461
|
+
className: "smem-btn" + (item.locked ? " smem-btn-locked" : ""),
|
|
2462
|
+
key: "lock",
|
|
2463
|
+
title: item.locked
|
|
2464
|
+
? "已锁定:自动归档不会选它,手动归档也会被拒绝。点一下解锁"
|
|
2465
|
+
: "锁定这条记忆:自动与手动归档都不再动它(重要的人设 / 偏好建议锁上)",
|
|
2466
|
+
disabled: props.busy,
|
|
2467
|
+
onClick: function () { props.onToggleLock(item.file, !item.locked); },
|
|
2468
|
+
}, item.locked ? "🔒 已锁定" : "🔓 锁定"),
|
|
2469
|
+
// 归档排在「锁定」和「删除」之间:比编辑轻、比删除温和,且随时可逆。
|
|
2470
|
+
h("button", {
|
|
2471
|
+
type: "button", className: "smem-btn", key: "arch",
|
|
2472
|
+
title: item.locked
|
|
2473
|
+
? "这条已锁定,归档会被拒绝(先解锁)"
|
|
2474
|
+
: "收进档案馆:不再参与检索与星图,随时可以恢复",
|
|
2475
|
+
disabled: props.busy || item.locked === true,
|
|
2476
|
+
onClick: function () { props.onAskArchive(item.file); },
|
|
2477
|
+
}, "归档"),
|
|
2413
2478
|
h("button", { type: "button", className: "smem-btn smem-btn-danger-ghost", key: "del", onClick: function () { props.onAskDelete(item.file); } }, "删除"),
|
|
2414
2479
|
])),
|
|
2415
2480
|
]);
|
|
@@ -2493,8 +2558,8 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
2493
2558
|
};
|
|
2494
2559
|
const autoArchiveOf = (v) => (v === "off" || v === "auto" ? v : "report");
|
|
2495
2560
|
|
|
2496
|
-
/**
|
|
2497
|
-
const TAB_LABEL = { files: "文件列表", star: "记忆星图", autodream: "自动做梦" };
|
|
2561
|
+
/** 头部带上的「我在哪」:五个 tab 的中文名(与 .smem-tabs 上的字面一致)。 */
|
|
2562
|
+
const TAB_LABEL = { files: "文件列表", star: "记忆星图", autodream: "自动做梦", archive: "自动归档", audit: "体检" };
|
|
2498
2563
|
|
|
2499
2564
|
/**
|
|
2500
2565
|
* 工具面板头部的「收起」按钮:抽屉里开的工具要能一键收回去 ——
|
|
@@ -2513,8 +2578,33 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
2513
2578
|
* 硬问题与提示**分开渲染**:`archivedLinks` 是提示(归档不是删除,目标还在档案馆里),
|
|
2514
2579
|
* 跟问题一样染红会让人去「修」一条本来正确的链接 —— 那正是审计里那条分界线。
|
|
2515
2580
|
*/
|
|
2581
|
+
/**
|
|
2582
|
+
* 面板底部的一行仓库链接。
|
|
2583
|
+
*
|
|
2584
|
+
* 参照手机访问插件那一行的做法:放在不抢视线的地方、语气轻、点开是新标签页。
|
|
2585
|
+
* 它**是引流不是功能** —— 所以不占一屏、不做成按钮、失败也没有任何副作用;
|
|
2586
|
+
* 四个 tab 下都在(谁也没必要为看它切页)。
|
|
2587
|
+
*/
|
|
2588
|
+
function StarLine() {
|
|
2589
|
+
return h("div", { className: "smem-star" }, [
|
|
2590
|
+
h("span", { key: "ask" }, "⭐ 顺手留颗 Star,作者能高兴一整天"),
|
|
2591
|
+
h("a", {
|
|
2592
|
+
key: "cta",
|
|
2593
|
+
href: "https://github.com/gezi-wen/sage-mem",
|
|
2594
|
+
target: "_blank",
|
|
2595
|
+
rel: "noreferrer",
|
|
2596
|
+
className: "smem-star-link",
|
|
2597
|
+
}, "行,给你一颗 Star"),
|
|
2598
|
+
]);
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2516
2601
|
function AuditPanel(props) {
|
|
2517
2602
|
const r = props.report || {};
|
|
2603
|
+
/**
|
|
2604
|
+
* 独立成 tab 时不套抽屉那层窄框(`--cap` 有 320px 限高)、也不给「收起」按钮 ——
|
|
2605
|
+
* 它不是「暂时摊开的一层」,它就是这一屏。
|
|
2606
|
+
*/
|
|
2607
|
+
const standalone = props.standalone === true;
|
|
2518
2608
|
const rows = (list, render) => (list || []).map(function (x, i) {
|
|
2519
2609
|
return h("div", { className: "smem-audit-row", key: String(x.file || "?") + "-" + i }, render(x));
|
|
2520
2610
|
});
|
|
@@ -2525,7 +2615,7 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
2525
2615
|
])
|
|
2526
2616
|
: null);
|
|
2527
2617
|
const hints = r.archivedLinks || [];
|
|
2528
|
-
return h("div", { className: "smem-tool-panel smem-tool-panel--cap", key: "audit" }, [
|
|
2618
|
+
return h("div", { className: "smem-tool-panel" + (standalone ? " smem-tool-panel--full" : " smem-tool-panel--cap"), key: "audit" }, [
|
|
2529
2619
|
h("div", { className: "smem-tool-head", key: "head" }, [
|
|
2530
2620
|
h("span", { className: "smem-tool-title", key: "t" }, "体检"),
|
|
2531
2621
|
h("span", { className: "smem-muted", key: "s" },
|
|
@@ -2535,7 +2625,7 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
2535
2625
|
h("span", { className: "smem-tool-badge" + (r.problems ? " smem-tool-badge--bad" : ""), key: "p" },
|
|
2536
2626
|
r.problems ? ("问题 " + r.problems) : "没有问题"),
|
|
2537
2627
|
h("button", { type: "button", className: "smem-btn", key: "again", disabled: props.busy, onClick: props.onRun }, props.busy ? "体检中…" : "重新体检"),
|
|
2538
|
-
toolClose(props),
|
|
2628
|
+
standalone ? null : toolClose(props),
|
|
2539
2629
|
]),
|
|
2540
2630
|
r.problems
|
|
2541
2631
|
? h("div", { className: "smem-audit-secs", key: "secs" }, [
|
|
@@ -2748,7 +2838,6 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
2748
2838
|
const sMemSet = react.useState(null); const memSet = sMemSet[0], setMemSet = sMemSet[1];
|
|
2749
2839
|
const sLimitsDraft = react.useState(null); const limitsDraft = sLimitsDraft[0], setLimitsDraft = sLimitsDraft[1];
|
|
2750
2840
|
const sSaveBusy = react.useState(false); const saveBusy = sSaveBusy[0], setSaveBusy = sSaveBusy[1];
|
|
2751
|
-
const sAutoSaving = react.useState(false); const autoSaving = sAutoSaving[0], setAutoSaving = sAutoSaving[1];
|
|
2752
2841
|
const sReservedNew = react.useState(""); const reservedNew = sReservedNew[0], setReservedNew = sReservedNew[1];
|
|
2753
2842
|
|
|
2754
2843
|
// 派生量放在 effect 之前:下面几个 effect 的依赖数组要在渲染期就能取到值。
|
|
@@ -2848,35 +2937,6 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
2848
2937
|
}, function (e) { setErr(detail(e)); });
|
|
2849
2938
|
}
|
|
2850
2939
|
|
|
2851
|
-
/**
|
|
2852
|
-
* 自动归档三档:保存后**回读校验**。
|
|
2853
|
-
*
|
|
2854
|
-
* `setConfig` 对非法值沿用的是「忽略、保留原值」的语义(没有 `{ok:false}` 通道),
|
|
2855
|
-
* 所以不能只看它返回的 config —— 必须再 `getConfig()` 一次,确认那个值真的落在
|
|
2856
|
-
* 配置里。没变就报错:**不能让用户点了保存以为成了、实际什么都没发生**。
|
|
2857
|
-
*/
|
|
2858
|
-
function saveAutoArchive(v) {
|
|
2859
|
-
setErr(null); setNotice(""); setAutoSaving(true);
|
|
2860
|
-
callRemote("setConfig", [{ autoArchive: v }], function (d) {
|
|
2861
|
-
if (d && d.config && d.config.autoArchive === v) {
|
|
2862
|
-
setCfg(d.config);
|
|
2863
|
-
setAutoSaving(false);
|
|
2864
|
-
setNotice("自动归档已设为「" + AUTO_ARCHIVE_LABEL[v] + "」");
|
|
2865
|
-
return;
|
|
2866
|
-
}
|
|
2867
|
-
// 返回的 config 里没看到新值 → 回读一次再定论
|
|
2868
|
-
callRemote("getConfig", [], function (d2) {
|
|
2869
|
-
const next = d2 && d2.config ? d2.config : null;
|
|
2870
|
-
setAutoSaving(false);
|
|
2871
|
-
if (next) setCfg(next);
|
|
2872
|
-
const got = next ? autoArchiveOf(next.autoArchive) : null;
|
|
2873
|
-
if (got === v) { setNotice("自动归档已设为「" + AUTO_ARCHIVE_LABEL[v] + "」"); return; }
|
|
2874
|
-
setErr("保存没生效:配置里仍是「" + (got ? AUTO_ARCHIVE_LABEL[got] : "读不出来") +
|
|
2875
|
-
"」。setConfig 对非法值会忽略并保留原值 —— 请重试,或看 DSH 启动日志。");
|
|
2876
|
-
}, function (m) { setAutoSaving(false); setErr("保存后回读失败:" + m); });
|
|
2877
|
-
}, function (m) { setAutoSaving(false); setErr(m); });
|
|
2878
|
-
}
|
|
2879
|
-
|
|
2880
2940
|
/** 保存 5 项上限。越界/非整数**本地先拦**(host 也是直接拒绝),别让它白跑一趟。 */
|
|
2881
2941
|
function saveLimits() {
|
|
2882
2942
|
if (!memRemote || typeof memRemote.setSettings !== "function") { setErr("宿主侧还没有 setSettings 接口(插件版本较旧)"); return; }
|
|
@@ -3174,28 +3234,9 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
3174
3234
|
]),
|
|
3175
3235
|
]) : null,
|
|
3176
3236
|
segOf === "settings" ? h("div", { className: "smem-seg", key: "seg-set" }, [
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
h(Seg, {
|
|
3181
|
-
key: "v", value: autoArchiveOf(c.autoArchive),
|
|
3182
|
-
options: [
|
|
3183
|
-
{ value: "off", label: AUTO_ARCHIVE_LABEL.off },
|
|
3184
|
-
{ value: "report", label: AUTO_ARCHIVE_LABEL.report },
|
|
3185
|
-
{ value: "auto", label: AUTO_ARCHIVE_LABEL.auto },
|
|
3186
|
-
],
|
|
3187
|
-
// 点一档就存一档,但**存完要回读**:setConfig 对非法值是静默忽略,
|
|
3188
|
-
// 只信返回值不够(见 saveAutoArchive 的注释)。
|
|
3189
|
-
onChange: function (v) { if (v !== autoArchiveOf(c.autoArchive)) saveAutoArchive(v); },
|
|
3190
|
-
}),
|
|
3191
|
-
autoSaving ? h("span", { className: "smem-status", key: "s" }, "保存中…") : null,
|
|
3192
|
-
]),
|
|
3193
|
-
h("div", { className: "smem-autodream-note", key: "cost" }, AUTO_ARCHIVE_COST[autoArchiveOf(c.autoArchive)]),
|
|
3194
|
-
autoArchiveOf(c.autoArchive) === "auto" && !c.apply
|
|
3195
|
-
? h("div", { className: "smem-autodream-note smem-ad-warn", key: "deg" },
|
|
3196
|
-
"注意:现在「改动方式」是只出报告 —— 这一档会自动降级成只出报告,不会动文件。")
|
|
3197
|
-
: null,
|
|
3198
|
-
]),
|
|
3237
|
+
// 自动归档那一段(三档策略 + 三个阈值)**已挪到「自动归档」页签**(v0.9.9):
|
|
3238
|
+
// 归档是确定性规则、不调模型,和这一页的「让模型整理」不是一件事;
|
|
3239
|
+
// 同一件事只在一处设定,这里不再重复摆一份。
|
|
3199
3240
|
h("div", { className: "smem-autodream-sec", key: "limits" }, [
|
|
3200
3241
|
h("div", { className: "smem-autodream-row", key: "h" }, [
|
|
3201
3242
|
h("span", { key: "t", style: { fontWeight: "600", fontSize: "12.5px" } }, "记忆注入参数"),
|
|
@@ -3411,6 +3452,187 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
3411
3452
|
]);
|
|
3412
3453
|
}
|
|
3413
3454
|
|
|
3455
|
+
/**
|
|
3456
|
+
* 「自动归档」面板 —— 独立成一屏(v0.9.9)。
|
|
3457
|
+
*
|
|
3458
|
+
* 为什么独立出来(用户 2026-10-11):这一块原来塞在「自动做梦 → 设置」里,
|
|
3459
|
+
* 他自己的原话是「扔这里连我都找不到」。而且它确实是**另一件事**:
|
|
3460
|
+
* 自动做梦 = 让模型读记忆、整理、改写(要调模型、有 token 成本)
|
|
3461
|
+
* 自动归档 = 按「多久没被注入过」把记忆移进 archive/(纯确定性规则、不调模型)
|
|
3462
|
+
* 混在模型那套设置里,既难找、也容易让人以为归档会花钱。
|
|
3463
|
+
*
|
|
3464
|
+
* 这一页管两件事:
|
|
3465
|
+
* 1. **三档策略**:关闭 / 只出报告 / 满足条件自动跑
|
|
3466
|
+
* 2. **三个阈值**(项目 / 参考 / 用户):多少天没被注入就算闲置
|
|
3467
|
+
*
|
|
3468
|
+
* 数据全走 `remote.autodream` 的 `getConfig` / `setConfig` —— 阈值本来就在配置里
|
|
3469
|
+
* (`lib/autodream/config.js` 的 archiveAfterDays*),只是以前**界面够不着**。
|
|
3470
|
+
* 所以这一版没有新增任何 remote。
|
|
3471
|
+
*
|
|
3472
|
+
* 两处诚实标注:阈值**存完立即生效**(不经过模块加载期固化,与注入参数不同);
|
|
3473
|
+
* 保存后**一定回读**(`setConfig` 对非法值是「忽略并保留原值」,只信返回值会骗人)。
|
|
3474
|
+
*/
|
|
3475
|
+
function ArchiveSettingsPanel(props) {
|
|
3476
|
+
const remote = props.ctx && props.ctx.get ? props.ctx.get("remote.autodream") : null;
|
|
3477
|
+
const sCfg = react.useState(null); const cfg = sCfg[0], setCfg = sCfg[1];
|
|
3478
|
+
const sDraft = react.useState(null); const draft = sDraft[0], setDraft = sDraft[1];
|
|
3479
|
+
const sErr = react.useState(null); const err = sErr[0], setErr = sErr[1];
|
|
3480
|
+
const sNotice = react.useState(""); const notice = sNotice[0], setNotice = sNotice[1];
|
|
3481
|
+
const sBusy = react.useState(false); const busy = sBusy[0], setBusy = sBusy[1];
|
|
3482
|
+
|
|
3483
|
+
const TH_KEYS = ["archiveAfterDaysProject", "archiveAfterDaysReference", "archiveAfterDaysUser"];
|
|
3484
|
+
const TH_LABEL = { archiveAfterDaysProject: "项目", archiveAfterDaysReference: "参考", archiveAfterDaysUser: "用户" };
|
|
3485
|
+
const TH_HINT = {
|
|
3486
|
+
archiveAfterDaysProject: "project_* 连续这么多天没被注入 → 进候选",
|
|
3487
|
+
archiveAfterDaysReference: "reference_* 同上(资料一般比项目留得久)",
|
|
3488
|
+
archiveAfterDaysUser: "user_* 同上(人设这类动它要格外谨慎)",
|
|
3489
|
+
};
|
|
3490
|
+
|
|
3491
|
+
/** 阈值 → 输入框草稿。存成字符串:number input 清空时立刻变 0 会很难用。 */
|
|
3492
|
+
const draftOf = function (c) {
|
|
3493
|
+
const src = c || {};
|
|
3494
|
+
const out = {};
|
|
3495
|
+
for (const k of TH_KEYS) out[k] = String(src[k] == null ? "" : src[k]);
|
|
3496
|
+
return out;
|
|
3497
|
+
};
|
|
3498
|
+
|
|
3499
|
+
react.useEffect(function () {
|
|
3500
|
+
if (!remote || typeof remote.getConfig !== "function") {
|
|
3501
|
+
setErr("宿主侧没有 autodream 配置接口(插件版本较旧)");
|
|
3502
|
+
return;
|
|
3503
|
+
}
|
|
3504
|
+
remote.getConfig()
|
|
3505
|
+
.then(function (r) {
|
|
3506
|
+
const d = unwrap(r);
|
|
3507
|
+
const c = d && d.config ? d.config : null;
|
|
3508
|
+
if (!c) { setErr("读不出自动做梦配置"); return; }
|
|
3509
|
+
setCfg(c);
|
|
3510
|
+
setDraft(draftOf(c));
|
|
3511
|
+
})
|
|
3512
|
+
.catch(function (e) { setErr("读配置失败:" + ((e && e.message) || e)); });
|
|
3513
|
+
}, []);
|
|
3514
|
+
|
|
3515
|
+
const c = cfg || {};
|
|
3516
|
+
const mode = autoArchiveOf(c.autoArchive);
|
|
3517
|
+
|
|
3518
|
+
/**
|
|
3519
|
+
* 存完回读。
|
|
3520
|
+
* @param {Object} patch 要写的配置
|
|
3521
|
+
* @param {string} okMsg 成功提示
|
|
3522
|
+
* @param {Function} failOf (got) => string|null —— 返回 null 表示这次改动真的落进去了
|
|
3523
|
+
*/
|
|
3524
|
+
function save(patch, okMsg, failOf) {
|
|
3525
|
+
setBusy(true); setErr(null); setNotice("");
|
|
3526
|
+
remote.setConfig(patch)
|
|
3527
|
+
.then(function () { return remote.getConfig(); })
|
|
3528
|
+
.then(function (r) {
|
|
3529
|
+
const d = unwrap(r);
|
|
3530
|
+
const got = d && d.config ? d.config : null;
|
|
3531
|
+
if (got) { setCfg(got); setDraft(draftOf(got)); }
|
|
3532
|
+
const bad = failOf(got);
|
|
3533
|
+
if (!bad) { setNotice(okMsg); return; }
|
|
3534
|
+
setErr("保存没生效:" + bad + "。setConfig 对非法值会忽略并保留原值 —— 请重试,或看 DSH 启动日志。");
|
|
3535
|
+
})
|
|
3536
|
+
.catch(function (e) { setErr("保存失败:" + ((e && e.message) || e)); })
|
|
3537
|
+
.then(function () { setBusy(false); });
|
|
3538
|
+
}
|
|
3539
|
+
|
|
3540
|
+
function saveMode(v) {
|
|
3541
|
+
save({ autoArchive: v }, "自动归档已设为「" + AUTO_ARCHIVE_LABEL[v] + "」", function (got) {
|
|
3542
|
+
const g = got ? autoArchiveOf(got.autoArchive) : null;
|
|
3543
|
+
return g === v ? null : ("配置里仍是「" + (g ? AUTO_ARCHIVE_LABEL[g] : "读不出来") + "」");
|
|
3544
|
+
});
|
|
3545
|
+
}
|
|
3546
|
+
|
|
3547
|
+
/** 越界/非整数**本地先拦**(host 也会拒),别让它白跑一趟。 */
|
|
3548
|
+
function saveThresholds() {
|
|
3549
|
+
const d = draft || {};
|
|
3550
|
+
const patch = {};
|
|
3551
|
+
for (const k of TH_KEYS) {
|
|
3552
|
+
const n = Number(d[k]);
|
|
3553
|
+
if (!Number.isFinite(n) || n < 1 || n > 3650) {
|
|
3554
|
+
setErr(TH_LABEL[k] + "的阈值要是 1~3650 之间的天数(现在是「" + d[k] + "」)");
|
|
3555
|
+
return;
|
|
3556
|
+
}
|
|
3557
|
+
patch[k] = Math.round(n);
|
|
3558
|
+
}
|
|
3559
|
+
save(patch, "归档条件已保存", function (got) {
|
|
3560
|
+
const bad = TH_KEYS.filter(function (k) { return !got || Number(got[k]) !== patch[k]; });
|
|
3561
|
+
if (!bad.length) return null;
|
|
3562
|
+
return bad.map(function (k) { return TH_LABEL[k] + " 仍是 " + (got ? got[k] : "读不出来"); }).join("、");
|
|
3563
|
+
});
|
|
3564
|
+
}
|
|
3565
|
+
|
|
3566
|
+
return h("div", { className: "smem-seg" }, [
|
|
3567
|
+
// ── ① 它做什么(这一屏存在的理由写在最前面)──
|
|
3568
|
+
h("div", { className: "smem-autodream-sec", key: "what" }, [
|
|
3569
|
+
h("div", { className: "smem-autodream-row", key: "h" }, [
|
|
3570
|
+
h("span", { key: "t", style: { fontWeight: "600", fontSize: "12.5px" } }, "自动归档做什么"),
|
|
3571
|
+
]),
|
|
3572
|
+
h("div", { className: "smem-autodream-note", key: "p1" },
|
|
3573
|
+
"闲置太久没被注入过的记忆,会被移进 archive/ —— 不删除,随时能恢复。这一页决定「多久算闲置」,以及要不要真的动手。"),
|
|
3574
|
+
h("div", { className: "smem-autodream-note", key: "p2" },
|
|
3575
|
+
"它不调模型:候选完全按访问台账的天数算,同一份输入每次算出来都一样。"),
|
|
3576
|
+
h("div", { className: "smem-autodream-note", key: "p3" },
|
|
3577
|
+
"想现在看看会被收走哪些:文件列表 →「工具 ▾」→ 归档候选。"),
|
|
3578
|
+
]),
|
|
3579
|
+
// ── ② 三档策略 ──
|
|
3580
|
+
h("div", { className: "smem-autodream-sec", key: "mode" }, [
|
|
3581
|
+
h("div", { className: "smem-autodream-row", key: "r" }, [
|
|
3582
|
+
h("span", { className: "smem-autodream-key", key: "k" }, "自动归档"),
|
|
3583
|
+
h(Seg, {
|
|
3584
|
+
key: "v", value: mode,
|
|
3585
|
+
options: [
|
|
3586
|
+
{ value: "off", label: AUTO_ARCHIVE_LABEL.off },
|
|
3587
|
+
{ value: "report", label: AUTO_ARCHIVE_LABEL.report },
|
|
3588
|
+
{ value: "auto", label: AUTO_ARCHIVE_LABEL.auto },
|
|
3589
|
+
],
|
|
3590
|
+
onChange: function (v) { if (v !== mode) saveMode(v); },
|
|
3591
|
+
}),
|
|
3592
|
+
busy ? h("span", { className: "smem-status", key: "s" }, "保存中…") : null,
|
|
3593
|
+
]),
|
|
3594
|
+
h("div", { className: "smem-autodream-note", key: "cost" }, AUTO_ARCHIVE_COST[mode]),
|
|
3595
|
+
mode === "auto" && !c.apply
|
|
3596
|
+
? h("div", { className: "smem-autodream-note smem-ad-warn", key: "deg" },
|
|
3597
|
+
"注意:「自动做梦 → 改动方式」现在是只出报告 —— 这一档会自动降级成只出报告,不会动文件。")
|
|
3598
|
+
: null,
|
|
3599
|
+
]),
|
|
3600
|
+
// ── ③ 三个阈值 ──
|
|
3601
|
+
h("div", { className: "smem-autodream-sec", key: "th" }, [
|
|
3602
|
+
h("div", { className: "smem-autodream-row", key: "h" }, [
|
|
3603
|
+
h("span", { key: "t", style: { fontWeight: "600", fontSize: "12.5px" } }, "闲置多少天算可以归档"),
|
|
3604
|
+
]),
|
|
3605
|
+
draft
|
|
3606
|
+
? h("div", { key: "rows" }, TH_KEYS.map(function (k) {
|
|
3607
|
+
return h("div", { className: "smem-autodream-row", key: k }, [
|
|
3608
|
+
h("span", { className: "smem-autodream-key", key: "k" }, TH_LABEL[k]),
|
|
3609
|
+
h("input", {
|
|
3610
|
+
className: "smem-input smem-num", key: "i", type: "number", min: 1, max: 3650,
|
|
3611
|
+
value: draft[k],
|
|
3612
|
+
onChange: function (e) {
|
|
3613
|
+
const nx = Object.assign({}, draft);
|
|
3614
|
+
nx[k] = e.target.value;
|
|
3615
|
+
setDraft(nx);
|
|
3616
|
+
},
|
|
3617
|
+
}),
|
|
3618
|
+
h("span", { className: "smem-muted", key: "u" }, TH_HINT[k]),
|
|
3619
|
+
]);
|
|
3620
|
+
}))
|
|
3621
|
+
: h("div", { className: "smem-muted", key: "loading" }, "读配置中…"),
|
|
3622
|
+
h("div", { className: "smem-autodream-row", key: "btns" }, [
|
|
3623
|
+
h("button", {
|
|
3624
|
+
type: "button", className: "smem-btn smem-btn-primary", key: "save",
|
|
3625
|
+
disabled: busy || !draft, onClick: saveThresholds,
|
|
3626
|
+
}, busy ? "保存中…" : "保存归档条件"),
|
|
3627
|
+
h("span", { className: "smem-muted", key: "n" }, "保存即生效,不用重启 DSH"),
|
|
3628
|
+
]),
|
|
3629
|
+
h("div", { className: "smem-autodream-note", key: "note" },
|
|
3630
|
+
"天数按访问台账算(这条记忆上次被注入给模型是什么时候)。feedback_* 永不入选;锁定的记忆也不会被选中。"),
|
|
3631
|
+
]),
|
|
3632
|
+
err ? h("div", { className: "smem-err", key: "err" }, "⚠ " + err) : null,
|
|
3633
|
+
notice ? h("div", { className: "smem-ok", key: "ok" }, "✓ " + notice) : null,
|
|
3634
|
+
]);
|
|
3635
|
+
}
|
|
3414
3636
|
function Section(props) {
|
|
3415
3637
|
const remote = props.ctx.get("remote.memory");
|
|
3416
3638
|
const sItems = react.useState([]);
|
|
@@ -3504,6 +3726,9 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
3504
3726
|
const audit = sAudit[0], setAudit = sAudit[1];
|
|
3505
3727
|
const sAuditBusy = react.useState(false);
|
|
3506
3728
|
const auditBusy = sAuditBusy[0], setAuditBusy = sAuditBusy[1];
|
|
3729
|
+
// 更新提示:只在**确实有新版本**时才被赋值(查不到/限流/网络错都不赋值)
|
|
3730
|
+
const sUpdate = react.useState(null);
|
|
3731
|
+
const updateInfo = sUpdate[0], setUpdateInfo = sUpdate[1];
|
|
3507
3732
|
const sCand = react.useState(null);
|
|
3508
3733
|
const cand = sCand[0], setCand = sCand[1];
|
|
3509
3734
|
const sCandBusy = react.useState(false);
|
|
@@ -3558,6 +3783,19 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
3558
3783
|
// 头部带的两个徽标要有数据才立得住:体检(只读扫描)与待重启项。
|
|
3559
3784
|
runAudit();
|
|
3560
3785
|
loadSettings();
|
|
3786
|
+
/**
|
|
3787
|
+
* 更新检查:**静默**跑,失败一律不打扰 ——
|
|
3788
|
+
* 网络不通、被 GitHub 限流、仓库还没建 release,都不该在面板上冒一条红。
|
|
3789
|
+
* 只有 `updateAvailable` 为真才把结果放进 state,界面才有一条横幅。
|
|
3790
|
+
*/
|
|
3791
|
+
if (remote && typeof remote.checkUpdate === "function") {
|
|
3792
|
+
remote.checkUpdate()
|
|
3793
|
+
.then(function (r) {
|
|
3794
|
+
const d = unwrap(r);
|
|
3795
|
+
if (d && d.updateAvailable) setUpdateInfo(d);
|
|
3796
|
+
})
|
|
3797
|
+
.catch(function () { /* 查版本失败不是用户的事 */ });
|
|
3798
|
+
}
|
|
3561
3799
|
}, []);
|
|
3562
3800
|
|
|
3563
3801
|
/**
|
|
@@ -3699,9 +3937,33 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
3699
3937
|
.then(function () { setBusy(false); });
|
|
3700
3938
|
}
|
|
3701
3939
|
|
|
3940
|
+
/**
|
|
3941
|
+
* 上锁 / 解锁一条记忆。
|
|
3942
|
+
*
|
|
3943
|
+
* 锁定 = **拒绝归档**(自动归档的候选里不出现、手动点归档也被宿主拒绝),
|
|
3944
|
+
* **不是「只读」** —— 文案必须按这个说,否则用户会以为锁上就不能编辑了。
|
|
3945
|
+
*/
|
|
3946
|
+
function toggleLock(file, locked) {
|
|
3947
|
+
if (typeof remote.setLocked !== "function") {
|
|
3948
|
+
setError("宿主侧还没有 setLocked 接口(插件版本较旧)");
|
|
3949
|
+
return;
|
|
3950
|
+
}
|
|
3951
|
+
setBusy(true); setError(null); setNotice("");
|
|
3952
|
+
remote.setLocked(file, locked)
|
|
3953
|
+
.then(function (r) {
|
|
3954
|
+
const d = unwrap(r);
|
|
3955
|
+
if (d && d.ok === false) { setError(d.error || "锁定操作被拒绝"); return; }
|
|
3956
|
+
setNotice(locked
|
|
3957
|
+
? ("已锁定 " + file + " —— 自动归档不会再选它,手动归档也会被拒绝")
|
|
3958
|
+
: ("已解锁 " + file));
|
|
3959
|
+
load();
|
|
3960
|
+
})
|
|
3961
|
+
.catch(function (e) { setError(String((e && e.message) || e)); })
|
|
3962
|
+
.then(function () { setBusy(false); });
|
|
3963
|
+
}
|
|
3964
|
+
|
|
3702
3965
|
/** 从档案馆捞回来:文件移回记忆目录,归档留痕被抹掉。 */
|
|
3703
|
-
function restoreFile(file) {
|
|
3704
|
-
if (typeof remote.restore !== "function") {
|
|
3966
|
+
function restoreFile(file) { if (typeof remote.restore !== "function") {
|
|
3705
3967
|
setError("宿主侧还没有 restore 接口(插件版本较旧)");
|
|
3706
3968
|
return;
|
|
3707
3969
|
}
|
|
@@ -3710,7 +3972,8 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
3710
3972
|
remote.restore(file)
|
|
3711
3973
|
.then(function (r) {
|
|
3712
3974
|
if (r && r.ok === false) { setError(r.error || "恢复被拒绝"); return; }
|
|
3713
|
-
|
|
3975
|
+
// 恢复会顺带把这条补进索引:说清楚,否则用户不知道「漏索引」是怎么好的
|
|
3976
|
+
setNotice("已恢复 " + file + " —— 它回到了活动记忆里" + (r && r.indexed ? ",并补进了索引" : ""));
|
|
3714
3977
|
load();
|
|
3715
3978
|
})
|
|
3716
3979
|
.catch(function (e) { setError(String((e && e.message) || e)); })
|
|
@@ -3810,16 +4073,27 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
3810
4073
|
const next = tool === name ? null : name;
|
|
3811
4074
|
setTool(next);
|
|
3812
4075
|
setError(null);
|
|
3813
|
-
if (next === "audit" && !audit) runAudit();
|
|
3814
4076
|
if (next === "candidates" && !cand) loadCandidates();
|
|
3815
4077
|
if (next === "raw" && !settings) loadSettings();
|
|
3816
4078
|
}
|
|
3817
4079
|
|
|
3818
|
-
/**
|
|
4080
|
+
/**
|
|
4081
|
+
* 切到「体检」这一屏。
|
|
4082
|
+
*
|
|
4083
|
+
* 体检从工具抽屉里独立出来了(它本来就是「看」不是「做」),排在「自动做梦」之后。
|
|
4084
|
+
* 数据没拉过才拉:初始加载已经跑过一次体检(头部带那个徽标要有数据才立得住),
|
|
4085
|
+
* 切过来通常直接就看得见内容。
|
|
4086
|
+
*/
|
|
4087
|
+
function openAuditTab() {
|
|
4088
|
+
setTab("audit");
|
|
4089
|
+
setError(null);
|
|
4090
|
+
if (!audit) runAudit();
|
|
4091
|
+
}
|
|
4092
|
+
|
|
4093
|
+
/** 打开某个工具(抽屉里点进来时用)。 */
|
|
3819
4094
|
function openTool(name) {
|
|
3820
4095
|
setTool(name);
|
|
3821
4096
|
setError(null);
|
|
3822
|
-
if (name === "audit" && !audit) runAudit();
|
|
3823
4097
|
if (name === "candidates" && !cand) loadCandidates();
|
|
3824
4098
|
if (name === "raw" && !settings) loadSettings();
|
|
3825
4099
|
}
|
|
@@ -4013,6 +4287,7 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
4013
4287
|
onAskArchive: function (file) { setArchiving(file); setArchiveReason(""); setDeleting(null); },
|
|
4014
4288
|
onCancelArchive: function () { setArchiving(null); setArchiveReason(""); },
|
|
4015
4289
|
onConfirmArchive: confirmArchive,
|
|
4290
|
+
onToggleLock: toggleLock,
|
|
4016
4291
|
onToggle: function (file) {
|
|
4017
4292
|
const nx = {};
|
|
4018
4293
|
nx[file] = !expanded[file];
|
|
@@ -4039,8 +4314,15 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
4039
4314
|
h("button", { type: "button", className: "smem-tab" + (tab === "files" ? " smem-tab--on" : ""), key: "f", onClick: function () { setTab("files"); } }, "文件列表"),
|
|
4040
4315
|
h("button", { type: "button", className: "smem-tab" + (tab === "star" ? " smem-tab--on" : ""), key: "s", onClick: function () { setTab("star"); } }, "记忆星图"),
|
|
4041
4316
|
h("button", { type: "button", className: "smem-tab" + (tab === "autodream" ? " smem-tab--on" : ""), key: "d", onClick: function () { setTab("autodream"); } }, "自动做梦"),
|
|
4317
|
+
// 「自动归档」排在自动做梦之后:归档是**确定性规则**(按闲置天数算,不调模型),
|
|
4318
|
+
// 和「让模型整理记忆」是两件事。塞在自动做梦的设置里用户找不到
|
|
4319
|
+
// (2026-10-11 用户原话:「扔这里连我都找不到」)。
|
|
4320
|
+
h("button", { type: "button", className: "smem-tab" + (tab === "archive" ? " smem-tab--on" : ""), key: "ar", onClick: function () { setTab("archive"); } }, "自动归档"),
|
|
4321
|
+
// 体检再排在「自动归档」之后、独立成一屏:它只报告不修改,和「文件列表」里的
|
|
4322
|
+
// 增删改是两件事,塞在工具抽屉里会让人以为它是个动作按钮。
|
|
4323
|
+
h("button", { type: "button", className: "smem-tab" + (tab === "audit" ? " smem-tab--on" : ""), key: "a", onClick: openAuditTab }, "体检"),
|
|
4042
4324
|
]),
|
|
4043
|
-
//
|
|
4325
|
+
// 四个 tab 共用的一条头部带:我在哪 · 目录状态 · 活动/归档计数 · 体检徽标 · 待重启徽标 · 主操作。
|
|
4044
4326
|
// 层级形状固定:导航=下划线(.smem-tabs)、筛选=chip、动作=按钮,三种不混用。
|
|
4045
4327
|
h("div", { className: "smem-head", key: "head" }, [
|
|
4046
4328
|
h("span", { className: "smem-title", key: "t" }, "记忆管理"),
|
|
@@ -4048,13 +4330,12 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
4048
4330
|
h("span", { className: "smem-dot" + (dirOk ? "" : " smem-dot--bad"), key: "dot", title: dirOk ? "memory 目录可读" : "memory 目录不可读" }),
|
|
4049
4331
|
h("span", { className: "smem-muted", key: "st" }, dirOk ? "目录可读" : "目录不可读"),
|
|
4050
4332
|
h("span", { className: "smem-badge", key: "cnt" }, headerCount),
|
|
4051
|
-
//
|
|
4052
|
-
// 没有这个徽标,三个能力就退回「藏起来」了(上一轮刚把它们做成用户自己的入口)。
|
|
4333
|
+
// 体检徽标:**抽屉关着也一直看得见**,点它就切到「体检」那一屏。
|
|
4053
4334
|
h("button", {
|
|
4054
4335
|
type: "button", key: "aud",
|
|
4055
4336
|
className: "smem-tool-badge" + (audit && audit.problems ? " smem-tool-badge--bad" : ""),
|
|
4056
|
-
title: audit ? (audit.problems ? "
|
|
4057
|
-
onClick:
|
|
4337
|
+
title: audit ? (audit.problems ? "切到体检看是哪几条" : "体检没有发现问题") : "跑一次体检(只读)",
|
|
4338
|
+
onClick: openAuditTab,
|
|
4058
4339
|
}, audit ? (audit.problems ? ("体检 " + audit.problems + " 个问题") : "体检 通过") : "体检 · 未跑"),
|
|
4059
4340
|
restartPending
|
|
4060
4341
|
? h("button", {
|
|
@@ -4069,6 +4350,22 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
4069
4350
|
h("button", { type: "button", className: "smem-btn smem-btn-primary", key: "add", onClick: openAdd }, "+ 添加记忆"),
|
|
4070
4351
|
] : null),
|
|
4071
4352
|
]),
|
|
4353
|
+
// 更新提示:四个 tab 共用一条,只在真有新版时出现;「×」只是这次不看
|
|
4354
|
+
updateInfo
|
|
4355
|
+
? h("div", { className: "smem-update", key: "update" }, [
|
|
4356
|
+
h("span", { key: "t" }, "发现新版本 v" + String(updateInfo.latest) + "(当前 v" + String(updateInfo.current) + ")"),
|
|
4357
|
+
h("a", {
|
|
4358
|
+
key: "l", className: "smem-update-link",
|
|
4359
|
+
href: updateInfo.releasesUrl || "https://github.com/gezi-wen/sage-mem/releases",
|
|
4360
|
+
target: "_blank", rel: "noreferrer",
|
|
4361
|
+
}, "看更新内容"),
|
|
4362
|
+
h("button", {
|
|
4363
|
+
type: "button", key: "x", className: "smem-update-x",
|
|
4364
|
+
title: "这次不看了(下次进面板还会提示)",
|
|
4365
|
+
onClick: function () { setUpdateInfo(null); },
|
|
4366
|
+
}, "×"),
|
|
4367
|
+
])
|
|
4368
|
+
: null,
|
|
4072
4369
|
// 工具栏只剩「搜索 + 工具 ▾」:刷新与添加已经收进头部带的主操作位。
|
|
4073
4370
|
tab === "files" ? h("div", { className: "smem-toolbar", key: "bar" }, [
|
|
4074
4371
|
h("input", { className: "smem-search", key: "q", value: query, placeholder: "搜索文件名 / 描述 / 类型 / 标签…", onChange: function (e) { setQuery(e.target.value); } }),
|
|
@@ -4078,15 +4375,11 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
4078
4375
|
type: "button", key: "btn",
|
|
4079
4376
|
className: "smem-btn" + (drawer ? " smem-btn-primary" : ""),
|
|
4080
4377
|
"aria-expanded": drawer ? "true" : "false",
|
|
4081
|
-
title: "
|
|
4378
|
+
title: "归档候选 / 保留名编辑",
|
|
4082
4379
|
onClick: function () { setDrawer(!drawer); },
|
|
4083
4380
|
}, "工具 ▾"),
|
|
4084
4381
|
drawer ? h("div", { className: "smem-drawer-pop", key: "pop" }, [
|
|
4085
|
-
|
|
4086
|
-
type: "button", className: "smem-drawer-item", key: "a",
|
|
4087
|
-
"data-on": tool === "audit" ? "1" : "0",
|
|
4088
|
-
onClick: function () { toggleTool("audit"); setDrawer(false); },
|
|
4089
|
-
}, ["体检", h("span", { className: "smem-status" + (audit && audit.problems ? " smem-status--bad" : ""), key: "s" }, audit ? (audit.problems ? (audit.problems + " 个问题") : "通过") : "未跑")]),
|
|
4382
|
+
// 体检独立成 tab 之后,抽屉里只剩这两件「要动手」的事。
|
|
4090
4383
|
h("button", {
|
|
4091
4384
|
type: "button", className: "smem-drawer-item", key: "c",
|
|
4092
4385
|
"data-on": tool === "candidates" ? "1" : "0",
|
|
@@ -4100,8 +4393,8 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
4100
4393
|
]) : null,
|
|
4101
4394
|
]),
|
|
4102
4395
|
]) : null,
|
|
4103
|
-
tab === "
|
|
4104
|
-
? h(AuditPanel, { report: audit, busy: auditBusy, onRun: runAudit,
|
|
4396
|
+
tab === "audit"
|
|
4397
|
+
? h(AuditPanel, { report: audit, busy: auditBusy, onRun: runAudit, standalone: true })
|
|
4105
4398
|
: null,
|
|
4106
4399
|
tab === "files" && tool === "candidates"
|
|
4107
4400
|
? h(CandidatePanel, {
|
|
@@ -4232,7 +4525,15 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
4232
4525
|
: tab === "star"
|
|
4233
4526
|
? h("div", { className: "smem-starmap", key: "starmap" },
|
|
4234
4527
|
h(StarmapRuntime.StarMap, { ctx: props.ctx }))
|
|
4528
|
+
: tab === "archive"
|
|
4529
|
+
? h(ArchiveSettingsPanel, { key: "archive", ctx: props.ctx })
|
|
4530
|
+
: tab === "audit"
|
|
4531
|
+
// 体检面板在上面已经渲染过了 —— 这里必须显式给 null,
|
|
4532
|
+
// 否则会落进下面那个 `filesView` 兜底分支,变成「体检 + 一整套文件列表」同屏。
|
|
4533
|
+
? null
|
|
4235
4534
|
: filesView,
|
|
4535
|
+
// 底部一行仓库链接(四个 tab 都在,不抢视线)
|
|
4536
|
+
h(StarLine, { key: "star-line" }),
|
|
4236
4537
|
]);
|
|
4237
4538
|
}
|
|
4238
4539
|
|
package/lib/index.js
CHANGED
|
@@ -29,6 +29,8 @@ import { warnLog, writeFileAtomic } from './memory/util.js'
|
|
|
29
29
|
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
|
30
30
|
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'
|
|
31
31
|
import { basename, join } from 'node:path'
|
|
32
|
+
import { checkUpdate as checkUpdateImpl } from './update.js'
|
|
33
|
+
import { readLockSet, setLocked as setLockedImpl } from './memory/lock.js'
|
|
32
34
|
|
|
33
35
|
/**
|
|
34
36
|
* `SAGE_MEM_DEBUG=1` 才出声的注入链调试出口。
|
|
@@ -377,9 +379,20 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
377
379
|
})
|
|
378
380
|
}
|
|
379
381
|
|
|
380
|
-
/**
|
|
382
|
+
/**
|
|
383
|
+
* 查有没有新版本(面板进入时静默调一次)。
|
|
384
|
+
*
|
|
385
|
+
* 实现全在 `lib/update.js`:**当前版本运行时读自己的 `package.json`**(不内联常量 ——
|
|
386
|
+
* 内联会在「构建早于 bump」时停留在旧版本,插件于是永远对自己报「有新版本」);
|
|
387
|
+
* 查不到一律降级为「不提示」,绝不在界面上冒红条。
|
|
388
|
+
*/
|
|
389
|
+
async checkUpdate() {
|
|
390
|
+
return await checkUpdateImpl()
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** 列出 memory 目录全部记忆文件(文件名 + 类型 + 描述 + 大小 + 自定义标签 + 是否锁定)。 */
|
|
381
394
|
async listFiles() {
|
|
382
|
-
const files = await scanMemoryFiles(this.ctx, '')
|
|
395
|
+
const [files, locked] = await Promise.all([scanMemoryFiles(this.ctx, ''), readLockSet(MEMORY_DIR)])
|
|
383
396
|
return files.map(f => {
|
|
384
397
|
const meta = parseFrontmatter(f.content)
|
|
385
398
|
return {
|
|
@@ -388,6 +401,8 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
388
401
|
description: meta.description || '',
|
|
389
402
|
size: f.content.length,
|
|
390
403
|
tags: meta.tags,
|
|
404
|
+
// 锁定状态跟着每条一起给:卡片上的「锁定 / 已锁定」按钮要据此渲染
|
|
405
|
+
locked: locked.has(nameKey(f.file)),
|
|
391
406
|
}
|
|
392
407
|
})
|
|
393
408
|
}
|
|
@@ -616,17 +631,40 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
616
631
|
* 复用 `memory/access.js` 的台账与候选计算,与 autodream 引擎跑的是同一份逻辑。
|
|
617
632
|
* 先强制 flush 台账:面板看到的候选不能比引擎少(内存里还压着的那批注入必须算进去)。
|
|
618
633
|
*/
|
|
634
|
+
/**
|
|
635
|
+
* 上锁 / 解锁一条记忆。锁上的记忆**拒绝归档**(自动与手动都不行)。
|
|
636
|
+
*
|
|
637
|
+
* 落点是**状态目录**(`.sage-mem/locked.json`),不写进记忆自己的 frontmatter ——
|
|
638
|
+
* 理由见 `lib/memory/lock.js` 的头注:frontmatter 会被「自动做梦」重写,锁写在里面
|
|
639
|
+
* 可能被整理过程擦掉,而锁一丢那条记忆就重新进了自动归档候选(正是这功能要防的事)。
|
|
640
|
+
*/
|
|
641
|
+
async setLocked(name, locked) {
|
|
642
|
+
return await setLockedImpl(MEMORY_DIR, name, locked === true)
|
|
643
|
+
}
|
|
644
|
+
|
|
619
645
|
async archiveCandidates() {
|
|
620
646
|
await flushAccess(MEMORY_DIR)
|
|
621
|
-
const [ledger, files] = await Promise.all([
|
|
647
|
+
const [ledger, files, locked] = await Promise.all([
|
|
648
|
+
readAccessLedger(),
|
|
649
|
+
scanMemoryFiles(this.ctx, ''),
|
|
650
|
+
readLockSet(MEMORY_DIR),
|
|
651
|
+
])
|
|
622
652
|
const cfg = await autodreamConfigSnapshot()
|
|
623
653
|
const thresholds = {
|
|
624
654
|
project: cfg.archiveAfterDaysProject,
|
|
625
655
|
reference: cfg.archiveAfterDaysReference,
|
|
626
656
|
user: cfg.archiveAfterDaysUser,
|
|
627
657
|
}
|
|
628
|
-
|
|
629
|
-
|
|
658
|
+
// 锁表交给候选计算:锁上的记忆一个都不选(用户对**单条**记忆说的话优先于阈值)。
|
|
659
|
+
const candidates = pickArchiveCandidates(files, ledger, { thresholds, locked })
|
|
660
|
+
return {
|
|
661
|
+
count: candidates.length,
|
|
662
|
+
candidates,
|
|
663
|
+
autoArchive: cfg.autoArchive,
|
|
664
|
+
thresholds,
|
|
665
|
+
// 面板要显示「哪些锁着」,所以把锁表也带回去(已按 nameKey 归一)
|
|
666
|
+
locked: [...locked],
|
|
667
|
+
}
|
|
630
668
|
}
|
|
631
669
|
|
|
632
670
|
/**
|
|
@@ -784,6 +822,8 @@ markRemote(MemoryGateway, 'listArchived', 'listArchived')
|
|
|
784
822
|
markRemote(MemoryGateway, 'readArchived', 'readArchived')
|
|
785
823
|
markRemote(MemoryGateway, 'writeArchived', 'writeArchived')
|
|
786
824
|
markRemote(MemoryGateway, 'audit', 'audit')
|
|
825
|
+
markRemote(MemoryGateway, 'checkUpdate', 'checkUpdate')
|
|
826
|
+
markRemote(MemoryGateway, 'setLocked', 'setLocked')
|
|
787
827
|
markRemote(MemoryGateway, 'archiveCandidates', 'archiveCandidates')
|
|
788
828
|
markRemote(MemoryGateway, 'getSettings', 'getSettings')
|
|
789
829
|
markRemote(MemoryGateway, 'setSettings', 'setSettings')
|
package/lib/memory/access.js
CHANGED
|
@@ -19,6 +19,7 @@ import { parseFrontmatter } from './frontmatter.js'
|
|
|
19
19
|
import { writeFileAtomic } from './util.js'
|
|
20
20
|
import { mkdir, readFile, readdir } from 'node:fs/promises'
|
|
21
21
|
import { dirname, join } from 'node:path'
|
|
22
|
+
import { nameKey } from './naming.js'
|
|
22
23
|
|
|
23
24
|
/** 台账文件:状态根下,与记忆文件分开。 */
|
|
24
25
|
export const ACCESS_LEDGER_FILE = join(STATE_ROOT, 'access.json')
|
|
@@ -192,14 +193,18 @@ export function idleDays(f, ledger = {}, now = Date.now()) {
|
|
|
192
193
|
*
|
|
193
194
|
* @param {Array} files — `scanMemoryFiles` 的结果(**只有根目录**,所以归档区天然不在内)
|
|
194
195
|
* @param {Object} ledger — 访问台账
|
|
195
|
-
* @param {{thresholds?: Object}} opts —
|
|
196
|
+
* @param {{thresholds?: Object, locked?: Set<string>}} opts — 按类型的阈值(天;缺阈值=该类型永不候选)
|
|
197
|
+
* 与**锁表**(`lib/memory/lock.js` 的 `readLockSet`,键已按 `nameKey` 归一)
|
|
196
198
|
* @returns {Array<{file:string,type:string,days:number,limit:number,reason:string}>} 按闲置天数降序
|
|
197
199
|
*/
|
|
198
200
|
export function pickArchiveCandidates(files, ledger = {}, opts = {}, now = Date.now()) {
|
|
199
201
|
const thresholds = opts.thresholds ?? {}
|
|
202
|
+
const locked = opts.locked ?? null
|
|
200
203
|
const out = []
|
|
201
204
|
for (const f of files ?? []) {
|
|
202
205
|
if (!f?.file || f.archived === true) continue
|
|
206
|
+
// 锁定的记忆一个都不选:这是用户对**单条**记忆说的「别动它」,优先于阈值。
|
|
207
|
+
if (locked && locked.has(nameKey(f.file))) continue
|
|
203
208
|
const type = fileType(f)
|
|
204
209
|
if (NEVER_ARCHIVE_TYPES.has(type)) continue
|
|
205
210
|
// baseline 是「每场会话必读」的那几条:它们本来就不靠检索命中,闲置天数没有意义。
|
package/lib/memory/archive.js
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
import { ARCHIVE_DIR_NAME, addArchiveMeta, inspectDir, inspectMemoryEntry, localStamp, stripArchiveMeta } from './scan.js'
|
|
19
19
|
import { isReserved, nameKey, safeName } from './naming.js'
|
|
20
20
|
import { writeFileAtomic } from './util.js'
|
|
21
|
+
import { isLocked } from './lock.js'
|
|
22
|
+
import { ensureIndexEntry } from './index-file.js'
|
|
21
23
|
import { mkdir, readFile, stat, unlink } from 'node:fs/promises'
|
|
22
24
|
import { join } from 'node:path'
|
|
23
25
|
|
|
@@ -47,6 +49,13 @@ export async function archiveMemory(memoryDir, name, reason, at = localStamp())
|
|
|
47
49
|
const gate = checkName(name, 'archive')
|
|
48
50
|
if (!gate.ok) return gate
|
|
49
51
|
const safe = gate.safe
|
|
52
|
+
/**
|
|
53
|
+
* 「锁定」优先于一切:锁上的记忆**自动与手动都不许归档**。
|
|
54
|
+
* 放在最前面判 —— 名字合法之后立刻拒绝,不读目录、不算理由。
|
|
55
|
+
*/
|
|
56
|
+
if (await isLocked(memoryDir, safe)) {
|
|
57
|
+
return { ok: false, code: 'locked', error: `sage-mem: locked, refusing to archive: ${safe}(先在界面里解锁)` }
|
|
58
|
+
}
|
|
50
59
|
const src = join(memoryDir, safe)
|
|
51
60
|
const dst = join(archiveDirOf(memoryDir), safe)
|
|
52
61
|
/**
|
|
@@ -118,5 +127,13 @@ export async function restoreMemory(memoryDir, name) {
|
|
|
118
127
|
const content = await readFile(src, 'utf8')
|
|
119
128
|
await writeFileAtomic(dst, stripArchiveMeta(content))
|
|
120
129
|
await unlink(src)
|
|
121
|
-
|
|
130
|
+
/**
|
|
131
|
+
* **注册回索引**:移回文件不等于这条记忆又存在了 —— 索引里没有它,
|
|
132
|
+
* 面板与检索都看不见,体检会报「漏索引」(真实事故见 lib/memory/index-file.js 的头注)。
|
|
133
|
+
*
|
|
134
|
+
* 幂等(索引里已有指向它的一行就什么都不做);索引缺失或写不进去**不算恢复失败** ——
|
|
135
|
+
* 文件已经回到顶层了,那才是主要结果,索引只是补账。
|
|
136
|
+
*/
|
|
137
|
+
const indexed = await ensureIndexEntry(memoryDir, safe).catch(() => null)
|
|
138
|
+
return { ok: true, file: safe, indexed: !!(indexed && indexed.added) }
|
|
122
139
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 索引(`MEMORY.md`)的**写入侧** —— 只有一件事:确保某条记忆在索引里有一行。
|
|
3
|
+
*
|
|
4
|
+
* 为什么要单独一个文件:索引是**用户手写**的东西(「改动记忆时同步改这里」这条就写在
|
|
5
|
+
* 索引自己的说明段里)。所以这里只做**追加**,绝不重排、绝不删用户已有的行、绝不重建
|
|
6
|
+
* 整个文件 —— 索引被整份覆盖就等于用户的维护成果没了。
|
|
7
|
+
*
|
|
8
|
+
* 起因(2026-10-11 在一次真实使用中确认):恢复归档记忆时只把文件搬回顶层、
|
|
9
|
+
* 不补索引,于是顶层有文件、索引里没有 → 体检报「漏索引」。
|
|
10
|
+
*/
|
|
11
|
+
import { readFile, readdir } from 'node:fs/promises'
|
|
12
|
+
import { basename, join } from 'node:path'
|
|
13
|
+
import { parseFrontmatter } from './frontmatter.js'
|
|
14
|
+
import { nameKey } from './naming.js'
|
|
15
|
+
import { writeFileAtomic } from './util.js'
|
|
16
|
+
|
|
17
|
+
const INDEX_KEY = 'memory.md'
|
|
18
|
+
/** 条目里的描述上限:索引是清单,不是内容堆。 */
|
|
19
|
+
const DESC_MAX = 120
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 索引文件的**磁盘真名**(大小写不敏感地找)。
|
|
23
|
+
* 不假设盘上是 `MEMORY.md` —— 系统盘是 Windows、用户可能就写成了小写。
|
|
24
|
+
*/
|
|
25
|
+
export async function findIndexFile(memoryDir) {
|
|
26
|
+
const names = await readdir(memoryDir).catch(() => [])
|
|
27
|
+
return names.find((n) => nameKey(n) === INDEX_KEY) ?? null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 索引里有没有指向该文件的条目(`](file.md)` 或 `](file.md#L12)`,大小写不敏感)。 */
|
|
31
|
+
function hasEntry(indexText, name) {
|
|
32
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
33
|
+
return new RegExp(`\\]\\(\\s*${escaped}(?:#[^)]*)?\\s*\\)`, 'i').test(indexText)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 确保索引里有指向 `name` 的一行;已经有就**什么都不做**(幂等)。
|
|
38
|
+
*
|
|
39
|
+
* 条目形状与索引自己声明的约定一致:`- [标题](文件.md) — 一句话`。
|
|
40
|
+
* 标题取 frontmatter 的 `name`(没有就用文件名),描述取 `description`。
|
|
41
|
+
*
|
|
42
|
+
* @returns {Promise<{ok:boolean, added:boolean, file?:string, line?:string, error?:string}>}
|
|
43
|
+
*/
|
|
44
|
+
export async function ensureIndexEntry(memoryDir, name) {
|
|
45
|
+
const indexName = await findIndexFile(memoryDir)
|
|
46
|
+
// 没有索引是**正常状态**(索引是可选件):不崩、不建、不当失败。
|
|
47
|
+
if (!indexName) return { ok: true, added: false, error: 'no-index' }
|
|
48
|
+
const indexPath = join(memoryDir, indexName)
|
|
49
|
+
const indexText = await readFile(indexPath, 'utf8').catch(() => '')
|
|
50
|
+
if (hasEntry(indexText, name)) return { ok: true, added: false }
|
|
51
|
+
|
|
52
|
+
const content = await readFile(join(memoryDir, name), 'utf8').catch(() => '')
|
|
53
|
+
const fm = parseFrontmatter(content)
|
|
54
|
+
const title = fm.name || basename(name, '.md')
|
|
55
|
+
const desc = (fm.description || '').trim().slice(0, DESC_MAX)
|
|
56
|
+
const line = `- [${title}](${name})${desc ? ' — ' + desc : ''}`
|
|
57
|
+
// 追加到末尾。文件末尾没换行就先补一个,别把新条目粘到上一行的尾巴上。
|
|
58
|
+
const head = indexText === '' || indexText.endsWith('\n') ? indexText : indexText + '\n'
|
|
59
|
+
await writeFileAtomic(indexPath, head + line + '\n')
|
|
60
|
+
return { ok: true, added: true, file: name, line }
|
|
61
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「锁定」—— 让一条记忆拒绝归档。
|
|
3
|
+
*
|
|
4
|
+
* 为什么用**状态文件**而不是写进记忆自己的 frontmatter:
|
|
5
|
+
* 1. frontmatter 是用户的正文,而「自动做梦」会重写它 —— 锁写在里面可能被整理过程擦掉,
|
|
6
|
+
* 而锁一旦丢失,那条记忆就会重新进入自动归档候选(**正是这个功能要防的事**)
|
|
7
|
+
* 2. 状态根 `.sage-mem/` 已经是访问台账、快照、回滚点的家,锁放同一层语义一致
|
|
8
|
+
* 代价要说清楚:锁不在记忆文件里,**跟着文件单独拷贝时不会跟走**。别把它当文件属性。
|
|
9
|
+
*
|
|
10
|
+
* 锁表按 `nameKey()` 归一存放:NTFS 不区分大小写,`User_Persona.MD` 与 `user_persona.md`
|
|
11
|
+
* 是同一个文件 —— 锁必须认得出这一点(与保留名保护同一套判据)。
|
|
12
|
+
*/
|
|
13
|
+
import { mkdir, readFile } from 'node:fs/promises'
|
|
14
|
+
import { dirname, join } from 'node:path'
|
|
15
|
+
import { nameKey } from './naming.js'
|
|
16
|
+
import { writeFileAtomic } from './util.js'
|
|
17
|
+
|
|
18
|
+
/** 锁表文件:状态根下,与记忆文件分开。 */
|
|
19
|
+
export function lockFileOf(memoryDir) {
|
|
20
|
+
return join(dirname(memoryDir), '.sage-mem', 'locked.json')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 读锁表。读不到 / JSON 坏 / 不是对象 / 值不是 `true` → 一律当空表。
|
|
25
|
+
* **绝不抛**:锁表坏了最坏是「锁没生效」,不该升级成「读不了记忆」。
|
|
26
|
+
*/
|
|
27
|
+
export async function readLockSet(memoryDir) {
|
|
28
|
+
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()
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 这条记忆锁上了吗。 */
|
|
42
|
+
export async function isLocked(memoryDir, name) {
|
|
43
|
+
return (await readLockSet(memoryDir)).has(nameKey(name))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 上锁 / 解锁。
|
|
48
|
+
*
|
|
49
|
+
* 读-改-写:两张并发的锁操作理论上会互相覆盖(丢一次锁)。这里**不引入锁**:
|
|
50
|
+
* 代价是「同时锁两条记忆时可能少一条」,而这远轻于给一个状态文件加一套锁 ——
|
|
51
|
+
* 重按一次就好,用户看得见结果。
|
|
52
|
+
*/
|
|
53
|
+
export async function setLocked(memoryDir, name, locked) {
|
|
54
|
+
const key = nameKey(name)
|
|
55
|
+
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 }
|
|
68
|
+
}
|
package/lib/typert.host.js
CHANGED
|
@@ -49,6 +49,9 @@ const fileSchema = z.object({
|
|
|
49
49
|
// strict 模式下 host 多返回的字段会被剥掉,所以「列表页要能按标签筛」这件事
|
|
50
50
|
// 必须在这里显式声明,否则前端永远拿到 undefined。
|
|
51
51
|
'tags': z.array(z.string()).readonly(),
|
|
52
|
+
// 锁定状态(拒绝归档)跟着每条一起给 —— strict 会把没声明的字段剥掉,
|
|
53
|
+
// 不在这里声明,卡片上的「锁定 / 已锁定」按钮就永远拿到 undefined。
|
|
54
|
+
'locked': z.boolean().readonly(),
|
|
52
55
|
}).readonly()
|
|
53
56
|
|
|
54
57
|
const listResultSchema = z.array(fileSchema)
|
|
@@ -116,6 +119,16 @@ const archivedWriteResultSchema = z.object({
|
|
|
116
119
|
}).readonly()
|
|
117
120
|
|
|
118
121
|
// ── 面板 API(v0.9.0 D1):体检 / 归档候选 / 设置 / 保留名原样读写 ──────────────
|
|
122
|
+
// 更新检查:只回答「有没有更新的正式版」。失败一律降级为「不提示」(error / rateLimited
|
|
123
|
+
// 只是给日志与调试看的,界面不该因为查版本失败而冒红条)。
|
|
124
|
+
const updateCheckSchema = z.object({
|
|
125
|
+
'current': z.string().readonly(),
|
|
126
|
+
'latest': z.string().readonly().nullable(),
|
|
127
|
+
'updateAvailable': z.boolean().readonly(),
|
|
128
|
+
'rateLimited': z.boolean().readonly(),
|
|
129
|
+
'error': z.string().readonly(),
|
|
130
|
+
'releasesUrl': z.string().readonly(),
|
|
131
|
+
}).readonly()
|
|
119
132
|
// 审计报告:字段面就是 `auditMemoryDir()` 的返回,逐项在这里钉住 —— strict 会把没声明的
|
|
120
133
|
// 字段剥掉,面板看到的清单必须与模型看到的一致。
|
|
121
134
|
const auditItemSchema = z.object({ 'file': z.string().readonly() }).readonly()
|
|
@@ -161,6 +174,14 @@ const archiveCandidatesResultSchema = z.object({
|
|
|
161
174
|
'reference': z.number().readonly(),
|
|
162
175
|
'user': z.number().readonly(),
|
|
163
176
|
}).readonly(),
|
|
177
|
+
// 锁定的记忆(已按 nameKey 归一):面板要显示「哪些锁着」
|
|
178
|
+
'locked': z.array(z.string().readonly()).readonly(),
|
|
179
|
+
}).readonly()
|
|
180
|
+
// 上锁 / 解锁的结果
|
|
181
|
+
const lockResultSchema = z.object({
|
|
182
|
+
'ok': z.boolean().readonly(),
|
|
183
|
+
'file': z.string().readonly(),
|
|
184
|
+
'locked': z.boolean().readonly(),
|
|
164
185
|
}).readonly()
|
|
165
186
|
|
|
166
187
|
// 设置:limits = 当前**已生效**的值(模块加载时读的),pendingLimits = 待生效值
|
|
@@ -528,6 +549,31 @@ export const TYPERT = {
|
|
|
528
549
|
result: { mode: 'strict', typeSymbol: 'sage-mem/types#AuditReport', schema: auditReportSchema, create: () => auditReportSchema },
|
|
529
550
|
sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
|
|
530
551
|
},
|
|
552
|
+
{
|
|
553
|
+
// 面板进入时静默查一次;只有「有新版本」才在界面上提醒。
|
|
554
|
+
id: 'sage-mem#memory/checkUpdate',
|
|
555
|
+
service: 'memory',
|
|
556
|
+
namespace: 'memory',
|
|
557
|
+
method: 'checkUpdate',
|
|
558
|
+
invocation: { kind: 'direct' },
|
|
559
|
+
parameters: [],
|
|
560
|
+
result: { mode: 'strict', typeSymbol: 'sage-mem/types#UpdateCheck', schema: updateCheckSchema, create: () => updateCheckSchema },
|
|
561
|
+
sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
|
|
562
|
+
},
|
|
563
|
+
{
|
|
564
|
+
// 上锁 / 解锁一条记忆:锁上的记忆拒绝归档(自动与手动都不行)。
|
|
565
|
+
id: 'sage-mem#memory/setLocked',
|
|
566
|
+
service: 'memory',
|
|
567
|
+
namespace: 'memory',
|
|
568
|
+
method: 'setLocked',
|
|
569
|
+
invocation: { kind: 'direct' },
|
|
570
|
+
parameters: [
|
|
571
|
+
{ name: 'name', wire: 'name', source: 'json', codec: { mode: 'strict', typeSymbol: 'sage-mem/types#FileName', schema: fileNameSchema, create: () => fileNameSchema } },
|
|
572
|
+
{ name: 'locked', wire: 'locked', source: 'json', codec: { mode: 'strict', typeSymbol: 'sage-mem/types#LockFlag', schema: z.boolean().readonly(), create: () => z.boolean().readonly() } },
|
|
573
|
+
],
|
|
574
|
+
result: { mode: 'strict', typeSymbol: 'sage-mem/types#LockResult', schema: lockResultSchema, create: () => lockResultSchema },
|
|
575
|
+
sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
|
|
576
|
+
},
|
|
531
577
|
{
|
|
532
578
|
id: 'sage-mem#memory/archiveCandidates',
|
|
533
579
|
service: 'memory',
|
package/lib/update.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 版本检查(宿主侧)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么放在宿主侧查、而不是界面里 fetch:
|
|
5
|
+
* - 跨域与限流都按用户的浏览器出口 IP 算(未认证 60 次/小时),宿主侧可以带
|
|
6
|
+
* `GITHUB_TOKEN` 把额度提到 5000 次/小时
|
|
7
|
+
* - 界面拿不到进程环境里的凭据,宿主侧拿得到
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ **当前版本一律运行时读自己的 `package.json`**,绝不内联常量:
|
|
10
|
+
* 客户端 bundle 是构建期产物,如果版本号在构建时被写死,那么「先 build 再 bump」
|
|
11
|
+
* 的发布流程会让插件永远拿旧常量去比最新 release —— 于是它**对自己报「有新版本」**,
|
|
12
|
+
* 而其实已经是最新的。运行时读文件没有这个时间差。
|
|
13
|
+
*/
|
|
14
|
+
import { readFile } from 'node:fs/promises'
|
|
15
|
+
|
|
16
|
+
/** 仓库与接口(单一事实源:界面上的「看更新内容」也指向它)。 */
|
|
17
|
+
export const REPO_SLUG = 'gezi-wen/sage-mem'
|
|
18
|
+
export const REPO_URL = `https://github.com/${REPO_SLUG}`
|
|
19
|
+
export const RELEASES_URL = `${REPO_URL}/releases`
|
|
20
|
+
export const RELEASES_LATEST_API = `https://api.github.com/repos/${REPO_SLUG}/releases/latest`
|
|
21
|
+
|
|
22
|
+
/** 当前安装的版本;读不到就回空串(宁可不提示,也不误报)。 */
|
|
23
|
+
export async function currentVersion() {
|
|
24
|
+
try {
|
|
25
|
+
const raw = await readFile(new URL('../package.json', import.meta.url), 'utf8')
|
|
26
|
+
const pkg = JSON.parse(raw)
|
|
27
|
+
return typeof pkg?.version === 'string' ? pkg.version : ''
|
|
28
|
+
} catch {
|
|
29
|
+
return ''
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 简易版本比较:数字段逐位比,忽略 `v` 前缀与预发布后缀(`-rc.1` 这种不参与)。
|
|
35
|
+
*
|
|
36
|
+
* **故意不引 semver 依赖**:这里只回答「有没有更新的正式版」,
|
|
37
|
+
* 预发布前缀的比较规则(`1.0.0-rc.1 < 1.0.0`)不在这个问题的范围内。
|
|
38
|
+
* @returns {number} a > b 返回正数,a < b 返回负数,相等返回 0
|
|
39
|
+
*/
|
|
40
|
+
export function compareVersions(a, b) {
|
|
41
|
+
const parts = (v) =>
|
|
42
|
+
String(v ?? '')
|
|
43
|
+
.replace(/^v/i, '')
|
|
44
|
+
.split('-')[0]
|
|
45
|
+
.split('.')
|
|
46
|
+
.map((n) => parseInt(n, 10) || 0)
|
|
47
|
+
const pa = parts(a)
|
|
48
|
+
const pb = parts(b)
|
|
49
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
50
|
+
const diff = (pa[i] ?? 0) - (pb[i] ?? 0)
|
|
51
|
+
if (diff !== 0) return diff
|
|
52
|
+
}
|
|
53
|
+
return 0
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 查有没有新版本。
|
|
58
|
+
*
|
|
59
|
+
* 失败一律**降级为「不提示」**,并把原因放在 `error` / `rateLimited` 里 ——
|
|
60
|
+
* 网络不可达、被限流、仓库还没建 release,都不该在面板上冒一个红条打断人。
|
|
61
|
+
*
|
|
62
|
+
* @param {{fetchImpl?: Function, timeoutMs?: number}} [opts] — `fetchImpl` 只为测试注入
|
|
63
|
+
* @returns {Promise<{current:string, latest:string|null, updateAvailable:boolean, rateLimited:boolean, error:string}>}
|
|
64
|
+
*/
|
|
65
|
+
export async function checkUpdate(opts = {}) {
|
|
66
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch
|
|
67
|
+
const current = await currentVersion()
|
|
68
|
+
const base = { current, latest: null, updateAvailable: false, rateLimited: false, error: '', releasesUrl: RELEASES_URL }
|
|
69
|
+
if (typeof fetchImpl !== 'function') return { ...base, error: '当前运行时不支持 fetch' }
|
|
70
|
+
|
|
71
|
+
// 手动 AbortController + finally 清理:AbortSignal.timeout 的隐式定时器在 Windows 上
|
|
72
|
+
// 退出时可能触发 libuv 断言(UV_HANDLE_CLOSING)。
|
|
73
|
+
const controller = new AbortController()
|
|
74
|
+
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 10000)
|
|
75
|
+
try {
|
|
76
|
+
const token = String(process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? '').trim()
|
|
77
|
+
const res = await fetchImpl(RELEASES_LATEST_API, {
|
|
78
|
+
headers: {
|
|
79
|
+
Accept: 'application/vnd.github+json',
|
|
80
|
+
'User-Agent': 'sage-mem',
|
|
81
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
82
|
+
},
|
|
83
|
+
redirect: 'follow',
|
|
84
|
+
signal: controller.signal,
|
|
85
|
+
})
|
|
86
|
+
if (res.status === 403 || res.status === 429) {
|
|
87
|
+
await res.body?.cancel?.().catch?.(() => {})
|
|
88
|
+
return { ...base, rateLimited: true, error: 'GitHub API 限流' }
|
|
89
|
+
}
|
|
90
|
+
if (!res.ok) {
|
|
91
|
+
await res.body?.cancel?.().catch?.(() => {})
|
|
92
|
+
return { ...base, error: `GitHub API ${res.status}` }
|
|
93
|
+
}
|
|
94
|
+
const data = await res.json().catch(() => undefined)
|
|
95
|
+
const tag = typeof data?.tag_name === 'string' ? data.tag_name.replace(/^v/i, '') : ''
|
|
96
|
+
if (!tag) return { ...base, error: 'release 里没有 tag_name' }
|
|
97
|
+
return { ...base, latest: tag, updateAvailable: current !== '' && compareVersions(tag, current) > 0 }
|
|
98
|
+
} catch (err) {
|
|
99
|
+
return { ...base, error: err?.message ?? String(err) }
|
|
100
|
+
} finally {
|
|
101
|
+
clearTimeout(timer)
|
|
102
|
+
}
|
|
103
|
+
}
|
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.9",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|