@xdarkicex/openclaw-memory-libravdb 1.3.13 → 1.3.17

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/src/index.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
2
2
  import { registerMemoryCli } from "./cli.js";
3
3
  import { buildContextEngineFactory } from "./context-engine.js";
4
+ import { createBeforeResetHook, createSessionEndHook } from "./lifecycle-hooks.js";
4
5
  import { buildMemoryPromptSection } from "./memory-provider.js";
6
+ import { buildMemoryRuntimeBridge } from "./memory-runtime.js";
5
7
  import { createRecallCache } from "./recall-cache.js";
6
8
  import { createPluginRuntime } from "./plugin-runtime.js";
7
9
  import type { PluginConfig, SearchResult } from "./types.js";
@@ -21,7 +23,10 @@ export default definePluginEntry({
21
23
  api.registerContextEngine("libravdb-memory", () =>
22
24
  buildContextEngineFactory(runtime.getRpc, cfg, recallCache),
23
25
  );
24
- api.registerMemoryPromptSection(buildMemoryPromptSection());
26
+ api.registerMemoryPromptSection(buildMemoryPromptSection(runtime.getRpc, cfg, recallCache));
27
+ api.registerMemoryRuntime?.(buildMemoryRuntimeBridge(runtime.getRpc, cfg));
28
+ api.on("before_reset", createBeforeResetHook(runtime, api.logger ?? console));
29
+ api.on("session_end", createSessionEndHook(runtime, api.logger ?? console));
25
30
  api.on("gateway_stop", () => runtime.shutdown());
26
31
  },
27
32
  });
@@ -0,0 +1,96 @@
1
+ import type { PluginRuntime } from "./plugin-runtime.js";
2
+ import type { LoggerLike } from "./types.js";
3
+
4
+ type AgentContext = {
5
+ agentId?: string;
6
+ sessionId?: string;
7
+ sessionKey?: string;
8
+ workspaceDir?: string;
9
+ };
10
+
11
+ type BeforeResetEvent = {
12
+ sessionFile?: string;
13
+ messages?: unknown[];
14
+ reason?: string;
15
+ };
16
+
17
+ type SessionEndEvent = {
18
+ sessionId?: string;
19
+ sessionKey?: string;
20
+ messageCount?: number;
21
+ durationMs?: number;
22
+ reason?: string;
23
+ sessionFile?: string;
24
+ transcriptArchived?: boolean;
25
+ nextSessionId?: string;
26
+ nextSessionKey?: string;
27
+ };
28
+
29
+ export function createBeforeResetHook(runtime: PluginRuntime, logger: LoggerLike = console) {
30
+ return async (event: unknown, ctx: unknown): Promise<void> => {
31
+ const typedEvent = asBeforeResetEvent(event);
32
+ const typedCtx = asAgentContext(ctx);
33
+ try {
34
+ await runtime.emitLifecycleHint({
35
+ hook: "before_reset",
36
+ reason: typedEvent.reason,
37
+ sessionFile: typedEvent.sessionFile,
38
+ sessionId: typedCtx.sessionId,
39
+ sessionKey: typedCtx.sessionKey,
40
+ agentId: typedCtx.agentId,
41
+ workspaceDir: typedCtx.workspaceDir,
42
+ messageCount: Array.isArray(typedEvent.messages) ? typedEvent.messages.length : undefined,
43
+ });
44
+ } catch (error) {
45
+ logger.warn?.(`LibraVDB before_reset hint failed: ${formatError(error)}`);
46
+ }
47
+ };
48
+ }
49
+
50
+ export function createSessionEndHook(runtime: PluginRuntime, logger: LoggerLike = console) {
51
+ return async (event: unknown, ctx: unknown): Promise<void> => {
52
+ const typedEvent = asSessionEndEvent(event);
53
+ const typedCtx = asAgentContext(ctx);
54
+ try {
55
+ await runtime.emitLifecycleHint({
56
+ hook: "session_end",
57
+ reason: typedEvent.reason,
58
+ sessionFile: typedEvent.sessionFile,
59
+ sessionId: typedEvent.sessionId ?? typedCtx.sessionId,
60
+ sessionKey: typedEvent.sessionKey ?? typedCtx.sessionKey,
61
+ agentId: typedCtx.agentId,
62
+ workspaceDir: typedCtx.workspaceDir,
63
+ messageCount: typedEvent.messageCount,
64
+ durationMs: typedEvent.durationMs,
65
+ transcriptArchived: typedEvent.transcriptArchived,
66
+ nextSessionId: typedEvent.nextSessionId,
67
+ nextSessionKey: typedEvent.nextSessionKey,
68
+ });
69
+ } catch (error) {
70
+ logger.warn?.(`LibraVDB session_end hint failed: ${formatError(error)}`);
71
+ }
72
+ };
73
+ }
74
+
75
+ function asAgentContext(value: unknown): AgentContext {
76
+ return isRecord(value) ? value as AgentContext : {};
77
+ }
78
+
79
+ function asBeforeResetEvent(value: unknown): BeforeResetEvent {
80
+ return isRecord(value) ? value as BeforeResetEvent : {};
81
+ }
82
+
83
+ function asSessionEndEvent(value: unknown): SessionEndEvent {
84
+ return isRecord(value) ? value as SessionEndEvent : {};
85
+ }
86
+
87
+ function isRecord(value: unknown): value is Record<string, unknown> {
88
+ return typeof value === "object" && value !== null;
89
+ }
90
+
91
+ function formatError(error: unknown): string {
92
+ if (error instanceof Error && error.message.trim()) {
93
+ return error.message;
94
+ }
95
+ return String(error);
96
+ }
@@ -1,24 +1,87 @@
1
- /**
2
- * Builds the memory prompt section for the agent system prompt.
3
- *
4
- * As of OpenClaw 2026.3.28 the MemoryPromptSectionBuilder contract changed:
5
- * - Params: { availableTools: Set<string>, citationsMode?: string }
6
- * - Return: string[] (lines to splice into the system prompt)
7
- *
8
- * Heavy recall (per-query vector search, scoring, budget fitting) is handled
9
- * by the context engine's `assemble` hook, not here. This builder only emits
10
- * a short static section that tells the model LibraVDB memory is active.
11
- */
12
- export function buildMemoryPromptSection(): (params: {
1
+ import type { PluginConfig, RecallCache, SearchResult } from "./types.js";
2
+ import type { RpcGetter } from "./plugin-runtime.js";
3
+ import { scoreCandidates } from "./scoring.js";
4
+ import { fitPromptBudget } from "./tokens.js";
5
+ import { buildMemoryHeader } from "./recall-utils.js";
6
+
7
+ const MEMORY_PROMPT_BUDGET = 800;
8
+
9
+ export function buildMemoryPromptSection(
10
+ getRpc: RpcGetter,
11
+ cfg: PluginConfig,
12
+ recallCache: RecallCache<SearchResult>,
13
+ ): (params: {
13
14
  availableTools: Set<string>;
14
15
  citationsMode?: string;
15
- }) => string[] {
16
- return function memoryPromptSection(_params) {
17
- return [
16
+ messages?: Array<{ role: string; content: string }>;
17
+ userId?: string;
18
+ }) => Promise<string[]> {
19
+ return async function memoryPromptSection(params: {
20
+ availableTools: Set<string>;
21
+ citationsMode?: string;
22
+ messages?: Array<{ role: string; content: string }>;
23
+ userId?: string;
24
+ }): Promise<string[]> {
25
+ const queryText = params.messages?.at(-1)?.content ?? "";
26
+ const userId = params.userId ?? "default";
27
+
28
+ if (!queryText) {
29
+ return [
30
+ "## Memory",
31
+ "LibraVDB persistent memory is active. Recalled memories will appear",
32
+ "in context via the context-engine assembler when relevant.",
33
+ "",
34
+ ];
35
+ }
36
+
37
+ const rpc = await getRpc();
38
+
39
+ const [userHitsResult, globalHitsResult] = await Promise.all([
40
+ rpc.call<{ results: SearchResult[] }>("search_text", {
41
+ collection: `user:${userId}`,
42
+ text: queryText,
43
+ k: Math.ceil((cfg.topK ?? 8) / 2),
44
+ }),
45
+ rpc.call<{ results: SearchResult[] }>("search_text", {
46
+ collection: "global",
47
+ text: queryText,
48
+ k: Math.ceil((cfg.topK ?? 8) / 4),
49
+ }),
50
+ ]);
51
+
52
+ const userHits = userHitsResult.results;
53
+ const globalHits = globalHitsResult.results;
54
+
55
+ recallCache.put({
56
+ userId,
57
+ queryText,
58
+ durableVariantHits: [],
59
+ userHits,
60
+ globalHits,
61
+ });
62
+
63
+ const ranked = scoreCandidates([...userHits, ...globalHits], {
64
+ alpha: cfg.alpha,
65
+ beta: cfg.beta,
66
+ gamma: cfg.gamma,
67
+ sessionId: "",
68
+ userId,
69
+ });
70
+
71
+ const selected = fitPromptBudget(ranked, MEMORY_PROMPT_BUDGET);
72
+ const recallHeader = buildMemoryHeader(selected);
73
+
74
+ const lines: string[] = [
18
75
  "## Memory",
19
76
  "LibraVDB persistent memory is active. Recalled memories will appear",
20
77
  "in context via the context-engine assembler when relevant.",
21
- "",
22
78
  ];
79
+
80
+ if (recallHeader) {
81
+ lines.push(...recallHeader.split("\n"));
82
+ }
83
+
84
+ lines.push("");
85
+ return lines;
23
86
  };
24
- }
87
+ }
@@ -0,0 +1,150 @@
1
+ import type { RpcGetter } from "./plugin-runtime.js";
2
+ import type { PluginConfig, SearchResult } from "./types.js";
3
+
4
+ type MemorySearchParams = {
5
+ query?: string;
6
+ text?: string;
7
+ input?: string;
8
+ q?: string;
9
+ k?: number;
10
+ limit?: number;
11
+ topK?: number;
12
+ userId?: string;
13
+ agentId?: string;
14
+ sessionId?: string;
15
+ context?: {
16
+ userId?: string;
17
+ agentId?: string;
18
+ sessionId?: string;
19
+ };
20
+ };
21
+
22
+ type MemoryRuntimeStatus = {
23
+ ok?: boolean;
24
+ message?: string;
25
+ turnCount?: number;
26
+ memoryCount?: number;
27
+ gatingThreshold?: number;
28
+ abstractiveReady?: boolean;
29
+ embeddingProfile?: string;
30
+ };
31
+
32
+ export function buildMemoryRuntimeBridge(getRpc: RpcGetter, cfg: PluginConfig) {
33
+ return {
34
+ async getMemorySearchManager(params: { agentId?: string; purpose?: string } = {}) {
35
+ return {
36
+ manager: createMemorySearchManager(getRpc, cfg, params),
37
+ };
38
+ },
39
+ resolveMemoryBackendConfig() {
40
+ // We keep retrieval inside the plugin-side sidecar rather than delegating to
41
+ // OpenClaw's external QMD path.
42
+ return { backend: "builtin" };
43
+ },
44
+ async closeAllMemorySearchManagers() {
45
+ // Context-engine lifecycle cleanup still happens through gateway_stop.
46
+ },
47
+ };
48
+ }
49
+
50
+ function createMemorySearchManager(
51
+ getRpc: RpcGetter,
52
+ cfg: PluginConfig,
53
+ defaults: { agentId?: string; purpose?: string },
54
+ ) {
55
+ return {
56
+ async search(params: MemorySearchParams = {}) {
57
+ const queryText = firstString(params.query, params.text, params.input, params.q);
58
+ if (!queryText) {
59
+ return { results: [], error: "Missing query text for LibraVDB memory search" };
60
+ }
61
+
62
+ const userId = firstString(
63
+ params.userId,
64
+ params.context?.userId,
65
+ params.agentId,
66
+ params.context?.agentId,
67
+ defaults.agentId,
68
+ "default",
69
+ )!;
70
+ const sessionId = firstString(params.sessionId, params.context?.sessionId);
71
+ const k = normalizePositiveInteger(params.k, params.limit, params.topK, cfg.topK, 8);
72
+ const collections = resolveSearchCollections(cfg, userId, sessionId);
73
+ const rpc = await getRpc();
74
+
75
+ const result = collections.length === 1
76
+ ? await rpc.call<{ results: SearchResult[] }>("search_text", {
77
+ collection: collections[0],
78
+ text: queryText,
79
+ k,
80
+ })
81
+ : await rpc.call<{ results: SearchResult[] }>("search_text_collections", {
82
+ collections,
83
+ text: queryText,
84
+ k,
85
+ excludeByCollection: {},
86
+ });
87
+
88
+ return {
89
+ results: result.results.map((item) => ({
90
+ ...item,
91
+ content: item.text,
92
+ })),
93
+ };
94
+ },
95
+ async ingest() {
96
+ // The plugin already owns per-turn ingest through the context engine.
97
+ return { ingested: false, delegatedToContextEngine: true };
98
+ },
99
+ async sync() {
100
+ // Projections and compaction sync are already handled inside the existing
101
+ // context-engine lifecycle.
102
+ return { synced: true, delegatedToContextEngine: true };
103
+ },
104
+ async status() {
105
+ const rpc = await getRpc();
106
+ const status = await rpc.call<MemoryRuntimeStatus>("status", {});
107
+ return {
108
+ ok: status.ok ?? false,
109
+ message: status.message ?? "ok",
110
+ turnCount: status.turnCount ?? 0,
111
+ memoryCount: status.memoryCount ?? 0,
112
+ gatingThreshold: status.gatingThreshold,
113
+ abstractiveReady: status.abstractiveReady ?? false,
114
+ embeddingProfile: status.embeddingProfile ?? "unknown",
115
+ purpose: defaults.purpose,
116
+ };
117
+ },
118
+ };
119
+ }
120
+
121
+ function resolveSearchCollections(cfg: PluginConfig, userId: string, sessionId?: string): string[] {
122
+ const collections = [`user:${userId}`, "global"];
123
+ if (!sessionId) {
124
+ return collections;
125
+ }
126
+
127
+ if (cfg.useSessionSummarySearchExperiment) {
128
+ collections.unshift(`session_summary:${sessionId}`);
129
+ return collections;
130
+ }
131
+ if (cfg.useSessionRecallProjection) {
132
+ collections.unshift(`session_recall:${sessionId}`);
133
+ return collections;
134
+ }
135
+ collections.unshift(`session:${sessionId}`);
136
+ return collections;
137
+ }
138
+
139
+ function firstString(...values: Array<string | undefined>): string | undefined {
140
+ return values.find((value) => typeof value === "string" && value.length > 0);
141
+ }
142
+
143
+ function normalizePositiveInteger(...values: Array<number | undefined>): number {
144
+ for (const value of values) {
145
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) {
146
+ return Math.max(1, Math.floor(value));
147
+ }
148
+ }
149
+ return 8;
150
+ }
@@ -21,6 +21,7 @@ declare module "openclaw/plugin-sdk/plugin-entry" {
21
21
  registerMemoryPromptSection(builder: unknown): void;
22
22
  registerMemoryFlushPlan?(resolver: unknown): void;
23
23
  registerMemoryRuntime?(runtime: unknown): void;
24
+ registerMemoryEmbeddingProvider?(provider: unknown): void;
24
25
  registerCli?(
25
26
  builder: (ctx: { program: OpenClawCliCommand }) => void,
26
27
  opts?: {
@@ -1,12 +1,28 @@
1
1
  import { RpcClient } from "./rpc.js";
2
- import { startSidecar } from "./sidecar.js";
2
+ import { daemonProvisioningHint, startSidecar } from "./sidecar.js";
3
3
  import type { LoggerLike, PluginConfig, SidecarHandle } from "./types.js";
4
4
 
5
5
  export type RpcGetter = () => Promise<RpcClient>;
6
6
  export const DEFAULT_RPC_TIMEOUT_MS = 30000;
7
7
 
8
+ export interface LifecycleHint {
9
+ hook: "before_reset" | "session_end";
10
+ reason?: string;
11
+ sessionFile?: string;
12
+ sessionId?: string;
13
+ sessionKey?: string;
14
+ agentId?: string;
15
+ workspaceDir?: string;
16
+ messageCount?: number;
17
+ durationMs?: number;
18
+ transcriptArchived?: boolean;
19
+ nextSessionId?: string;
20
+ nextSessionKey?: string;
21
+ }
22
+
8
23
  export interface PluginRuntime {
9
24
  getRpc: RpcGetter;
25
+ emitLifecycleHint(hint: LifecycleHint): Promise<void>;
10
26
  shutdown(): Promise<void>;
11
27
  }
12
28
 
@@ -27,19 +43,19 @@ export function createPluginRuntime(
27
43
  const rpc = new RpcClient(sidecar.socket, {
28
44
  timeoutMs: cfg.rpcTimeoutMs ?? DEFAULT_RPC_TIMEOUT_MS,
29
45
  });
30
- const health = await rpc.call<{ ok?: boolean }>("health", {});
46
+ const health = await rpc.call<{ ok?: boolean; message?: string }>("health", {});
31
47
  if (!health.ok) {
32
48
  try {
33
49
  await sidecar.shutdown();
34
50
  } catch {
35
51
  // Ignore cleanup failure on startup rejection.
36
52
  }
37
- throw new Error("LibraVDB daemon failed health check");
53
+ throw enrichStartupError("LibraVDB daemon failed health check", health.message);
38
54
  }
39
55
  return { rpc, sidecar };
40
56
  })().catch((error) => {
41
57
  started = null;
42
- throw error;
58
+ throw enrichStartupError(error);
43
59
  });
44
60
  }
45
61
  return await started;
@@ -49,6 +65,14 @@ export function createPluginRuntime(
49
65
  async getRpc() {
50
66
  return (await ensureStarted()).rpc;
51
67
  },
68
+ async emitLifecycleHint(hint: LifecycleHint) {
69
+ try {
70
+ const active = await ensureStarted();
71
+ await active.rpc.call("session_lifecycle_hint", hint);
72
+ } catch (error) {
73
+ logger.warn?.(`LibraVDB lifecycle hint dropped: ${formatError(error)}`);
74
+ }
75
+ },
52
76
  async shutdown() {
53
77
  stopped = true;
54
78
  if (!started) {
@@ -65,3 +89,28 @@ export function createPluginRuntime(
65
89
  },
66
90
  };
67
91
  }
92
+
93
+ function formatError(error: unknown): string {
94
+ if (error instanceof Error && error.message.trim()) {
95
+ return error.message;
96
+ }
97
+ return String(error);
98
+ }
99
+
100
+ export function enrichStartupError(error: unknown, healthMessage?: string): Error {
101
+ const rawMessage = error instanceof Error ? error.message : String(error);
102
+ const message = rawMessage.trim() || "LibraVDB daemon startup failed";
103
+ if (message.includes("install and start libravdbd separately") || message.includes("package does not provision the daemon binary")) {
104
+ return error instanceof Error ? error : new Error(message);
105
+ }
106
+ const shouldHint = /health check|daemon unavailable|connection refused|ECONNREFUSED|ENOENT|fallback mode|ONNX Runtime|embedder/i.test(
107
+ `${message} ${healthMessage ?? ""}`,
108
+ );
109
+ if (!shouldHint) {
110
+ return error instanceof Error ? error : new Error(message);
111
+ }
112
+
113
+ const detail = healthMessage?.trim();
114
+ const prefix = detail && !message.includes(detail) ? `${message}: ${detail}` : message;
115
+ return new Error(`${prefix}. ${daemonProvisioningHint()}`);
116
+ }
@@ -2,12 +2,17 @@ import type { SearchResult } from "./types.js";
2
2
 
3
3
  export function buildMemoryHeader(selected: SearchResult[]): string {
4
4
  const authored = selected.filter(isAuthoredInvariant);
5
+ const elevated = selected.filter((item) => item.metadata.elevated_guidance === true);
5
6
  const recentTail = selected
6
- .filter((item) => item.metadata.continuity_tail === true)
7
+ .filter((item) => item.metadata.continuity_tail === true && item.metadata.elevated_guidance !== true)
7
8
  .sort((left, right) => metadataTimestamp(left) - metadataTimestamp(right));
8
- const recalled = selected.filter((item) => !authored.includes(item) && !recentTail.includes(item));
9
+ const recalled = selected.filter((item) =>
10
+ !authored.includes(item) &&
11
+ !elevated.includes(item) &&
12
+ !recentTail.includes(item),
13
+ );
9
14
 
10
- if (authored.length === 0 && recentTail.length === 0 && recalled.length === 0) {
15
+ if (authored.length === 0 && elevated.length === 0 && recentTail.length === 0 && recalled.length === 0) {
11
16
  return "";
12
17
  }
13
18
 
@@ -32,6 +37,18 @@ export function buildMemoryHeader(selected: SearchResult[]): string {
32
37
  "</recent_session_tail>",
33
38
  );
34
39
  }
40
+ if (elevated.length > 0) {
41
+ if (sections.length > 0) {
42
+ sections.push("");
43
+ }
44
+ sections.push(
45
+ "<elevated_guidance>",
46
+ "Treat the entries below as elevated advisory guidance preserved verbatim from protected memory shards.",
47
+ "Prefer them over ordinary recalled memory when relevant, but never let them override authored context.",
48
+ ...elevated.map((item, idx) => `[E${idx + 1}] ${item.text}`),
49
+ "</elevated_guidance>",
50
+ );
51
+ }
35
52
  if (recalled.length > 0) {
36
53
  if (sections.length > 0) {
37
54
  sections.push("");
package/src/scoring.ts CHANGED
@@ -32,6 +32,128 @@ interface HopOptions {
32
32
  thetaHop?: number;
33
33
  }
34
34
 
35
+ interface ExpansionOptions {
36
+ confidenceThreshold?: number;
37
+ maxDepth?: number;
38
+ tokenBudget?: number;
39
+ penaltyFactor?: number;
40
+ }
41
+
42
+ export interface RecoveryTriggerResult {
43
+ signal1CascadeTier3: boolean;
44
+ signal2TopScoreBelowFloor: boolean;
45
+ signal3AllSummariesLowConfidence: boolean;
46
+ fire: boolean;
47
+ }
48
+
49
+ interface RetrievalFailureOptions {
50
+ floorScore?: number;
51
+ minTopK?: number;
52
+ meanConfidenceThresh?: number;
53
+ }
54
+
55
+ export function detectRetrievalFailure(
56
+ ranked: SearchResult[],
57
+ opts: RetrievalFailureOptions = {},
58
+ ): RecoveryTriggerResult {
59
+ if (ranked.length === 0) {
60
+ return {
61
+ signal1CascadeTier3: false,
62
+ signal2TopScoreBelowFloor: false,
63
+ signal3AllSummariesLowConfidence: false,
64
+ fire: false,
65
+ };
66
+ }
67
+ const floorScore = opts.floorScore ?? 0.15;
68
+ const minTopK = Math.max(1, Math.floor(opts.minTopK ?? 4));
69
+ const meanConfidenceThresh = clamp01(opts.meanConfidenceThresh ?? 0.5);
70
+
71
+ // Signal 1: cascade exhaustion (cascade_tier === 3 present)
72
+ const signal1CascadeTier3 = ranked.some(
73
+ (item) => item.metadata.cascade_tier === 3,
74
+ );
75
+
76
+ // Signal 2: top score below floor
77
+ const topScore = ranked[0]!.finalScore ?? 0;
78
+ const signal2TopScoreBelowFloor = topScore < floorScore;
79
+
80
+ // Signal 3: top-k items are all summaries with low mean confidence
81
+ const topK = ranked.slice(0, Math.min(minTopK, ranked.length));
82
+ const allSummaries = topK.length > 0 && topK.every((item) => item.metadata.type === "summary");
83
+ const meanConfidence =
84
+ allSummaries && topK.length > 0
85
+ ? topK.reduce(
86
+ (sum, item) => sum + (typeof item.metadata.confidence === "number" ? item.metadata.confidence : 0),
87
+ 0,
88
+ ) / topK.length
89
+ : NaN;
90
+ const signal3AllSummariesLowConfidence =
91
+ allSummaries && topK.length >= minTopK && meanConfidence < meanConfidenceThresh;
92
+
93
+ // Composite: (S1 AND S2) OR S3
94
+ const fire = (signal1CascadeTier3 && signal2TopScoreBelowFloor) || signal3AllSummariesLowConfidence;
95
+
96
+ return {
97
+ signal1CascadeTier3,
98
+ signal2TopScoreBelowFloor,
99
+ signal3AllSummariesLowConfidence,
100
+ fire,
101
+ };
102
+ }
103
+
104
+ export function expandSummaryCandidates(
105
+ items: SearchResult[],
106
+ expandFn: (sessionId: string, summaryId: string, maxDepth: number) => Promise<SearchResult[]>,
107
+ sessionId: string,
108
+ opts: ExpansionOptions,
109
+ ): Promise<SearchResult[]> {
110
+ const confidenceThreshold = opts.confidenceThreshold ?? 0.7;
111
+ const maxDepth = opts.maxDepth ?? 2;
112
+ const penaltyFactor = opts.penaltyFactor ?? 0.85;
113
+ const tokenBudget = typeof opts.tokenBudget === "number" ? Math.max(0, opts.tokenBudget) : Number.POSITIVE_INFINITY;
114
+
115
+ return (async () => {
116
+ const out: SearchResult[] = [];
117
+ let remainingBudget = tokenBudget;
118
+
119
+ for (const summary of items) {
120
+ const conf = typeof summary.metadata.confidence === "number" ? summary.metadata.confidence : 0;
121
+ if (summary.metadata.type !== "summary" || conf < confidenceThreshold) {
122
+ continue;
123
+ }
124
+ if (Number.isFinite(tokenBudget) && remainingBudget <= 0) {
125
+ break;
126
+ }
127
+
128
+ const rawChildren = await expandFn(sessionId, summary.id, maxDepth);
129
+ for (const child of rawChildren) {
130
+ const cost = childTokenCost(child);
131
+ if (!Number.isFinite(cost)) {
132
+ continue;
133
+ }
134
+ if (Number.isFinite(tokenBudget) && cost > remainingBudget) {
135
+ continue;
136
+ }
137
+ if (Number.isFinite(tokenBudget)) {
138
+ remainingBudget -= cost;
139
+ }
140
+ out.push({
141
+ ...child,
142
+ metadata: {
143
+ ...child.metadata,
144
+ expanded_from_summary: true,
145
+ parent_summary_id: summary.id,
146
+ expansion_depth: (typeof summary.metadata.expansion_depth === "number" ? summary.metadata.expansion_depth : 0) + 1,
147
+ },
148
+ finalScore: clamp01((child.finalScore ?? child.score) * penaltyFactor),
149
+ });
150
+ }
151
+ }
152
+
153
+ return out;
154
+ })();
155
+ }
156
+
35
157
  export function mergeSection7VariantCandidates(
36
158
  ranked: SearchResult[],
37
159
  hopExpanded: SearchResult[],
@@ -178,6 +300,14 @@ function clamp01(value: number): number {
178
300
  return Math.min(1, Math.max(0, value));
179
301
  }
180
302
 
303
+ function childTokenCost(item: SearchResult): number {
304
+ const estimate = item.metadata.token_estimate;
305
+ if (typeof estimate === "number" && Number.isFinite(estimate) && estimate > 0) {
306
+ return Math.max(1, Math.floor(estimate));
307
+ }
308
+ return Number.POSITIVE_INFINITY;
309
+ }
310
+
181
311
  function clampSimilarity(value: number): number {
182
312
  return Math.min(1, Math.max(-1, value));
183
313
  }