dsh-prime-memory 0.20.0-beta.12 → 0.20.0-beta.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/CHANGELOG.md +25 -0
- package/dist/client.js +133 -30
- package/dist/contract.d.ts +16 -0
- package/dist/hooks/recall.d.ts +4 -1
- package/dist/hooks/recall.js +7 -1
- package/dist/index.js +7 -1
- package/dist/stats.d.ts +15 -0
- package/dist/stats.js +22 -3
- package/dist/store/l1.d.ts +2 -0
- package/dist/store/l1.js +4 -0
- package/dist/store/recall-usage.d.ts +43 -0
- package/dist/store/recall-usage.js +149 -0
- package/dist/store/rooms-registry.d.ts +3 -1
- package/dist/store/rooms-registry.js +5 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -17,6 +17,31 @@
|
|
|
17
17
|
- **建议状态(`longtask-hint-get`)以纯端点提供**:判定 = 上下文占用达 `contextThresholdPct` 或 todo 漂移达 `driftThreshold`(部署配置可调);配合 `longtask-compress-tail` 作手动压缩入口。
|
|
18
18
|
- **撤下浮动球 UI(`client/src/longtask-fab.ts`;beta.1 起曾随包发布)**:悬浮建议面板的归属本在 context-compression 侧(「建议启动压缩」是其领域),pm 属误载——2026-10-02 用户裁定撤下。撤下不违座位契约(settings.section 恰一个 + 输入栏 pill,body 单例不占座位),但全应用级 overlay 会与其他插件的悬浮 UI 抢占屏幕角落;长任务开关与手动压缩入口待长任务定版时落记忆设置分节。
|
|
19
19
|
- **`session-mode-set` 的 `mode` 转为可选**:与 `longTask` 二选一必传——纯切长任务开关不再被迫发送档位(旧客户端恒传 mode,行为不变)。响应 `mode` 改回当前生效值。
|
|
20
|
+
## [0.20.0-beta.14] — 2026-10-02
|
|
21
|
+
|
|
22
|
+
> 记忆卡片新增**老化权重**与**召回使用**展示,并开始为"老化权重效果统计"积累数据。口径(用户裁定):**进 topN(被注入上下文)才算被使用**;参与尝试未进 = 仅计尝试。
|
|
23
|
+
|
|
24
|
+
### 新增
|
|
25
|
+
|
|
26
|
+
- **卡片字段**:「老化 N%」chip(读路径衰减系数 `max(0.5, 0.5^(Δ天/半衰期))`,与召回排序同式同源;≤60% 标红=偏老)+「用 used/attempts」计数(title 含最近使用/尝试时间)。工具条新增汇总「召回使用 used/attempted(rate%)」。
|
|
27
|
+
- **统计面**:`RecallUsageStore`(recall-usage.json sidecar,与 rooms-registry 同族纪律——损坏只读降级、绝不阻塞召回读路径);每轮召回在注入/全压制/预算清零三个终态记录(候选 attempts++、注入 used++,时间戳取最新);内存即时 + 去抖 5s 原子落盘,5 万条按最近尝试淘汰;未装配 = 零成本旁路。
|
|
28
|
+
- 契约:UiRecord 增 `agingWeight`/`usage`,ListRecordsResponse 增 `usageSummary`(可选字段,旧客户端零断裂);契约键数不变。
|
|
29
|
+
|
|
30
|
+
### 效果统计口径(后续分析用)
|
|
31
|
+
- usageSummary:tracked(有统计的记忆数)/ attempted(Σ尝试)/ used(Σ使用)/ rate(使用率%)
|
|
32
|
+
- 细粒度分析(按类型/年龄段的使用分布、半衰期调参依据)数据自本版起积累,分析视图另议。
|
|
33
|
+
## [0.20.0-beta.13] — 2026-10-02
|
|
34
|
+
|
|
35
|
+
> 补全归类逻辑:把 room 归到已有 hall 下(此前只能新增 hall,或合并进别的 room 抹掉身份)。面板支持**拖拽归类**——拖 room 放到 hall 头上即可。
|
|
36
|
+
|
|
37
|
+
### 新增
|
|
38
|
+
|
|
39
|
+
- **Hall 页拖拽归类**:room 行可拖(hiPriv 开启时抓手光标),hall 头为放置目标(虚线高亮);松手 = 预填改名(major/minor)并直接出 dryRun 预览,确认后实跑——记录随迁、旧名进别名、分组视图即挂到该 hall 下。触屏/不支持拖放的环境用 room 行「归类」按钮或改名表单里的 hall 快选 chips 兜底。
|
|
40
|
+
- 后端修复:`renameSlug` 目标校验 `isTag` → `isRoomSlug`——此前两级目标(major/minor)被误拒,「归类到 hall 下」这条路实际不通(且 rename 编排会先重写记录再在注册表改名时抛错,留下半程状态)。按 C3 卸除/变更纪律:红测试先行(unit + HTTP 面集成各一),验红后修复回绿。
|
|
41
|
+
|
|
42
|
+
### 修复
|
|
43
|
+
|
|
44
|
+
- 同上:`renameSlug` 两级目标校验(beta.5 引入的缺口)。
|
|
20
45
|
## [0.20.0-beta.12] — 2026-10-02
|
|
21
46
|
|
|
22
47
|
> lint 清账 + 卸除纪律成文(ENGINEERING-NOTES 新增 C3)。**无功能变更**。
|
package/dist/client.js
CHANGED
|
@@ -3326,6 +3326,7 @@ var __defProp = Object.defineProperty;
|
|
|
3326
3326
|
}
|
|
3327
3327
|
const v = r.value;
|
|
3328
3328
|
setItems((prev) => append ? prev.concat(v.items) : v.items);
|
|
3329
|
+
if (v.usageSummary) setUsageSummary(v.usageSummary);
|
|
3329
3330
|
if (!append) setSel(/* @__PURE__ */ new Set());
|
|
3330
3331
|
setHasMore(!!v.hasMore);
|
|
3331
3332
|
setTotal(v.total === void 0 || v.total === null ? null : v.total);
|
|
@@ -3464,6 +3465,7 @@ var __defProp = Object.defineProperty;
|
|
|
3464
3465
|
unknown: "已退场"
|
|
3465
3466
|
};
|
|
3466
3467
|
const countText = total !== null ? "共 " + total + " 条" : items.length + " 条" + (hasMore ? "+" : "");
|
|
3468
|
+
const [usageSummary, setUsageSummary] = (0, import_react15.useState)(null);
|
|
3467
3469
|
const selCount = sel.size;
|
|
3468
3470
|
return /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("div", { children: [
|
|
3469
3471
|
/* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("div", { style: S.toolbar, children: [
|
|
@@ -3746,6 +3748,7 @@ var __defProp = Object.defineProperty;
|
|
|
3746
3748
|
] }),
|
|
3747
3749
|
/* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("div", { style: { ...S.flexRow, marginBottom: 10 }, children: [
|
|
3748
3750
|
/* @__PURE__ */ (0, import_jsx_runtime15.jsx)("span", { style: S.muted, children: loading ? "加载中…" : countText }),
|
|
3751
|
+
usageSummary ? /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("span", { style: S.muted, title: "老化权重效果口径:参与召回尝试的记忆中,进 topN(被注入上下文)的比例", children: "召回使用 " + usageSummary.used + "/" + usageSummary.attempted + "(" + usageSummary.rate + "%)" }) : null,
|
|
3749
3752
|
/* @__PURE__ */ (0, import_jsx_runtime15.jsx)("span", { style: S.muted, children: "退场筛查" }),
|
|
3750
3753
|
[["", "全部"], ["active", "仅活跃"], ["retired", "仅退场"]].map(([val, label]) => {
|
|
3751
3754
|
const on = retiredFilter === val;
|
|
@@ -3849,6 +3852,29 @@ var __defProp = Object.defineProperty;
|
|
|
3849
3852
|
}
|
|
3850
3853
|
) : null,
|
|
3851
3854
|
/* @__PURE__ */ (0, import_jsx_runtime15.jsx)("span", { style: S.muted, children: "优先级 " + m.priority }),
|
|
3855
|
+
m.agingWeight != null ? /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(
|
|
3856
|
+
"span",
|
|
3857
|
+
{
|
|
3858
|
+
style: {
|
|
3859
|
+
fontSize: 11,
|
|
3860
|
+
padding: "1px 6px",
|
|
3861
|
+
borderRadius: 999,
|
|
3862
|
+
whiteSpace: "nowrap",
|
|
3863
|
+
border: "1px solid " + (m.agingWeight <= 0.6 ? "var(--dsh-mem-danger)" : "var(--dsh-mem-border)"),
|
|
3864
|
+
color: m.agingWeight <= 0.6 ? "var(--dsh-mem-danger)" : "var(--dsh-mem-text-2)"
|
|
3865
|
+
},
|
|
3866
|
+
title: "老化权重 " + Math.round(m.agingWeight * 100) + "%(读路径衰减系数 max(0.5, 0.5^(Δ天/半衰期)));越低越老",
|
|
3867
|
+
children: "老化 " + Math.round(m.agingWeight * 100) + "%"
|
|
3868
|
+
}
|
|
3869
|
+
) : null,
|
|
3870
|
+
m.usage ? /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(
|
|
3871
|
+
"span",
|
|
3872
|
+
{
|
|
3873
|
+
style: S.muted,
|
|
3874
|
+
title: "召回使用 " + m.usage.used + "/" + m.usage.attempts + "(进 topN 才算 used)" + (m.usage.lastUsedAt ? ";最近使用 " + fmtTime(new Date(m.usage.lastUsedAt).toISOString()) : "") + (m.usage.lastAttemptAt ? ";最近尝试 " + fmtTime(new Date(m.usage.lastAttemptAt).toISOString()) : ""),
|
|
3875
|
+
children: "用 " + m.usage.used + "/" + m.usage.attempts
|
|
3876
|
+
}
|
|
3877
|
+
) : null,
|
|
3852
3878
|
m.score !== null && m.score !== void 0 ? /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("span", { style: S.muted, children: "相关度 " + Number(m.score).toFixed(2) }) : null,
|
|
3853
3879
|
/* @__PURE__ */ (0, import_jsx_runtime15.jsx)("div", { style: S.grow }),
|
|
3854
3880
|
/* @__PURE__ */ (0, import_jsx_runtime15.jsx)("span", { style: S.muted, children: fmtTime(m.updatedAt) }),
|
|
@@ -3978,6 +4004,10 @@ var __defProp = Object.defineProperty;
|
|
|
3978
4004
|
const i = slug.indexOf("/");
|
|
3979
4005
|
return i === -1 ? slug : slug.slice(0, i);
|
|
3980
4006
|
}
|
|
4007
|
+
function suffixOf(slug) {
|
|
4008
|
+
const i = slug.indexOf("/");
|
|
4009
|
+
return i === -1 ? slug : slug.slice(i + 1);
|
|
4010
|
+
}
|
|
3981
4011
|
function downloadCsv(csv, name) {
|
|
3982
4012
|
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
|
|
3983
4013
|
const url = URL.createObjectURL(blob);
|
|
@@ -4012,6 +4042,7 @@ var __defProp = Object.defineProperty;
|
|
|
4012
4042
|
const [form, setForm] = (0, import_react16.useState)(null);
|
|
4013
4043
|
const [preview, setPreview] = (0, import_react16.useState)(null);
|
|
4014
4044
|
const [grownOpen, setGrownOpen] = (0, import_react16.useState)(false);
|
|
4045
|
+
const [dragOverHall, setDragOverHall] = (0, import_react16.useState)(null);
|
|
4015
4046
|
const loadAll = (0, import_react16.useCallback)(() => {
|
|
4016
4047
|
rpc("dsh-memory/rooms-get", {}).then((r) => {
|
|
4017
4048
|
if (r && r.ok) {
|
|
@@ -4073,22 +4104,41 @@ var __defProp = Object.defineProperty;
|
|
|
4073
4104
|
loadAll();
|
|
4074
4105
|
} else setError(r && r.error ? r.error.message : "收编失败");
|
|
4075
4106
|
});
|
|
4076
|
-
const previewMerge = () => run(async () => {
|
|
4077
|
-
|
|
4078
|
-
|
|
4107
|
+
const previewMerge = (override) => run(async () => {
|
|
4108
|
+
const fm = override ?? form;
|
|
4109
|
+
if (!fm) return;
|
|
4110
|
+
const to = fm.to.trim();
|
|
4079
4111
|
if (!to) {
|
|
4080
4112
|
setError("目标 slug 不能为空");
|
|
4081
4113
|
return;
|
|
4082
4114
|
}
|
|
4083
|
-
if (to ===
|
|
4115
|
+
if (to === fm.from) {
|
|
4084
4116
|
setError("目标与源相同");
|
|
4085
4117
|
return;
|
|
4086
4118
|
}
|
|
4087
|
-
const r = await rpc("dsh-memory/room-admin", { action:
|
|
4119
|
+
const r = await rpc("dsh-memory/room-admin", { action: fm.action, from: fm.from, to });
|
|
4088
4120
|
if (r && r.ok) {
|
|
4089
|
-
setPreview({ action:
|
|
4121
|
+
setPreview({ action: fm.action, from: fm.from, to, notice: String(r.value?.notice ?? "") });
|
|
4090
4122
|
} else setError(r && r.error ? r.error.message : "预览失败");
|
|
4091
4123
|
});
|
|
4124
|
+
const beginReclass = (from, hall) => {
|
|
4125
|
+
if (!busy) {
|
|
4126
|
+
setMsg(null);
|
|
4127
|
+
setError(null);
|
|
4128
|
+
}
|
|
4129
|
+
if (!hall) {
|
|
4130
|
+
setError("尚无 hall:先注册一个(如 dsh-plugin),再归类。");
|
|
4131
|
+
return;
|
|
4132
|
+
}
|
|
4133
|
+
if (majorOf(from) === hall) {
|
|
4134
|
+
setMsg(`${from} 已在 ${hall} 下。`);
|
|
4135
|
+
return;
|
|
4136
|
+
}
|
|
4137
|
+
const f = { action: "rename", from, to: `${hall}/${suffixOf(from)}` };
|
|
4138
|
+
setForm(f);
|
|
4139
|
+
setPreview(null);
|
|
4140
|
+
void previewMerge(f);
|
|
4141
|
+
};
|
|
4092
4142
|
const execMerge = () => run(async () => {
|
|
4093
4143
|
if (!preview) return;
|
|
4094
4144
|
const r = await rpc("dsh-memory/room-admin", { action: preview.action, from: preview.from, to: preview.to, dryRun: false });
|
|
@@ -4239,41 +4289,83 @@ var __defProp = Object.defineProperty;
|
|
|
4239
4289
|
msg ? /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { style: S.hint, children: msg }) : null,
|
|
4240
4290
|
error ? /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { style: S.error, children: error }) : null,
|
|
4241
4291
|
groups.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("p", { style: S.intro, children: "注册表为空。在上方注册 Hall/Room(如 dsh-plugin),或把自生长 slug「收编」进词表 —— 之后候选标注器会优先把相关记忆挂到这些 slug 上。" }) : groups.map((g) => /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { style: { marginBottom: 10, border: "1px solid var(--dsh-mem-border)", borderRadius: 8, overflow: "hidden" }, children: [
|
|
4242
|
-
/* @__PURE__ */ (0, import_jsx_runtime16.jsxs)(
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
|
|
4248
|
-
|
|
4249
|
-
|
|
4250
|
-
|
|
4251
|
-
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
4256
|
-
|
|
4257
|
-
|
|
4258
|
-
}
|
|
4259
|
-
|
|
4260
|
-
|
|
4292
|
+
/* @__PURE__ */ (0, import_jsx_runtime16.jsxs)(
|
|
4293
|
+
"div",
|
|
4294
|
+
{
|
|
4295
|
+
onDragOver: (ev) => {
|
|
4296
|
+
if (!hiPriv || busy) return;
|
|
4297
|
+
ev.preventDefault();
|
|
4298
|
+
ev.dataTransfer.dropEffect = "move";
|
|
4299
|
+
setDragOverHall(g.hall);
|
|
4300
|
+
},
|
|
4301
|
+
onDragLeave: () => setDragOverHall((cur) => cur === g.hall ? null : cur),
|
|
4302
|
+
onDrop: (ev) => {
|
|
4303
|
+
ev.preventDefault();
|
|
4304
|
+
setDragOverHall(null);
|
|
4305
|
+
if (!hiPriv || busy) return;
|
|
4306
|
+
const from = ev.dataTransfer.getData("text/dsh-room");
|
|
4307
|
+
if (from) beginReclass(from, g.hall);
|
|
4308
|
+
},
|
|
4309
|
+
style: {
|
|
4310
|
+
display: "flex",
|
|
4311
|
+
alignItems: "center",
|
|
4312
|
+
gap: 8,
|
|
4313
|
+
padding: "5px 10px",
|
|
4314
|
+
background: "var(--dsh-mem-bg-inset)",
|
|
4315
|
+
outline: dragOverHall === g.hall ? "2px dashed var(--dsh-mem-accent)" : void 0,
|
|
4316
|
+
outlineOffset: dragOverHall === g.hall ? "-2px" : void 0
|
|
4317
|
+
},
|
|
4318
|
+
title: hiPriv ? "拖入 room 归类到该 hall 下(major/minor;记录随迁,旧名进别名,预览后确认)" : void 0,
|
|
4319
|
+
children: [
|
|
4320
|
+
/* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("span", { style: { fontSize: 12, fontWeight: 600 }, children: [
|
|
4321
|
+
g.hall,
|
|
4322
|
+
g.label ? /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("span", { style: S.muted, children: " (" + g.label + ")" }) : null
|
|
4323
|
+
] }),
|
|
4324
|
+
/* @__PURE__ */ (0, import_jsx_runtime16.jsx)("span", { style: S.muted, children: `合计 ${g.total} 条 · ${g.entries.length} 个 room` }),
|
|
4325
|
+
/* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { style: S.grow }),
|
|
4326
|
+
/* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
|
|
4327
|
+
"button",
|
|
4328
|
+
{
|
|
4329
|
+
type: "button",
|
|
4330
|
+
disabled: !hiPriv || busy,
|
|
4331
|
+
title: `预填注册表单为 ${g.hall}/…(注册该 hall 下的小类)`,
|
|
4332
|
+
onClick: () => setRegSlug(g.hall + "/"),
|
|
4333
|
+
style: btn,
|
|
4334
|
+
children: "+小类"
|
|
4335
|
+
}
|
|
4336
|
+
)
|
|
4337
|
+
]
|
|
4338
|
+
}
|
|
4339
|
+
),
|
|
4261
4340
|
g.entries.map((e) => {
|
|
4262
4341
|
const standalone = e.slug === g.hall;
|
|
4263
4342
|
const count = rooms.find((r) => r.room === e.slug)?.count ?? 0;
|
|
4264
4343
|
const retired = e.status === "retired";
|
|
4265
4344
|
return /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { style: { padding: "4px 10px", borderTop: "1px solid var(--dsh-mem-border)", opacity: retired ? 0.55 : 1 }, children: [
|
|
4266
4345
|
/* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { style: { display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }, children: [
|
|
4267
|
-
/* @__PURE__ */ (0, import_jsx_runtime16.jsxs)(
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
|
|
4346
|
+
/* @__PURE__ */ (0, import_jsx_runtime16.jsxs)(
|
|
4347
|
+
"span",
|
|
4348
|
+
{
|
|
4349
|
+
style: { fontSize: 12, cursor: hiPriv && !busy ? "grab" : "default" },
|
|
4350
|
+
draggable: hiPriv && !busy,
|
|
4351
|
+
onDragStart: (ev) => {
|
|
4352
|
+
ev.dataTransfer.setData("text/dsh-room", e.slug);
|
|
4353
|
+
ev.dataTransfer.effectAllowed = "move";
|
|
4354
|
+
},
|
|
4355
|
+
title: hiPriv && !busy ? "拖到某个 hall 头上归类(major/minor;记录随迁,旧名进别名)" : e.description ? `${e.slug} — ${e.description}` : e.slug,
|
|
4356
|
+
children: [
|
|
4357
|
+
standalone ? e.slug : "· " + e.slug.slice(g.hall.length + 1),
|
|
4358
|
+
e.source === "pre-registered" ? " ⭐" : ""
|
|
4359
|
+
]
|
|
4360
|
+
}
|
|
4361
|
+
),
|
|
4271
4362
|
e.label ? /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("span", { style: S.muted, children: e.label }) : null,
|
|
4272
4363
|
/* @__PURE__ */ (0, import_jsx_runtime16.jsx)("span", { style: S.muted, children: count + " 条" }),
|
|
4273
4364
|
retired ? /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("span", { style: { ...S.muted, color: "var(--dsh-mem-danger)" }, children: "已退役" }) : null,
|
|
4274
4365
|
(e.aliases ?? []).length > 0 ? /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("span", { style: S.muted, children: "别名: " + (e.aliases ?? []).join(", ") }) : null,
|
|
4275
4366
|
/* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { style: S.grow }),
|
|
4276
4367
|
!hiPriv ? null : /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)(import_jsx_runtime16.Fragment, { children: [
|
|
4368
|
+
/* @__PURE__ */ (0, import_jsx_runtime16.jsx)("button", { type: "button", disabled: busy, title: "归类到已有 hall 下(major/minor;记录随迁)", onClick: () => beginReclass(e.slug, groups.find((gg) => gg.hall !== majorOf(e.slug))?.hall ?? ""), style: btn, children: "归类" }),
|
|
4277
4369
|
/* @__PURE__ */ (0, import_jsx_runtime16.jsx)("button", { type: "button", disabled: busy, title: `改名(= merge 1:1 + 注册表改名)`, onClick: () => {
|
|
4278
4370
|
setForm({ action: "rename", from: e.slug, to: "" });
|
|
4279
4371
|
setPreview(null);
|
|
@@ -4303,7 +4395,18 @@ var __defProp = Object.defineProperty;
|
|
|
4303
4395
|
style: { ...S.input, width: 180, fontSize: 12 }
|
|
4304
4396
|
}
|
|
4305
4397
|
),
|
|
4306
|
-
/* @__PURE__ */ (0, import_jsx_runtime16.jsx)("
|
|
4398
|
+
form.action === "rename" ? /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("span", { style: { display: "inline-flex", gap: 4, flexWrap: "wrap" }, children: groups.filter((gg) => gg.hall !== majorOf(form.from)).map((gg) => /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
|
|
4399
|
+
"button",
|
|
4400
|
+
{
|
|
4401
|
+
type: "button",
|
|
4402
|
+
title: `归入 ${gg.hall} 下(${gg.hall}/${suffixOf(form.from)})`,
|
|
4403
|
+
onClick: () => setForm({ ...form, to: `${gg.hall}/${suffixOf(form.from)}` }),
|
|
4404
|
+
style: { ...btn, borderRadius: 999, padding: "1px 8px" },
|
|
4405
|
+
children: gg.hall
|
|
4406
|
+
},
|
|
4407
|
+
gg.hall
|
|
4408
|
+
)) }) : null,
|
|
4409
|
+
/* @__PURE__ */ (0, import_jsx_runtime16.jsx)("button", { type: "button", disabled: busy, onClick: () => previewMerge(), style: { ...btn, color: "var(--dsh-mem-accent)", borderColor: "var(--dsh-mem-accent)" }, children: "预览" }),
|
|
4307
4410
|
/* @__PURE__ */ (0, import_jsx_runtime16.jsx)("button", { type: "button", disabled: busy, onClick: () => {
|
|
4308
4411
|
setForm(null);
|
|
4309
4412
|
setPreview(null);
|
package/dist/contract.d.ts
CHANGED
|
@@ -765,6 +765,15 @@ export interface UiRecord {
|
|
|
765
765
|
sourceAnchors: string[];
|
|
766
766
|
/** 检索相关度(列表路径无 score → null)。 */
|
|
767
767
|
score: number | null;
|
|
768
|
+
/** 老化权重:读路径衰减系数 max(0.5, 0.5^(Δ天/半衰期))(Δ=updatedAt;半衰期 0=关 → null)。 */
|
|
769
|
+
agingWeight?: number | null;
|
|
770
|
+
/** 召回使用统计:**进 topN(被注入)才算 used**;参与尝试未进 = 仅 attempts。缺省 = 未装配。 */
|
|
771
|
+
usage?: {
|
|
772
|
+
attempts: number;
|
|
773
|
+
used: number;
|
|
774
|
+
lastAttemptAt: number | null;
|
|
775
|
+
lastUsedAt: number | null;
|
|
776
|
+
} | null;
|
|
768
777
|
/**
|
|
769
778
|
* 是否已退场(软删)。`true` 时该记录在活动列表里仍以「已退场」态呈现(置灰 + 徽标),
|
|
770
779
|
* 可被 `records-restore` 找回——这正是设计要的「不静默消失、可恢复」,而非真删。
|
|
@@ -783,6 +792,13 @@ export interface ListRecordsResponse {
|
|
|
783
792
|
truncated: boolean;
|
|
784
793
|
/** 场景筛选下拉选项(仅 offset===0 时附带)。 */
|
|
785
794
|
scenes?: string[];
|
|
795
|
+
/** 召回使用汇总(老化权重效果口径:进 topN 才算 used)。未装配 = undefined。 */
|
|
796
|
+
usageSummary?: {
|
|
797
|
+
tracked: number;
|
|
798
|
+
attempted: number;
|
|
799
|
+
used: number;
|
|
800
|
+
rate: number;
|
|
801
|
+
} | null;
|
|
786
802
|
/**
|
|
787
803
|
* Wing 词表(R8 单一事实源:服务端随 list-records 下发,client 不再手抄;仅 offset===0 时附带)。
|
|
788
804
|
* 含 8 角 + `general`(跨域兜底,存量大 reserved 值仍可筛)。缺省(旧服务端)时 client
|
package/dist/hooks/recall.d.ts
CHANGED
|
@@ -22,6 +22,7 @@ import type { L1Store } from '../store/l1.js';
|
|
|
22
22
|
import type { PersonaStore } from '../store/persona.js';
|
|
23
23
|
import type { SceneStore } from '../store/scenes.js';
|
|
24
24
|
import type { SessionModeStore } from '../store/session-modes.js';
|
|
25
|
+
import type { RecallUsageStore } from '../store/recall-usage.js';
|
|
25
26
|
import type { MemoryLogger } from '../types.js';
|
|
26
27
|
import { type OccupancyLedger } from '../util/context-occupancy.js';
|
|
27
28
|
/**
|
|
@@ -89,4 +90,6 @@ export declare function registerRecall(ctx: Context, cfg: MemoryConfig, stores:
|
|
|
89
90
|
l1: L1Store;
|
|
90
91
|
scenes: Record<'chat' | 'work', SceneStore>;
|
|
91
92
|
persona: Record<'chat' | 'work', PersonaStore>;
|
|
92
|
-
}, logger: MemoryLogger, live: LiveSettingsHandle, modes: SessionModeStore, dataDir: string
|
|
93
|
+
}, logger: MemoryLogger, live: LiveSettingsHandle, modes: SessionModeStore, dataDir: string,
|
|
94
|
+
/** 召回使用统计(可选;未装配 = 零成本旁路):进 topN(注入)才算 used。 */
|
|
95
|
+
usage?: RecallUsageStore): RecallHooks;
|
package/dist/hooks/recall.js
CHANGED
|
@@ -114,7 +114,9 @@ export function emptyRecallStats(now = Date.now()) {
|
|
|
114
114
|
updatedAt: now,
|
|
115
115
|
};
|
|
116
116
|
}
|
|
117
|
-
export function registerRecall(ctx, cfg, stores, logger, live, modes, dataDir
|
|
117
|
+
export function registerRecall(ctx, cfg, stores, logger, live, modes, dataDir,
|
|
118
|
+
/** 召回使用统计(可选;未装配 = 零成本旁路):进 topN(注入)才算 used。 */
|
|
119
|
+
usage) {
|
|
118
120
|
/** 召回去重存储(同会话已注入的记忆不再重复注入;写穿持久化,重启不丢)。 */
|
|
119
121
|
const dedupe = new RecallDedupeStore(dataDir, logger);
|
|
120
122
|
/** 记忆占用流水(账本迁移写穿;重启后历史会话账目由此复生)。 */
|
|
@@ -393,6 +395,7 @@ export function registerRecall(ctx, cfg, stores, logger, live, modes, dataDir) {
|
|
|
393
395
|
injectedIds: [], suppressedCount: scoped.length - fresh.length,
|
|
394
396
|
durationMs: Date.now() - searchStart, outcome: 'suppressed',
|
|
395
397
|
});
|
|
398
|
+
usage?.recordAttempt(scoped.map((h) => h.id), []);
|
|
396
399
|
return decision;
|
|
397
400
|
}
|
|
398
401
|
const lines = applyRecallBudget(fresh.map((h) => `- [${h.scene_name ? `${h.type}|${h.scene_name}` : h.type}] ${h.content}`), { maxCharsPerMemory: cfg.recall.maxCharsPerMemory, maxTotalRecallChars: cfg.recall.maxTotalRecallChars });
|
|
@@ -404,6 +407,7 @@ export function registerRecall(ctx, cfg, stores, logger, live, modes, dataDir) {
|
|
|
404
407
|
injectedIds: [], suppressedCount: scoped.length - fresh.length,
|
|
405
408
|
durationMs: Date.now() - searchStart, outcome: 'suppressed',
|
|
406
409
|
});
|
|
410
|
+
usage?.recordAttempt(fresh.map((h) => h.id), []);
|
|
407
411
|
return decision;
|
|
408
412
|
}
|
|
409
413
|
st.lastHits = lines.length;
|
|
@@ -436,6 +440,8 @@ export function registerRecall(ctx, cfg, stores, logger, live, modes, dataDir) {
|
|
|
436
440
|
injectedIds: fresh.slice(0, lines.length).map((h) => h.id), suppressedCount: scoped.length - fresh.length,
|
|
437
441
|
durationMs: Date.now() - searchStart, outcome: 'injected',
|
|
438
442
|
});
|
|
443
|
+
// 使用统计:候选=scoped(尝试),进 topN(注入)=used
|
|
444
|
+
usage?.recordAttempt(scoped.map((h) => h.id), fresh.slice(0, lines.length).map((h) => h.id));
|
|
439
445
|
// 入账在成功构造注入消息之后、返回 enter 之前——任何前置抛错路径账目零扰动
|
|
440
446
|
const led = ledgerFor(payload.agent.id);
|
|
441
447
|
recordRecallInjection(led, text.length);
|
package/dist/index.js
CHANGED
|
@@ -37,6 +37,7 @@ import { registerMemoryTools } from './tools/index.js';
|
|
|
37
37
|
import { registerSlotTools } from './tools/slots.js';
|
|
38
38
|
import { registerSlotRecall } from './hooks/slot-recall.js';
|
|
39
39
|
import { RoomRegistryStore } from './store/rooms-registry.js';
|
|
40
|
+
import { RecallUsageStore } from './store/recall-usage.js';
|
|
40
41
|
import { registerSlotsProjection } from './projection/slots.js';
|
|
41
42
|
import { errDetail, withFileLog } from './util/filelog.js';
|
|
42
43
|
import { buildRouteChain, resolveModelRoute, invalidateEffortCache } from './llm.js';
|
|
@@ -159,6 +160,7 @@ export async function apply(ctx, config) {
|
|
|
159
160
|
}
|
|
160
161
|
}
|
|
161
162
|
const stores = {
|
|
163
|
+
usage: undefined, // 装配于下方(依赖 dataDir 就绪)
|
|
162
164
|
l0: new L0Store(dataDir, db, embed, logger),
|
|
163
165
|
l1: new L1Store(dataDir, db, embed, config.recall.strategy, logger, config.recall.decayHalfLifeDays,
|
|
164
166
|
// §D 第 3 路:图谱回链。图谱不可用时 searchNodes 自带 no-op,不影响双路。
|
|
@@ -361,7 +363,11 @@ export async function apply(ctx, config) {
|
|
|
361
363
|
if (storageOk) {
|
|
362
364
|
flushL0 = registerCapture(ctx, config, runner, stores.l0, logger, live, modes);
|
|
363
365
|
}
|
|
364
|
-
|
|
366
|
+
// 召回使用统计(进 topN 才算 used;热路径只碰内存,去抖落盘)
|
|
367
|
+
const recallUsage = new RecallUsageStore(dataDir, logger);
|
|
368
|
+
await recallUsage.init();
|
|
369
|
+
stores.usage = recallUsage;
|
|
370
|
+
const recall = registerRecall(ctx, config, stores, logger, live, modes, dataDir, recallUsage);
|
|
365
371
|
runner.setAfterRun(recall.invalidateProfile);
|
|
366
372
|
registerMemoryTools(ctx, config, stores, logger, modes, live, ruminate, storageOk ? roomRegistry : undefined);
|
|
367
373
|
// 激活槽位(active slot):工具面 + 常驻注入 + 服务端投影(均走 ctx.effect,可撤销)
|
package/dist/stats.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { type RecallSessionStats } from './hooks/recall.js';
|
|
|
4
4
|
import type { RebuildController } from './pipeline/rebuild.js';
|
|
5
5
|
import type { RuminateController } from './pipeline/ruminate.js';
|
|
6
6
|
import type { RoomRegistryStore } from './store/rooms-registry.js';
|
|
7
|
+
import type { RecallUsageStore } from './store/recall-usage.js';
|
|
7
8
|
import { type LiveSettingsHandle } from './settings.js';
|
|
8
9
|
import type { GraphStore } from './store/graph-store.js';
|
|
9
10
|
import type { L0Store } from './store/l0.js';
|
|
@@ -94,6 +95,8 @@ export declare function registerMemoryRpc(ctx: Context, cfg: MemoryConfig, store
|
|
|
94
95
|
scenes: Record<MemoryFamily, SceneStore>;
|
|
95
96
|
persona: Record<MemoryFamily, PersonaStore>;
|
|
96
97
|
state: StateStore;
|
|
98
|
+
/** 召回使用统计(可选)。 */
|
|
99
|
+
usage?: RecallUsageStore;
|
|
97
100
|
/** 记忆后端(后台边界);未装配时回退为包 l1 的进程内实现。 */
|
|
98
101
|
backend?: MemoryBackend;
|
|
99
102
|
/** 图谱存储(可选:未装配时图谱端点返空,不报错)。 */
|
|
@@ -109,6 +112,8 @@ export interface EndpointDeps {
|
|
|
109
112
|
stores: {
|
|
110
113
|
l0: L0Store;
|
|
111
114
|
l1: L1Store;
|
|
115
|
+
/** 召回使用统计(可选:未装配 = 列表不带 usage/usageSummary)。 */
|
|
116
|
+
usage?: RecallUsageStore;
|
|
112
117
|
scenes: Record<MemoryFamily, SceneStore>;
|
|
113
118
|
persona: Record<MemoryFamily, PersonaStore>;
|
|
114
119
|
state: StateStore;
|
|
@@ -131,6 +136,8 @@ export interface EndpointDeps {
|
|
|
131
136
|
/** 端点分发表(导出供测试直调:可精确注入 rebuild/ruminate 等可选控制器,验证 deps 接线)。 */
|
|
132
137
|
export declare function handleEndpoint(endpoint: string, payload: unknown, deps: EndpointDeps): Promise<unknown>;
|
|
133
138
|
/** 浏览器卡片字段(比 MemoryRecord 精简,去掉大 metadata;Hall 从 metadata 提取)。 */
|
|
139
|
+
/** 老化权重(与读路径 applyDecayWeight 同式):max(0.5, 0.5^(Δ天/半衰期));半衰期 ≤0 = 关 → null。 */
|
|
140
|
+
export declare function agingWeightOf(updatedAtMs: number | undefined, halfLifeDays: number, now?: number): number | null;
|
|
134
141
|
export declare function hitToUiRecord(r: {
|
|
135
142
|
id: string;
|
|
136
143
|
content: string;
|
|
@@ -147,4 +154,12 @@ export declare function hitToUiRecord(r: {
|
|
|
147
154
|
family?: string;
|
|
148
155
|
/** 退场判据:`valid_to` 闭合即已退场(软删)。由 `listL1`/`getByIds` 透传。 */
|
|
149
156
|
validTo?: number;
|
|
157
|
+
}, extra?: {
|
|
158
|
+
agingWeight?: number | null;
|
|
159
|
+
usage?: {
|
|
160
|
+
attempts: number;
|
|
161
|
+
used: number;
|
|
162
|
+
lastAttemptAt: number | null;
|
|
163
|
+
lastUsedAt: number | null;
|
|
164
|
+
} | null;
|
|
150
165
|
}): UiRecord;
|
package/dist/stats.js
CHANGED
|
@@ -1007,13 +1007,19 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
1007
1007
|
return true;
|
|
1008
1008
|
});
|
|
1009
1009
|
}
|
|
1010
|
+
const pageIds = filtered.slice(offset, offset + limit).map((h) => h.id);
|
|
1011
|
+
const updatedById = new Map(stores.l1.getByIds(pageIds).map((r) => [r.id, r.updatedAt]));
|
|
1010
1012
|
const resp = {
|
|
1011
|
-
items: filtered.slice(offset, offset + limit).map((h) => hitToUiRecord({ ...h, metadata: metaById?.get(h.id) }
|
|
1013
|
+
items: filtered.slice(offset, offset + limit).map((h) => hitToUiRecord({ ...h, metadata: metaById?.get(h.id) }, {
|
|
1014
|
+
agingWeight: agingWeightOf(updatedById.get(h.id), stores.l1.decayHalfLife),
|
|
1015
|
+
usage: stores.usage?.get(h.id) ?? null,
|
|
1016
|
+
})),
|
|
1012
1017
|
hasMore: filtered.length > offset + limit,
|
|
1013
1018
|
total: null,
|
|
1014
1019
|
truncated: wanted > SEARCH_CAP,
|
|
1015
1020
|
scenes: offset === 0 ? stores.l1.distinctScenes() : undefined,
|
|
1016
1021
|
wingCatalog,
|
|
1022
|
+
usageSummary: stores.usage?.summary() ?? null,
|
|
1017
1023
|
};
|
|
1018
1024
|
return resp;
|
|
1019
1025
|
}
|
|
@@ -1021,12 +1027,16 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
1021
1027
|
// 接了只会让「仅退场+关键词」永远空结果,不如如实不筛。
|
|
1022
1028
|
const { items, total } = stores.l1.list({ type: p.type || undefined, scene: p.scene || undefined, hall: p.hall || undefined, halls: wingSel.length > 0 ? wingSel : undefined, tag: tagSel || undefined, untagged: untaggedSel || undefined, retired: retiredSel, limit, offset });
|
|
1023
1029
|
const resp = {
|
|
1024
|
-
items: items.map(hitToUiRecord
|
|
1030
|
+
items: items.map((r) => hitToUiRecord(r, {
|
|
1031
|
+
agingWeight: agingWeightOf(r.updatedAt, stores.l1.decayHalfLife),
|
|
1032
|
+
usage: stores.usage?.get(r.id) ?? null,
|
|
1033
|
+
})),
|
|
1025
1034
|
hasMore: offset + items.length < total,
|
|
1026
1035
|
total,
|
|
1027
1036
|
truncated: false,
|
|
1028
1037
|
scenes: offset === 0 ? stores.l1.distinctScenes() : undefined,
|
|
1029
1038
|
wingCatalog,
|
|
1039
|
+
usageSummary: stores.usage?.summary() ?? null,
|
|
1030
1040
|
};
|
|
1031
1041
|
return resp;
|
|
1032
1042
|
}
|
|
@@ -1633,7 +1643,14 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
1633
1643
|
}
|
|
1634
1644
|
}
|
|
1635
1645
|
/** 浏览器卡片字段(比 MemoryRecord 精简,去掉大 metadata;Hall 从 metadata 提取)。 */
|
|
1636
|
-
|
|
1646
|
+
/** 老化权重(与读路径 applyDecayWeight 同式):max(0.5, 0.5^(Δ天/半衰期));半衰期 ≤0 = 关 → null。 */
|
|
1647
|
+
export function agingWeightOf(updatedAtMs, halfLifeDays, now = Date.now()) {
|
|
1648
|
+
if (!(halfLifeDays > 0))
|
|
1649
|
+
return null;
|
|
1650
|
+
const ageDays = Math.max(0, (now - (updatedAtMs ?? 0)) / 86_400_000);
|
|
1651
|
+
return Math.max(0.5, Math.pow(0.5, ageDays / halfLifeDays));
|
|
1652
|
+
}
|
|
1653
|
+
export function hitToUiRecord(r, extra) {
|
|
1637
1654
|
const retired = r.validTo !== undefined;
|
|
1638
1655
|
const mark = retired ? readSupersedeMarker(r.metadata) : undefined;
|
|
1639
1656
|
return {
|
|
@@ -1659,6 +1676,8 @@ export function hitToUiRecord(r) {
|
|
|
1659
1676
|
// 但必须给出可见差异。
|
|
1660
1677
|
retired,
|
|
1661
1678
|
retiredReason: retired ? (mark?.reason ?? 'unknown') : null,
|
|
1679
|
+
agingWeight: extra?.agingWeight ?? null,
|
|
1680
|
+
usage: extra?.usage ?? null,
|
|
1662
1681
|
};
|
|
1663
1682
|
}
|
|
1664
1683
|
/**
|
package/dist/store/l1.d.ts
CHANGED
|
@@ -70,6 +70,8 @@ export declare class L1Store {
|
|
|
70
70
|
decayHalfLifeDays?: number,
|
|
71
71
|
/** 图谱路提供者(§D 第 3 路);不传则该路不存在,融合退回双路。 */
|
|
72
72
|
graphLane?: GraphLaneProvider);
|
|
73
|
+
/** 读路径时效半衰期(天;0=关)。老化权重展示与召回衰减同一事实源。 */
|
|
74
|
+
get decayHalfLife(): number;
|
|
73
75
|
init(): Promise<void>;
|
|
74
76
|
/** 旧版单文件 records.jsonl 一次性导入检索库,成功后改名 .imported。 */
|
|
75
77
|
private importLegacy;
|
package/dist/store/l1.js
CHANGED
|
@@ -56,6 +56,10 @@ export class L1Store {
|
|
|
56
56
|
this.decayHalfLifeDays = decayHalfLifeDays ?? 30;
|
|
57
57
|
this.graphLaneProvider = graphLane;
|
|
58
58
|
}
|
|
59
|
+
/** 读路径时效半衰期(天;0=关)。老化权重展示与召回衰减同一事实源。 */
|
|
60
|
+
get decayHalfLife() {
|
|
61
|
+
return this.decayHalfLifeDays;
|
|
62
|
+
}
|
|
59
63
|
async init() {
|
|
60
64
|
await ensureDir(this.recordsDir);
|
|
61
65
|
await this.importLegacy();
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { MemoryLogger } from '../types.js';
|
|
2
|
+
export declare const RECALL_USAGE_FILE_VERSION: 1;
|
|
3
|
+
export interface RecallUsageEntry {
|
|
4
|
+
attempts: number;
|
|
5
|
+
used: number;
|
|
6
|
+
lastAttemptAt: number | null;
|
|
7
|
+
lastUsedAt: number | null;
|
|
8
|
+
}
|
|
9
|
+
export interface RecallUsageSummary {
|
|
10
|
+
/** 有统计的记忆条数。 */
|
|
11
|
+
tracked: number;
|
|
12
|
+
/** 尝试总次数(Σ attempts)。 */
|
|
13
|
+
attempted: number;
|
|
14
|
+
/** 使用总次数(Σ used)。 */
|
|
15
|
+
used: number;
|
|
16
|
+
/** 使用率(百分比整数;attempted=0 时 0)。 */
|
|
17
|
+
rate: number;
|
|
18
|
+
}
|
|
19
|
+
export declare class RecallUsageStore {
|
|
20
|
+
private readonly logger?;
|
|
21
|
+
private readonly file;
|
|
22
|
+
private entries;
|
|
23
|
+
private loaded;
|
|
24
|
+
private degraded;
|
|
25
|
+
private degradedLogged;
|
|
26
|
+
private writeTimer;
|
|
27
|
+
private writeChain;
|
|
28
|
+
constructor(dataDir: string, logger?: MemoryLogger | undefined);
|
|
29
|
+
init(): Promise<void>;
|
|
30
|
+
private ensureLoaded;
|
|
31
|
+
/** 一轮召回:候选集 attempts++/lastAttemptAt=now;used 子集额外 used++/lastUsedAt=now
|
|
32
|
+
* (used 不在候选内 = 防御路径,同样算一次尝试——用了就是被尝试过)。 */
|
|
33
|
+
recordAttempt(candidateIds: readonly string[], usedIds: readonly string[]): void;
|
|
34
|
+
get(id: string): RecallUsageEntry | undefined;
|
|
35
|
+
summary(): RecallUsageSummary;
|
|
36
|
+
/** 立即落盘(去抖窗口内的兜底;测试/停机用)。 */
|
|
37
|
+
flush(): Promise<void>;
|
|
38
|
+
/** 同步等待在途写(测试用;无在途写时立即返回)。 */
|
|
39
|
+
flushSync(): void;
|
|
40
|
+
private prune;
|
|
41
|
+
private schedulePersist;
|
|
42
|
+
private persist;
|
|
43
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 召回使用统计 sidecar(老化权重效果统计的数据面)。
|
|
3
|
+
*
|
|
4
|
+
* 口径(用户裁定):**进 topN(被注入上下文)才算被使用**;参与尝试未进 = 只有
|
|
5
|
+
* attempts。每轮召回 recordAttempt(候选集, 注入集)——内存即时、去抖原子持久化
|
|
6
|
+
* (召回是热路径,统计只碰内存 Map)。
|
|
7
|
+
*
|
|
8
|
+
* 存储:`recall-usage.json` slots 式 sidecar,与 rooms-registry/session-modes 同族
|
|
9
|
+
* 纪律:缺失 = 合法空表;损坏/不可读 → 只读降级(内存态照常,停止回写),绝不阻塞
|
|
10
|
+
* 召回读路径(R2 红线)。
|
|
11
|
+
*/
|
|
12
|
+
import * as path from 'node:path';
|
|
13
|
+
import { readJsonStrict, atomicWriteText, ensureDir } from '../util/io.js';
|
|
14
|
+
export const RECALL_USAGE_FILE_VERSION = 1;
|
|
15
|
+
/** 条目上限(防已退场/被取代记忆的 id 无限累积):超限按 lastAttemptAt 淘汰最旧。 */
|
|
16
|
+
const MAX_ENTRIES = 50_000;
|
|
17
|
+
/** 去抖持久化间隔(召回热路径;窗口内断电最多丢最近统计,不影响记忆本体)。 */
|
|
18
|
+
const PERSIST_DEBOUNCE_MS = 5_000;
|
|
19
|
+
export class RecallUsageStore {
|
|
20
|
+
logger;
|
|
21
|
+
file;
|
|
22
|
+
entries = new Map();
|
|
23
|
+
loaded = false;
|
|
24
|
+
degraded;
|
|
25
|
+
degradedLogged = false;
|
|
26
|
+
writeTimer;
|
|
27
|
+
writeChain = Promise.resolve();
|
|
28
|
+
constructor(dataDir, logger) {
|
|
29
|
+
this.logger = logger;
|
|
30
|
+
this.file = path.join(dataDir, 'recall-usage.json');
|
|
31
|
+
}
|
|
32
|
+
async init() {
|
|
33
|
+
await ensureDir(path.dirname(this.file));
|
|
34
|
+
const read = await readJsonStrict(this.file);
|
|
35
|
+
if (!read.ok) {
|
|
36
|
+
if (read.reason !== 'missing') {
|
|
37
|
+
this.degraded = `${read.reason}${'detail' in read && read.detail ? `: ${read.detail}` : ''}`;
|
|
38
|
+
this.logger?.warn(`[memory] 召回使用统计只读降级: ${this.degraded}`);
|
|
39
|
+
}
|
|
40
|
+
this.loaded = true;
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const raw = read.value.entries ?? {};
|
|
44
|
+
for (const [id, e] of Object.entries(raw)) {
|
|
45
|
+
if (!e || typeof e.attempts !== 'number')
|
|
46
|
+
continue;
|
|
47
|
+
this.entries.set(id, {
|
|
48
|
+
attempts: e.attempts,
|
|
49
|
+
used: typeof e.used === 'number' ? e.used : 0,
|
|
50
|
+
lastAttemptAt: typeof e.lastAttemptAt === 'number' ? e.lastAttemptAt : null,
|
|
51
|
+
lastUsedAt: typeof e.lastUsedAt === 'number' ? e.lastUsedAt : null,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
this.loaded = true;
|
|
55
|
+
}
|
|
56
|
+
ensureLoaded() {
|
|
57
|
+
if (!this.loaded)
|
|
58
|
+
throw new Error('召回使用统计未初始化(先 await init())');
|
|
59
|
+
}
|
|
60
|
+
/** 一轮召回:候选集 attempts++/lastAttemptAt=now;used 子集额外 used++/lastUsedAt=now
|
|
61
|
+
* (used 不在候选内 = 防御路径,同样算一次尝试——用了就是被尝试过)。 */
|
|
62
|
+
recordAttempt(candidateIds, usedIds) {
|
|
63
|
+
this.ensureLoaded();
|
|
64
|
+
if (candidateIds.length === 0 && usedIds.length === 0)
|
|
65
|
+
return;
|
|
66
|
+
const now = Date.now();
|
|
67
|
+
const cand = new Set(candidateIds);
|
|
68
|
+
for (const id of cand) {
|
|
69
|
+
const e = this.entries.get(id) ?? { attempts: 0, used: 0, lastAttemptAt: null, lastUsedAt: null };
|
|
70
|
+
e.attempts += 1;
|
|
71
|
+
e.lastAttemptAt = now;
|
|
72
|
+
this.entries.set(id, e);
|
|
73
|
+
}
|
|
74
|
+
for (const id of usedIds) {
|
|
75
|
+
const e = this.entries.get(id) ?? { attempts: 0, used: 0, lastAttemptAt: null, lastUsedAt: null };
|
|
76
|
+
e.used += 1;
|
|
77
|
+
if (!cand.has(id)) {
|
|
78
|
+
e.attempts += 1;
|
|
79
|
+
e.lastAttemptAt = now;
|
|
80
|
+
}
|
|
81
|
+
e.lastUsedAt = now;
|
|
82
|
+
this.entries.set(id, e);
|
|
83
|
+
}
|
|
84
|
+
this.prune();
|
|
85
|
+
this.schedulePersist();
|
|
86
|
+
}
|
|
87
|
+
get(id) {
|
|
88
|
+
this.ensureLoaded();
|
|
89
|
+
const e = this.entries.get(id);
|
|
90
|
+
return e ? { ...e } : undefined;
|
|
91
|
+
}
|
|
92
|
+
summary() {
|
|
93
|
+
this.ensureLoaded();
|
|
94
|
+
let attempted = 0;
|
|
95
|
+
let used = 0;
|
|
96
|
+
for (const e of this.entries.values()) {
|
|
97
|
+
attempted += e.attempts;
|
|
98
|
+
used += e.used;
|
|
99
|
+
}
|
|
100
|
+
return { tracked: this.entries.size, attempted, used, rate: attempted > 0 ? Math.round((used / attempted) * 100) : 0 };
|
|
101
|
+
}
|
|
102
|
+
/** 立即落盘(去抖窗口内的兜底;测试/停机用)。 */
|
|
103
|
+
flush() {
|
|
104
|
+
if (this.writeTimer) {
|
|
105
|
+
clearTimeout(this.writeTimer);
|
|
106
|
+
this.writeTimer = undefined;
|
|
107
|
+
}
|
|
108
|
+
return this.persist();
|
|
109
|
+
}
|
|
110
|
+
/** 同步等待在途写(测试用;无在途写时立即返回)。 */
|
|
111
|
+
flushSync() {
|
|
112
|
+
void this.flush();
|
|
113
|
+
}
|
|
114
|
+
prune() {
|
|
115
|
+
if (this.entries.size <= MAX_ENTRIES)
|
|
116
|
+
return;
|
|
117
|
+
const byOldest = [...this.entries.entries()].sort((a, b) => (a[1].lastAttemptAt ?? 0) - (b[1].lastAttemptAt ?? 0));
|
|
118
|
+
for (const [id] of byOldest.slice(0, this.entries.size - MAX_ENTRIES))
|
|
119
|
+
this.entries.delete(id);
|
|
120
|
+
}
|
|
121
|
+
schedulePersist() {
|
|
122
|
+
if (this.degraded || this.writeTimer)
|
|
123
|
+
return;
|
|
124
|
+
this.writeTimer = setTimeout(() => {
|
|
125
|
+
this.writeTimer = undefined;
|
|
126
|
+
void this.persist();
|
|
127
|
+
}, PERSIST_DEBOUNCE_MS);
|
|
128
|
+
this.writeTimer.unref?.(); // 不为统计写hold进程(停机即丢窗口内统计,可接受)
|
|
129
|
+
}
|
|
130
|
+
async persist() {
|
|
131
|
+
if (this.degraded)
|
|
132
|
+
return;
|
|
133
|
+
this.writeChain = this.writeChain.then(async () => {
|
|
134
|
+
try {
|
|
135
|
+
await ensureDir(path.dirname(this.file));
|
|
136
|
+
const data = { version: RECALL_USAGE_FILE_VERSION, entries: Object.fromEntries(this.entries) };
|
|
137
|
+
await atomicWriteText(this.file, JSON.stringify(data));
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
this.degraded = err instanceof Error ? err.message : String(err);
|
|
141
|
+
if (!this.degradedLogged) {
|
|
142
|
+
this.degradedLogged = true;
|
|
143
|
+
this.logger?.warn(`[memory] 召回使用统计写入失败,只读降级: ${this.degraded}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
await this.writeChain;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -50,7 +50,9 @@ export declare class RoomRegistryStore {
|
|
|
50
50
|
}>;
|
|
51
51
|
/** 改状态(active↔retired);无变化返回 false。 */
|
|
52
52
|
setStatus(slug: string, status: 'active' | 'retired'): Promise<boolean>;
|
|
53
|
-
/** 改名(merge 1:1 收尾):slug 换名 + 旧 slug 进 aliases。新名与既有条目冲突时走 merge。
|
|
53
|
+
/** 改名(merge 1:1 收尾):slug 换名 + 旧 slug 进 aliases。新名与既有条目冲突时走 merge。
|
|
54
|
+
* 目标用两级制词表(isRoomSlug)——把 standalone room 归类到 hall 下(改名 major/minor)
|
|
55
|
+
* 走的就是这条路;isTag 会误拒带 slash 的目标。 */
|
|
54
56
|
renameSlug(oldSlug: string, newSlug: string): Promise<boolean>;
|
|
55
57
|
/** 合并收尾:fromSlug 条目转 retired + toSlug 进 aliases(记录重写由调用方游标执行)。 */
|
|
56
58
|
markMerged(fromSlug: string, toSlug: string): Promise<boolean>;
|
|
@@ -170,11 +170,13 @@ export class RoomRegistryStore {
|
|
|
170
170
|
await this.persist();
|
|
171
171
|
return true;
|
|
172
172
|
}
|
|
173
|
-
/** 改名(merge 1:1 收尾):slug 换名 + 旧 slug 进 aliases。新名与既有条目冲突时走 merge。
|
|
173
|
+
/** 改名(merge 1:1 收尾):slug 换名 + 旧 slug 进 aliases。新名与既有条目冲突时走 merge。
|
|
174
|
+
* 目标用两级制词表(isRoomSlug)——把 standalone room 归类到 hall 下(改名 major/minor)
|
|
175
|
+
* 走的就是这条路;isTag 会误拒带 slash 的目标。 */
|
|
174
176
|
async renameSlug(oldSlug, newSlug) {
|
|
175
177
|
this.ensureLoaded();
|
|
176
|
-
if (!
|
|
177
|
-
throw new Error(`非法 Room slug: ${newSlug}`);
|
|
178
|
+
if (!isRoomSlug(newSlug))
|
|
179
|
+
throw new Error(`非法 Room slug: ${newSlug}(两级制:major 或 major/minor)`);
|
|
178
180
|
const found = this.entries.find((e) => e.slug === oldSlug);
|
|
179
181
|
if (!found)
|
|
180
182
|
return false;
|