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>
161 lines
7.0 KiB
TypeScript
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,
|
|
},
|
|
};
|
|
}
|