singularity-forge/src/resources/extensions/sf/memory-relations.js
Mikael Hugo ac371926cb refactor(tools): rename SF tools to cleaner action-oriented names
Align tool names with Copilot coding agent conventions:
- sf_exec → run_command
- sf_exec_search → read_output
- sf_resume → resume_agent
- capture_thought → log_reasoning
- sf_log_judgment → log_decision
- sf_self_report → report_issue
- sf_self_feedback_resolve → resolve_issue
- sf_save_gate_result → record_gate
- sf_autonomous_checkpoint → checkpoint
- sf_milestone_generate_id → new_milestone_id
- sf_graph → memory_graph
- memory_query → memory_search
- sf_retrieval_evidence → search_evidence

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-10 07:10:41 +02:00

218 lines
6.9 KiB
JavaScript

// SF Memory Relations — knowledge-graph edges between memories
//
// Edges live in the `memory_relations` table and are produced by:
// (a) `applyMemoryActions` auto-linking co-extracted memories with
// `related_to` (confidence 0.5) — same-batch memories from the
// extractor share narrative context.
// (b) `/memory import` loading explicit edges from a JSON export.
// Read consumers:
// (1) `getRelevantMemoriesRanked` walks edges of cosine top-N memories
// and applies a one-pass intra-pool score boost (damping 0.4).
// (2) `memory_graph` exposes BFS traversal for explicit agent queries.
// All writes go through the single-writer gate in `sf-db.ts`.
import { _getAdapter, isDbAvailable } from "./sf-db.js";
export const VALID_RELATIONS = [
"related_to",
"depends_on",
"contradicts",
"elaborates",
"supersedes",
];
// ─── Helpers ────────────────────────────────────────────────────────────────
/**
* Type guard: check if a value is a valid RelationType.
*/
export function isValidRelation(value) {
return typeof value === "string" && VALID_RELATIONS.includes(value);
}
function clampConfidence(value) {
if (typeof value !== "number" || !Number.isFinite(value)) return 0.8;
if (value < 0.1) return 0.1;
if (value > 0.99) return 0.99;
return value;
}
// ─── Mutations ──────────────────────────────────────────────────────────────
/**
* Create a knowledge-graph edge between two memories with a relation type.
* Returns true if successful, false if memories don't exist or DB unavailable.
*/
export function createMemoryRelation(from, to, rel, confidence) {
if (!isDbAvailable()) return false;
if (!from || !to || from === to || !isValidRelation(rel)) return false;
const adapter = _getAdapter();
if (!adapter) return false;
try {
const fromRow = adapter
.prepare("SELECT 1 FROM memories WHERE id = :id")
.get({ ":id": from });
const toRow = adapter
.prepare("SELECT 1 FROM memories WHERE id = :id")
.get({ ":id": to });
if (!fromRow || !toRow) return false;
adapter
.prepare(
"INSERT OR REPLACE INTO memory_relations (from_id, to_id, rel, confidence, created_at) VALUES (:from_id, :to_id, :rel, :confidence, :created_at)",
)
.run({
":from_id": from,
":to_id": to,
":rel": rel,
":confidence": clampConfidence(confidence),
":created_at": new Date().toISOString(),
});
return true;
} catch {
return false;
}
}
/**
* Remove all edges (from or to) for a given memory ID.
*/
export function removeMemoryRelationsFor(memoryId) {
if (!isDbAvailable() || !memoryId) return;
const adapter = _getAdapter();
if (!adapter) return;
try {
adapter
.prepare(
"DELETE FROM memory_relations WHERE from_id = :id OR to_id = :id",
)
.run({ ":id": memoryId });
} catch {
// non-fatal
}
}
// ─── Queries ────────────────────────────────────────────────────────────────
/**
* List all relations (incoming or outgoing) for a given memory ID.
*/
export function listRelationsFor(memoryId) {
if (!isDbAvailable()) return [];
const adapter = _getAdapter();
if (!adapter) return [];
try {
const rows = adapter
.prepare(
"SELECT from_id, to_id, rel, confidence, created_at FROM memory_relations WHERE from_id = :id OR to_id = :id",
)
.all({ ":id": memoryId });
return rows.map(rowToRelation);
} catch {
return [];
}
}
/**
* BFS traversal of the memory knowledge graph starting from a memory ID.
* Returns nodes and edges up to the specified depth (clamped to max 5).
*/
export function traverseGraph(startId, depth) {
const emptyResult = { nodes: [], edges: [] };
if (!isDbAvailable() || !startId) return emptyResult;
const adapter = _getAdapter();
if (!adapter) return emptyResult;
const hop = Math.max(0, Math.min(5, Math.floor(depth || 0)));
try {
const visited = new Set();
const queue = [{ id: startId, hop: 0 }];
const nodes = new Map();
const edges = [];
while (queue.length > 0) {
const { id, hop: level } = queue.shift();
if (visited.has(id)) continue;
visited.add(id);
const nodeRow = adapter
.prepare(
"SELECT id, category, content, confidence, superseded_by FROM memories WHERE id = :id",
)
.get({ ":id": id });
if (!nodeRow) continue;
nodes.set(id, {
id: nodeRow["id"],
category: nodeRow["category"],
content: nodeRow["content"],
confidence: nodeRow["confidence"],
});
// Include supersedes edges from the base table so old graphs remain
// connected even before the extractor starts emitting LINK actions.
const successor = nodeRow["superseded_by"];
if (successor && successor !== "CAP_EXCEEDED") {
edges.push({
from: id,
to: successor,
rel: "supersedes",
confidence: 1,
createdAt: "",
});
if (!visited.has(successor) && level < hop) {
queue.push({ id: successor, hop: level + 1 });
}
}
const predecessors = adapter
.prepare("SELECT id FROM memories WHERE superseded_by = :id")
.all({ ":id": id });
for (const pred of predecessors) {
const predId = pred["id"];
edges.push({
from: predId,
to: id,
rel: "supersedes",
confidence: 1,
createdAt: "",
});
if (!visited.has(predId) && level < hop) {
queue.push({ id: predId, hop: level + 1 });
}
}
if (level >= hop) continue;
const outgoing = adapter
.prepare(
"SELECT from_id, to_id, rel, confidence, created_at FROM memory_relations WHERE from_id = :id",
)
.all({ ":id": id });
for (const row of outgoing) {
const edge = rowToRelation(row);
edges.push(edge);
if (!visited.has(edge.to)) queue.push({ id: edge.to, hop: level + 1 });
}
const incoming = adapter
.prepare(
"SELECT from_id, to_id, rel, confidence, created_at FROM memory_relations WHERE to_id = :id",
)
.all({ ":id": id });
for (const row of incoming) {
const edge = rowToRelation(row);
edges.push(edge);
if (!visited.has(edge.from))
queue.push({ id: edge.from, hop: level + 1 });
}
}
return {
nodes: [...nodes.values()],
edges: dedupeEdges(edges),
};
} catch {
return emptyResult;
}
}
function rowToRelation(row) {
const relRaw = row["rel"];
const rel = isValidRelation(relRaw) ? relRaw : "related_to";
return {
from: row["from_id"],
to: row["to_id"],
rel,
confidence: row["confidence"] ?? 0.8,
createdAt: row["created_at"] ?? "",
};
}
function dedupeEdges(edges) {
const seen = new Set();
const out = [];
for (const e of edges) {
const key = `${e.from}|${e.to}|${e.rel}`;
if (seen.has(key)) continue;
seen.add(key);
out.push(e);
}
return out;
}