dsh-prime-memory 0.18.2 → 0.19.0

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 (51) hide show
  1. package/CHANGELOG.en.md +21 -0
  2. package/CHANGELOG.ja.md +21 -0
  3. package/CHANGELOG.ko.md +21 -0
  4. package/CHANGELOG.md +21 -0
  5. package/INSTALL.en.md +1 -1
  6. package/INSTALL.ja.md +1 -1
  7. package/INSTALL.ko.md +1 -1
  8. package/INSTALL.md +1 -1
  9. package/README.en.md +7 -2
  10. package/README.ja.md +7 -2
  11. package/README.ko.md +7 -2
  12. package/README.md +7 -2
  13. package/cordis.patch.yml +6 -0
  14. package/dist/client.js +36 -9
  15. package/dist/config.d.ts +37 -0
  16. package/dist/config.js +7 -0
  17. package/dist/contract.d.ts +46 -0
  18. package/dist/hooks/capture-recovery.d.ts +59 -0
  19. package/dist/hooks/capture-recovery.js +88 -0
  20. package/dist/hooks/capture.js +110 -1
  21. package/dist/hooks/recall-ack.d.ts +48 -0
  22. package/dist/hooks/recall-ack.js +79 -0
  23. package/dist/hooks/recall.d.ts +19 -0
  24. package/dist/hooks/recall.js +116 -7
  25. package/dist/index.d.ts +22 -0
  26. package/dist/index.js +4 -0
  27. package/dist/pipeline/l1.d.ts +4 -0
  28. package/dist/pipeline/l1.js +9 -2
  29. package/dist/pipeline/reconcile.js +4 -0
  30. package/dist/pipeline/runner.js +62 -0
  31. package/dist/prompts/boundary.d.ts +17 -0
  32. package/dist/prompts/boundary.js +20 -0
  33. package/dist/prompts/graph-projection.js +4 -1
  34. package/dist/prompts/l1-dedup.d.ts +3 -3
  35. package/dist/prompts/l1-dedup.js +9 -1
  36. package/dist/prompts/l1-extraction.d.ts +3 -3
  37. package/dist/prompts/l1-extraction.js +12 -0
  38. package/dist/prompts/persona.js +6 -0
  39. package/dist/prompts/scene.js +7 -1
  40. package/dist/stats.js +9 -0
  41. package/dist/store/l0.d.ts +4 -0
  42. package/dist/store/l0.js +8 -0
  43. package/dist/store/sqlite.d.ts +12 -0
  44. package/dist/store/sqlite.js +37 -0
  45. package/dist/store/trace.d.ts +39 -0
  46. package/dist/store/trace.js +149 -0
  47. package/dist/tools/index.js +25 -1
  48. package/dist/util/redact.d.ts +27 -0
  49. package/dist/util/redact.js +228 -0
  50. package/dsh.plugin.json +2 -2
  51. package/package.json +23 -22
@@ -0,0 +1,59 @@
1
+ /**
2
+ * §A 崩溃恢复 · 折叠纯函数(memorax-absorb Wave 1 / task_6)。
3
+ *
4
+ * 宿主在 turn 中途崩溃时,插件的内存缓冲丢失、该轮永不落 L0——但事件日志就在
5
+ * 宿主持久化层里,且 persistence 会在 reload 时给崩溃孤儿轮补写
6
+ * `turn/end{reason:'interrupted'}` 闭合标记(运行期实证:本机 98 处,
7
+ * 见计划 evidence/probe-runtime.json)。resume 时对账 `(水位线, 末尾]` 内
8
+ * bracket 完整的 turn,经 capture 的正常路径落 L0 + 蒸馏。
9
+ *
10
+ * 本模块只做**纯折叠**:events in → bracket 列表 out,零 I/O、零时钟、零随机。
11
+ * 幂等(水位线 + 逐 turn 存在性检查)与 off 档排除在调用方(capture.ts 接线层)。
12
+ *
13
+ * 切片语义与 CaptureBuffers.takeTurn 对齐:含 turn/end、不含 turn/start
14
+ * (turn/start 与 turn/end 本身不产消息,与既有转换器的类型过滤一致)。
15
+ */
16
+ import type { SessionEvent } from '@deepseek-ai/dsh-session';
17
+ /** 一个可恢复的完整轮次括号;events 为 (startSeq, endSeq] 切片。 */
18
+ export interface RecoveredTurn {
19
+ turn: number;
20
+ startSeq: number;
21
+ endSeq: number;
22
+ events: SessionEvent[];
23
+ }
24
+ export interface FoldOptions {
25
+ /** 最多恢复的轮数(取最靠尾的 N 个);缺省 2——只补尾轮,非历史倾倒。 */
26
+ maxTurns?: number;
27
+ }
28
+ /**
29
+ * 从持久化事件日志折叠出可恢复轮次。
30
+ *
31
+ * - **bracket 完整性**:只有 `turn/start(T) … turn/end(T)` 成对(任意 reason——
32
+ * interrupted 与 completed 同等对待,插件停机期间宿主正常完成的轮次同样缺 L0)才入选;
33
+ * 未闭合 tail 跳过(宿主 reload 补闭合后,下次 resume 自然纳入)。
34
+ * - **水位线窗口**:只取 `turn > watermark`(watermark=undefined 视为会话 L0 全空,
35
+ * 全部入选)。
36
+ * - **上限**:按 turn 取最靠尾的 `maxTurns`(缺省 2)个,返回按 startSeq 升序
37
+ * (调用方按序落 L0,保持时序)。
38
+ * - **防御**:输入乱序时按 seq 稳定排序;同 turn 重复 bracket(代际文件重叠等畸形)
39
+ * 保留 endSeq 更晚的一个;`data.turn` 非有限数字的括号一律不折叠。
40
+ */
41
+ export declare function foldRecoverableTurns(events: readonly SessionEvent[], watermark: number | undefined, opts?: FoldOptions): RecoveredTurn[];
42
+ /**
43
+ * 宿主读日志服务的最小结构形状。真实类型随宿主版本漂移(compat 0.1.1-rc.2 与
44
+ * 检出主线已是两代 API),故只做**结构探针**——方法存在才调,逐级 try/catch。
45
+ */
46
+ export interface HostRecoveryServices {
47
+ sessionQuery?: unknown;
48
+ sessionPersistence?: unknown;
49
+ }
50
+ /**
51
+ * task_8 降级链:`agent.session.events` 不可得时,依次尝试
52
+ * ① `ctx.sessionQuery.readSession(id)`(live 或 persisted 均可,返回完整原始日志);
53
+ * ② `ctx.sessionPersistence.readFrom(id, 0)`(按 seq 范围读,0 = 从头取全量——
54
+ * 水位线以 turn 计,无 seq 对应,全量交由 fold + 上限 2 收敛)。
55
+ *
56
+ * 返回 undefined = 两级均不可用/均失败(调用方走一次性提示 + 维持现状)。
57
+ * 守卫纪律与 recall.ts 的 loadStored 探针一致:不假设方法存在,失败静默降级。
58
+ */
59
+ export declare function readPersistedEventsViaServices(svcs: HostRecoveryServices, sessionId: string): SessionEvent[] | undefined;
@@ -0,0 +1,88 @@
1
+ function turnOf(event) {
2
+ const t = event.data?.turn;
3
+ return typeof t === 'number' && Number.isFinite(t) ? t : undefined;
4
+ }
5
+ /**
6
+ * 从持久化事件日志折叠出可恢复轮次。
7
+ *
8
+ * - **bracket 完整性**:只有 `turn/start(T) … turn/end(T)` 成对(任意 reason——
9
+ * interrupted 与 completed 同等对待,插件停机期间宿主正常完成的轮次同样缺 L0)才入选;
10
+ * 未闭合 tail 跳过(宿主 reload 补闭合后,下次 resume 自然纳入)。
11
+ * - **水位线窗口**:只取 `turn > watermark`(watermark=undefined 视为会话 L0 全空,
12
+ * 全部入选)。
13
+ * - **上限**:按 turn 取最靠尾的 `maxTurns`(缺省 2)个,返回按 startSeq 升序
14
+ * (调用方按序落 L0,保持时序)。
15
+ * - **防御**:输入乱序时按 seq 稳定排序;同 turn 重复 bracket(代际文件重叠等畸形)
16
+ * 保留 endSeq 更晚的一个;`data.turn` 非有限数字的括号一律不折叠。
17
+ */
18
+ export function foldRecoverableTurns(events, watermark, opts) {
19
+ const maxTurns = opts?.maxTurns ?? 2;
20
+ const sorted = [...events].sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0));
21
+ const open = new Map();
22
+ const brackets = [];
23
+ for (let i = 0; i < sorted.length; i++) {
24
+ const event = sorted[i];
25
+ if (event.type === 'turn/start') {
26
+ const turn = turnOf(event);
27
+ if (turn !== undefined)
28
+ open.set(turn, { startSeq: event.seq, startIdx: i });
29
+ }
30
+ else if (event.type === 'turn/end') {
31
+ const turn = turnOf(event);
32
+ const start = turn !== undefined ? open.get(turn) : undefined;
33
+ if (turn === undefined || start === undefined)
34
+ continue;
35
+ open.delete(turn);
36
+ brackets.push({
37
+ turn,
38
+ startSeq: start.startSeq,
39
+ endSeq: event.seq,
40
+ events: sorted.slice(start.startIdx + 1, i + 1),
41
+ });
42
+ }
43
+ }
44
+ const eligible = brackets.filter((b) => watermark === undefined || b.turn > watermark);
45
+ const byTurn = new Map();
46
+ for (const b of eligible) {
47
+ const prev = byTurn.get(b.turn);
48
+ if (prev === undefined || b.endSeq >= prev.endSeq)
49
+ byTurn.set(b.turn, b);
50
+ }
51
+ return [...byTurn.values()].sort((a, b) => a.turn - b.turn).slice(-maxTurns).sort((a, b) => a.startSeq - b.startSeq);
52
+ }
53
+ /**
54
+ * task_8 降级链:`agent.session.events` 不可得时,依次尝试
55
+ * ① `ctx.sessionQuery.readSession(id)`(live 或 persisted 均可,返回完整原始日志);
56
+ * ② `ctx.sessionPersistence.readFrom(id, 0)`(按 seq 范围读,0 = 从头取全量——
57
+ * 水位线以 turn 计,无 seq 对应,全量交由 fold + 上限 2 收敛)。
58
+ *
59
+ * 返回 undefined = 两级均不可用/均失败(调用方走一次性提示 + 维持现状)。
60
+ * 守卫纪律与 recall.ts 的 loadStored 探针一致:不假设方法存在,失败静默降级。
61
+ */
62
+ export function readPersistedEventsViaServices(svcs, sessionId) {
63
+ const accepts = (r) => r !== null && typeof r === 'object' && Array.isArray(r.events) &&
64
+ r.events.length > 0;
65
+ const q = svcs.sessionQuery;
66
+ if (q !== null && typeof q === 'object' && typeof q.readSession === 'function') {
67
+ try {
68
+ const result = q.readSession(sessionId);
69
+ if (accepts(result))
70
+ return result.events;
71
+ }
72
+ catch {
73
+ /* 落到下一级 */
74
+ }
75
+ }
76
+ const p = svcs.sessionPersistence;
77
+ if (p !== null && typeof p === 'object' && typeof p.readFrom === 'function') {
78
+ try {
79
+ const result = p.readFrom(sessionId, 0);
80
+ if (accepts(result))
81
+ return result.events;
82
+ }
83
+ catch {
84
+ /* 均不可用 */
85
+ }
86
+ }
87
+ return undefined;
88
+ }
@@ -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/created', async (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 = typeof session?.snapshotEvents === 'function' ? session.snapshotEvents() : undefined;
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
+ }
@@ -41,6 +41,25 @@ export declare function isOwnRecallSource(src: {
41
41
  export declare function buildRecallQuery(messages: Array<{
42
42
  content: unknown;
43
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
+ }
44
63
  /** 单会话召回统计(悬浮卡信息区数据源;每轮 O(1) 记账,agent/disposed 清理)。
45
64
  * 口径声明:这是"注入统计"而非 bench 的离线 recall@k——运行时没有 ground truth,
46
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';
@@ -42,6 +45,43 @@ export function buildRecallQuery(messages, tailMessages = RECALL_QUERY_TAIL_MESS
42
45
  text = text.slice(-maxChars);
43
46
  return text;
44
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
+ }
45
85
  const MEMORY_TOOLS_GUIDE = `<memory-tools-guide>
46
86
  ## 记忆工具调用指南
47
87
 
@@ -160,10 +200,25 @@ export function registerRecall(ctx, cfg, stores, logger, live, modes, dataDir) {
160
200
  recallStats.delete(payload.agent.id);
161
201
  occupancyByAgent.delete(payload.agent.id);
162
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
+ });
163
218
  // 上下文压缩/清空 → 已注入内容从模型上下文丢失,重置该会话的去重压制
164
219
  // (resume/startup 不重置:历史仍在,已注入的记忆模型还持有)。
165
220
  // 占用账本同步全量归零(宁低勿高;轮级粒度近似)。
166
- ctx.on('agent/session-start', (payload) => {
221
+ ctx.on('agent/created', async (payload) => {
167
222
  if (payload.source === 'compact' || payload.source === 'clear') {
168
223
  dedupe.reset(payload.agent.id);
169
224
  const led = ledgerFor(payload.agent.id);
@@ -205,6 +260,13 @@ export function registerRecall(ctx, cfg, stores, logger, live, modes, dataDir) {
205
260
  // prepend 注册 + 先 next() 再改写:不劫持其他监听器(dsh-time-context 官方范式)。
206
261
  if (cfg.recall.enabled) {
207
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
+ }
208
270
  const decision = await next();
209
271
  if (decision.kind === 'reject' || payload.signal.aborted)
210
272
  return decision;
@@ -220,6 +282,17 @@ export function registerRecall(ctx, cfg, stores, logger, live, modes, dataDir) {
220
282
  const hasNewUserMessage = decision.messages.some((m) => m.source?.kind === 'user');
221
283
  if (!hasNewUserMessage)
222
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
+ }
223
296
  const query = buildRecallQuery(payload.messages);
224
297
  // 空查询是退化轮(无用户文本),重置命中信号但不计入统计
225
298
  if (!query) {
@@ -227,6 +300,10 @@ export function registerRecall(ctx, cfg, stores, logger, live, modes, dataDir) {
227
300
  degenerate.lastHits = 0;
228
301
  return decision;
229
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;
230
307
  const st = statFor(payload.agent.id);
231
308
  st.injectedTurns++;
232
309
  st.lastHits = 0;
@@ -245,6 +322,11 @@ export function registerRecall(ctx, cfg, stores, logger, live, modes, dataDir) {
245
322
  if (hits === undefined) {
246
323
  st.timeouts++;
247
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
+ });
248
330
  return decision;
249
331
  }
250
332
  st.lastDurationMs = Date.now() - searchStart;
@@ -275,7 +357,8 @@ export function registerRecall(ctx, cfg, stores, logger, live, modes, dataDir) {
275
357
  }
276
358
  // 召回去重:同会话已注入过的记录不再重复注入(模型上下文已持有,省 token)。
277
359
  // 纯过滤——剩几条注几条,全量压制(0 条新鲜命中)是正确状态而非未命中。
278
- const seen = dedupe.seen(payload.agent.id);
360
+ // §E:压缩后增强轮跳过压制(压缩把已注入内容逐出了模型上下文)。
361
+ const seen = boosted ? new Set() : dedupe.seen(payload.agent.id);
279
362
  const fresh = scoped.filter((h) => !seen.has(h.id));
280
363
  const suppressed = scoped.length - fresh.length;
281
364
  st.suppressedRecalls += suppressed;
@@ -287,13 +370,27 @@ export function registerRecall(ctx, cfg, stores, logger, live, modes, dataDir) {
287
370
  st.hitTurns++;
288
371
  st.totalHits += fresh.length;
289
372
  }
290
- 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
+ });
291
381
  return decision;
382
+ }
292
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 });
293
- 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
+ });
294
392
  return decision;
295
- // 预算截断只丢尾部(前缀保留):实际注入 = fresh 的前 lines.length 条——只标记模型真实看到的
296
- dedupe.mark(payload.agent.id, fresh.slice(0, lines.length).map((h) => h.id));
393
+ }
297
394
  st.lastHits = lines.length;
298
395
  const text = [
299
396
  '<relevant-memories>',
@@ -312,6 +409,18 @@ export function registerRecall(ctx, cfg, stores, logger, live, modes, dataDir) {
312
409
  // v3 旧行(kind:'plugin'+plugin 字段)由宿主 v3→v4 迁移升格,读侧双形状兼容。
313
410
  source: { kind: 'plugin:memory', form: 'recall' },
314
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',
423
+ });
315
424
  // 入账在成功构造注入消息之后、返回 enter 之前——任何前置抛错路径账目零扰动
316
425
  const led = ledgerFor(payload.agent.id);
317
426
  recordRecallInjection(led, text.length);
@@ -459,7 +568,7 @@ export function registerRecall(ctx, cfg, stores, logger, live, modes, dataDir) {
459
568
  for (const agent of agents.list())
460
569
  registerForAgent(agent);
461
570
  }
462
- ctx.on('agent/created', (payload) => {
571
+ ctx.on('agent/created', async (payload) => {
463
572
  registerForAgent(payload.agent);
464
573
  });
465
574
  ctx.effect(() => () => {