From 0d2610863d26bca10e3809fc11cdca773a51fb1b Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Sat, 22 Aug 2026 19:55:47 +0530 Subject: [PATCH] Improve recall result quality --- README.md | 2 +- src/config.ts | 2 +- src/index.ts | 34 +++++++-- src/services/client.ts | 6 +- src/services/context.ts | 32 ++++----- src/services/recall-results.test.ts | 36 ++++++++++ src/services/recall-results.ts | 104 ++++++++++++++++++++++++++++ src/services/result-merge.ts | 12 +--- 8 files changed, 189 insertions(+), 39 deletions(-) create mode 100644 src/services/recall-results.test.ts create mode 100644 src/services/recall-results.ts diff --git a/README.md b/README.md index a488d8f..9edaece 100644 --- a/README.md +++ b/README.md @@ -251,7 +251,7 @@ Create `~/.config/opencode/supermemory.jsonc`: "baseUrl": "https://api.supermemory.ai", // Min similarity for memory retrieval (0-1) - "similarityThreshold": 0.6, + "similarityThreshold": 0.55, // Max memories injected per request "maxMemories": 5, diff --git a/src/config.ts b/src/config.ts index b100652..592cb9e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -52,7 +52,7 @@ const DEFAULT_KEYWORD_PATTERNS = [ ]; const DEFAULTS: Required> = { - similarityThreshold: 0.6, + similarityThreshold: 0.55, maxMemories: 5, maxProjectMemories: 10, maxProfileItems: 5, diff --git a/src/index.ts b/src/index.ts index cb090e5..869fa68 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,10 @@ import { supermemoryClient } from "./services/client.js"; import { formatContextForPrompt } from "./services/context.js"; import { createCaptureHook } from "./services/capture.js"; import { buildRecallDirective } from "./services/recall.js"; +import { + formatRecallHit, + normalizeRecallResult, +} from "./services/recall-results.js"; import { getTags } from "./services/tags.js"; import { stripPrivateContent, isFullyPrivate } from "./services/privacy.js"; import { createCompactionHook, type CompactionContext } from "./services/compaction.js"; @@ -591,19 +595,39 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { function formatSearchResults( query: string, scope: string | undefined, - results: { results?: Array<{ id?: string; memory?: string; chunk?: string; similarity?: number }> }, + results: { + results?: Array<{ + id?: string; + memory?: string; + chunk?: string; + content?: string; + text?: string; + context?: unknown; + similarity?: number; + score?: number; + title?: string; + filepath?: string; + metadata?: Record | null; + }>; + }, limit?: number ): string { - const memoryResults = results.results || []; + const memoryResults = (results.results || []) + .map((result) => normalizeRecallResult(result)) + .filter((hit): hit is NonNullable => hit !== null) + .slice(0, limit ?? 10); return JSON.stringify({ success: true, query, scope, count: memoryResults.length, - results: memoryResults.slice(0, limit || 10).map((r) => { + results: memoryResults.map((hit) => { + const r = hit.result; const result = { - content: r.memory ?? r.chunk, - similarity: Math.round((r.similarity ?? 0) * 100), + content: formatRecallHit(hit), + similarity: Math.round(hit.similarity * 100), + ...(hit.title ? { title: hit.title } : {}), + ...(hit.filepath ? { filepath: hit.filepath } : {}), }; return r.memory === undefined diff --git a/src/services/client.ts b/src/services/client.ts index d9454fc..77e6a10 100644 --- a/src/services/client.ts +++ b/src/services/client.ts @@ -29,10 +29,12 @@ export interface SearchResultItem { memory?: string; content?: string; chunk?: string; + text?: string; context?: unknown; score?: number; similarity?: number; title?: string; + filepath?: string; updatedAt?: string; metadata?: Record | null; containerTag?: string; @@ -269,10 +271,6 @@ export class SupermemoryClient { result.searchResults.results as SearchResultItem[] ).map((item) => ({ ...item, - memory: - item.memory ?? - item.content ?? - String(item.context ?? ""), containerTag, })), total: result.searchResults.total, diff --git a/src/services/context.ts b/src/services/context.ts index 06cc3d6..d159dfd 100644 --- a/src/services/context.ts +++ b/src/services/context.ts @@ -1,14 +1,12 @@ -import type { ProfileResponse } from "./client.js"; +import type { ProfileResponse, SearchResultItem } from "./client.js"; import { CONFIG } from "../config.js"; - -interface MemoryResultMinimal { - similarity?: number; - memory?: string; - chunk?: string; -} +import { + formatRecallHit, + normalizeRecallResults, +} from "./recall-results.js"; interface MemoriesResponseMinimal { - results?: MemoryResultMinimal[]; + results?: SearchResultItem[]; } function extractFactText(fact: unknown): string { @@ -48,23 +46,21 @@ export function formatContextForPrompt( } } - const projectResults = projectMemories.results || []; + const projectResults = normalizeRecallResults(projectMemories.results || []); if (projectResults.length > 0) { parts.push("\nProject Knowledge:"); - projectResults.forEach((mem) => { - const similarity = Math.round((mem.similarity ?? 0) * 100); - const content = mem.memory || mem.chunk || ""; - parts.push(`- [${similarity}%] ${content}`); + projectResults.forEach((hit) => { + const similarity = Math.round(hit.similarity * 100); + parts.push(`- [${similarity}%] ${formatRecallHit(hit)}`); }); } - const userResults = userMemories.results || []; + const userResults = normalizeRecallResults(userMemories.results || []); if (userResults.length > 0) { parts.push("\nRelevant Memories:"); - userResults.forEach((mem) => { - const similarity = Math.round((mem.similarity ?? 0) * 100); - const content = mem.memory || mem.chunk || ""; - parts.push(`- [${similarity}%] ${content}`); + userResults.forEach((hit) => { + const similarity = Math.round(hit.similarity * 100); + parts.push(`- [${similarity}%] ${formatRecallHit(hit)}`); }); } diff --git a/src/services/recall-results.test.ts b/src/services/recall-results.test.ts new file mode 100644 index 0000000..93924e8 --- /dev/null +++ b/src/services/recall-results.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test"; + +import { + formatRecallHit, + normalizeRecallResults, +} from "./recall-results.js"; + +describe("recall result normalization", () => { + test("keeps the strongest supported result shapes with provenance", () => { + const hits = normalizeRecallResults([ + { memory: "memory result", similarity: 0.99 }, + { chunk: "chunk result", similarity: 0.9 }, + { content: "content result", similarity: 0.8 }, + { text: "text result", similarity: 0.7 }, + { + context: "context result", + similarity: 0.6, + title: "Decision", + filepath: "src/index.ts", + }, + { memory: "below threshold", similarity: 0.54 }, + { memory: "sixth result", similarity: 0.56 }, + ]); + + expect(hits.map((hit) => hit.text)).toEqual([ + "memory result", + "chunk result", + "content result", + "text result", + "context result", + ]); + expect(formatRecallHit(hits[4]!)).toBe( + "Decision: context result (src/index.ts)", + ); + }); +}); diff --git a/src/services/recall-results.ts b/src/services/recall-results.ts new file mode 100644 index 0000000..c00928d --- /dev/null +++ b/src/services/recall-results.ts @@ -0,0 +1,104 @@ +import type { SearchResultItem } from "./client.js"; + +export const MIN_RECALL_SIMILARITY = 0.55; +export const MAX_RECALL_RESULTS = 5; +export const MAX_RECALL_HIT_CHARS = 300; + +export interface RecallHit { + result: SearchResultItem; + text: string; + similarity: number; + title?: string; + filepath?: string; +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : undefined; +} + +export function getRecallResultText(result: SearchResultItem): string { + return ( + nonEmptyString(result.memory) ?? + nonEmptyString(result.chunk) ?? + nonEmptyString(result.content) ?? + nonEmptyString(result.text) ?? + nonEmptyString(result.context) ?? + "" + ); +} + +function getRecallResultTitle(result: SearchResultItem): string | undefined { + return nonEmptyString(result.title) ?? nonEmptyString(result.metadata?.title); +} + +function getRecallResultFilepath(result: SearchResultItem): string | undefined { + return ( + nonEmptyString(result.filepath) ?? + nonEmptyString(result.metadata?.filepath) ?? + nonEmptyString(result.metadata?.filePath) ?? + nonEmptyString(result.metadata?.path) + ); +} + +function truncateHit(text: string, maxChars: number): string { + if (text.length <= maxChars) return text; + if (maxChars <= 3) return text.slice(0, maxChars); + return `${text.slice(0, maxChars - 3).trimEnd()}...`; +} + +export function normalizeRecallResult( + result: SearchResultItem, + maxHitChars?: number, +): RecallHit | null { + const text = getRecallResultText(result); + if (!text) return null; + + return { + result, + text: + maxHitChars === undefined + ? text + : truncateHit(text, Math.max(1, maxHitChars)), + similarity: result.similarity ?? result.score ?? 0, + title: getRecallResultTitle(result), + filepath: getRecallResultFilepath(result), + }; +} + +export function normalizeRecallResults( + results: SearchResultItem[], + options?: { + limit?: number; + minSimilarity?: number; + maxHitChars?: number; + }, +): RecallHit[] { + const limit = Math.max(0, options?.limit ?? MAX_RECALL_RESULTS); + const minSimilarity = Math.max( + MIN_RECALL_SIMILARITY, + options?.minSimilarity ?? MIN_RECALL_SIMILARITY, + ); + const maxHitChars = Math.max(1, options?.maxHitChars ?? MAX_RECALL_HIT_CHARS); + const seen = new Set(); + + 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) => { + const key = hit.text.toLowerCase().replace(/\s+/g, " ").trim(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }) + .slice(0, limit); +} + +export function formatRecallHit(hit: RecallHit): string { + const title = hit.title ? `${hit.title}: ` : ""; + const filepath = hit.filepath ? ` (${hit.filepath})` : ""; + return `${title}${hit.text}${filepath}`; +} diff --git a/src/services/result-merge.ts b/src/services/result-merge.ts index 121dce2..dd5d177 100644 --- a/src/services/result-merge.ts +++ b/src/services/result-merge.ts @@ -5,6 +5,7 @@ import type { SearchResponse, SearchResultItem, } from "./client.js"; +import { getRecallResultText } from "./recall-results.js"; function normalize(value: unknown): string { return String(value ?? "").toLowerCase().trim(); @@ -20,17 +21,8 @@ function dedupe(items: T[], getKey: (item: T) => string): T[] { }); } -function memoryText(result: SearchResultItem): string { - return ( - result.memory ?? - result.chunk ?? - result.content ?? - String(result.context ?? "") - ); -} - function searchKey(result: SearchResultItem): string { - const content = normalize(memoryText(result)); + const content = normalize(getRecallResultText(result)); if (content) return `content:${content}`; return result.id ? `id:${result.id}` : ""; }