From c361d9de47755b78bd3c8abd97358f3ed2d08dca Mon Sep 17 00:00:00 2001 From: Divyansh <2023csb1119@iitrpr.ac.in> Date: Sun, 30 Aug 2026 19:17:12 +0530 Subject: [PATCH] fix: recall via /v3/search so self-hosted backends inject matches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-prompt recall only read /v4/profile's embedded searchResults, which stays empty on self-hosted even when /v3/search returns real hits — so the hook looked healthy but never injected context. Route recall through a dedicated searchMemory helper, normalize score/nested-chunk shapes, and leave /v4/profile for session-start profile facts. Fixes #106 --- plugin/hooks/lib/api.js | 82 +++++++++++++++++++++++++++++++- plugin/hooks/recall-directive.js | 10 ++-- test/unit.mjs | 79 ++++++++++++++++++++++++------ 3 files changed, 151 insertions(+), 20 deletions(-) diff --git a/plugin/hooks/lib/api.js b/plugin/hooks/lib/api.js index 8da5228..8206384 100644 --- a/plugin/hooks/lib/api.js +++ b/plugin/hooks/lib/api.js @@ -51,6 +51,81 @@ function getProfile(baseUrl, apiKey, containerTag, query, options = {}) { return post(baseUrl, apiKey, '/v4/profile', { containerTag, q: query }, options.timeoutMs); } +// Self-hosted backends sometimes return `score` instead of `similarity`, and +// document-mode hits may nest chunk text under `chunks[]`. Normalize so the +// recall hook's resultText/similarity filter sees a flat, consistent shape. +function normalizeSearchHit(hit) { + if (!hit || typeof hit !== 'object') return null; + const similarity = Number.isFinite(hit.similarity) + ? hit.similarity + : Number.isFinite(hit.score) + ? hit.score + : undefined; + const filepath = + (typeof hit.filepath === 'string' && hit.filepath) || + (typeof hit.metadata?.filepath === 'string' && hit.metadata.filepath) || + undefined; + return { + ...hit, + ...(similarity !== undefined ? { similarity } : {}), + ...(filepath ? { filepath } : {}), + }; +} + +function flattenSearchResults(response) { + const raw = Array.isArray(response?.results) ? response.results : []; + const out = []; + for (const item of raw) { + if (Array.isArray(item?.chunks) && item.chunks.length > 0) { + for (const chunk of item.chunks) { + const text = + [chunk?.chunk, chunk?.content, chunk?.text, chunk?.memory].find( + (v) => typeof v === 'string' && v.trim(), + ) || null; + const normalized = normalizeSearchHit({ + ...chunk, + ...(text && !chunk.chunk && !chunk.memory ? { chunk: text } : {}), + title: chunk.title || item.title, + filepath: chunk.filepath || item.filepath, + similarity: chunk.similarity ?? chunk.score ?? item.similarity ?? item.score, + }); + if (normalized) out.push(normalized); + } + continue; + } + const normalized = normalizeSearchHit(item); + if (normalized) out.push(normalized); + } + return out; +} + +// Per-prompt recall must not depend on /v4/profile's embedded searchResults — +// on self-hosted backends that field stays empty even when /v3/search finds +// real hits (issue #106). Dedicated search keeps cloud and local recall working. +async function searchMemory(baseUrl, apiKey, containerTag, query, options = {}) { + const body = { + q: query, + // Singular is current; plural is what the #106 self-hosted repro used on + // /v3/search. Send both so neither cloud nor local silently scopes wrong. + containerTag, + containerTags: [containerTag], + limit: options.limit ?? 10, + searchMode: options.searchMode ?? 'hybrid', + }; + const response = await post( + baseUrl, + apiKey, + '/v3/search', + body, + options.timeoutMs, + ); + return { + results: flattenSearchResults(response), + total: response?.total, + timing: response?.timing, + }; +} + function addMemory(baseUrl, apiKey, content, containerTag, metadata, options = {}) { const body = { content, @@ -62,4 +137,9 @@ function addMemory(baseUrl, apiKey, content, containerTag, metadata, options = { return post(baseUrl, apiKey, '/v3/documents', body, options.timeoutMs); } -module.exports = { AGENT_ENTITY_CONTEXT, getProfile, addMemory }; +module.exports = { + AGENT_ENTITY_CONTEXT, + getProfile, + searchMemory, + addMemory, +}; diff --git a/plugin/hooks/recall-directive.js b/plugin/hooks/recall-directive.js index 1cf28af..a0e34ef 100644 --- a/plugin/hooks/recall-directive.js +++ b/plugin/hooks/recall-directive.js @@ -2,7 +2,7 @@ const crypto = require('node:crypto'); const fs = require('node:fs'); const path = require('node:path'); -const { getProfile } = require('./lib/api'); +const { searchMemory } = require('./lib/api'); const { BRAND, gray, red } = require('./lib/colors'); const { getContainerTag } = require('./lib/container-tag'); const { getUserFriendlyError } = require('./lib/error-helpers'); @@ -123,15 +123,17 @@ async function main() { } const containerTag = getContainerTag(cwd); - const response = await getProfile( + // Use /v3/search directly — /v4/profile's embedded searchResults is empty + // on self-hosted backends even when search finds hits (issue #106). + const response = await searchMemory( getBaseUrl(cwd, projectConfig), apiKey, containerTag, prompt.slice(0, MAX_QUERY_LENGTH), - { timeoutMs: SEARCH_TIMEOUT_MS }, + { timeoutMs: SEARCH_TIMEOUT_MS, limit: MAX_RESULTS }, ); - const results = (response?.searchResults?.results || []) + const results = (response?.results || []) .filter((r) => resultText(r)) .filter((r) => !Number.isFinite(r.similarity) || r.similarity >= MIN_SIMILARITY) .slice(0, MAX_RESULTS); diff --git a/test/unit.mjs b/test/unit.mjs index c883f95..4c258bb 100644 --- a/test/unit.mjs +++ b/test/unit.mjs @@ -177,15 +177,13 @@ describe('recall-directive hook', () => { res.setHeader('Content-Type', 'application/json'); res.end( JSON.stringify({ - searchResults: { - results: [ - { memory: 'Chose Drizzle over Prisma', similarity: 0.82 }, - { chunk: 'export const db = drizzle(client)', filepath: 'src/db.ts', similarity: 0.74 }, - { memory: 'Errors must be loud and obvious', similarity: 0.71 }, - { title: 'Migration plan', content: 'Use expand-contract migrations', similarity: 0.7 }, - { memory: 'irrelevant low-similarity hit', similarity: 0.2 }, - ], - }, + results: [ + { memory: 'Chose Drizzle over Prisma', similarity: 0.82 }, + { chunk: 'export const db = drizzle(client)', filepath: 'src/db.ts', similarity: 0.74 }, + { memory: 'Errors must be loud and obvious', similarity: 0.71 }, + { title: 'Migration plan', content: 'Use expand-contract migrations', similarity: 0.7 }, + { memory: 'irrelevant low-similarity hit', similarity: 0.2 }, + ], }), ); }); @@ -207,11 +205,11 @@ describe('recall-directive hook', () => { assert.doesNotMatch(context, /irrelevant low-similarity hit/); assert.match(context, /repo_example_project__/); assert.match(plain(output.systemMessage), /^◪ supermemory · recalled \d+ memories \(\d+ tok\)$/); - assert.equal(stub.requests[0].url, '/v4/profile'); - assert.equal( - JSON.parse(stub.requests[0].body).q, - 'continue the database work from before', - ); + assert.equal(stub.requests[0].url, '/v3/search'); + const requestBody = JSON.parse(stub.requests[0].body); + assert.equal(requestBody.q, 'continue the database work from before'); + assert.match(requestBody.containerTag, /^repo_example_project__/); + assert.deepEqual(requestBody.containerTags, [requestBody.containerTag]); const state = readState('s1', { dataDir: join(home, '.supermemory-claude', 'statusline'), @@ -253,7 +251,7 @@ describe('recall-directive hook', () => { ]; const stub = await startStubServer(t, (record, res) => { res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ searchResults: { results: hits } })); + res.end(JSON.stringify({ results: hits })); }); const env = { HOME: home, USERPROFILE: home, SUPERMEMORY_API_URL: stub.url }; const input = { session_id: 's-dedup', cwd: repo, prompt: 'continue the database work' }; @@ -294,6 +292,57 @@ describe('recall-directive hook', () => { ); assert.equal(JSON.parse(stdout).hookSpecificOutput.additionalContext, 'CUSTOM DIRECTIVE'); }); + + test('recalls via /v3/search including score and nested chunk shapes (issue #106)', async (t) => { + const { repo, home } = makeRepo(t); + mkdirSync(join(home, '.supermemory-claude'), { recursive: true }); + writeFileSync( + join(home, '.supermemory-claude', 'credentials.json'), + JSON.stringify({ apiKey: 'sm_test_key_0123456789abcdef' }), + ); + const stub = await startStubServer(t, (record, res) => { + assert.equal(record.url, '/v3/search'); + res.setHeader('Content-Type', 'application/json'); + // Self-hosted shape from #106: score instead of similarity, and a + // document hit carrying nested chunks (total counts chunks, results + // length can be 1). + res.end( + JSON.stringify({ + results: [ + { + title: 'Region runbook', + score: 0.767, + chunks: [ + { content: 'Failover steps for multi-region deploy', score: 0.767 }, + { chunk: 'Health checks must probe both regions', score: 0.71 }, + ], + }, + { memory: 'too weak to inject', score: 0.2 }, + ], + total: 3, + }), + ); + }); + + const { code, stdout } = await runHook( + 'recall-directive.js', + { + session_id: 's-v3', + cwd: repo, + prompt: 'how can I see all the things supermemory have saved already', + }, + { HOME: home, USERPROFILE: home, SUPERMEMORY_API_URL: stub.url }, + ); + assert.equal(code, 0); + const output = JSON.parse(stdout); + const context = output.hookSpecificOutput.additionalContext; + assert.match(context, /Failover steps for multi-region deploy/); + assert.match(context, /Health checks must probe both regions/); + assert.doesNotMatch(context, /too weak to inject/); + assert.match(plain(output.systemMessage), /^◪ supermemory · recalled 2 memories \(\d+ tok\)$/); + assert.equal(stub.requests.length, 1); + assert.equal(stub.requests[0].url, '/v3/search'); + }); }); describe('stdin handling', () => {