Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 81 additions & 1 deletion plugin/hooks/lib/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
};
10 changes: 6 additions & 4 deletions plugin/hooks/recall-directive.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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);
Expand Down
79 changes: 64 additions & 15 deletions test/unit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
],
}),
);
});
Expand All @@ -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'),
Expand Down Expand Up @@ -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' };
Expand Down Expand Up @@ -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', () => {
Expand Down