Files
fusion/packages/core/src/knowledge-graph/graph-builder.ts
gsxdsm eef68fe0b9 FN-8933: capture semantic memory from completed work
Add inferred graph relationships and detached recall capture across completed-work memory surfaces.

- Validate and persist deterministic inferred semantic edges with audit outcomes.
- Capture completed tasks, research findings, and insights as bounded recall entries.
- Wire memory semantics through engine, research APIs, and coverage tests.

Files changed:
 .changeset/fn-8933-memory-semantics-capture.md     |   7 +
 AGENTS.md                                          |   1 +
 docs/knowledge-graph.md                            |  10 +-
 .../src/__tests__/memory/recall-capture.test.ts    | 111 +++++++++++++
 .../__tests__/postgres/insight-store.pg.test.ts    |  25 +++
 .../postgres/research-execution.pg.test.ts         |  38 +++++
 .../__tests__/research-feature-promotion.test.ts   |  39 +++++
 .../core/src/async-stores/async-insight-store.ts   |  23 ++-
 packages/core/src/index.ts                         |   1 +
 .../__tests__/graph-builder-incremental.test.ts    |  28 ++++
 .../__tests__/inferred-edge-writer.test.ts         |  74 +++++++++
 packages/core/src/knowledge-graph/graph-builder.ts |  27 ++-
 .../src/knowledge-graph/graph-serialization.ts     |   2 +-
 packages/core/src/knowledge-graph/graph-store.ts   |  12 ++
 packages/core/src/knowledge-graph/graph-types.ts   |   2 +-
 packages/core/src/knowledge-graph/index.ts         |   1 +
 .../src/knowledge-graph/inferred-edge-writer.ts    |  96 +++++++++++
 packages/core/src/memory/index.ts                  |   1 +
 packages/core/src/memory/recall-capture.ts         | 184 +++++++++++++++++++++
 .../src/research/research-feature-promotion.ts     |  23 ++-
 packages/core/src/task-store/task-store-helpers.ts |  13 +-
 .../src/__tests__/research-routes.test.ts          |  80 ++++++++-
 packages/dashboard/src/research-routes.ts          |  27 ++-
 .../src/__tests__/agent-mission-tools.test.ts      |  60 ++++++-
 .../src/__tests__/in-process-runtime.pg.test.ts    |  28 +++-
 .../memory-consolidation-heartbeat-hook.test.ts    |   8 +-
 .../__tests__/memory-consolidation-ports.test.ts   |  17 +-
 .../src/__tests__/memory-semantics-pass.test.ts    | 115 +++++++++++++
 .../engine/src/__tests__/project-engine.test.ts    |  65 +++++++-
 packages/engine/src/agent-heartbeat.ts             |  14 +-
 packages/engine/src/agent-tools.ts                 |  12 +-
 packages/engine/src/agents/agent-reflection.ts     |   9 +-
 packages/engine/src/memory/index.ts                |   1 +
 .../src/memory/memory-consolidation-adapters.ts    |  18 +-
 packages/engine/src/memory/memory-consolidation.ts |  13 +-
 packages/engine/src/memory/memory-semantics.ts     |  67 ++++++++
 packages/engine/src/project-engine.ts              |   3 +
 .../engine/src/research/research-orchestrator.ts   |  20 ++-
 packages/engine/src/runtimes/in-process-runtime.ts |  12 ++
 packages/engine/src/util/run-audit.ts              |  11 ++
 40 files changed, 1250 insertions(+), 48 deletions(-)

Fusion-Task-Id: FN-8933

Fusion-Task-Lineage: b75a6b23-906f-4255-92f1-7684beb742b8

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-08-11 05:45:36 -07:00

161 lines
7.0 KiB
TypeScript

import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { deriveModules } from "./derive-modules.js";
import { discoverFiles, type FileDiscoveryOptions } from "./file-discovery.js";
import { extractFile } from "./extract-file.js";
import { fingerprintContent } from "./graph-manifest.js";
import {
assertNoInternalDuplicateNodes,
edgeId,
fileNodeId,
KNOWLEDGE_GRAPH_SCHEMA_VERSION,
KNOWLEDGE_GRAPH_EXTRACTOR_VERSION,
type ExtractorDependencies,
type GraphEdge,
type GraphManifest,
type KnowledgeGraph,
} from "./graph-types.js";
import { selectImportTarget } from "./resolve-imports.js";
import { loadArtifacts, resolveKnowledgeGraphDir, writeArtifacts, type RecoveryReason } from "./graph-store.js";
export interface BuildKnowledgeGraphOptions {
projectRoot: string;
/** Defaults to the tracked artifact location under projectRoot. */
graphDir?: string;
force?: boolean;
deps?: ExtractorDependencies;
extractFile?: typeof extractFile;
logger?: (message: string) => void;
discovery?: FileDiscoveryOptions;
}
/**
* Rebuild the content-pure file layer incrementally, then regenerate cross-file and derived facts.
* Import edges are deliberately synthesized from persisted references on every run: an added or
* removed target must not require parsing an otherwise unchanged importer.
*/
/*
FNXC:KnowledgeGraph 2026-08-10-11:07:
The programmatic builder defaults to the tracked graph location just like the CLI. Callers may
supply another contained directory for tests or project policy, but an omitted option must not
turn a normal build into an undefined-path I/O failure.
*/
export async function buildKnowledgeGraph(options: BuildKnowledgeGraphOptions) {
const graphDir = resolveKnowledgeGraphDir(options.projectRoot, options.graphDir);
const files = await discoverFiles(options.projectRoot, options.discovery);
const prior = options.force
? { ok: false as const, recoveryReason: "forced" as RecoveryReason }
: await loadArtifacts(graphDir, options.logger);
const graph: KnowledgeGraph = prior.ok
? {
schemaVersion: prior.graph.schemaVersion,
nodes: prior.graph.nodes.filter(node => node.owner !== "derived"),
// FNXC:KnowledgeGraphInferredEdges 2026-08-11-10:56: FN-8933 semantic edges are durable LLM
// conclusions, not parser-owned facts, so an incremental structural rebuild must retain them.
edges: prior.graph.edges.filter(edge => edge.provenance === "inferred" || (edge.owner !== "derived" && edge.kind !== "imports" && edge.kind !== "re-exports")),
}
: { schemaVersion: KNOWLEDGE_GRAPH_SCHEMA_VERSION, nodes: [], edges: [] };
const manifest: GraphManifest = prior.ok
? { ...prior.manifest, files: { ...prior.manifest.files } }
: { schemaVersion: KNOWLEDGE_GRAPH_SCHEMA_VERSION, extractorVersion: KNOWLEDGE_GRAPH_EXTRACTOR_VERSION, files: {} };
const oldPaths = new Set(Object.keys(manifest.files));
let parsedFiles = 0;
let reusedFiles = 0;
let addedFiles = 0;
for (const relPath of files) {
const bytes = await readFile(resolve(options.projectRoot, relPath));
const content = bytes.toString("utf8");
const hash = fingerprintContent(bytes);
const previous = manifest.files[relPath];
if (prior.ok && previous?.hash === hash) {
reusedFiles++;
continue;
}
if (!previous) addedFiles++;
parsedFiles++;
graph.nodes = graph.nodes.filter(node => node.ownerPath !== relPath);
graph.edges = graph.edges.filter(edge => edge.provenance === "inferred" || edge.ownerPath !== relPath);
const result = (options.extractFile ?? extractFile)({ relPath, content }, options.deps);
graph.nodes.push(...result.nodes);
graph.edges.push(...result.edges);
manifest.files[relPath] = { hash, ...(result.importRefs.length > 0 ? { importRefs: result.importRefs } : {}) };
}
let deletedFiles = 0;
for (const relPath of oldPaths) {
if (files.includes(relPath)) continue;
deletedFiles++;
delete manifest.files[relPath];
graph.nodes = graph.nodes.filter(node => node.ownerPath !== relPath);
graph.edges = graph.edges.filter(edge => edge.provenance === "inferred" || edge.ownerPath !== relPath);
}
const discovered = new Set(files);
for (const relPath of files) {
for (const reference of manifest.files[relPath]?.importRefs ?? []) {
const target = selectImportTarget(reference.candidates, discovered);
if (!target) continue;
const edge: GraphEdge = {
id: edgeId(reference.kind, fileNodeId(relPath), fileNodeId(target)),
kind: reference.kind,
from: fileNodeId(relPath),
to: fileNodeId(target),
provenance: "extracted",
owner: "file",
ownerPath: relPath,
source: { path: relPath, line: reference.line, column: reference.column },
attributes: { specifier: reference.specifier, ...(reference.typeOnly ? { typeOnly: "true" } : {}) },
};
if (!graph.edges.some(existing => existing.id === edge.id)) graph.edges.push(edge);
}
}
const derived = deriveModules(graph.nodes);
graph.nodes.push(...derived.nodes);
graph.edges.push(...derived.edges);
const nodesById = new Map(graph.nodes.map(node => [node.id, node]));
/*
FNXC:KnowledgeGraphInferredEdges 2026-08-11-11:53:
An incremental structural rebuild replaces parsed node anchors while retaining inferred semantic
edges by stable endpoint id. Re-anchor each retained inferred edge to its current `from` node so
the persisted artifact remains self-consistent after a heading, rationale, or symbol moves lines.
*/
graph.edges = graph.edges.map(edge => {
if (edge.provenance !== "inferred") return edge;
const anchor = nodesById.get(edge.from);
return anchor ? {
...edge,
owner: anchor.owner,
ownerPath: anchor.ownerPath,
source: { ...anchor.source },
} : edge;
});
const nodeIds = new Set(nodesById.keys());
graph.edges = graph.edges.filter(edge => nodeIds.has(edge.from) && nodeIds.has(edge.to));
// Validate the deliberately narrow internal-invariant throw surface before any defensive
// normalization can hide a cross-kind or cross-owner collision. Same-owner symbol duplicates
// are collapsed by extractFile, so source content cannot reach this check as a build failure.
assertNoInternalDuplicateNodes(graph.nodes);
graph.nodes.sort((left, right) => left.id.localeCompare(right.id));
graph.edges.sort((left, right) => left.from.localeCompare(right.from) || left.to.localeCompare(right.to) || left.kind.localeCompare(right.kind) || left.id.localeCompare(right.id));
const written = await writeArtifacts(graphDir, graph, manifest);
return {
graph,
manifest,
changed: written.changed,
stats: {
parsedFiles,
reusedFiles,
prunedFiles: deletedFiles,
addedFiles,
deletedFiles,
synthesizedImportEdges: graph.edges.filter(edge => edge.kind === "imports" || edge.kind === "re-exports").length,
derivedModuleCount: derived.nodes.length,
recoveryReason: prior.ok ? null : prior.recoveryReason,
},
};
}