openclaw-hybrid-memory 2026.3.310 → 2026.4.11
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/backends/facts-db/fact-queries.ts +6 -2
- package/backends/facts-db.ts +77 -14
- 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 +27 -4
- package/lifecycle/hooks.ts +1 -1
- package/lifecycle/resolve-agent-id.ts +42 -0
- package/lifecycle/stage-auth-failure.ts +1 -0
- package/lifecycle/stage-cleanup.ts +58 -21
- package/lifecycle/stage-recall.ts +87 -35
- package/lifecycle/stage-setup.ts +2 -2
- package/npm-shrinkwrap.json +2 -2
- package/openclaw.plugin.json +37 -2
- package/package.json +1 -1
- package/services/context-engine.ts +8 -9
- package/services/recall-pipeline.ts +2 -0
- package/services/retrieval-mode-policy.ts +25 -5
- package/setup/register-hooks.ts +2 -25
- package/utils/constants.ts +5 -0
- package/utils/entity-lookup-resolve.ts +25 -0
|
@@ -15,9 +15,13 @@ export function fetchSupersededFactTextsLower(db: DatabaseSync): string[] {
|
|
|
15
15
|
* OR-joined quoted FTS terms (+ optional prefix terms) for classification-style lookups (#898 alignment with porter).
|
|
16
16
|
*/
|
|
17
17
|
/** Broader OR clause for `FactsDB.search()` (min term length 1, all tokens, porter prefix). */
|
|
18
|
-
export function buildFactsSearchFtsOrClause(query: string): string | null {
|
|
18
|
+
export function buildFactsSearchFtsOrClause(query: string, options?: { maxOrTerms?: number }): string | null {
|
|
19
19
|
const sanitized = sanitizeFts5QueryForFacts(query);
|
|
20
|
-
|
|
20
|
+
let terms = sanitized.split(/\s+/).filter((w) => w.length > 1);
|
|
21
|
+
const cap = options?.maxOrTerms;
|
|
22
|
+
if (cap !== undefined && cap > 0 && terms.length > cap) {
|
|
23
|
+
terms = terms.slice(0, cap);
|
|
24
|
+
}
|
|
21
25
|
if (terms.length === 0) return null;
|
|
22
26
|
const parts = terms.map((w) => {
|
|
23
27
|
if (/^[a-zA-Z0-9_]+$/.test(w) && w.length >= 3) {
|
package/backends/facts-db.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { applyConsolidationRetrievalControls } from "../utils/consolidation-cont
|
|
|
25
25
|
import { computeDynamicSalience } from "../utils/salience.js";
|
|
26
26
|
import { estimateTokensForDisplay } from "../utils/text.js";
|
|
27
27
|
import { capturePluginError } from "../services/error-reporter.js";
|
|
28
|
+
import { INTERACTIVE_FTS_MAX_OR_TERMS } from "../utils/constants.js";
|
|
28
29
|
import { getLanguageKeywordsFilePath } from "../utils/language-keywords.js";
|
|
29
30
|
import { createTransaction } from "../utils/sqlite-transaction.js";
|
|
30
31
|
import { tryRestrictSqliteDbFileMode } from "../utils/sqlite-file-perms.js";
|
|
@@ -1964,6 +1965,11 @@ export class FactsDB extends BaseSqliteStore {
|
|
|
1964
1965
|
reinforcementBoost?: number;
|
|
1965
1966
|
/** Weight applied to diversity score when calculating effective boost (default: 1.0). */
|
|
1966
1967
|
diversityWeight?: number;
|
|
1968
|
+
/**
|
|
1969
|
+
* Interactive auto-recall hot path: cap FTS OR-term explosion and avoid loading full fact rows
|
|
1970
|
+
* until top matches are chosen (reduces WhatsApp/gateway stalls from huge MATCH + wide SELECT f.*).
|
|
1971
|
+
*/
|
|
1972
|
+
interactiveFtsFastPath?: boolean;
|
|
1967
1973
|
} = {},
|
|
1968
1974
|
): SearchResult[] {
|
|
1969
1975
|
const {
|
|
@@ -1975,9 +1981,12 @@ export class FactsDB extends BaseSqliteStore {
|
|
|
1975
1981
|
scopeFilter,
|
|
1976
1982
|
reinforcementBoost = 0.1,
|
|
1977
1983
|
diversityWeight = 1.0,
|
|
1984
|
+
interactiveFtsFastPath = false,
|
|
1978
1985
|
} = options;
|
|
1979
1986
|
|
|
1980
|
-
const safeQuery =
|
|
1987
|
+
const safeQuery = interactiveFtsFastPath
|
|
1988
|
+
? buildFactsSearchFtsOrClause(query, { maxOrTerms: INTERACTIVE_FTS_MAX_OR_TERMS })
|
|
1989
|
+
: buildFactsSearchFtsOrClause(query);
|
|
1981
1990
|
if (!safeQuery) return [];
|
|
1982
1991
|
|
|
1983
1992
|
const nowSec = Math.floor(Date.now() / 1000);
|
|
@@ -1993,9 +2002,70 @@ export class FactsDB extends BaseSqliteStore {
|
|
|
1993
2002
|
const tierFilterClause = tierFilter === "warm" ? "AND (f.tier IS NULL OR f.tier = 'warm' OR f.tier = 'hot')" : "";
|
|
1994
2003
|
const { clause: scopeFilterClauseStr, params: scopeParams } = this.scopeFilterClause(scopeFilter);
|
|
1995
2004
|
|
|
1996
|
-
const
|
|
1997
|
-
|
|
1998
|
-
|
|
2005
|
+
const decayWindowSec = 7 * 24 * 3600;
|
|
2006
|
+
const paramBag: Record<string, SQLInputValue> = {
|
|
2007
|
+
"@query": safeQuery,
|
|
2008
|
+
"@now": nowSec,
|
|
2009
|
+
...(asOf != null ? { "@asOf": asOf } : {}),
|
|
2010
|
+
"@limit": limit * 2,
|
|
2011
|
+
"@decay_window": decayWindowSec,
|
|
2012
|
+
...(tagPattern ? { "@tagPattern": tagPattern } : {}),
|
|
2013
|
+
...scopeParams,
|
|
2014
|
+
};
|
|
2015
|
+
|
|
2016
|
+
let rows: Array<Record<string, unknown>>;
|
|
2017
|
+
|
|
2018
|
+
if (interactiveFtsFastPath) {
|
|
2019
|
+
// node:sqlite rejects named parameters not referenced by the prepared SQL — omit @decay_window for the narrow query.
|
|
2020
|
+
const narrowParamBag: Record<string, SQLInputValue> = {
|
|
2021
|
+
"@query": safeQuery,
|
|
2022
|
+
"@now": nowSec,
|
|
2023
|
+
...(asOf != null ? { "@asOf": asOf } : {}),
|
|
2024
|
+
"@limit": limit * 2,
|
|
2025
|
+
...(tagPattern ? { "@tagPattern": tagPattern } : {}),
|
|
2026
|
+
...scopeParams,
|
|
2027
|
+
};
|
|
2028
|
+
const narrowSql = `SELECT f.id AS id, fts.rank AS fts_score
|
|
2029
|
+
FROM facts f
|
|
2030
|
+
JOIN facts_fts fts ON f.rowid = fts.rowid
|
|
2031
|
+
WHERE facts_fts MATCH @query
|
|
2032
|
+
${expiryFilter}
|
|
2033
|
+
${temporalFilter}
|
|
2034
|
+
${tagFilter}
|
|
2035
|
+
${tierFilterClause}
|
|
2036
|
+
${scopeFilterClauseStr}
|
|
2037
|
+
ORDER BY fts.rank
|
|
2038
|
+
LIMIT @limit`;
|
|
2039
|
+
const narrowRows = this.liveDb.prepare(narrowSql).all(narrowParamBag) as Array<{ id: string; fts_score: number }>;
|
|
2040
|
+
if (narrowRows.length === 0) return [];
|
|
2041
|
+
const ids = narrowRows.map((r) => r.id);
|
|
2042
|
+
const ftsById = new Map(narrowRows.map((r) => [r.id, r.fts_score]));
|
|
2043
|
+
const placeholders = ids.map(() => "?").join(",");
|
|
2044
|
+
const fullRows = this.liveDb.prepare(`SELECT * FROM facts WHERE id IN (${placeholders})`).all(...ids) as Array<
|
|
2045
|
+
Record<string, unknown>
|
|
2046
|
+
>;
|
|
2047
|
+
const byId = new Map(fullRows.map((r) => [r.id as string, r]));
|
|
2048
|
+
const decayWindow = decayWindowSec;
|
|
2049
|
+
rows = [];
|
|
2050
|
+
for (const id of ids) {
|
|
2051
|
+
const row = byId.get(id);
|
|
2052
|
+
if (!row) continue;
|
|
2053
|
+
const expiresAt = row.expires_at as number | null | undefined;
|
|
2054
|
+
let freshness: number;
|
|
2055
|
+
if (expiresAt == null) freshness = 1.0;
|
|
2056
|
+
else if (expiresAt <= nowSec) freshness = 0.0;
|
|
2057
|
+
else freshness = Math.min(1.0, (expiresAt - nowSec) / decayWindow);
|
|
2058
|
+
rows.push({
|
|
2059
|
+
...row,
|
|
2060
|
+
fts_score: ftsById.get(id) as number,
|
|
2061
|
+
freshness,
|
|
2062
|
+
});
|
|
2063
|
+
}
|
|
2064
|
+
if (rows.length === 0) return [];
|
|
2065
|
+
} else {
|
|
2066
|
+
rows = this.liveDb
|
|
2067
|
+
.prepare(
|
|
2068
|
+
`SELECT f.*, bm25(facts_fts) as fts_score,
|
|
1999
2069
|
CASE
|
|
2000
2070
|
WHEN f.expires_at IS NULL THEN 1.0
|
|
2001
2071
|
WHEN f.expires_at <= @now THEN 0.0
|
|
@@ -2011,16 +2081,9 @@ export class FactsDB extends BaseSqliteStore {
|
|
|
2011
2081
|
${scopeFilterClauseStr}
|
|
2012
2082
|
ORDER BY bm25(facts_fts)
|
|
2013
2083
|
LIMIT @limit`,
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
"@now": nowSec,
|
|
2018
|
-
...(asOf != null ? { "@asOf": asOf } : {}),
|
|
2019
|
-
"@limit": limit * 2,
|
|
2020
|
-
"@decay_window": 7 * 24 * 3600,
|
|
2021
|
-
...(tagPattern ? { "@tagPattern": tagPattern } : {}),
|
|
2022
|
-
...scopeParams,
|
|
2023
|
-
}) as Array<Record<string, unknown>>;
|
|
2084
|
+
)
|
|
2085
|
+
.all(paramBag) as Array<Record<string, unknown>>;
|
|
2086
|
+
}
|
|
2024
2087
|
|
|
2025
2088
|
if (rows.length === 0) return [];
|
|
2026
2089
|
|
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,14 @@ 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
|
-
|
|
36
|
+
/** Credentials vault on; set `credentials.encryptionKey` (16+ chars or env:VAR) for encryption at rest. */
|
|
37
|
+
credentials: { enabled: true },
|
|
38
|
+
autoRecall: {
|
|
39
|
+
enabled: true,
|
|
40
|
+
interactiveEnrichment: "fast",
|
|
41
|
+
entityLookup: { enabled: false },
|
|
42
|
+
authFailure: { enabled: false },
|
|
43
|
+
},
|
|
37
44
|
autoClassify: { enabled: false, suggestCategories: false },
|
|
38
45
|
store: { fuzzyDedupe: true, classifyBeforeWrite: false },
|
|
39
46
|
graph: { enabled: false },
|
|
@@ -51,7 +58,13 @@ export const PRESET_OVERRIDES: Record<ConfigMode, Record<string, unknown>> = {
|
|
|
51
58
|
/** Minimal: nano for auto-classify, default (flash) for distill — good value at low cost. Ingest paths on so occasional ingest-files gets facts. */
|
|
52
59
|
minimal: {
|
|
53
60
|
autoCapture: true,
|
|
54
|
-
|
|
61
|
+
credentials: { enabled: true },
|
|
62
|
+
autoRecall: {
|
|
63
|
+
enabled: true,
|
|
64
|
+
interactiveEnrichment: "fast",
|
|
65
|
+
entityLookup: { enabled: false },
|
|
66
|
+
authFailure: { enabled: true },
|
|
67
|
+
},
|
|
55
68
|
autoClassify: { enabled: true, suggestCategories: true },
|
|
56
69
|
store: { fuzzyDedupe: false, classifyBeforeWrite: false },
|
|
57
70
|
graph: { enabled: true, autoLink: false, useInRecall: true, strengthenOnRecall: false },
|
|
@@ -67,7 +80,12 @@ export const PRESET_OVERRIDES: Record<ConfigMode, Record<string, unknown>> = {
|
|
|
67
80
|
},
|
|
68
81
|
enhanced: {
|
|
69
82
|
autoCapture: true,
|
|
70
|
-
autoRecall: {
|
|
83
|
+
autoRecall: {
|
|
84
|
+
enabled: true,
|
|
85
|
+
interactiveEnrichment: "fast",
|
|
86
|
+
entityLookup: { enabled: true },
|
|
87
|
+
authFailure: { enabled: true },
|
|
88
|
+
},
|
|
71
89
|
autoClassify: { enabled: true, suggestCategories: true },
|
|
72
90
|
credentials: { autoDetect: true, autoCapture: { toolCalls: true } },
|
|
73
91
|
store: { fuzzyDedupe: true, classifyBeforeWrite: true },
|
|
@@ -104,7 +122,12 @@ export const PRESET_OVERRIDES: Record<ConfigMode, Record<string, unknown>> = {
|
|
|
104
122
|
},
|
|
105
123
|
complete: {
|
|
106
124
|
autoCapture: true,
|
|
107
|
-
autoRecall: {
|
|
125
|
+
autoRecall: {
|
|
126
|
+
enabled: true,
|
|
127
|
+
interactiveEnrichment: "fast",
|
|
128
|
+
entityLookup: { enabled: true },
|
|
129
|
+
authFailure: { enabled: true },
|
|
130
|
+
},
|
|
108
131
|
autoClassify: { enabled: true, suggestCategories: true },
|
|
109
132
|
credentials: { autoDetect: true, autoCapture: { toolCalls: true } },
|
|
110
133
|
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
|
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the active agent id for lifecycle hooks (`before_agent_start`, etc.).
|
|
3
|
+
*
|
|
4
|
+
* OpenClaw should populate `api.context.agentId` for routed channels; some builds
|
|
5
|
+
* only pass identity on the event payload. This module centralizes best-effort
|
|
6
|
+
* extraction so WhatsApp and similar routes can be recognized without duplicating
|
|
7
|
+
* ad-hoc field checks across stages.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ClawdbotPluginApi } from "openclaw/plugin-sdk/core";
|
|
11
|
+
|
|
12
|
+
function nonEmptyString(v: unknown): string | null {
|
|
13
|
+
if (typeof v !== "string") return null;
|
|
14
|
+
const t = v.trim();
|
|
15
|
+
return t.length > 0 ? t : null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @returns Detected agent id, or `null` if nothing usable was found.
|
|
20
|
+
*/
|
|
21
|
+
export function resolveAgentIdFromHookEvent(event: unknown, api: ClawdbotPluginApi): string | null {
|
|
22
|
+
const ev = event as Record<string, unknown>;
|
|
23
|
+
const session = ev.session as Record<string, unknown> | undefined;
|
|
24
|
+
const run = ev.run as Record<string, unknown> | undefined;
|
|
25
|
+
const payloadCtx = ev.context as Record<string, unknown> | undefined;
|
|
26
|
+
const activeAgent = session?.activeAgent;
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
nonEmptyString(ev.agentId) ??
|
|
30
|
+
nonEmptyString(session?.agentId) ??
|
|
31
|
+
nonEmptyString(session?.agent) ??
|
|
32
|
+
nonEmptyString(session?.activeAgentId) ??
|
|
33
|
+
nonEmptyString(session?.botId) ??
|
|
34
|
+
nonEmptyString(session?.routedAgentId) ??
|
|
35
|
+
(activeAgent && typeof activeAgent === "object"
|
|
36
|
+
? nonEmptyString((activeAgent as Record<string, unknown>).id)
|
|
37
|
+
: null) ??
|
|
38
|
+
nonEmptyString(run?.agentId) ??
|
|
39
|
+
nonEmptyString(payloadCtx?.agentId) ??
|
|
40
|
+
nonEmptyString(api.context?.agentId)
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -96,6 +96,7 @@ export function registerAuthFailureRecall(
|
|
|
96
96
|
scopeFilter,
|
|
97
97
|
reinforcementBoost: ctx.cfg.distill?.reinforcementBoost ?? 0.1,
|
|
98
98
|
diversityWeight: ctx.cfg.reinforcement?.diversityWeight ?? 1.0,
|
|
99
|
+
interactiveFtsFastPath: true,
|
|
99
100
|
});
|
|
100
101
|
const vector = await ctx.embeddings.embed(query);
|
|
101
102
|
let lanceResults = await ctx.vectorDb.search(vector, 5, 0.3);
|
|
@@ -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
|
|
|
@@ -111,6 +141,7 @@ async function runRecall(
|
|
|
111
141
|
scopeFilter,
|
|
112
142
|
reinforcementBoost: ctx.cfg.distill?.reinforcementBoost ?? 0.1,
|
|
113
143
|
diversityWeight: ctx.cfg.reinforcement?.diversityWeight ?? 1.0,
|
|
144
|
+
interactiveFtsFastPath: true,
|
|
114
145
|
};
|
|
115
146
|
const degradedLimit = ctx.cfg.autoRecall.limit;
|
|
116
147
|
const trimmed = e.prompt.trim();
|
|
@@ -240,12 +271,14 @@ async function runRecall(
|
|
|
240
271
|
}
|
|
241
272
|
|
|
242
273
|
await yieldEventLoop();
|
|
274
|
+
if (recallAborted(signal)) return emptyRecallStage();
|
|
243
275
|
|
|
244
276
|
const recallOpts = {
|
|
245
277
|
tierFilter,
|
|
246
278
|
scopeFilter,
|
|
247
279
|
reinforcementBoost: ctx.cfg.distill?.reinforcementBoost ?? 0.1,
|
|
248
280
|
diversityWeight: ctx.cfg.reinforcement?.diversityWeight ?? 1.0,
|
|
281
|
+
interactiveFtsFastPath: true,
|
|
249
282
|
};
|
|
250
283
|
const hydeUsedRef = { value: false };
|
|
251
284
|
const pipelineDeps: RecallPipelineDeps = {
|
|
@@ -282,6 +315,8 @@ async function runRecall(
|
|
|
282
315
|
const ambientSeenFacts = ambientSeenFactsMap.get(sessionScopeKey)!;
|
|
283
316
|
const ambientLastEmbedding = ambientLastEmbeddingMap.get(sessionScopeKey) ?? null;
|
|
284
317
|
|
|
318
|
+
if (recallAborted(signal)) return emptyRecallStage();
|
|
319
|
+
|
|
285
320
|
let promptEmbedding: number[] | null = null;
|
|
286
321
|
if (
|
|
287
322
|
interactivePolicy.allowAmbientMultiQuery &&
|
|
@@ -296,6 +331,8 @@ async function runRecall(
|
|
|
296
331
|
}
|
|
297
332
|
}
|
|
298
333
|
|
|
334
|
+
if (recallAborted(signal)) return emptyRecallStage();
|
|
335
|
+
|
|
299
336
|
let candidates = await runRecallPipelineQuery(e.prompt, limit, pipelineDeps, hydeUsedRef, {
|
|
300
337
|
hydeLabel: "HyDE",
|
|
301
338
|
errorPrefix: "auto-recall-",
|
|
@@ -303,6 +340,8 @@ async function runRecall(
|
|
|
303
340
|
policy: interactivePolicy,
|
|
304
341
|
});
|
|
305
342
|
|
|
343
|
+
if (recallAborted(signal)) return emptyRecallStage();
|
|
344
|
+
|
|
306
345
|
if (interactivePolicy.allowAmbientMultiQuery && ambientCfg.enabled && ambientCfg.multiQuery) {
|
|
307
346
|
try {
|
|
308
347
|
const isTopicShift =
|
|
@@ -323,6 +362,7 @@ async function runRecall(
|
|
|
323
362
|
if (extraQueries.length > 0) {
|
|
324
363
|
const extraResultSets: SearchResult[][] = [candidates];
|
|
325
364
|
for (const q of extraQueries) {
|
|
365
|
+
if (recallAborted(signal)) return emptyRecallStage();
|
|
326
366
|
await yieldEventLoop();
|
|
327
367
|
try {
|
|
328
368
|
const qResults = await runRecallPipelineQuery(q.text, Math.ceil(limit / 2), pipelineDeps, hydeUsedRef, {
|
|
@@ -408,31 +448,35 @@ async function runRecall(
|
|
|
408
448
|
}
|
|
409
449
|
|
|
410
450
|
await yieldEventLoop();
|
|
451
|
+
if (recallAborted(signal)) return emptyRecallStage();
|
|
411
452
|
|
|
412
453
|
const promptLower = e.prompt.toLowerCase();
|
|
413
454
|
const { entityLookup } = ctx.cfg.autoRecall;
|
|
414
|
-
if (entityLookup.enabled
|
|
415
|
-
const
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
const
|
|
419
|
-
.
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
455
|
+
if (entityLookup.enabled) {
|
|
456
|
+
const entityLookupNames = resolveEntityLookupNames(entityLookup, ctx.factsDb);
|
|
457
|
+
if (entityLookupNames.length > 0) {
|
|
458
|
+
const seenIds = new Set(candidates.map((c) => c.entry.id));
|
|
459
|
+
for (const entity of entityLookupNames) {
|
|
460
|
+
if (!promptLower.includes(entity.toLowerCase())) continue;
|
|
461
|
+
const entityResults = ctx.factsDb
|
|
462
|
+
.lookup(entity, undefined, undefined, { scopeFilter })
|
|
463
|
+
.slice(0, entityLookup.maxFactsPerEntity);
|
|
464
|
+
for (const r of entityResults) {
|
|
465
|
+
if (!seenIds.has(r.entry.id)) {
|
|
466
|
+
seenIds.add(r.entry.id);
|
|
467
|
+
candidates.push(r);
|
|
468
|
+
}
|
|
425
469
|
}
|
|
426
470
|
}
|
|
471
|
+
candidates.sort((a, b) => {
|
|
472
|
+
const s = b.score - a.score;
|
|
473
|
+
if (s !== 0) return s;
|
|
474
|
+
const da = a.entry.sourceDate ?? a.entry.createdAt;
|
|
475
|
+
const db = b.entry.sourceDate ?? b.entry.createdAt;
|
|
476
|
+
return db - da;
|
|
477
|
+
});
|
|
478
|
+
candidates = candidates.slice(0, limit);
|
|
427
479
|
}
|
|
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
480
|
}
|
|
437
481
|
|
|
438
482
|
const directivesCfg = ctx.cfg.autoRecall.retrievalDirectives;
|
|
@@ -461,23 +505,29 @@ async function runRecall(
|
|
|
461
505
|
|
|
462
506
|
if (directivesCfg.enabled) {
|
|
463
507
|
try {
|
|
464
|
-
if (
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
const
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
508
|
+
if (recallAborted(signal)) return emptyRecallStage();
|
|
509
|
+
if (directivesCfg.entityMentioned && entityLookup.enabled) {
|
|
510
|
+
const entityLookupNames = resolveEntityLookupNames(entityLookup, ctx.factsDb);
|
|
511
|
+
if (entityLookupNames.length > 0) {
|
|
512
|
+
for (const entity of entityLookupNames) {
|
|
513
|
+
if (recallAborted(signal)) return emptyRecallStage();
|
|
514
|
+
if (!promptLower.includes(entity.toLowerCase())) continue;
|
|
515
|
+
if (!canRunDirective()) break;
|
|
516
|
+
const results = await runRecallPipelineQuery(entity, directiveLimit, pipelineDeps, hydeUsedRef, {
|
|
517
|
+
entity,
|
|
518
|
+
hydeLabel: "HyDE",
|
|
519
|
+
errorPrefix: "directive-",
|
|
520
|
+
limitHydeOnce: true,
|
|
521
|
+
policy: interactivePolicy,
|
|
522
|
+
});
|
|
523
|
+
directiveCalls += 1;
|
|
524
|
+
addDirectiveResults(results, `entity:${entity}`);
|
|
525
|
+
}
|
|
477
526
|
}
|
|
478
527
|
}
|
|
479
528
|
if (directivesCfg.keywords.length > 0) {
|
|
480
529
|
for (const keyword of directivesCfg.keywords) {
|
|
530
|
+
if (recallAborted(signal)) return emptyRecallStage();
|
|
481
531
|
if (!promptLower.includes(keyword.toLowerCase())) continue;
|
|
482
532
|
if (!canRunDirective()) break;
|
|
483
533
|
const results = await runRecallPipelineQuery(keyword, directiveLimit, pipelineDeps, hydeUsedRef, {
|
|
@@ -491,6 +541,7 @@ async function runRecall(
|
|
|
491
541
|
}
|
|
492
542
|
}
|
|
493
543
|
for (const [taskType, triggers] of Object.entries(directivesCfg.taskTypes)) {
|
|
544
|
+
if (recallAborted(signal)) return emptyRecallStage();
|
|
494
545
|
const hit = triggers.some((t) => promptLower.includes(t.toLowerCase()));
|
|
495
546
|
if (!hit || !canRunDirective()) continue;
|
|
496
547
|
const results = await runRecallPipelineQuery(taskType, directiveLimit, pipelineDeps, hydeUsedRef, {
|
|
@@ -503,6 +554,7 @@ async function runRecall(
|
|
|
503
554
|
addDirectiveResults(results, `taskType:${taskType}`);
|
|
504
555
|
}
|
|
505
556
|
if (directivesCfg.sessionStart) {
|
|
557
|
+
if (recallAborted(signal)) return emptyRecallStage();
|
|
506
558
|
const sessionKey = resolveSessionKey(e, api) ?? currentAgentIdRef.value ?? "default";
|
|
507
559
|
if (!sessionStartSeen.has(sessionKey) && canRunDirective()) {
|
|
508
560
|
const results = await runRecallPipelineQuery("session start", directiveLimit, pipelineDeps, hydeUsedRef, {
|
package/lifecycle/stage-setup.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { capturePluginError } from "../services/error-reporter.js";
|
|
|
11
11
|
import { withTimeout } from "../utils/timeout.js";
|
|
12
12
|
import type { LifecycleContext, SessionState } from "./types.js";
|
|
13
13
|
import { pluginLogger } from "../utils/logger.js";
|
|
14
|
+
import { resolveAgentIdFromHookEvent } from "./resolve-agent-id.js";
|
|
14
15
|
|
|
15
16
|
const SETUP_TIMEOUT_MS = 5000;
|
|
16
17
|
|
|
@@ -50,8 +51,7 @@ async function runSetup(
|
|
|
50
51
|
}
|
|
51
52
|
}
|
|
52
53
|
|
|
53
|
-
const
|
|
54
|
-
const detectedAgentId = e.agentId || e.session?.agentId || api.context?.agentId;
|
|
54
|
+
const detectedAgentId = resolveAgentIdFromHookEvent(event, api);
|
|
55
55
|
if (detectedAgentId) {
|
|
56
56
|
currentAgentIdRef.value = detectedAgentId;
|
|
57
57
|
api.logger.debug?.(`memory-hybrid: Detected agentId: ${detectedAgentId}`);
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openclaw-hybrid-memory",
|
|
3
|
-
"version": "2026.
|
|
3
|
+
"version": "2026.4.11",
|
|
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.11",
|
|
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.11",
|
|
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)"
|
|
@@ -371,6 +383,10 @@
|
|
|
371
383
|
"label": "Use in recall",
|
|
372
384
|
"help": "Enable graph traversal in memory_recall (default true)"
|
|
373
385
|
},
|
|
386
|
+
"graph.strengthenOnRecall": {
|
|
387
|
+
"label": "Strengthen on recall",
|
|
388
|
+
"help": "When true, strengthen RELATED_TO links between facts recalled together (default false; Phase 1 baseline off)"
|
|
389
|
+
},
|
|
374
390
|
"procedures.enabled": {
|
|
375
391
|
"label": "Procedural memory",
|
|
376
392
|
"help": "Extract tool-call procedures from session logs, inject in recall, auto-generate skills"
|
|
@@ -609,6 +625,16 @@
|
|
|
609
625
|
},
|
|
610
626
|
"maxFactsPerEntity": {
|
|
611
627
|
"type": "number"
|
|
628
|
+
},
|
|
629
|
+
"autoFromFacts": {
|
|
630
|
+
"type": "boolean",
|
|
631
|
+
"description": "When true (default) and entities is empty, load names from DISTINCT entity on facts (capped by maxAutoEntities)."
|
|
632
|
+
},
|
|
633
|
+
"maxAutoEntities": {
|
|
634
|
+
"type": "number",
|
|
635
|
+
"minimum": 1,
|
|
636
|
+
"maximum": 2000,
|
|
637
|
+
"description": "Max distinct entity names when using autoFromFacts (default 500)."
|
|
612
638
|
}
|
|
613
639
|
}
|
|
614
640
|
},
|
|
@@ -665,6 +691,11 @@
|
|
|
665
691
|
"summarizeModel": {
|
|
666
692
|
"type": "string"
|
|
667
693
|
},
|
|
694
|
+
"interactiveEnrichment": {
|
|
695
|
+
"type": "string",
|
|
696
|
+
"enum": ["fast", "balanced", "full"],
|
|
697
|
+
"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)."
|
|
698
|
+
},
|
|
668
699
|
"authFailure": {
|
|
669
700
|
"type": "object",
|
|
670
701
|
"additionalProperties": false,
|
|
@@ -1124,6 +1155,10 @@
|
|
|
1124
1155
|
"useInRecall": {
|
|
1125
1156
|
"type": "boolean",
|
|
1126
1157
|
"description": "Enable graph traversal in memory_recall (default: true)"
|
|
1158
|
+
},
|
|
1159
|
+
"strengthenOnRecall": {
|
|
1160
|
+
"type": "boolean",
|
|
1161
|
+
"description": "Strengthen RELATED_TO links when facts are recalled together (default: false)"
|
|
1127
1162
|
}
|
|
1128
1163
|
},
|
|
1129
1164
|
"description": "Graph-based spreading activation: typed relationships for zero-LLM recall"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openclaw-hybrid-memory",
|
|
3
|
-
"version": "2026.
|
|
3
|
+
"version": "2026.4.11",
|
|
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",
|
|
@@ -70,6 +70,8 @@ export interface RecallSearchOpts {
|
|
|
70
70
|
scopeFilter: ScopeFilter | undefined;
|
|
71
71
|
reinforcementBoost: number;
|
|
72
72
|
diversityWeight: number;
|
|
73
|
+
/** Passed to `FactsDB.search` — bounded FTS + two-phase fetch on interactive recall. */
|
|
74
|
+
interactiveFtsFastPath?: boolean;
|
|
73
75
|
}
|
|
74
76
|
|
|
75
77
|
/** All explicit dependencies consumed by `runRecallPipelineQuery`. */
|
|
@@ -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 }> => {
|
package/utils/constants.ts
CHANGED
|
@@ -51,6 +51,11 @@ export const REFLECTION_TEMPERATURE = 0.2;
|
|
|
51
51
|
export const BATCH_THROTTLE_MS = 200;
|
|
52
52
|
/** SQLite busy timeout (ms). Mitigates SQLITE_BUSY under concurrent writers (#875). */
|
|
53
53
|
export const SQLITE_BUSY_TIMEOUT_MS = 30_000;
|
|
54
|
+
/**
|
|
55
|
+
* Max OR-joined FTS terms for `FactsDB.search` interactive fast path.
|
|
56
|
+
* Long prompts expand to huge MATCH expressions; capping keeps FTS5 work bounded (WhatsApp / gateway latency).
|
|
57
|
+
*/
|
|
58
|
+
export const INTERACTIVE_FTS_MAX_OR_TERMS = 16;
|
|
54
59
|
/** Seconds per day. */
|
|
55
60
|
export const SECONDS_PER_DAY = 86400;
|
|
56
61
|
|
|
@@ -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
|
+
}
|