dsh-prime-memory 0.20.0-beta.2 → 0.20.0-beta.21

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.
Files changed (40) hide show
  1. package/CHANGELOG.md +192 -3
  2. package/ENGINEERING-NOTES.md +17 -0
  3. package/dist/client.js +1158 -405
  4. package/dist/config.d.ts +0 -120
  5. package/dist/config.js +1 -21
  6. package/dist/contract.d.ts +111 -56
  7. package/dist/hooks/recall.d.ts +4 -1
  8. package/dist/hooks/recall.js +7 -1
  9. package/dist/index.d.ts +0 -78
  10. package/dist/index.js +14 -21
  11. package/dist/metadata-validators.d.ts +5 -0
  12. package/dist/metadata-validators.js +11 -1
  13. package/dist/pipeline/recluster.js +1 -1
  14. package/dist/pipeline/ruminate.d.ts +11 -1
  15. package/dist/pipeline/ruminate.js +18 -1
  16. package/dist/room-review.d.ts +196 -0
  17. package/dist/room-review.js +281 -0
  18. package/dist/stats.d.ts +23 -34
  19. package/dist/stats.js +268 -201
  20. package/dist/store/l1.d.ts +34 -0
  21. package/dist/store/l1.js +68 -1
  22. package/dist/store/recall-usage.d.ts +43 -0
  23. package/dist/store/recall-usage.js +149 -0
  24. package/dist/store/rooms-registry.d.ts +66 -0
  25. package/dist/store/rooms-registry.js +250 -0
  26. package/dist/store/session-modes.d.ts +0 -8
  27. package/dist/store/session-modes.js +0 -52
  28. package/dist/store/sqlite.d.ts +4 -0
  29. package/dist/store/sqlite.js +20 -0
  30. package/dist/tools/index.d.ts +4 -1
  31. package/dist/tools/index.js +232 -1
  32. package/dist/types.d.ts +4 -0
  33. package/dsh.plugin.json +1 -1
  34. package/package.json +1 -1
  35. package/dist/hooks/long-task.d.ts +0 -31
  36. package/dist/hooks/long-task.js +0 -93
  37. package/dist/store/context-usage.d.ts +0 -28
  38. package/dist/store/context-usage.js +0 -47
  39. package/dist/store/todo-ref.d.ts +0 -41
  40. package/dist/store/todo-ref.js +0 -65
package/dist/stats.js CHANGED
@@ -18,6 +18,7 @@ import { EFFORT_CHOICES, resolveDataDir } from './config.js';
18
18
  import { effectiveCfg } from './pipeline/runner.js';
19
19
  import { emptyRecallStats } from './hooks/recall.js';
20
20
  import { buildRouteChain, decideSendableEffort, LAYER_DEFAULT_BUDGETS, layerChainOrNull, resolveModelContextWindow, resolveModelEfforts, resolveModelRoute } from './llm.js';
21
+ import { mergeRoom, renameRoom } from './room-review.js';
21
22
  import { projectDistillChain, validateDistillChain } from './settings.js';
22
23
  import { RECEIPTS_QUERY_LIMIT_MAX, dimensionOf, toReceiptView } from './store/receipts.js';
23
24
  import { getTraceStore } from './store/trace.js';
@@ -25,9 +26,8 @@ import { resolveConflictPair, listConflictPairs } from './conflict-service.js';
25
26
  import { sourceAnchorLabels } from './pipeline/anchors.js';
26
27
  import { readSupersedeMarker } from './store/supersede.js';
27
28
  import { isSnapshotName } from './store/l1-snapshot.js';
28
- import { closeTodoRefSlot } from './hooks/long-task.js';
29
29
  import { WING_CATALOG, WING_FALLBACK } from './types.js';
30
- import { isTag } from './metadata-validators.js';
30
+ import { isRoomSlug } from './metadata-validators.js';
31
31
  import { InProcMemoryBackend } from './store/memory-backend.js';
32
32
  import { isWingCorner } from './store/session-modes.js';
33
33
  import { startWingBackfill } from './wing-backfill.js';
@@ -36,7 +36,7 @@ import { snapshotTokenCost } from './token-cost.js';
36
36
  const require = createRequire(import.meta.url);
37
37
  export const PLUGIN_VERSION = require('../package.json').version;
38
38
  /**
39
- * 端点全集运行时清单(36 个,与 tests/contract-keys.test.ts 的 ENDPOINTS 及
39
+ * 端点全集运行时清单(与 tests/contract-keys.test.ts 的 ENDPOINTS 及
40
40
  * contract.ts 类型映射表三方对齐,漂移由键集 diff 测试暴露)。
41
41
  * 注意:本清单同时是 HTTP 前缀路由 `/dsh-memory/rpc/<短名>` 的**放行白名单**
42
42
  * (见下方 SHORT_ENDPOINTS),漏一条 = 该端点在面板里静默消失(404 被客户端
@@ -51,10 +51,11 @@ export const MEMORY_ENDPOINTS = [
51
51
  'dsh-memory/token-cost',
52
52
  'dsh-memory/session-mode-get',
53
53
  'dsh-memory/session-mode-set',
54
- 'dsh-memory/longtask-hint-get',
55
- 'dsh-memory/longtask-compress-tail',
56
54
  'dsh-memory/wing-overview',
57
55
  'dsh-memory/rooms-get',
56
+ 'dsh-memory/rooms-export',
57
+ 'dsh-memory/room-register',
58
+ 'dsh-memory/room-admin',
58
59
  'dsh-memory/wing-backfill',
59
60
  'dsh-memory/session-stats',
60
61
  'dsh-memory/settings-get',
@@ -156,51 +157,6 @@ function readJsonBody(req) {
156
157
  req.on('error', reject);
157
158
  });
158
159
  }
159
- /** 压缩后去抖 ruminate:上次触发时刻(模块级;跨轮询/按压共享)。 */
160
- let lastCompressRuminateAt = 0;
161
- const COMPRESS_RUMINATE_DEBOUNCE_MS = 10 * 60_000;
162
- /**
163
- * 尾部压缩闭环(T05):压缩入队成功后,冲刷+L2(带 repo 感知)+relabel+recluster
164
- * 消费一条龙由 ruminate 轻刷新接管——去抖 10 分钟,失败静默(压缩本身已成功,
165
- * 重算由下一次任何触发兜底)。
166
- */
167
- function debouncedCompressRuminate(ruminate, logger) {
168
- const now = Date.now();
169
- if (!ruminate || now - lastCompressRuminateAt < COMPRESS_RUMINATE_DEBOUNCE_MS)
170
- return;
171
- lastCompressRuminateAt = now;
172
- void ruminate.start().catch((err) => logger.warn(`[memory] 压缩后反刍触发失败(压缩产物保留,重算由下次触发兜底): ${err instanceof Error ? err.message : String(err)}`));
173
- }
174
- /**
175
- * 尾部 turn 压缩:把 (水位线, maxCapturedTurn] 内最近的最近 N 轮强制蒸馏入记忆
176
- * (force 跳过阈值;L1 去重与水位线双重防重)。off 档会话保持完全隐身(不压缩)。
177
- * 无锚点老数据只在首次压缩(无水位线)时纳入——宁少收不重蒸。
178
- */
179
- export async function compressTailTurns(sessionId, turns, io) {
180
- const toTurn = io.l0.maxCapturedTurn(sessionId);
181
- if (toTurn === undefined)
182
- return { enqueued: 0, fromTurn: null, toTurn: null };
183
- const watermark = io.modes.getTailWatermark(sessionId) ?? 0;
184
- const fromTurn = Math.max(watermark + 1, toTurn - turns + 1);
185
- if (fromTurn > toTurn)
186
- return { enqueued: 0, fromTurn, toTurn };
187
- const mode = io.modes.get(sessionId);
188
- if (mode === 'off')
189
- return { enqueued: 0, fromTurn, toTurn };
190
- const messages = await io.l0.recentBySession(sessionId, turns * 12 + 24);
191
- const picked = messages.filter((m) => {
192
- const t = m.anchor?.turn;
193
- if (t === undefined)
194
- return watermark === 0;
195
- return t >= fromTurn && t <= toTurn;
196
- });
197
- if (picked.length === 0)
198
- return { enqueued: 0, fromTurn, toTurn };
199
- io.runner.enqueue(sessionId, picked, mode, { force: true });
200
- io.modes.setTailWatermark(sessionId, toTurn);
201
- io.logger.info(`[memory] 尾部压缩:turn ${fromTurn}..${toTurn} 已强制入队蒸馏(${picked.length} 条)`);
202
- return { enqueued: picked.length, fromTurn, toTurn };
203
- }
204
160
  /**
205
161
  * 组装端点 deps。抽成函数是为了让"哪个控制器落入哪个字段"成为可测接缝:
206
162
  * 反刍端点读 deps.ruminate,若此处漏注入,端点会静默恒返 {supported:false}(面板整块不渲染)。
@@ -216,15 +172,15 @@ export function buildEndpointDeps(base, sources, controller) {
216
172
  embedManager: sources.embedManager,
217
173
  sessionInfo: sources.sessionInfo,
218
174
  ruminate: controller,
219
- longTask: sources.longTask,
175
+ roomRegistry: sources.roomRegistry,
220
176
  };
221
177
  return { ...base, ...injected };
222
178
  }
223
179
  export function registerMemoryRpc(ctx, cfg, stores, logger, status, live, modes, dataDir, rebuild, embedManager, sessionInfo,
224
180
  /** 反刍控制器(存储降级时为 undefined):经 buildEndpointDeps 落入 deps.ruminate。 */
225
181
  ruminate,
226
- /** 长任务端点依赖(cfg.longTask.enabled 门控;缺省 = 端点恒 disabled)。 */
227
- longTaskDeps) {
182
+ /** Room 分类管理依赖(注册表;缺省 = rooms-get 不带 registry、rooms-export 恒 404 语义)。 */
183
+ roomRegistry) {
228
184
  /** 当前是否持有一段有效注册(dispose 完成后清空,允许服务重上线时重注册)。 */
229
185
  let holding = false;
230
186
  /** 当前 handle 绑定的 connection 实例(internal/service 第二参;用于识别实例替换)。 */
@@ -242,7 +198,10 @@ longTaskDeps) {
242
198
  // 端点处理器:HTTP 层与旧 connection.rpc 的 handler 共用同一分发。
243
199
  const rpcHandler = async (endpoint, payload) => {
244
200
  try {
245
- const value = await handleEndpoint(endpoint, payload, buildEndpointDeps({ ctx, cfg, stores, logger }, { status, live, modes, dataDir, rebuild, embedManager, sessionInfo }, ruminate));
201
+ const value = await handleEndpoint(endpoint, payload, buildEndpointDeps({ ctx, cfg, stores, logger },
202
+ // roomRegistry 必须随 sources 下发——漏带 = 面板侧 room-* 恒"未装配"
203
+ // (工具面直传不受影响;2026-10-02 beta.9 实测回归)
204
+ { status, live, modes, dataDir, rebuild, embedManager, sessionInfo, roomRegistry }, ruminate));
246
205
  return { ok: true, value };
247
206
  }
248
207
  catch (err) {
@@ -390,7 +349,7 @@ function sanitizeSettings(s) {
390
349
  }
391
350
  /** 端点分发表(导出供测试直调:可精确注入 rebuild/ruminate 等可选控制器,验证 deps 接线)。 */
392
351
  export async function handleEndpoint(endpoint, payload, deps) {
393
- const { cfg, stores, status, live, modes, dataDir, rebuild, ruminate, embedManager, sessionInfo, longTask: longTaskDeps } = deps;
352
+ const { cfg, stores, status, live, modes, dataDir, rebuild, ruminate, embedManager, sessionInfo, roomRegistry } = deps;
394
353
  switch (endpoint) {
395
354
  case 'dsh-memory/stats':
396
355
  return buildStats(cfg, stores, status);
@@ -424,7 +383,6 @@ export async function handleEndpoint(endpoint, payload, deps) {
424
383
  halls: locked,
425
384
  hallIncludeUnlabeled: bounds.includeUnlabeled,
426
385
  hallIncludeGeneral: bounds.includeGeneral,
427
- longTask: modes.getLongTask(sessionId),
428
386
  };
429
387
  return v;
430
388
  }
@@ -434,9 +392,8 @@ export async function handleEndpoint(endpoint, payload, deps) {
434
392
  const p = (payload ?? {});
435
393
  const sessionId = expectSessionId(p.sessionId);
436
394
  const allowed = ['auto', 'chat', 'work', 'off'];
437
- // mode 可选(带 longTask 的轻量切换);二者都不传 = 无操作拒绝(防旧客户端漏字段静默)。
438
- if (p.mode === undefined && p.longTask === undefined) {
439
- throw new Error('session-mode-set 需要 mode 或 longTask 之一');
395
+ if (p.mode === undefined) {
396
+ throw new Error('session-mode-set 需要 mode');
440
397
  }
441
398
  if (p.mode !== undefined && (typeof p.mode !== 'string' || !allowed.includes(p.mode))) {
442
399
  throw new Error(`非法档位: ${String(p.mode)}(允许 ${allowed.join('/')})`);
@@ -458,17 +415,6 @@ export async function handleEndpoint(endpoint, payload, deps) {
458
415
  throw new Error(`非法 Wing 锁定: ${String(bad)}(允许 ${WING_CATALOG.map((h) => h.id).join('/')}/null)`);
459
416
  }
460
417
  }
461
- // 长任务开关可选同车:布尔 = 切换;缺省 = 不动。关闭时撤下 todo 参考槽位
462
- // (todo 参考只在长任务模式可用;撤下失败不影响开关本身,log 即可)。
463
- if (p.longTask !== undefined) {
464
- if (typeof p.longTask !== 'boolean') {
465
- throw new Error(`非法长任务开关: ${String(p.longTask)}(允许 true/false)`);
466
- }
467
- modes.setLongTask(sessionId, p.longTask);
468
- if (!p.longTask && longTaskDeps?.slots) {
469
- closeTodoRefSlot({ cfg, modes, slots: longTaskDeps.slots, contextUsage: longTaskDeps.contextUsage, todoRef: longTaskDeps.todoRef, logger: deps.logger }).catch((err) => deps.logger.warn(`[memory] todo 参考槽位撤下失败: ${err instanceof Error ? err.message : String(err)}`));
470
- }
471
- }
472
418
  if (p.mode !== undefined)
473
419
  modes.set(sessionId, p.mode);
474
420
  if (typeof p.recall === 'boolean') {
@@ -508,129 +454,6 @@ export async function handleEndpoint(endpoint, payload, deps) {
508
454
  halls: locked,
509
455
  hallIncludeUnlabeled: bounds.includeUnlabeled,
510
456
  hallIncludeGeneral: bounds.includeGeneral,
511
- longTask: modes.getLongTask(sessionId),
512
- };
513
- return v;
514
- }
515
- case 'dsh-memory/longtask-hint-get': {
516
- if (!modes)
517
- throw new Error('档位存储未初始化');
518
- const p = (payload ?? {});
519
- const sessionId = expectSessionId(p.sessionId);
520
- const enabled = cfg.longTask.enabled;
521
- const longTask = modes.getLongTask(sessionId);
522
- let contextPct = null;
523
- if (enabled && longTask) {
524
- // 分母:主对话模型官方窗口(3s race 封顶,同 session-stats 口径)
525
- let window = null;
526
- try {
527
- const sel = deps.ctx.get('agentDefaultModel')?.currentSelection?.();
528
- if (sel?.provider && sel?.model) {
529
- window = await Promise.race([
530
- resolveModelContextWindow(deps.ctx, sel.provider, sel.model),
531
- new Promise((resolve) => setTimeout(() => resolve(null), 3_000)),
532
- ]);
533
- }
534
- }
535
- catch {
536
- /* 可选服务缺失 = 分母未知 */
537
- }
538
- contextPct = longTaskDeps
539
- ? longTaskDeps.contextUsage.percentOfWindow(sessionId, window ?? undefined)
540
- : null;
541
- }
542
- const drift = enabled ? longTaskDeps?.todoRef.latest(sessionId)?.drift ?? null : null;
543
- const todoCount = enabled ? longTaskDeps?.todoRef.latest(sessionId)?.items.length ?? 0 : 0;
544
- const reasons = [];
545
- if (!longTask) {
546
- if (contextPct !== null && contextPct >= cfg.longTask.contextThresholdPct) {
547
- reasons.push('context-threshold');
548
- }
549
- if (drift !== null && drift >= cfg.longTask.driftThreshold) {
550
- reasons.push('todo-drift');
551
- }
552
- }
553
- const watermark = modes.getTailWatermark(sessionId);
554
- let pendingTailTurns = null;
555
- if (enabled && longTask && longTaskDeps) {
556
- const captured = stores.l0.maxCapturedTurn(sessionId);
557
- pendingTailTurns = captured === undefined ? 0 : Math.max(0, captured - (watermark ?? 0));
558
- }
559
- // 自动尾部压缩(长任务开启 + 显式 opt-in + 跨阈值):水位线只前进,
560
- // 轮询重复触发天然幂等;失败只 warn,不打断建议状态返回。
561
- if (enabled &&
562
- longTask &&
563
- longTaskDeps &&
564
- cfg.longTask.autoCompress &&
565
- pendingTailTurns !== null &&
566
- pendingTailTurns > 0 &&
567
- contextPct !== null &&
568
- contextPct >= cfg.longTask.contextThresholdPct) {
569
- try {
570
- const ar = await compressTailTurns(sessionId, cfg.longTask.tailTurns, {
571
- modes,
572
- l0: stores.l0,
573
- runner: longTaskDeps.runner,
574
- logger: deps.logger,
575
- });
576
- if (ar.enqueued > 0)
577
- debouncedCompressRuminate(ruminate, deps.logger);
578
- }
579
- catch (err) {
580
- deps.logger.warn(`[memory] 自动尾部压缩失败: ${err instanceof Error ? err.message : String(err)}`);
581
- }
582
- const captured = stores.l0.maxCapturedTurn(sessionId);
583
- pendingTailTurns = captured === undefined ? 0 : Math.max(0, captured - (modes.getTailWatermark(sessionId) ?? 0));
584
- }
585
- const v = {
586
- sessionId,
587
- enabled,
588
- longTask,
589
- contextPct,
590
- contextThresholdPct: cfg.longTask.contextThresholdPct,
591
- drift,
592
- driftThreshold: cfg.longTask.driftThreshold,
593
- todoCount,
594
- suggest: reasons.length > 0,
595
- reasons,
596
- tailTurns: cfg.longTask.tailTurns,
597
- lastTailCompressedTurn: watermark ?? null,
598
- pendingTailTurns,
599
- };
600
- return v;
601
- }
602
- case 'dsh-memory/longtask-compress-tail': {
603
- if (!modes)
604
- throw new Error('档位存储未初始化');
605
- const p = (payload ?? {});
606
- const sessionId = expectSessionId(p.sessionId);
607
- const longTask = modes.getLongTask(sessionId);
608
- const turns = p.turns === undefined
609
- ? cfg.longTask.tailTurns
610
- : Math.max(1, Math.min(50, Math.round(p.turns)));
611
- let enqueued = 0;
612
- let fromTurn = null;
613
- let toTurn = null;
614
- if (longTask && longTaskDeps) {
615
- // 只在长任务模式可用(部署开关在 compressTailTurns 内再守一道)
616
- const r = await compressTailTurns(sessionId, turns, {
617
- modes,
618
- l0: stores.l0,
619
- runner: longTaskDeps.runner,
620
- logger: deps.logger,
621
- });
622
- enqueued = r.enqueued;
623
- fromTurn = r.fromTurn;
624
- toTurn = r.toTurn;
625
- if (enqueued > 0)
626
- debouncedCompressRuminate(ruminate, deps.logger);
627
- }
628
- const v = {
629
- sessionId,
630
- enqueued,
631
- fromTurn,
632
- toTurn,
633
- longTask,
634
457
  };
635
458
  return v;
636
459
  }
@@ -647,10 +470,226 @@ export async function handleEndpoint(endpoint, payload, deps) {
647
470
  // ── Room 分类计数(标签自生长分类;展开 metadata.tags 聚合,零 schema) ──
648
471
  case 'dsh-memory/rooms-get': {
649
472
  const rooms = stores.l1.listRooms();
650
- const v = { rooms, total: rooms.length };
473
+ // 端点层合并注册表(Agent A 设计):注册表 active 条目 count=0 补齐并带
474
+ // source/label(source 用条目真实来源——收编 grown ≠ 预注册,⭐角标靠它区分);
475
+ // 自生长条目标 source='grown'。不动 listRooms() 的缓存语义。
476
+ const merged = rooms.map((r) => ({ ...r, source: 'grown' }));
477
+ const registry = roomRegistry?.listActive() ?? [];
478
+ for (const e of registry) {
479
+ const idx = merged.findIndex((r) => r.room === e.slug);
480
+ if (idx === -1) {
481
+ merged.push({ room: e.slug, count: 0, source: e.source, label: e.label });
482
+ }
483
+ else {
484
+ merged[idx] = { ...merged[idx], source: e.source, label: e.label ?? merged[idx].label };
485
+ }
486
+ }
487
+ const v = {
488
+ rooms: merged,
489
+ total: merged.length,
490
+ orphanCount: stores.l1.untaggedL1Count(),
491
+ registry: roomRegistry?.list().map((e) => ({
492
+ slug: e.slug,
493
+ label: e.label,
494
+ description: e.description,
495
+ source: e.source,
496
+ aliases: e.aliases,
497
+ status: e.status,
498
+ })),
499
+ };
500
+ return v;
501
+ }
502
+ case 'dsh-memory/room-register': {
503
+ if (!roomRegistry)
504
+ throw new Error('Room 注册表未装配(需要插件重载以初始化)。');
505
+ if (!live?.get().memoryMutate)
506
+ throw new Error('记忆写删未开放:请在记忆库面板开启「高权限模式」后,模型才能预注册 Room。');
507
+ const p = (payload ?? {});
508
+ const slug = String(p.slug ?? '').trim();
509
+ if (!slug)
510
+ throw new Error('slug 不能为空');
511
+ const r = await roomRegistry.register({
512
+ slug,
513
+ label: typeof p.label === 'string' ? p.label : undefined,
514
+ description: typeof p.description === 'string' ? p.description : undefined,
515
+ source: p.source === 'grown' ? 'grown' : undefined,
516
+ });
517
+ const v = {
518
+ slug: r.entry.slug,
519
+ created: r.created,
520
+ notice: r.created ? '已预注册。' : '已存在(只补全了缺失的 label/description)。',
521
+ };
522
+ return v;
523
+ }
524
+ // ── Room 破坏性面管理(merge/rename/retire;与 memory_room_admin 工具同编排) ──
525
+ case 'dsh-memory/room-admin': {
526
+ if (!roomRegistry)
527
+ throw new Error('Room 注册表未装配(需要插件重载以初始化)。');
528
+ if (!live?.get().memoryMutate)
529
+ throw new Error('记忆写删未开放:请在记忆库面板开启「高权限模式」后,才能执行 Room 管理。');
530
+ const p = (payload ?? {});
531
+ if (p.action === 'update') {
532
+ const slug = String(p.slug ?? '').trim();
533
+ if (!slug)
534
+ throw new Error('update 需要 slug');
535
+ const changed = await roomRegistry.update(slug, {
536
+ label: typeof p.label === 'string' ? p.label : undefined,
537
+ description: typeof p.description === 'string' ? p.description : undefined,
538
+ });
539
+ const v = {
540
+ notice: changed
541
+ ? `已更新:${slug}(显示名/归类说明,标注器下次即用新词)`
542
+ : `无变化:${slug} 未找到或未提供任何非空字段`,
543
+ };
544
+ return v;
545
+ }
546
+ if (p.action === 'retire') {
547
+ const slug = String(p.slug ?? '').trim();
548
+ if (!slug)
549
+ throw new Error('retire 需要 slug');
550
+ const active = p.active === true;
551
+ const changed = await roomRegistry.setStatus(slug, active ? 'active' : 'retired');
552
+ // 带记录退场(软删,可恢复):该 room 下全部**活跃**记录分批 retire(每批 ≤200,与 records-delete 同原语)
553
+ let retiredRecords = 0;
554
+ if (p.withRecords === true && !active) {
555
+ for (let offset = 0;; offset += 200) {
556
+ const page = stores.l1.list({ tag: slug, retired: false, limit: 200, offset });
557
+ if (page.items.length === 0)
558
+ break;
559
+ const n = stores.l1.retire(page.items.map((r) => r.id), { at: new Date().toISOString(), reason: 'manual' });
560
+ retiredRecords += n;
561
+ if (page.items.length < 200)
562
+ break;
563
+ }
564
+ deps.logger.info(`[memory] Room 退役带记录:${slug},退场 ${retiredRecords} 条(软删,可恢复)`);
565
+ }
566
+ const v = {
567
+ retiredRecords: retiredRecords > 0 ? retiredRecords : undefined,
568
+ notice: changed
569
+ ? active
570
+ ? `已恢复:${slug}`
571
+ : `已退役:${slug}(存量记录的 tags 不动,仅词表不再推荐)。${retiredRecords > 0 ? `已连同退场 ${retiredRecords} 条记录(软删,可在「已退场」区恢复)。` : ''}`
572
+ : `未找到注册条目或状态无变化:${slug}。${retiredRecords > 0 ? `已退场 ${retiredRecords} 条记录(软删,可恢复)。` : ''}`,
573
+ };
574
+ return v;
575
+ }
576
+ if (p.action === 'merge' || p.action === 'rename') {
577
+ const from = String(p.from ?? '').trim();
578
+ const to = String(p.to ?? '').trim();
579
+ if (!from || !to)
580
+ throw new Error('merge/rename 需要 from 与 to 两个 slug');
581
+ const dryRun = p.dryRun !== false;
582
+ const r = await (p.action === 'merge' ? mergeRoom : renameRoom)({ l1: stores.l1, registry: roomRegistry, logger: deps.logger }, from, to, { dryRun });
583
+ if (r.dryRun) {
584
+ const v = {
585
+ notice: `[dryRun 预览] ${from} → ${to}:影响 ${r.affected} 条${r.hasMore ? '(超过单次上限,需续跑)' : ''}。` +
586
+ `样例:${r.preview.join(', ') || '无'}。确认无误后以 dryRun=false 执行(执行前自动备份,完成后自动入队场景重算)。`,
587
+ dryRun: true,
588
+ affected: r.affected,
589
+ preview: r.preview,
590
+ hasMore: r.hasMore,
591
+ };
592
+ return v;
593
+ }
594
+ // 实跑:受影响场景入队重算(source='room-merge';与工具同口径)
595
+ let requeued = 0;
596
+ for (const family of r.families ?? []) {
597
+ for (const scene of r.scenes ?? []) {
598
+ stores.l1.enqueueSceneRecluster(family, [scene], `room-merge:${Date.now()}`, 'room-merge');
599
+ requeued++;
600
+ }
601
+ }
602
+ const v = {
603
+ notice: `${p.action === 'merge' ? '合并' : '改名'}完成:${from} → ${to},重写 ${r.applied} 条` +
604
+ `(备份:${r.backupFile ?? '无'})${requeued > 0 ? `;已入队 ${requeued} 个场景重算` : ''}。`,
605
+ dryRun: false,
606
+ affected: r.applied,
607
+ backupFile: r.backupFile,
608
+ };
609
+ return v;
610
+ }
611
+ throw new Error(`非法 action:${String(p.action)}(允许 merge/rename/retire)`);
612
+ }
613
+ case 'dsh-memory/rooms-export': {
614
+ const p = (payload ?? {});
615
+ const kind = p.kind === 'orphans' ? 'orphans' : p.kind === 'rooms' ? 'rooms' : p.kind === 'records' ? 'records' : '';
616
+ if (!kind)
617
+ throw new Error(`非法 kind: ${String(p.kind)}(允许 rooms/orphans/records)`);
618
+ const limit = Math.min(Math.max(Number(p.limit) || 2000, 1), 10_000);
619
+ const esc = (v) => {
620
+ const t = String(v ?? '');
621
+ return /[,\r\n"]/.test(t) ? '"' + t.replace(/"/g, '""') + '"' : t;
622
+ };
623
+ if (kind === 'rooms') {
624
+ const registry = roomRegistry?.list() ?? [];
625
+ const counts = new Map(stores.l1.listRooms().map((r) => [r.room, r.count]));
626
+ const lines = ['slug,count,source,label,description,status,aliases'];
627
+ let n = 0;
628
+ const seen = new Set();
629
+ for (const e of registry) {
630
+ lines.push([e.slug, String(counts.get(e.slug) ?? 0), e.source, e.label ?? '', e.description ?? '', e.status, (e.aliases ?? []).join('|')].map(esc).join(','));
631
+ seen.add(e.slug);
632
+ n++;
633
+ }
634
+ for (const r of stores.l1.listRooms()) {
635
+ if (seen.has(r.room))
636
+ continue;
637
+ lines.push([r.room, String(r.count), 'grown', '', '', 'active', ''].map(esc).join(','));
638
+ n++;
639
+ }
640
+ const v = { kind, csv: lines.join('\r\n'), total: n, truncated: false };
641
+ return v;
642
+ }
643
+ if (kind === 'records') {
644
+ const tag = String(p.tag ?? '').trim();
645
+ if (!tag)
646
+ throw new Error('kind=records 需要 tag(目标 Room slug)');
647
+ const header = 'id,type,scene_name,updated_at,tags,content';
648
+ const lines = [header];
649
+ let n = 0;
650
+ let truncated2 = false;
651
+ for (let offset = 0; offset < limit; offset += 200) {
652
+ const page = stores.l1.list({ tag, retired: undefined, limit: 200, offset });
653
+ for (const r of page.items) {
654
+ if (n >= limit) {
655
+ truncated2 = true;
656
+ break;
657
+ }
658
+ const meta = (r.metadata ?? {});
659
+ const tags = Array.isArray(meta.tags) ? meta.tags.join('|') : '';
660
+ lines.push([r.id, r.type, r.scene_name, new Date(r.updatedAt).toISOString(), tags, r.content].map(esc).join(','));
661
+ n++;
662
+ }
663
+ if (truncated2 || page.items.length < 200)
664
+ break;
665
+ }
666
+ const v = { kind, csv: lines.join('\r\n'), total: n, truncated: truncated2 };
667
+ return v;
668
+ }
669
+ // orphans:无 Room 绑定的活跃记录清单(id/type/scene/updated/content/候选/状态)
670
+ const header = 'id,type,scene_name,updated_at,content,room_candidates,room_review';
671
+ const lines = [header];
672
+ let n = 0;
673
+ let truncated = false;
674
+ for (let offset = 0; offset < limit; offset += 200) {
675
+ const page = stores.l1.list({ untagged: true, retired: false, limit: 200, offset });
676
+ for (const r of page.items) {
677
+ if (n >= limit) {
678
+ truncated = true;
679
+ break;
680
+ }
681
+ const meta = (r.metadata ?? {});
682
+ lines.push([r.id, r.type, r.scene_name, new Date(r.updatedAt).toISOString(), r.content,
683
+ Array.isArray(meta.roomCandidates) ? meta.roomCandidates.join('|') : '',
684
+ String(meta.roomReview ?? '')].map(esc).join(','));
685
+ n++;
686
+ }
687
+ if (truncated || page.items.length < 200)
688
+ break;
689
+ }
690
+ const v = { kind, csv: lines.join('\r\n'), total: n, truncated };
651
691
  return v;
652
692
  }
653
- // ── 一键回填(task_15):后台任务,单飞;端点立即返回,进度看 wing-overview ──
654
693
  case 'dsh-memory/wing-backfill': {
655
694
  const r = startWingBackfill({
656
695
  ctx: deps.ctx,
@@ -944,8 +983,10 @@ export async function handleEndpoint(endpoint, payload, deps) {
944
983
  const retiredSel = typeof p.retired === 'boolean' ? p.retired : undefined;
945
984
  // Room 过滤:tag 是**自生长**的 slug(无枚举),只做形状与长度校验(SQL 侧参数化)
946
985
  const tagSel = typeof p.tag === 'string' ? p.tag.trim().slice(0, 64) : '';
947
- if (tagSel && !isTag(tagSel))
948
- throw new Error('tag 非法(需小写字母数字连字符,1-32 字符)');
986
+ if (tagSel && !isRoomSlug(tagSel))
987
+ throw new Error('tag 非法(两级制:major 或 major/minor,各段小写字母数字连字符)');
988
+ // 孤儿记忆筛选(与 tag 互斥,tag 优先):只认显式布尔,其余视为缺省。
989
+ const untaggedSel = p.untagged === true && !tagSel;
949
990
  // R13 多值归一: halls 数组只留非空字符串(≤40 字符),去重,上限 8(角数);
950
991
  // wing 单值保留兼容,归一后与 halls 合并
951
992
  const wingSel = Array.from(new Set([
@@ -986,29 +1027,46 @@ export async function handleEndpoint(endpoint, payload, deps) {
986
1027
  if (!Array.isArray(tags) || !tags.some((t) => t === tagSel))
987
1028
  return false;
988
1029
  }
1030
+ if (untaggedSel) {
1031
+ // 孤儿:tags 缺失/非数组/全部空串均算无 Room
1032
+ const tags = m?.tags;
1033
+ const hasRoom = Array.isArray(tags) && tags.some((t) => typeof t === 'string' && t !== '');
1034
+ if (hasRoom)
1035
+ return false;
1036
+ }
989
1037
  return true;
990
1038
  });
991
1039
  }
1040
+ const pageIds = filtered.slice(offset, offset + limit).map((h) => h.id);
1041
+ const updatedById = new Map(stores.l1.getByIds(pageIds).map((r) => [r.id, r.updatedAt]));
992
1042
  const resp = {
993
- items: filtered.slice(offset, offset + limit).map((h) => hitToUiRecord({ ...h, metadata: metaById?.get(h.id) })),
1043
+ items: filtered.slice(offset, offset + limit).map((h) => hitToUiRecord({ ...h, metadata: metaById?.get(h.id) }, {
1044
+ agingWeight: agingWeightOf(updatedById.get(h.id), stores.l1.decayHalfLife),
1045
+ usage: stores.usage?.get(h.id) ?? null,
1046
+ })),
994
1047
  hasMore: filtered.length > offset + limit,
995
1048
  total: null,
996
1049
  truncated: wanted > SEARCH_CAP,
997
1050
  scenes: offset === 0 ? stores.l1.distinctScenes() : undefined,
998
1051
  wingCatalog,
1052
+ usageSummary: stores.usage?.summary() ?? null,
999
1053
  };
1000
1054
  return resp;
1001
1055
  }
1002
1056
  // 退场筛查仅浏览路径透传;检索路径不接——FTS/向量里本就没有已退场行,
1003
1057
  // 接了只会让「仅退场+关键词」永远空结果,不如如实不筛。
1004
- 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, retired: retiredSel, limit, offset });
1058
+ 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 });
1005
1059
  const resp = {
1006
- items: items.map(hitToUiRecord),
1060
+ items: items.map((r) => hitToUiRecord(r, {
1061
+ agingWeight: agingWeightOf(r.updatedAt, stores.l1.decayHalfLife),
1062
+ usage: stores.usage?.get(r.id) ?? null,
1063
+ })),
1007
1064
  hasMore: offset + items.length < total,
1008
1065
  total,
1009
1066
  truncated: false,
1010
1067
  scenes: offset === 0 ? stores.l1.distinctScenes() : undefined,
1011
1068
  wingCatalog,
1069
+ usageSummary: stores.usage?.summary() ?? null,
1012
1070
  };
1013
1071
  return resp;
1014
1072
  }
@@ -1615,7 +1673,14 @@ export async function handleEndpoint(endpoint, payload, deps) {
1615
1673
  }
1616
1674
  }
1617
1675
  /** 浏览器卡片字段(比 MemoryRecord 精简,去掉大 metadata;Hall 从 metadata 提取)。 */
1618
- export function hitToUiRecord(r) {
1676
+ /** 老化权重(与读路径 applyDecayWeight 同式):max(0.5, 0.5^(Δ天/半衰期));半衰期 ≤0 = 关 → null。 */
1677
+ export function agingWeightOf(updatedAtMs, halfLifeDays, now = Date.now()) {
1678
+ if (!(halfLifeDays > 0))
1679
+ return null;
1680
+ const ageDays = Math.max(0, (now - (updatedAtMs ?? 0)) / 86_400_000);
1681
+ return Math.max(0.5, Math.pow(0.5, ageDays / halfLifeDays));
1682
+ }
1683
+ export function hitToUiRecord(r, extra) {
1619
1684
  const retired = r.validTo !== undefined;
1620
1685
  const mark = retired ? readSupersedeMarker(r.metadata) : undefined;
1621
1686
  return {
@@ -1641,6 +1706,8 @@ export function hitToUiRecord(r) {
1641
1706
  // 但必须给出可见差异。
1642
1707
  retired,
1643
1708
  retiredReason: retired ? (mark?.reason ?? 'unknown') : null,
1709
+ agingWeight: extra?.agingWeight ?? null,
1710
+ usage: extra?.usage ?? null,
1644
1711
  };
1645
1712
  }
1646
1713
  /**