dsh-prime-memory 0.18.1 → 0.18.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 (52) hide show
  1. package/CHANGELOG.en.md +22 -0
  2. package/CHANGELOG.ja.md +22 -0
  3. package/CHANGELOG.ko.md +22 -0
  4. package/CHANGELOG.md +22 -0
  5. package/README.en.md +4 -0
  6. package/README.ja.md +4 -0
  7. package/README.ko.md +4 -0
  8. package/README.md +4 -0
  9. package/cordis.patch.yml +6 -0
  10. package/dist/client.js +36 -9
  11. package/dist/config.d.ts +37 -0
  12. package/dist/config.js +7 -0
  13. package/dist/contract.d.ts +46 -0
  14. package/dist/hooks/capture-recovery.d.ts +59 -0
  15. package/dist/hooks/capture-recovery.js +88 -0
  16. package/dist/hooks/capture.js +110 -1
  17. package/dist/hooks/recall-ack.d.ts +48 -0
  18. package/dist/hooks/recall-ack.js +79 -0
  19. package/dist/hooks/recall.d.ts +29 -0
  20. package/dist/hooks/recall.js +131 -11
  21. package/dist/hooks/slot-recall.js +3 -2
  22. package/dist/index.d.ts +22 -0
  23. package/dist/index.js +4 -0
  24. package/dist/pipeline/l1.d.ts +4 -0
  25. package/dist/pipeline/l1.js +9 -2
  26. package/dist/pipeline/reconcile.js +4 -0
  27. package/dist/pipeline/relabel.js +1 -1
  28. package/dist/pipeline/runner.js +62 -0
  29. package/dist/projection/slots.js +4 -2
  30. package/dist/prompts/boundary.d.ts +17 -0
  31. package/dist/prompts/boundary.js +20 -0
  32. package/dist/prompts/graph-projection.js +4 -1
  33. package/dist/prompts/l1-dedup.d.ts +3 -3
  34. package/dist/prompts/l1-dedup.js +9 -1
  35. package/dist/prompts/l1-extraction.d.ts +3 -3
  36. package/dist/prompts/l1-extraction.js +12 -0
  37. package/dist/prompts/persona.js +6 -0
  38. package/dist/prompts/scene.js +7 -1
  39. package/dist/stats.js +9 -0
  40. package/dist/store/evidence-source.js +15 -1
  41. package/dist/store/l0.d.ts +4 -0
  42. package/dist/store/l0.js +8 -0
  43. package/dist/store/memory-worker.js +1 -1
  44. package/dist/store/sqlite.d.ts +12 -0
  45. package/dist/store/sqlite.js +37 -0
  46. package/dist/store/trace.d.ts +39 -0
  47. package/dist/store/trace.js +149 -0
  48. package/dist/tools/index.js +25 -1
  49. package/dist/types.d.ts +17 -0
  50. package/dist/util/redact.d.ts +27 -0
  51. package/dist/util/redact.js +228 -0
  52. package/package.json +1 -1
@@ -6,7 +6,32 @@
6
6
  */
7
7
  import { randomBytes } from 'node:crypto';
8
8
  import { blocksToText } from '../util/text.js';
9
+ import { redactSecrets } from '../util/redact.js';
9
10
  import { sanitizeText, shouldCaptureL0, stripCodeBlocks } from '../util/sanitize.js';
11
+ import { foldRecoverableTurns, readPersistedEventsViaServices } from './capture-recovery.js';
12
+ import { confirmInjectionByMessageId } from './recall-ack.js';
13
+ /** §C 一次性告警标记:脱敏自身异常时放行原文,只提示一次(不阻断捕获)。 */
14
+ let redactWarnedOnce = false;
15
+ /**
16
+ * §C 捕获单点脱敏(memorax-absorb):`capture.redactSecrets !== false` 时把
17
+ * 8 类密钥替换为 `[REDACTED:<KIND>]`。位置在 sanitize 之后、L0/蒸馏分发之前——
18
+ * 同一份 messages 覆盖 L0 JSONL/L0 SQLite/蒸馏输入三条下游,无分裂态。
19
+ * 红线:脱敏失败放行原文 + 一次性 warn(不阻断捕获)。
20
+ */
21
+ function redactContent(content, cfg, logger) {
22
+ if (cfg.capture?.redactSecrets === false)
23
+ return content;
24
+ try {
25
+ return redactSecrets(content).text;
26
+ }
27
+ catch (err) {
28
+ if (!redactWarnedOnce) {
29
+ redactWarnedOnce = true;
30
+ logger.warn(`[memory] 脱敏失败,本轮放行原文(一次性提示): ${err instanceof Error ? err.message : String(err)}`);
31
+ }
32
+ return content;
33
+ }
34
+ }
10
35
  /**
11
36
  * 需要进缓冲的事件类型。流式 chunk(text-delta/reasoning 等)一秒钟可达数百条,
12
37
  * 缓冲它们会把 MAX_BUFFER 撑爆、把轮次头部(turn/start + user 消息)裁掉——
@@ -70,6 +95,8 @@ export function registerCapture(ctx, cfg, runner, l0, logger, live, modes) {
70
95
  return;
71
96
  const startFloor = Date.now();
72
97
  const buffers = new CaptureBuffers();
98
+ // task_8 一次性提示标记:宿主读日志服务不可得时只提示一次(防每次 resume 刷屏)
99
+ let warnedRecoveryUnavailable = false;
73
100
  // L0 即时落盘链:turn/end 立刻写,不被蒸馏队列(慢 LLM 调用)阻塞;
74
101
  // 串行化保证同轮次顺序与单次写入(进程退出时排队中的 L0 不再依赖蒸馏完成)
75
102
  let l0Queue = Promise.resolve();
@@ -79,6 +106,15 @@ export function registerCapture(ctx, cfg, runner, l0, logger, live, modes) {
79
106
  if (!s.enabled || !s.capture)
80
107
  return;
81
108
  const sid = String(session.id ?? session);
109
+ // §D 日志回执(memorax-absorb):注入消息以原样 user/message 入日志时在此确认,
110
+ // 才真正 dedupe.mark。**必须置于 source.kind 过滤之前**——过滤在
111
+ // turnEventsToMessages 内且只放行 kind='user',注入消息(kind='plugin:memory')
112
+ // 走不到那里;也置于 off 档返回之前(确认与档位无关,且代价是一次 Map 查找)。
113
+ if (event.type === 'user/message') {
114
+ const mid = event.data.id;
115
+ if (typeof mid === 'string' && mid)
116
+ confirmInjectionByMessageId(mid);
117
+ }
82
118
  // off 档:本会话对记忆系统完全隐身(不缓冲、不写 L0、不蒸馏)
83
119
  if (modes.get(sid) === 'off')
84
120
  return;
@@ -124,6 +160,78 @@ export function registerCapture(ctx, cfg, runner, l0, logger, live, modes) {
124
160
  logger.warn(`[memory] session/event 处理失败: ${err instanceof Error ? err.message : String(err)}`);
125
161
  }
126
162
  });
163
+ // ── §A 崩溃恢复(memorax-absorb Wave 1 / task_7):resume 时对账宿主持久化日志,
164
+ // 补收 (L0 水位线, 末尾] 内 bracket 完整的尾轮(≤2 轮)——崩溃轮在缓冲丢失后
165
+ // 仍可从持久化层找回(persistence 会给孤儿轮补 interrupted 闭合,探针② 实证 98 处)。
166
+ // fail-open 纪律:任何异常只 warn,不阻塞会话启动;幂等 = 水位线 + 逐 turn 存在性检查。
167
+ ctx.on('agent/session-start', (payload) => {
168
+ try {
169
+ if (payload.source !== 'resume')
170
+ return;
171
+ const s = live.get();
172
+ if (!s.enabled || !s.capture)
173
+ return;
174
+ const session = payload.agent?.session;
175
+ const sid = session?.id !== undefined && session.id !== null ? String(session.id) : '';
176
+ if (!sid)
177
+ return;
178
+ const mode = modes.get(sid);
179
+ if (mode === 'off')
180
+ return; // off 档对记忆系统完全隐身,对齐现行为
181
+ let events = session?.events;
182
+ if (!events || events.length === 0) {
183
+ // task_8 降级链:events 不可得(宿主版本偏差)时依次试
184
+ // ctx.sessionQuery.readSession / ctx.sessionPersistence.readFrom(守卫探针式)
185
+ const get = ctx.get;
186
+ const svcs = typeof get === 'function'
187
+ ? {
188
+ sessionQuery: get.call(ctx, 'sessionQuery'),
189
+ sessionPersistence: get.call(ctx, 'sessionPersistence'),
190
+ }
191
+ : {};
192
+ const degraded = readPersistedEventsViaServices(svcs, sid);
193
+ if (degraded === undefined) {
194
+ if (!warnedRecoveryUnavailable) {
195
+ warnedRecoveryUnavailable = true;
196
+ logger.info('[memory] resume 恢复:宿主读日志服务不可得,跳过对账(一次性提示)');
197
+ }
198
+ return;
199
+ }
200
+ events = degraded;
201
+ }
202
+ const watermark = l0.maxCapturedTurn(sid);
203
+ const brackets = foldRecoverableTurns(events, watermark);
204
+ if (brackets.length === 0)
205
+ return;
206
+ let recovered = 0;
207
+ let skipped = 0;
208
+ let total = 0;
209
+ for (const bracket of brackets) {
210
+ if (l0.hasAnyL0Message(sid, bracket.turn)) {
211
+ // 部分写残行:水位线只判"整轮无",存在性检查兜底——整轮跳过,宁少收不重复
212
+ skipped += 1;
213
+ continue;
214
+ }
215
+ const messages = turnEventsToMessages(bracket.events, cfg, logger, sid, bracket.turn);
216
+ if (messages.length === 0)
217
+ continue;
218
+ recovered += 1;
219
+ total += messages.length;
220
+ const n = messages.length;
221
+ l0Queue = l0Queue
222
+ .then(() => l0.append(sid, messages))
223
+ .then(() => logger.info(`[memory] L0 恢复落盘 ${n} 条`))
224
+ .catch((err) => logger.warn(`[memory] L0 恢复落盘失败: ${err instanceof Error ? err.message : String(err)}`));
225
+ runner.enqueue(sid, messages, mode);
226
+ }
227
+ if (recovered > 0 || skipped > 0) {
228
+ logger.info(`[memory] resume 对账:水位线 ${watermark ?? '无'} → 恢复 ${recovered} 轮 ${total} 条,幂等跳过 ${skipped} 轮(session=${sid})`);
229
+ }
230
+ }
231
+ catch (err) {
232
+ logger.warn(`[memory] resume 恢复失败(不阻塞会话启动): ${err instanceof Error ? err.message : String(err)}`);
233
+ }
234
+ });
127
235
  // 冲刷 = 等待串行链排空(链上每环自带 catch,永不 reject)。
128
236
  // 注:极小概率在 await 期间又入队的新消息会落到链尾——其 JSONL 事实源照写、
129
237
  // DB 写入由 upsert 内部兜底(关库后 warn),下次重建自愈。
@@ -190,7 +298,7 @@ function turnEventsToMessages(events, cfg, logger, sessionId, turn) {
190
298
  logger.info(`[memory] L0 跳过非用户来源消息(source.kind=${msg.source?.kind ?? 'none'})`);
191
299
  continue;
192
300
  }
193
- const content = sanitizeText(blocksToText(msg.content));
301
+ const content = redactContent(sanitizeText(blocksToText(msg.content)), cfg, logger);
194
302
  if (shouldCaptureL0(content)) {
195
303
  const anchor = { sessionId, turn };
196
304
  if (currentStep !== undefined)
@@ -203,6 +311,7 @@ function turnEventsToMessages(events, cfg, logger, sessionId, turn) {
203
311
  let content = sanitizeText(blocksToText(data.message?.content));
204
312
  if (cfg.capture.stripCodeBlocks)
205
313
  content = stripCodeBlocks(content);
314
+ content = redactContent(content, cfg, logger);
206
315
  if (shouldCaptureL0(content)) {
207
316
  // 事件自带 turn/step 优先(fold 只服务于不带该字段的事件类型)
208
317
  const evTurn = typeof data.turn === 'number' && Number.isFinite(data.turn) ? data.turn : turn;
@@ -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
+ }
@@ -28,9 +28,38 @@ import { type OccupancyLedger } from '../util/context-occupancy.js';
28
28
  * 从会话消息构建召回查询(纯函数):末尾 N 条 + 总长截断,空输入返回空串。
29
29
  * 全史拼接会让 MATCH 表达式随会话长度线性膨胀(整会话累计二次方成本)。
30
30
  */
31
+ /**
32
+ * 本插件 recall 注入的署名判据(v4 `plugin:memory` 与 v3 旧行双形状;宿主迁移负责
33
+ * 升格旧行)。导出供双形状单测钉住——估算函数(estimateRecallFromStorage /
34
+ * estimateRecallTokens)对旧形状漏判会让"记忆召回份额"静默归零。
35
+ */
36
+ export declare function isOwnRecallSource(src: {
37
+ kind?: unknown;
38
+ plugin?: unknown;
39
+ form?: unknown;
40
+ } | undefined | null): boolean;
31
41
  export declare function buildRecallQuery(messages: Array<{
32
42
  content: unknown;
33
43
  }>, tailMessages?: number, maxChars?: number): string;
44
+ /**
45
+ * §E 压缩感知标记器(memorax-absorb / task_24-26):记录刚完成压缩的会话,
46
+ * pre-step 消费后做一次性增强召回。纯内存状态,CaptureBuffers 同款可单测导出。
47
+ *
48
+ * - `onCompactionEnd`:只认无 error 的 compaction/end(失败的压缩没有把内容逐出上下文);
49
+ * - `consume`:消费即清(一次性增强);容量上限淘汰最旧(积压即异常,回退路径兜底)。
50
+ */
51
+ export declare class PostCompactionTracker {
52
+ private readonly cap;
53
+ private readonly map;
54
+ constructor(cap?: number);
55
+ /** @returns 是否真的置了标记(false = 非 compaction/end 或带 error)。 */
56
+ onCompactionEnd(sessionId: string, data?: {
57
+ error?: unknown;
58
+ }): boolean;
59
+ /** 消费该会话的增强标记(存在即清,返回是否存在)。 */
60
+ consume(sessionId: string): boolean;
61
+ get size(): number;
62
+ }
34
63
  /** 单会话召回统计(悬浮卡信息区数据源;每轮 O(1) 记账,agent/disposed 清理)。
35
64
  * 口径声明:这是"注入统计"而非 bench 的离线 recall@k——运行时没有 ground truth,
36
65
  * 命中率 = hitTurns / injectedTurns。去重语义:全量压制轮计入 hitTurns
@@ -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';
@@ -27,6 +30,14 @@ const RECALL_QUERY_MAX_CHARS = 2_000;
27
30
  * 从会话消息构建召回查询(纯函数):末尾 N 条 + 总长截断,空输入返回空串。
28
31
  * 全史拼接会让 MATCH 表达式随会话长度线性膨胀(整会话累计二次方成本)。
29
32
  */
33
+ /**
34
+ * 本插件 recall 注入的署名判据(v4 `plugin:memory` 与 v3 旧行双形状;宿主迁移负责
35
+ * 升格旧行)。导出供双形状单测钉住——估算函数(estimateRecallFromStorage /
36
+ * estimateRecallTokens)对旧形状漏判会让"记忆召回份额"静默归零。
37
+ */
38
+ export function isOwnRecallSource(src) {
39
+ return !!src && src.form === 'recall' && (src.kind === 'plugin:memory' || (src.kind === 'plugin' && src.plugin === 'memory'));
40
+ }
30
41
  export function buildRecallQuery(messages, tailMessages = RECALL_QUERY_TAIL_MESSAGES, maxChars = RECALL_QUERY_MAX_CHARS) {
31
42
  const tail = messages.slice(-tailMessages);
32
43
  let text = tail.map((m) => blocksToText(m.content)).join(' ').trim();
@@ -34,6 +45,43 @@ export function buildRecallQuery(messages, tailMessages = RECALL_QUERY_TAIL_MESS
34
45
  text = text.slice(-maxChars);
35
46
  return text;
36
47
  }
48
+ /**
49
+ * §E 压缩感知标记器(memorax-absorb / task_24-26):记录刚完成压缩的会话,
50
+ * pre-step 消费后做一次性增强召回。纯内存状态,CaptureBuffers 同款可单测导出。
51
+ *
52
+ * - `onCompactionEnd`:只认无 error 的 compaction/end(失败的压缩没有把内容逐出上下文);
53
+ * - `consume`:消费即清(一次性增强);容量上限淘汰最旧(积压即异常,回退路径兜底)。
54
+ */
55
+ export class PostCompactionTracker {
56
+ cap;
57
+ map = new Map();
58
+ constructor(cap = 64) {
59
+ this.cap = cap;
60
+ }
61
+ /** @returns 是否真的置了标记(false = 非 compaction/end 或带 error)。 */
62
+ onCompactionEnd(sessionId, data) {
63
+ if (!sessionId || data?.error)
64
+ return false;
65
+ this.map.delete(sessionId); // 置尾刷新
66
+ this.map.set(sessionId, true);
67
+ while (this.map.size > this.cap) {
68
+ const oldest = this.map.keys().next().value;
69
+ if (oldest === undefined)
70
+ break;
71
+ this.map.delete(oldest);
72
+ }
73
+ return true;
74
+ }
75
+ /** 消费该会话的增强标记(存在即清,返回是否存在)。 */
76
+ consume(sessionId) {
77
+ if (!this.map.delete(sessionId))
78
+ return false;
79
+ return true;
80
+ }
81
+ get size() {
82
+ return this.map.size;
83
+ }
84
+ }
37
85
  const MEMORY_TOOLS_GUIDE = `<memory-tools-guide>
38
86
  ## 记忆工具调用指南
39
87
 
@@ -152,6 +200,21 @@ export function registerRecall(ctx, cfg, stores, logger, live, modes, dataDir) {
152
200
  recallStats.delete(payload.agent.id);
153
201
  occupancyByAgent.delete(payload.agent.id);
154
202
  });
203
+ // §D:绑定实际标记动作 + 超时清扫挂到 pre-step 入口(见注入点注释)。
204
+ setInjectionMarker((agentId, recordIds) => dedupe.mark(agentId, recordIds));
205
+ let ackDegradeWarned = false;
206
+ // §E 压缩感知(memorax-absorb):宿主 `compaction/end`(无 error)→ 该会话下一轮
207
+ // 做一次"定向增强召回"——跳过去重压制、画像立即重注入、占用账本归零;消费即清。
208
+ // 会话级回退路径保留(下方 session-start source='compact' 全量重置),两者幂等。
209
+ // 宿主无压缩计数器(compactionId 为 per-transaction UUID),故不做 generation 对账。
210
+ const postCompaction = new PostCompactionTracker();
211
+ ctx.on('session/event', (session, event) => {
212
+ if (event.type !== 'compaction/end')
213
+ return;
214
+ if (postCompaction.onCompactionEnd(String(session.id ?? session))) {
215
+ logger.info(`[memory] compaction/end:下一轮将做一次增强召回(session=${String(session.id ?? session)})`);
216
+ }
217
+ });
155
218
  // 上下文压缩/清空 → 已注入内容从模型上下文丢失,重置该会话的去重压制
156
219
  // (resume/startup 不重置:历史仍在,已注入的记忆模型还持有)。
157
220
  // 占用账本同步全量归零(宁低勿高;轮级粒度近似)。
@@ -197,6 +260,13 @@ export function registerRecall(ctx, cfg, stores, logger, live, modes, dataDir) {
197
260
  // prepend 注册 + 先 next() 再改写:不劫持其他监听器(dsh-time-context 官方范式)。
198
261
  if (cfg.recall.enabled) {
199
262
  ctx.on('agent/pre-step', async (payload, next) => {
263
+ // §D 超时清扫:超时未确认的注入照旧标记(降级保连续,防 dedupe 失效回归);
264
+ // 正常确认在秒级完成,这里只是兜底。一次性提示。
265
+ const expiredAcks = expirePendingInjections();
266
+ if (expiredAcks > 0 && !ackDegradeWarned) {
267
+ ackDegradeWarned = true;
268
+ logger.warn(`[memory] 注入确认:${expiredAcks} 条 pending 超时未回执,已降级为照常标记(一次性提示)`);
269
+ }
200
270
  const decision = await next();
201
271
  if (decision.kind === 'reject' || payload.signal.aborted)
202
272
  return decision;
@@ -212,6 +282,17 @@ export function registerRecall(ctx, cfg, stores, logger, live, modes, dataDir) {
212
282
  const hasNewUserMessage = decision.messages.some((m) => m.source?.kind === 'user');
213
283
  if (!hasNewUserMessage)
214
284
  return decision;
285
+ // §E 消费增强标记:压缩后下一轮跳过去重压制(预算/档位/门控照常),
286
+ // 画像 TTL 失效立即重注入,占用账本归零。消费即清(一次性)。
287
+ const sessionKey = String(payload.agent.session?.id ?? payload.agent.id);
288
+ const boosted = postCompaction.consume(sessionKey);
289
+ if (boosted) {
290
+ invalidateProfile();
291
+ const led = ledgerFor(payload.agent.id);
292
+ resetForCompaction(led);
293
+ occupancyStore.save(payload.agent.id, led);
294
+ logger.info(`[memory] 压缩后增强召回:本轮跳过去重压制 + 画像重注入(session=${sessionKey})`);
295
+ }
215
296
  const query = buildRecallQuery(payload.messages);
216
297
  // 空查询是退化轮(无用户文本),重置命中信号但不计入统计
217
298
  if (!query) {
@@ -219,6 +300,10 @@ export function registerRecall(ctx, cfg, stores, logger, live, modes, dataDir) {
219
300
  degenerate.lastHits = 0;
220
301
  return decision;
221
302
  }
303
+ // §F 追踪字段:metadata-only 默认(chars+sha256 前 16),captureContent 才存原文
304
+ const queryChars = query.length;
305
+ const querySha = createHash('sha256').update(query).digest('hex').slice(0, 16);
306
+ const queryText = cfg.trace?.captureContent === true ? query.slice(0, 200) : undefined;
222
307
  const st = statFor(payload.agent.id);
223
308
  st.injectedTurns++;
224
309
  st.lastHits = 0;
@@ -237,6 +322,11 @@ export function registerRecall(ctx, cfg, stores, logger, live, modes, dataDir) {
237
322
  if (hits === undefined) {
238
323
  st.timeouts++;
239
324
  logger.warn('[memory] 召回超时,跳过本轮注入(不阻塞对话)');
325
+ trace({
326
+ kind: 'recall_turn', ts: Date.now(), sessionId: sessionKey, queryChars, querySha, queryText,
327
+ hitIds: [], hitScores: [], injectedIds: [], suppressedCount: 0,
328
+ durationMs: Date.now() - searchStart, outcome: 'timeout',
329
+ });
240
330
  return decision;
241
331
  }
242
332
  st.lastDurationMs = Date.now() - searchStart;
@@ -267,7 +357,8 @@ export function registerRecall(ctx, cfg, stores, logger, live, modes, dataDir) {
267
357
  }
268
358
  // 召回去重:同会话已注入过的记录不再重复注入(模型上下文已持有,省 token)。
269
359
  // 纯过滤——剩几条注几条,全量压制(0 条新鲜命中)是正确状态而非未命中。
270
- const seen = dedupe.seen(payload.agent.id);
360
+ // §E:压缩后增强轮跳过压制(压缩把已注入内容逐出了模型上下文)。
361
+ const seen = boosted ? new Set() : dedupe.seen(payload.agent.id);
271
362
  const fresh = scoped.filter((h) => !seen.has(h.id));
272
363
  const suppressed = scoped.length - fresh.length;
273
364
  st.suppressedRecalls += suppressed;
@@ -279,13 +370,27 @@ export function registerRecall(ctx, cfg, stores, logger, live, modes, dataDir) {
279
370
  st.hitTurns++;
280
371
  st.totalHits += fresh.length;
281
372
  }
282
- if (fresh.length === 0)
373
+ if (fresh.length === 0) {
374
+ // §F 追踪:全量压制轮——零注入是正确状态,如实记录
375
+ trace({
376
+ kind: 'recall_turn', ts: Date.now(), sessionId: sessionKey, queryChars, querySha, queryText,
377
+ hitIds: scoped.map((h) => h.id), hitScores: scoped.map((h) => Number(h.score.toFixed(4))),
378
+ injectedIds: [], suppressedCount: scoped.length - fresh.length,
379
+ durationMs: Date.now() - searchStart, outcome: 'suppressed',
380
+ });
283
381
  return decision;
382
+ }
284
383
  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)
384
+ if (lines.length === 0) {
385
+ // §F 追踪:预算清零轮(截断后无完整行)
386
+ trace({
387
+ kind: 'recall_turn', ts: Date.now(), sessionId: sessionKey, queryChars, querySha, queryText,
388
+ hitIds: fresh.map((h) => h.id), hitScores: fresh.map((h) => Number(h.score.toFixed(4))),
389
+ injectedIds: [], suppressedCount: scoped.length - fresh.length,
390
+ durationMs: Date.now() - searchStart, outcome: 'suppressed',
391
+ });
286
392
  return decision;
287
- // 预算截断只丢尾部(前缀保留):实际注入 = fresh 的前 lines.length 条——只标记模型真实看到的
288
- dedupe.mark(payload.agent.id, fresh.slice(0, lines.length).map((h) => h.id));
393
+ }
289
394
  st.lastHits = lines.length;
290
395
  const text = [
291
396
  '<relevant-memories>',
@@ -298,10 +403,23 @@ export function registerRecall(ctx, cfg, stores, logger, live, modes, dataDir) {
298
403
  logger.info(`[memory] 召回注入 ${lines.length} 条 L1(mode=${mode},query="${query.slice(0, 30).replace(/\n/g, ' ')}…",agent=${payload.agent.id},消息侧)`);
299
404
  const injection = createUserMessage({
300
405
  content: [{ type: 'text', text }],
301
- // plugin 字段是宿主 UI 的署名后缀("上下文注入 · memory")——用展示友好的
302
- // 子系统名,不用 cordis id(dsh-memory);kind:'plugin' 的标题恒为通用
303
- // "上下文注入"(专用"跨会话召回"标题仅留给 session-reference 来源)
304
- source: { kind: 'plugin', plugin: 'memory', form: 'recall' },
406
+ // v4(宿主 ≥0.1.7-rc.1)署名为 producer-owned kind:plugin:<子系统名>。
407
+ // 用展示友好的子系统名 memory,不用 cordis id(dsh-memory);宿主 UI 标题
408
+ // 规则随之变化(不再有 catch-all 'plugin' 的通用"上下文注入"标题)。
409
+ // v3 旧行(kind:'plugin'+plugin 字段)由宿主 v3→v4 迁移升格,读侧双形状兼容。
410
+ source: { kind: 'plugin:memory', form: 'recall' },
411
+ });
412
+ // §D 注入确认(memorax-absorb):宿主接受 enter 后,注入消息以原样
413
+ // `user/message` 进入会话日志(message.id 稳定保留)——capture 侧观察到
414
+ // 该 id 才真正 dedupe.mark(recall-ack 两段式)。这里只登记 pending,
415
+ // 未确认不标记:被覆盖/取消的注入不该压制这些记忆(下轮可重注)。
416
+ registerPendingInjection(payload.agent.id, injection.id, fresh.slice(0, lines.length).map((h) => h.id));
417
+ // §F 追踪:注入轮
418
+ trace({
419
+ kind: 'recall_turn', ts: Date.now(), sessionId: sessionKey, queryChars, querySha, queryText,
420
+ hitIds: scoped.map((h) => h.id), hitScores: scoped.map((h) => Number(h.score.toFixed(4))),
421
+ injectedIds: fresh.slice(0, lines.length).map((h) => h.id), suppressedCount: scoped.length - fresh.length,
422
+ durationMs: Date.now() - searchStart, outcome: 'injected',
305
423
  });
306
424
  // 入账在成功构造注入消息之后、返回 enter 之前——任何前置抛错路径账目零扰动
307
425
  const led = ledgerFor(payload.agent.id);
@@ -342,6 +460,8 @@ export function registerRecall(ctx, cfg, stores, logger, live, modes, dataDir) {
342
460
  return '';
343
461
  return body ? `${body}\n\n${MEMORY_TOOLS_GUIDE}` : MEMORY_TOOLS_GUIDE;
344
462
  };
463
+ /** 本插件 recall 注入的署名判据(v4 `plugin:memory` 与 v3 旧行双形状;宿主迁移负责升格旧行)。 */
464
+ const isOwnRecall = isOwnRecallSource;
345
465
  async function estimateRecallFromStorage(sessionId) {
346
466
  if (storedEstimateCache.has(sessionId))
347
467
  return storedEstimateCache.get(sessionId) ?? null;
@@ -358,7 +478,7 @@ export function registerRecall(ctx, cfg, stores, logger, live, modes, dataDir) {
358
478
  if (ev.type !== 'user/message')
359
479
  continue;
360
480
  const src = ev.data?.source;
361
- if (!src || src.kind !== 'plugin' || src.plugin !== 'memory' || src.form !== 'recall')
481
+ if (!isOwnRecall(src))
362
482
  continue;
363
483
  let chars = 0;
364
484
  for (const b of ev.data?.content ?? []) {
@@ -395,7 +515,7 @@ export function registerRecall(ctx, cfg, stores, logger, live, modes, dataDir) {
395
515
  continue;
396
516
  const msg = ev.data;
397
517
  const src = msg?.source;
398
- if (!src || src.kind !== 'plugin' || src.plugin !== 'memory' || src.form !== 'recall')
518
+ if (!isOwnRecall(src) || !msg)
399
519
  continue;
400
520
  let chars = 0;
401
521
  for (const b of msg.content ?? []) {
@@ -30,8 +30,9 @@ export function registerSlotRecall(ctx, cfg, slots, logger, _live) {
30
30
  return decision;
31
31
  const injection = createUserMessage({
32
32
  content: [{ type: 'text', text }],
33
- // form 受宿主类型约束(仅 recall/snapshot/notice/...),槽位常驻注入沿用 'recall' 分组
34
- source: { kind: 'plugin', plugin: 'memory', form: 'recall' },
33
+ // v4 producer-owned kind(宿主 ≥0.1.7-rc.1);form 沿用 'recall' 分组
34
+ // (宿主 ContextFormed 的 recall 变体无伴随字段)
35
+ source: { kind: 'plugin:memory', form: 'recall' },
35
36
  });
36
37
  return { kind: 'enter', messages: [injection, ...decision.messages] };
37
38
  }
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, "defined">;
24
24
  stripCodeBlocks: import("@deepseek-ai/schemastery").default<boolean, boolean, "defined">;
25
25
  maxMessageChars: import("@deepseek-ai/schemastery").default<number, number, "defined">;
26
+ redactSecrets: import("@deepseek-ai/schemastery").default<boolean, boolean, "defined">;
26
27
  }>>, Schemastery.ObjectT<NoInfer<{
27
28
  enabled: import("@deepseek-ai/schemastery").default<boolean, boolean, "defined">;
28
29
  stripCodeBlocks: import("@deepseek-ai/schemastery").default<boolean, boolean, "defined">;
29
30
  maxMessageChars: import("@deepseek-ai/schemastery").default<number, number, "defined">;
31
+ redactSecrets: import("@deepseek-ai/schemastery").default<boolean, boolean, "defined">;
32
+ }>>, "plain">;
33
+ trace: import("@deepseek-ai/schemastery").default<Schemastery.ObjectS<NoInfer<{
34
+ enabled: import("@deepseek-ai/schemastery").default<boolean, boolean, "defined">;
35
+ retentionDays: import("@deepseek-ai/schemastery").default<number, number, "defined">;
36
+ captureContent: import("@deepseek-ai/schemastery").default<boolean, boolean, "defined">;
37
+ }>>, Schemastery.ObjectT<NoInfer<{
38
+ enabled: import("@deepseek-ai/schemastery").default<boolean, boolean, "defined">;
39
+ retentionDays: import("@deepseek-ai/schemastery").default<number, number, "defined">;
40
+ captureContent: import("@deepseek-ai/schemastery").default<boolean, boolean, "defined">;
30
41
  }>>, "plain">;
31
42
  extract: import("@deepseek-ai/schemastery").default<Schemastery.ObjectS<NoInfer<{
32
43
  enabled: import("@deepseek-ai/schemastery").default<boolean, boolean, "defined">;
@@ -502,10 +513,21 @@ export declare const Config: import("@deepseek-ai/schemastery").default<Schemast
502
513
  enabled: import("@deepseek-ai/schemastery").default<boolean, boolean, "defined">;
503
514
  stripCodeBlocks: import("@deepseek-ai/schemastery").default<boolean, boolean, "defined">;
504
515
  maxMessageChars: import("@deepseek-ai/schemastery").default<number, number, "defined">;
516
+ redactSecrets: import("@deepseek-ai/schemastery").default<boolean, boolean, "defined">;
505
517
  }>>, Schemastery.ObjectT<NoInfer<{
506
518
  enabled: import("@deepseek-ai/schemastery").default<boolean, boolean, "defined">;
507
519
  stripCodeBlocks: import("@deepseek-ai/schemastery").default<boolean, boolean, "defined">;
508
520
  maxMessageChars: import("@deepseek-ai/schemastery").default<number, number, "defined">;
521
+ redactSecrets: import("@deepseek-ai/schemastery").default<boolean, boolean, "defined">;
522
+ }>>, "plain">;
523
+ trace: import("@deepseek-ai/schemastery").default<Schemastery.ObjectS<NoInfer<{
524
+ enabled: import("@deepseek-ai/schemastery").default<boolean, boolean, "defined">;
525
+ retentionDays: import("@deepseek-ai/schemastery").default<number, number, "defined">;
526
+ captureContent: import("@deepseek-ai/schemastery").default<boolean, boolean, "defined">;
527
+ }>>, Schemastery.ObjectT<NoInfer<{
528
+ enabled: import("@deepseek-ai/schemastery").default<boolean, boolean, "defined">;
529
+ retentionDays: import("@deepseek-ai/schemastery").default<number, number, "defined">;
530
+ captureContent: import("@deepseek-ai/schemastery").default<boolean, boolean, "defined">;
509
531
  }>>, "plain">;
510
532
  extract: import("@deepseek-ai/schemastery").default<Schemastery.ObjectS<NoInfer<{
511
533
  enabled: import("@deepseek-ai/schemastery").default<boolean, boolean, "defined">;
package/dist/index.js CHANGED
@@ -42,6 +42,7 @@ import { buildRouteChain, resolveModelRoute, invalidateEffortCache } from './llm
42
42
  import { effectiveCfg } from './pipeline/runner.js';
43
43
  import { pendingPathFor } from './store/pending.js';
44
44
  import { initTokenCost, resetTokenCost } from './token-cost.js';
45
+ import { initTraceStore } from './store/trace.js';
45
46
  export const name = 'dsh-memory-plugin';
46
47
  /**
47
48
  * 硬依赖:蒸馏要用 llm,工具注册要用 tools,召回注入要用 systemPrompt。
@@ -137,6 +138,9 @@ export async function apply(ctx, config) {
137
138
  const embed = initial.svc;
138
139
  const db = new MemoryDb(path.join(dataDir, 'memory.db'), initial.dims, logger);
139
140
  initTokenCost(db, config.tokenCost.retentionDays);
141
+ // §F 追踪(trace.enabled=false 时保持未初始化,埋点零副作用)
142
+ if (config.trace?.enabled)
143
+ initTraceStore(dataDir, { retentionDays: config.trace.retentionDays });
140
144
  // 插件卸载时关闭连接(WAL 落盘),注册一次即可
141
145
  ctx.effect(() => () => db.close());
142
146
  let dbInit = { needsReindex: false };
@@ -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>;