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>
218 lines
6.9 KiB
JavaScript
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;
|
|
}
|