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

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,30 @@
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.15] — 2026-10-02
21
+
22
+ > 归类实测反馈两连修:①改名后的条目误带「已退役」(会掉出标注词表);②HTML5 拖拽在部分 webview 不生效 → 新增**点选归类**主路径。
23
+
24
+ ### 修复
25
+
26
+ - **归类(改名)后条目误标 retired**:renameRoom 复用 merge 编排,`markMerged` 的"旧条目转退役"被带了过来——归完类的新 room 显示「已退役」并**掉出标注器词表**(listActive 过滤)。现 rename 收尾恢复先前状态(active 条目改名后仍 active);真 merge(并集语义)不受影响。红测试先行(unit + HTTP 面集成)。
27
+
28
+ ### 变更
29
+
30
+ - **点选归类为主路径**:点 room 行「归类」→ 进入挑选态(按钮变「选 hall…」,所有 hall 头虚线高亮)→ 点目标 hall 头 → 出 dryRun 预览 → 确认实跑。拖拽保留(支持的環境仍可用),但不再依赖。
31
+ ## [0.20.0-beta.14] — 2026-10-02
32
+
33
+ > 记忆卡片新增**老化权重**与**召回使用**展示,并开始为"老化权重效果统计"积累数据。口径(用户裁定):**进 topN(被注入上下文)才算被使用**;参与尝试未进 = 仅计尝试。
34
+
35
+ ### 新增
36
+
37
+ - **卡片字段**:「老化 N%」chip(读路径衰减系数 `max(0.5, 0.5^(Δ天/半衰期))`,与召回排序同式同源;≤60% 标红=偏老)+「用 used/attempts」计数(title 含最近使用/尝试时间)。工具条新增汇总「召回使用 used/attempted(rate%)」。
38
+ - **统计面**:`RecallUsageStore`(recall-usage.json sidecar,与 rooms-registry 同族纪律——损坏只读降级、绝不阻塞召回读路径);每轮召回在注入/全压制/预算清零三个终态记录(候选 attempts++、注入 used++,时间戳取最新);内存即时 + 去抖 5s 原子落盘,5 万条按最近尝试淘汰;未装配 = 零成本旁路。
39
+ - 契约:UiRecord 增 `agingWeight`/`usage`,ListRecordsResponse 增 `usageSummary`(可选字段,旧客户端零断裂);契约键数不变。
40
+
41
+ ### 效果统计口径(后续分析用)
42
+ - usageSummary:tracked(有统计的记忆数)/ attempted(Σ尝试)/ used(Σ使用)/ rate(使用率%)
43
+ - 细粒度分析(按类型/年龄段的使用分布、半衰期调参依据)数据自本版起积累,分析视图另议。
20
44
  ## [0.20.0-beta.13] — 2026-10-02
21
45
 
22
46
  > 补全归类逻辑:把 room 归到已有 hall 下(此前只能新增 hall,或合并进别的 room 抹掉身份)。面板支持**拖拽归类**——拖 room 放到 hall 头上即可。
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) }),
@@ -4017,6 +4043,7 @@ var __defProp = Object.defineProperty;
4017
4043
  const [preview, setPreview] = (0, import_react16.useState)(null);
4018
4044
  const [grownOpen, setGrownOpen] = (0, import_react16.useState)(false);
4019
4045
  const [dragOverHall, setDragOverHall] = (0, import_react16.useState)(null);
4046
+ const [picking, setPicking] = (0, import_react16.useState)(null);
4020
4047
  const loadAll = (0, import_react16.useCallback)(() => {
4021
4048
  rpc("dsh-memory/rooms-get", {}).then((r) => {
4022
4049
  if (r && r.ok) {
@@ -4280,16 +4307,23 @@ var __defProp = Object.defineProperty;
4280
4307
  const from = ev.dataTransfer.getData("text/dsh-room");
4281
4308
  if (from) beginReclass(from, g.hall);
4282
4309
  },
4310
+ onClick: () => {
4311
+ if (!picking || busy) return;
4312
+ const from = picking;
4313
+ setPicking(null);
4314
+ beginReclass(from, g.hall);
4315
+ },
4283
4316
  style: {
4284
4317
  display: "flex",
4285
4318
  alignItems: "center",
4286
4319
  gap: 8,
4287
4320
  padding: "5px 10px",
4288
4321
  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
4322
+ outline: dragOverHall === g.hall || picking !== null ? "2px dashed var(--dsh-mem-accent)" : void 0,
4323
+ outlineOffset: dragOverHall === g.hall || picking !== null ? "-2px" : void 0,
4324
+ cursor: picking ? "pointer" : void 0
4291
4325
  },
4292
- title: hiPriv ? "拖入 room 归类到该 hall 下(major/minor;记录随迁,旧名进别名,预览后确认)" : void 0,
4326
+ title: picking ? "点击此 hall:把「" + picking + "」归入 " + g.hall + " 下(major/minor,预览后确认)" : hiPriv ? "拖入 room 归类到该 hall 下(major/minor;记录随迁,旧名进别名,预览后确认)" : void 0,
4293
4327
  children: [
4294
4328
  /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("span", { style: { fontSize: 12, fontWeight: 600 }, children: [
4295
4329
  g.hall,
@@ -4339,7 +4373,27 @@ var __defProp = Object.defineProperty;
4339
4373
  (e.aliases ?? []).length > 0 ? /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("span", { style: S.muted, children: "别名: " + (e.aliases ?? []).join(", ") }) : null,
4340
4374
  /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { style: S.grow }),
4341
4375
  !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: "归类" }),
4376
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
4377
+ "button",
4378
+ {
4379
+ type: "button",
4380
+ disabled: busy,
4381
+ title: picking === e.slug ? "再点一次取消挑选;然后点击目标 hall 头完成归类" : "归类到已有 hall 下:点击后选目标 hall(记录随迁,旧名进别名)",
4382
+ onClick: () => {
4383
+ if (picking === e.slug) {
4384
+ setPicking(null);
4385
+ return;
4386
+ }
4387
+ setForm({ action: "rename", from: e.slug, to: "" });
4388
+ setPreview(null);
4389
+ setPicking(e.slug);
4390
+ setMsg(null);
4391
+ setError(null);
4392
+ },
4393
+ style: { ...btn, ...picking === e.slug ? { color: "var(--dsh-mem-accent)", borderColor: "var(--dsh-mem-accent)" } : {} },
4394
+ children: picking === e.slug ? "选 hall…" : "归类"
4395
+ }
4396
+ ),
4343
4397
  /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("button", { type: "button", disabled: busy, title: `改名(= merge 1:1 + 注册表改名)`, onClick: () => {
4344
4398
  setForm({ action: "rename", from: e.slug, to: "" });
4345
4399
  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,可撤销)
@@ -65,6 +65,11 @@ export interface RoomMergeStore extends RoomReviewStore {
65
65
  export interface RoomRegistryMerge {
66
66
  markMerged(fromSlug: string, toSlug: string): Promise<boolean>;
67
67
  renameSlug(oldSlug: string, newSlug: string): Promise<boolean>;
68
+ /** rename 收尾恢复先前状态用(active 条目改名后不掉出标注词表)。 */
69
+ bySlug?(slug: string): {
70
+ status: 'active' | 'retired';
71
+ } | undefined;
72
+ setStatus?(slug: string, status: 'active' | 'retired'): Promise<boolean>;
68
73
  }
69
74
  export interface RoomCandidate {
70
75
  id: string;
@@ -153,7 +158,9 @@ export declare function mergeRoom(io: {
153
158
  dryRun?: boolean;
154
159
  cap?: number;
155
160
  }): Promise<MergeRoomResult>;
156
- /** rename = merge 1:1 + 注册表改名(冲突时抛错提示走 merge)。 */
161
+ /** rename = merge 1:1 + 注册表改名(冲突时抛错提示走 merge)。
162
+ * 归类语义:改名**不是退役**——merge 编排的 markMerged 会把旧条目置 retired,
163
+ * 这里在改名后恢复其先前状态(active 条目改名后仍 active,不掉出标注词表)。 */
157
164
  export declare function renameRoom(io: {
158
165
  l1: RoomMergeStore;
159
166
  registry: RoomRegistryMerge;
@@ -202,11 +202,17 @@ export async function mergeRoom(io, from, to, opts = {}) {
202
202
  roomMergeRunning = false;
203
203
  }
204
204
  }
205
- /** rename = merge 1:1 + 注册表改名(冲突时抛错提示走 merge)。 */
205
+ /** rename = merge 1:1 + 注册表改名(冲突时抛错提示走 merge)。
206
+ * 归类语义:改名**不是退役**——merge 编排的 markMerged 会把旧条目置 retired,
207
+ * 这里在改名后恢复其先前状态(active 条目改名后仍 active,不掉出标注词表)。 */
206
208
  export async function renameRoom(io, from, to, opts = {}) {
209
+ const priorStatus = io.registry.bySlug?.(from)?.status ?? 'active';
207
210
  const r = await mergeRoom(io, from, to, opts);
208
- if (!r.dryRun && r.applied > 0)
211
+ if (!r.dryRun && r.applied > 0) {
209
212
  await io.registry.renameSlug(from, to);
213
+ if (priorStatus === 'active')
214
+ await io.registry.setStatus?.(to, 'active');
215
+ }
210
216
  return r;
211
217
  }
212
218
  /** 下一条待复查(供 agent 逐个过):记录摘要 + 候选 + 现有 Room 词表 + 剩余数。 */
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-prime-memory",
3
- "version": "0.20.0-beta.13",
3
+ "version": "0.20.0-beta.15",
4
4
  "publishConfig": {
5
5
  "registry": "https://registry.npmjs.org",
6
6
  "access": "public",