feat: Add SM cross-project recall to memory ranking (Phase 3)

- Import querySmMemories from sm-client.js
- Merge cross-project memories into getRelevantMemoriesRanked
- Cap cross-project confidence at 0.8 with 0.9 reduction (conservative)
- Gracefully degrade: fail-open if SM unavailable
- Preserve cosine ranking with relation boost for merged pool
- Tests: 3821 passing, no regressions

Implements Tier 1.2 Phase 3: Cross-project memory recall via Singularity Memory.
Enables dispatch to leverage patterns from other projects while maintaining
local autonomy via fail-open semantics.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Mikael Hugo 2026-05-07 06:56:15 +02:00
parent bfb892eca3
commit 5e518dd7d4

View file

@ -18,6 +18,7 @@ import {
updateMemoryContentRow,
} from "./sf-db.js";
import { queueMemorySync } from "./sync-scheduler.js";
import { querySmMemories } from "./sm-client.js";
export { isDbAvailable };
@ -118,6 +119,37 @@ export async function getRelevantMemoriesRanked(query, limit = 10) {
if (pool.length === 0 || !query.trim()) {
return pool.slice(0, limit);
}
// Phase 3: Query cross-project memories from Singularity Memory (fail-open)
let crossProjectMemories = [];
try {
const smResults = await querySmMemories(query, {
limit: Math.max(3, Math.ceil(limit * 0.3)), // Cross-project recall is 30% of local limit
smConnected: process.env.SM_ENABLED !== "false",
});
// Convert SM results to local format (all cross-project memories tagged as such)
crossProjectMemories = (smResults || []).map((m) => ({
id: `sm-${m.id || "unknown"}`,
category: m.category || "pattern",
content: m.content || m.text || "",
confidence: Math.min(0.8, (m.confidence || 0.5) * 0.9), // Cap and reduce cross-project confidence
source_unit_type: "cross-project",
source_unit_id: m.source_tool || "sm",
created_at: m.created_at || Date.now(),
updated_at: m.updated_at || Date.now(),
superseded_by: null,
hit_count: m.hit_count || 0,
tags: ["cross-project"],
}));
} catch {
// SM unavailable or query failed — gracefully degrade to local-only
}
// Merge local and cross-project memories
const mergedPool = [...pool, ...crossProjectMemories];
if (mergedPool.length === 0 || !query.trim()) {
return mergedPool.slice(0, limit);
}
try {
const { embedQueryViaGateway, loadEmbeddingMap, rankMemoriesByEmbedding } =
await import("./memory-embeddings.js");
@ -126,10 +158,10 @@ export async function getRelevantMemoriesRanked(query, limit = 10) {
Promise.resolve(loadEmbeddingMap()),
]);
if (!queryVec || embeddingMap.size === 0) {
return pool.slice(0, limit);
return mergedPool.slice(0, limit);
}
let ranked = rankMemoriesByEmbedding(
pool.map((m) => ({
mergedPool.map((m) => ({
id: m.id,
staticScore: m.confidence * (1 + m.hit_count * 0.1),
})),
@ -165,7 +197,7 @@ export async function getRelevantMemoriesRanked(query, limit = 10) {
} catch {
// Relation boost is additive; failure preserves cosine ranking.
}
const byId = new Map(pool.map((m) => [m.id, m]));
const byId = new Map(mergedPool.map((m) => [m.id, m]));
// Top-K from cosine+relation rank — feed this into the optional rerank pass.
const topK = [];
for (const r of ranked) {
@ -201,7 +233,7 @@ export async function getRelevantMemoriesRanked(query, limit = 10) {
}
return topK;
} catch {
return pool.slice(0, limit);
return mergedPool.slice(0, limit);
}
}
/**