FN-9038: add Knowledge Graph tab to Memory view

Expose knowledge graph data in the Memory view through a localized, resilient dashboard panel.

- Add protected knowledge graph API routes and access helpers.
- Add a Memory view tab, graph panel, loading/error states, and localized labels.
- Document graph navigation and cover access, route, hook, and component behavior.

Files changed:
 .changeset/fn-9038-knowledge-graph-tab.md          |   7 +
 docs/dashboard-guide.md                            |   2 +
 docs/knowledge-graph.md                            |  10 ++
 packages/dashboard/app/api/legacy.ts               |  17 ++-
 packages/dashboard/app/api/system/index.ts         |   1 +
 .../dashboard/app/api/system/knowledge-graph.ts    |  23 +++
 .../app/components/KnowledgeGraphPanel.css         |  35 +++++
 .../app/components/KnowledgeGraphPanel.tsx         |  68 +++++++++
 packages/dashboard/app/components/MemoryView.tsx   |  15 +-
 .../__tests__/KnowledgeGraphPanel.test.tsx         |  49 +++++++
 .../app/components/__tests__/MemoryView.test.tsx   |  13 ++
 .../app/hooks/__tests__/useKnowledgeGraph.test.ts  |  66 +++++++++
 packages/dashboard/app/hooks/useKnowledgeGraph.ts  |  94 ++++++++++++
 .../src/__tests__/knowledge-graph-access.test.ts   |  88 ++++++++++++
 packages/dashboard/src/knowledge-graph-access.ts   | 160 +++++++++++++++++++++
 .../register-knowledge-graph-routes.test.ts        |  75 ++++++++++
 .../src/routes/register-knowledge-routes.ts        | 120 ++++++++++++++++
 packages/i18n/locales/en/app.json                  |  15 +-
 packages/i18n/locales/es/app.json                  |  15 +-
 packages/i18n/locales/fr/app.json                  |  15 +-
 packages/i18n/locales/ko/app.json                  |  15 +-
 packages/i18n/locales/pt-BR/app.json               |  15 +-
 packages/i18n/locales/zh-CN/app.json               |  15 +-
 packages/i18n/locales/zh-TW/app.json               |  15 +-
 24 files changed, 939 insertions(+), 9 deletions(-)

Fusion-Task-Id: FN-9038

Fusion-Task-Lineage: 4200e214-ad22-4f92-b980-c9bcb8ffd3b9

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-13 16:48:01 -07:00
parent d1bb5f4eb0
commit dad726c777
24 changed files with 939 additions and 9 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add Memory Knowledge Graph navigation and bounded path search.
category: feature
dev: Adds /knowledge/graph endpoints; path searches cap at 10 hops and 20,000 expansions.

View File

@@ -2450,3 +2450,5 @@ The shared Task Detail Definition view shows the persisted spec alignment, lates
`GET /api/models` bounds each catalog refresh to 15 seconds and continues serving the registry's retained `getAvailable()` rows when a provider stalls or fails. Refreshes are single-flight per registry instance: a timed-out operation can continue in the provider runtime, but Fusion never starts another concurrently. A successful refresh is fresh for 60 seconds from its successful settlement; a failed refresh uses a separate 60-second retry window measured from its attempt start, so a failure is never reported as fresh. After a failed refresh settles, the next attempt starts only after both settlement and that retry interval. `GET /api/models` bounds each catalog refresh to 15 seconds and continues serving the registry's retained `getAvailable()` rows when a provider stalls or fails. Refreshes are single-flight per registry instance: a timed-out operation can continue in the provider runtime, but Fusion never starts another concurrently. A successful refresh is fresh for 60 seconds from its successful settlement; a failed refresh uses a separate 60-second retry window measured from its attempt start, so a failure is never reported as fresh. After a failed refresh settles, the next attempt starts only after both settlement and that retry interval.
Saving or removing API keys, completing OAuth login/manual-code flows, logging out, and removing credential instances invalidate that registry's generation and clear both windows. If a credential change happens while an uncancellable refresh is already running, the model list temporarily serves its retained rows rather than overlapping the refresh. Once that old refresh settles, the first following request starts a current-credential refresh with no additional cache-window wait. Saving or removing API keys, completing OAuth login/manual-code flows, logging out, and removing credential instances invalidate that registry's generation and clear both windows. If a credential change happens while an uncancellable refresh is already running, the model list temporarily serves its retained rows rather than overlapping the refresh. Once that old refresh settles, the first following request starts a current-credential refresh with no additional cache-window wait.
The Memory view also includes a fourth **Knowledge Graph** tab. It provides capped search, node detail, edge and neighbor drill-down, bounded shortest-path navigation, and explicit artifact rebuilding for the deterministic project knowledge graph.

View File

@@ -43,3 +43,13 @@ Memory capture is optional and detached. `RecallCaptureWriter.capture()` returns
## Non-goals ## Non-goals
This layer has no LLM calls, embeddings, vector recall, MCP tools, source-validity diagnostics, language support beyond TypeScript/TSX symbols, CommonMark parser, cross-rename identity, or capability-fabric bundle. The FR-29/FR-34 bundle format is deferred. This layer has no LLM calls, embeddings, vector recall, MCP tools, source-validity diagnostics, language support beyond TypeScript/TSX symbols, CommonMark parser, cross-rename identity, or capability-fabric bundle. The FR-29/FR-34 bundle format is deferred.
## Dashboard navigation
The dashboard's **Memory → Knowledge Graph** tab provides list-and-detail navigation without transferring the full artifact or rendering a whole-graph canvas. It shows graph status, counts, directory and provenance data; operators can rebuild the artifact explicitly (including force rebuild). Reads use a small manifest-validated artifact cache and never rebuild the graph.
The tab queries capped node pages (200 nodes), node edges (200 per direction), and neighbors with direction, edge-kind, and depth controls. Node IDs are query parameters, so IDs containing `/`, `#`, `@`, or `~` remain safe to navigate. It also supports node filters for kind, path/id prefix, regular-expression name, FNXC area, symbol kind, and owner.
The HTTP surface is `GET /api/knowledge/graph/status`, `/nodes`, `/node`, `/neighbors`, and `/path`, plus `POST /api/knowledge/graph/build`. Missing artifacts are a recoverable status state; query endpoints report them as 404 rather than silently returning empty results.
Dashboard shortest paths use a bounded undirected BFS, not the core unbounded helper. The default is six hops, the maximum is ten hops, and at most 20,000 nodes are expanded. Results are `found`, exhaustive `not-found`, or `limit-reached`; the latter has `truncated: true` and is intentionally distinct from no path. Unknown node IDs return 404. Whole-graph force-directed/canvas rendering remains an explicit non-goal.

View File

@@ -272,7 +272,22 @@ export type {
MemoryRetrievalTestResult, MemoryRetrievalTestResult,
QmdInstallResult, QmdInstallResult,
} from "./system/memory.js"; } from "./system/memory.js";
export {
fetchKnowledgeGraphStatus,
queryKnowledgeGraphNodes,
fetchKnowledgeGraphNode,
fetchKnowledgeGraphNeighbors,
fetchKnowledgeGraphPath,
buildKnowledgeGraphArtifacts,
} from "./system/knowledge-graph.js";
export type {
KnowledgeGraphStatus,
KnowledgeGraphNode,
KnowledgeGraphEdge,
KnowledgeGraphNeighbor,
KnowledgeGraphNodeQuery,
KnowledgeGraphPathResult,
} from "./system/knowledge-graph.js";
// Re-export skills types so hooks/components keep stable import paths via this barrel. // Re-export skills types so hooks/components keep stable import paths via this barrel.
import type { import type {

View File

@@ -6,6 +6,7 @@ export * from "./system-panel.js";
export * from "./insights.js"; export * from "./insights.js";
export * from "./research.js"; export * from "./research.js";
export * from "./memory.js"; export * from "./memory.js";
export * from "./knowledge-graph.js";
export * from "./scheduling.js"; export * from "./scheduling.js";
export * from "./workflows.js"; export * from "./workflows.js";
export * from "./plugins-and-skills.js"; export * from "./plugins-and-skills.js";

View File

@@ -0,0 +1,23 @@
import { api } from "../client/client.js";
import { withProjectId } from "../client/health.js";
export type KnowledgeGraphNode = { id: string; kind: "file" | "module" | "symbol" | "doc-concept" | "rationale"; name: string; owner: "file" | "derived"; ownerPath: string; source: { path: string; line: number; column: number }; attributes: Record<string, string> };
export type KnowledgeGraphEdge = { id: string; kind: "contains" | "imports" | "re-exports" | "relates-to" | "rationale-supports"; from: string; to: string; provenance: "extracted" | "inferred"; owner: "file" | "derived"; ownerPath: string; source: { path: string; line: number; column: number }; attributes: Record<string, string> };
export type KnowledgeGraphNeighbor = { node: KnowledgeGraphNode; distance: number; edges: KnowledgeGraphEdge[] };
export type KnowledgeGraphNodeQuery = { kinds?: string[]; pathPrefix?: string; idPrefix?: string; namePattern?: string; fnxcArea?: string; symbolKind?: string; owner?: string; limit?: number; offset?: number };
export type KnowledgeGraphStatus = { available: boolean; recoveryReason?: string | null; graphDir?: string; nodeCount?: number; edgeCount?: number; nodeKindCounts?: Record<string, number>; edgeKindCounts?: Record<string, number>; provenanceCounts?: Record<string, number>; ownerCounts?: Record<string, number>; fnxcAreas?: string[]; pathLimits: { defaultMaxHops: number; maxHops: number; maxExpansions: number } };
export type KnowledgeGraphPathResult =
| { outcome: "found"; path: { nodes: KnowledgeGraphNode[]; edges: KnowledgeGraphEdge[] }; hops: number; maxHops: number; expansions: number; truncated: false }
| { outcome: "not-found"; path: null; maxHops: number; expansions: number; truncated: false }
| { outcome: "limit-reached"; path: null; maxHops: number; expansions: number; truncated: true; limit: "max-hops" | "max-expansions" };
function query(params: Record<string, string | number | string[] | undefined>): string {
const entries = Object.entries(params).flatMap(([key, value]) => Array.isArray(value) ? value.filter(Boolean).map(item => [key, item] as const) : value === undefined || value === "" ? [] : [[key, String(value)] as const]);
return entries.length ? `?${entries.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`).join("&")}` : "";
}
export function fetchKnowledgeGraphStatus(projectId?: string): Promise<KnowledgeGraphStatus> { return api(withProjectId("/knowledge/graph/status", projectId)); }
export function queryKnowledgeGraphNodes(projectId: string | undefined, filters: KnowledgeGraphNodeQuery = {}): Promise<{ nodes: KnowledgeGraphNode[]; total: number; limit: number; offset: number }> { return api(withProjectId(`/knowledge/graph/nodes${query(filters)}`, projectId)); }
export function fetchKnowledgeGraphNode(projectId: string | undefined, id: string): Promise<{ node: KnowledgeGraphNode; outgoing: KnowledgeGraphEdge[]; incoming: KnowledgeGraphEdge[]; outgoingTotal: number; incomingTotal: number }> { return api(withProjectId(`/knowledge/graph/node${query({ id })}`, projectId)); }
export function fetchKnowledgeGraphNeighbors(projectId: string | undefined, options: { nodeId: string; direction?: "out" | "in" | "both"; edgeKinds?: string[]; depth?: number; limit?: number }): Promise<{ neighbors: KnowledgeGraphNeighbor[]; total: number }> { return api(withProjectId(`/knowledge/graph/neighbors${query(options)}`, projectId)); }
export function fetchKnowledgeGraphPath(projectId: string | undefined, options: { fromId: string; toId: string; maxHops?: number }): Promise<KnowledgeGraphPathResult> { return api(withProjectId(`/knowledge/graph/path${query(options)}`, projectId)); }
export function buildKnowledgeGraphArtifacts(projectId?: string, force = false): Promise<{ changed: boolean; nodes: number; edges: number; stats: Record<string, unknown> }> { return api(withProjectId("/knowledge/graph/build", projectId), { method: "POST", body: JSON.stringify({ force }) }); }

View File

@@ -0,0 +1,35 @@
/*
FNXC:KnowledgeGraphDashboard 2026-08-13-23:30:
The graph view deliberately presents capped list/detail slices rather than a whole-graph canvas. Responsive stacking keeps every filter and drill-down control usable below the Memory view mobile breakpoint.
*/
.knowledge-graph-panel { display: flex; flex: 1; min-height: 0; flex-direction: column; gap: var(--space-md); overflow: auto; }
.knowledge-graph-empty { align-items: center; justify-content: center; }
.knowledge-graph-status, .knowledge-graph-filters, .knowledge-graph-neighbors, .knowledge-graph-path { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-sm); padding: var(--space-md); }
.knowledge-graph-status > div { display: grid; gap: var(--space-xs); margin-right: auto; }
.knowledge-graph-status strong { color: var(--text); }
.knowledge-graph-status span, .knowledge-graph-muted { color: var(--text-muted); }
.knowledge-graph-status dl { display: flex; flex-wrap: wrap; gap: var(--space-sm); margin: 0; }
.knowledge-graph-status dl div { display: grid; gap: 0; }
.knowledge-graph-status dt { color: var(--text-muted); font-size: var(--font-size-xs); }
.knowledge-graph-status dd { margin: 0; font-size: var(--font-size-xs); }
.knowledge-graph-toggle, .knowledge-graph-kind-options { display: flex; flex-wrap: wrap; gap: var(--space-xs); align-items: center; }
.knowledge-graph-kind-options label { display: inline-flex; gap: var(--space-xs); align-items: center; color: var(--text-muted); font-size: var(--font-size-sm); }
.knowledge-graph-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--space-md); }
.knowledge-graph-results, .knowledge-graph-detail { min-width: 0; padding: var(--space-md); }
.knowledge-graph-results h3, .knowledge-graph-detail h3, .knowledge-graph-neighbors h3, .knowledge-graph-path h3 { width: 100%; margin: 0; }
.knowledge-graph-results ul, .knowledge-graph-edge-list ul, .knowledge-graph-neighbors ul { display: flex; flex-direction: column; gap: var(--space-xs); margin: var(--space-sm) 0; padding: 0; list-style: none; }
.knowledge-graph-results li .btn { display: grid; width: 100%; justify-content: start; gap: var(--space-xs); text-align: left; }
.knowledge-graph-results li span { color: var(--text-muted); font-size: var(--font-size-xs); overflow-wrap: anywhere; }
.knowledge-graph-pagination { display: flex; justify-content: space-between; gap: var(--space-sm); }
.knowledge-graph-edge-list h4 { display: flex; justify-content: space-between; margin: var(--space-md) 0 var(--space-xs); }
.knowledge-graph-edge-list h4 span { color: var(--text-muted); font-size: var(--font-size-xs); font-weight: normal; }
.knowledge-graph-edge-list li, .knowledge-graph-neighbors li { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-xs); }
.knowledge-graph-provenance { border-radius: var(--radius-full); padding: var(--space-xs) var(--space-xs); font-size: var(--font-size-xs); }
.knowledge-graph-provenance--extracted { color: var(--color-success); background: color-mix(in srgb, var(--color-success) 15%, transparent); }
.knowledge-graph-provenance--inferred { color: var(--accent); background: color-mix(in srgb, var(--accent) 15%, transparent); }
.knowledge-graph-endpoint { max-width: 100%; overflow-wrap: anywhere; text-align: left; }
.knowledge-graph-path-chain { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-xs); margin-top: var(--space-sm); }
.knowledge-graph-path-chain span { display: inline-flex; align-items: center; gap: var(--space-xs); }
.knowledge-graph-path-chain em { color: var(--text-muted); font-size: var(--font-size-xs); font-style: normal; }
.knowledge-graph-error { width: 100%; color: var(--color-error); }
@media (max-width: 768px) { .knowledge-graph-layout { grid-template-columns: minmax(0, 1fr); } .knowledge-graph-status, .knowledge-graph-filters, .knowledge-graph-neighbors, .knowledge-graph-path { align-items: stretch; } .knowledge-graph-status > div { margin-right: 0; } .knowledge-graph-status dl { display: grid; grid-template-columns: minmax(0, 1fr); } }

View File

@@ -0,0 +1,68 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useKnowledgeGraph } from "../hooks/useKnowledgeGraph.js";
import type { KnowledgeGraphEdge } from "../api/system/knowledge-graph.js";
import "./KnowledgeGraphPanel.css";
const NODE_KINDS = ["file", "module", "symbol", "doc-concept", "rationale"] as const;
/* FNXC:KnowledgeGraphDashboard 2026-08-13-23:21: Symbol filter values must exactly match the core SymbolKind union because the API rejects unknown enum values rather than silently widening a query. */
const SYMBOL_KINDS = ["function", "class", "interface", "type-alias", "enum", "variable", "namespace", "alias"] as const;
const EDGE_KINDS = ["contains", "imports", "re-exports", "relates-to", "rationale-supports"] as const;
/** FNXC:KnowledgeGraphDashboard 2026-08-13-23:30: Each result is a bounded graph slice; node-to-node buttons are the navigation model instead of a whole-graph canvas. */
function EndpointButton({ id, onSelect }: { id: string; onSelect: (id: string) => void }) {
return <button type="button" className="btn btn-ghost knowledge-graph-endpoint" onClick={() => onSelect(id)}>{id}</button>;
}
function EdgeList({ title, edges, total, endpoint, onSelect }: { title: string; edges: KnowledgeGraphEdge[]; total: number; endpoint: "from" | "to"; onSelect: (id: string) => void }) {
return <section className="knowledge-graph-edge-list"><h4>{title} <span>showing {edges.length} of {total}</span></h4>{edges.length === 0 ? <p className="knowledge-graph-muted">None</p> : <ul>{edges.map((edge) => <li key={edge.id}><span>{edge.kind}</span><span className={`knowledge-graph-provenance knowledge-graph-provenance--${edge.provenance}`}>{edge.provenance}</span><EndpointButton id={edge[endpoint]} onSelect={onSelect} /></li>)}</ul>}</section>;
}
export function KnowledgeGraphPanel({ projectId, addToast }: { projectId?: string; addToast: (message: string, type: "success" | "error" | "info") => void }) {
const { t } = useTranslation("app");
const graph = useKnowledgeGraph({ projectId, enabled: true });
const [force, setForce] = useState(false);
useEffect(() => { if (graph.error) addToast(graph.error, "error"); }, [addToast, graph.error]);
/* FNXC:KnowledgeGraphDashboard 2026-08-13-23:21: A failed status request is an error state, not an indefinite loading state, so operators can distinguish an unavailable graph artifact from a failed dashboard request. */
if (!graph.status && graph.error) return <div className="knowledge-graph-panel knowledge-graph-error" data-testid="knowledge-graph-error">{graph.error}</div>;
if (!graph.status) return <div className="knowledge-graph-panel" data-testid="knowledge-graph-loading">{t("memory.graphLoading", "Loading knowledge graph…")}</div>;
if (!graph.status.available) return <div className="knowledge-graph-panel knowledge-graph-empty" data-testid="knowledge-graph-no-graph"><p>{t("memory.graphUnavailable", "Graph unavailable")}: {graph.status.recoveryReason}</p><button type="button" className="btn" onClick={() => void graph.rebuild(false)}>{t("memory.graphBuild", "Build graph")}</button></div>;
const setFilter = (patch: Partial<typeof graph.filters>) => graph.setFilters({ ...graph.filters, ...patch, offset: 0 });
const updateKinds = (kind: string) => {
const kinds = graph.filters.kinds ?? [];
setFilter({ kinds: kinds.includes(kind) ? kinds.filter((item) => item !== kind) : [...kinds, kind] });
};
const updateEdgeKinds = (kind: string) => {
const edgeKinds = graph.neighborOptions.edgeKinds ?? [];
graph.setNeighborOptions({ ...graph.neighborOptions, edgeKinds: edgeKinds.includes(kind) ? edgeKinds.filter((item) => item !== kind) : [...edgeKinds, kind] });
};
const selected = graph.selectedId;
const limits = graph.status.pathLimits;
const foundPath = graph.pathResult?.outcome === "found" ? graph.pathResult.path : null;
const foundHops = graph.pathResult?.outcome === "found" ? graph.pathResult.hops : null;
return <section className="knowledge-graph-panel" data-testid="knowledge-graph-panel">
<header className="knowledge-graph-status card"><div><strong>{graph.status.nodeCount} {t("memory.graphNodes", "nodes")} · {graph.status.edgeCount} {t("memory.graphEdges", "edges")}</strong><span>{graph.status.graphDir}</span></div><dl><div><dt>Node kinds</dt><dd>{Object.entries(graph.status.nodeKindCounts ?? {}).map(([kind, count]) => `${kind}: ${count}`).join(" · ") || "—"}</dd></div><div><dt>Edge kinds</dt><dd>{Object.entries(graph.status.edgeKindCounts ?? {}).map(([kind, count]) => `${kind}: ${count}`).join(" · ") || "—"}</dd></div><div><dt>Provenance</dt><dd>{Object.entries(graph.status.provenanceCounts ?? {}).map(([kind, count]) => `${kind}: ${count}`).join(" · ") || "—"}</dd></div></dl><label className="knowledge-graph-toggle"><input type="checkbox" checked={force} onChange={(event) => setForce(event.target.checked)} /> {t("memory.graphForce", "Force rebuild")}</label><button type="button" className="btn" disabled={graph.rebuilding} onClick={() => void graph.rebuild(force)}>{graph.rebuilding ? t("memory.graphRebuilding", "Rebuilding…") : t("memory.graphRebuild", "Rebuild")}</button></header>
<section className="knowledge-graph-filters card" aria-label={t("memory.graphFilters", "Graph filters")}>
<div className="knowledge-graph-kind-options">{NODE_KINDS.map((kind) => <label key={kind}><input type="checkbox" checked={(graph.filters.kinds ?? []).includes(kind)} onChange={() => updateKinds(kind)} /> {kind}</label>)}</div>
<input className="input" aria-label="Path prefix" placeholder="Path prefix" value={graph.filters.pathPrefix ?? ""} onChange={(event) => setFilter({ pathPrefix: event.target.value })} />
<input className="input" aria-label="Node id prefix" placeholder="Node id prefix" value={graph.filters.idPrefix ?? ""} onChange={(event) => setFilter({ idPrefix: event.target.value })} />
<input className="input" aria-label="Name regex" placeholder="Name regex" value={graph.filters.namePattern ?? ""} onChange={(event) => setFilter({ namePattern: event.target.value })} />
<select className="select" aria-label="FNXC area" value={graph.filters.fnxcArea ?? ""} onChange={(event) => setFilter({ fnxcArea: event.target.value || undefined })}><option value="">All FNXC areas</option>{graph.status.fnxcAreas?.map((area) => <option key={area} value={area}>{area}</option>)}</select>
<select className="select" aria-label="Symbol kind" value={graph.filters.symbolKind ?? ""} onChange={(event) => setFilter({ symbolKind: event.target.value || undefined })}><option value="">All symbol kinds</option>{SYMBOL_KINDS.map((kind) => <option key={kind}>{kind}</option>)}</select>
<select className="select" aria-label="Owner" value={graph.filters.owner ?? ""} onChange={(event) => setFilter({ owner: event.target.value || undefined })}><option value="">All owners</option><option value="file">file</option><option value="derived">derived</option></select>
<select className="select" aria-label="Result limit" value={graph.filters.limit ?? 50} onChange={(event) => setFilter({ limit: Number(event.target.value) })}>{[25, 50, 100, 200].map((limit) => <option key={limit} value={limit}>{limit} results</option>)}</select>
<button type="button" className="btn btn-ghost" onClick={graph.clearFilters}>{t("memory.graphClearFilters", "Clear filters")}</button>
</section>
<div className="knowledge-graph-layout"><section className="card knowledge-graph-results"><h3>{graph.nodes?.total ?? 0} {t("memory.graphResults", "results")}</h3>{graph.loading ? <p data-testid="knowledge-graph-searching">Searching…</p> : graph.nodes?.nodes.length ? <><ul>{graph.nodes.nodes.map((node) => <li key={node.id}><button type="button" className="btn btn-ghost" onClick={() => void graph.selectNode(node.id)}><strong>{node.kind}: {node.name}</strong><span>{node.id} · {node.ownerPath}:{node.source.line}</span></button></li>)}</ul><div className="knowledge-graph-pagination"><button type="button" className="btn btn-ghost" disabled={!graph.filters.offset} onClick={() => setFilter({ offset: Math.max(0, (graph.filters.offset ?? 0) - (graph.filters.limit ?? 50)) })}>Previous</button><button type="button" className="btn btn-ghost" disabled={(graph.filters.offset ?? 0) + (graph.filters.limit ?? 50) >= (graph.nodes?.total ?? 0)} onClick={() => setFilter({ offset: (graph.filters.offset ?? 0) + (graph.filters.limit ?? 50) })}>Next</button></div></> : <p data-testid="knowledge-graph-no-results">{t("memory.graphNoResults", "No matching nodes.")}</p>}</section>
<section className="card knowledge-graph-detail">{graph.detail ? <><h3>{graph.detail.node.name}</h3><p>{graph.detail.node.id}</p><p>{graph.detail.node.ownerPath}:{graph.detail.node.source.line}</p><EdgeList title="Outgoing" edges={graph.detail.outgoing} total={graph.detail.outgoingTotal} endpoint="to" onSelect={(id) => void graph.selectNode(id)} /><EdgeList title="Incoming" edges={graph.detail.incoming} total={graph.detail.incomingTotal} endpoint="from" onSelect={(id) => void graph.selectNode(id)} /></> : <p className="knowledge-graph-muted">Select a node to inspect its edges.</p>}</section></div>
{graph.detail && <section className="knowledge-graph-neighbors card"><h3>Neighbors</h3><select className="select" aria-label="Neighbor direction" value={graph.neighborOptions.direction} onChange={(event) => graph.setNeighborOptions({ ...graph.neighborOptions, direction: event.target.value as "out" | "in" | "both" })}><option value="out">Outgoing</option><option value="in">Incoming</option><option value="both">Both directions</option></select><select className="select" aria-label="Neighbor depth" value={graph.neighborOptions.depth} onChange={(event) => graph.setNeighborOptions({ ...graph.neighborOptions, depth: Number(event.target.value) })}>{[1, 2, 3].map((depth) => <option key={depth} value={depth}>Depth {depth}</option>)}</select><div className="knowledge-graph-kind-options">{EDGE_KINDS.map((kind) => <label key={kind}><input type="checkbox" checked={(graph.neighborOptions.edgeKinds ?? []).includes(kind)} onChange={() => updateEdgeKinds(kind)} /> {kind}</label>)}</div><button type="button" className="btn btn-ghost" onClick={() => void graph.refreshNeighbors()}>Refresh neighbors</button>{graph.neighborResults?.neighbors.length ? <ul>{graph.neighborResults.neighbors.map((neighbor) => <li key={neighbor.node.id}><span>distance {neighbor.distance}</span><EndpointButton id={neighbor.node.id} onSelect={(id) => void graph.selectNode(id)} /></li>)}</ul> : <p className="knowledge-graph-muted">No neighbors found.</p>}</section>}
<section className="knowledge-graph-path card"><h3>Shortest path</h3><input className="input" aria-label="From node id" placeholder="From node id" value={graph.pathInput.fromId} onChange={(event) => graph.setPathInput({ ...graph.pathInput, fromId: event.target.value })} /><input className="input" aria-label="To node id" placeholder="To node id" value={graph.pathInput.toId} onChange={(event) => graph.setPathInput({ ...graph.pathInput, toId: event.target.value })} /><input className="input" aria-label="Maximum hops" type="number" min="1" max={limits.maxHops} value={graph.pathInput.maxHops} onChange={(event) => graph.setPathInput({ ...graph.pathInput, maxHops: Number(event.target.value) })} />{selected && <><button type="button" className="btn btn-ghost" onClick={() => graph.setPathInput({ ...graph.pathInput, fromId: selected })}>Use selected as from</button><button type="button" className="btn btn-ghost" onClick={() => graph.setPathInput({ ...graph.pathInput, toId: selected })}>Use selected as to</button></>}<button type="button" className="btn" onClick={() => void graph.findPath()}>Find path</button>{foundPath && <div data-testid="knowledge-graph-path-found"><strong>{foundHops} hops</strong><div className="knowledge-graph-path-chain">{foundPath.nodes.map((node, index) => <span key={node.id}><EndpointButton id={node.id} onSelect={(id) => void graph.selectNode(id)} />{index < foundPath.edges.length && <em>{foundPath.edges[index].kind}</em>}</span>)}</div></div>}{graph.pathResult?.outcome === "not-found" && <p data-testid="knowledge-graph-path-not-found">No path exists between these nodes.</p>}{graph.pathResult?.outcome === "limit-reached" && <div data-testid="knowledge-graph-path-limit-reached"><p>No path found within {graph.pathResult.maxHops} hops; {graph.pathResult.limit} was reached.</p>{graph.pathInput.maxHops < limits.maxHops && <button type="button" className="btn btn-ghost" onClick={() => graph.setPathInput({ ...graph.pathInput, maxHops: Math.min(limits.maxHops, graph.pathInput.maxHops + 1) })}>Raise hop limit</button>}</div>}{graph.error && <p data-testid="knowledge-graph-path-error" className="knowledge-graph-error">{graph.error}</p>}</section>
</section>;
}

View File

@@ -7,6 +7,7 @@ import type { MemoryFileInfo, MemoryRetrievalTestResult } from "../api";
import { FileEditor } from "./FileEditor"; import { FileEditor } from "./FileEditor";
import { ViewHeader } from "./ViewHeader"; import { ViewHeader } from "./ViewHeader";
import { useMemoryData } from "../hooks/useMemoryData"; import { useMemoryData } from "../hooks/useMemoryData";
import { KnowledgeGraphPanel } from "./KnowledgeGraphPanel";
interface MemoryViewProps { interface MemoryViewProps {
projectId?: string; projectId?: string;
@@ -14,7 +15,7 @@ interface MemoryViewProps {
onSendSelectionToTask?: (description: string) => void; onSendSelectionToTask?: (description: string) => void;
} }
type Tab = "working" | "insights" | "engines"; type Tab = "working" | "insights" | "engines" | "graph";
/** Known category headers in the insights file */ /** Known category headers in the insights file */
const CATEGORY_HEADERS: Record<string, string> = { const CATEGORY_HEADERS: Record<string, string> = {
@@ -413,6 +414,16 @@ export function MemoryView({ projectId, addToast, onSendSelectionToTask }: Memor
> >
{t("memory.tabInsights", "Insights")} {t("memory.tabInsights", "Insights")}
</button> </button>
<button
type="button"
role="tab"
aria-selected={activeTab === "graph"}
className={`memory-view-tab${activeTab === "graph" ? " memory-view-tab--active" : ""}`}
onClick={() => setActiveTab("graph")}
data-testid="memory-tab-graph"
>
{t("memory.tabGraph", "Knowledge Graph")}
</button>
<button <button
type="button" type="button"
role="tab" role="tab"
@@ -427,6 +438,8 @@ export function MemoryView({ projectId, addToast, onSendSelectionToTask }: Memor
{/* Content area */} {/* Content area */}
<div className="memory-view-content"> <div className="memory-view-content">
{activeTab === "graph" && <KnowledgeGraphPanel projectId={projectId} addToast={addToast} />}
{/* Working Memory Tab */} {/* Working Memory Tab */}
{activeTab === "working" && ( {activeTab === "working" && (
<div className="memory-working-tab"> <div className="memory-working-tab">

View File

@@ -0,0 +1,49 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { KnowledgeGraphPanel } from "../KnowledgeGraphPanel";
const useKnowledgeGraph = vi.fn();
vi.mock("../../hooks/useKnowledgeGraph.js", () => ({ useKnowledgeGraph: (...args: unknown[]) => useKnowledgeGraph(...args) }));
const selectNode = vi.fn();
const setFilters = vi.fn();
const setNeighborOptions = vi.fn();
const setPathInput = vi.fn();
const node = { id: "symbol:src/a.ts#run", kind: "symbol" as const, name: "run", owner: "file" as const, ownerPath: "src/a.ts", source: { path: "src/a.ts", line: 4, column: 1 }, attributes: {} };
const edge = { id: "e-1", kind: "contains" as const, from: "file:src/a.ts", to: node.id, provenance: "extracted" as const, owner: "file" as const, ownerPath: "src/a.ts", source: { path: "src/a.ts", line: 1, column: 1 }, attributes: {} };
function graph(overrides: Record<string, unknown> = {}) { return { status: { available: true, nodeCount: 2, edgeCount: 1, graphDir: ".fusion-knowledge/graph", nodeKindCounts: { symbol: 1 }, edgeKindCounts: { contains: 1 }, provenanceCounts: { extracted: 1, inferred: 0 }, fnxcAreas: ["Memory"], pathLimits: { defaultMaxHops: 6, maxHops: 10, maxExpansions: 20000 } }, filters: { limit: 50, offset: 0 }, setFilters, nodes: { nodes: [node], total: 1, limit: 50, offset: 0 }, loading: false, selectedId: node.id, detail: { node, outgoing: [edge], incoming: [edge], outgoingTotal: 1, incomingTotal: 1 }, selectNode, neighborOptions: { direction: "both", depth: 1, edgeKinds: [] }, setNeighborOptions, neighborResults: { neighbors: [{ node, distance: 1, edges: [edge] }], total: 1 }, refreshNeighbors: vi.fn(), pathInput: { fromId: "file:src/a.ts", toId: node.id, maxHops: 2 }, setPathInput, pathResult: null, findPath: vi.fn(), rebuild: vi.fn(), rebuilding: false, refreshStatus: vi.fn(), error: null, clearFilters: vi.fn(), ...overrides }; }
describe("KnowledgeGraphPanel", () => {
it("renders status, complete filters, detail provenance, and drills through endpoints", async () => {
useKnowledgeGraph.mockReturnValue(graph());
render(<KnowledgeGraphPanel addToast={vi.fn()} />);
expect(screen.getByText("2 nodes · 1 edges")).toBeInTheDocument();
const symbolKind = screen.getByLabelText("Symbol kind");
expect(symbolKind).toBeInTheDocument();
expect(Array.from((symbolKind as HTMLSelectElement).options).map((option) => option.value)).toEqual([
"", "function", "class", "interface", "type-alias", "enum", "variable", "namespace", "alias",
]);
expect(screen.getByLabelText("Owner")).toBeInTheDocument();
expect(screen.getByLabelText("Result limit")).toBeInTheDocument();
expect(screen.getAllByText("extracted").length).toBeGreaterThan(0);
await userEvent.click(screen.getAllByRole("button", { name: node.id })[0]);
expect(selectNode).toHaveBeenCalledWith(node.id);
});
it("distinguishes unavailable graph, empty search, and all path outcomes", () => {
useKnowledgeGraph.mockReturnValue(graph({ status: { ...graph().status, available: false, recoveryReason: "missing-artifact" } }));
const { rerender } = render(<KnowledgeGraphPanel addToast={vi.fn()} />);
expect(screen.getByTestId("knowledge-graph-no-graph")).toHaveTextContent("missing-artifact");
useKnowledgeGraph.mockReturnValue(graph({ status: null, error: "Request failed" }));
rerender(<KnowledgeGraphPanel addToast={vi.fn()} />);
expect(screen.getByTestId("knowledge-graph-error")).toHaveTextContent("Request failed");
useKnowledgeGraph.mockReturnValue(graph({ nodes: { nodes: [], total: 0, limit: 50, offset: 0 } }));
rerender(<KnowledgeGraphPanel addToast={vi.fn()} />);
expect(screen.getByTestId("knowledge-graph-no-results")).toBeInTheDocument();
for (const [result, id] of [[{ outcome: "found", path: { nodes: [node], edges: [] }, hops: 0, maxHops: 2, expansions: 1, truncated: false }, "knowledge-graph-path-found"], [{ outcome: "not-found", path: null, maxHops: 2, expansions: 2, truncated: false }, "knowledge-graph-path-not-found"], [{ outcome: "limit-reached", path: null, maxHops: 2, expansions: 2, truncated: true, limit: "max-hops" }, "knowledge-graph-path-limit-reached"]] as const) {
useKnowledgeGraph.mockReturnValue(graph({ pathResult: result })); rerender(<KnowledgeGraphPanel addToast={vi.fn()} />); expect(screen.getByTestId(id)).toBeInTheDocument();
}
useKnowledgeGraph.mockReturnValue(graph({ error: "Unknown graph node" })); rerender(<KnowledgeGraphPanel addToast={vi.fn()} />); expect(screen.getByTestId("knowledge-graph-path-error")).toHaveTextContent("Unknown graph node");
});
});

View File

@@ -14,6 +14,10 @@ vi.mock("../../hooks/useMemoryData", () => ({
useMemoryData: (...args: unknown[]) => mockUseMemoryData(...args), useMemoryData: (...args: unknown[]) => mockUseMemoryData(...args),
})); }));
vi.mock("../KnowledgeGraphPanel", () => ({
KnowledgeGraphPanel: () => <div data-testid="knowledge-graph-panel" />,
}));
vi.mock("../FileEditor", () => ({ vi.mock("../FileEditor", () => ({
FileEditor: (props: { filePath: string; onSendSelectionToTask?: (description: string) => void }) => { FileEditor: (props: { filePath: string; onSendSelectionToTask?: (description: string) => void }) => {
capturedFileEditorProps.push(props); capturedFileEditorProps.push(props);
@@ -101,6 +105,15 @@ function createMemoryData(overrides: Record<string, unknown> = {}) {
} }
describe("MemoryView", () => { describe("MemoryView", () => {
it("adds the lazy Knowledge Graph tab without mounting it initially", async () => {
render(<MemoryView addToast={vi.fn()} />);
expect(screen.getByTestId("memory-tab-graph")).toBeInTheDocument();
expect(screen.queryByTestId("knowledge-graph-panel")).not.toBeInTheDocument();
await userEvent.click(screen.getByTestId("memory-tab-graph"));
expect(screen.getByTestId("knowledge-graph-panel")).toBeInTheDocument();
await userEvent.click(screen.getByTestId("memory-tab-working"));
expect(screen.queryByTestId("knowledge-graph-panel")).not.toBeInTheDocument();
});
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
capturedFileEditorProps.length = 0; capturedFileEditorProps.length = 0;

View File

@@ -0,0 +1,66 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useKnowledgeGraph } from "../useKnowledgeGraph";
const api = vi.hoisted(() => ({ status: vi.fn(), nodes: vi.fn(), node: vi.fn(), neighbors: vi.fn(), path: vi.fn(), build: vi.fn() }));
vi.mock("../../api/system/knowledge-graph.js", () => ({ fetchKnowledgeGraphStatus: api.status, queryKnowledgeGraphNodes: api.nodes, fetchKnowledgeGraphNode: api.node, fetchKnowledgeGraphNeighbors: api.neighbors, fetchKnowledgeGraphPath: api.path, buildKnowledgeGraphArtifacts: api.build }));
const status = { available: true, pathLimits: { defaultMaxHops: 6, maxHops: 10, maxExpansions: 20_000 } };
const emptyNodes = { nodes: [], total: 0, limit: 50, offset: 0 };
function defaults() { api.status.mockResolvedValue(status); api.nodes.mockResolvedValue(emptyNodes); api.node.mockResolvedValue({ node: { id: "a" }, outgoing: [], incoming: [], outgoingTotal: 0, incomingTotal: 0 }); api.neighbors.mockResolvedValue({ neighbors: [], total: 0 }); }
beforeEach(() => { vi.clearAllMocks(); defaults(); });
afterEach(() => { vi.useRealTimers(); });
describe("useKnowledgeGraph", () => {
it("loads status lazily and debounces one search for a filter update", async () => {
renderHook(() => useKnowledgeGraph({ projectId: "project", enabled: false }));
expect(api.status).not.toHaveBeenCalled();
// Mounting an enabled instance makes the lazy boundary explicit.
const enabled = renderHook(() => useKnowledgeGraph({ projectId: "project", enabled: true }));
await waitFor(() => { expect(api.status).toHaveBeenCalledWith("project"); expect(api.nodes).toHaveBeenCalledTimes(1); });
act(() => { enabled.result.current.setFilters({ pathPrefix: "src", limit: 50, offset: 0 }); });
await waitFor(() => expect(api.nodes).toHaveBeenLastCalledWith("project", expect.objectContaining({ pathPrefix: "src" })));
});
it("fences stale searches and loads node details with neighbors", async () => {
let resolveFirst: ((value: typeof emptyNodes) => void) | undefined;
api.nodes.mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve; })).mockResolvedValueOnce({ ...emptyNodes, total: 2 });
const { result } = renderHook(() => useKnowledgeGraph({ enabled: true }));
await waitFor(() => expect(resolveFirst).toBeTypeOf("function"));
act(() => result.current.setFilters({ pathPrefix: "new", limit: 50, offset: 0 }));
await waitFor(() => expect(result.current.nodes?.total).toBe(2));
await act(async () => { resolveFirst?.(emptyNodes); });
expect(result.current.nodes?.total).toBe(2);
await act(async () => { await result.current.selectNode("a"); });
expect(api.node).toHaveBeenCalledWith(undefined, "a"); expect(api.neighbors).toHaveBeenCalledWith(undefined, expect.objectContaining({ nodeId: "a" }));
expect(result.current.detail?.node.id).toBe("a");
});
it("preserves every path outcome, rejects unknown ids distinctly, and reapplies raised hop bounds", async () => {
api.path.mockResolvedValueOnce({ outcome: "found", path: { nodes: [], edges: [] }, hops: 0, maxHops: 6, expansions: 0, truncated: false })
.mockResolvedValueOnce({ outcome: "not-found", path: null, maxHops: 6, expansions: 1, truncated: false })
.mockResolvedValueOnce({ outcome: "limit-reached", path: null, maxHops: 8, expansions: 1, truncated: true, limit: "max-hops" })
.mockRejectedValueOnce(new Error("Unknown graph node"));
const { result } = renderHook(() => useKnowledgeGraph({ enabled: true }));
await waitFor(() => expect(result.current.status).toEqual(status));
for (const outcome of ["found", "not-found", "limit-reached"]) {
await act(async () => { await result.current.findPath(); });
expect(result.current.pathResult?.outcome).toBe(outcome);
}
act(() => result.current.setPathInput({ fromId: "a", toId: "b", maxHops: 8 }));
await act(async () => { await result.current.findPath(); });
expect(result.current.pathResult).toBeNull(); expect(result.current.error).toBe("Unknown graph node");
expect(api.path).toHaveBeenLastCalledWith(undefined, { fromId: "a", toId: "b", maxHops: 8 });
});
it("rebuilds once, refreshes status, and returns rejected calls as state", async () => {
api.build.mockResolvedValue({ changed: true });
const { result } = renderHook(() => useKnowledgeGraph({ projectId: "project", enabled: true }));
await waitFor(() => expect(result.current.status).toEqual(status));
await act(async () => { await result.current.rebuild(true); });
expect(api.build).toHaveBeenCalledWith("project", true); expect(api.status).toHaveBeenCalledTimes(2); expect(result.current.rebuilding).toBe(false);
api.path.mockRejectedValueOnce(new Error("network failure"));
await act(async () => { await result.current.findPath(); });
expect(result.current.error).toBe("network failure");
});
});

View File

@@ -0,0 +1,94 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
buildKnowledgeGraphArtifacts,
fetchKnowledgeGraphNeighbors,
fetchKnowledgeGraphNode,
fetchKnowledgeGraphPath,
fetchKnowledgeGraphStatus,
queryKnowledgeGraphNodes,
type KnowledgeGraphNodeQuery,
type KnowledgeGraphPathResult,
} from "../api/system/knowledge-graph.js";
const EMPTY_FILTERS: KnowledgeGraphNodeQuery = { limit: 50, offset: 0 };
type NeighborOptions = { direction: "out" | "in" | "both"; edgeKinds?: string[]; depth: number };
/*
FNXC:KnowledgeGraphDashboard 2026-08-13-23:30:
Graph state is isolated and enabled only by the active tab, so Memory's existing tabs never parse or request the multi-megabyte graph artifacts. Request sequence fences retain the latest operator intent when debounced searches, node drills, or path requests resolve out of order.
*/
export function useKnowledgeGraph({ projectId, enabled }: { projectId?: string; enabled: boolean }) {
const [status, setStatus] = useState<Awaited<ReturnType<typeof fetchKnowledgeGraphStatus>> | null>(null);
const [filters, setFilters] = useState<KnowledgeGraphNodeQuery>(EMPTY_FILTERS);
const [nodes, setNodes] = useState<Awaited<ReturnType<typeof queryKnowledgeGraphNodes>> | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [detail, setDetail] = useState<Awaited<ReturnType<typeof fetchKnowledgeGraphNode>> | null>(null);
const [neighborOptions, setNeighborOptions] = useState<NeighborOptions>({ direction: "both", depth: 1 });
const [neighborResults, setNeighborResults] = useState<Awaited<ReturnType<typeof fetchKnowledgeGraphNeighbors>> | null>(null);
const [pathInput, setPathInput] = useState({ fromId: "", toId: "", maxHops: 6 });
const [pathResult, setPathResult] = useState<KnowledgeGraphPathResult | null>(null);
const [loading, setLoading] = useState(false);
const [rebuilding, setRebuilding] = useState(false);
const [error, setError] = useState<string | null>(null);
const searchSequence = useRef(0);
const detailSequence = useRef(0);
const pathSequence = useRef(0);
const refreshStatus = useCallback(async () => {
try {
const value = await fetchKnowledgeGraphStatus(projectId);
setStatus(value);
setPathInput((input) => ({ ...input, maxHops: value.pathLimits.defaultMaxHops }));
} catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)); }
}, [projectId]);
useEffect(() => { if (enabled) void refreshStatus(); }, [enabled, refreshStatus]);
useEffect(() => {
if (!enabled || !status?.available) return;
const current = ++searchSequence.current;
setLoading(true);
const timer = window.setTimeout(() => void queryKnowledgeGraphNodes(projectId, filters)
.then((value) => { if (current === searchSequence.current) setNodes(value); })
.catch((reason) => { if (current === searchSequence.current) setError(reason instanceof Error ? reason.message : String(reason)); })
.finally(() => { if (current === searchSequence.current) setLoading(false); }), 250);
return () => window.clearTimeout(timer);
}, [enabled, filters, projectId, status?.available]);
const refreshNeighbors = useCallback(async (id = selectedId) => {
if (!id) return;
const current = ++detailSequence.current;
try {
const value = await fetchKnowledgeGraphNeighbors(projectId, { nodeId: id, ...neighborOptions });
if (current === detailSequence.current) setNeighborResults(value);
} catch (reason) { if (current === detailSequence.current) setError(reason instanceof Error ? reason.message : String(reason)); }
}, [neighborOptions, projectId, selectedId]);
const selectNode = useCallback(async (id: string) => {
const current = ++detailSequence.current;
setSelectedId(id);
try {
const [node, neighborValue] = await Promise.all([
fetchKnowledgeGraphNode(projectId, id),
fetchKnowledgeGraphNeighbors(projectId, { nodeId: id, ...neighborOptions }),
]);
if (current === detailSequence.current) { setDetail(node); setNeighborResults(neighborValue); }
} catch (reason) { if (current === detailSequence.current) setError(reason instanceof Error ? reason.message : String(reason)); }
}, [neighborOptions, projectId]);
const findPath = useCallback(async () => {
const current = ++pathSequence.current;
setPathResult(null); setError(null);
try {
const result = await fetchKnowledgeGraphPath(projectId, pathInput);
if (current === pathSequence.current) setPathResult(result);
} catch (reason) { if (current === pathSequence.current) setError(reason instanceof Error ? reason.message : String(reason)); }
}, [pathInput, projectId]);
const rebuild = useCallback(async (force = false) => {
setRebuilding(true);
try { await buildKnowledgeGraphArtifacts(projectId, force); await refreshStatus(); }
catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)); }
finally { setRebuilding(false); }
}, [projectId, refreshStatus]);
return { status, filters, setFilters, nodes, loading, selectedId, detail, selectNode, neighborOptions, setNeighborOptions, neighborResults, refreshNeighbors, pathInput, setPathInput, pathResult, findPath, rebuild, rebuilding, refreshStatus, error, clearFilters: () => setFilters(EMPTY_FILTERS) };
}

View File

@@ -0,0 +1,88 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const core = vi.hoisted(() => ({ loadArtifacts: vi.fn(), buildKnowledgeGraph: vi.fn() }));
vi.mock("@fusion/core", async (importOriginal) => ({
...await importOriginal<typeof import("@fusion/core")>(),
loadArtifacts: core.loadArtifacts,
buildKnowledgeGraph: core.buildKnowledgeGraph,
}));
import { KnowledgeGraphError, shortestPath, type KnowledgeGraph } from "@fusion/core";
import {
__resetKnowledgeGraphCacheForTests,
findBoundedShortestPath,
loadProjectKnowledgeGraph,
rebuildProjectKnowledgeGraph,
} from "../knowledge-graph-access.js";
const node = (id: string) => ({ id, kind: "file" as const, name: id, owner: "file" as const, ownerPath: id, source: { path: id, line: 1, column: 1 }, attributes: {} });
const edge = (id: string, from: string, to: string) => ({ id, kind: "contains" as const, from, to, provenance: "extracted" as const, owner: "file" as const, ownerPath: from, source: { path: from, line: 1, column: 1 }, attributes: {} });
function graph(ids: string[], links: Array<[string, string]>): KnowledgeGraph { return { schemaVersion: 1, nodes: ids.map(node), edges: links.map(([from, to], index) => edge(`e-${index}`, from, to)) }; }
let graphDir = "";
beforeEach(async () => {
__resetKnowledgeGraphCacheForTests();
vi.clearAllMocks();
graphDir = await mkdtemp(join(tmpdir(), "fusion-kg-access-"));
await writeFile(join(graphDir, "manifest.json"), "first");
});
afterEach(async () => { await rm(graphDir, { recursive: true, force: true }); });
describe("knowledge graph artifact access", () => {
it("reuses unchanged artifacts, reloads a changed manifest, and returns missing artifacts", async () => {
const first = { ok: true as const, graph: graph(["a"], []), manifest: {} as never };
const second = { ok: true as const, graph: graph(["b"], []), manifest: {} as never };
core.loadArtifacts.mockResolvedValueOnce(first).mockResolvedValueOnce(second).mockResolvedValue({ ok: false, recoveryReason: "missing-artifact" });
expect(await loadProjectKnowledgeGraph(graphDir)).toBe(first);
expect(await loadProjectKnowledgeGraph(graphDir)).toBe(first);
expect(core.loadArtifacts).toHaveBeenCalledTimes(1);
await writeFile(join(graphDir, "manifest.json"), "manifest with a different size");
expect(await loadProjectKnowledgeGraph(graphDir)).toBe(second);
expect(core.loadArtifacts).toHaveBeenCalledTimes(2);
const missingDir = join(graphDir, "missing");
await expect(loadProjectKnowledgeGraph(missingDir)).resolves.toEqual({ ok: false, recoveryReason: "missing-artifact" });
});
it("deduplicates concurrent rebuilds and invalidates the cached entry", async () => {
const cached = { ok: true as const, graph: graph(["cached"], []), manifest: {} as never };
const reloaded = { ok: true as const, graph: graph(["reloaded"], []), manifest: {} as never };
core.loadArtifacts.mockResolvedValueOnce(cached).mockResolvedValueOnce(reloaded);
await loadProjectKnowledgeGraph(graphDir);
let finishBuild: ((value: any) => void) | undefined;
core.buildKnowledgeGraph.mockImplementation(() => new Promise((resolve) => { finishBuild = resolve; }));
const store = { getRootDir: () => graphDir, getSettings: async () => ({ knowledgeGraphDir: "." }) };
const first = rebuildProjectKnowledgeGraph(store as never, { force: true });
const second = rebuildProjectKnowledgeGraph(store as never, { force: true });
await vi.waitFor(() => expect(core.buildKnowledgeGraph).toHaveBeenCalledTimes(1));
finishBuild?.({ changed: true, graph: graph(["built"], []), stats: { parsedFiles: 1 } });
await expect(Promise.all([first, second])).resolves.toEqual([
expect.objectContaining({ changed: true, nodes: 1, edges: 0 }),
expect.objectContaining({ changed: true, nodes: 1, edges: 0 }),
]);
await loadProjectKnowledgeGraph(graphDir);
expect(core.loadArtifacts).toHaveBeenCalledTimes(2);
});
});
describe("findBoundedShortestPath", () => {
it("keeps exhaustive negatives distinct from bounded searches and matches core hop count", () => {
const connected = graph(["a", "b", "c"], [["a", "b"], ["b", "c"]]);
const found = findBoundedShortestPath(connected, "a", "c", { maxHops: 2 });
expect(found).toMatchObject({ outcome: "found", hops: 2, truncated: false });
expect(found.outcome === "found" && found.path.edges.length).toBe(shortestPath(connected, "a", "c")?.edges.length);
expect(findBoundedShortestPath(connected, "a", "c", { maxHops: 1 })).toMatchObject({ outcome: "limit-reached", limit: "max-hops", truncated: true });
expect(findBoundedShortestPath(graph(["a", "b"], []), "a", "b")).toMatchObject({ outcome: "not-found", truncated: false });
});
it("returns zero-hop self paths, fences expansion, and rejects unknown ids", () => {
const fan = graph(["a", "b", "c"], [["a", "b"], ["b", "c"]]);
expect(findBoundedShortestPath(fan, "a", "a")).toMatchObject({ outcome: "found", hops: 0, path: { edges: [] } });
expect(findBoundedShortestPath(fan, "a", "c", { maxExpansions: 1 })).toMatchObject({ outcome: "limit-reached", limit: "max-expansions" });
expect(() => findBoundedShortestPath(fan, "missing", "a")).toThrow(KnowledgeGraphError);
});
});

View File

@@ -0,0 +1,160 @@
import { stat } from "node:fs/promises";
import {
buildKnowledgeGraph,
KnowledgeGraphError,
loadArtifacts,
resolveKnowledgeGraphDir,
type ArtifactLoadResult,
type GraphEdge,
type GraphNode,
type KnowledgeGraph,
type TaskStore,
} from "@fusion/core";
export const KNOWLEDGE_GRAPH_PATH_MAX_HOPS = 10;
export const KNOWLEDGE_GRAPH_PATH_DEFAULT_HOPS = 6;
export const KNOWLEDGE_GRAPH_PATH_MAX_EXPANSIONS = 20_000;
const CACHE_SIZE = 4;
type CacheEntry = {
result: ArtifactLoadResult;
manifestMtimeMs: number | undefined;
manifestSize: number | undefined;
};
const cache = new Map<string, CacheEntry>();
const rebuilds = new Map<string, Promise<RebuildKnowledgeGraphResult>>();
export type BoundedPathResult =
| { outcome: "found"; path: { nodes: GraphNode[]; edges: GraphEdge[] }; hops: number; maxHops: number; expansions: number; truncated: false }
| { outcome: "not-found"; path: null; maxHops: number; expansions: number; truncated: false }
| { outcome: "limit-reached"; path: null; maxHops: number; expansions: number; truncated: true; limit: "max-hops" | "max-expansions" };
export type RebuildKnowledgeGraphResult = {
changed: boolean;
nodes: number;
edges: number;
stats: Awaited<ReturnType<typeof buildKnowledgeGraph>>["stats"];
};
async function manifestSignature(graphDir: string): Promise<{ manifestMtimeMs: number | undefined; manifestSize: number | undefined }> {
try {
const manifest = await stat(`${graphDir}/manifest.json`);
return { manifestMtimeMs: manifest.mtimeMs, manifestSize: manifest.size };
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { manifestMtimeMs: undefined, manifestSize: undefined };
throw error;
}
}
/** Resolve only inside the project root, honoring a project's graph-directory setting. */
export async function resolveProjectGraphDir(store: TaskStore): Promise<string> {
const settings = await store.getSettings();
return resolveKnowledgeGraphDir(store.getRootDir(), settings.knowledgeGraphDir);
}
/*
FNXC:KnowledgeGraphDashboard 2026-08-13-22:45:
Knowledge-graph artifacts can be multi-megabyte (including this repository's large edge set), so
read endpoints reuse a manifest-validated LRU cache and never rebuild on a read. Rebuilding remains
an explicit operator operation because it walks and parses the project.
*/
export async function loadProjectKnowledgeGraph(graphDir: string): Promise<ArtifactLoadResult> {
const signature = await manifestSignature(graphDir);
const existing = cache.get(graphDir);
if (existing && existing.manifestMtimeMs === signature.manifestMtimeMs && existing.manifestSize === signature.manifestSize) {
cache.delete(graphDir);
cache.set(graphDir, existing);
return existing.result;
}
const result = await loadArtifacts(graphDir);
cache.set(graphDir, { result, ...signature });
while (cache.size > CACHE_SIZE) cache.delete(cache.keys().next().value!);
return result;
}
export function invalidateKnowledgeGraphCache(graphDir?: string): void {
if (graphDir) cache.delete(graphDir);
else cache.clear();
}
export function __resetKnowledgeGraphCacheForTests(): void {
cache.clear();
rebuilds.clear();
}
export async function rebuildProjectKnowledgeGraph(store: TaskStore, options: { force?: boolean } = {}): Promise<RebuildKnowledgeGraphResult> {
const graphDir = await resolveProjectGraphDir(store);
const active = rebuilds.get(graphDir);
if (active) return active;
const rebuild = (async () => {
const built = await buildKnowledgeGraph({ projectRoot: store.getRootDir(), graphDir, force: options.force });
invalidateKnowledgeGraphCache(graphDir);
return { changed: built.changed, nodes: built.graph.nodes.length, edges: built.graph.edges.length, stats: built.stats };
})();
rebuilds.set(graphDir, rebuild);
try { return await rebuild; } finally { rebuilds.delete(graphDir); }
}
function clampInteger(value: number | undefined, fallback: number, minimum: number, maximum: number): number {
return typeof value === "number" && Number.isFinite(value) ? Math.min(maximum, Math.max(minimum, Math.trunc(value))) : fallback;
}
/*
FNXC:KnowledgeGraphDashboard 2026-08-13-22:45:
The dashboard cannot use core shortestPath directly: unbounded BFS over a large edge set can walk a
whole disconnected component and make the response scale with graph diameter. This bounded search
uses back-pointers and reports limit-reached separately from not-found, so an incomplete search is
never presented as proof that no path exists.
*/
export function findBoundedShortestPath(
graph: KnowledgeGraph,
fromId: string,
toId: string,
options: { maxHops?: number; maxExpansions?: number } = {},
): BoundedPathResult {
const maxHops = clampInteger(options.maxHops, KNOWLEDGE_GRAPH_PATH_DEFAULT_HOPS, 1, KNOWLEDGE_GRAPH_PATH_MAX_HOPS);
const maxExpansions = clampInteger(options.maxExpansions, KNOWLEDGE_GRAPH_PATH_MAX_EXPANSIONS, 1, KNOWLEDGE_GRAPH_PATH_MAX_EXPANSIONS);
const nodes = new Map(graph.nodes.map(node => [node.id, node]));
if (!nodes.has(fromId) || !nodes.has(toId)) throw new KnowledgeGraphError("Unknown graph node");
if (fromId === toId) return { outcome: "found", path: { nodes: [nodes.get(fromId)!], edges: [] }, hops: 0, maxHops, expansions: 0, truncated: false };
const adjacency = new Map<string, GraphEdge[]>();
for (const edge of [...graph.edges].sort((left, right) => left.id.localeCompare(right.id))) {
adjacency.set(edge.from, [...(adjacency.get(edge.from) ?? []), edge]);
adjacency.set(edge.to, [...(adjacency.get(edge.to) ?? []), edge]);
}
const queue: Array<{ id: string; depth: number }> = [{ id: fromId, depth: 0 }];
const previous = new Map<string, { id: string; edge: GraphEdge }>();
const visited = new Set([fromId]);
let expansions = 0;
let cursor = 0;
while (cursor < queue.length) {
if (expansions >= maxExpansions) return { outcome: "limit-reached", path: null, maxHops, expansions, truncated: true, limit: "max-expansions" };
const current = queue[cursor++]!;
expansions++;
const candidates = adjacency.get(current.id) ?? [];
let deferredByHops = false;
for (const edge of candidates) {
const next = edge.from === current.id ? edge.to : edge.from;
if (visited.has(next) || !nodes.has(next)) continue;
if (current.depth >= maxHops) { deferredByHops = true; continue; }
previous.set(next, { id: current.id, edge });
if (next === toId) {
const pathNodes: GraphNode[] = [nodes.get(toId)!];
const pathEdges: GraphEdge[] = [];
let id = toId;
while (id !== fromId) {
const step = previous.get(id)!;
pathEdges.unshift(step.edge);
id = step.id;
pathNodes.unshift(nodes.get(id)!);
}
return { outcome: "found", path: { nodes: pathNodes, edges: pathEdges }, hops: pathEdges.length, maxHops, expansions, truncated: false };
}
visited.add(next);
queue.push({ id: next, depth: current.depth + 1 });
}
if (deferredByHops) return { outcome: "limit-reached", path: null, maxHops, expansions, truncated: true, limit: "max-hops" };
}
return { outcome: "not-found", path: null, maxHops, expansions, truncated: false };
}

View File

@@ -0,0 +1,75 @@
// @vitest-environment node
import express from "express";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { request as performRequest } from "../../test-request.js";
import { KnowledgeGraphError } from "@fusion/core";
const access = vi.hoisted(() => ({ load: vi.fn(), resolve: vi.fn(), rebuild: vi.fn(), path: vi.fn() }));
vi.mock("../../knowledge-graph-access.js", () => ({
loadProjectKnowledgeGraph: access.load, resolveProjectGraphDir: access.resolve, rebuildProjectKnowledgeGraph: access.rebuild, findBoundedShortestPath: access.path,
KNOWLEDGE_GRAPH_PATH_DEFAULT_HOPS: 6, KNOWLEDGE_GRAPH_PATH_MAX_HOPS: 10, KNOWLEDGE_GRAPH_PATH_MAX_EXPANSIONS: 20_000,
}));
import { registerKnowledgeRoutes } from "../register-knowledge-routes.js";
const source = { path: "src/a.ts", line: 1, column: 1 };
const nodes = [
{ id: "file:src/a.ts", kind: "file", name: "a.ts", owner: "file", ownerPath: "src/a.ts", source, attributes: {} },
{ id: "symbol:src/a.ts#run", kind: "symbol", name: "run", owner: "file", ownerPath: "src/a.ts", source, attributes: { symbolKind: "function", fnxcArea: "Memory" } },
{ id: "module:src", kind: "module", name: "src", owner: "derived", ownerPath: "src", source, attributes: {} },
];
const edges = [
{ id: "edge-in", kind: "contains", from: "file:src/a.ts", to: "symbol:src/a.ts#run", provenance: "extracted", owner: "file", ownerPath: "src/a.ts", source, attributes: {} },
{ id: "edge-out", kind: "imports", from: "symbol:src/a.ts#run", to: "module:src", provenance: "inferred", owner: "derived", ownerPath: "src", source, attributes: {} },
];
const graph = { schemaVersion: 1, nodes, edges };
function app() {
const router = express.Router();
registerKnowledgeRoutes({ router, getScopedStore: vi.fn(async () => ({ getRootDir: () => "/project", getSettings: async () => ({}) })), rethrowAsApiError: (error: unknown) => { throw error; } } as never);
const result = express(); result.use(express.json()); result.use("/api", router);
result.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => res.status(err.statusCode ?? 500).json({ error: err.message }));
return result;
}
beforeEach(() => { vi.clearAllMocks(); access.resolve.mockResolvedValue("/project/.fusion-knowledge/graph"); access.load.mockResolvedValue({ ok: true, graph }); });
describe("knowledge graph routes", () => {
it("reports missing graph availability and applies all bounded search validation", async () => {
const server = app();
access.load.mockResolvedValueOnce({ ok: false, recoveryReason: "missing-artifact" });
expect((await performRequest(server, "GET", "/api/knowledge/graph/status")).body).toMatchObject({ available: false, recoveryReason: "missing-artifact", pathLimits: { maxHops: 10 } });
const search = await performRequest(server, "GET", "/api/knowledge/graph/nodes?limit=999&kinds=symbol&pathPrefix=src&idPrefix=symbol%3A&namePattern=run&fnxcArea=Memory&symbolKind=function&owner=file");
expect(search.body).toMatchObject({ total: 1, limit: 200 });
for (const invalid of ["kinds=nope", "owner=nope", "symbolKind=nope", "namePattern=%5B"]) expect((await performRequest(server, "GET", `/api/knowledge/graph/nodes?${invalid}`)).status).toBe(400);
});
it("returns capped node detail and validates bounded neighbor traversal", async () => {
const server = app();
const detail = await performRequest(server, "GET", "/api/knowledge/graph/node?id=symbol%3Asrc%2Fa.ts%23run");
expect(detail.status).toBe(200); expect(detail.body).toMatchObject({ node: { id: "symbol:src/a.ts#run" }, incomingTotal: 1, outgoingTotal: 1 });
expect((await performRequest(server, "GET", "/api/knowledge/graph/node?id=missing")).status).toBe(404);
const neighbors = await performRequest(server, "GET", "/api/knowledge/graph/neighbors?nodeId=symbol%3Asrc%2Fa.ts%23run&direction=both&edgeKinds=imports&depth=99&limit=1");
expect(neighbors.status).toBe(200); expect((neighbors.body as any).neighbors).toHaveLength(1);
expect((await performRequest(server, "GET", "/api/knowledge/graph/neighbors?nodeId=file%3Asrc%2Fa.ts&direction=sideways")).status).toBe(400);
expect((await performRequest(server, "GET", "/api/knowledge/graph/neighbors?nodeId=missing")).status).toBe(404);
});
it("passes all bounded path outcomes and parameter normalization through unchanged", async () => {
const server = app();
access.path.mockReturnValueOnce({ outcome: "found", path: { nodes: [nodes[0]], edges: [] }, hops: 0, maxHops: 10, expansions: 1, truncated: false });
const found = await performRequest(server, "GET", "/api/knowledge/graph/path?fromId=a&toId=b&maxHops=99");
expect(found.body).toMatchObject({ outcome: "found", hops: 0 }); expect(access.path).toHaveBeenLastCalledWith(graph, "a", "b", { maxHops: 10 });
access.path.mockReturnValueOnce({ outcome: "not-found", path: null, maxHops: 6, expansions: 2, truncated: false });
expect((await performRequest(server, "GET", "/api/knowledge/graph/path?fromId=a&toId=b&maxHops=wat")).body).toMatchObject({ outcome: "not-found", truncated: false });
expect(access.path).toHaveBeenLastCalledWith(graph, "a", "b", { maxHops: 6 });
access.path.mockReturnValueOnce({ outcome: "limit-reached", path: null, maxHops: 2, expansions: 2, truncated: true, limit: "max-hops" });
expect((await performRequest(server, "GET", "/api/knowledge/graph/path?fromId=a&toId=b&maxHops=2")).body).toMatchObject({ outcome: "limit-reached", truncated: true, limit: "max-hops" });
access.path.mockImplementationOnce(() => { throw new KnowledgeGraphError("Unknown graph node"); });
expect((await performRequest(server, "GET", "/api/knowledge/graph/path?fromId=missing&toId=b")).status).toBe(404);
});
it("uses the explicit build path once and returns its stats", async () => {
access.rebuild.mockResolvedValue({ changed: true, nodes: 3, edges: 2, stats: { parsedFiles: 2 } });
const response = await performRequest(app(), "POST", "/api/knowledge/graph/build", JSON.stringify({ force: true }), { "content-type": "application/json" });
expect(response.body).toMatchObject({ changed: true, nodes: 3, edges: 2 });
expect(access.rebuild).toHaveBeenCalledTimes(1); expect(access.rebuild.mock.calls[0]?.[1]).toEqual({ force: true });
});
});

View File

@@ -9,6 +9,25 @@ import {
} from "../knowledge-index.js"; } from "../knowledge-index.js";
import type { ApiRouteRegistrar } from "./types.js"; import type { ApiRouteRegistrar } from "./types.js";
import { requireAsyncLayer } from "../require-async-layer.js"; import { requireAsyncLayer } from "../require-async-layer.js";
import { relative } from "node:path";
import {
KnowledgeGraphError,
neighbors,
queryNodes,
type EdgeKind,
type GraphNodeKind,
type GraphOwner,
type SymbolKind,
} from "@fusion/core";
import {
findBoundedShortestPath,
KNOWLEDGE_GRAPH_PATH_DEFAULT_HOPS,
KNOWLEDGE_GRAPH_PATH_MAX_EXPANSIONS,
KNOWLEDGE_GRAPH_PATH_MAX_HOPS,
loadProjectKnowledgeGraph,
rebuildProjectKnowledgeGraph,
resolveProjectGraphDir,
} from "../knowledge-graph-access.js";
/** /**
* Persistent knowledge-index API (U14). * Persistent knowledge-index API (U14).
@@ -28,6 +47,41 @@ import { requireAsyncLayer } from "../require-async-layer.js";
*/ */
const VALID_SOURCE_KINDS: ReadonlySet<string> = new Set<KnowledgeSourceKind>(["task", "pr"]); const VALID_SOURCE_KINDS: ReadonlySet<string> = new Set<KnowledgeSourceKind>(["task", "pr"]);
const KNOWLEDGE_GRAPH_NODE_PAGE_MAX = 200;
const KNOWLEDGE_GRAPH_EDGE_PAGE_MAX = 200;
const GRAPH_NODE_KINDS = new Set<GraphNodeKind>(["file", "module", "symbol", "doc-concept", "rationale"]);
const GRAPH_EDGE_KINDS = new Set<EdgeKind>(["contains", "imports", "re-exports", "relates-to", "rationale-supports"]);
const GRAPH_SYMBOL_KINDS = new Set<SymbolKind>(["function", "class", "interface", "type-alias", "enum", "variable", "namespace", "alias"]);
const GRAPH_OWNERS = new Set<GraphOwner>(["file", "derived"]);
function queryValues(value: unknown): string[] {
const values = Array.isArray(value) ? value : [value];
return values.flatMap(item => typeof item === "string" ? item.split(",") : []).map(item => item.trim()).filter(Boolean);
}
function enumValues<T extends string>(value: unknown, allowed: ReadonlySet<T>, name: string): T[] | undefined {
const values = queryValues(value);
if (values.some(item => !allowed.has(item as T))) throw new ApiError(400, `Invalid ${name}`);
return values.length ? values as T[] : undefined;
}
function stringValue(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value.trim() : undefined; }
function boundedNumber(value: unknown, fallback: number, min: number, max: number): number {
const parsed = typeof value === "string" ? Number(value) : NaN;
return Number.isFinite(parsed) ? Math.min(max, Math.max(min, Math.trunc(parsed))) : fallback;
}
/** Missing graph artifacts are a recoverable operator state, not an empty query result. */
async function dashboardGraph(store: Parameters<typeof resolveProjectGraphDir>[0]) {
const graphDir = await resolveProjectGraphDir(store);
const loaded = await loadProjectKnowledgeGraph(graphDir);
if (!loaded.ok) throw new ApiError(404, loaded.recoveryReason);
return { graphDir, graph: loaded.graph };
}
function graphCounts<T extends { kind: string }>(values: readonly T[]): Record<string, number> {
return values.reduce<Record<string, number>>((counts, value) => ({ ...counts, [value.kind]: (counts[value.kind] ?? 0) + 1 }), {});
}
function resolveSourceKind(query: { sourceKind?: unknown }): KnowledgeSourceKind | undefined { function resolveSourceKind(query: { sourceKind?: unknown }): KnowledgeSourceKind | undefined {
const raw = typeof query.sourceKind === "string" ? query.sourceKind : undefined; const raw = typeof query.sourceKind === "string" ? query.sourceKind : undefined;
@@ -69,6 +123,72 @@ export const registerKnowledgeRoutes: ApiRouteRegistrar = (ctx) => {
} }
}); });
/*
FNXC:KnowledgeGraphDashboard 2026-08-13-22:45:
Graph HTTP responses are capped because edges.json can be tens of megabytes; graph ids contain
slashes, #, @, and ~, so clients must send them as encoded query values rather than path segments.
The path endpoint preserves found, not-found, and limit-reached as successful distinct outcomes.
*/
router.get("/knowledge/graph/status", async (req, res) => {
try {
const store = await getScopedStore(req);
const graphDir = await resolveProjectGraphDir(store);
const loaded = await loadProjectKnowledgeGraph(graphDir);
const pathLimits = { defaultMaxHops: KNOWLEDGE_GRAPH_PATH_DEFAULT_HOPS, maxHops: KNOWLEDGE_GRAPH_PATH_MAX_HOPS, maxExpansions: KNOWLEDGE_GRAPH_PATH_MAX_EXPANSIONS };
if (!loaded.ok) return res.json({ available: false, recoveryReason: loaded.recoveryReason, pathLimits });
const fnxcAreas = [...new Set(loaded.graph.nodes.map(node => node.attributes.fnxcArea).filter((area): area is string => Boolean(area)))].sort().slice(0, KNOWLEDGE_GRAPH_NODE_PAGE_MAX);
return res.json({ available: true, recoveryReason: null, graphDir: relative(store.getRootDir(), graphDir), nodeCount: loaded.graph.nodes.length, edgeCount: loaded.graph.edges.length, nodeKindCounts: graphCounts(loaded.graph.nodes), edgeKindCounts: graphCounts(loaded.graph.edges), provenanceCounts: loaded.graph.edges.reduce((counts, edge) => ({ ...counts, [edge.provenance]: (counts[edge.provenance] ?? 0) + 1 }), { extracted: 0, inferred: 0 }), ownerCounts: loaded.graph.nodes.reduce((counts, node) => ({ ...counts, [node.owner]: (counts[node.owner] ?? 0) + 1 }), { file: 0, derived: 0 }), fnxcAreas, pathLimits });
} catch (err) { if (err instanceof ApiError) throw err; rethrowAsApiError(err, "Failed to load knowledge graph status"); }
});
router.get("/knowledge/graph/nodes", async (req, res) => {
try {
const store = await getScopedStore(req);
const { graph } = await dashboardGraph(store);
const namePattern = stringValue(req.query.namePattern);
if (namePattern) try { new RegExp(namePattern, "i"); } catch { throw new ApiError(400, "Invalid namePattern"); }
const nodes = queryNodes(graph, { kinds: enumValues(req.query.kinds, GRAPH_NODE_KINDS, "kinds"), pathPrefix: stringValue(req.query.pathPrefix), idPrefix: stringValue(req.query.idPrefix), namePattern, fnxcArea: stringValue(req.query.fnxcArea), symbolKind: enumValues(req.query.symbolKind, GRAPH_SYMBOL_KINDS, "symbolKind")?.[0], owner: enumValues(req.query.owner, GRAPH_OWNERS, "owner")?.[0] });
const limit = boundedNumber(req.query.limit, 50, 1, KNOWLEDGE_GRAPH_NODE_PAGE_MAX);
const offset = boundedNumber(req.query.offset, 0, 0, Number.MAX_SAFE_INTEGER);
res.json({ nodes: nodes.slice(offset, offset + limit), total: nodes.length, limit, offset });
} catch (err) { if (err instanceof ApiError) throw err; rethrowAsApiError(err, "Failed to query knowledge graph nodes"); }
});
router.get("/knowledge/graph/node", async (req, res) => {
try {
const store = await getScopedStore(req); const { graph } = await dashboardGraph(store);
const id = stringValue(req.query.id); const node = id ? graph.nodes.find(item => item.id === id) : undefined;
if (!node) throw new ApiError(404, "Unknown graph node");
const outgoing = graph.edges.filter(edge => edge.from === id); const incoming = graph.edges.filter(edge => edge.to === id);
res.json({ node, outgoing: outgoing.slice(0, KNOWLEDGE_GRAPH_EDGE_PAGE_MAX), incoming: incoming.slice(0, KNOWLEDGE_GRAPH_EDGE_PAGE_MAX), outgoingTotal: outgoing.length, incomingTotal: incoming.length });
} catch (err) { if (err instanceof ApiError) throw err; rethrowAsApiError(err, "Failed to load knowledge graph node"); }
});
router.get("/knowledge/graph/neighbors", async (req, res) => {
try {
const store = await getScopedStore(req); const { graph } = await dashboardGraph(store);
const nodeId = stringValue(req.query.nodeId); if (!nodeId || !graph.nodes.some(node => node.id === nodeId)) throw new ApiError(404, "Unknown graph node");
const direction = stringValue(req.query.direction) ?? "out";
if (!["out", "in", "both"].includes(direction)) throw new ApiError(400, "Invalid direction");
const results = neighbors(graph, nodeId, { direction: direction as "out" | "in" | "both", edgeKinds: enumValues(req.query.edgeKinds, GRAPH_EDGE_KINDS, "edgeKinds"), depth: boundedNumber(req.query.depth, 1, 1, 3) });
res.json({ neighbors: results.slice(0, boundedNumber(req.query.limit, 50, 1, KNOWLEDGE_GRAPH_NODE_PAGE_MAX)), total: results.length });
} catch (err) { if (err instanceof ApiError) throw err; rethrowAsApiError(err, "Failed to load knowledge graph neighbors"); }
});
router.get("/knowledge/graph/path", async (req, res) => {
try {
const store = await getScopedStore(req); const { graph } = await dashboardGraph(store);
const fromId = stringValue(req.query.fromId); const toId = stringValue(req.query.toId);
if (!fromId || !toId) throw new ApiError(400, "fromId and toId are required");
res.json(findBoundedShortestPath(graph, fromId, toId, { maxHops: boundedNumber(req.query.maxHops, KNOWLEDGE_GRAPH_PATH_DEFAULT_HOPS, 1, KNOWLEDGE_GRAPH_PATH_MAX_HOPS) }));
} catch (err) { if (err instanceof ApiError) throw err; if (err instanceof KnowledgeGraphError && err.message === "Unknown graph node") throw new ApiError(404, err.message); rethrowAsApiError(err, "Failed to find knowledge graph path"); }
});
router.post("/knowledge/graph/build", async (req, res) => {
try { const store = await getScopedStore(req); res.json(await rebuildProjectKnowledgeGraph(store, { force: req.body?.force === true })); }
catch (err) { if (err instanceof ApiError) throw err; rethrowAsApiError(err, "Failed to rebuild knowledge graph"); }
});
/** /**
* POST /api/knowledge/refresh { taskId } * POST /api/knowledge/refresh { taskId }
* Incrementally re-index a single task as a knowledge page. Exposes the * Incrementally re-index a single task as a knowledge page. Exposes the

View File

@@ -3618,7 +3618,20 @@
"testSearchHint": "Runs the same qmd-backed memory_search path agents use.", "testSearchHint": "Runs the same qmd-backed memory_search path agents use.",
"title": "Memory", "title": "Memory",
"totalInsights": "Total Insights", "totalInsights": "Total Insights",
"workingMemoryLabel": "Working Memory" "workingMemoryLabel": "Working Memory",
"tabGraph": "Knowledge Graph",
"graphLoading": "Loading knowledge graph…",
"graphUnavailable": "Graph unavailable",
"graphBuild": "Build graph",
"graphNodes": "nodes",
"graphEdges": "edges",
"graphForce": "Force rebuild",
"graphRebuilding": "Rebuilding…",
"graphRebuild": "Rebuild",
"graphFilters": "Graph filters",
"graphClearFilters": "Clear filters",
"graphResults": "results",
"graphNoResults": "No matching nodes."
}, },
"merge": { "merge": {
"advanced": "Advanced", "advanced": "Advanced",

View File

@@ -3608,7 +3608,20 @@
"testSearchHint": "Ejecuta el mismo camino memory_search respaldado por qmd que usan los agentes.", "testSearchHint": "Ejecuta el mismo camino memory_search respaldado por qmd que usan los agentes.",
"title": "Memoria", "title": "Memoria",
"totalInsights": "Total de perspectivas", "totalInsights": "Total de perspectivas",
"workingMemoryLabel": "Memoria de trabajo" "workingMemoryLabel": "Memoria de trabajo",
"tabGraph": "Knowledge Graph",
"graphLoading": "Loading knowledge graph…",
"graphUnavailable": "Graph unavailable",
"graphBuild": "Build graph",
"graphNodes": "nodes",
"graphEdges": "edges",
"graphForce": "Force rebuild",
"graphRebuilding": "Rebuilding…",
"graphRebuild": "Rebuild",
"graphFilters": "Graph filters",
"graphClearFilters": "Clear filters",
"graphResults": "results",
"graphNoResults": "No matching nodes."
}, },
"merge": { "merge": {
"advanced": "Avanzado", "advanced": "Avanzado",

View File

@@ -3608,7 +3608,20 @@
"testSearchHint": "Exécute le même chemin memory_search basé sur qmd qu'utilisent les agents.", "testSearchHint": "Exécute le même chemin memory_search basé sur qmd qu'utilisent les agents.",
"title": "Mémoire", "title": "Mémoire",
"totalInsights": "Total des insights", "totalInsights": "Total des insights",
"workingMemoryLabel": "Mémoire de travail" "workingMemoryLabel": "Mémoire de travail",
"tabGraph": "Knowledge Graph",
"graphLoading": "Loading knowledge graph…",
"graphUnavailable": "Graph unavailable",
"graphBuild": "Build graph",
"graphNodes": "nodes",
"graphEdges": "edges",
"graphForce": "Force rebuild",
"graphRebuilding": "Rebuilding…",
"graphRebuild": "Rebuild",
"graphFilters": "Graph filters",
"graphClearFilters": "Clear filters",
"graphResults": "results",
"graphNoResults": "No matching nodes."
}, },
"merge": { "merge": {
"advanced": "Avancé", "advanced": "Avancé",

View File

@@ -3608,7 +3608,20 @@
"testSearchHint": "에이전트가 사용하는 동일한 qmd 기반 memory_search 경로를 실행합니다.", "testSearchHint": "에이전트가 사용하는 동일한 qmd 기반 memory_search 경로를 실행합니다.",
"title": "메모리", "title": "메모리",
"totalInsights": "총 인사이트", "totalInsights": "총 인사이트",
"workingMemoryLabel": "작업 메모리" "workingMemoryLabel": "작업 메모리",
"tabGraph": "Knowledge Graph",
"graphLoading": "Loading knowledge graph…",
"graphUnavailable": "Graph unavailable",
"graphBuild": "Build graph",
"graphNodes": "nodes",
"graphEdges": "edges",
"graphForce": "Force rebuild",
"graphRebuilding": "Rebuilding…",
"graphRebuild": "Rebuild",
"graphFilters": "Graph filters",
"graphClearFilters": "Clear filters",
"graphResults": "results",
"graphNoResults": "No matching nodes."
}, },
"merge": { "merge": {
"advanced": "고급", "advanced": "고급",

View File

@@ -3618,7 +3618,20 @@
"testSearchHint": "Executa o mesmo caminho memory_search baseado em qmd que os agentes usam.", "testSearchHint": "Executa o mesmo caminho memory_search baseado em qmd que os agentes usam.",
"title": "Memória", "title": "Memória",
"totalInsights": "Total de insights", "totalInsights": "Total de insights",
"workingMemoryLabel": "Memória de trabalho" "workingMemoryLabel": "Memória de trabalho",
"tabGraph": "Knowledge Graph",
"graphLoading": "Loading knowledge graph…",
"graphUnavailable": "Graph unavailable",
"graphBuild": "Build graph",
"graphNodes": "nodes",
"graphEdges": "edges",
"graphForce": "Force rebuild",
"graphRebuilding": "Rebuilding…",
"graphRebuild": "Rebuild",
"graphFilters": "Graph filters",
"graphClearFilters": "Clear filters",
"graphResults": "results",
"graphNoResults": "No matching nodes."
}, },
"merge": { "merge": {
"advanced": "Avançado", "advanced": "Avançado",

View File

@@ -3608,7 +3608,20 @@
"testSearchHint": "运行与代理使用的相同 qmd 支持的 memory_search 路径。", "testSearchHint": "运行与代理使用的相同 qmd 支持的 memory_search 路径。",
"title": "记忆", "title": "记忆",
"totalInsights": "洞察总数", "totalInsights": "洞察总数",
"workingMemoryLabel": "工作记忆" "workingMemoryLabel": "工作记忆",
"tabGraph": "Knowledge Graph",
"graphLoading": "Loading knowledge graph…",
"graphUnavailable": "Graph unavailable",
"graphBuild": "Build graph",
"graphNodes": "nodes",
"graphEdges": "edges",
"graphForce": "Force rebuild",
"graphRebuilding": "Rebuilding…",
"graphRebuild": "Rebuild",
"graphFilters": "Graph filters",
"graphClearFilters": "Clear filters",
"graphResults": "results",
"graphNoResults": "No matching nodes."
}, },
"merge": { "merge": {
"advanced": "高级", "advanced": "高级",

View File

@@ -3608,7 +3608,20 @@
"testSearchHint": "執行與代理使用的相同 qmd 支援的 memory_search 路徑。", "testSearchHint": "執行與代理使用的相同 qmd 支援的 memory_search 路徑。",
"title": "記憶", "title": "記憶",
"totalInsights": "洞察總數", "totalInsights": "洞察總數",
"workingMemoryLabel": "工作記憶" "workingMemoryLabel": "工作記憶",
"tabGraph": "Knowledge Graph",
"graphLoading": "Loading knowledge graph…",
"graphUnavailable": "Graph unavailable",
"graphBuild": "Build graph",
"graphNodes": "nodes",
"graphEdges": "edges",
"graphForce": "Force rebuild",
"graphRebuilding": "Rebuilding…",
"graphRebuild": "Rebuild",
"graphFilters": "Graph filters",
"graphClearFilters": "Clear filters",
"graphResults": "results",
"graphNoResults": "No matching nodes."
}, },
"merge": { "merge": {
"advanced": "進階", "advanced": "進階",