Skip to content
Merged
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
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,13 +280,20 @@ memories do not consume slots that could hold eligible memories. Setting
the user profile to be injected when enabled.

Injected memory tags identify `selection="recent"` or `selection="semantic"`.
Recent selections (including missing-query fallback and recent fill in `both`) have no
Recent selections (including fallback and recent fill in `both`) have no
numeric score. Semantic selections carry a finite `retrieval_score` when available;
this is a ranking signal that can include the current-project boost, not a probability
of relevance or factual correctness. Missing or invalid scores are omitted. This
replaces the former `relevance="...%"` prompt attribute without changing memory
selection, ordering, content, or provenance. Compaction retains its existing format.

When semantic recall fails (for example, embedding is unavailable), `relevant` and
`both` fall back to the existing recent listing with the same scope, count, session,
and age restrictions. A successful search with no eligible matches stays empty in
`relevant`; `both` still fills from recent as usual. Partial-shard warnings do not
trigger failure fallback. If recent fallback also fails, recall remains empty and
nonfatal. Missing-query fallback and per-turn refresh behavior are unchanged.

### Update-on-capture (optional)

Opt-in path that keeps memories current as facts change. When `updateOnCaptureEnabled` is
Expand Down
39 changes: 29 additions & 10 deletions src/services/memory-recall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,8 @@ export interface LoadRecallOptions {
/**
* Select memories for chat auto-injection according to chatMessage.recall.
* Falls back to recent listing when relevant/both is requested but no query text
* is available (e.g. V2 context rehydrate without a fresh prompt).
* is available (e.g. V2 context rehydrate without a fresh prompt), or semantic
* search fails. Successful searches with no eligible matches are not failures.
*/
export async function loadRecallMemories(opts: LoadRecallOptions): Promise<RecallMemory[]> {
const scope = resolveRecallScope(opts.recallScope, opts.defaultScope);
Expand Down Expand Up @@ -189,15 +190,21 @@ export async function loadRecallMemories(opts: LoadRecallOptions): Promise<Recal
return listResult.memories.map(listRowToRecall);
};

const loadRelevant = async (): Promise<RecallMemory[]> => {
const loadRelevant = async (): Promise<RecallMemory[] | null> => {
const query = opts.queryText!.trim();
// Over-fetch so session/age filters and project boost can still fill the slot budget.
const fetchLimit = Math.max(opts.maxMemories * 3, opts.maxMemories);
const searchRes = await memoryClient.searchMemories(query, opts.containerTag, scope, {
limit: fetchLimit,
similarityThreshold: opts.similarityThreshold,
});
if (!searchRes.success) return [];
let searchRes;
try {
searchRes = await memoryClient.searchMemories(query, opts.containerTag, scope, {
limit: fetchLimit,
similarityThreshold: opts.similarityThreshold,
});
} catch {
return null;
}
// Partial-shard warnings remain successful searches, even with no matches.
if (!searchRes.success) return null;
let results = searchRes.results.map(searchResultToRecall);
results = filterRecallResults(results, filters);
if (scope === "all-projects") {
Expand All @@ -212,12 +219,24 @@ export async function loadRecallMemories(opts: LoadRecallOptions): Promise<Recal
return loadRecent();
}

if (mode === "relevant") {
return loadRelevant();
const relevant = await loadRelevant();
if (relevant === null) {
log("memory recall: semantic search failed, falling back to recent", {
sessionID: opts.sessionID,
requested: mode,
});
// Return directly so both mode never repeats a short or failed recent load.
try {
return await loadRecent();
} catch {
log("memory recall: recent fallback failed", { sessionID: opts.sessionID });
return [];
}
}

if (mode === "relevant") return relevant;

// both
const relevant = await loadRelevant();
if (relevant.length >= opts.maxMemories) return relevant.slice(0, opts.maxMemories);
const recent = await loadRecent();
return mergeBoth(relevant, recent, opts.maxMemories);
Expand Down
36 changes: 35 additions & 1 deletion tests/fixtures/memory-injection-eligibility.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,18 @@ mock.module(moduleUrl("config"), () => ({
initConfig() {},
isConfigured: () => true,
}));
let failedSearchCalls = 0;
mock.module(moduleUrl("services/embedding"), () => ({
EmbeddingService: class {},
embeddingService: { isWarmedUp: true, warmup: async () => {}, dispose: async () => {} },
embeddingService: {
isWarmedUp: true,
warmup: async () => {},
dispose: async () => {},
embedWithTimeout: async () => {
failedSearchCalls++;
throw new Error("synthetic semantic embedding outage");
},
},
}));
mock.module(moduleUrl("services/turso/ready"), () => ({
ensureTursoReady: async () => {},
Expand Down Expand Up @@ -166,6 +175,23 @@ const result = {
),
readSizes: [...readSizes],
};
// The real V1 injection and V2 shared loader must both recover via the real
// client/database recent path when semantic retrieval is unavailable.
const originalSearch = memoryClient.searchMemories;
config.chatMessage.recall = "relevant";
result.failedRecallLoaded = await plugin.memoryContext.load(sessionID);
const failureMessage = {
message: { id: "fixture-failure-message" },
parts: [{ type: "text", text: "A fresh real user request" }],
};
await plugin["chat.message"]({ sessionID }, failureMessage);
result.failedRecallV1 = failureMessage.parts
.filter((part) => part.synthetic)
.map((part) => part.text)
.join("\n");
result.failedRecallSearchCalls = failedSearchCalls;
config.chatMessage.recall = "recent";
memoryClient.searchMemories = originalSearch;
result.ordinaryList = (await memoryClient.listMemories(projectTag, 3)).memories.map((m) => m.id);
config.chatMessage.excludeCurrentSession = false;
result.unfiltered = ids(await plugin.memoryContext.load(sessionID));
Expand All @@ -183,6 +209,14 @@ await add("eligible-c", 200, undefined, 1);
await add("eligible-d", 400, undefined, 1);
result.multishard = ids(await plugin.memoryContext.load(sessionID));
result.multishardReadSizes = [...readSizes];
config.chatMessage.recall = "relevant";
memoryClient.searchMemories = async () => {
throw new Error("synthetic semantic transport outage");
};
result.thrownRecallEligible = ids(await plugin.memoryContext.load(sessionID));
result.thrownRecallOtherSession = ids(await plugin.memoryContext.load("different-session"));
config.chatMessage.recall = "recent";
memoryClient.searchMemories = originalSearch;

// Preserve the strict age cutoff, even when an older pinned row is ahead of it.
await clear();
Expand Down
117 changes: 117 additions & 0 deletions tests/fixtures/recall-failure-fallback.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { mock } from "bun:test";
const moduleUrl = (path) => new URL("../../src/" + path + ".js", import.meta.url).href;
mock.module(moduleUrl("config"), () => ({ CONFIG: { injectProfile: false } }));
mock.module(moduleUrl("services/logger"), () => ({ log() {} }));
mock.module(moduleUrl("services/user-profile/profile-context"), () => ({
getUserProfileContext: async () => "",
}));
const now = Date.UTC(2026, 10, 2);
Date.now = () => now;
const recent = [
{
id: "recent",
summary: "recent body",
createdAt: "2026-11-01",
projectName: "project",
metadata: { sessionID: "previous" },
},
];
const semantic = {
id: "semantic",
memory: "semantic body",
similarity: 0.75,
createdAt: now,
containerTag: "project",
};
let scenario;
let calls;
mock.module(moduleUrl("services/client"), () => ({
memoryClient: {
listMemories: async (...args) => {
calls.list.push(args);
if (scenario.list === "throw") throw new Error("recent client unavailable");
if (scenario.list === "failure")
return { success: false, error: "recent client unavailable" };
return { success: true, memories: scenario.list === "empty" ? [] : recent };
},
searchMemories: async (...args) => {
calls.search.push(args);
if (scenario.search === "throw") throw new Error("semantic client unavailable");
if (scenario.search === "failure")
return { success: false, error: "semantic client unavailable" };
const results =
scenario.search === "filtered"
? [
{ ...semantic, sessionId: "session" },
{ ...semantic, id: "old", createdAt: now - 10 * 86400000 },
]
: scenario.search === "empty"
? []
: [semantic];
return {
success: true,
results,
...(scenario.warning ? { warnings: ["One shard could not be searched"] } : {}),
};
},
},
}));
globalThis.fetch = () => {
throw new Error("Network forbidden in fixture");
};
const { loadRecallMemories } = await import(moduleUrl("services/memory-recall"));
const { formatContextForPrompt } = await import(moduleUrl("services/context"));
const defaults = {
containerTag: "project",
sessionID: "session",
queryText: " query ",
maxMemories: 3,
recall: "relevant",
recallScope: "default",
defaultScope: "project",
excludeCurrentSession: true,
maxAgeDays: 2,
similarityThreshold: 0.6,
};
const scenarios = {
failed: { search: "failure" },
thrown: { search: "throw" },
bothFailed: { search: "failure", recall: "both" },
bothThrown: { search: "throw", recall: "both" },
bothFailedEmpty: { search: "failure", recall: "both", list: "empty" },
empty: { search: "empty" },
filtered: { search: "filtered" },
partial: { warning: true },
partialEmpty: { search: "empty", warning: true },
fallbackFailed: { search: "failure", list: "failure" },
fallbackThrown: { search: "failure", list: "throw" },
bothFallbackThrown: { search: "throw", recall: "both", list: "throw" },
recent: { recall: "recent", search: "throw" },
missingQuery: { queryText: null, search: "throw" },
bothMissingQuery: { recall: "both", queryText: " ", search: "throw" },
bothEmpty: { recall: "both", search: "empty" },
bothSuccess: { recall: "both" },
full: { recall: "both", maxMemories: 1 },
zero: { maxMemories: 0, search: "throw" },
zeroBoth: { recall: "both", maxMemories: 0, search: "throw" },
zeroRecent: { recall: "recent", maxMemories: 0 },
allProjects: { search: "failure", defaultScope: "all-projects" },
explicitProject: { search: "failure", defaultScope: "all-projects", recallScope: "project" },
disabledFilters: { search: "failure", excludeCurrentSession: false, maxAgeDays: 0 },
};
const output = {};
for (const [name, value] of Object.entries(scenarios)) {
scenario = value;
calls = { search: [], list: [] };
try {
const memories = await loadRecallMemories({ ...defaults, ...value });
output[name] = {
memories,
calls,
context: await formatContextForPrompt(null, { results: memories }),
};
} catch (error) {
output[name] = { error: String(error), calls };
}
}
console.log(JSON.stringify(output));
36 changes: 36 additions & 0 deletions tests/fixtures/v2-memory-context.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ let configured = true;
let memories = [{ summary: "original project knowledge", createdAt: new Date().toISOString() }];
let failLookup = false;
let lookupCount = 0;
let searchCount = 0;
let failSearch = true;
const captures = [];
const histories = new Map();
const titles = new Map();
Expand All @@ -34,6 +36,12 @@ mock.module(moduleUrl("config"), () => ({
}));
mock.module(moduleUrl("services/client"), () => ({
memoryClient: {
searchMemories: async () => {
searchCount++;
return failSearch
? { success: false, error: "synthetic semantic outage", results: [], total: 0 }
: { success: true, results: [], total: 0 };
},
warmup: async () => {},
close: async () => {},
listMemories: async (_tag, limit, _scope, filters = {}) => {
Expand Down Expand Up @@ -225,6 +233,34 @@ const later = {
};
await plugin["chat.message"]({ sessionID: "v1" }, later);
output.v1Later = later.parts;
// Relevant-mode failure fallback uses the same per-turn cache as semantic recall.
config.chatMessage.recall = "relevant";
const lookupsBeforeFallback = lookupCount;
memories = [
{ id: "fallback-a", summary: "fallback session A", createdAt: new Date().toISOString() },
];
await host.prompt("fallback-a", "first fallback prompt", "msg-fallback-a1");
output.fallbackFirst = await host.context("fallback-a");
output.fallbackModelStep = await host.context("fallback-a");
output.fallbackInitialCounts = { search: searchCount, list: lookupCount - lookupsBeforeFallback };
memories = [
{ id: "fallback-b", summary: "fallback session B", createdAt: new Date().toISOString() },
];
await host.prompt("fallback-b", "another session prompt", "msg-fallback-b1");
output.fallbackOtherSession = await host.context("fallback-b");
output.fallbackOriginalSession = await host.context("fallback-a");
memories = [
{ id: "fallback-new", summary: "fresh fallback A", createdAt: new Date().toISOString() },
];
await host.prompt("fallback-a", "next authored prompt retries", "msg-fallback-a2");
output.fallbackNextTurn = await host.context("fallback-a");
output.fallbackNextStep = await host.context("fallback-a");
output.fallbackNextCounts = { search: searchCount, list: lookupCount - lookupsBeforeFallback };
failSearch = false;
await host.prompt("fallback-a", "successful empty retrieval", "msg-fallback-a3");
output.fallbackRecoveredEmpty = await host.context("fallback-a");
output.fallbackOtherAfterRecovery = await host.context("fallback-b");
output.fallbackFinalCounts = { search: searchCount, list: lookupCount - lookupsBeforeFallback };
await host.cleanup();
await plugin.dispose();
console.log(JSON.stringify(output));
21 changes: 21 additions & 0 deletions tests/memory-injection-eligibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ import { fileURLToPath } from "node:url";
const directory = mkdtempSync(join(tmpdir(), "opencode-mem-injection-eligibility-"));
let result: {
loaded: string[];
failedRecallLoaded: string;
failedRecallV1: string;
failedRecallSearchCalls: number;
thrownRecallEligible: string[];
thrownRecallOtherSession: string[];
v1: string[];
readSizes: number[];
ordinaryList: string[];
Expand Down Expand Up @@ -45,6 +50,22 @@ describe("memory injection eligibility", () => {
expect(result.v1).toEqual(["eligible-0", "eligible-1", "eligible-2"]);
});

it("recovers V1 and the V2 shared loader through real eligible recent listing", () => {
for (const context of [result.failedRecallLoaded, result.failedRecallV1]) {
expect(context).toContain('selection="recent" id="eligible-0"');
expect(context).toContain('selection="recent" id="eligible-1"');
expect(context).toContain('selection="recent" id="eligible-2"');
expect(context).not.toContain('id="excluded-');
expect(context).not.toContain("retrieval_score=");
}
expect(result.failedRecallSearchCalls).toBe(2);
});

it("preserves project, age, and session isolation after a thrown semantic failure", () => {
expect(result.thrownRecallEligible).toEqual(["eligible-a", "eligible-c", "eligible-b"]);
expect(result.thrownRecallOtherSession).toEqual(["current-pinned", "eligible-a", "eligible-c"]);
});

it("leaves ordinary listing and disabled session exclusion unchanged", () => {
expect(result.ordinaryList).toEqual(["excluded-0", "excluded-1", "excluded-2"]);
expect(result.unfiltered).toEqual(result.ordinaryList);
Expand Down
Loading
Loading