dsh-prime-memory 0.20.0-beta.11 → 0.20.0-beta.13

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,26 @@
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.13] — 2026-10-02
21
+
22
+ > 补全归类逻辑:把 room 归到已有 hall 下(此前只能新增 hall,或合并进别的 room 抹掉身份)。面板支持**拖拽归类**——拖 room 放到 hall 头上即可。
23
+
24
+ ### 新增
25
+
26
+ - **Hall 页拖拽归类**:room 行可拖(hiPriv 开启时抓手光标),hall 头为放置目标(虚线高亮);松手 = 预填改名(major/minor)并直接出 dryRun 预览,确认后实跑——记录随迁、旧名进别名、分组视图即挂到该 hall 下。触屏/不支持拖放的环境用 room 行「归类」按钮或改名表单里的 hall 快选 chips 兜底。
27
+ - 后端修复:`renameSlug` 目标校验 `isTag` → `isRoomSlug`——此前两级目标(major/minor)被误拒,「归类到 hall 下」这条路实际不通(且 rename 编排会先重写记录再在注册表改名时抛错,留下半程状态)。按 C3 卸除/变更纪律:红测试先行(unit + HTTP 面集成各一),验红后修复回绿。
28
+
29
+ ### 修复
30
+
31
+ - 同上:`renameSlug` 两级目标校验(beta.5 引入的缺口)。
32
+ ## [0.20.0-beta.12] — 2026-10-02
33
+
34
+ > lint 清账 + 卸除纪律成文(ENGINEERING-NOTES 新增 C3)。**无功能变更**。
35
+
36
+ ### 维护
37
+
38
+ - **eslint 清账(13 error → 0)**:longtask 撤除残渣(stats.ts 死 import ×2、RecordsTab 死 state)+ 更早批次欠账(recluster.ts 死接口与 let、rooms-registry.ts / ruminate.ts / room-review.ts / 两个测试文件的死 import 与 let)。余 4 个 warning 为基线既有(primitives/tools 的 any)。
39
+ - **ENGINEERING-NOTES 新增 C3「卸除也是 TDD」**:卸除先改测试表达新预期(红)→ 卸实现(绿);lint 是删除型改动的必跑门(unused import = 没卸干净的自动探测器)。
20
40
  ## [0.20.0-beta.11] — 2026-10-02
21
41
 
22
42
  > Hall·Room **集成测试**入库(`tests/hall-room.integration.test.ts`:HTTP 面全链 × 真实存储的用户旅程,9 用例),集成首跑即抓到一个单测盲区并修复。
@@ -282,6 +282,23 @@ const stores = { scenes: { chat: { list: () => gate }, work: { list: () => gate
282
282
 
283
283
  **如何验证**:`tsc -p tsconfig.test.json` exit 0;新加入的测试文件必须落在 include 里。
284
284
 
285
+ ## C3. 卸除也是 TDD —— 先改测试(红)再卸实现(绿)
286
+
287
+ **现象**:longtask 全量撤除(beta.10)时**先卸实现、后补测试**,测试全程没红过;
288
+ 同一批还漏跑了 `eslint`(F 清单明列的门)。lint 补跑揪出 **13 个 error**:
289
+ 一半正是撤除残渣(删了实现、import/局部量还在),另一半是更早批次欠账
290
+ (`recluster.ts` 死接口、`rooms-registry.ts` 死 import)——lint 若当时跑,当场就暴露。
291
+
292
+ **根因**:把"卸除"当成了"删代码",忘了它同样是**行为变更**——TDD 的红-绿循环
293
+ 对撤除一样成立,只是方向反过来:测试先表达**新预期**,再让实现追上。
294
+
295
+ **正确做法(卸除三步)**:
296
+ 1. **先改测试表达新预期**:契约键数(48→46)、"端点不存在"断言、必传字段恢复
297
+ 必传……跑一遍,应当**红**(实现还在);
298
+ 2. **卸实现**到**回绿**;实现删除的每一步都以"哪条红还没变绿"为准,不凭感觉删;
299
+ 3. **lint 是交付门不是可选步骤**(F 清单"eslint 0 error"):删除型改动必跑——
300
+ unused import/死变量就是"没卸干净"的自动探测器。
301
+
285
302
  ---
286
303
 
287
304
  # D. 环境与工具链陷阱
package/dist/client.js CHANGED
@@ -3298,7 +3298,6 @@ var __defProp = Object.defineProperty;
3298
3298
  });
3299
3299
  const [roomPage, setRoomPage] = (0, import_react15.useState)(0);
3300
3300
  const [orphanCount, setOrphanCount] = (0, import_react15.useState)(0);
3301
- const [roomRegistry, setRoomRegistry] = (0, import_react15.useState)([]);
3302
3301
  const [retiredFilter, setRetiredFilter] = (0, import_react15.useState)("");
3303
3302
  const [rooms, setRooms] = (0, import_react15.useState)([]);
3304
3303
  const [wingCatalog, setHallCatalog] = (0, import_react15.useState)(null);
@@ -3357,7 +3356,6 @@ var __defProp = Object.defineProperty;
3357
3356
  if (r && r.ok) {
3358
3357
  setRooms(r.value.rooms ?? []);
3359
3358
  setOrphanCount(r.value.orphanCount ?? 0);
3360
- setRoomRegistry(r.value.registry ?? []);
3361
3359
  }
3362
3360
  }).catch(() => {
3363
3361
  });
@@ -3980,6 +3978,10 @@ var __defProp = Object.defineProperty;
3980
3978
  const i = slug.indexOf("/");
3981
3979
  return i === -1 ? slug : slug.slice(0, i);
3982
3980
  }
3981
+ function suffixOf(slug) {
3982
+ const i = slug.indexOf("/");
3983
+ return i === -1 ? slug : slug.slice(i + 1);
3984
+ }
3983
3985
  function downloadCsv(csv, name) {
3984
3986
  const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
3985
3987
  const url = URL.createObjectURL(blob);
@@ -4014,6 +4016,7 @@ var __defProp = Object.defineProperty;
4014
4016
  const [form, setForm] = (0, import_react16.useState)(null);
4015
4017
  const [preview, setPreview] = (0, import_react16.useState)(null);
4016
4018
  const [grownOpen, setGrownOpen] = (0, import_react16.useState)(false);
4019
+ const [dragOverHall, setDragOverHall] = (0, import_react16.useState)(null);
4017
4020
  const loadAll = (0, import_react16.useCallback)(() => {
4018
4021
  rpc("dsh-memory/rooms-get", {}).then((r) => {
4019
4022
  if (r && r.ok) {
@@ -4075,22 +4078,41 @@ var __defProp = Object.defineProperty;
4075
4078
  loadAll();
4076
4079
  } else setError(r && r.error ? r.error.message : "收编失败");
4077
4080
  });
4078
- const previewMerge = () => run(async () => {
4079
- if (!form) return;
4080
- const to = form.to.trim();
4081
+ const previewMerge = (override) => run(async () => {
4082
+ const fm = override ?? form;
4083
+ if (!fm) return;
4084
+ const to = fm.to.trim();
4081
4085
  if (!to) {
4082
4086
  setError("目标 slug 不能为空");
4083
4087
  return;
4084
4088
  }
4085
- if (to === form.from) {
4089
+ if (to === fm.from) {
4086
4090
  setError("目标与源相同");
4087
4091
  return;
4088
4092
  }
4089
- const r = await rpc("dsh-memory/room-admin", { action: form.action, from: form.from, to });
4093
+ const r = await rpc("dsh-memory/room-admin", { action: fm.action, from: fm.from, to });
4090
4094
  if (r && r.ok) {
4091
- setPreview({ action: form.action, from: form.from, to, notice: String(r.value?.notice ?? "") });
4095
+ setPreview({ action: fm.action, from: fm.from, to, notice: String(r.value?.notice ?? "") });
4092
4096
  } else setError(r && r.error ? r.error.message : "预览失败");
4093
4097
  });
4098
+ const beginReclass = (from, hall) => {
4099
+ if (!busy) {
4100
+ setMsg(null);
4101
+ setError(null);
4102
+ }
4103
+ if (!hall) {
4104
+ setError("尚无 hall:先注册一个(如 dsh-plugin),再归类。");
4105
+ return;
4106
+ }
4107
+ if (majorOf(from) === hall) {
4108
+ setMsg(`${from} 已在 ${hall} 下。`);
4109
+ return;
4110
+ }
4111
+ const f = { action: "rename", from, to: `${hall}/${suffixOf(from)}` };
4112
+ setForm(f);
4113
+ setPreview(null);
4114
+ void previewMerge(f);
4115
+ };
4094
4116
  const execMerge = () => run(async () => {
4095
4117
  if (!preview) return;
4096
4118
  const r = await rpc("dsh-memory/room-admin", { action: preview.action, from: preview.from, to: preview.to, dryRun: false });
@@ -4241,41 +4263,83 @@ var __defProp = Object.defineProperty;
4241
4263
  msg ? /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { style: S.hint, children: msg }) : null,
4242
4264
  error ? /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { style: S.error, children: error }) : null,
4243
4265
  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: [
4244
- /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { style: { display: "flex", alignItems: "center", gap: 8, padding: "5px 10px", background: "var(--dsh-mem-bg-inset)" }, children: [
4245
- /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("span", { style: { fontSize: 12, fontWeight: 600 }, children: [
4246
- g.hall,
4247
- g.label ? /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("span", { style: S.muted, children: " (" + g.label + ")" }) : null
4248
- ] }),
4249
- /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("span", { style: S.muted, children: `合计 ${g.total} 条 · ${g.entries.length} 个 room` }),
4250
- /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { style: S.grow }),
4251
- /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
4252
- "button",
4253
- {
4254
- type: "button",
4255
- disabled: !hiPriv || busy,
4256
- title: `预填注册表单为 ${g.hall}/…(注册该 hall 下的小类)`,
4257
- onClick: () => setRegSlug(g.hall + "/"),
4258
- style: btn,
4259
- children: "+小类"
4260
- }
4261
- )
4262
- ] }),
4266
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)(
4267
+ "div",
4268
+ {
4269
+ onDragOver: (ev) => {
4270
+ if (!hiPriv || busy) return;
4271
+ ev.preventDefault();
4272
+ ev.dataTransfer.dropEffect = "move";
4273
+ setDragOverHall(g.hall);
4274
+ },
4275
+ onDragLeave: () => setDragOverHall((cur) => cur === g.hall ? null : cur),
4276
+ onDrop: (ev) => {
4277
+ ev.preventDefault();
4278
+ setDragOverHall(null);
4279
+ if (!hiPriv || busy) return;
4280
+ const from = ev.dataTransfer.getData("text/dsh-room");
4281
+ if (from) beginReclass(from, g.hall);
4282
+ },
4283
+ style: {
4284
+ display: "flex",
4285
+ alignItems: "center",
4286
+ gap: 8,
4287
+ padding: "5px 10px",
4288
+ background: "var(--dsh-mem-bg-inset)",
4289
+ outline: dragOverHall === g.hall ? "2px dashed var(--dsh-mem-accent)" : void 0,
4290
+ outlineOffset: dragOverHall === g.hall ? "-2px" : void 0
4291
+ },
4292
+ title: hiPriv ? "拖入 room 归类到该 hall 下(major/minor;记录随迁,旧名进别名,预览后确认)" : void 0,
4293
+ children: [
4294
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("span", { style: { fontSize: 12, fontWeight: 600 }, children: [
4295
+ g.hall,
4296
+ g.label ? /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("span", { style: S.muted, children: " (" + g.label + ")" }) : null
4297
+ ] }),
4298
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("span", { style: S.muted, children: `合计 ${g.total} 条 · ${g.entries.length} 个 room` }),
4299
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { style: S.grow }),
4300
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
4301
+ "button",
4302
+ {
4303
+ type: "button",
4304
+ disabled: !hiPriv || busy,
4305
+ title: `预填注册表单为 ${g.hall}/…(注册该 hall 下的小类)`,
4306
+ onClick: () => setRegSlug(g.hall + "/"),
4307
+ style: btn,
4308
+ children: "+小类"
4309
+ }
4310
+ )
4311
+ ]
4312
+ }
4313
+ ),
4263
4314
  g.entries.map((e) => {
4264
4315
  const standalone = e.slug === g.hall;
4265
4316
  const count = rooms.find((r) => r.room === e.slug)?.count ?? 0;
4266
4317
  const retired = e.status === "retired";
4267
4318
  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: [
4268
4319
  /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { style: { display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }, children: [
4269
- /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("span", { style: { fontSize: 12 }, title: e.description ? `${e.slug} — ${e.description}` : e.slug, children: [
4270
- standalone ? e.slug : "· " + e.slug.slice(g.hall.length + 1),
4271
- e.source === "pre-registered" ? " ⭐" : ""
4272
- ] }),
4320
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)(
4321
+ "span",
4322
+ {
4323
+ style: { fontSize: 12, cursor: hiPriv && !busy ? "grab" : "default" },
4324
+ draggable: hiPriv && !busy,
4325
+ onDragStart: (ev) => {
4326
+ ev.dataTransfer.setData("text/dsh-room", e.slug);
4327
+ ev.dataTransfer.effectAllowed = "move";
4328
+ },
4329
+ title: hiPriv && !busy ? "拖到某个 hall 头上归类(major/minor;记录随迁,旧名进别名)" : e.description ? `${e.slug} — ${e.description}` : e.slug,
4330
+ children: [
4331
+ standalone ? e.slug : "· " + e.slug.slice(g.hall.length + 1),
4332
+ e.source === "pre-registered" ? " ⭐" : ""
4333
+ ]
4334
+ }
4335
+ ),
4273
4336
  e.label ? /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("span", { style: S.muted, children: e.label }) : null,
4274
4337
  /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("span", { style: S.muted, children: count + " 条" }),
4275
4338
  retired ? /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("span", { style: { ...S.muted, color: "var(--dsh-mem-danger)" }, children: "已退役" }) : null,
4276
4339
  (e.aliases ?? []).length > 0 ? /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("span", { style: S.muted, children: "别名: " + (e.aliases ?? []).join(", ") }) : null,
4277
4340
  /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { style: S.grow }),
4278
4341
  !hiPriv ? null : /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)(import_jsx_runtime16.Fragment, { children: [
4342
+ /* @__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: "归类" }),
4279
4343
  /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("button", { type: "button", disabled: busy, title: `改名(= merge 1:1 + 注册表改名)`, onClick: () => {
4280
4344
  setForm({ action: "rename", from: e.slug, to: "" });
4281
4345
  setPreview(null);
@@ -4305,7 +4369,18 @@ var __defProp = Object.defineProperty;
4305
4369
  style: { ...S.input, width: 180, fontSize: 12 }
4306
4370
  }
4307
4371
  ),
4308
- /* @__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: "预览" }),
4372
+ 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)(
4373
+ "button",
4374
+ {
4375
+ type: "button",
4376
+ title: `归入 ${gg.hall} 下(${gg.hall}/${suffixOf(form.from)})`,
4377
+ onClick: () => setForm({ ...form, to: `${gg.hall}/${suffixOf(form.from)}` }),
4378
+ style: { ...btn, borderRadius: 999, padding: "1px 8px" },
4379
+ children: gg.hall
4380
+ },
4381
+ gg.hall
4382
+ )) }) : null,
4383
+ /* @__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: "预览" }),
4309
4384
  /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("button", { type: "button", disabled: busy, onClick: () => {
4310
4385
  setForm(null);
4311
4386
  setPreview(null);
@@ -15,7 +15,7 @@ export async function processSceneReclusterJobs(source, scenes, logger, maxJobs
15
15
  const family = job.family === 'work' ? 'work' : 'chat';
16
16
  const store = scenes[family];
17
17
  // 快照在 try 外声明:catch 的回滚要用它
18
- let snapshots = [];
18
+ const snapshots = [];
19
19
  try {
20
20
  // ① 内存快照:按场景名(文件 basename)匹配当前存在的摘要文件
21
21
  const all = await store.list();
@@ -171,10 +171,10 @@ export async function mergeRoom(io, from, to, opts = {}) {
171
171
  const backup = io.l1.backupTagRecords(from);
172
172
  io.logger.info(`[memory] Room merge 备份:${from} → ${to},${backup.count} 条 → ${backup.file}`);
173
173
  let applied = 0;
174
- let hasMore = false;
174
+ const hasMore = false; // 循环到收敛,单次调用内 hasMore 恒 false(重写返回值沿用结果形状)
175
175
  const families = new Set();
176
176
  const scenes = new Set();
177
- // 游标重写:上限 cap/次,hasMore 时继续(总量受 cap×轮次约束,单飞内收敛)
177
+ // 游标重写:上限 cap/次,r.hasMore 时继续(总量受 cap×轮次约束,单飞内收敛)
178
178
  for (;;) {
179
179
  const r = io.l1.rewriteTag(from, to, cap);
180
180
  applied += r.rewritten;
@@ -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.11",
3
+ "version": "0.20.0-beta.13",
4
4
  "publishConfig": {
5
5
  "registry": "https://registry.npmjs.org",
6
6
  "access": "public",