Eliminates ~120 repetitions of `err instanceof Error ? err.message : String(err)` across the entire extension source tree. All callers now import and use `getErrorMessage` from the canonical `./error-utils.js`. Files updated (56 files): - auto.js, auto-worktree.js, auto-recovery.js, auto-dashboard.js, auto-timers.js - auto-prompts.js, auto-start.js, auto-post-unit.js, auto-model-selection.js - auto/phases.js, auto/loop.js, auto/infra-errors.js - autonomous-solver-eval.js, bootstrap/agent-end-recovery.js, bootstrap/db-tools.js - bootstrap/exec-tools.js, bootstrap/journal-tools.js, bootstrap/register-extension.js - bootstrap/register-hooks.js, canonical-milestone-plan.js, changelog.js - clean-root-preflight.js, code-intelligence.js, commands-add-tests.js - commands-debug.js, commands-eval-review.js, commands-handlers.js - commands-maintenance.js, commands-pr-branch.js, commands-scan.js, commands-ship.js - commands-todo.js, commands-worktree.js, definition-io.js, doctor.js - doctor-config-checks.js, doctor-engine-checks.js, ecosystem/loader.js - eval-review-schema.js, exec-sandbox.js, execution-instruction-guard.js - graph-context.js, hook-emitter.js, index.js, learning/runtime.js - lifecycle-hooks.js, onboarding-state.js, orphan-worktree-sweep.js - planning-depth.js, quick.js, scaffold-keeper.js, sf-db/sf-db-core.js - slice-cadence.js, sm-client.js, spec-projections.js, subagent/background-jobs.js - subagent/isolation.js, sync-scheduler.js, tools/exec-tool.js - tools/sift-search-tool.js, tools/workflow-tool-executors.js, ui/index.js - uok/a2a-agent-server.js, uok/auto-dispatch.js, uok/auto-unit-closeout.js - uok/auto-verification.js, uok/chaos-monkey.js, uok/gate-runner.js - vault-resolver.js, workflow-install.js, workflow-plugins.js, worktree-manager.js - worktree-resolver.js Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
178 lines
5.7 KiB
JavaScript
178 lines
5.7 KiB
JavaScript
/**
|
|
* Graph-aware context injection for dispatch prompt builders.
|
|
*
|
|
* Reads the pre-built graph.json and returns a formatted context block
|
|
* for injection into prompts. Gracefully returns null when no graph exists
|
|
* or the query yields no results — callers must handle null.
|
|
*/
|
|
import { readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { logWarning } from "./workflow-logger.js";
|
|
import { getErrorMessage } from "./error-utils.js";
|
|
|
|
let cachedGraphApi = null;
|
|
let resolvedGraphApi = false;
|
|
function readGraphFile(projectDir) {
|
|
try {
|
|
const graphPath = join(projectDir, ".sf", "graphs", "graph.json");
|
|
const raw = readFileSync(graphPath, "utf-8");
|
|
const parsed = JSON.parse(raw);
|
|
const nodes = Array.isArray(parsed.nodes) ? parsed.nodes : [];
|
|
const edges = Array.isArray(parsed.edges) ? parsed.edges : [];
|
|
return {
|
|
nodes,
|
|
edges,
|
|
builtAt: typeof parsed.builtAt === "string" ? parsed.builtAt : undefined,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
async function fallbackGraphQuery(projectDir, term, budget = 3000) {
|
|
const graph = readGraphFile(projectDir);
|
|
if (!graph) return { nodes: [], edges: [] };
|
|
const needle = term.trim().toLowerCase();
|
|
const matches = graph.nodes.filter((node) => {
|
|
const hay = [node.id, node.label, node.description]
|
|
.filter(Boolean)
|
|
.join(" ")
|
|
.toLowerCase();
|
|
return hay.includes(needle);
|
|
});
|
|
const maxNodes = Math.max(1, Math.floor(Math.max(1, budget) / 20));
|
|
const selectedIds = new Set(
|
|
matches.slice(0, maxNodes).map((node) => node.id),
|
|
);
|
|
const nodeById = new Map(graph.nodes.map((node) => [node.id, node]));
|
|
// Pull one-hop neighbors so relation context survives even when the term
|
|
// matches only one side of an edge.
|
|
for (const edge of graph.edges) {
|
|
if (selectedIds.size >= maxNodes) break;
|
|
const touchesSelection =
|
|
selectedIds.has(edge.from) || selectedIds.has(edge.to);
|
|
if (!touchesSelection) continue;
|
|
if (
|
|
selectedIds.has(edge.from) &&
|
|
!selectedIds.has(edge.to) &&
|
|
nodeById.has(edge.to)
|
|
) {
|
|
selectedIds.add(edge.to);
|
|
} else if (
|
|
selectedIds.has(edge.to) &&
|
|
!selectedIds.has(edge.from) &&
|
|
nodeById.has(edge.from)
|
|
) {
|
|
selectedIds.add(edge.from);
|
|
}
|
|
}
|
|
const nodes = graph.nodes.filter((node) => selectedIds.has(node.id));
|
|
const remainingBudget = Math.max(0, budget - nodes.length * 20);
|
|
const maxEdges = Math.floor(remainingBudget / 10);
|
|
const edges = graph.edges
|
|
.filter((edge) => selectedIds.has(edge.from) && selectedIds.has(edge.to))
|
|
.slice(0, maxEdges);
|
|
return { nodes, edges };
|
|
}
|
|
async function fallbackGraphStatus(projectDir) {
|
|
const graph = readGraphFile(projectDir);
|
|
if (!graph) return { exists: false, stale: false };
|
|
if (!graph.builtAt) return { exists: true, stale: false };
|
|
const builtAtMs = Date.parse(graph.builtAt);
|
|
if (!Number.isFinite(builtAtMs)) return { exists: true, stale: false };
|
|
const ageHours = (Date.now() - builtAtMs) / (1000 * 60 * 60);
|
|
return { exists: true, stale: ageHours > 24, ageHours };
|
|
}
|
|
function isGraphApi(mod) {
|
|
if (!mod || typeof mod !== "object") return false;
|
|
const candidate = mod;
|
|
return (
|
|
typeof candidate.graphQuery === "function" &&
|
|
typeof candidate.graphStatus === "function"
|
|
);
|
|
}
|
|
async function resolveGraphApi() {
|
|
if (resolvedGraphApi && cachedGraphApi) return cachedGraphApi;
|
|
resolvedGraphApi = true;
|
|
try {
|
|
const imported = await import("@singularity-forge/agent-core");
|
|
if (isGraphApi(imported)) {
|
|
cachedGraphApi = imported;
|
|
return cachedGraphApi;
|
|
}
|
|
logWarning(
|
|
"prompt",
|
|
"@singularity-forge/agent-core graph exports unavailable; using local graph fallback",
|
|
);
|
|
} catch {
|
|
// Fall back to local reader implementation.
|
|
}
|
|
cachedGraphApi = {
|
|
graphQuery: fallbackGraphQuery,
|
|
graphStatus: fallbackGraphStatus,
|
|
};
|
|
return cachedGraphApi;
|
|
}
|
|
/**
|
|
* Query the knowledge graph for nodes related to the given term and format
|
|
* the result as an inlined context block.
|
|
*
|
|
* Returns null when:
|
|
* - @singularity-forge/agent-core fails to import
|
|
* - graph.json does not exist (graphQuery already handles this gracefully)
|
|
* - query returns zero nodes
|
|
*
|
|
* Annotates the block header when the graph is stale (> 24 hours old).
|
|
*/
|
|
export async function inlineGraphSubgraph(projectDir, term, opts) {
|
|
if (!term || !term.trim()) return null;
|
|
try {
|
|
const graphApi = await resolveGraphApi();
|
|
const result = await graphApi.graphQuery(projectDir, term, opts.budget);
|
|
if (result.nodes.length === 0) return null;
|
|
// Check staleness for annotation
|
|
let staleAnnotation = "";
|
|
try {
|
|
const status = await graphApi.graphStatus(projectDir);
|
|
if (status.exists && status.stale && status.ageHours !== undefined) {
|
|
const hours = Math.round(status.ageHours);
|
|
staleAnnotation = `\n> ⚠ Graph last built ${hours}h ago — context may be outdated`;
|
|
}
|
|
} catch {
|
|
// Non-fatal — skip annotation on error
|
|
}
|
|
// Format nodes as a compact list
|
|
const nodeLines = result.nodes.map((node) => {
|
|
const desc = node.description ? ` — ${node.description}` : "";
|
|
return `- **${node.label}** (\`${node.type}\`, ${node.confidence})${desc}`;
|
|
});
|
|
// Format edges as relations (only if present)
|
|
const edgeLines =
|
|
result.edges.length > 0
|
|
? result.edges.map(
|
|
(edge) => `- \`${edge.from}\` →[${edge.type}]→ \`${edge.to}\``,
|
|
)
|
|
: [];
|
|
const sections = [
|
|
`### Knowledge Graph Context (term: "${term}")`,
|
|
`Source: \`.sf/graphs/graph.json\``,
|
|
staleAnnotation,
|
|
"",
|
|
`**Nodes (${result.nodes.length}):**`,
|
|
...nodeLines,
|
|
];
|
|
if (edgeLines.length > 0) {
|
|
sections.push(
|
|
"",
|
|
`**Relations (${result.edges.length}):**`,
|
|
...edgeLines,
|
|
);
|
|
}
|
|
return sections.filter((l) => l !== undefined).join("\n");
|
|
} catch (err) {
|
|
logWarning(
|
|
"prompt",
|
|
`inlineGraphSubgraph failed (non-fatal): ${getErrorMessage(err)}`,
|
|
);
|
|
return null;
|
|
}
|
|
}
|