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 +11 -1
- package/config/parsers/retrieval.ts +16 -1
- package/config/types/retrieval.ts +14 -0
- package/config/utils.ts +24 -4
- package/lifecycle/hooks.ts +1 -1
- package/lifecycle/stage-cleanup.ts +58 -21
- package/lifecycle/stage-recall.ts +85 -35
- package/npm-shrinkwrap.json +2 -2
- package/openclaw.plugin.json +29 -2
- package/package.json +1 -1
- package/services/context-engine.ts +8 -9
- package/services/retrieval-mode-policy.ts +25 -5
- package/setup/register-hooks.ts +2 -25
- package/utils/entity-lookup-resolve.ts +25 -0
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
|
-
|
|
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: {
|
|
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: {
|
|
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: {
|
|
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: {
|
|
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 },
|
package/lifecycle/hooks.ts
CHANGED
|
@@ -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,
|
|
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
|
-
*
|
|
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
|
|
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("
|
|
323
|
+
api.on("subagent_spawned", async (event: unknown) => {
|
|
291
324
|
try {
|
|
292
|
-
const ev = event as
|
|
293
|
-
const
|
|
294
|
-
const
|
|
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:
|
|
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
|
|
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-
|
|
359
|
+
operation: "active-task-subagent-spawned",
|
|
326
360
|
subsystem: "active-task",
|
|
327
361
|
});
|
|
328
|
-
api.logger.debug?.(`memory-hybrid: active task auto-checkpoint on
|
|
362
|
+
api.logger.debug?.(`memory-hybrid: active task auto-checkpoint on subagent_spawned failed: ${err}`);
|
|
329
363
|
}
|
|
330
364
|
});
|
|
331
365
|
|
|
332
|
-
api.on("
|
|
366
|
+
api.on("subagent_ended", async (event: unknown) => {
|
|
333
367
|
try {
|
|
334
|
-
const ev = event as
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
449
|
+
`memory-hybrid: skipped ACTIVE-TASK.md write in subagent_ended (Failed): ${writeResult.reason}`,
|
|
415
450
|
);
|
|
416
451
|
} else {
|
|
417
|
-
api.logger.info?.(
|
|
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-
|
|
467
|
+
operation: "active-task-subagent-ended",
|
|
431
468
|
subsystem: "active-task",
|
|
432
469
|
});
|
|
433
|
-
api.logger.debug?.(`memory-hybrid: active task auto-checkpoint on
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
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
|
|
415
|
-
const
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
const
|
|
419
|
-
.
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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 (
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
const
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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, {
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openclaw-hybrid-memory",
|
|
3
|
-
"version": "2026.
|
|
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.
|
|
9
|
+
"version": "2026.4.10",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@lancedb/lancedb": "^0.27.1",
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "openclaw-hybrid-memory",
|
|
3
3
|
"kind": "memory",
|
|
4
|
-
"version": "2026.
|
|
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
|
|
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
|
+
"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:
|
|
515
|
-
*
|
|
516
|
-
*
|
|
517
|
-
*
|
|
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
|
-
*
|
|
522
|
-
*
|
|
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
|
|
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
|
-
|
|
41
|
-
const
|
|
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
|
-
|
|
76
|
-
|
|
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
|
|
105
|
+
allowAmbientMultiQuery,
|
|
87
106
|
allowHyde,
|
|
107
|
+
interactiveEnrichment: enrichment,
|
|
88
108
|
};
|
|
89
109
|
}
|
|
90
110
|
|
package/setup/register-hooks.ts
CHANGED
|
@@ -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
|
-
|
|
261
|
-
|
|
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
|
+
}
|