dsh-prime-memory 0.18.4 → 0.18.5

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/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';
@@ -26,7 +27,7 @@ import { sourceAnchorLabels } from './pipeline/anchors.js';
26
27
  import { readSupersedeMarker } from './store/supersede.js';
27
28
  import { isSnapshotName } from './store/l1-snapshot.js';
28
29
  import { WING_CATALOG, WING_FALLBACK } from './types.js';
29
- import { isTag } from './metadata-validators.js';
30
+ import { isRoomSlug } from './metadata-validators.js';
30
31
  import { InProcMemoryBackend } from './store/memory-backend.js';
31
32
  import { isWingCorner } from './store/session-modes.js';
32
33
  import { startWingBackfill } from './wing-backfill.js';
@@ -35,7 +36,7 @@ import { snapshotTokenCost } from './token-cost.js';
35
36
  const require = createRequire(import.meta.url);
36
37
  export const PLUGIN_VERSION = require('../package.json').version;
37
38
  /**
38
- * 端点全集运行时清单(36 个,与 tests/contract-keys.test.ts 的 ENDPOINTS 及
39
+ * 端点全集运行时清单(与 tests/contract-keys.test.ts 的 ENDPOINTS 及
39
40
  * contract.ts 类型映射表三方对齐,漂移由键集 diff 测试暴露)。
40
41
  * 注意:本清单同时是 HTTP 前缀路由 `/dsh-memory/rpc/<短名>` 的**放行白名单**
41
42
  * (见下方 SHORT_ENDPOINTS),漏一条 = 该端点在面板里静默消失(404 被客户端
@@ -52,6 +53,9 @@ export const MEMORY_ENDPOINTS = [
52
53
  'dsh-memory/session-mode-set',
53
54
  'dsh-memory/wing-overview',
54
55
  'dsh-memory/rooms-get',
56
+ 'dsh-memory/rooms-export',
57
+ 'dsh-memory/room-register',
58
+ 'dsh-memory/room-admin',
55
59
  'dsh-memory/wing-backfill',
56
60
  'dsh-memory/session-stats',
57
61
  'dsh-memory/settings-get',
@@ -168,12 +172,15 @@ export function buildEndpointDeps(base, sources, controller) {
168
172
  embedManager: sources.embedManager,
169
173
  sessionInfo: sources.sessionInfo,
170
174
  ruminate: controller,
175
+ roomRegistry: sources.roomRegistry,
171
176
  };
172
177
  return { ...base, ...injected };
173
178
  }
174
179
  export function registerMemoryRpc(ctx, cfg, stores, logger, status, live, modes, dataDir, rebuild, embedManager, sessionInfo,
175
180
  /** 反刍控制器(存储降级时为 undefined):经 buildEndpointDeps 落入 deps.ruminate。 */
176
- ruminate) {
181
+ ruminate,
182
+ /** Room 分类管理依赖(注册表;缺省 = rooms-get 不带 registry、rooms-export 恒 404 语义)。 */
183
+ roomRegistry) {
177
184
  /** 当前是否持有一段有效注册(dispose 完成后清空,允许服务重上线时重注册)。 */
178
185
  let holding = false;
179
186
  /** 当前 handle 绑定的 connection 实例(internal/service 第二参;用于识别实例替换)。 */
@@ -191,7 +198,7 @@ ruminate) {
191
198
  // 端点处理器:HTTP 层与旧 connection.rpc 的 handler 共用同一分发。
192
199
  const rpcHandler = async (endpoint, payload) => {
193
200
  try {
194
- 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 }, { status, live, modes, dataDir, rebuild, embedManager, sessionInfo, roomRegistry }, ruminate));
195
202
  return { ok: true, value };
196
203
  }
197
204
  catch (err) {
@@ -339,7 +346,7 @@ function sanitizeSettings(s) {
339
346
  }
340
347
  /** 端点分发表(导出供测试直调:可精确注入 rebuild/ruminate 等可选控制器,验证 deps 接线)。 */
341
348
  export async function handleEndpoint(endpoint, payload, deps) {
342
- const { cfg, stores, status, live, modes, dataDir, rebuild, ruminate, embedManager, sessionInfo } = deps;
349
+ const { cfg, stores, status, live, modes, dataDir, rebuild, ruminate, embedManager, sessionInfo, roomRegistry } = deps;
343
350
  switch (endpoint) {
344
351
  case 'dsh-memory/stats':
345
352
  return buildStats(cfg, stores, status);
@@ -456,10 +463,223 @@ export async function handleEndpoint(endpoint, payload, deps) {
456
463
  // ── Room 分类计数(标签自生长分类;展开 metadata.tags 聚合,零 schema) ──
457
464
  case 'dsh-memory/rooms-get': {
458
465
  const rooms = stores.l1.listRooms();
459
- const v = { rooms, total: rooms.length };
466
+ // 端点层合并注册表(Agent A 设计):注册表 active 条目 count=0 补齐并带
467
+ // source/label(source 用条目真实来源——收编 grown ≠ 预注册,⭐角标靠它区分);
468
+ // 自生长条目标 source='grown'。不动 listRooms() 的缓存语义。
469
+ const merged = rooms.map((r) => ({ ...r, source: 'grown' }));
470
+ const registry = roomRegistry?.listActive() ?? [];
471
+ for (const e of registry) {
472
+ const idx = merged.findIndex((r) => r.room === e.slug);
473
+ if (idx === -1) {
474
+ merged.push({ room: e.slug, count: 0, source: e.source, label: e.label });
475
+ }
476
+ else {
477
+ merged[idx] = { ...merged[idx], source: e.source, label: e.label ?? merged[idx].label };
478
+ }
479
+ }
480
+ const v = {
481
+ rooms: merged,
482
+ total: merged.length,
483
+ orphanCount: stores.l1.untaggedL1Count(),
484
+ registry: roomRegistry?.list().map((e) => ({
485
+ slug: e.slug,
486
+ label: e.label,
487
+ description: e.description,
488
+ source: e.source,
489
+ aliases: e.aliases,
490
+ status: e.status,
491
+ })),
492
+ };
493
+ return v;
494
+ }
495
+ case 'dsh-memory/room-register': {
496
+ if (!roomRegistry)
497
+ throw new Error('Room 注册表未装配(需要插件重载以初始化)。');
498
+ if (!live?.get().memoryMutate)
499
+ throw new Error('记忆写删未开放:请在记忆库面板开启「高权限模式」后,模型才能预注册 Room。');
500
+ const p = (payload ?? {});
501
+ const slug = String(p.slug ?? '').trim();
502
+ if (!slug)
503
+ throw new Error('slug 不能为空');
504
+ const r = await roomRegistry.register({
505
+ slug,
506
+ label: typeof p.label === 'string' ? p.label : undefined,
507
+ description: typeof p.description === 'string' ? p.description : undefined,
508
+ source: p.source === 'grown' ? 'grown' : undefined,
509
+ });
510
+ const v = {
511
+ slug: r.entry.slug,
512
+ created: r.created,
513
+ notice: r.created ? '已预注册。' : '已存在(只补全了缺失的 label/description)。',
514
+ };
515
+ return v;
516
+ }
517
+ // ── Room 破坏性面管理(merge/rename/retire;与 memory_room_admin 工具同编排) ──
518
+ case 'dsh-memory/room-admin': {
519
+ if (!roomRegistry)
520
+ throw new Error('Room 注册表未装配(需要插件重载以初始化)。');
521
+ if (!live?.get().memoryMutate)
522
+ throw new Error('记忆写删未开放:请在记忆库面板开启「高权限模式」后,才能执行 Room 管理。');
523
+ const p = (payload ?? {});
524
+ if (p.action === 'update') {
525
+ const slug = String(p.slug ?? '').trim();
526
+ if (!slug)
527
+ throw new Error('update 需要 slug');
528
+ const changed = await roomRegistry.update(slug, {
529
+ label: typeof p.label === 'string' ? p.label : undefined,
530
+ description: typeof p.description === 'string' ? p.description : undefined,
531
+ });
532
+ const v = {
533
+ notice: changed
534
+ ? `已更新:${slug}(显示名/归类说明,标注器下次即用新词)`
535
+ : `无变化:${slug} 未找到或未提供任何非空字段`,
536
+ };
537
+ return v;
538
+ }
539
+ if (p.action === 'retire') {
540
+ const slug = String(p.slug ?? '').trim();
541
+ if (!slug)
542
+ throw new Error('retire 需要 slug');
543
+ const active = p.active === true;
544
+ // 自生长未注册 slug 退场:自动补一条 grown 注册条目再置 retired——
545
+ // 否则词表无物可变,面板按钮无法切换为「恢复」(用户实测反馈)
546
+ if (!active && p.withRecords === true && !roomRegistry.bySlug(slug)) {
547
+ await roomRegistry.register({ slug, source: 'grown' });
548
+ }
549
+ const changed = await roomRegistry.setStatus(slug, active ? 'active' : 'retired');
550
+ // 带记录退场(软删,可恢复):该 room 下全部**活跃**记录分批 retire(每批 ≤200,与 records-delete 同原语)
551
+ let retiredRecords = 0;
552
+ if (p.withRecords === true && !active) {
553
+ for (let offset = 0;; offset += 200) {
554
+ const page = stores.l1.list({ tag: slug, retired: false, limit: 200, offset });
555
+ if (page.items.length === 0)
556
+ break;
557
+ const n = stores.l1.retire(page.items.map((r) => r.id), { at: new Date().toISOString(), reason: 'manual' });
558
+ retiredRecords += n;
559
+ if (page.items.length < 200)
560
+ break;
561
+ }
562
+ deps.logger.info(`[memory] Room 退役带记录:${slug},退场 ${retiredRecords} 条(软删,可恢复)`);
563
+ }
564
+ const v = {
565
+ retiredRecords: retiredRecords > 0 ? retiredRecords : undefined,
566
+ notice: changed
567
+ ? active
568
+ ? `已恢复:${slug}`
569
+ : `已退役:${slug}(存量记录的 tags 不动,仅词表不再推荐)。${retiredRecords > 0 ? `已连同退场 ${retiredRecords} 条记录(软删,可在「已退场」区恢复)。` : ''}`
570
+ : `未找到注册条目或状态无变化:${slug}。${retiredRecords > 0 ? `已退场 ${retiredRecords} 条记录(软删,可恢复)。` : ''}`,
571
+ };
572
+ return v;
573
+ }
574
+ if (p.action === 'merge' || p.action === 'rename') {
575
+ const from = String(p.from ?? '').trim();
576
+ const to = String(p.to ?? '').trim();
577
+ if (!from || !to)
578
+ throw new Error('merge/rename 需要 from 与 to 两个 slug');
579
+ const dryRun = p.dryRun !== false;
580
+ const r = await (p.action === 'merge' ? mergeRoom : renameRoom)({ l1: stores.l1, registry: roomRegistry, logger: deps.logger }, from, to, { dryRun });
581
+ if (r.dryRun) {
582
+ const v = {
583
+ notice: `[dryRun 预览] ${from} → ${to}:影响 ${r.affected} 条${r.hasMore ? '(超过单次上限,需续跑)' : ''}。` +
584
+ `样例:${r.preview.join(', ') || '无'}。确认无误后以 dryRun=false 执行(执行前自动备份,完成后自动入队场景重算)。`,
585
+ dryRun: true,
586
+ affected: r.affected,
587
+ preview: r.preview,
588
+ hasMore: r.hasMore,
589
+ };
590
+ return v;
591
+ }
592
+ const v = {
593
+ notice: `${p.action === 'merge' ? '合并' : '改名'}完成:${from} → ${to},重写 ${r.applied} 条` +
594
+ `(备份:${r.backupFile ?? '无'})。`,
595
+ dryRun: false,
596
+ affected: r.applied,
597
+ backupFile: r.backupFile,
598
+ };
599
+ return v;
600
+ }
601
+ throw new Error(`非法 action:${String(p.action)}(允许 merge/rename/retire)`);
602
+ }
603
+ case 'dsh-memory/rooms-export': {
604
+ const p = (payload ?? {});
605
+ const kind = p.kind === 'orphans' ? 'orphans' : p.kind === 'rooms' ? 'rooms' : p.kind === 'records' ? 'records' : '';
606
+ if (!kind)
607
+ throw new Error(`非法 kind: ${String(p.kind)}(允许 rooms/orphans/records)`);
608
+ const limit = Math.min(Math.max(Number(p.limit) || 2000, 1), 10_000);
609
+ const esc = (v) => {
610
+ const t = String(v ?? '');
611
+ return /[,\r\n"]/.test(t) ? '"' + t.replace(/"/g, '""') + '"' : t;
612
+ };
613
+ if (kind === 'rooms') {
614
+ const registry = roomRegistry?.list() ?? [];
615
+ const counts = new Map(stores.l1.listRooms().map((r) => [r.room, r.count]));
616
+ const lines = ['slug,count,source,label,description,status,aliases'];
617
+ let n = 0;
618
+ const seen = new Set();
619
+ for (const e of registry) {
620
+ lines.push([e.slug, String(counts.get(e.slug) ?? 0), e.source, e.label ?? '', e.description ?? '', e.status, (e.aliases ?? []).join('|')].map(esc).join(','));
621
+ seen.add(e.slug);
622
+ n++;
623
+ }
624
+ for (const r of stores.l1.listRooms()) {
625
+ if (seen.has(r.room))
626
+ continue;
627
+ lines.push([r.room, String(r.count), 'grown', '', '', 'active', ''].map(esc).join(','));
628
+ n++;
629
+ }
630
+ const v = { kind, csv: lines.join('\r\n'), total: n, truncated: false };
631
+ return v;
632
+ }
633
+ if (kind === 'records') {
634
+ const tag = String(p.tag ?? '').trim();
635
+ if (!tag)
636
+ throw new Error('kind=records 需要 tag(目标 Room slug)');
637
+ const header = 'id,type,scene_name,updated_at,tags,content';
638
+ const lines = [header];
639
+ let n = 0;
640
+ let truncated2 = false;
641
+ for (let offset = 0; offset < limit; offset += 200) {
642
+ const page = stores.l1.list({ tag, retired: undefined, limit: 200, offset });
643
+ for (const r of page.items) {
644
+ if (n >= limit) {
645
+ truncated2 = true;
646
+ break;
647
+ }
648
+ const meta = (r.metadata ?? {});
649
+ const tags = Array.isArray(meta.tags) ? meta.tags.join('|') : '';
650
+ lines.push([r.id, r.type, r.scene_name, new Date(r.updatedAt).toISOString(), tags, r.content].map(esc).join(','));
651
+ n++;
652
+ }
653
+ if (truncated2 || page.items.length < 200)
654
+ break;
655
+ }
656
+ const v = { kind, csv: lines.join('\r\n'), total: n, truncated: truncated2 };
657
+ return v;
658
+ }
659
+ // orphans:无 Room 绑定的活跃记录清单(id/type/scene/updated/content/候选/状态)
660
+ const header = 'id,type,scene_name,updated_at,content,room_candidates,room_review';
661
+ const lines = [header];
662
+ let n = 0;
663
+ let truncated = false;
664
+ for (let offset = 0; offset < limit; offset += 200) {
665
+ const page = stores.l1.list({ untagged: true, retired: false, limit: 200, offset });
666
+ for (const r of page.items) {
667
+ if (n >= limit) {
668
+ truncated = true;
669
+ break;
670
+ }
671
+ const meta = (r.metadata ?? {});
672
+ lines.push([r.id, r.type, r.scene_name, new Date(r.updatedAt).toISOString(), r.content,
673
+ Array.isArray(meta.roomCandidates) ? meta.roomCandidates.join('|') : '',
674
+ String(meta.roomReview ?? '')].map(esc).join(','));
675
+ n++;
676
+ }
677
+ if (truncated || page.items.length < 200)
678
+ break;
679
+ }
680
+ const v = { kind, csv: lines.join('\r\n'), total: n, truncated };
460
681
  return v;
461
682
  }
462
- // ── 一键回填(task_15):后台任务,单飞;端点立即返回,进度看 wing-overview ──
463
683
  case 'dsh-memory/wing-backfill': {
464
684
  const r = startWingBackfill({
465
685
  ctx: deps.ctx,
@@ -753,8 +973,10 @@ export async function handleEndpoint(endpoint, payload, deps) {
753
973
  const retiredSel = typeof p.retired === 'boolean' ? p.retired : undefined;
754
974
  // Room 过滤:tag 是**自生长**的 slug(无枚举),只做形状与长度校验(SQL 侧参数化)
755
975
  const tagSel = typeof p.tag === 'string' ? p.tag.trim().slice(0, 64) : '';
756
- if (tagSel && !isTag(tagSel))
757
- throw new Error('tag 非法(需小写字母数字连字符,1-32 字符)');
976
+ if (tagSel && !isRoomSlug(tagSel))
977
+ throw new Error('tag 非法(两级制:major 或 major/minor,各段小写字母数字连字符)');
978
+ // 孤儿记忆筛选(与 tag 互斥,tag 优先):只认显式布尔,其余视为缺省。
979
+ const untaggedSel = p.untagged === true && !tagSel;
758
980
  // R13 多值归一: halls 数组只留非空字符串(≤40 字符),去重,上限 8(角数);
759
981
  // wing 单值保留兼容,归一后与 halls 合并
760
982
  const wingSel = Array.from(new Set([
@@ -795,29 +1017,46 @@ export async function handleEndpoint(endpoint, payload, deps) {
795
1017
  if (!Array.isArray(tags) || !tags.some((t) => t === tagSel))
796
1018
  return false;
797
1019
  }
1020
+ if (untaggedSel) {
1021
+ // 孤儿:tags 缺失/非数组/全部空串均算无 Room
1022
+ const tags = m?.tags;
1023
+ const hasRoom = Array.isArray(tags) && tags.some((t) => typeof t === 'string' && t !== '');
1024
+ if (hasRoom)
1025
+ return false;
1026
+ }
798
1027
  return true;
799
1028
  });
800
1029
  }
1030
+ const pageIds = filtered.slice(offset, offset + limit).map((h) => h.id);
1031
+ const updatedById = new Map(stores.l1.getByIds(pageIds).map((r) => [r.id, r.updatedAt]));
801
1032
  const resp = {
802
- items: filtered.slice(offset, offset + limit).map((h) => hitToUiRecord({ ...h, metadata: metaById?.get(h.id) })),
1033
+ items: filtered.slice(offset, offset + limit).map((h) => hitToUiRecord({ ...h, metadata: metaById?.get(h.id) }, {
1034
+ agingWeight: agingWeightOf(updatedById.get(h.id), stores.l1.decayHalfLife),
1035
+ usage: stores.usage?.get(h.id) ?? null,
1036
+ })),
803
1037
  hasMore: filtered.length > offset + limit,
804
1038
  total: null,
805
1039
  truncated: wanted > SEARCH_CAP,
806
1040
  scenes: offset === 0 ? stores.l1.distinctScenes() : undefined,
807
1041
  wingCatalog,
1042
+ usageSummary: stores.usage?.summary() ?? null,
808
1043
  };
809
1044
  return resp;
810
1045
  }
811
1046
  // 退场筛查仅浏览路径透传;检索路径不接——FTS/向量里本就没有已退场行,
812
1047
  // 接了只会让「仅退场+关键词」永远空结果,不如如实不筛。
813
- 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 });
1048
+ 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 });
814
1049
  const resp = {
815
- items: items.map(hitToUiRecord),
1050
+ items: items.map((r) => hitToUiRecord(r, {
1051
+ agingWeight: agingWeightOf(r.updatedAt, stores.l1.decayHalfLife),
1052
+ usage: stores.usage?.get(r.id) ?? null,
1053
+ })),
816
1054
  hasMore: offset + items.length < total,
817
1055
  total,
818
1056
  truncated: false,
819
1057
  scenes: offset === 0 ? stores.l1.distinctScenes() : undefined,
820
1058
  wingCatalog,
1059
+ usageSummary: stores.usage?.summary() ?? null,
821
1060
  };
822
1061
  return resp;
823
1062
  }
@@ -1424,7 +1663,14 @@ export async function handleEndpoint(endpoint, payload, deps) {
1424
1663
  }
1425
1664
  }
1426
1665
  /** 浏览器卡片字段(比 MemoryRecord 精简,去掉大 metadata;Hall 从 metadata 提取)。 */
1427
- export function hitToUiRecord(r) {
1666
+ /** 老化权重(与读路径 applyDecayWeight 同式):max(0.5, 0.5^(Δ天/半衰期));半衰期 ≤0 = 关 → null。 */
1667
+ export function agingWeightOf(updatedAtMs, halfLifeDays, now = Date.now()) {
1668
+ if (!(halfLifeDays > 0))
1669
+ return null;
1670
+ const ageDays = Math.max(0, (now - (updatedAtMs ?? 0)) / 86_400_000);
1671
+ return Math.max(0.5, Math.pow(0.5, ageDays / halfLifeDays));
1672
+ }
1673
+ export function hitToUiRecord(r, extra) {
1428
1674
  const retired = r.validTo !== undefined;
1429
1675
  const mark = retired ? readSupersedeMarker(r.metadata) : undefined;
1430
1676
  return {
@@ -1450,6 +1696,8 @@ export function hitToUiRecord(r) {
1450
1696
  // 但必须给出可见差异。
1451
1697
  retired,
1452
1698
  retiredReason: retired ? (mark?.reason ?? 'unknown') : null,
1699
+ agingWeight: extra?.agingWeight ?? null,
1700
+ usage: extra?.usage ?? null,
1453
1701
  };
1454
1702
  }
1455
1703
  /**
@@ -61,6 +61,8 @@ export declare class L1Store {
61
61
  private readonly decayHalfLifeDays;
62
62
  /** §D 第 3 路(图谱回链);缺省 = 不接,恰为 2 路。 */
63
63
  private readonly graphLaneProvider?;
64
+ /** 孤儿记忆计数(没有任何 Room 的记录;rooms-get 面板「无绑定」chip 用)。 */
65
+ untaggedL1Count(): number;
64
66
  /** Room 计数缓存(见 `listRooms()`:tags 仅随反刍变化,不必每次敲库)。 */
65
67
  private roomCache;
66
68
  constructor(dataDir: string, db: MemoryDb, embed?: EmbeddingService, strategy?: RecallStrategy, logger?: MemoryLogger,
@@ -68,6 +70,8 @@ export declare class L1Store {
68
70
  decayHalfLifeDays?: number,
69
71
  /** 图谱路提供者(§D 第 3 路);不传则该路不存在,融合退回双路。 */
70
72
  graphLane?: GraphLaneProvider);
73
+ /** 读路径时效半衰期(天;0=关)。老化权重展示与召回衰减同一事实源。 */
74
+ get decayHalfLife(): number;
71
75
  init(): Promise<void>;
72
76
  /** 旧版单文件 records.jsonl 一次性导入检索库,成功后改名 .imported。 */
73
77
  private importLegacy;
@@ -88,6 +92,34 @@ export declare class L1Store {
88
92
  * 返回 false 表示 id 不存在或写入失败——调用方须记账,不许静默。
89
93
  */
90
94
  patchMetadata(id: string, metadata: Record<string, unknown>): boolean;
95
+ /**
96
+ * 收集某 Room 的全部匹配记录(含已退场行,口径与 Room 计数一致)。
97
+ * 内部分页拉取,上限 cap 防跑飞;返回记录数组供备份与干跑预览。
98
+ */
99
+ listByTagAll(tag: string, cap?: number): MemoryRecord[];
100
+ /**
101
+ * 备份某 Room 的匹配记录到 `<dataDir>/rooms-merge-backups/<ts>-<tag>.json`
102
+ * (执行前快照;破坏性重写前的唯一回滚物料)。返回备份文件路径与条数。
103
+ */
104
+ backupTagRecords(tag: string): {
105
+ file: string;
106
+ count: number;
107
+ };
108
+ /**
109
+ * 游标重写:把 metadata.tags 里的 `from` 全部替换为 `to`(**含已退场行**,
110
+ * 口径与 Room 计数一致)。写前重读-合并-写回(relabel 同款):只动 tags,
111
+ * roomCandidates/roomReview/hall/cogHall/sourceAnchors 等其余键原样保全。
112
+ *
113
+ * 单次上限 cap(500)可续跑:再次调用同一 from 即继续处理剩余行。
114
+ * 返回重写条数 + 受影响的 (family, scene) 集(供 recluster 'room-merge' 入队)。
115
+ */
116
+ rewriteTag(from: string, to: string, cap?: number): {
117
+ rewritten: number;
118
+ scanned: number;
119
+ families: string[];
120
+ scenes: string[];
121
+ hasMore: boolean;
122
+ };
91
123
  /** 按 id 精确取记录(去重决策的版本号查询用,避免全表扫描)。 */
92
124
  getByIds(ids: string[]): MemoryRecord[];
93
125
  /**
@@ -291,6 +323,8 @@ export declare class L1Store {
291
323
  halls?: readonly string[];
292
324
  /** Room 过滤(metadata.tags 含该 slug)。 */
293
325
  tag?: string;
326
+ /** 孤儿记忆筛选(true = 只列没有 Room 的记录;与 tag 互斥,tag 优先)。 */
327
+ untagged?: boolean;
294
328
  workspaceId?: string;
295
329
  /** 退场筛查:三态(`undefined` 全部 / `false` 仅活跃 / `true` 仅已退场)。 */
296
330
  retired?: boolean;
package/dist/store/l1.js CHANGED
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * 去重/合并的更新记录走 upsert(新 record id + 版本递增),不再全量重写文件。
8
8
  */
9
- import { existsSync, promises as fs } from 'node:fs';
9
+ import { existsSync, mkdirSync, promises as fs, writeFileSync } from 'node:fs';
10
10
  import * as path from 'node:path';
11
11
  import { familyForType, isScopeVisible } from '../types.js';
12
12
  import { graphHitRecordIds } from '../graph/search.js';
@@ -34,6 +34,10 @@ export class L1Store {
34
34
  decayHalfLifeDays;
35
35
  /** §D 第 3 路(图谱回链);缺省 = 不接,恰为 2 路。 */
36
36
  graphLaneProvider;
37
+ /** 孤儿记忆计数(没有任何 Room 的记录;rooms-get 面板「无绑定」chip 用)。 */
38
+ untaggedL1Count() {
39
+ return this.db.untaggedL1Count();
40
+ }
37
41
  /** Room 计数缓存(见 `listRooms()`:tags 仅随反刍变化,不必每次敲库)。 */
38
42
  roomCache = null;
39
43
  constructor(dataDir, db, embed = new NoopEmbeddingService(), strategy = 'hybrid', logger,
@@ -52,6 +56,10 @@ export class L1Store {
52
56
  this.decayHalfLifeDays = decayHalfLifeDays ?? 30;
53
57
  this.graphLaneProvider = graphLane;
54
58
  }
59
+ /** 读路径时效半衰期(天;0=关)。老化权重展示与召回衰减同一事实源。 */
60
+ get decayHalfLife() {
61
+ return this.decayHalfLifeDays;
62
+ }
55
63
  async init() {
56
64
  await ensureDir(this.recordsDir);
57
65
  await this.importLegacy();
@@ -113,6 +121,65 @@ export class L1Store {
113
121
  patchMetadata(id, metadata) {
114
122
  return this.db.patchL1Metadata(id, metadata);
115
123
  }
124
+ // ── Room merge/rename 的游标重写(分类管理 beta.5;破坏性面)──────────────
125
+ /**
126
+ * 收集某 Room 的全部匹配记录(含已退场行,口径与 Room 计数一致)。
127
+ * 内部分页拉取,上限 cap 防跑飞;返回记录数组供备份与干跑预览。
128
+ */
129
+ listByTagAll(tag, cap = 500) {
130
+ const out = [];
131
+ for (let offset = 0; offset < cap; offset += 200) {
132
+ const page = this.list({ tag, retired: undefined, limit: Math.min(200, cap - out.length), offset });
133
+ out.push(...page.items);
134
+ if (page.items.length < 200 || out.length >= cap)
135
+ break;
136
+ }
137
+ return out;
138
+ }
139
+ /**
140
+ * 备份某 Room 的匹配记录到 `<dataDir>/rooms-merge-backups/<ts>-<tag>.json`
141
+ * (执行前快照;破坏性重写前的唯一回滚物料)。返回备份文件路径与条数。
142
+ */
143
+ backupTagRecords(tag) {
144
+ const records = this.listByTagAll(tag, 2000);
145
+ const backupDir = path.join(this.dataDir, 'rooms-merge-backups');
146
+ if (!existsSync(backupDir))
147
+ mkdirSync(backupDir, { recursive: true });
148
+ const file = path.join(backupDir, `${Date.now()}-${tag.replace(/[^a-z0-9-]/gi, '_')}.json`);
149
+ writeFileSync(file, JSON.stringify({ tag, exportedAt: new Date().toISOString(), records }, null, 2), 'utf8');
150
+ return { file, count: records.length };
151
+ }
152
+ /**
153
+ * 游标重写:把 metadata.tags 里的 `from` 全部替换为 `to`(**含已退场行**,
154
+ * 口径与 Room 计数一致)。写前重读-合并-写回(relabel 同款):只动 tags,
155
+ * roomCandidates/roomReview/hall/cogHall/sourceAnchors 等其余键原样保全。
156
+ *
157
+ * 单次上限 cap(500)可续跑:再次调用同一 from 即继续处理剩余行。
158
+ * 返回重写条数 + 受影响的 (family, scene) 集(供 recluster 'room-merge' 入队)。
159
+ */
160
+ rewriteTag(from, to, cap = 500) {
161
+ const rows = this.listByTagAll(from, cap + 1);
162
+ const hasMore = rows.length > cap;
163
+ const batch = rows.slice(0, cap);
164
+ let rewritten = 0;
165
+ const families = new Set();
166
+ const scenes = new Set();
167
+ for (const r of batch) {
168
+ const meta = { ...(r.metadata ?? {}) };
169
+ const tags = Array.isArray(meta.tags) ? meta.tags.map(String) : [];
170
+ if (!tags.includes(from))
171
+ continue; // 命中是 json_each 判等,数组缺失即无可改
172
+ const next = tags.map((t) => (t === from ? to : t));
173
+ meta.tags = [...new Set(next)];
174
+ if (this.patchMetadata(r.id, meta)) {
175
+ rewritten++;
176
+ families.add(r.family ?? 'chat');
177
+ if (r.scene_name)
178
+ scenes.add(r.scene_name);
179
+ }
180
+ }
181
+ return { rewritten, scanned: batch.length, families: [...families], scenes: [...scenes], hasMore };
182
+ }
116
183
  /** 按 id 精确取记录(去重决策的版本号查询用,避免全表扫描)。 */
117
184
  getByIds(ids) {
118
185
  return this.db.getL1ByIds(ids);
@@ -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
+ }