dsh-prime-memory 0.16.1 → 0.16.4

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 (59) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/cordis.patch.yml +6 -0
  3. package/dist/client.js +1333 -168
  4. package/dist/config.d.ts +37 -0
  5. package/dist/config.js +7 -0
  6. package/dist/contract.d.ts +155 -1
  7. package/dist/hooks/capture-recovery.d.ts +64 -0
  8. package/dist/hooks/capture-recovery.js +105 -0
  9. package/dist/hooks/capture.js +123 -1
  10. package/dist/hooks/recall-ack.d.ts +48 -0
  11. package/dist/hooks/recall-ack.js +79 -0
  12. package/dist/hooks/recall.d.ts +23 -1
  13. package/dist/hooks/recall.js +121 -6
  14. package/dist/index.d.ts +22 -0
  15. package/dist/index.js +18 -4
  16. package/dist/metadata-validators.d.ts +5 -0
  17. package/dist/metadata-validators.js +11 -1
  18. package/dist/pipeline/l1.d.ts +4 -0
  19. package/dist/pipeline/l1.js +9 -2
  20. package/dist/pipeline/reconcile.js +4 -0
  21. package/dist/pipeline/ruminate.d.ts +11 -1
  22. package/dist/pipeline/ruminate.js +18 -1
  23. package/dist/pipeline/runner.js +62 -0
  24. package/dist/prompts/boundary.d.ts +17 -0
  25. package/dist/prompts/boundary.js +20 -0
  26. package/dist/prompts/graph-projection.js +4 -1
  27. package/dist/prompts/l1-dedup.d.ts +3 -3
  28. package/dist/prompts/l1-dedup.js +9 -1
  29. package/dist/prompts/l1-extraction.d.ts +3 -3
  30. package/dist/prompts/l1-extraction.js +12 -0
  31. package/dist/prompts/persona.js +6 -0
  32. package/dist/prompts/scene.js +7 -1
  33. package/dist/room-review.d.ts +196 -0
  34. package/dist/room-review.js +281 -0
  35. package/dist/stats.d.ts +26 -2
  36. package/dist/stats.js +270 -13
  37. package/dist/store/l0.d.ts +4 -0
  38. package/dist/store/l0.js +8 -0
  39. package/dist/store/l1.d.ts +34 -0
  40. package/dist/store/l1.js +68 -1
  41. package/dist/store/recall-usage.d.ts +43 -0
  42. package/dist/store/recall-usage.js +149 -0
  43. package/dist/store/rooms-registry.d.ts +66 -0
  44. package/dist/store/rooms-registry.js +250 -0
  45. package/dist/store/sqlite.d.ts +16 -0
  46. package/dist/store/sqlite.js +57 -0
  47. package/dist/store/trace.d.ts +39 -0
  48. package/dist/store/trace.js +149 -0
  49. package/dist/tools/index.d.ts +4 -1
  50. package/dist/tools/index.js +249 -2
  51. package/dist/types.d.ts +4 -0
  52. package/dist/util/redact.d.ts +27 -0
  53. package/dist/util/redact.js +228 -0
  54. package/dsh.plugin.json +1 -1
  55. package/package.json +10 -10
  56. package/dist/hall-backfill.d.ts +0 -33
  57. package/dist/hall-backfill.js +0 -95
  58. package/dist/hall-migrate.d.ts +0 -32
  59. package/dist/hall-migrate.js +0 -88
@@ -0,0 +1,48 @@
1
+ /**
2
+ * §D 注入确认——日志回执协议(memorax-absorb Wave 3 / task_20-22)。
3
+ *
4
+ * 问题:`dedupe.mark` 原在 pre-step 返回注入的瞬间执行,但宿主是否真的把该
5
+ * 注入消息收进会话历史,此时并未确认(pre-step 被其他插件覆盖/步骤取消时,
6
+ * 模型根本没看到)——误标记会把这些记忆压制整个会话。
7
+ *
8
+ * 协议(两段式):
9
+ * ① recall.ts 构造注入消息后只登记 pending(messageId → 记录 id 集),不标记;
10
+ * ② capture.ts 在 `session/event` 观察到该 `user/message` 真正入日志时确认,
11
+ * 此时才 `dedupe.mark`(与 §D 红线一致:确认匹配在 source.kind 过滤之前——
12
+ * 注入消息的 source.kind 是 `plugin:memory`,放过滤后永远到不了匹配)。
13
+ *
14
+ * 超时降级(task_22):pending 超 5 分钟未确认 → 照旧标记 + 一次性提示。
15
+ * 防确认链路自身故障导致 dedupe 永不生效、记忆每轮重注的回归;正常确认时延
16
+ * <2s(同进程事件循环)。未确认且未超时的 pending 过期即弃(不标记)——
17
+ * 模型没看到的东西不应被抑制,允许下轮重注。
18
+ *
19
+ * 进程内 volatile,容量 64 条 LRU(注入速率低,正常情况秒级确认,积压即异常)。
20
+ */
21
+ export interface PendingInjection {
22
+ agentId: string;
23
+ messageId: string;
24
+ recordIds: string[];
25
+ at: number;
26
+ }
27
+ /** 确认后执行的实际标记动作(recall.ts 注入 = dedupe.mark 的绑定)。 */
28
+ type InjectionMarker = (agentId: string, recordIds: string[]) => void;
29
+ export declare const PENDING_TIMEOUT_MS: number;
30
+ /** recall.ts 注册实际标记动作(dedupe.mark 绑定);重复注册以最后一次为准。 */
31
+ export declare function setInjectionMarker(fn: InjectionMarker): void;
32
+ /** ① 登记待确认注入(超容量淘汰最旧——积压到 64 条说明确认链路已异常)。 */
33
+ export declare function registerPendingInjection(agentId: string, messageId: string, recordIds: string[], now?: number): void;
34
+ /**
35
+ * ② 日志回执确认:该注入消息已真实进入会话历史。执行标记并移除 pending。
36
+ * @returns 是否命中一条 pending(未命中 = 与本插件注入无关的事件,静默)。
37
+ */
38
+ export declare function confirmInjectionByMessageId(messageId: string): boolean;
39
+ /**
40
+ * 超时清扫:超时的 pending 照旧标记(降级保连续,防 dedupe 失效回归),并移除。
41
+ * 未超时的不动。@returns 本次降级标记的条数(recall 侧据此前瞻性提示一次)。
42
+ */
43
+ export declare function expirePendingInjections(now?: number, timeoutMs?: number): number;
44
+ /** 诊断/测试:当前 pending 条数。 */
45
+ export declare function pendingCount(): number;
46
+ /** 测试隔离:清空 pending 与 marker(模块级状态,用例间互扰)。 */
47
+ export declare function resetAckForTests(): void;
48
+ export {};
@@ -0,0 +1,79 @@
1
+ /**
2
+ * §D 注入确认——日志回执协议(memorax-absorb Wave 3 / task_20-22)。
3
+ *
4
+ * 问题:`dedupe.mark` 原在 pre-step 返回注入的瞬间执行,但宿主是否真的把该
5
+ * 注入消息收进会话历史,此时并未确认(pre-step 被其他插件覆盖/步骤取消时,
6
+ * 模型根本没看到)——误标记会把这些记忆压制整个会话。
7
+ *
8
+ * 协议(两段式):
9
+ * ① recall.ts 构造注入消息后只登记 pending(messageId → 记录 id 集),不标记;
10
+ * ② capture.ts 在 `session/event` 观察到该 `user/message` 真正入日志时确认,
11
+ * 此时才 `dedupe.mark`(与 §D 红线一致:确认匹配在 source.kind 过滤之前——
12
+ * 注入消息的 source.kind 是 `plugin:memory`,放过滤后永远到不了匹配)。
13
+ *
14
+ * 超时降级(task_22):pending 超 5 分钟未确认 → 照旧标记 + 一次性提示。
15
+ * 防确认链路自身故障导致 dedupe 永不生效、记忆每轮重注的回归;正常确认时延
16
+ * <2s(同进程事件循环)。未确认且未超时的 pending 过期即弃(不标记)——
17
+ * 模型没看到的东西不应被抑制,允许下轮重注。
18
+ *
19
+ * 进程内 volatile,容量 64 条 LRU(注入速率低,正常情况秒级确认,积压即异常)。
20
+ */
21
+ const PENDING_CAP = 64;
22
+ export const PENDING_TIMEOUT_MS = 5 * 60_000;
23
+ const pending = new Map();
24
+ let marker;
25
+ /** recall.ts 注册实际标记动作(dedupe.mark 绑定);重复注册以最后一次为准。 */
26
+ export function setInjectionMarker(fn) {
27
+ marker = fn;
28
+ }
29
+ /** ① 登记待确认注入(超容量淘汰最旧——积压到 64 条说明确认链路已异常)。 */
30
+ export function registerPendingInjection(agentId, messageId, recordIds, now = Date.now()) {
31
+ if (!messageId || recordIds.length === 0)
32
+ return;
33
+ pending.delete(messageId); // 同 id 重登记 = 刷新(置尾)
34
+ pending.set(messageId, { agentId, messageId, recordIds, at: now });
35
+ while (pending.size > PENDING_CAP) {
36
+ const oldest = pending.keys().next().value;
37
+ if (oldest === undefined)
38
+ break;
39
+ pending.delete(oldest);
40
+ }
41
+ }
42
+ /**
43
+ * ② 日志回执确认:该注入消息已真实进入会话历史。执行标记并移除 pending。
44
+ * @returns 是否命中一条 pending(未命中 = 与本插件注入无关的事件,静默)。
45
+ */
46
+ export function confirmInjectionByMessageId(messageId) {
47
+ const entry = pending.get(messageId);
48
+ if (!entry)
49
+ return false;
50
+ pending.delete(messageId);
51
+ marker?.(entry.agentId, entry.recordIds);
52
+ return true;
53
+ }
54
+ /**
55
+ * 超时清扫:超时的 pending 照旧标记(降级保连续,防 dedupe 失效回归),并移除。
56
+ * 未超时的不动。@returns 本次降级标记的条数(recall 侧据此前瞻性提示一次)。
57
+ */
58
+ export function expirePendingInjections(now = Date.now(), timeoutMs = PENDING_TIMEOUT_MS) {
59
+ if (pending.size === 0)
60
+ return 0;
61
+ let expired = 0;
62
+ for (const [key, entry] of pending) {
63
+ if (now - entry.at <= timeoutMs)
64
+ continue;
65
+ pending.delete(key);
66
+ marker?.(entry.agentId, entry.recordIds);
67
+ expired += 1;
68
+ }
69
+ return expired;
70
+ }
71
+ /** 诊断/测试:当前 pending 条数。 */
72
+ export function pendingCount() {
73
+ return pending.size;
74
+ }
75
+ /** 测试隔离:清空 pending 与 marker(模块级状态,用例间互扰)。 */
76
+ export function resetAckForTests() {
77
+ pending.clear();
78
+ marker = undefined;
79
+ }
@@ -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
  /**
@@ -31,6 +32,25 @@ import { type OccupancyLedger } from '../util/context-occupancy.js';
31
32
  export declare function buildRecallQuery(messages: Array<{
32
33
  content: unknown;
33
34
  }>, tailMessages?: number, maxChars?: number): string;
35
+ /**
36
+ * §E 压缩感知标记器(memorax-absorb / task_24-26):记录刚完成压缩的会话,
37
+ * pre-step 消费后做一次性增强召回。纯内存状态,CaptureBuffers 同款可单测导出。
38
+ *
39
+ * - `onCompactionEnd`:只认无 error 的 compaction/end(失败的压缩没有把内容逐出上下文);
40
+ * - `consume`:消费即清(一次性增强);容量上限淘汰最旧(积压即异常,回退路径兜底)。
41
+ */
42
+ export declare class PostCompactionTracker {
43
+ private readonly cap;
44
+ private readonly map;
45
+ constructor(cap?: number);
46
+ /** @returns 是否真的置了标记(false = 非 compaction/end 或带 error)。 */
47
+ onCompactionEnd(sessionId: string, data?: {
48
+ error?: unknown;
49
+ }): boolean;
50
+ /** 消费该会话的增强标记(存在即清,返回是否存在)。 */
51
+ consume(sessionId: string): boolean;
52
+ get size(): number;
53
+ }
34
54
  /** 单会话召回统计(悬浮卡信息区数据源;每轮 O(1) 记账,agent/disposed 清理)。
35
55
  * 口径声明:这是"注入统计"而非 bench 的离线 recall@k——运行时没有 ground truth,
36
56
  * 命中率 = hitTurns / injectedTurns。去重语义:全量压制轮计入 hitTurns
@@ -60,4 +80,6 @@ export declare function registerRecall(ctx: Context, cfg: MemoryConfig, stores:
60
80
  l1: L1Store;
61
81
  scenes: Record<'chat' | 'work', SceneStore>;
62
82
  persona: Record<'chat' | 'work', PersonaStore>;
63
- }, logger: MemoryLogger, live: LiveSettingsHandle, modes: SessionModeStore, dataDir: string): RecallHooks;
83
+ }, logger: MemoryLogger, live: LiveSettingsHandle, modes: SessionModeStore, dataDir: string,
84
+ /** 召回使用统计(可选;未装配 = 零成本旁路):进 topN(注入)才算 used。 */
85
+ usage?: RecallUsageStore): RecallHooks;
@@ -1,6 +1,9 @@
1
1
  import { createUserMessage } from '@deepseek-ai/dsh-llm';
2
+ import { createHash } from 'node:crypto';
3
+ import { trace } from '../store/trace.js';
2
4
  import { RecallDedupeStore } from '../store/recall-dedupe.js';
3
5
  import { OccupancyStore } from '../store/occupancy.js';
6
+ import { expirePendingInjections, registerPendingInjection, setInjectionMarker, } from './recall-ack.js';
4
7
  import { scopeFilterOf } from '../workspace.js';
5
8
  import { applyRecallBudget, raceRecallTimeout, RECALL_EMBED_CAP_MS } from '../util/recall-budget.js';
6
9
  import { clearProfileShare, emptyOccupancyLedger, estimateInjectedMessageTokens, estimateStableSectionTokens, recordProfileShare, recordRecallInjection, resetForCompaction, } from '../util/context-occupancy.js';
@@ -34,6 +37,43 @@ export function buildRecallQuery(messages, tailMessages = RECALL_QUERY_TAIL_MESS
34
37
  text = text.slice(-maxChars);
35
38
  return text;
36
39
  }
40
+ /**
41
+ * §E 压缩感知标记器(memorax-absorb / task_24-26):记录刚完成压缩的会话,
42
+ * pre-step 消费后做一次性增强召回。纯内存状态,CaptureBuffers 同款可单测导出。
43
+ *
44
+ * - `onCompactionEnd`:只认无 error 的 compaction/end(失败的压缩没有把内容逐出上下文);
45
+ * - `consume`:消费即清(一次性增强);容量上限淘汰最旧(积压即异常,回退路径兜底)。
46
+ */
47
+ export class PostCompactionTracker {
48
+ cap;
49
+ map = new Map();
50
+ constructor(cap = 64) {
51
+ this.cap = cap;
52
+ }
53
+ /** @returns 是否真的置了标记(false = 非 compaction/end 或带 error)。 */
54
+ onCompactionEnd(sessionId, data) {
55
+ if (!sessionId || data?.error)
56
+ return false;
57
+ this.map.delete(sessionId); // 置尾刷新
58
+ this.map.set(sessionId, true);
59
+ while (this.map.size > this.cap) {
60
+ const oldest = this.map.keys().next().value;
61
+ if (oldest === undefined)
62
+ break;
63
+ this.map.delete(oldest);
64
+ }
65
+ return true;
66
+ }
67
+ /** 消费该会话的增强标记(存在即清,返回是否存在)。 */
68
+ consume(sessionId) {
69
+ if (!this.map.delete(sessionId))
70
+ return false;
71
+ return true;
72
+ }
73
+ get size() {
74
+ return this.map.size;
75
+ }
76
+ }
37
77
  const MEMORY_TOOLS_GUIDE = `<memory-tools-guide>
38
78
  ## 记忆工具调用指南
39
79
 
@@ -64,7 +104,9 @@ export function emptyRecallStats(now = Date.now()) {
64
104
  updatedAt: now,
65
105
  };
66
106
  }
67
- export function registerRecall(ctx, cfg, stores, logger, live, modes, dataDir) {
107
+ export function registerRecall(ctx, cfg, stores, logger, live, modes, dataDir,
108
+ /** 召回使用统计(可选;未装配 = 零成本旁路):进 topN(注入)才算 used。 */
109
+ usage) {
68
110
  /** 召回去重存储(同会话已注入的记忆不再重复注入;写穿持久化,重启不丢)。 */
69
111
  const dedupe = new RecallDedupeStore(dataDir, logger);
70
112
  /** 记忆占用流水(账本迁移写穿;重启后历史会话账目由此复生)。 */
@@ -152,6 +194,21 @@ export function registerRecall(ctx, cfg, stores, logger, live, modes, dataDir) {
152
194
  recallStats.delete(payload.agent.id);
153
195
  occupancyByAgent.delete(payload.agent.id);
154
196
  });
197
+ // §D:绑定实际标记动作 + 超时清扫挂到 pre-step 入口(见注入点注释)。
198
+ setInjectionMarker((agentId, recordIds) => dedupe.mark(agentId, recordIds));
199
+ let ackDegradeWarned = false;
200
+ // §E 压缩感知(memorax-absorb):宿主 `compaction/end`(无 error)→ 该会话下一轮
201
+ // 做一次"定向增强召回"——跳过去重压制、画像立即重注入、占用账本归零;消费即清。
202
+ // 会话级回退路径保留(下方 session-start source='compact' 全量重置),两者幂等。
203
+ // 宿主无压缩计数器(compactionId 为 per-transaction UUID),故不做 generation 对账。
204
+ const postCompaction = new PostCompactionTracker();
205
+ ctx.on('session/event', (session, event) => {
206
+ if (event.type !== 'compaction/end')
207
+ return;
208
+ if (postCompaction.onCompactionEnd(String(session.id ?? session))) {
209
+ logger.info(`[memory] compaction/end:下一轮将做一次增强召回(session=${String(session.id ?? session)})`);
210
+ }
211
+ });
155
212
  // 上下文压缩/清空 → 已注入内容从模型上下文丢失,重置该会话的去重压制
156
213
  // (resume/startup 不重置:历史仍在,已注入的记忆模型还持有)。
157
214
  // 占用账本同步全量归零(宁低勿高;轮级粒度近似)。
@@ -197,6 +254,13 @@ export function registerRecall(ctx, cfg, stores, logger, live, modes, dataDir) {
197
254
  // prepend 注册 + 先 next() 再改写:不劫持其他监听器(dsh-time-context 官方范式)。
198
255
  if (cfg.recall.enabled) {
199
256
  ctx.on('agent/pre-step', async (payload, next) => {
257
+ // §D 超时清扫:超时未确认的注入照旧标记(降级保连续,防 dedupe 失效回归);
258
+ // 正常确认在秒级完成,这里只是兜底。一次性提示。
259
+ const expiredAcks = expirePendingInjections();
260
+ if (expiredAcks > 0 && !ackDegradeWarned) {
261
+ ackDegradeWarned = true;
262
+ logger.warn(`[memory] 注入确认:${expiredAcks} 条 pending 超时未回执,已降级为照常标记(一次性提示)`);
263
+ }
200
264
  const decision = await next();
201
265
  if (decision.kind === 'reject' || payload.signal.aborted)
202
266
  return decision;
@@ -212,6 +276,17 @@ export function registerRecall(ctx, cfg, stores, logger, live, modes, dataDir) {
212
276
  const hasNewUserMessage = decision.messages.some((m) => m.source?.kind === 'user');
213
277
  if (!hasNewUserMessage)
214
278
  return decision;
279
+ // §E 消费增强标记:压缩后下一轮跳过去重压制(预算/档位/门控照常),
280
+ // 画像 TTL 失效立即重注入,占用账本归零。消费即清(一次性)。
281
+ const sessionKey = String(payload.agent.session?.id ?? payload.agent.id);
282
+ const boosted = postCompaction.consume(sessionKey);
283
+ if (boosted) {
284
+ invalidateProfile();
285
+ const led = ledgerFor(payload.agent.id);
286
+ resetForCompaction(led);
287
+ occupancyStore.save(payload.agent.id, led);
288
+ logger.info(`[memory] 压缩后增强召回:本轮跳过去重压制 + 画像重注入(session=${sessionKey})`);
289
+ }
215
290
  const query = buildRecallQuery(payload.messages);
216
291
  // 空查询是退化轮(无用户文本),重置命中信号但不计入统计
217
292
  if (!query) {
@@ -219,6 +294,10 @@ export function registerRecall(ctx, cfg, stores, logger, live, modes, dataDir) {
219
294
  degenerate.lastHits = 0;
220
295
  return decision;
221
296
  }
297
+ // §F 追踪字段:metadata-only 默认(chars+sha256 前 16),captureContent 才存原文
298
+ const queryChars = query.length;
299
+ const querySha = createHash('sha256').update(query).digest('hex').slice(0, 16);
300
+ const queryText = cfg.trace?.captureContent === true ? query.slice(0, 200) : undefined;
222
301
  const st = statFor(payload.agent.id);
223
302
  st.injectedTurns++;
224
303
  st.lastHits = 0;
@@ -237,6 +316,11 @@ export function registerRecall(ctx, cfg, stores, logger, live, modes, dataDir) {
237
316
  if (hits === undefined) {
238
317
  st.timeouts++;
239
318
  logger.warn('[memory] 召回超时,跳过本轮注入(不阻塞对话)');
319
+ trace({
320
+ kind: 'recall_turn', ts: Date.now(), sessionId: sessionKey, queryChars, querySha, queryText,
321
+ hitIds: [], hitScores: [], injectedIds: [], suppressedCount: 0,
322
+ durationMs: Date.now() - searchStart, outcome: 'timeout',
323
+ });
240
324
  return decision;
241
325
  }
242
326
  st.lastDurationMs = Date.now() - searchStart;
@@ -267,7 +351,8 @@ export function registerRecall(ctx, cfg, stores, logger, live, modes, dataDir) {
267
351
  }
268
352
  // 召回去重:同会话已注入过的记录不再重复注入(模型上下文已持有,省 token)。
269
353
  // 纯过滤——剩几条注几条,全量压制(0 条新鲜命中)是正确状态而非未命中。
270
- const seen = dedupe.seen(payload.agent.id);
354
+ // §E:压缩后增强轮跳过压制(压缩把已注入内容逐出了模型上下文)。
355
+ const seen = boosted ? new Set() : dedupe.seen(payload.agent.id);
271
356
  const fresh = scoped.filter((h) => !seen.has(h.id));
272
357
  const suppressed = scoped.length - fresh.length;
273
358
  st.suppressedRecalls += suppressed;
@@ -279,13 +364,29 @@ export function registerRecall(ctx, cfg, stores, logger, live, modes, dataDir) {
279
364
  st.hitTurns++;
280
365
  st.totalHits += fresh.length;
281
366
  }
282
- if (fresh.length === 0)
367
+ if (fresh.length === 0) {
368
+ // §F 追踪:全量压制轮——零注入是正确状态,如实记录
369
+ trace({
370
+ kind: 'recall_turn', ts: Date.now(), sessionId: sessionKey, queryChars, querySha, queryText,
371
+ hitIds: scoped.map((h) => h.id), hitScores: scoped.map((h) => Number(h.score.toFixed(4))),
372
+ injectedIds: [], suppressedCount: scoped.length - fresh.length,
373
+ durationMs: Date.now() - searchStart, outcome: 'suppressed',
374
+ });
375
+ usage?.recordAttempt(scoped.map((h) => h.id), []);
283
376
  return decision;
377
+ }
284
378
  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 });
285
- if (lines.length === 0)
379
+ if (lines.length === 0) {
380
+ // §F 追踪:预算清零轮(截断后无完整行)
381
+ trace({
382
+ kind: 'recall_turn', ts: Date.now(), sessionId: sessionKey, queryChars, querySha, queryText,
383
+ hitIds: fresh.map((h) => h.id), hitScores: fresh.map((h) => Number(h.score.toFixed(4))),
384
+ injectedIds: [], suppressedCount: scoped.length - fresh.length,
385
+ durationMs: Date.now() - searchStart, outcome: 'suppressed',
386
+ });
387
+ usage?.recordAttempt(fresh.map((h) => h.id), []);
286
388
  return decision;
287
- // 预算截断只丢尾部(前缀保留):实际注入 = fresh 的前 lines.length 条——只标记模型真实看到的
288
- dedupe.mark(payload.agent.id, fresh.slice(0, lines.length).map((h) => h.id));
389
+ }
289
390
  st.lastHits = lines.length;
290
391
  const text = [
291
392
  '<relevant-memories>',
@@ -303,6 +404,20 @@ export function registerRecall(ctx, cfg, stores, logger, live, modes, dataDir) {
303
404
  // "上下文注入"(专用"跨会话召回"标题仅留给 session-reference 来源)
304
405
  source: { kind: 'plugin', plugin: 'memory', form: 'recall' },
305
406
  });
407
+ // §D 注入确认(memorax-absorb):宿主接受 enter 后,注入消息以原样
408
+ // `user/message` 进入会话日志(message.id 稳定保留)——capture 侧观察到
409
+ // 该 id 才真正 dedupe.mark(recall-ack 两段式)。这里只登记 pending,
410
+ // 未确认不标记:被覆盖/取消的注入不该压制这些记忆(下轮可重注)。
411
+ registerPendingInjection(payload.agent.id, injection.id, fresh.slice(0, lines.length).map((h) => h.id));
412
+ // §F 追踪:注入轮
413
+ trace({
414
+ kind: 'recall_turn', ts: Date.now(), sessionId: sessionKey, queryChars, querySha, queryText,
415
+ hitIds: scoped.map((h) => h.id), hitScores: scoped.map((h) => Number(h.score.toFixed(4))),
416
+ injectedIds: fresh.slice(0, lines.length).map((h) => h.id), suppressedCount: scoped.length - fresh.length,
417
+ durationMs: Date.now() - searchStart, outcome: 'injected',
418
+ });
419
+ // 使用统计:候选=scoped(尝试),进 topN(注入)=used
420
+ usage?.recordAttempt(scoped.map((h) => h.id), fresh.slice(0, lines.length).map((h) => h.id));
306
421
  // 入账在成功构造注入消息之后、返回 enter 之前——任何前置抛错路径账目零扰动
307
422
  const led = ledgerFor(payload.agent.id);
308
423
  recordRecallInjection(led, text.length);
package/dist/index.d.ts CHANGED
@@ -23,10 +23,21 @@ export declare const Config: import("@deepseek-ai/schemastery").default<Schemast
23
23
  enabled: import("@deepseek-ai/schemastery").default<boolean, boolean>;
24
24
  stripCodeBlocks: import("@deepseek-ai/schemastery").default<boolean, boolean>;
25
25
  maxMessageChars: import("@deepseek-ai/schemastery").default<number, number>;
26
+ redactSecrets: import("@deepseek-ai/schemastery").default<boolean, boolean>;
26
27
  }>, Schemastery.ObjectT<{
27
28
  enabled: import("@deepseek-ai/schemastery").default<boolean, boolean>;
28
29
  stripCodeBlocks: import("@deepseek-ai/schemastery").default<boolean, boolean>;
29
30
  maxMessageChars: import("@deepseek-ai/schemastery").default<number, number>;
31
+ redactSecrets: import("@deepseek-ai/schemastery").default<boolean, boolean>;
32
+ }>>;
33
+ trace: import("@deepseek-ai/schemastery").default<Schemastery.ObjectS<{
34
+ enabled: import("@deepseek-ai/schemastery").default<boolean, boolean>;
35
+ retentionDays: import("@deepseek-ai/schemastery").default<number, number>;
36
+ captureContent: import("@deepseek-ai/schemastery").default<boolean, boolean>;
37
+ }>, Schemastery.ObjectT<{
38
+ enabled: import("@deepseek-ai/schemastery").default<boolean, boolean>;
39
+ retentionDays: import("@deepseek-ai/schemastery").default<number, number>;
40
+ captureContent: import("@deepseek-ai/schemastery").default<boolean, boolean>;
30
41
  }>>;
31
42
  extract: import("@deepseek-ai/schemastery").default<Schemastery.ObjectS<{
32
43
  enabled: import("@deepseek-ai/schemastery").default<boolean, boolean>;
@@ -307,10 +318,21 @@ export declare const Config: import("@deepseek-ai/schemastery").default<Schemast
307
318
  enabled: import("@deepseek-ai/schemastery").default<boolean, boolean>;
308
319
  stripCodeBlocks: import("@deepseek-ai/schemastery").default<boolean, boolean>;
309
320
  maxMessageChars: import("@deepseek-ai/schemastery").default<number, number>;
321
+ redactSecrets: import("@deepseek-ai/schemastery").default<boolean, boolean>;
310
322
  }>, Schemastery.ObjectT<{
311
323
  enabled: import("@deepseek-ai/schemastery").default<boolean, boolean>;
312
324
  stripCodeBlocks: import("@deepseek-ai/schemastery").default<boolean, boolean>;
313
325
  maxMessageChars: import("@deepseek-ai/schemastery").default<number, number>;
326
+ redactSecrets: import("@deepseek-ai/schemastery").default<boolean, boolean>;
327
+ }>>;
328
+ trace: import("@deepseek-ai/schemastery").default<Schemastery.ObjectS<{
329
+ enabled: import("@deepseek-ai/schemastery").default<boolean, boolean>;
330
+ retentionDays: import("@deepseek-ai/schemastery").default<number, number>;
331
+ captureContent: import("@deepseek-ai/schemastery").default<boolean, boolean>;
332
+ }>, Schemastery.ObjectT<{
333
+ enabled: import("@deepseek-ai/schemastery").default<boolean, boolean>;
334
+ retentionDays: import("@deepseek-ai/schemastery").default<number, number>;
335
+ captureContent: import("@deepseek-ai/schemastery").default<boolean, boolean>;
314
336
  }>>;
315
337
  extract: import("@deepseek-ai/schemastery").default<Schemastery.ObjectS<{
316
338
  enabled: import("@deepseek-ai/schemastery").default<boolean, boolean>;
package/dist/index.js CHANGED
@@ -36,12 +36,15 @@ import { SlotStore } from './store/slots.js';
36
36
  import { registerMemoryTools } from './tools/index.js';
37
37
  import { registerSlotTools } from './tools/slots.js';
38
38
  import { registerSlotRecall } from './hooks/slot-recall.js';
39
+ import { RoomRegistryStore } from './store/rooms-registry.js';
40
+ import { RecallUsageStore } from './store/recall-usage.js';
39
41
  import { registerSlotsProjection } from './projection/slots.js';
40
42
  import { errDetail, withFileLog } from './util/filelog.js';
41
43
  import { buildRouteChain, resolveModelRoute, invalidateEffortCache } from './llm.js';
42
44
  import { effectiveCfg } from './pipeline/runner.js';
43
45
  import { pendingPathFor } from './store/pending.js';
44
46
  import { initTokenCost, resetTokenCost } from './token-cost.js';
47
+ import { initTraceStore } from './store/trace.js';
45
48
  export const name = 'dsh-memory-plugin';
46
49
  /**
47
50
  * 硬依赖:蒸馏要用 llm,工具注册要用 tools,召回注入要用 systemPrompt。
@@ -134,6 +137,9 @@ export async function apply(ctx, config) {
134
137
  const embed = initial.svc;
135
138
  const db = new MemoryDb(path.join(dataDir, 'memory.db'), initial.dims, logger);
136
139
  initTokenCost(db, config.tokenCost.retentionDays);
140
+ // §F 追踪(trace.enabled=false 时保持未初始化,埋点零副作用)
141
+ if (config.trace?.enabled)
142
+ initTraceStore(dataDir, { retentionDays: config.trace.retentionDays });
137
143
  // 插件卸载时关闭连接(WAL 落盘),注册一次即可
138
144
  ctx.effect(() => () => db.close());
139
145
  let dbInit = { needsReindex: false };
@@ -151,6 +157,7 @@ export async function apply(ctx, config) {
151
157
  }
152
158
  }
153
159
  const stores = {
160
+ usage: undefined, // 装配于下方(依赖 dataDir 就绪)
154
161
  l0: new L0Store(dataDir, db, embed, logger),
155
162
  l1: new L1Store(dataDir, db, embed, config.recall.strategy, logger, config.recall.decayHalfLifeDays,
156
163
  // §D 第 3 路:图谱回链。图谱不可用时 searchNodes 自带 no-op,不影响双路。
@@ -343,16 +350,23 @@ export async function apply(ctx, config) {
343
350
  backend = await createMemoryBackend({ dbPath: path.join(dataDir, 'memory.db'), dimensions: initial.dims, logger }, backend);
344
351
  }
345
352
  stores.backend = backend;
353
+ // Room 注册表(分类管理 beta.4;损坏降级为纯自生长目录)
354
+ const roomRegistry = new RoomRegistryStore(dataDir, logger);
355
+ await roomRegistry.init();
346
356
  const ruminate = storageOk && !db.isDegraded() && ruminateFile
347
- ? new RuminateController(ctx, config, runner, stores, logger, live, ruminateFile)
357
+ ? new RuminateController(ctx, config, runner, stores, logger, live, ruminateFile, roomRegistry)
348
358
  : undefined;
349
359
  let flushL0;
350
360
  if (storageOk) {
351
361
  flushL0 = registerCapture(ctx, config, runner, stores.l0, logger, live, modes);
352
362
  }
353
- const recall = registerRecall(ctx, config, stores, logger, live, modes, dataDir);
363
+ // 召回使用统计(进 topN 才算 used;热路径只碰内存,去抖落盘)
364
+ const recallUsage = new RecallUsageStore(dataDir, logger);
365
+ await recallUsage.init();
366
+ stores.usage = recallUsage;
367
+ const recall = registerRecall(ctx, config, stores, logger, live, modes, dataDir, recallUsage);
354
368
  runner.setAfterRun(recall.invalidateProfile);
355
- registerMemoryTools(ctx, config, stores, logger, modes, live, ruminate);
369
+ registerMemoryTools(ctx, config, stores, logger, modes, live, ruminate, storageOk ? roomRegistry : undefined);
356
370
  // 激活槽位(active slot):工具面 + 常驻注入 + 服务端投影(均走 ctx.effect,可撤销)
357
371
  registerSlotTools(ctx, config, stores.slots, logger, modes, live, stores.l1);
358
372
  const slotRecall = registerSlotRecall(ctx, config, stores.slots, logger, live);
@@ -371,7 +385,7 @@ export async function apply(ctx, config) {
371
385
  runnerView: (sid, mode) => runner.sessionView(sid, mode),
372
386
  l0Count: (sid) => stores.l0.countBySession(sid),
373
387
  capabilities: () => db.getCapabilities(),
374
- }, ruminate);
388
+ }, ruminate, storageOk ? roomRegistry : undefined);
375
389
  // bench 控制服务(config.benchControl 门控,默认关):仅基准/调试部署注册,
376
390
  // 供同进程的 bench-runner lifecycle 赛道触发 rebuild / 设置会话档位
377
391
  // (宿主侧 RPC 无 call(),见 bench-control.ts)
@@ -10,6 +10,11 @@
10
10
  * 已是 string 的变量收窄成 `never`(该分支内无法再 `.slice`,已在 wing-backfill 踩过)。
11
11
  */
12
12
  export declare function isWingId(v: unknown): boolean;
13
+ /**
14
+ * 两级 Room slug(`major` 或 `major/minor`,各段满足 isTag;至多一个 slash)。
15
+ * Room 注册表/归类写回的 tags 域;与 rooms-registry.ts 同一事实源(就近定义避免依赖环)。
16
+ */
17
+ export declare function isRoomSlug(v: unknown): boolean;
13
18
  /** 同为布尔谓词(理由见 isWingId 注释)。 */
14
19
  export declare function isTag(v: unknown): boolean;
15
20
  /**
@@ -31,6 +31,16 @@ export function isWingId(v) {
31
31
  }
32
32
  /** 标签(Room 的构成单元)校验:小写字母数字连字符,1-32 字符。 */
33
33
  const TAG_RE = /^[a-z0-9][a-z0-9-]{0,31}$/;
34
+ /**
35
+ * 两级 Room slug(`major` 或 `major/minor`,各段满足 isTag;至多一个 slash)。
36
+ * Room 注册表/归类写回的 tags 域;与 rooms-registry.ts 同一事实源(就近定义避免依赖环)。
37
+ */
38
+ export function isRoomSlug(v) {
39
+ if (typeof v !== 'string')
40
+ return false;
41
+ const parts = v.split('/');
42
+ return parts.length <= 2 && parts.every((p) => TAG_RE.test(p));
43
+ }
34
44
  /** 同为布尔谓词(理由见 isWingId 注释)。 */
35
45
  export function isTag(v) {
36
46
  return typeof v === 'string' && TAG_RE.test(v);
@@ -47,7 +57,7 @@ export function normTags(v, max = 3) {
47
57
  if (typeof raw !== 'string')
48
58
  continue;
49
59
  const t = raw.trim().toLowerCase();
50
- if (!isTag(t) || out.includes(t))
60
+ if (!(isTag(t) || isRoomSlug(t)) || out.includes(t))
51
61
  continue;
52
62
  out.push(t);
53
63
  if (out.length >= max)
@@ -8,6 +8,10 @@ export interface ExtractionResult {
8
8
  skipped: boolean;
9
9
  sceneName: string;
10
10
  newRecords: MemoryRecord[];
11
+ /** §F 追踪捎带:与本轮 l1_receipts 同源的 runId(可交叉审计);早退分支缺省。 */
12
+ runId?: string;
13
+ /** §F 追踪捎带:六值决策词表聚合;早退分支缺省。 */
14
+ byKind?: Record<string, number>;
11
15
  }
12
16
  /** 分族 checkpoint 桶(活引用,改动由调用方 save 落盘)。 */
13
17
  export type FamilyStates = Record<MemoryFamily, MemoryState>;
@@ -262,13 +262,20 @@ anchorMap) {
262
262
  // runId 提到块外:§C 冻结对要沿用同一批次 id,使「这一轮判了什么」与
263
263
  // 「这一轮冻结了哪一对」在记忆库侧可交叉审计(findings.md §3 / §9)。
264
264
  const runId = newRunId();
265
+ let byKind;
265
266
  {
266
267
  const items = extracted.map((m, i) => ({
267
268
  recordId: m.record_id,
268
269
  candidateIds: (matches[i]?.candidates ?? []).map((c) => c.id),
269
270
  action: byRecord.get(m.record_id)?.action,
270
271
  }));
271
- persistReceiptsSafely((rows) => store.recordReceipts(rows), buildReceipts(runId, new Date().toISOString(), items), logger);
272
+ const rows = buildReceipts(runId, new Date().toISOString(), items);
273
+ persistReceiptsSafely((rows) => store.recordReceipts(rows), rows, logger);
274
+ // §F 追踪捎带:六值决策词表聚合(与凭证同源,runner 侧并入 distill_run 事件)
275
+ byKind = rows.reduce((acc, r) => {
276
+ acc[r.kind] = (acc[r.kind] ?? 0) + 1;
277
+ return acc;
278
+ }, {});
272
279
  }
273
280
  // ── Step 3: 应用决策(官方语义:新记录追加进事实源,被替换目标只从检索库删除) ──
274
281
  // 只按需取决策涉及的记录(候选 + 目标 id 并集),避免每轮全表扫描
@@ -521,7 +528,7 @@ anchorMap) {
521
528
  }
522
529
  markExtracted(states, mode, lastScene);
523
530
  logger.info(`[memory] L1 抽取完成(mode=${mode}):消息 ${pending.length} 条,抽取 ${extracted.length} 条,去重后新增 ${added.length} 条(取代退场 ${supersededBy.size} 条,chat=${addedByFamily.chat}/work=${addedByFamily.work}),累计 chat=${states.chat.totalExtracted}/work=${states.work.totalExtracted}`);
524
- return { stored: added.length, skipped: false, sceneName: lastScene, newRecords: added };
531
+ return { stored: added.length, skipped: false, sceneName: lastScene, newRecords: added, runId, byKind };
525
532
  }
526
533
  /** auto 档取最近活跃的族 checkpoint(情境链/计数锚点)。 */
527
534
  function activeState(states) {
@@ -1,4 +1,5 @@
1
1
  import { callLLM, parseJson } from '../llm.js';
2
+ import { CONTENT_IS_DATA_CLAUSE, DATA_DELIMITER_NOTE } from '../prompts/boundary.js';
2
3
  /** 三态判定。顺序即优先级(报告与面板按此排序)。 */
3
4
  export const RECONCILE_STATES = ['contradicted', 'supported', 'unverifiable'];
4
5
  const DEFAULT_MAX_RECORDS = 50;
@@ -46,6 +47,8 @@ export function buildReconcilePrompt(memory, events, maxEvidenceChars) {
46
47
  const system = [
47
48
  '你是一个记忆核对员。给你一条「记忆」和它在会话里的「原文证据」,判断这条记忆是否与原文相符。',
48
49
  '',
50
+ CONTENT_IS_DATA_CLAUSE,
51
+ '',
49
52
  '只输出一个 JSON 对象,不要解释、不要 Markdown 代码块:',
50
53
  '{"state": "supported|contradicted|unverifiable", "reason": "一句话理由", "quote": "支撑你判断的原文片段"}',
51
54
  '',
@@ -57,6 +60,7 @@ export function buildReconcilePrompt(memory, events, maxEvidenceChars) {
57
60
  '- 拿不准就选 unverifiable。**不要猜。**',
58
61
  ].join('\n');
59
62
  const user = [
63
+ DATA_DELIMITER_NOTE,
60
64
  `记忆:${memory.text}`,
61
65
  '',
62
66
  '原文证据:',
@@ -66,6 +66,8 @@ export declare class RuminateController {
66
66
  private sessions;
67
67
  private totalL1;
68
68
  private pendingFile;
69
+ /** Room 注册表(可选;孤儿候选预标记的合并词表来源)。 */
70
+ private readonly roomRegistry;
69
71
  /** 记忆后端:未注入时包 l1(进程内,行为与改造前等价)。 */
70
72
  private backend;
71
73
  constructor(ctx: Context, cfg: MemoryConfig, runner: Pick<MemoryRunner, 'enqueue' | 'states'>, stores: {
@@ -75,7 +77,15 @@ export declare class RuminateController {
75
77
  scenes: Record<MemoryFamily, SceneStore>;
76
78
  persona: Record<MemoryFamily, PersonaStore>;
77
79
  state: StateStore;
78
- }, logger: MemoryLogger, live: LiveSettingsHandle, pendingFile: string);
80
+ }, logger: MemoryLogger, live: LiveSettingsHandle, pendingFile: string,
81
+ /** Room 注册表(可选;孤儿候选预标记的合并词表来源)。 */
82
+ roomRegistry?: {
83
+ listActive(): Array<{
84
+ slug: string;
85
+ label?: string;
86
+ description?: string;
87
+ }>;
88
+ });
79
89
  /** 状态快照 */
80
90
  getStatus(): RuminateStatus;
81
91
  /**