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 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
- if (!form) return;
4078
- const to = form.to.trim();
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 === form.from) {
4115
+ if (to === fm.from) {
4084
4116
  setError("目标与源相同");
4085
4117
  return;
4086
4118
  }
4087
- const r = await rpc("dsh-memory/room-admin", { action: form.action, from: form.from, to });
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: form.action, from: form.from, to, notice: String(r.value?.notice ?? "") });
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)("div", { style: { display: "flex", alignItems: "center", gap: 8, padding: "5px 10px", background: "var(--dsh-mem-bg-inset)" }, children: [
4243
- /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("span", { style: { fontSize: 12, fontWeight: 600 }, children: [
4244
- g.hall,
4245
- g.label ? /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("span", { style: S.muted, children: " (" + g.label + ")" }) : null
4246
- ] }),
4247
- /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("span", { style: S.muted, children: `合计 ${g.total} 条 · ${g.entries.length} 个 room` }),
4248
- /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { style: S.grow }),
4249
- /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
4250
- "button",
4251
- {
4252
- type: "button",
4253
- disabled: !hiPriv || busy,
4254
- title: `预填注册表单为 ${g.hall}/…(注册该 hall 下的小类)`,
4255
- onClick: () => setRegSlug(g.hall + "/"),
4256
- style: btn,
4257
- children: "+小类"
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)("span", { style: { fontSize: 12 }, title: e.description ? `${e.slug} — ${e.description}` : e.slug, children: [
4268
- standalone ? e.slug : "· " + e.slug.slice(g.hall.length + 1),
4269
- e.source === "pre-registered" ? " ⭐" : ""
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)("button", { type: "button", disabled: busy, onClick: previewMerge, style: { ...btn, color: "var(--dsh-mem-accent)", borderColor: "var(--dsh-mem-accent)" }, children: "预览" }),
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);
@@ -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
@@ -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): RecallHooks;
93
+ }, logger: MemoryLogger, live: LiveSettingsHandle, modes: SessionModeStore, dataDir: string,
94
+ /** 召回使用统计(可选;未装配 = 零成本旁路):进 topN(注入)才算 used。 */
95
+ usage?: RecallUsageStore): RecallHooks;
@@ -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
- const recall = registerRecall(ctx, config, stores, logger, live, modes, dataDir);
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
- export function hitToUiRecord(r) {
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
  /**
@@ -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 (!isTag(newSlug))
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-prime-memory",
3
- "version": "0.20.0-beta.12",
3
+ "version": "0.20.0-beta.14",
4
4
  "publishConfig": {
5
5
  "registry": "https://registry.npmjs.org",
6
6
  "access": "public",