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>
116 lines
6.1 KiB
TypeScript
116 lines
6.1 KiB
TypeScript
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";
|
|
import { loadArtifacts, neighbors, resolveKnowledgeGraphDir, type KnowledgeGraph } from "@fusion/core";
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
create: vi.fn(),
|
|
prompt: vi.fn(),
|
|
mcp: vi.fn(async () => ({ servers: [] })),
|
|
}));
|
|
vi.mock("../pi.js", () => ({ createFnAgent: mocks.create, promptWithFallback: mocks.prompt }));
|
|
vi.mock("../mcp/mcp-resolution.js", () => ({ resolveMcpServersForStore: mocks.mcp }));
|
|
|
|
import { parseMemorySemanticsResponse, runMemorySemanticsPass } from "../memory/memory-semantics.js";
|
|
import { resolveMemoryConsolidationPorts } from "../memory/memory-consolidation-adapters.js";
|
|
|
|
const roots: string[] = [];
|
|
afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); });
|
|
|
|
const graph: KnowledgeGraph = {
|
|
schemaVersion: 2,
|
|
nodes: [
|
|
{ id: "doc:docs/a.md#one~0", kind: "doc-concept", name: "one", owner: "file", ownerPath: "docs/a.md", source: { path: "docs/a.md", line: 1, column: 1 }, attributes: {} },
|
|
{ id: "doc:docs/a.md#two~1", kind: "doc-concept", name: "two", owner: "file", ownerPath: "docs/a.md", source: { path: "docs/a.md", line: 2, column: 1 }, attributes: {} },
|
|
],
|
|
edges: [],
|
|
};
|
|
|
|
const taskStore = { getSettings: vi.fn(async () => ({ defaultProvider: "mock", defaultModelId: "scripted" })) } as never;
|
|
|
|
beforeEach(() => {
|
|
mocks.create.mockReset();
|
|
mocks.prompt.mockReset();
|
|
mocks.mcp.mockClear();
|
|
});
|
|
|
|
describe("memory semantics pass", () => {
|
|
it("accepts only semantic relationship families and drops a model provenance claim", () => {
|
|
const result = parseMemorySemanticsResponse(JSON.stringify({ proposals: [
|
|
{ kind: "relates-to", from: "doc:a", to: "doc:b", provenance: "extracted" },
|
|
{ kind: "contains", from: "doc:a", to: "doc:b" },
|
|
] }));
|
|
expect(result.proposals).toEqual([{ kind: "relates-to", from: "doc:a", to: "doc:b" }]);
|
|
});
|
|
|
|
it("drives the readonly mock lane and forwards provenance-free proposals only to the inferred writer", async () => {
|
|
let onText: ((text: string) => void) | undefined;
|
|
const session = { state: {}, dispose: vi.fn() };
|
|
mocks.create.mockImplementation(async (options) => { onText = options.onText; return { session }; });
|
|
mocks.prompt.mockImplementation(async () => {
|
|
onText?.(JSON.stringify({ proposals: [{ kind: "relates-to", from: "doc:docs/a.md#one~0", to: "doc:docs/a.md#two~1", provenance: "extracted" }] }));
|
|
});
|
|
const write = vi.fn(async (proposals) => ({ written: proposals.length, deduped: 0, droppedUnresolved: 0 }));
|
|
|
|
await expect(runMemorySemanticsPass({ graph, graphChanged: true, taskStore, agentId: "memory", rootDir: "/repo", write })).resolves.toMatchObject({ written: 1, deduped: 0, droppedUnresolved: 0 });
|
|
expect(mocks.create).toHaveBeenCalledWith(expect.objectContaining({ tools: "readonly", defaultProvider: "mock", defaultModelId: "scripted" }));
|
|
expect(write).toHaveBeenCalledWith([{ kind: "relates-to", from: "doc:docs/a.md#one~0", to: "doc:docs/a.md#two~1" }]);
|
|
expect(session.dispose).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("persists mocked model output through the production consolidation adapter as inferred graph evidence", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "memory-semantics-"));
|
|
roots.push(root);
|
|
await writeFile(join(root, "AGENTS.md"), "# One\n\n# Two\n");
|
|
const layer = { projectId: "P-SEMANTICS" };
|
|
const productionStore = {
|
|
getAsyncLayer: () => layer,
|
|
getSettings: async () => ({ defaultProvider: "mock", defaultModelId: "scripted" }),
|
|
} as never;
|
|
const resolved = await resolveMemoryConsolidationPorts({ taskStore: productionStore, rootDir: root, agentId: "memory" });
|
|
expect(resolved.status).toBe("ready");
|
|
if (resolved.status !== "ready") return;
|
|
|
|
await resolved.ports.refreshGraph();
|
|
const before = await loadArtifacts(resolveKnowledgeGraphDir(root));
|
|
expect(before.ok).toBe(true);
|
|
if (!before.ok) return;
|
|
const [from, to] = before.graph.nodes.filter(node => node.kind === "doc-concept").map(node => node.id);
|
|
expect(from).toBeTruthy();
|
|
expect(to).toBeTruthy();
|
|
|
|
let onText: ((text: string) => void) | undefined;
|
|
mocks.create.mockImplementation(async (options) => { onText = options.onText; return { session: { state: {}, dispose: vi.fn() } }; });
|
|
mocks.prompt.mockImplementation(async () => {
|
|
onText?.(JSON.stringify({ proposals: [{ kind: "relates-to", from, to, provenance: "extracted" }] }));
|
|
});
|
|
|
|
await expect(resolved.ports.runSemantics(true)).resolves.toMatchObject({ written: 1 });
|
|
const loaded = await loadArtifacts(resolveKnowledgeGraphDir(root));
|
|
expect(loaded.ok).toBe(true);
|
|
if (!loaded.ok) return;
|
|
expect(neighbors(loaded.graph, from!)).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({
|
|
node: expect.objectContaining({ id: to }),
|
|
edges: expect.arrayContaining([expect.objectContaining({ provenance: "inferred" })]),
|
|
}),
|
|
]));
|
|
});
|
|
|
|
it("does not contact a model or write during an unchanged repeat tick", async () => {
|
|
const write = vi.fn();
|
|
await expect(runMemorySemanticsPass({ graph, graphChanged: false, taskStore, agentId: "memory", rootDir: "/repo", write })).resolves.toEqual({ proposals: [], skipped: "unchanged" });
|
|
expect(mocks.create).not.toHaveBeenCalled();
|
|
expect(mocks.prompt).not.toHaveBeenCalled();
|
|
expect(write).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("treats malformed model output as a counted, non-throwing skip", async () => {
|
|
let onText: ((text: string) => void) | undefined;
|
|
mocks.create.mockImplementation(async (options) => { onText = options.onText; return { session: { state: {}, dispose: vi.fn() } }; });
|
|
mocks.prompt.mockImplementation(async () => { onText?.("not json"); });
|
|
await expect(runMemorySemanticsPass({ graph, graphChanged: true, taskStore, agentId: "memory", rootDir: "/repo", write: vi.fn() })).resolves.toMatchObject({ skipped: "malformed-response" });
|
|
});
|
|
});
|