From 2a8b3fa8f91ccdca00be10dba9fbd41869bc5a07 Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Sat, 22 Aug 2026 21:03:31 +0530 Subject: [PATCH] Harden recall and capture reliability Keeps recall fast and useful while making automatic capture bounded, nonblocking, and retry-safe. --- src/index.ts | 24 +++++-- src/services/capture.test.ts | 76 ++++++++++++++++++++ src/services/capture.ts | 108 ++++++++++++++++++---------- src/services/client.ts | 44 ++++++++++-- src/services/context.ts | 79 ++++++++++++++------ src/services/recall-results.test.ts | 20 ++++++ src/services/recall-results.ts | 28 ++++++-- src/services/recall.test.ts | 56 ++++++++++----- src/services/recall.ts | 19 +++-- src/services/result-merge.ts | 7 +- 10 files changed, 359 insertions(+), 102 deletions(-) diff --git a/src/index.ts b/src/index.ts index b4e92c2..1dea102 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,10 @@ import { tool } from "@opencode-ai/plugin"; import { AGENT_ENTITY_CONTEXT } from "./services/entity-context.js"; import { supermemoryClient } from "./services/client.js"; -import { formatContextForPrompt } from "./services/context.js"; +import { + formatContextForPrompt, + getInjectedProfileFactTexts, +} from "./services/context.js"; import { createCaptureHook } from "./services/capture.js"; import { buildDirectRecallContext, @@ -204,10 +207,7 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { suppressTexts: isFirstMessage ? profileRequest.then((result) => result?.success && result.profile - ? [ - ...result.profile.static, - ...result.profile.dynamic, - ] + ? getInjectedProfileFactTexts(result) : [], ) : undefined, @@ -615,7 +615,15 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { await compactionHook.event(input); } if (captureHook) { - await captureHook.event(input); + if (input.event.type === "session.idle") { + void captureHook.event(input).catch((error) => { + log("[capture] background idle capture failed", { + error: String(error), + }); + }); + } else { + await captureHook.event(input); + } } }, }; @@ -654,7 +662,9 @@ function formatSearchResults( const r = hit.result; const result = { content: formatRecallHit(hit), - similarity: Math.round(hit.similarity * 100), + ...(hit.similarity === undefined + ? {} + : { similarity: Math.round(hit.similarity * 100) }), ...(hit.title ? { title: hit.title } : {}), ...(hit.filepath ? { filepath: hit.filepath } : {}), }; diff --git a/src/services/capture.test.ts b/src/services/capture.test.ts index ad81591..8104e01 100644 --- a/src/services/capture.test.ts +++ b/src/services/capture.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import type { Part } from "@opencode-ai/sdk"; import { + AUTOMATIC_CAPTURE_TIMEOUT_MS, buildCadenceBatches, buildCaptureTurns, buildSessionEndBatch, @@ -9,6 +10,7 @@ import { getCaptureId, type SessionMessage, } from "./capture.js"; +import { SupermemoryClient } from "./client.js"; import type { ResolvedTags } from "./tags.js"; function textPart( @@ -122,6 +124,7 @@ describe("automatic conversation capture", () => { conversationId: string; metadata?: Record; customId?: string; + timeoutMs?: number; }> = []; const ctx = { directory: "/repo", @@ -155,6 +158,7 @@ describe("automatic conversation capture", () => { conversationId, metadata, customId: options?.customId, + timeoutMs: options?.timeoutMs, }); return { success: true }; }, @@ -183,6 +187,7 @@ describe("automatic conversation capture", () => { expect(writes).toHaveLength(1); expect(writes[0]?.metadata?.captureReason).toBe("cadence"); + expect(writes[0]?.timeoutMs).toBe(AUTOMATIC_CAPTURE_TIMEOUT_MS); messages = conversation(4); await hook.event({ @@ -208,4 +213,75 @@ describe("automatic conversation capture", () => { expect(writes[1]?.metadata?.captureReason).toBe("session_end"); expect(writes[0]?.customId).not.toBe(writes[1]?.customId); }); + + test("retains terminal capture state after failure and retries with bounded SDK options", async () => { + const sdkOptions: Array<{ timeout?: number; maxRetries?: number }> = []; + let attempts = 0; + let readAttempts = 0; + const memoryClient = new SupermemoryClient(); + ( + memoryClient as unknown as { + client: { + memories: { + add: ( + payload: unknown, + options?: { timeout?: number; maxRetries?: number }, + ) => Promise<{ id: string }>; + }; + }; + } + ).client = { + memories: { + add: async (_payload, options) => { + sdkOptions.push(options ?? {}); + attempts += 1; + if (attempts === 1) throw new Error("temporary capture failure"); + return { id: "memory-1" }; + }, + }, + }; + + const hook = createCaptureHook( + { + directory: "/repo", + client: { + session: { + messages: async () => { + readAttempts += 1; + if (readAttempts === 1) { + throw new Error("temporary transcript read failure"); + } + return { data: conversation(1) }; + }, + }, + }, + }, + { + canonical: "repo_test__0123456789abcdef", + user: "repo_test__0123456789abcdef", + project: "repo_test__0123456789abcdef", + projectId: "0123456789abcdef", + projectName: "test", + personalReads: [], + projectReads: [], + allReads: [], + }, + { captureEveryNTurns: 0, memoryClient }, + ); + + for (let attempt = 0; attempt < 4; attempt += 1) { + await hook.event({ + event: { + type: "session.deleted", + properties: { info: { id: "session-1" } }, + }, + }); + } + + expect(sdkOptions).toEqual([ + { timeout: AUTOMATIC_CAPTURE_TIMEOUT_MS, maxRetries: 0 }, + { timeout: AUTOMATIC_CAPTURE_TIMEOUT_MS, maxRetries: 0 }, + ]); + expect(readAttempts).toBe(3); + }); }); diff --git a/src/services/capture.ts b/src/services/capture.ts index 96cef0e..159d921 100644 --- a/src/services/capture.ts +++ b/src/services/capture.ts @@ -9,6 +9,8 @@ import { log } from "./logger.js"; import { isFullyPrivate, stripPrivateContent } from "./privacy.js"; import type { ResolvedTags } from "./tags.js"; +export const AUTOMATIC_CAPTURE_TIMEOUT_MS = 3_000; + interface CaptureMessageInfo { id: string; role: string; @@ -56,6 +58,7 @@ interface ConversationWriter { options?: { defaultEntityContext?: string; customId?: string; + timeoutMs?: number; }, ) => Promise<{ success: boolean; error?: string }>; } @@ -237,35 +240,44 @@ export function createCaptureHook( sessionID: string, batch: CaptureBatch, reason: "cadence" | "session_end", - ): Promise { + ): Promise { const captureId = getCaptureId(sessionID, batch); - if (completedCaptureIds.has(captureId)) return; + if (completedCaptureIds.has(captureId)) return true; const messages = batch.turns.flatMap((turn) => turn.messages); if (messages.length === 0) { completedCaptureIds.add(captureId); - return; + return true; } - const result = await memoryClient.ingestConversation( - `${sessionID}:${batch.startTurn}-${batch.endTurn}`, - messages, - [tags.canonical], - { - project: tags.projectName, - sm_project_id: tags.projectId, - sm_scope: "personal", - sm_capture_mode: "automatic", - captureReason: reason, - sessionId: sessionID, - turnStart: batch.startTurn, - turnEnd: batch.endTurn, - }, - { - defaultEntityContext: AGENT_ENTITY_CONTEXT, - customId: captureId, - }, - ); + let result: { success: boolean; error?: string }; + try { + result = await memoryClient.ingestConversation( + `${sessionID}:${batch.startTurn}-${batch.endTurn}`, + messages, + [tags.canonical], + { + project: tags.projectName, + sm_project_id: tags.projectId, + sm_scope: "personal", + sm_capture_mode: "automatic", + captureReason: reason, + sessionId: sessionID, + turnStart: batch.startTurn, + turnEnd: batch.endTurn, + }, + { + defaultEntityContext: AGENT_ENTITY_CONTEXT, + customId: captureId, + timeoutMs: AUTOMATIC_CAPTURE_TIMEOUT_MS, + }, + ); + } catch (error) { + result = { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } if (result.success) { completedCaptureIds.add(captureId); @@ -275,7 +287,7 @@ export function createCaptureHook( startTurn: batch.startTurn, endTurn: batch.endTurn, }); - return; + return true; } log("[capture] failed to save conversation batch", { @@ -285,26 +297,42 @@ export function createCaptureHook( endTurn: batch.endTurn, error: result.error, }); + return false; } async function captureCadence( sessionID: string, turns: CaptureTurn[], - ): Promise { + ): Promise { + let complete = true; for (const batch of buildCadenceBatches(turns, captureEveryNTurns)) { - await saveBatch(sessionID, batch, "cadence"); + if (!(await saveBatch(sessionID, batch, "cadence"))) { + complete = false; + } } + return complete; } - async function captureSessionEnd(sessionID: string): Promise { - const turns = snapshots.get(sessionID); - if (!turns) return; + async function captureSessionEnd(sessionID: string): Promise { + let turns = snapshots.get(sessionID); + if (!turns) { + try { + turns = await refreshSnapshot(sessionID); + } catch (error) { + log("[capture] failed to read terminal session", { + sessionID, + error: String(error), + }); + return false; + } + } - await captureCadence(sessionID, turns); + const cadenceComplete = await captureCadence(sessionID, turns); const finalBatch = buildSessionEndBatch(turns, captureEveryNTurns); - if (finalBatch) { - await saveBatch(sessionID, finalBatch, "session_end"); - } + const finalComplete = finalBatch + ? await saveBatch(sessionID, finalBatch, "session_end") + : true; + return cadenceComplete && finalComplete; } async function runExclusive( @@ -336,6 +364,7 @@ export function createCaptureHook( if (event.type === "session.idle") { const sessionID = props?.sessionID as string | undefined; if (!sessionID) return; + activeSessions.add(sessionID); await runExclusive(sessionID, async () => { try { @@ -355,11 +384,13 @@ export function createCaptureHook( const sessionInfo = props?.info as { id?: string } | undefined; const sessionID = sessionInfo?.id; if (!sessionID) return; + activeSessions.add(sessionID); await runExclusive(sessionID, async () => { - await captureSessionEnd(sessionID); - snapshots.delete(sessionID); - activeSessions.delete(sessionID); + if (await captureSessionEnd(sessionID)) { + snapshots.delete(sessionID); + activeSessions.delete(sessionID); + } }); return; } @@ -368,9 +399,10 @@ export function createCaptureHook( await Promise.all( [...activeSessions].map((sessionID) => runExclusive(sessionID, async () => { - await captureSessionEnd(sessionID); - snapshots.delete(sessionID); - activeSessions.delete(sessionID); + if (await captureSessionEnd(sessionID)) { + snapshots.delete(sessionID); + activeSessions.delete(sessionID); + } }), ), ); diff --git a/src/services/client.ts b/src/services/client.ts index 8fd7985..73f4d81 100644 --- a/src/services/client.ts +++ b/src/services/client.ts @@ -20,6 +20,7 @@ import type { const TIMEOUT_MS = 30000; const TIMEOUT_BACKSTOP_GRACE_MS = 250; +const SETTINGS_UPDATE_TIMEOUT_MS = 3_000; const MAX_CONVERSATION_CHARS = 100_000; const OPENCODE_SOURCE = "opencode"; @@ -165,10 +166,25 @@ export class SupermemoryClient { baseURL: getApiBaseUrl(), defaultHeaders: { "x-sm-source": OPENCODE_SOURCE }, }); - void this.client.settings.update({ - shouldLLMFilter: true, - filterPrompt: CONFIG.filterPrompt, - }); + try { + void this.client.settings + .update( + { + shouldLLMFilter: true, + filterPrompt: CONFIG.filterPrompt, + }, + { timeout: SETTINGS_UPDATE_TIMEOUT_MS, maxRetries: 0 }, + ) + .catch((error) => { + log("settings.update: best-effort update failed", { + error: error instanceof Error ? error.message : String(error), + }); + }); + } catch (error) { + log("settings.update: best-effort update failed", { + error: error instanceof Error ? error.message : String(error), + }); + } } return this.client; } @@ -396,7 +412,11 @@ export class SupermemoryClient { tool?: string; [key: string]: unknown; }, - options?: { customId?: string; entityContext?: string }, + options?: { + customId?: string; + entityContext?: string; + timeoutMs?: number; + }, ) { log("addMemory: start", { containerTag, @@ -405,6 +425,7 @@ export class SupermemoryClient { hasEntityContext: !!options?.entityContext, }); try { + const requestTimeout = options?.timeoutMs; const mergedMetadata = Object.fromEntries( Object.entries({ sm_source: OPENCODE_SOURCE, @@ -434,8 +455,15 @@ export class SupermemoryClient { } const result = await withTimeout( - this.getClient().memories.add(payload), - TIMEOUT_MS, + this.getClient().memories.add( + payload, + requestTimeout + ? { timeout: requestTimeout, maxRetries: 0 } + : undefined, + ), + requestTimeout + ? requestTimeout + TIMEOUT_BACKSTOP_GRACE_MS + : TIMEOUT_MS, ); log("addMemory: success", { id: result.id }); return { success: true as const, ...result }; @@ -579,6 +607,7 @@ export class SupermemoryClient { defaultEntityContext?: string; entityContextByContainerTag?: Record; customId?: string; + timeoutMs?: number; }, ) { log("ingestConversation: start", { @@ -630,6 +659,7 @@ export class SupermemoryClient { const result = await this.addMemory(content, tag, ingestMetadata, { ...(entityContext ? { entityContext } : {}), ...(customId ? { customId } : {}), + ...(options?.timeoutMs ? { timeoutMs: options.timeoutMs } : {}), }); if (result.success) { savedIds.push(result.id); diff --git a/src/services/context.ts b/src/services/context.ts index d159dfd..12efa45 100644 --- a/src/services/context.ts +++ b/src/services/context.ts @@ -19,39 +19,71 @@ function extractFactText(fact: unknown): string { return String(fact ?? ""); } +function selectInjectedProfileFacts( + profile: ProfileResponse | null, + maxItems: number, +): { static: string[]; dynamic: string[] } { + if (!profile?.profile) { + return { static: [], dynamic: [] }; + } + + return { + static: profile.profile.static + .slice(0, maxItems) + .map(extractFactText) + .filter(Boolean), + dynamic: profile.profile.dynamic + .slice(0, maxItems) + .map(extractFactText) + .filter(Boolean), + }; +} + +export function getInjectedProfileFactTexts( + profile: ProfileResponse | null, + maxItems = CONFIG.maxProfileItems, +): string[] { + const facts = selectInjectedProfileFacts(profile, maxItems); + return [...facts.static, ...facts.dynamic]; +} + export function formatContextForPrompt( profile: ProfileResponse | null, userMemories: MemoriesResponseMinimal, projectMemories: MemoriesResponseMinimal ): string { - const parts: string[] = ["[SUPERMEMORY]"]; + const parts: string[] = [ + "[SUPERMEMORY]", + "Every line marked ◪ comes from supermemory. When one shapes your answer, credit it naturally with the ◪ prefix; if you name the source, say \"from supermemory\".", + ]; - if (CONFIG.injectProfile && profile?.profile) { - const { static: staticFacts, dynamic: dynamicFacts } = profile.profile; + const profileFacts = CONFIG.injectProfile + ? selectInjectedProfileFacts(profile, CONFIG.maxProfileItems) + : { static: [], dynamic: [] }; - if (staticFacts.length > 0) { - parts.push("\nUser Profile:"); - staticFacts.slice(0, CONFIG.maxProfileItems).forEach((fact) => { - const text = extractFactText(fact); - parts.push(`- ${text}`); - }); - } + if (profileFacts.static.length > 0) { + parts.push("\nUser Profile:"); + profileFacts.static.forEach((fact) => { + parts.push(`- ◪ ${fact}`); + }); + } - if (dynamicFacts.length > 0) { - parts.push("\nRecent Context:"); - dynamicFacts.slice(0, CONFIG.maxProfileItems).forEach((fact) => { - const text = extractFactText(fact); - parts.push(`- ${text}`); - }); - } + if (profileFacts.dynamic.length > 0) { + parts.push("\nRecent Context:"); + profileFacts.dynamic.forEach((fact) => { + parts.push(`- ◪ ${fact}`); + }); } const projectResults = normalizeRecallResults(projectMemories.results || []); if (projectResults.length > 0) { parts.push("\nProject Knowledge:"); projectResults.forEach((hit) => { - const similarity = Math.round(hit.similarity * 100); - parts.push(`- [${similarity}%] ${formatRecallHit(hit)}`); + const score = + hit.similarity === undefined + ? "" + : ` [${Math.round(hit.similarity * 100)}%]`; + parts.push(`- ◪${score} ${formatRecallHit(hit)}`); }); } @@ -59,12 +91,15 @@ export function formatContextForPrompt( if (userResults.length > 0) { parts.push("\nRelevant Memories:"); userResults.forEach((hit) => { - const similarity = Math.round(hit.similarity * 100); - parts.push(`- [${similarity}%] ${formatRecallHit(hit)}`); + const score = + hit.similarity === undefined + ? "" + : ` [${Math.round(hit.similarity * 100)}%]`; + parts.push(`- ◪${score} ${formatRecallHit(hit)}`); }); } - if (parts.length === 1) { + if (parts.length === 2) { return ""; } diff --git a/src/services/recall-results.test.ts b/src/services/recall-results.test.ts index 93924e8..55015be 100644 --- a/src/services/recall-results.test.ts +++ b/src/services/recall-results.test.ts @@ -32,5 +32,25 @@ describe("recall result normalization", () => { expect(formatRecallHit(hits[4]!)).toBe( "Decision: context result (src/index.ts)", ); + + const unscored = normalizeRecallResults([ + { memory: "missing score remains valid" }, + { memory: "non-finite score remains valid", similarity: Number.NaN }, + ]); + expect(unscored.map((hit) => hit.text)).toEqual([ + "missing score remains valid", + "non-finite score remains valid", + ]); + + const titleAlreadyPresent = normalizeRecallResults([ + { + title: "Migration plan", + content: "Migration plan: use expand-contract migrations", + similarity: 0.9, + }, + ])[0]!; + expect(formatRecallHit(titleAlreadyPresent)).toBe( + "Migration plan: use expand-contract migrations", + ); }); }); diff --git a/src/services/recall-results.ts b/src/services/recall-results.ts index c00928d..efb3447 100644 --- a/src/services/recall-results.ts +++ b/src/services/recall-results.ts @@ -7,11 +7,18 @@ export const MAX_RECALL_HIT_CHARS = 300; export interface RecallHit { result: SearchResultItem; text: string; - similarity: number; + similarity?: number; title?: string; filepath?: string; } +function finiteSimilarity(result: SearchResultItem): number | undefined { + return [result.similarity, result.score].find( + (value): value is number => + typeof value === "number" && Number.isFinite(value), + ); +} + function nonEmptyString(value: unknown): string | undefined { return typeof value === "string" && value.trim().length > 0 ? value.trim() @@ -61,7 +68,7 @@ export function normalizeRecallResult( maxHitChars === undefined ? text : truncateHit(text, Math.max(1, maxHitChars)), - similarity: result.similarity ?? result.score ?? 0, + similarity: finiteSimilarity(result), title: getRecallResultTitle(result), filepath: getRecallResultFilepath(result), }; @@ -86,8 +93,15 @@ export function normalizeRecallResults( return results .map((result) => normalizeRecallResult(result, maxHitChars)) .filter((hit): hit is RecallHit => hit !== null) - .filter((hit) => hit.similarity >= minSimilarity) - .sort((a, b) => b.similarity - a.similarity) + .filter( + (hit) => + hit.similarity === undefined || hit.similarity >= minSimilarity, + ) + .sort( + (a, b) => + (b.similarity ?? Number.NEGATIVE_INFINITY) - + (a.similarity ?? Number.NEGATIVE_INFINITY), + ) .filter((hit) => { const key = hit.text.toLowerCase().replace(/\s+/g, " ").trim(); if (seen.has(key)) return false; @@ -98,7 +112,11 @@ export function normalizeRecallResults( } export function formatRecallHit(hit: RecallHit): string { - const title = hit.title ? `${hit.title}: ` : ""; + const title = + hit.title && + !hit.text.toLocaleLowerCase().startsWith(hit.title.toLocaleLowerCase()) + ? `${hit.title}: ` + : ""; const filepath = hit.filepath ? ` (${hit.filepath})` : ""; return `${title}${hit.text}${filepath}`; } diff --git a/src/services/recall.test.ts b/src/services/recall.test.ts index 8ab61ac..1e1f5cf 100644 --- a/src/services/recall.test.ts +++ b/src/services/recall.test.ts @@ -5,6 +5,7 @@ import { MAX_RECALL_QUERY_CHARS, RecallSessionCache, } from "./recall.js"; +import { getInjectedProfileFactTexts } from "./context.js"; describe("direct recall", () => { test("applies prompt policy, fails open, and bounds session dedupe", async () => { @@ -65,32 +66,53 @@ describe("direct recall", () => { ).toBe(""); }); - test("suppresses first-turn hits already injected from the profile", async () => { + test("starts first-turn search alongside profile and suppresses only injected facts", async () => { const cache = new RecallSessionCache(); - const skippedContext = await buildDirectRecallContext({ - prompt: "hello", - sessionID: "session-1", - cache, - suppressTexts: Promise.resolve(["Use Bun for all package scripts"]), - search: async () => { - throw new Error("short prompts must not search"); + const profile = { + success: true, + profile: { + static: [ + "Use Bun for all package scripts", + "This undisplayed profile fact may still be recalled", + ], + dynamic: [], }, + }; + let resolveSuppression!: (texts: string[]) => void; + const suppression = new Promise((resolve) => { + resolveSuppression = resolve; }); - const context = await buildDirectRecallContext({ + let searchStarted = false; + + const contextPromise = buildDirectRecallContext({ prompt: "what did we decide about the build system", sessionID: "session-1", cache, - search: async () => ({ - success: true, - results: [ - { memory: "Use Bun for all package scripts", similarity: 0.9 }, - { memory: "Run typecheck before build", similarity: 0.8 }, - ], - }), + suppressTexts: suppression, + search: async () => { + searchStarted = true; + return { + success: true as const, + results: [ + { memory: "Use Bun for all package scripts", similarity: 0.9 }, + { + memory: "This undisplayed profile fact may still be recalled", + similarity: 0.85, + }, + { memory: "Run typecheck before build", similarity: 0.8 }, + ], + }; + }, }); + await Promise.resolve(); + + expect(searchStarted).toBe(true); + resolveSuppression(getInjectedProfileFactTexts(profile, 1)); + const context = await contextPromise; - expect(skippedContext).toBe(""); expect(context).not.toContain("Use Bun for all package scripts"); + expect(context).toContain("This undisplayed profile fact may still be recalled"); expect(context).toContain("Run typecheck before build"); + expect(context).toContain("◪"); }); }); diff --git a/src/services/recall.ts b/src/services/recall.ts index a0bfb4f..e3ae57b 100644 --- a/src/services/recall.ts +++ b/src/services/recall.ts @@ -115,8 +115,9 @@ export class RecallSessionCache { function formatDirectRecallContext(hits: RecallHit[]): string { return [ "", - "Relevant memories automatically recalled for this prompt:", - ...hits.map((hit) => `- ${formatRecallHit(hit)}`), + "Relevant memories automatically recalled for this prompt. Every line marked ◪ comes from supermemory:", + ...hits.map((hit) => `- ◪ ${formatRecallHit(hit)}`), + "When one shapes your answer, credit it naturally with the ◪ prefix; if you name the source, say \"from supermemory\".", "Use these memories only when relevant. Search Supermemory for deeper context if needed.", "", ].join("\n"); @@ -130,17 +131,25 @@ export async function buildDirectRecallContext(options: { suppressTexts?: Iterable | Promise>; }): Promise { try { + const query = prepareRecallQuery(options.prompt); + // Begin the search before waiting for first-turn profile suppression. Both + // reads have the same timeout, so a cold prompt pays one network window. + const searchPromise = query + ? Promise.resolve() + .then(() => options.search(query)) + .catch(() => null) + : null; + if (options.suppressTexts) { options.cache.rememberTexts( options.sessionID, await options.suppressTexts, ); } - const query = prepareRecallQuery(options.prompt); if (!query) return ""; - const response = await options.search(query); - if (!response.success) return ""; + const response = await searchPromise; + if (!response?.success) return ""; const hits = normalizeRecallResults(response.results ?? []); const freshHits = options.cache.takeFresh(options.sessionID, hits); return freshHits.length > 0 ? formatDirectRecallContext(freshHits) : ""; diff --git a/src/services/result-merge.ts b/src/services/result-merge.ts index dd5d177..fec9ccb 100644 --- a/src/services/result-merge.ts +++ b/src/services/result-merge.ts @@ -28,7 +28,12 @@ function searchKey(result: SearchResultItem): string { } function score(result: SearchResultItem): number { - return result.similarity ?? result.score ?? -1; + return ( + [result.similarity, result.score].find( + (value): value is number => + typeof value === "number" && Number.isFinite(value), + ) ?? -1 + ); } export function mergeSearchResponses(