openclaw-hybrid-memory 2026.3.310 → 2026.4.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli/cmd-config.ts CHANGED
@@ -199,7 +199,17 @@ export function runConfigViewForCli(ctx: HandlerContext, sink: VerifyCliSink): v
199
199
  }`,
200
200
  );
201
201
  log(` Retrieval directives: ${on(cfg.autoRecall.retrievalDirectives?.enabled ?? false)}`);
202
- log(` Entity lookup: ${on(cfg.autoRecall.entityLookup.enabled)}`);
202
+ const el = cfg.autoRecall.entityLookup;
203
+ const entityNames = Array.isArray(el?.entities) ? el.entities : [];
204
+ const autoFromFacts = el?.autoFromFacts !== false;
205
+ const maxAutoEntities = typeof el?.maxAutoEntities === "number" && el.maxAutoEntities > 0 ? el.maxAutoEntities : 500;
206
+ const entitySrc =
207
+ entityNames.length > 0
208
+ ? `${entityNames.length} configured name(s)`
209
+ : autoFromFacts
210
+ ? `auto from facts (cap ${maxAutoEntities})`
211
+ : "manual list empty (auto off)";
212
+ log(` Entity lookup: ${on(el?.enabled ?? false)} — ${entitySrc}`);
203
213
  log("");
204
214
 
205
215
  log("To change a setting: openclaw hybrid-mem config-set <key> <value>");
@@ -50,6 +50,10 @@ export function parseAutoRecallConfig(cfg: Record<string, unknown>): AutoRecallC
50
50
  const preferLongTerm = ar.preferLongTerm === true;
51
51
  const useImportanceRecency = ar.useImportanceRecency === true;
52
52
  const entityLookupRaw = ar.entityLookup as Record<string, unknown> | undefined;
53
+ const maxAutoRaw =
54
+ typeof entityLookupRaw?.maxAutoEntities === "number" && entityLookupRaw.maxAutoEntities > 0
55
+ ? Math.max(1, Math.floor(entityLookupRaw.maxAutoEntities))
56
+ : 500;
53
57
  const entityLookup: EntityLookupConfig = {
54
58
  enabled: entityLookupRaw?.enabled === true,
55
59
  entities: Array.isArray(entityLookupRaw?.entities)
@@ -59,6 +63,8 @@ export function parseAutoRecallConfig(cfg: Record<string, unknown>): AutoRecallC
59
63
  typeof entityLookupRaw?.maxFactsPerEntity === "number" && entityLookupRaw.maxFactsPerEntity > 0
60
64
  ? Math.floor(entityLookupRaw.maxFactsPerEntity)
61
65
  : 2,
66
+ autoFromFacts: entityLookupRaw?.autoFromFacts !== false,
67
+ maxAutoEntities: Math.min(2000, maxAutoRaw),
62
68
  };
63
69
  const summaryThreshold =
64
70
  typeof ar.summaryThreshold === "number" && ar.summaryThreshold >= 0 ? ar.summaryThreshold : 300;
@@ -116,6 +122,13 @@ export function parseAutoRecallConfig(cfg: Record<string, unknown>): AutoRecallC
116
122
  typeof ar.progressivePinnedRecallCount === "number" && ar.progressivePinnedRecallCount >= 0
117
123
  ? Math.floor(ar.progressivePinnedRecallCount)
118
124
  : 3;
125
+ const VALID_ENRICHMENT = ["fast", "balanced", "full"] as const;
126
+ const interactiveEnrichmentRaw = ar.interactiveEnrichment;
127
+ const interactiveEnrichment =
128
+ typeof interactiveEnrichmentRaw === "string" &&
129
+ (VALID_ENRICHMENT as readonly string[]).includes(interactiveEnrichmentRaw)
130
+ ? (interactiveEnrichmentRaw as (typeof VALID_ENRICHMENT)[number])
131
+ : "balanced";
119
132
  const scopeFilterRaw = ar.scopeFilter as Record<string, unknown> | undefined;
120
133
  const scopeFilter =
121
134
  scopeFilterRaw && typeof scopeFilterRaw === "object" && !Array.isArray(scopeFilterRaw)
@@ -178,6 +191,7 @@ export function parseAutoRecallConfig(cfg: Record<string, unknown>): AutoRecallC
178
191
  typeof ar.degradationMaxLatencyMs === "number" && ar.degradationMaxLatencyMs >= 0
179
192
  ? Math.floor(ar.degradationMaxLatencyMs)
180
193
  : 5000,
194
+ interactiveEnrichment,
181
195
  };
182
196
  }
183
197
  return {
@@ -189,7 +203,7 @@ export function parseAutoRecallConfig(cfg: Record<string, unknown>): AutoRecallC
189
203
  minScore: 0.3,
190
204
  preferLongTerm: false,
191
205
  useImportanceRecency: false,
192
- entityLookup: { enabled: false, entities: [], maxFactsPerEntity: 2 },
206
+ entityLookup: { enabled: false, entities: [], maxFactsPerEntity: 2, autoFromFacts: true, maxAutoEntities: 500 },
193
207
  retrievalDirectives: {
194
208
  enabled: true,
195
209
  entityMentioned: true,
@@ -216,6 +230,7 @@ export function parseAutoRecallConfig(cfg: Record<string, unknown>): AutoRecallC
216
230
  maxRecallsPerTarget: 1,
217
231
  includeVaultHints: true,
218
232
  },
233
+ interactiveEnrichment: "balanced",
219
234
  };
220
235
  }
221
236
 
@@ -18,6 +18,13 @@ export type EntityLookupConfig = {
18
18
  enabled: boolean;
19
19
  entities: string[]; // e.g. ["user", "owner", "decision"]; prompt matched case-insensitively
20
20
  maxFactsPerEntity: number; // max facts to merge per matched entity (default 2)
21
+ /**
22
+ * When `entities` is empty, load names from the facts table (`SELECT DISTINCT entity`).
23
+ * Default true. Set false to require an explicit `entities` list (legacy no-op when empty).
24
+ */
25
+ autoFromFacts: boolean;
26
+ /** Max distinct entity names to consider when using autoFromFacts (default 500, max 2000). */
27
+ maxAutoEntities: number;
21
28
  };
22
29
 
23
30
  /** Auto-recall on authentication failures (reactive memory trigger) */
@@ -82,6 +89,13 @@ export type AutoRecallConfig = {
82
89
  degradationQueueDepth?: number;
83
90
  /** Phase 2.1: Hard degradation. When recall latency (ms) exceeds this value, use FTS-only + HOT and set degraded. 0 = disabled. Default 5000. */
84
91
  degradationMaxLatencyMs?: number;
92
+ /**
93
+ * Single control for **interactive** chat-turn recall cost/latency (HyDE + ambient multi-query).
94
+ * - **fast** — No HyDE on the hot path, no extra ambient `runRecallPipelineQuery` calls (lowest latency/cost; still runs main FTS+vector recall when semantic is enabled).
95
+ * - **balanced** (default) — Respects `queryExpansion.skipForInteractiveTurns` and `ambient.enabled` / `ambient.multiQuery` as today.
96
+ * - **full** — When `queryExpansion.enabled`, runs HyDE on interactive turns regardless of `skipForInteractiveTurns`; allows ambient multi-query when `autoRecall.enabled` and ambient is configured.
97
+ */
98
+ interactiveEnrichment?: "fast" | "balanced" | "full";
85
99
  };
86
100
 
87
101
  /** Multi-strategy retrieval pipeline configuration (Issue #152: RRF scoring pipeline). */
package/config/utils.ts CHANGED
@@ -33,7 +33,12 @@ export function isValidCategory(cat: string): boolean {
33
33
  export const PRESET_OVERRIDES: Record<ConfigMode, Record<string, unknown>> = {
34
34
  local: {
35
35
  autoCapture: true,
36
- autoRecall: { enabled: true, entityLookup: { enabled: false }, authFailure: { enabled: false } },
36
+ autoRecall: {
37
+ enabled: true,
38
+ interactiveEnrichment: "fast",
39
+ entityLookup: { enabled: false },
40
+ authFailure: { enabled: false },
41
+ },
37
42
  autoClassify: { enabled: false, suggestCategories: false },
38
43
  store: { fuzzyDedupe: true, classifyBeforeWrite: false },
39
44
  graph: { enabled: false },
@@ -51,7 +56,12 @@ export const PRESET_OVERRIDES: Record<ConfigMode, Record<string, unknown>> = {
51
56
  /** Minimal: nano for auto-classify, default (flash) for distill — good value at low cost. Ingest paths on so occasional ingest-files gets facts. */
52
57
  minimal: {
53
58
  autoCapture: true,
54
- autoRecall: { enabled: true, entityLookup: { enabled: false }, authFailure: { enabled: true } },
59
+ autoRecall: {
60
+ enabled: true,
61
+ interactiveEnrichment: "fast",
62
+ entityLookup: { enabled: false },
63
+ authFailure: { enabled: true },
64
+ },
55
65
  autoClassify: { enabled: true, suggestCategories: true },
56
66
  store: { fuzzyDedupe: false, classifyBeforeWrite: false },
57
67
  graph: { enabled: true, autoLink: false, useInRecall: true, strengthenOnRecall: false },
@@ -67,7 +77,12 @@ export const PRESET_OVERRIDES: Record<ConfigMode, Record<string, unknown>> = {
67
77
  },
68
78
  enhanced: {
69
79
  autoCapture: true,
70
- autoRecall: { enabled: true, entityLookup: { enabled: true }, authFailure: { enabled: true } },
80
+ autoRecall: {
81
+ enabled: true,
82
+ interactiveEnrichment: "fast",
83
+ entityLookup: { enabled: true },
84
+ authFailure: { enabled: true },
85
+ },
71
86
  autoClassify: { enabled: true, suggestCategories: true },
72
87
  credentials: { autoDetect: true, autoCapture: { toolCalls: true } },
73
88
  store: { fuzzyDedupe: true, classifyBeforeWrite: true },
@@ -104,7 +119,12 @@ export const PRESET_OVERRIDES: Record<ConfigMode, Record<string, unknown>> = {
104
119
  },
105
120
  complete: {
106
121
  autoCapture: true,
107
- autoRecall: { enabled: true, entityLookup: { enabled: true }, authFailure: { enabled: true } },
122
+ autoRecall: {
123
+ enabled: true,
124
+ interactiveEnrichment: "fast",
125
+ entityLookup: { enabled: true },
126
+ authFailure: { enabled: true },
127
+ },
108
128
  autoClassify: { enabled: true, suggestCategories: true },
109
129
  credentials: { autoDetect: true, autoCapture: { toolCalls: true } },
110
130
  store: { fuzzyDedupe: true, classifyBeforeWrite: true },
@@ -2,7 +2,7 @@ import { getEnv } from "../utils/env-manager.js";
2
2
  /**
3
3
  * Lifecycle Hooks (Phase 2.3: staged pipeline).
4
4
  *
5
- * Dispatcher: registers before_agent_start, agent_end, subagent, and frustration handlers.
5
+ * Dispatcher: registers before_agent_start, agent_end, and frustration handlers (subagent hooks: stage-cleanup).
6
6
  * All stage logic lives in stage-*.ts and session-state.ts; this file stays <200 lines.
7
7
  */
8
8
 
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Lifecycle stage: Cleanup (Phase 2.3).
3
- * Subagent_start/subagent_end handlers, stale session sweep timer, dispose.
3
+ * OpenClaw typed hooks **subagent_spawned** / **subagent_ended** (issue #966), stale session sweep timer, dispose.
4
4
  * Exports: consumePendingTaskSignals, registerCleanupHandlers, createStaleSweepTimer, getDispose.
5
5
  */
6
6
 
@@ -26,6 +26,38 @@ import type { LifecycleContext, SessionState } from "./types.js";
26
26
  const STALE_SESSION_TTL_MS = 30 * 60 * 1000; // 30 minutes
27
27
  const STALE_SWEEP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
28
28
 
29
+ /** OpenClaw core dispatch shapes — see issue #966 / runSubagentSpawned */
30
+ type SubagentSpawnedEvent = {
31
+ childSessionKey?: string;
32
+ /** Legacy / alternate field names from older handlers */
33
+ sessionKey?: string;
34
+ label?: string;
35
+ task?: string;
36
+ agentId?: string;
37
+ runId?: string;
38
+ };
39
+
40
+ /** OpenClaw core dispatch shapes — see issue #966 / runSubagentEnded */
41
+ type SubagentEndedEvent = {
42
+ targetSessionKey?: string;
43
+ sessionKey?: string;
44
+ label?: string;
45
+ success?: boolean;
46
+ outcome?: string;
47
+ error?: string;
48
+ reason?: string;
49
+ runId?: string;
50
+ };
51
+
52
+ function subagentEndedIsSuccess(ev: SubagentEndedEvent): boolean {
53
+ if (typeof ev.success === "boolean") return ev.success;
54
+ const o = (ev.outcome ?? "").toLowerCase();
55
+ if (!o) return true;
56
+ if (["error", "timeout", "killed", "failed", "failure"].includes(o)) return false;
57
+ if (["success", "completed", "ok", "done"].includes(o)) return true;
58
+ return true;
59
+ }
60
+
29
61
  /**
30
62
  * Read all pending task signals from `memory/task-signals/*.json` and apply
31
63
  * their status changes to ACTIVE-TASK.md. Called after subagent completes.
@@ -276,7 +308,8 @@ export function getDispose(timerRef: ReturnType<typeof setInterval> | null, sess
276
308
  }
277
309
 
278
310
  /**
279
- * Register subagent_start and subagent_end handlers (active-task checkpoint + signal consumption).
311
+ * Register **subagent_spawned** and **subagent_ended** handlers (active-task checkpoint + signal consumption).
312
+ * Hook names must match OpenClaw `PLUGIN_HOOK_NAMES` (issue #966).
280
313
  */
281
314
  export function registerCleanupHandlers(
282
315
  api: ClawdbotPluginApi,
@@ -287,11 +320,12 @@ export function registerCleanupHandlers(
287
320
  ): void {
288
321
  if (!ctx.cfg.activeTask.enabled || !ctx.cfg.activeTask.autoCheckpoint) return;
289
322
 
290
- api.on("subagent_start", async (event: unknown) => {
323
+ api.on("subagent_spawned", async (event: unknown) => {
291
324
  try {
292
- const ev = event as { sessionKey?: string; label?: string; task?: string; agentId?: string };
293
- const label = ev.label ?? ev.sessionKey ?? `subagent-${Date.now()}`;
294
- const description = ev.task ?? `Subagent task (session: ${ev.sessionKey ?? "unknown"})`;
325
+ const ev = event as SubagentSpawnedEvent;
326
+ const childOrSession = ev.childSessionKey ?? ev.sessionKey;
327
+ const label = ev.label ?? childOrSession ?? `subagent-${Date.now()}`;
328
+ const description = ev.task ?? `Subagent task (session: ${childOrSession ?? "unknown"})`;
295
329
  const taskFile = await readActiveTaskFile(
296
330
  resolvedActiveTaskPath,
297
331
  parseDuration(ctx.cfg.activeTask.staleThreshold),
@@ -304,7 +338,7 @@ export function registerCleanupHandlers(
304
338
  label,
305
339
  description,
306
340
  status: "In progress",
307
- subagent: ev.sessionKey,
341
+ subagent: childOrSession,
308
342
  started: existing?.started ?? now,
309
343
  updated: now,
310
344
  };
@@ -316,23 +350,23 @@ export function registerCleanupHandlers(
316
350
  api.context?.sessionKey,
317
351
  );
318
352
  if (writeResult.skipped) {
319
- api.logger.debug?.(`memory-hybrid: skipped ACTIVE-TASK.md write in subagent_start: ${writeResult.reason}`);
353
+ api.logger.debug?.(`memory-hybrid: skipped ACTIVE-TASK.md write in subagent_spawned: ${writeResult.reason}`);
320
354
  } else {
321
355
  api.logger.info?.(`memory-hybrid: auto-checkpoint — created active task [${label}] for subagent spawn`);
322
356
  }
323
357
  } catch (err) {
324
358
  capturePluginError(err instanceof Error ? err : new Error(String(err)), {
325
- operation: "active-task-subagent-start",
359
+ operation: "active-task-subagent-spawned",
326
360
  subsystem: "active-task",
327
361
  });
328
- api.logger.debug?.(`memory-hybrid: active task auto-checkpoint on subagent_start failed: ${err}`);
362
+ api.logger.debug?.(`memory-hybrid: active task auto-checkpoint on subagent_spawned failed: ${err}`);
329
363
  }
330
364
  });
331
365
 
332
- api.on("subagent_end", async (event: unknown) => {
366
+ api.on("subagent_ended", async (event: unknown) => {
333
367
  try {
334
- const ev = event as { sessionKey?: string; label?: string; success?: boolean; error?: string };
335
- const label = ev.label ?? ev.sessionKey;
368
+ const ev = event as SubagentEndedEvent;
369
+ const label = ev.label ?? ev.targetSessionKey ?? ev.sessionKey;
336
370
  const staleMinutes = parseDuration(ctx.cfg.activeTask.staleThreshold);
337
371
  if (!label) {
338
372
  await consumePendingTaskSignals(
@@ -370,7 +404,7 @@ export function registerCleanupHandlers(
370
404
  }
371
405
 
372
406
  const now = new Date().toISOString();
373
- const newStatus = ev.success === false ? "Failed" : "Done";
407
+ const newStatus = subagentEndedIsSuccess(ev) ? "Done" : "Failed";
374
408
 
375
409
  if (newStatus === "Done") {
376
410
  const { updated, completed } = completeTask(taskFile.active, label);
@@ -383,7 +417,7 @@ export function registerCleanupHandlers(
383
417
  );
384
418
  if (writeResult.skipped) {
385
419
  api.logger.debug?.(
386
- `memory-hybrid: skipped ACTIVE-TASK.md write in subagent_end (Done): ${writeResult.reason}`,
420
+ `memory-hybrid: skipped ACTIVE-TASK.md write in subagent_ended (Done): ${writeResult.reason}`,
387
421
  );
388
422
  } else {
389
423
  if (ctx.cfg.activeTask.flushOnComplete) {
@@ -391,16 +425,17 @@ export function registerCleanupHandlers(
391
425
  await flushCompletedTaskToMemory(completed, memoryDir).catch(() => {});
392
426
  }
393
427
  api.logger.info?.(
394
- `memory-hybrid: auto-checkpoint — updated task [${label}] to ${newStatus} on subagent_end`,
428
+ `memory-hybrid: auto-checkpoint — updated task [${label}] to ${newStatus} on subagent_ended`,
395
429
  );
396
430
  }
397
431
  }
398
432
  } else {
433
+ const errHint = ev.error ?? ev.reason;
399
434
  const updatedEntry: ActiveTaskEntry = {
400
435
  ...existingTask,
401
436
  status: "Failed",
402
437
  updated: now,
403
- next: ev.error ? `Fix: ${ev.error.slice(0, 100)}` : existingTask.next,
438
+ next: errHint ? `Fix: ${String(errHint).slice(0, 100)}` : existingTask.next,
404
439
  };
405
440
  const updated = upsertTask(taskFile.active, updatedEntry);
406
441
  const writeResult = await writeActiveTaskFileGuarded(
@@ -411,10 +446,12 @@ export function registerCleanupHandlers(
411
446
  );
412
447
  if (writeResult.skipped) {
413
448
  api.logger.debug?.(
414
- `memory-hybrid: skipped ACTIVE-TASK.md write in subagent_end (Failed): ${writeResult.reason}`,
449
+ `memory-hybrid: skipped ACTIVE-TASK.md write in subagent_ended (Failed): ${writeResult.reason}`,
415
450
  );
416
451
  } else {
417
- api.logger.info?.(`memory-hybrid: auto-checkpoint — updated task [${label}] to ${newStatus} on subagent_end`);
452
+ api.logger.info?.(
453
+ `memory-hybrid: auto-checkpoint — updated task [${label}] to ${newStatus} on subagent_ended`,
454
+ );
418
455
  }
419
456
  }
420
457
 
@@ -427,10 +464,10 @@ export function registerCleanupHandlers(
427
464
  );
428
465
  } catch (err) {
429
466
  capturePluginError(err instanceof Error ? err : new Error(String(err)), {
430
- operation: "active-task-subagent-end",
467
+ operation: "active-task-subagent-ended",
431
468
  subsystem: "active-task",
432
469
  });
433
- api.logger.debug?.(`memory-hybrid: active task auto-checkpoint on subagent_end failed: ${err}`);
470
+ api.logger.debug?.(`memory-hybrid: active task auto-checkpoint on subagent_ended failed: ${err}`);
434
471
  }
435
472
  });
436
473
  }
@@ -3,7 +3,7 @@
3
3
  * Owns the interactive recall path for chat turns.
4
4
  * Runs the bounded recall pipeline: degradation check, FTS+vector, ambient, directives,
5
5
  * entity lookup, scoring. Returns either degraded/empty prependContext or RecallResult for injection.
6
- * Config: autoRecall.enabled. Timeout: 35s.
6
+ * Config: autoRecall.enabled. Stage wall-clock: INTERACTIVE_RECALL_STAGE_TIMEOUT_MS (abort).
7
7
  */
8
8
 
9
9
  import type { ClawdbotPluginApi } from "openclaw/plugin-sdk/core";
@@ -19,7 +19,7 @@ import {
19
19
  import { capturePluginError } from "../services/error-reporter.js";
20
20
  import { formatNarrativeRange, recallNarrativeSummaries } from "../services/narrative-recall.js";
21
21
  import { yieldEventLoop } from "../utils/event-loop-yield.js";
22
- import { withTimeout } from "../utils/timeout.js";
22
+ import { resolveEntityLookupNames } from "../utils/entity-lookup-resolve.js";
23
23
  import { estimateTokens } from "../utils/text.js";
24
24
  import { isConsolidatedDerivedFact } from "../utils/consolidation-controls.js";
25
25
  import type { LifecycleContext, RecallResult, RecallStageResult, SessionState } from "./types.js";
@@ -31,6 +31,14 @@ import {
31
31
 
32
32
  const RECALL_STAGE_TIMEOUT_MS = INTERACTIVE_RECALL_STAGE_TIMEOUT_MS;
33
33
 
34
+ function emptyRecallStage(): RecallStageResult {
35
+ return { kind: "empty", prependContext: undefined };
36
+ }
37
+
38
+ function recallAborted(signal: AbortSignal | undefined): boolean {
39
+ return signal?.aborted === true;
40
+ }
41
+
34
42
  function clipNarrativeText(text: string, maxChars = 360): string {
35
43
  if (text.length <= maxChars) return text;
36
44
  return `${text.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`;
@@ -42,7 +50,22 @@ export async function runRecallStage(
42
50
  ctx: LifecycleContext,
43
51
  sessionState: SessionState,
44
52
  ): Promise<RecallStageResult | null> {
45
- return withTimeout(RECALL_STAGE_TIMEOUT_MS, () => runRecall(event, api, ctx, sessionState));
53
+ const ac = new AbortController();
54
+ const { signal } = ac;
55
+ let timer: ReturnType<typeof setTimeout> | undefined;
56
+ try {
57
+ return await Promise.race([
58
+ runRecall(event, api, ctx, sessionState, signal),
59
+ new Promise<RecallStageResult | null>((resolve) => {
60
+ timer = setTimeout(() => {
61
+ ac.abort();
62
+ resolve(null);
63
+ }, RECALL_STAGE_TIMEOUT_MS);
64
+ }),
65
+ ]);
66
+ } finally {
67
+ if (timer !== undefined) clearTimeout(timer);
68
+ }
46
69
  }
47
70
 
48
71
  async function runRecall(
@@ -50,6 +73,7 @@ async function runRecall(
50
73
  api: ClawdbotPluginApi,
51
74
  ctx: LifecycleContext,
52
75
  sessionState: SessionState,
76
+ signal?: AbortSignal,
53
77
  ): Promise<RecallStageResult> {
54
78
  const e = event as { prompt?: string };
55
79
  if (!e.prompt || e.prompt.length < 5) {
@@ -59,6 +83,8 @@ async function runRecall(
59
83
  ctx.recallInFlightRef.value++;
60
84
  const recallStartMs = Date.now();
61
85
  try {
86
+ if (recallAborted(signal)) return emptyRecallStage();
87
+
62
88
  const { currentAgentIdRef } = ctx;
63
89
  const { resolveSessionKey, ambientSeenFactsMap, ambientLastEmbeddingMap, pruneSessionMaps, sessionStartSeen } =
64
90
  sessionState;
@@ -67,6 +93,7 @@ async function runRecall(
67
93
 
68
94
  // Let pending gateway I/O (health RPCs, WebSocket) run before heavy sync work (#931).
69
95
  await yieldEventLoop();
96
+ if (recallAborted(signal)) return emptyRecallStage();
70
97
 
71
98
  const fmt = ctx.cfg.autoRecall.injectionFormat;
72
99
  const isProgressive = fmt === "progressive" || fmt === "progressive_hybrid";
@@ -102,6 +129,9 @@ async function runRecall(
102
129
  ctx.cfg.queryExpansion,
103
130
  ctx.cfg.retrieval,
104
131
  );
132
+ api.logger.debug?.(
133
+ `memory-hybrid: interactive enrichment=${interactivePolicy.interactiveEnrichment} (HyDE=${interactivePolicy.allowHyde}, ambientMulti=${interactivePolicy.allowAmbientMultiQuery})`,
134
+ );
105
135
  const { degradationQueueDepth, degradationMaxLatencyMs } = interactivePolicy;
106
136
  const forceDegraded = degradationQueueDepth > 0 && ctx.recallInFlightRef.value > degradationQueueDepth;
107
137
 
@@ -240,6 +270,7 @@ async function runRecall(
240
270
  }
241
271
 
242
272
  await yieldEventLoop();
273
+ if (recallAborted(signal)) return emptyRecallStage();
243
274
 
244
275
  const recallOpts = {
245
276
  tierFilter,
@@ -282,6 +313,8 @@ async function runRecall(
282
313
  const ambientSeenFacts = ambientSeenFactsMap.get(sessionScopeKey)!;
283
314
  const ambientLastEmbedding = ambientLastEmbeddingMap.get(sessionScopeKey) ?? null;
284
315
 
316
+ if (recallAborted(signal)) return emptyRecallStage();
317
+
285
318
  let promptEmbedding: number[] | null = null;
286
319
  if (
287
320
  interactivePolicy.allowAmbientMultiQuery &&
@@ -296,6 +329,8 @@ async function runRecall(
296
329
  }
297
330
  }
298
331
 
332
+ if (recallAborted(signal)) return emptyRecallStage();
333
+
299
334
  let candidates = await runRecallPipelineQuery(e.prompt, limit, pipelineDeps, hydeUsedRef, {
300
335
  hydeLabel: "HyDE",
301
336
  errorPrefix: "auto-recall-",
@@ -303,6 +338,8 @@ async function runRecall(
303
338
  policy: interactivePolicy,
304
339
  });
305
340
 
341
+ if (recallAborted(signal)) return emptyRecallStage();
342
+
306
343
  if (interactivePolicy.allowAmbientMultiQuery && ambientCfg.enabled && ambientCfg.multiQuery) {
307
344
  try {
308
345
  const isTopicShift =
@@ -323,6 +360,7 @@ async function runRecall(
323
360
  if (extraQueries.length > 0) {
324
361
  const extraResultSets: SearchResult[][] = [candidates];
325
362
  for (const q of extraQueries) {
363
+ if (recallAborted(signal)) return emptyRecallStage();
326
364
  await yieldEventLoop();
327
365
  try {
328
366
  const qResults = await runRecallPipelineQuery(q.text, Math.ceil(limit / 2), pipelineDeps, hydeUsedRef, {
@@ -408,31 +446,35 @@ async function runRecall(
408
446
  }
409
447
 
410
448
  await yieldEventLoop();
449
+ if (recallAborted(signal)) return emptyRecallStage();
411
450
 
412
451
  const promptLower = e.prompt.toLowerCase();
413
452
  const { entityLookup } = ctx.cfg.autoRecall;
414
- if (entityLookup.enabled && entityLookup.entities.length > 0) {
415
- const seenIds = new Set(candidates.map((c) => c.entry.id));
416
- for (const entity of entityLookup.entities) {
417
- if (!promptLower.includes(entity.toLowerCase())) continue;
418
- const entityResults = ctx.factsDb
419
- .lookup(entity, undefined, undefined, { scopeFilter })
420
- .slice(0, entityLookup.maxFactsPerEntity);
421
- for (const r of entityResults) {
422
- if (!seenIds.has(r.entry.id)) {
423
- seenIds.add(r.entry.id);
424
- candidates.push(r);
453
+ if (entityLookup.enabled) {
454
+ const entityLookupNames = resolveEntityLookupNames(entityLookup, ctx.factsDb);
455
+ if (entityLookupNames.length > 0) {
456
+ const seenIds = new Set(candidates.map((c) => c.entry.id));
457
+ for (const entity of entityLookupNames) {
458
+ if (!promptLower.includes(entity.toLowerCase())) continue;
459
+ const entityResults = ctx.factsDb
460
+ .lookup(entity, undefined, undefined, { scopeFilter })
461
+ .slice(0, entityLookup.maxFactsPerEntity);
462
+ for (const r of entityResults) {
463
+ if (!seenIds.has(r.entry.id)) {
464
+ seenIds.add(r.entry.id);
465
+ candidates.push(r);
466
+ }
425
467
  }
426
468
  }
469
+ candidates.sort((a, b) => {
470
+ const s = b.score - a.score;
471
+ if (s !== 0) return s;
472
+ const da = a.entry.sourceDate ?? a.entry.createdAt;
473
+ const db = b.entry.sourceDate ?? b.entry.createdAt;
474
+ return db - da;
475
+ });
476
+ candidates = candidates.slice(0, limit);
427
477
  }
428
- candidates.sort((a, b) => {
429
- const s = b.score - a.score;
430
- if (s !== 0) return s;
431
- const da = a.entry.sourceDate ?? a.entry.createdAt;
432
- const db = b.entry.sourceDate ?? b.entry.createdAt;
433
- return db - da;
434
- });
435
- candidates = candidates.slice(0, limit);
436
478
  }
437
479
 
438
480
  const directivesCfg = ctx.cfg.autoRecall.retrievalDirectives;
@@ -461,23 +503,29 @@ async function runRecall(
461
503
 
462
504
  if (directivesCfg.enabled) {
463
505
  try {
464
- if (directivesCfg.entityMentioned && entityLookup.enabled && entityLookup.entities.length > 0) {
465
- for (const entity of entityLookup.entities) {
466
- if (!promptLower.includes(entity.toLowerCase())) continue;
467
- if (!canRunDirective()) break;
468
- const results = await runRecallPipelineQuery(entity, directiveLimit, pipelineDeps, hydeUsedRef, {
469
- entity,
470
- hydeLabel: "HyDE",
471
- errorPrefix: "directive-",
472
- limitHydeOnce: true,
473
- policy: interactivePolicy,
474
- });
475
- directiveCalls += 1;
476
- addDirectiveResults(results, `entity:${entity}`);
506
+ if (recallAborted(signal)) return emptyRecallStage();
507
+ if (directivesCfg.entityMentioned && entityLookup.enabled) {
508
+ const entityLookupNames = resolveEntityLookupNames(entityLookup, ctx.factsDb);
509
+ if (entityLookupNames.length > 0) {
510
+ for (const entity of entityLookupNames) {
511
+ if (recallAborted(signal)) return emptyRecallStage();
512
+ if (!promptLower.includes(entity.toLowerCase())) continue;
513
+ if (!canRunDirective()) break;
514
+ const results = await runRecallPipelineQuery(entity, directiveLimit, pipelineDeps, hydeUsedRef, {
515
+ entity,
516
+ hydeLabel: "HyDE",
517
+ errorPrefix: "directive-",
518
+ limitHydeOnce: true,
519
+ policy: interactivePolicy,
520
+ });
521
+ directiveCalls += 1;
522
+ addDirectiveResults(results, `entity:${entity}`);
523
+ }
477
524
  }
478
525
  }
479
526
  if (directivesCfg.keywords.length > 0) {
480
527
  for (const keyword of directivesCfg.keywords) {
528
+ if (recallAborted(signal)) return emptyRecallStage();
481
529
  if (!promptLower.includes(keyword.toLowerCase())) continue;
482
530
  if (!canRunDirective()) break;
483
531
  const results = await runRecallPipelineQuery(keyword, directiveLimit, pipelineDeps, hydeUsedRef, {
@@ -491,6 +539,7 @@ async function runRecall(
491
539
  }
492
540
  }
493
541
  for (const [taskType, triggers] of Object.entries(directivesCfg.taskTypes)) {
542
+ if (recallAborted(signal)) return emptyRecallStage();
494
543
  const hit = triggers.some((t) => promptLower.includes(t.toLowerCase()));
495
544
  if (!hit || !canRunDirective()) continue;
496
545
  const results = await runRecallPipelineQuery(taskType, directiveLimit, pipelineDeps, hydeUsedRef, {
@@ -503,6 +552,7 @@ async function runRecall(
503
552
  addDirectiveResults(results, `taskType:${taskType}`);
504
553
  }
505
554
  if (directivesCfg.sessionStart) {
555
+ if (recallAborted(signal)) return emptyRecallStage();
506
556
  const sessionKey = resolveSessionKey(e, api) ?? currentAgentIdRef.value ?? "default";
507
557
  if (!sessionStartSeen.has(sessionKey) && canRunDirective()) {
508
558
  const results = await runRecallPipelineQuery("session start", directiveLimit, pipelineDeps, hydeUsedRef, {
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "openclaw-hybrid-memory",
3
- "version": "2026.3.310",
3
+ "version": "2026.4.10",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "openclaw-hybrid-memory",
9
- "version": "2026.3.310",
9
+ "version": "2026.4.10",
10
10
  "hasInstallScript": true,
11
11
  "dependencies": {
12
12
  "@lancedb/lancedb": "^0.27.1",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "openclaw-hybrid-memory",
3
3
  "kind": "memory",
4
- "version": "2026.3.310",
4
+ "version": "2026.4.10",
5
5
  "uiHints": {
6
6
  "embedding.provider": {
7
7
  "label": "Embedding Provider",
@@ -101,7 +101,15 @@
101
101
  },
102
102
  "autoRecall.entityLookup": {
103
103
  "label": "Auto-Recall entity lookup",
104
- "help": "When prompt mentions an entity from the list, merge lookup(entity) facts into candidates (4.1)"
104
+ "help": "When the prompt mentions an entity, merge lookup(entity) facts. If `entities` is empty and `autoFromFacts` is true (default), names come from DISTINCT entity on stored facts (capped by maxAutoEntities)."
105
+ },
106
+ "autoRecall.entityLookup.autoFromFacts": {
107
+ "label": "Entity lookup: auto from facts DB",
108
+ "help": "When true (default) and `entities` is empty, use distinct `entity` values from the facts table (up to maxAutoEntities). Set false to require an explicit `entities` list."
109
+ },
110
+ "autoRecall.entityLookup.maxAutoEntities": {
111
+ "label": "Entity lookup: max auto names",
112
+ "help": "Cap for auto-loaded entity names from the DB (default 500, max 2000)."
105
113
  },
106
114
  "autoRecall.retrievalDirectives.enabled": {
107
115
  "label": "Retrieval directives",
@@ -168,6 +176,10 @@
168
176
  "label": "Progressive pinned recall count",
169
177
  "help": "In progressive_hybrid: facts with recallCount >= this or permanent decay are injected in full (default 3)"
170
178
  },
179
+ "autoRecall.interactiveEnrichment": {
180
+ "label": "Interactive recall enrichment",
181
+ "help": "Single knob for chat-turn cost/latency: fast = no HyDE + no ambient multi-query; balanced (default) = respect queryExpansion.skipForInteractiveTurns and ambient.*; full = HyDE when query expansion is on + ambient multi-query path when ambient is configured."
182
+ },
171
183
  "autoRecall.authFailure.enabled": {
172
184
  "label": "Auth failure auto-recall",
173
185
  "help": "Detect authentication failures and auto-inject relevant credentials (default: true)"
@@ -609,6 +621,16 @@
609
621
  },
610
622
  "maxFactsPerEntity": {
611
623
  "type": "number"
624
+ },
625
+ "autoFromFacts": {
626
+ "type": "boolean",
627
+ "description": "When true (default) and entities is empty, load names from DISTINCT entity on facts (capped by maxAutoEntities)."
628
+ },
629
+ "maxAutoEntities": {
630
+ "type": "number",
631
+ "minimum": 1,
632
+ "maximum": 2000,
633
+ "description": "Max distinct entity names when using autoFromFacts (default 500)."
612
634
  }
613
635
  }
614
636
  },
@@ -665,6 +687,11 @@
665
687
  "summarizeModel": {
666
688
  "type": "string"
667
689
  },
690
+ "interactiveEnrichment": {
691
+ "type": "string",
692
+ "enum": ["fast", "balanced", "full"],
693
+ "description": "Unified control for interactive auto-recall: fast = lowest latency/cost (no HyDE, no ambient multi-query); balanced = default; full = richer (HyDE + ambient multi when configured)."
694
+ },
668
695
  "authFailure": {
669
696
  "type": "object",
670
697
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-hybrid-memory",
3
- "version": "2026.3.310",
3
+ "version": "2026.4.10",
4
4
  "type": "module",
5
5
  "description": "Give your OpenClaw agent lasting memory: structured facts, semantic search, auto-capture & recall, decay, optional credential vault. Part of Hybrid Memory v3.",
6
6
  "files": [
@@ -511,16 +511,15 @@ export class HybridMemoryContextEngine implements MinimalContextEngine {
511
511
  * Post-subagent cleanup: capture any session-scoped facts created by the sub-agent
512
512
  * and promote them to the appropriate scope.
513
513
  *
514
- * Guard against double-processing: the subagent_ended hook in lifecycle/hooks.ts
515
- * handles the primary fact capture pipeline. This method provides a lightweight
516
- * secondary pass scoped to the ContextEngine lifecycle, and only runs on
517
- * OpenClaw 2026.3.8. If a fact is already in the store it will be skipped
518
- * by the hasDuplicate check.
514
+ * Guard against double-processing: OpenClaw's typed **`subagent_ended`** hook
515
+ * (`lifecycle/stage-cleanup.ts`) only performs ACTIVE-TASK.md checkpointing not fact capture.
516
+ * This method provides a lightweight ContextEngine callback; primary fact capture is the
517
+ * child session's **agent_end** autoCapture path. If a fact is already in the store it will be
518
+ * skipped by the hasDuplicate check when that pipeline exists.
519
519
  *
520
520
  * NOTE: The current SDK interface does not pass the sub-agent's result text here.
521
- * Full result-text capture is handled by the lifecycle/hooks.ts subagent_ended handler.
522
- * When the SDK interface is extended to include result text, this method should parse
523
- * it using the existing autoCapture logic (see lifecycle/hooks.ts agent_end handler).
521
+ * When the SDK exposes result text, parse it using the existing autoCapture logic
522
+ * (see lifecycle/hooks.ts agent_end handler).
524
523
  */
525
524
  async onSubagentEnded(params: { childSessionKey: string; reason: string }): Promise<void> {
526
525
  const { factsDb, logger } = this.opts;
@@ -556,7 +555,7 @@ export class HybridMemoryContextEngine implements MinimalContextEngine {
556
555
  // Until then, all sub-agent fact capture is delegated to:
557
556
  // (a) The child session's own agent_end autoCapture hook (primary path — runs
558
557
  // inside the child's session and writes directly to the shared FactsDB)
559
- // (b) The subagent_end hook in lifecycle/hooks.ts (active-task checkpoint only)
558
+ // (b) The typed subagent_ended hook in lifecycle/stage-cleanup.ts (ACTIVE-TASK.md only; issue #966)
560
559
  } catch (err) {
561
560
  capturePluginError(err instanceof Error ? err : new Error(String(err)), {
562
561
  subsystem: "context-engine",
@@ -19,6 +19,8 @@ export interface InteractiveRecallPolicy {
19
19
  degradationMaxLatencyMs: number;
20
20
  allowHyde: boolean;
21
21
  allowAmbientMultiQuery: boolean;
22
+ /** Resolved from `autoRecall.interactiveEnrichment` (default balanced). */
23
+ interactiveEnrichment: "fast" | "balanced" | "full";
22
24
  notes: string[];
23
25
  }
24
26
 
@@ -37,8 +39,10 @@ export interface ExplicitDeepRetrievalPolicy {
37
39
  notes: string[];
38
40
  }
39
41
 
40
- export const INTERACTIVE_RECALL_STAGE_TIMEOUT_MS = 35_000;
41
- const INTERACTIVE_RECALL_VECTOR_TIMEOUT_MS = 30_000;
42
+ /** Wall-clock cap for the whole interactive recall stage (abort + return when exceeded). */
43
+ export const INTERACTIVE_RECALL_STAGE_TIMEOUT_MS = 32_000;
44
+ /** Per-vector-step cap (HyDE + embed + Lance) inside `runRecallPipelineQuery`. Kept below stage timeout to leave slack for FTS, ambient, directives. */
45
+ const INTERACTIVE_RECALL_VECTOR_TIMEOUT_MS = 26_000;
42
46
  const DEFAULT_INTERACTIVE_RECALL_DEGRADATION_QUEUE_DEPTH = 10;
43
47
  const DEFAULT_INTERACTIVE_RECALL_DEGRADATION_MAX_LATENCY_MS = 5_000;
44
48
 
@@ -60,6 +64,7 @@ export const DEFAULT_INTERACTIVE_RECALL_POLICY: InteractiveRecallPolicy = {
60
64
  degradationMaxLatencyMs: DEFAULT_INTERACTIVE_RECALL_DEGRADATION_MAX_LATENCY_MS,
61
65
  allowHyde: false,
62
66
  allowAmbientMultiQuery: true,
67
+ interactiveEnrichment: "balanced",
63
68
  notes: [
64
69
  "Owns the hot path for chat turns.",
65
70
  "Falls back to bounded FTS-only/HOT recall under pressure.",
@@ -72,8 +77,22 @@ export function resolveInteractiveRecallPolicy(
72
77
  queryExpansion?: { enabled: boolean; skipForInteractiveTurns: boolean },
73
78
  retrieval?: { ambientBudgetTokens: number },
74
79
  ): InteractiveRecallPolicy {
75
- // When queryExpansion.skipForInteractiveTurns is false, allow HyDE on interactive turns
76
- const allowHyde = queryExpansion?.enabled === true && queryExpansion.skipForInteractiveTurns !== true;
80
+ const enrichment = cfg.interactiveEnrichment ?? "balanced";
81
+
82
+ // Baseline (balanced): HyDE on interactive turns only when QE is on and skipForInteractiveTurns is not true.
83
+ let allowHyde = queryExpansion?.enabled === true && queryExpansion.skipForInteractiveTurns !== true;
84
+ // Historically true whenever auto-recall is on; ambient multi-query still requires ambient.enabled && multiQuery in stage-recall.
85
+ let allowAmbientMultiQuery = cfg.enabled === true;
86
+
87
+ if (enrichment === "fast") {
88
+ allowHyde = false;
89
+ allowAmbientMultiQuery = false;
90
+ } else if (enrichment === "full") {
91
+ // HyDE whenever query expansion is enabled; ignore skipForInteractiveTurns for the hot path.
92
+ allowHyde = queryExpansion?.enabled === true;
93
+ allowAmbientMultiQuery = cfg.enabled === true;
94
+ }
95
+
77
96
  // Enforce retrieval.ambientBudgetTokens as a hard total-token cap.
78
97
  // autoRecall.maxTokens is a user preference; ambientBudgetTokens is the architectural
79
98
  // ceiling — the injected context must not exceed either.
@@ -83,8 +102,9 @@ export function resolveInteractiveRecallPolicy(
83
102
  contextBudgetTokens,
84
103
  degradationQueueDepth: cfg.degradationQueueDepth ?? DEFAULT_INTERACTIVE_RECALL_DEGRADATION_QUEUE_DEPTH,
85
104
  degradationMaxLatencyMs: cfg.degradationMaxLatencyMs ?? DEFAULT_INTERACTIVE_RECALL_DEGRADATION_MAX_LATENCY_MS,
86
- allowAmbientMultiQuery: cfg.enabled === true,
105
+ allowAmbientMultiQuery,
87
106
  allowHyde,
107
+ interactiveEnrichment: enrichment,
88
108
  };
89
109
  }
90
110
 
@@ -257,31 +257,8 @@ export function registerLifecycleHooks(ctx: HooksContext, api: ClawdbotPluginApi
257
257
  api.logger.debug?.(`memory-hybrid: before_compaction hook not available (${err})`);
258
258
  }
259
259
 
260
- try {
261
- api.on("before_consolidation", async (event: unknown) => {
262
- const ev = event as {
263
- candidateCount?: number;
264
- source?: string;
265
- sessionFile?: string;
266
- };
267
-
268
- await runPreConsolidationFlush(
269
- { wal: ctx.wal, factsDb: ctx.factsDb, vectorDb: ctx.vectorDb, embeddings: ctx.embeddings },
270
- api.logger,
271
- "before_consolidation",
272
- );
273
-
274
- api.logger.info?.(
275
- `memory-hybrid: before_consolidation — candidates=${ev.candidateCount ?? "?"} source=${ev.source ?? "?"}`,
276
- );
277
- });
278
- } catch (err) {
279
- capturePluginError(err instanceof Error ? err : new Error(String(err)), {
280
- subsystem: "lifecycle",
281
- operation: "register-before_consolidation",
282
- });
283
- api.logger.debug?.(`memory-hybrid: before_consolidation hook not available (${err})`);
284
- }
260
+ // Issue #966: Do not register `before_consolidation` — it is not in OpenClaw's PLUGIN_HOOK_NAMES (ignored + noisy).
261
+ // WAL flush before compaction-style work is handled solely by `before_compaction` above (runPreConsolidationFlush).
285
262
 
286
263
  try {
287
264
  api.on("after_compaction", async (event: unknown): Promise<undefined | { prependContext: string }> => {
@@ -0,0 +1,25 @@
1
+ import type { EntityLookupConfig } from "../config/types/retrieval.js";
2
+
3
+ export type FactsDbWithKnownEntities = {
4
+ getKnownEntities?: () => string[];
5
+ };
6
+
7
+ /**
8
+ * Effective entity names for auto-recall entity lookup + retrieval directives.
9
+ * Manual `entities` wins; when empty and `autoFromFacts`, use DISTINCT entity from facts (capped).
10
+ */
11
+ export function resolveEntityLookupNames(
12
+ entityLookup: EntityLookupConfig,
13
+ factsDb: FactsDbWithKnownEntities,
14
+ ): string[] {
15
+ if (entityLookup.entities.length > 0) return entityLookup.entities;
16
+ if (!entityLookup.autoFromFacts) return [];
17
+ const raw = factsDb.getKnownEntities?.() ?? [];
18
+ const filtered = raw.filter((e) => typeof e === "string" && e.trim().length > 0);
19
+ // Stable order before capping so the same DB always yields the same subset under maxAutoEntities
20
+ // (SQL DISTINCT without ORDER BY is not guaranteed deterministic).
21
+ const sorted = [...filtered].sort((a, b) =>
22
+ a.trim().localeCompare(b.trim(), undefined, { sensitivity: "base", numeric: true }),
23
+ );
24
+ return sorted.slice(0, entityLookup.maxAutoEntities);
25
+ }