feat(FN-1399): add background memory summarization after task completion

- Add MemoryInsights class in @fusion/core for AI-powered memory audit generation
- Add post-run hook to CronRunner for triggering memory summarization after scheduled tasks
- Wire memory background processing in both dashboard and serve commands
- Add memoryAuditEnabled and memoryAuditSchedule settings for configurable automation
- Fix startup ordering: sync automation before cronRunner.start() to prevent race conditions
- Add comprehensive tests for memory-insights and dashboard/serve integration
- Update contributing.md and settings-reference.md with documentation
This commit is contained in:
gsxdsm
2026-04-09 16:14:44 -07:00
parent 8ba375549c
commit daabb51e71
13 changed files with 1803 additions and 4 deletions

View File

@@ -0,0 +1,26 @@
---
"@gsxdsm/fusion": minor
---
Add background memory summarization and audit processing
This change introduces real-time background processing that automatically extracts and audits project memory insights on a configurable schedule.
**New Features:**
- Background insight extraction: AI-powered analysis of `.fusion/memory.md` to distill insights into `.fusion/memory-insights.md`
- Audit reports: Automatic generation of `.fusion/memory-audit.md` after each extraction run
- Runtime wiring: Both `fn dashboard` and `fn serve` now sync and process insight extraction automation
**Files Generated:**
- `.fusion/memory-insights.md` — Long-term distilled insights (categories: Patterns, Principles, Conventions, Pitfalls, Context)
- `.fusion/memory-audit.md` — Human-readable audit report with health checks
**Settings:**
- `insightExtractionEnabled` — Enable/disable scheduled extraction
- `insightExtractionSchedule` — Cron expression for extraction timing
- `insightExtractionMinIntervalMs` — Minimum interval between extractions
**Safety:**
- Working memory (.fusion/memory.md) is never overwritten
- Malformed AI output preserves existing insights
- Post-processing failures are isolated and logged

View File

@@ -191,3 +191,25 @@ Key implementation details from the plugin core foundation task:
- Functions: `validatePluginManifest()` - Functions: `validatePluginManifest()`
- Classes: `PluginStore`, `PluginLoader` - Classes: `PluginStore`, `PluginLoader`
- Interfaces: `PluginStoreEvents`, `PluginRegistrationInput`, `PluginUpdateInput`, `PluginLoaderOptions` - Interfaces: `PluginStoreEvents`, `PluginRegistrationInput`, `PluginUpdateInput`, `PluginLoaderOptions`
## Background Memory Summarization (FN-1399)
The background memory summarization feature uses a three-layer architecture:
1. **CronRunner post-run hook**: `onScheduleRunProcessed` callback receives `(schedule, result)` after execution and recording. This keeps post-processing isolated from core execution.
2. **Schedule-specific filtering**: The callback in dashboard/serve checks `schedule.name === INSIGHT_EXTRACTION_SCHEDULE_NAME` to filter for the memory insight schedule only.
3. **Core processing**: `processAndAuditInsightExtraction()` parses AI output, merges insights, writes audit report, and handles errors gracefully.
**Key files**:
- `packages/core/src/memory-insights.ts` — Core helpers for processing, merging, and audit generation
- `packages/engine/src/cron-runner.ts` — Post-run callback option (`onScheduleRunProcessed`)
- `packages/cli/src/commands/dashboard.ts` — Startup sync + settings change handler
- `packages/cli/src/commands/serve.ts` — Same wiring for headless node mode
**Startup ordering**: `syncInsightExtractionAutomation()` must run BEFORE `cronRunner.start()` to avoid stale config races. The cron runner's immediate tick could execute outdated schedules before sync runs.
**Test patterns**:
- For CLI test suites with hoisted mocks (`vi.hoisted()`), helper functions like `triggerSignal` must be defined inside each `describe` block since they're not accessible from sibling blocks.
- When testing settings-change handlers in CLI commands, emit events on the mock store instance stored in `taskStores[0]` to trigger the handler.

View File

@@ -97,6 +97,15 @@ When enabled, agents can read/write durable project memory:
Use it for reusable patterns, constraints, and pitfalls that should persist across tasks. Use it for reusable patterns, constraints, and pitfalls that should persist across tasks.
### Background Memory Summarization
Fusion can automatically extract insights from working memory and distill them into long-term memory. Enable via `insightExtractionEnabled` setting:
- `.fusion/memory-insights.md` — Long-term distilled insights
- `.fusion/memory-audit.md` — Audit report after each extraction run
See [Settings Reference](./settings-reference.md#background-memory-summarization--audit) for configuration details.
## SQLite Test Runner Pitfall ## SQLite Test Runner Pitfall
When running engine tests with Vitest and `node:sqlite`, ensure the engine Vitest config uses thread pool mode: When running engine tests with Vitest and `node:sqlite`, ensure the engine Vitest config uses thread pool mode:

View File

@@ -223,3 +223,69 @@ These exist in `ProjectSettings` but are not part of `PROJECT_SETTINGS_KEYS`.
``` ```
See also: [Workflow Steps](./workflow-steps.md) for how `scripts` and workflow model overrides are used. See also: [Workflow Steps](./workflow-steps.md) for how `scripts` and workflow model overrides are used.
---
## Background Memory Summarization & Audit
Fusion can automatically extract insights from project memory and generate audit reports on a schedule. This feature is disabled by default and can be enabled via settings.
### How It Works
1. **Scheduled Extraction**: When `insightExtractionEnabled` is `true`, a background automation runs on the configured `insightExtractionSchedule` (default: daily at 2 AM).
2. **AI-Powered Analysis**: The automation uses an AI agent to read `.fusion/memory.md` and `.fusion/memory-insights.md`, extract new insights, and return structured JSON.
3. **Insight Merging**: New insights are automatically merged into `.fusion/memory-insights.md` under the appropriate category (Patterns, Principles, Conventions, Pitfalls, Context). Duplicates are skipped.
4. **Audit Report**: After each extraction run, a `.fusion/memory-audit.md` file is generated with:
- Working memory status (presence, size, sections)
- Insights memory status (insight counts by category)
- Last extraction results (success/failure, insight count, duplicates skipped)
- Health status (healthy/warning/issues)
- Individual audit checks
### Output Files
| File | Description |
|------|-------------|
| `.fusion/memory.md` | Working memory (read-only during extraction, never overwritten) |
| `.fusion/memory-insights.md` | Long-term insights distilled from working memory |
| `.fusion/memory-audit.md` | Human-readable audit report after each extraction |
### Settings Interaction
| Setting | Effect |
|---------|--------|
| `insightExtractionEnabled` | Enables/disables the automation |
| `insightExtractionSchedule` | Cron expression for when extraction runs (default: `"0 2 * * *"` = daily at 2 AM) |
| `insightExtractionMinIntervalMs` | Minimum time between extractions (default: 24 hours) |
### Safety Guarantees
- **No working memory mutation**: Background extraction reads `.fusion/memory.md` but never writes to it.
- **Graceful failures**: Malformed AI output does not destroy existing insights. Prior files are preserved.
- **Isolated processing**: Post-run callback errors are logged but do not flip successful runs to failed.
- **Startup sync**: Automation schedule is synchronized before the cron runner starts, preventing stale config races.
### Configuration Example
```json
{
"settings": {
"insightExtractionEnabled": true,
"insightExtractionSchedule": "0 2 * * *",
"insightExtractionMinIntervalMs": 86400000
}
}
```
### Cron Expression Format
Standard cron format: `minute hour day-of-month month day-of-week`
| Expression | Meaning |
|-----------|---------|
| `0 2 * * *` | Daily at 2:00 AM (default) |
| `0 */6 * * *` | Every 6 hours |
| `0 9 * * 1` | Weekly on Monday at 9:00 AM |

View File

@@ -40,6 +40,16 @@ function makeMockStore() {
// ── Mock @fusion/core ────────────────────────────────────────────────── // ── Mock @fusion/core ──────────────────────────────────────────────────
const mockSyncInsightExtraction = vi.fn().mockResolvedValue(undefined);
const mockProcessAndAudit = vi.fn().mockResolvedValue({
generatedAt: new Date().toISOString(),
health: "healthy",
checks: [],
workingMemory: { exists: true, size: 100, sectionCount: 2 },
insightsMemory: { exists: true, size: 50, insightCount: 3, categories: {}, lastUpdated: "2026-04-09" },
extraction: { runAt: new Date().toISOString(), success: true, insightCount: 3, duplicateCount: 0, skippedCount: 0, summary: "Test" },
});
vi.mock("@fusion/core", () => ({ vi.mock("@fusion/core", () => ({
TaskStore: vi.fn().mockImplementation(() => makeMockStore()), TaskStore: vi.fn().mockImplementation(() => makeMockStore()),
AutomationStore: vi.fn().mockImplementation(() => ({ AutomationStore: vi.fn().mockImplementation(() => ({
@@ -55,6 +65,9 @@ vi.mock("@fusion/core", () => ({
getAgent: vi.fn().mockResolvedValue(null), getAgent: vi.fn().mockResolvedValue(null),
deleteAgent: vi.fn(), deleteAgent: vi.fn(),
})), })),
syncInsightExtractionAutomation: mockSyncInsightExtraction,
INSIGHT_EXTRACTION_SCHEDULE_NAME: "Memory Insight Extraction",
processAndAuditInsightExtraction: mockProcessAndAudit,
})); }));
// ── Mock @fusion/dashboard ───────────────────────────────────────────── // ── Mock @fusion/dashboard ─────────────────────────────────────────────
@@ -381,3 +394,97 @@ describe("runDashboard — MissionAutopilot wiring", () => {
expect(autopilotInstance.start).toHaveBeenCalledTimes(1); expect(autopilotInstance.start).toHaveBeenCalledTimes(1);
}); });
}); });
describe("runDashboard — Memory Insight Automation wiring", () => {
beforeEach(async () => {
vi.clearAllMocks();
mockDiscoverAndLoadExtensions.mockResolvedValue({
runtime: { pendingProviderRegistrations: [] },
errors: [],
});
const { TaskStore } = await import("@fusion/core");
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => makeMockStore());
});
it("syncs insight extraction automation on startup", async () => {
await runDashboard(0, {});
expect(mockSyncInsightExtraction).toHaveBeenCalledTimes(1);
expect(mockSyncInsightExtraction).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
maxConcurrent: 1,
maxWorktrees: 2,
autoMerge: false,
pollIntervalMs: 60_000,
}),
);
});
it("passes onScheduleRunProcessed callback to CronRunner", async () => {
const { CronRunner } = await import("@fusion/engine");
await runDashboard(0, {});
expect(CronRunner).toHaveBeenCalledTimes(1);
const cronOptions = (CronRunner as ReturnType<typeof vi.fn>).mock.calls[0][2];
expect(cronOptions).toHaveProperty("onScheduleRunProcessed");
expect(typeof cronOptions.onScheduleRunProcessed).toBe("function");
});
it("calls syncInsightExtractionAutomation when insight extraction settings change", async () => {
await runDashboard(0, {});
// Get the store mock to emit settings change
const { TaskStore } = await import("@fusion/core");
const mockStore = (TaskStore as unknown as ReturnType<typeof vi.fn>).mock.results[0].value;
// Simulate settings update
mockSyncInsightExtraction.mockClear();
mockStore.emit("settings:updated", {
settings: {
insightExtractionEnabled: true,
insightExtractionSchedule: "0 3 * * *",
},
previous: {
insightExtractionEnabled: false,
insightExtractionSchedule: "0 2 * * *",
},
});
expect(mockSyncInsightExtraction).toHaveBeenCalledTimes(1);
});
it("does not call syncInsightExtractionAutomation for unrelated settings changes", async () => {
await runDashboard(0, {});
// Get the store mock to emit settings change
const { TaskStore } = await import("@fusion/core");
const mockStore = (TaskStore as unknown as ReturnType<typeof vi.fn>).mock.results[0].value;
// Simulate unrelated settings update
mockSyncInsightExtraction.mockClear();
mockStore.emit("settings:updated", {
settings: {
maxConcurrent: 5,
},
previous: {
maxConcurrent: 1,
},
});
expect(mockSyncInsightExtraction).not.toHaveBeenCalled();
});
it("handles syncInsightExtractionAutomation errors gracefully", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
mockSyncInsightExtraction.mockRejectedValueOnce(new Error("Sync failed"));
await runDashboard(0, {});
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("[memory-audit] Failed to sync insight extraction"),
);
consoleSpy.mockRestore();
});
});

View File

@@ -263,6 +263,16 @@ vi.mock("@fusion/core", () => ({
AgentStore: mocks.agentStoreCtor, AgentStore: mocks.agentStoreCtor,
CentralCore: mocks.centralCoreCtor, CentralCore: mocks.centralCoreCtor,
getTaskMergeBlocker: vi.fn().mockReturnValue(null), getTaskMergeBlocker: vi.fn().mockReturnValue(null),
syncInsightExtractionAutomation: vi.fn().mockResolvedValue(undefined),
INSIGHT_EXTRACTION_SCHEDULE_NAME: "Memory Insight Extraction",
processAndAuditInsightExtraction: vi.fn().mockResolvedValue({
generatedAt: new Date().toISOString(),
health: "healthy",
checks: [],
workingMemory: { exists: true, size: 100, sectionCount: 2 },
insightsMemory: { exists: true, size: 50, insightCount: 3, categories: {}, lastUpdated: "2026-04-09" },
extraction: { runAt: new Date().toISOString(), success: true, insightCount: 3, duplicateCount: 0, skippedCount: 0, summary: "Test" },
}),
})); }));
vi.mock("@fusion/dashboard", () => ({ vi.mock("@fusion/dashboard", () => ({
@@ -468,3 +478,143 @@ describe("runServe", () => {
await triggerSignal("SIGINT"); await triggerSignal("SIGINT");
}); });
}); });
describe("runServe — Memory Insight Automation wiring", () => {
const originalCwd = process.cwd;
const originalOn = process.on;
const originalExit = process.exit;
let signalHandlers: Record<"SIGINT" | "SIGTERM", Array<() => void>>;
let logSpy: ReturnType<typeof vi.spyOn>;
let warnSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
let cwdSpy: ReturnType<typeof vi.spyOn>;
let processOnSpy: ReturnType<typeof vi.spyOn>;
async function triggerSignal(signal: "SIGINT" | "SIGTERM") {
const handlers = signalHandlers[signal];
expect(handlers.length).toBeGreaterThan(0);
handlers[handlers.length - 1]();
await new Promise((resolve) => setTimeout(resolve, 0));
}
beforeEach(() => {
vi.clearAllMocks();
mocks.reset();
signalHandlers = { SIGINT: [], SIGTERM: [] };
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/repo");
processOnSpy = vi.spyOn(process, "on").mockImplementation(((event: string, listener: () => void) => {
if (event === "SIGINT" || event === "SIGTERM") {
signalHandlers[event].push(listener);
}
return process;
}) as typeof process.on);
process.exit = vi.fn() as never;
});
afterEach(() => {
logSpy.mockRestore();
warnSpy.mockRestore();
errorSpy.mockRestore();
cwdSpy.mockRestore();
processOnSpy.mockRestore();
process.cwd = originalCwd;
process.on = originalOn;
process.exit = originalExit;
});
it("syncs insight extraction automation on startup", async () => {
const { syncInsightExtractionAutomation } = await import("@fusion/core");
await runServe(4040, {});
expect(syncInsightExtractionAutomation).toHaveBeenCalledTimes(1);
expect(syncInsightExtractionAutomation).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
maxConcurrent: 2,
recycleWorktrees: false,
autoMerge: false,
pollIntervalMs: 60_000,
}),
);
await triggerSignal("SIGINT");
});
it("passes onScheduleRunProcessed callback to CronRunner", async () => {
await runServe(4040, {});
expect(mocks.cronRunnerCtor).toHaveBeenCalledTimes(1);
const cronOptions = mocks.cronRunnerCtor.mock.calls[0][2];
expect(cronOptions).toHaveProperty("onScheduleRunProcessed");
expect(typeof cronOptions.onScheduleRunProcessed).toBe("function");
await triggerSignal("SIGINT");
});
it("calls syncInsightExtractionAutomation when insight extraction settings change", async () => {
const { syncInsightExtractionAutomation } = await import("@fusion/core");
await runServe(4040, {});
// Simulate settings update
syncInsightExtractionAutomation.mockClear();
mocks.taskStores[0].emit("settings:updated", {
settings: {
insightExtractionEnabled: true,
insightExtractionSchedule: "0 3 * * *",
},
previous: {
insightExtractionEnabled: false,
insightExtractionSchedule: "0 2 * * *",
},
});
expect(syncInsightExtractionAutomation).toHaveBeenCalledTimes(1);
await triggerSignal("SIGINT");
});
it("does not call syncInsightExtractionAutomation for unrelated settings changes", async () => {
const { syncInsightExtractionAutomation } = await import("@fusion/core");
await runServe(4040, {});
// Simulate unrelated settings update
syncInsightExtractionAutomation.mockClear();
mocks.taskStores[0].emit("settings:updated", {
settings: {
maxConcurrent: 5,
},
previous: {
maxConcurrent: 2,
},
});
expect(syncInsightExtractionAutomation).not.toHaveBeenCalled();
await triggerSignal("SIGINT");
});
it("handles syncInsightExtractionAutomation errors gracefully", async () => {
const { syncInsightExtractionAutomation } = await import("@fusion/core");
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
syncInsightExtractionAutomation.mockRejectedValueOnce(new Error("Sync failed"));
await runServe(4040, {});
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("[memory-audit] Failed to sync insight extraction"),
);
consoleSpy.mockRestore();
await triggerSignal("SIGINT");
});
});

View File

@@ -1,8 +1,8 @@
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import type { AddressInfo } from "node:net"; import type { AddressInfo } from "node:net";
import { createInterface } from "node:readline"; import { createInterface } from "node:readline";
import { TaskStore, AutomationStore, CentralCore, AgentStore, getTaskMergeBlocker } from "@fusion/core"; import { TaskStore, AutomationStore, CentralCore, AgentStore, getTaskMergeBlocker, syncInsightExtractionAutomation, INSIGHT_EXTRACTION_SCHEDULE_NAME, processAndAuditInsightExtraction } from "@fusion/core";
import type { Settings, TaskDetail, PrInfo } from "@fusion/core"; import type { Settings, TaskDetail, PrInfo, ScheduledTask, AutomationRunResult } from "@fusion/core";
import { createServer, GitHubClient } from "@fusion/dashboard"; import { createServer, GitHubClient } from "@fusion/dashboard";
import { TriageProcessor, TaskExecutor, Scheduler, AgentSemaphore, WorktreePool, aiMergeTask, UsageLimitPauser, PRIORITY_MERGE, scanIdleWorktrees, cleanupOrphanedWorktrees, NtfyNotifier, PrMonitor, PrCommentHandler, CronRunner, StuckTaskDetector, SelfHealingManager, MissionAutopilot, createAiPromptExecutor, HeartbeatMonitor, HeartbeatTriggerScheduler, type WakeContext } from "@fusion/engine"; import { TriageProcessor, TaskExecutor, Scheduler, AgentSemaphore, WorktreePool, aiMergeTask, UsageLimitPauser, PRIORITY_MERGE, scanIdleWorktrees, cleanupOrphanedWorktrees, NtfyNotifier, PrMonitor, PrCommentHandler, CronRunner, StuckTaskDetector, SelfHealingManager, MissionAutopilot, createAiPromptExecutor, HeartbeatMonitor, HeartbeatTriggerScheduler, type WakeContext } from "@fusion/engine";
import { AuthStorage, DefaultPackageManager, ModelRegistry, SettingsManager, discoverAndLoadExtensions, getAgentDir, createExtensionRuntime } from "@mariozechner/pi-coding-agent"; import { AuthStorage, DefaultPackageManager, ModelRegistry, SettingsManager, discoverAndLoadExtensions, getAgentDir, createExtensionRuntime } from "@mariozechner/pi-coding-agent";
@@ -864,8 +864,65 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
missionAutopilot.setScheduler(scheduler); missionAutopilot.setScheduler(scheduler);
// ── CronRunner: scheduled task execution ────────────────────────── // ── CronRunner: scheduled task execution ──────────────────────────
// Post-run callback for memory insight extraction processing
const onMemoryInsightRunProcessed = async (
schedule: ScheduledTask,
result: AutomationRunResult,
): Promise<void> => {
// Only process the memory insight extraction schedule
if (schedule.name !== INSIGHT_EXTRACTION_SCHEDULE_NAME) {
return;
}
// Extract the AI step output from the result
const stepResults = result.stepResults ?? [];
const aiStep = stepResults.find((sr) => sr.stepName === "Extract Memory Insights");
if (!aiStep) {
console.log(`[memory-audit] No insight extraction step found in ${schedule.name} result`);
return;
}
console.log(`[memory-audit] Processing memory insight extraction run...`);
try {
const auditReport = await processAndAuditInsightExtraction(cwd, {
rawResponse: aiStep.output ?? "",
stepSuccess: aiStep.success,
runAt: result.startedAt,
error: aiStep.error,
});
console.log(
`[memory-audit] ✓ Audit complete — Health: ${auditReport.health}, ` +
`Insights: ${auditReport.insightsMemory.insightCount}`,
);
} catch (err) {
console.error(
`[memory-audit] ✗ Failed to process insight extraction: ${err instanceof Error ? err.message : String(err)}`,
);
}
};
const aiPromptExecutor = await createAiPromptExecutor(cwd); const aiPromptExecutor = await createAiPromptExecutor(cwd);
const cronRunner = new CronRunner(store, automationStore, { aiPromptExecutor }); const cronRunner = new CronRunner(store, automationStore, {
aiPromptExecutor,
onScheduleRunProcessed: onMemoryInsightRunProcessed,
});
// ── Sync insight extraction automation on startup ─────────────────
// Run sync BEFORE starting the cron runner to avoid stale config races.
// This ensures the insight extraction schedule is created/updated/deleted
// before the first tick can execute it.
try {
await syncInsightExtractionAutomation(automationStore, settings);
} catch (err) {
console.error(
`[memory-audit] Failed to sync insight extraction automation: ${err instanceof Error ? err.message : String(err)}`,
);
}
cronRunner.start(); cronRunner.start();
triage.start(); triage.start();
@@ -965,6 +1022,29 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
} }
}); });
// ── Insight extraction automation sync on settings change ─────────
// When insight extraction settings change (enable/disable/schedule/min interval),
// resync the automation schedule without requiring a restart.
registerHandler(store, "settings:updated", async ({ settings: s, previous: prev }) => {
const insightKeys = [
"insightExtractionEnabled",
"insightExtractionSchedule",
"insightExtractionMinIntervalMs",
] as const;
const relevantKeyChanged = insightKeys.some((key) => s[key] !== prev[key]);
if (relevantKeyChanged) {
try {
await syncInsightExtractionAutomation(automationStore, s);
console.log("[memory-audit] Insight extraction automation synced with settings");
} catch (err) {
console.error(
`[memory-audit] Failed to sync insight extraction automation: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
});
// ── Periodic retry: catch failed merges on each poll cycle ──────── // ── Periodic retry: catch failed merges on each poll cycle ────────
// Uses a setTimeout chain so the interval dynamically follows // Uses a setTimeout chain so the interval dynamically follows
// settings.pollIntervalMs without requiring an engine restart. // settings.pollIntervalMs without requiring an engine restart.

View File

@@ -5,7 +5,11 @@ import {
CentralCore, CentralCore,
AgentStore, AgentStore,
getTaskMergeBlocker, getTaskMergeBlocker,
syncInsightExtractionAutomation,
INSIGHT_EXTRACTION_SCHEDULE_NAME,
processAndAuditInsightExtraction,
} from "@fusion/core"; } from "@fusion/core";
import type { ScheduledTask, AutomationRunResult } from "@fusion/core";
import { createServer, GitHubClient } from "@fusion/dashboard"; import { createServer, GitHubClient } from "@fusion/dashboard";
import { import {
TriageProcessor, TriageProcessor,
@@ -562,8 +566,64 @@ export async function runServe(
missionAutopilot.setScheduler(scheduler); missionAutopilot.setScheduler(scheduler);
// Post-run callback for memory insight extraction processing
const onMemoryInsightRunProcessed = async (
schedule: ScheduledTask,
result: AutomationRunResult,
): Promise<void> => {
// Only process the memory insight extraction schedule
if (schedule.name !== INSIGHT_EXTRACTION_SCHEDULE_NAME) {
return;
}
// Extract the AI step output from the result
const stepResults = result.stepResults ?? [];
const aiStep = stepResults.find((sr) => sr.stepName === "Extract Memory Insights");
if (!aiStep) {
console.log(`[memory-audit] No insight extraction step found in ${schedule.name} result`);
return;
}
console.log(`[memory-audit] Processing memory insight extraction run...`);
try {
const auditReport = await processAndAuditInsightExtraction(cwd, {
rawResponse: aiStep.output ?? "",
stepSuccess: aiStep.success,
runAt: result.startedAt,
error: aiStep.error,
});
console.log(
`[memory-audit] ✓ Audit complete — Health: ${auditReport.health}, ` +
`Insights: ${auditReport.insightsMemory.insightCount}`,
);
} catch (err) {
console.error(
`[memory-audit] ✗ Failed to process insight extraction: ${err instanceof Error ? err.message : String(err)}`,
);
}
};
const aiPromptExecutor = await createAiPromptExecutor(cwd); const aiPromptExecutor = await createAiPromptExecutor(cwd);
const cronRunner = new CronRunner(store, automationStore, { aiPromptExecutor }); const cronRunner = new CronRunner(store, automationStore, {
aiPromptExecutor,
onScheduleRunProcessed: onMemoryInsightRunProcessed,
});
// ── Sync insight extraction automation on startup ─────────────────
// Run sync BEFORE starting the cron runner to avoid stale config races.
// This ensures the insight extraction schedule is created/updated/deleted
// before the first tick can execute it.
try {
await syncInsightExtractionAutomation(automationStore, settings);
} catch (err) {
console.error(
`[memory-audit] Failed to sync insight extraction automation: ${err instanceof Error ? err.message : String(err)}`,
);
}
cronRunner.start(); cronRunner.start();
triage.start(); triage.start();
@@ -656,6 +716,29 @@ export async function runServe(
} }
}); });
// ── Insight extraction automation sync on settings change ─────────
// When insight extraction settings change (enable/disable/schedule/min interval),
// resync the automation schedule without requiring a restart.
store.on("settings:updated", async ({ settings: s, previous: prev }) => {
const insightKeys = [
"insightExtractionEnabled",
"insightExtractionSchedule",
"insightExtractionMinIntervalMs",
] as const;
const relevantKeyChanged = insightKeys.some((key) => s[key] !== prev[key]);
if (relevantKeyChanged) {
try {
await syncInsightExtractionAutomation(automationStore, s);
console.log("[memory-audit] Insight extraction automation synced with settings");
} catch (err) {
console.error(
`[memory-audit] Failed to sync insight extraction automation: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
});
let mergeRetryTimer: ReturnType<typeof setTimeout> | null = null; let mergeRetryTimer: ReturnType<typeof setTimeout> | null = null;
async function scheduleMergeRetry(): Promise<void> { async function scheduleMergeRetry(): Promise<void> {
const currentSettings = await store.getSettings().catch(() => settings); const currentSettings = await store.getSettings().catch(() => settings);

View File

@@ -239,6 +239,7 @@ export {
export { export {
MEMORY_WORKING_PATH, MEMORY_WORKING_PATH,
MEMORY_INSIGHTS_PATH, MEMORY_INSIGHTS_PATH,
MEMORY_AUDIT_PATH,
DEFAULT_INSIGHT_SCHEDULE, DEFAULT_INSIGHT_SCHEDULE,
DEFAULT_MIN_INTERVAL_MS, DEFAULT_MIN_INTERVAL_MS,
MIN_INSIGHT_GROWTH_CHARS, MIN_INSIGHT_GROWTH_CHARS,
@@ -246,6 +247,8 @@ export {
readWorkingMemory, readWorkingMemory,
readInsightsMemory, readInsightsMemory,
writeInsightsMemory, writeInsightsMemory,
readMemoryAudit,
writeMemoryAudit,
buildInsightExtractionPrompt, buildInsightExtractionPrompt,
parseInsightExtractionResponse, parseInsightExtractionResponse,
mergeInsights, mergeInsights,
@@ -253,11 +256,18 @@ export {
getDefaultInsightsTemplate, getDefaultInsightsTemplate,
createInsightExtractionAutomation, createInsightExtractionAutomation,
syncInsightExtractionAutomation, syncInsightExtractionAutomation,
processInsightExtractionRun,
processAndAuditInsightExtraction,
generateMemoryAudit,
renderMemoryAuditMarkdown,
} from "./memory-insights.js"; } from "./memory-insights.js";
export type { export type {
MemoryInsightCategory, MemoryInsightCategory,
MemoryInsight, MemoryInsight,
InsightExtractionResult, InsightExtractionResult,
MemoryAuditCheck,
MemoryAuditReport,
ProcessRunInput,
} from "./memory-insights.js"; } from "./memory-insights.js";
export { export {

View File

@@ -536,3 +536,401 @@ describe("memory-insights", () => {
}); });
}); });
}); });
// ── Audit File Operations ──────────────────────────────────────────────
import {
MEMORY_AUDIT_PATH,
readMemoryAudit,
writeMemoryAudit,
} from "./memory-insights.js";
describe("memory-insights audit file operations", () => {
let tempDir: string;
beforeEach(async () => {
tempDir = mkdtempSync(join(tmpdir(), "kb-memory-audit-test-"));
await mkdir(join(tempDir, ".fusion"), { recursive: true });
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
describe("readMemoryAudit", () => {
it("should return null when audit file does not exist", async () => {
const result = await readMemoryAudit(tempDir);
expect(result).toBeNull();
});
it("should return content when audit file exists", async () => {
const content = "# Memory Audit Report\n\nGenerated...";
writeFileSync(join(tempDir, MEMORY_AUDIT_PATH), content);
const result = await readMemoryAudit(tempDir);
expect(result).toBe(content);
});
});
describe("writeMemoryAudit", () => {
it("should create the audit file", async () => {
const content = "# Memory Audit Report\n\nGenerated...";
await writeMemoryAudit(tempDir, content);
const filePath = join(tempDir, MEMORY_AUDIT_PATH);
expect(existsSync(filePath)).toBe(true);
expect(readFileSync(filePath, "utf-8")).toBe(content);
});
it("should overwrite existing content", async () => {
writeFileSync(join(tempDir, MEMORY_AUDIT_PATH), "old content");
await writeMemoryAudit(tempDir, "new content");
expect(readFileSync(join(tempDir, MEMORY_AUDIT_PATH), "utf-8")).toBe("new content");
});
it("should create .fusion directory if it does not exist", async () => {
const newDir = join(tempDir, "new-project");
await mkdir(newDir, { recursive: true });
await writeMemoryAudit(newDir, "test content");
expect(existsSync(join(newDir, MEMORY_AUDIT_PATH))).toBe(true);
});
});
});
// ── Run Processing ─────────────────────────────────────────────────────
import {
processInsightExtractionRun,
processAndAuditInsightExtraction,
} from "./memory-insights.js";
describe("memory-insights run processing", () => {
let tempDir: string;
beforeEach(async () => {
tempDir = mkdtempSync(join(tmpdir(), "kb-memory-run-test-"));
await mkdir(join(tempDir, ".fusion"), { recursive: true });
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
describe("processInsightExtractionRun", () => {
it("should parse and merge successful extraction", async () => {
// No existing insights
const rawResponse = JSON.stringify({
summary: "Found 2 new insights",
insights: [
{ category: "pattern", content: "Use TypeScript for type safety" },
{ category: "pitfall", content: "Avoid any type assertions" },
],
});
const result = await processInsightExtractionRun(tempDir, {
rawResponse,
stepSuccess: true,
runAt: new Date().toISOString(),
});
expect(result.insights).toHaveLength(2);
expect(result.summary).toBe("Found 2 new insights");
expect(result.newInsightCount).toBe(2);
expect(result.duplicateCount).toBe(0);
// Verify insights file was written
const insightsPath = join(tempDir, MEMORY_INSIGHTS_PATH);
expect(existsSync(insightsPath)).toBe(true);
const content = readFileSync(insightsPath, "utf-8");
expect(content).toContain("Use TypeScript for type safety");
expect(content).toContain("Avoid any type assertions");
});
it("should handle existing insights without duplicating", async () => {
// Create existing insights
const existingInsights = `# Memory Insights
## Patterns
- Already existing pattern
## Last Updated: 2026-01-01
`;
writeFileSync(join(tempDir, MEMORY_INSIGHTS_PATH), existingInsights);
const rawResponse = JSON.stringify({
summary: "Found 1 new insight",
insights: [
{ category: "pattern", content: "Already existing pattern" }, // duplicate
{ category: "principle", content: "New principle" }, // new
],
});
const result = await processInsightExtractionRun(tempDir, {
rawResponse,
stepSuccess: true,
runAt: new Date().toISOString(),
});
expect(result.insights).toHaveLength(2);
expect(result.duplicateCount).toBe(1);
expect(result.newInsightCount).toBe(1);
// Verify only new insight was added
const content = readFileSync(join(tempDir, MEMORY_INSIGHTS_PATH), "utf-8");
expect(content).toContain("Already existing pattern");
expect(content).toContain("New principle");
// Should only have "Already existing pattern" once
expect(content.match(/Already existing pattern/g)?.length).toBe(1);
});
it("should handle malformed JSON gracefully", async () => {
const result = await processInsightExtractionRun(tempDir, {
rawResponse: "not valid json at all",
stepSuccess: true,
runAt: new Date().toISOString(),
});
expect(result.insights).toHaveLength(0);
expect(result.summary).toContain("Parse error");
});
it("should handle failed step", async () => {
const result = await processInsightExtractionRun(tempDir, {
rawResponse: "",
stepSuccess: false,
runAt: new Date().toISOString(),
error: "AI timeout",
});
expect(result.insights).toHaveLength(0);
expect(result.summary).toContain("AI timeout");
});
it("should preserve existing insights on failure", async () => {
// Create existing insights
const existingInsights = `# Memory Insights
## Patterns
- Important existing pattern
## Last Updated: 2026-01-01
`;
writeFileSync(join(tempDir, MEMORY_INSIGHTS_PATH), existingInsights);
// Process with malformed JSON
await processInsightExtractionRun(tempDir, {
rawResponse: "invalid",
stepSuccess: true,
runAt: new Date().toISOString(),
});
// Existing insights should be preserved
const content = readFileSync(join(tempDir, MEMORY_INSIGHTS_PATH), "utf-8");
expect(content).toContain("Important existing pattern");
});
});
describe("processAndAuditInsightExtraction", () => {
it("should process run and generate audit report", async () => {
// Create working memory
writeFileSync(
join(tempDir, MEMORY_WORKING_PATH),
"## Architecture\n\nSome architecture notes\n## Conventions\n\nSome conventions",
);
const rawResponse = JSON.stringify({
summary: "Extracted insights",
insights: [{ category: "pattern", content: "Test pattern" }],
});
const report = await processAndAuditInsightExtraction(tempDir, {
rawResponse,
stepSuccess: true,
runAt: new Date().toISOString(),
});
expect(report).toBeDefined();
expect(report.health).toBeDefined();
expect(report.checks).toBeDefined();
expect(report.checks.length).toBeGreaterThan(0);
// Verify audit file was written
const auditPath = join(tempDir, MEMORY_AUDIT_PATH);
expect(existsSync(auditPath)).toBe(true);
const auditContent = readFileSync(auditPath, "utf-8");
expect(auditContent).toContain("Memory Audit Report");
});
it("should generate audit with failed extraction", async () => {
writeFileSync(
join(tempDir, MEMORY_WORKING_PATH),
"## Architecture\n\nNotes",
);
const report = await processAndAuditInsightExtraction(tempDir, {
rawResponse: "",
stepSuccess: false,
runAt: new Date().toISOString(),
error: "Step timed out",
});
expect(report.extraction.success).toBe(false);
expect(report.extraction.error).toBe("Step timed out");
expect(report.checks).toBeDefined();
});
});
});
// ── Audit Generation ──────────────────────────────────────────────────
import {
generateMemoryAudit,
renderMemoryAuditMarkdown,
} from "./memory-insights.js";
describe("memory-insights audit generation", () => {
let tempDir: string;
beforeEach(async () => {
tempDir = mkdtempSync(join(tmpdir(), "kb-memory-audit-gen-test-"));
await mkdir(join(tempDir, ".fusion"), { recursive: true });
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
describe("generateMemoryAudit", () => {
it("should detect missing working memory", async () => {
const report = await generateMemoryAudit(tempDir);
const check = report.checks.find((c) => c.id === "working-memory-exists");
expect(check).toBeDefined();
expect(check!.passed).toBe(false);
});
it("should detect present working memory", async () => {
writeFileSync(
join(tempDir, MEMORY_WORKING_PATH),
"## Architecture\n\nSome architecture\n## Conventions\n\nSome conventions",
);
const report = await generateMemoryAudit(tempDir);
const check = report.checks.find((c) => c.id === "working-memory-exists");
expect(check!.passed).toBe(true);
expect(report.workingMemory.exists).toBe(true);
expect(report.workingMemory.size).toBeGreaterThan(0);
});
it("should count insights in insights memory", async () => {
writeFileSync(
join(tempDir, MEMORY_INSIGHTS_PATH),
`# Memory Insights
## Patterns
- Pattern 1
- Pattern 2
## Principles
- Principle 1
## Last Updated: 2026-04-09
`,
);
const report = await generateMemoryAudit(tempDir);
expect(report.insightsMemory.exists).toBe(true);
expect(report.insightsMemory.categories.pattern).toBe(2);
expect(report.insightsMemory.categories.principle).toBe(1);
expect(report.insightsMemory.insightCount).toBe(3);
});
it("should include extraction info when provided", async () => {
writeFileSync(join(tempDir, MEMORY_WORKING_PATH), "## Architecture\n\nNotes");
const report = await generateMemoryAudit(tempDir, {
runAt: new Date().toISOString(),
success: true,
insightCount: 5,
duplicateCount: 2,
skippedCount: 0,
summary: "Found 5 new patterns",
});
expect(report.extraction.runAt).toBeTruthy();
expect(report.extraction.success).toBe(true);
expect(report.extraction.insightCount).toBe(5);
expect(report.extraction.duplicateCount).toBe(2);
});
it("should calculate health status", async () => {
// No files = issues
const noFilesReport = await generateMemoryAudit(tempDir);
expect(["healthy", "warning", "issues"]).toContain(noFilesReport.health);
// All good
writeFileSync(
join(tempDir, MEMORY_WORKING_PATH),
"## Architecture\n\n## Conventions\n\n## Pitfalls\n\nNotes",
);
writeFileSync(
join(tempDir, MEMORY_INSIGHTS_PATH),
`# Memory Insights
## Patterns
- Pattern 1
## Last Updated: 2026-04-09
`,
);
const goodReport = await generateMemoryAudit(tempDir, {
runAt: new Date().toISOString(),
success: true,
insightCount: 1,
duplicateCount: 0,
skippedCount: 0,
summary: "Found patterns",
});
expect(goodReport.health).toBe("healthy");
});
});
describe("renderMemoryAuditMarkdown", () => {
it("should render a complete audit report", async () => {
writeFileSync(join(tempDir, MEMORY_WORKING_PATH), "## Architecture\n\nNotes");
writeFileSync(join(tempDir, MEMORY_INSIGHTS_PATH), "# Memory Insights\n\n## Patterns\n- Pattern 1\n\n## Last Updated: 2026-04-09");
const report = await generateMemoryAudit(tempDir, {
runAt: new Date().toISOString(),
success: true,
insightCount: 1,
duplicateCount: 0,
skippedCount: 0,
summary: "Test summary",
});
const markdown = renderMemoryAuditMarkdown(report);
expect(markdown).toContain("# Memory Audit Report");
expect(markdown).toContain("## Working Memory");
expect(markdown).toContain("## Insights Memory");
expect(markdown).toContain("## Last Extraction");
expect(markdown).toContain("## Audit Checks");
expect(markdown).toContain("Health:");
});
it("should handle empty report", async () => {
const report = await generateMemoryAudit(tempDir);
const markdown = renderMemoryAuditMarkdown(report);
expect(markdown).toContain("Memory Audit Report");
expect(markdown).toContain("Working Memory");
expect(markdown).toContain("Insights Memory");
});
});
});

View File

@@ -78,6 +78,9 @@ export const MEMORY_WORKING_PATH = ".fusion/memory.md";
/** Path to insights memory relative to project root. */ /** Path to insights memory relative to project root. */
export const MEMORY_INSIGHTS_PATH = ".fusion/memory-insights.md"; export const MEMORY_INSIGHTS_PATH = ".fusion/memory-insights.md";
/** Path to memory audit report relative to project root. */
export const MEMORY_AUDIT_PATH = ".fusion/memory-audit.md";
/** Default cron schedule for insight extraction: daily at 2 AM. */ /** Default cron schedule for insight extraction: daily at 2 AM. */
export const DEFAULT_INSIGHT_SCHEDULE = "0 2 * * *"; export const DEFAULT_INSIGHT_SCHEDULE = "0 2 * * *";
@@ -122,6 +125,67 @@ export interface InsightExtractionResult {
extractedAt: string; extractedAt: string;
} }
// ── Run Processing Types ───────────────────────────────────────────────
/** Individual audit check result. */
export interface MemoryAuditCheck {
/** Unique identifier for this check. */
id: string;
/** Human-readable check name. */
name: string;
/** Whether the check passed. */
passed: boolean;
/** Details about the check result. */
details: string;
}
/** Result of a memory audit run. */
export interface MemoryAuditReport {
/** ISO-8601 timestamp of the audit. */
generatedAt: string;
/** Working memory file status. */
workingMemory: {
exists: boolean;
size: number;
sectionCount: number;
lastModified?: string;
};
/** Insights memory file status. */
insightsMemory: {
exists: boolean;
size: number;
insightCount: number;
categories: Record<MemoryInsightCategory, number>;
lastUpdated?: string;
};
/** Extraction metadata. */
extraction: {
runAt: string;
success: boolean;
insightCount: number;
duplicateCount: number;
skippedCount: number;
summary: string;
error?: string;
};
/** Individual audit checks. */
checks: MemoryAuditCheck[];
/** Overall health status. */
health: "healthy" | "warning" | "issues";
}
/** Input for processing an insight extraction run. */
export interface ProcessRunInput {
/** Raw AI response text from the insight extraction step. */
rawResponse: string;
/** Whether the AI step itself succeeded. */
stepSuccess: boolean;
/** Timestamp of the run. */
runAt: string;
/** Optional error message if the step failed. */
error?: string;
}
// ── File I/O ───────────────────────────────────────────────────────── // ── File I/O ─────────────────────────────────────────────────────────
/** /**
@@ -176,6 +240,39 @@ export async function writeInsightsMemory(rootDir: string, content: string): Pro
await writeFile(filePath, content, "utf-8"); await writeFile(filePath, content, "utf-8");
} }
/**
* Read the memory audit file (`memory-audit.md`).
*
* Returns `null` if the file does not exist.
*
* @param rootDir - Absolute path to the project root directory.
* @returns The audit file content, or null if not found.
*/
export async function readMemoryAudit(rootDir: string): Promise<string | null> {
const filePath = join(rootDir, MEMORY_AUDIT_PATH);
if (!existsSync(filePath)) {
return null;
}
return readFile(filePath, "utf-8");
}
/**
* Write the memory audit file (`memory-audit.md`).
*
* Creates the `.fusion` directory if it does not exist.
*
* @param rootDir - Absolute path to the project root directory.
* @param content - The markdown content to write.
*/
export async function writeMemoryAudit(rootDir: string, content: string): Promise<void> {
const filePath = join(rootDir, MEMORY_AUDIT_PATH);
const dir = join(rootDir, ".fusion");
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true });
}
await writeFile(filePath, content, "utf-8");
}
// ── AI Prompt Construction ─────────────────────────────────────────── // ── AI Prompt Construction ───────────────────────────────────────────
/** /**
@@ -621,3 +718,606 @@ export async function syncInsightExtractionAutomation(
return await automationStore.createSchedule(input); return await automationStore.createSchedule(input);
} }
} }
// ── Run Processing ─────────────────────────────────────────────────────
/**
* Process an insight extraction run and persist results.
*
* This function takes the raw AI response from an insight extraction step,
* parses it, merges new insights into the insights memory file, and generates
* an audit report.
*
* **Safety guarantees:**
* - Malformed AI output does not destroy existing insights content
* - Failures are surfaced as controlled errors and keep prior files intact
* - All operations are atomic where possible
*
* @param rootDir - Absolute path to the project root directory.
* @param input - The run processing input containing raw response and metadata.
* @returns The processed insight extraction result, or throws on failure.
* @throws Error if processing fails after all recovery attempts.
*/
export async function processInsightExtractionRun(
rootDir: string,
input: ProcessRunInput,
): Promise<InsightExtractionResult & { newInsightCount: number; duplicateCount: number }> {
const { rawResponse, stepSuccess, runAt, error: stepError } = input;
// Read existing insights before any modification
const existingInsights = await readInsightsMemory(rootDir);
let parsedResult: InsightExtractionResult;
let parseError: string | undefined;
// Try to parse the AI response
if (stepSuccess && rawResponse.trim()) {
try {
parsedResult = parseInsightExtractionResponse(rawResponse);
} catch (err) {
parseError = err instanceof Error ? err.message : String(err);
// Create a fallback result that indicates the parse failure
parsedResult = {
insights: [],
summary: `Parse error: ${parseError}`,
extractedAt: runAt,
};
}
} else {
parsedResult = {
insights: [],
summary: stepError || "Step did not produce output",
extractedAt: runAt,
};
}
// Calculate duplicate count before merging
const duplicateCount = countDuplicateInsights(existingInsights ?? "", parsedResult.insights);
const newInsights = parsedResult.insights;
// Merge new insights into insights memory
let newInsightsContent: string;
if (existingInsights !== null) {
newInsightsContent = mergeInsights(existingInsights, newInsights);
} else {
newInsightsContent = mergeInsights("", newInsights);
}
// Write the updated insights file
// Note: We write unconditionally to capture even empty merges
// The merge function returns existing unchanged when there are no new insights
await writeInsightsMemory(rootDir, newInsightsContent);
// Count actual new insights added (by comparing before/after)
const beforeCount = countInsightsInMarkdown(existingInsights ?? "");
const afterCount = countInsightsInMarkdown(newInsightsContent);
const newInsightCount = Math.max(0, afterCount - beforeCount);
return {
...parsedResult,
newInsightCount,
duplicateCount,
};
}
/**
* Count insights that would be duplicates against existing content.
*
* @param existingContent - The existing insights markdown content.
* @param newInsights - Array of new insights to check.
* @returns The number of insights that already exist (case-insensitive match).
*/
function countDuplicateInsights(
existingContent: string,
newInsights: MemoryInsight[],
): number {
if (!existingContent || newInsights.length === 0) {
return 0;
}
let duplicateCount = 0;
for (const insight of newInsights) {
if (existingContent.toLowerCase().includes(insight.content.toLowerCase())) {
duplicateCount++;
}
}
return duplicateCount;
}
/**
* Count the number of insights (bullet points) in a markdown string.
*
* @param markdown - The markdown content to count insights in.
* @returns The number of insight bullet points found.
*/
function countInsightsInMarkdown(markdown: string): number {
if (!markdown) return 0;
// Count lines that start with "- " and are inside section headers
// Simple heuristic: count all "- " lines that are indented or follow section headers
const lines = markdown.split("\n");
let inSection = false;
let count = 0;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith("## ")) {
inSection = true;
} else if (trimmed.startsWith("# ") || trimmed.startsWith("<!--")) {
// Title or comment, not a section
} else if (inSection && trimmed.startsWith("- ")) {
count++;
}
}
return count;
}
// ── Audit Generation ─────────────────────────────────────────────────────
/**
* Generate a memory audit report.
*
* The audit checks include:
* - Working memory file presence and structure
* - Insights memory file presence and content
* - Required section headers in working memory
* - Memory size and change metadata
* - Duplicate/empty insight handling
* - Extraction timestamp and summary
*
* @param rootDir - Absolute path to the project root directory.
* @param lastExtraction - Optional information about the last extraction run.
* @returns The generated audit report.
*/
export async function generateMemoryAudit(
rootDir: string,
lastExtraction?: {
runAt: string;
success: boolean;
insightCount: number;
duplicateCount: number;
skippedCount: number;
summary: string;
error?: string;
},
): Promise<MemoryAuditReport> {
const checks: MemoryAuditCheck[] = [];
const now = new Date().toISOString();
// ── Check 1: Working memory file presence ──────────────────────────
const workingMemoryPath = join(rootDir, MEMORY_WORKING_PATH);
const workingMemoryExists = existsSync(workingMemoryPath);
let workingMemorySize = 0;
let workingMemorySectionCount = 0;
let workingMemoryContent = "";
if (workingMemoryExists) {
try {
workingMemoryContent = await readFile(workingMemoryPath, "utf-8");
workingMemorySize = workingMemoryContent.length;
workingMemorySectionCount = countMarkdownSections(workingMemoryContent);
checks.push({
id: "working-memory-exists",
name: "Working memory file exists",
passed: true,
details: `File exists with ${workingMemorySize} characters and ${workingMemorySectionCount} sections`,
});
} catch (err) {
checks.push({
id: "working-memory-exists",
name: "Working memory file exists",
passed: false,
details: `File exists but could not be read: ${err instanceof Error ? err.message : String(err)}`,
});
}
} else {
checks.push({
id: "working-memory-exists",
name: "Working memory file exists",
passed: false,
details: "File .fusion/memory.md does not exist",
});
}
// ── Check 2: Working memory has meaningful content ──────────────────
if (workingMemoryExists) {
const hasContent = workingMemorySize > 0;
const hasSections = workingMemorySectionCount >= 2;
checks.push({
id: "working-memory-content",
name: "Working memory has meaningful content",
passed: hasContent && hasSections,
details: hasContent
? `Has ${workingMemorySize} characters with ${workingMemorySectionCount} sections`
: "Working memory is empty",
});
}
// ── Check 3: Working memory has required sections ──────────────────
if (workingMemoryContent) {
const requiredSections = ["Architecture", "Conventions", "Pitfalls"];
const foundSections = requiredSections.filter((section) =>
workingMemoryContent.includes(`## ${section}`),
);
checks.push({
id: "working-memory-sections",
name: "Working memory has required sections",
passed: foundSections.length >= 2, // At least 2 of 3 recommended
details: foundSections.length >= 2
? `Found sections: ${foundSections.join(", ")}`
: `Missing sections: ${requiredSections.filter((s) => !foundSections.includes(s)).join(", ")}`,
});
}
// ── Check 4: Insights memory file presence ──────────────────────────
const insightsMemoryPath = join(rootDir, MEMORY_INSIGHTS_PATH);
const insightsMemoryExists = existsSync(insightsMemoryPath);
let insightsMemorySize = 0;
let insightsMemoryContent = "";
const categoryCounts: Record<MemoryInsightCategory, number> = {
pattern: 0,
principle: 0,
convention: 0,
pitfall: 0,
context: 0,
};
let lastUpdated: string | undefined;
if (insightsMemoryExists) {
try {
insightsMemoryContent = await readFile(insightsMemoryPath, "utf-8");
insightsMemorySize = insightsMemoryContent.length;
// Count insights by category
for (const [category, header] of Object.entries({
pattern: "## Patterns",
principle: "## Principles",
convention: "## Conventions",
pitfall: "## Pitfalls",
context: "## Context",
})) {
categoryCounts[category as MemoryInsightCategory] = countInsightsInSection(
insightsMemoryContent,
header,
);
}
// Find last updated timestamp
const lastUpdatedMatch = insightsMemoryContent.match(/## Last Updated:\s*(.+)$/m);
if (lastUpdatedMatch) {
lastUpdated = lastUpdatedMatch[1].trim();
}
checks.push({
id: "insights-memory-exists",
name: "Insights memory file exists",
passed: true,
details: `File exists with ${insightsMemorySize} characters`,
});
} catch (err) {
checks.push({
id: "insights-memory-exists",
name: "Insights memory file exists",
passed: false,
details: `File exists but could not be read: ${err instanceof Error ? err.message : String(err)}`,
});
}
} else {
checks.push({
id: "insights-memory-exists",
name: "Insights memory file exists",
passed: false,
details: "File .fusion/memory-insights.md does not exist yet",
});
}
// ── Check 5: Insights memory has content ───────────────────────────
if (insightsMemoryExists) {
const totalInsights = Object.values(categoryCounts).reduce((a, b) => a + b, 0);
checks.push({
id: "insights-memory-content",
name: "Insights memory has content",
passed: totalInsights > 0,
details: totalInsights > 0
? `Contains ${totalInsights} insights across categories: patterns=${categoryCounts.pattern}, principles=${categoryCounts.principle}, conventions=${categoryCounts.convention}, pitfalls=${categoryCounts.pitfall}, context=${categoryCounts.context}`
: "No insights extracted yet",
});
}
// ── Check 6: Recent extraction activity ───────────────────────────
if (lastExtraction) {
const extractionAge = Date.now() - new Date(lastExtraction.runAt).getTime();
const oneWeekMs = 7 * 24 * 60 * 60 * 1000;
checks.push({
id: "recent-extraction",
name: "Recent extraction activity",
passed: lastExtraction.success && extractionAge < oneWeekMs,
details: lastExtraction.success
? `Last successful extraction ${formatTimeAgo(lastExtraction.runAt)} (${lastExtraction.insightCount} insights, ${lastExtraction.duplicateCount} duplicates skipped)`
: `Last extraction failed: ${lastExtraction.error || "Unknown error"}`,
});
// Check 7: Extraction summary quality
checks.push({
id: "extraction-summary",
name: "Extraction produces meaningful summaries",
passed: lastExtraction.success && lastExtraction.summary.length > 10,
details: lastExtraction.success
? `Summary: "${lastExtraction.summary.slice(0, 100)}${lastExtraction.summary.length > 100 ? "..." : ""}"`
: "No meaningful summary available",
});
} else {
checks.push({
id: "recent-extraction",
name: "Recent extraction activity",
passed: false,
details: "No extraction runs recorded",
});
}
// ── Calculate overall health ──────────────────────────────────────
const failedChecks = checks.filter((c) => !c.passed);
let health: "healthy" | "warning" | "issues" = "healthy";
if (failedChecks.length >= 3) {
health = "issues";
} else if (failedChecks.length >= 1) {
// Critical checks that affect health
const criticalFailed = failedChecks.filter((c) =>
["working-memory-exists", "insights-memory-exists", "recent-extraction"].includes(c.id),
);
health = criticalFailed.length > 0 ? "issues" : "warning";
}
return {
generatedAt: now,
workingMemory: {
exists: workingMemoryExists,
size: workingMemorySize,
sectionCount: workingMemorySectionCount,
},
insightsMemory: {
exists: insightsMemoryExists,
size: insightsMemorySize,
insightCount: Object.values(categoryCounts).reduce((a, b) => a + b, 0),
categories: categoryCounts,
lastUpdated,
},
extraction: lastExtraction
? {
runAt: lastExtraction.runAt,
success: lastExtraction.success,
insightCount: lastExtraction.insightCount,
duplicateCount: lastExtraction.duplicateCount,
skippedCount: lastExtraction.skippedCount,
summary: lastExtraction.summary,
error: lastExtraction.error,
}
: {
runAt: "",
success: false,
insightCount: 0,
duplicateCount: 0,
skippedCount: 0,
summary: "No extraction runs recorded",
},
checks,
health,
};
}
/**
* Count markdown sections (## headers) in content.
*
* @param content - The markdown content to analyze.
* @returns The number of level-2 section headers.
*/
function countMarkdownSections(content: string): number {
const matches = content.match(/^##\s+.+$/gm);
return matches ? matches.length : 0;
}
/**
* Count insight bullet points in a specific section.
*
* @param content - The markdown content.
* @param sectionHeader - The section header to look for (e.g., "## Patterns").
* @returns The number of insights (bullet points) in that section.
*/
function countInsightsInSection(content: string, sectionHeader: string): number {
const headerIndex = content.indexOf(sectionHeader);
if (headerIndex === -1) return 0;
// Find the next section header or end of file
const afterHeader = headerIndex + sectionHeader.length;
const nextSection = content.indexOf("\n## ", afterHeader);
const sectionContent =
nextSection === -1
? content.slice(afterHeader)
: content.slice(afterHeader, nextSection);
// Count lines starting with "- "
const lines = sectionContent.split("\n");
return lines.filter((line) => line.trim().startsWith("- ")).length;
}
/**
* Format a timestamp as a human-readable "time ago" string.
*
* @param isoTimestamp - ISO-8601 timestamp.
* @returns Human-readable time ago string.
*/
function formatTimeAgo(isoTimestamp: string): string {
const date = new Date(isoTimestamp);
const now = Date.now();
const diffMs = now - date.getTime();
const minutes = Math.floor(diffMs / (1000 * 60));
const hours = Math.floor(diffMs / (1000 * 60 * 60));
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
return `${days} day${days === 1 ? "" : "s"} ago`;
}
/**
* Render a memory audit report as markdown.
*
* @param report - The audit report to render.
* @returns Markdown-formatted audit report.
*/
export function renderMemoryAuditMarkdown(report: MemoryAuditReport): string {
const lines: string[] = [];
lines.push(`# Memory Audit Report`);
lines.push(``);
lines.push(`**Generated:** ${report.generatedAt}`);
lines.push(`**Health:** ${getHealthEmoji(report.health)} ${report.health}`);
lines.push(``);
// Working Memory Section
lines.push(`## Working Memory`);
lines.push(`- **Exists:** ${report.workingMemory.exists ? "✓" : "✗"}`);
lines.push(`- **Size:** ${report.workingMemory.size.toLocaleString()} characters`);
lines.push(`- **Sections:** ${report.workingMemory.sectionCount}`);
lines.push(``);
// Insights Memory Section
lines.push(`## Insights Memory`);
lines.push(`- **Exists:** ${report.insightsMemory.exists ? "✓" : "✗"}`);
lines.push(`- **Size:** ${report.insightsMemory.size.toLocaleString()} characters`);
lines.push(`- **Total Insights:** ${report.insightsMemory.insightCount}`);
lines.push(`- **By Category:**`);
for (const [category, count] of Object.entries(report.insightsMemory.categories)) {
lines.push(` - ${category}: ${count}`);
}
if (report.insightsMemory.lastUpdated) {
lines.push(`- **Last Updated:** ${report.insightsMemory.lastUpdated}`);
}
lines.push(``);
// Extraction Section
lines.push(`## Last Extraction`);
if (report.extraction.runAt) {
lines.push(`- **Run At:** ${report.extraction.runAt}`);
lines.push(`- **Success:** ${report.extraction.success ? "✓" : "✗"}`);
lines.push(`- **Insights Extracted:** ${report.extraction.insightCount}`);
lines.push(`- **Duplicates Skipped:** ${report.extraction.duplicateCount}`);
lines.push(`- **Skipped:** ${report.extraction.skippedCount}`);
lines.push(`- **Summary:** ${report.extraction.summary}`);
if (report.extraction.error) {
lines.push(`- **Error:** ${report.extraction.error}`);
}
} else {
lines.push(`- No extraction runs recorded`);
}
lines.push(``);
// Checks Section
lines.push(`## Audit Checks`);
for (const check of report.checks) {
const icon = check.passed ? "✓" : "✗";
lines.push(`### ${icon} ${check.name}`);
lines.push(`**${check.details}**`);
lines.push(``);
}
return lines.join("\n");
}
/**
* Get the emoji for a health status.
*
* @param health - The health status.
* @returns The corresponding emoji.
*/
function getHealthEmoji(health: "healthy" | "warning" | "issues"): string {
switch (health) {
case "healthy":
return "✅";
case "warning":
return "⚠️";
case "issues":
return "❌";
}
}
/**
* Process an insight extraction run and generate an audit report.
*
* This is a convenience function that combines:
* 1. Processing the insight extraction run (parsing, merging, writing)
* 2. Generating an audit report
* 3. Writing the audit report to disk
*
* All operations are performed as best-effort side effects. Failures are
* logged but do not cause the function to throw. The audit report always
* reflects the current state of the memory files.
*
* @param rootDir - Absolute path to the project root directory.
* @param input - The run processing input containing raw response and metadata.
* @returns The audit report generated after processing (never throws).
*/
export async function processAndAuditInsightExtraction(
rootDir: string,
input: ProcessRunInput,
): Promise<MemoryAuditReport> {
// Track extraction info for the audit
let extractionInfo: {
runAt: string;
success: boolean;
insightCount: number;
duplicateCount: number;
skippedCount: number;
summary: string;
error?: string;
};
try {
// Process the run
const result = await processInsightExtractionRun(rootDir, input);
extractionInfo = {
runAt: input.runAt,
success: input.stepSuccess,
insightCount: result.newInsightCount,
duplicateCount: result.duplicateCount,
skippedCount: result.insights.length - result.newInsightCount,
summary: result.summary,
error: input.error,
};
} catch (err) {
// On processing failure, generate audit with error info
extractionInfo = {
runAt: input.runAt,
success: false,
insightCount: 0,
duplicateCount: 0,
skippedCount: 0,
summary: `Processing failed: ${err instanceof Error ? err.message : String(err)}`,
error: err instanceof Error ? err.message : String(err),
};
}
// Generate the audit report
const auditReport = await generateMemoryAudit(rootDir, extractionInfo);
// Write the audit report (best-effort)
try {
const auditMarkdown = renderMemoryAuditMarkdown(auditReport);
await writeMemoryAudit(rootDir, auditMarkdown);
} catch (err) {
// Best-effort: log but don't throw
console.error(
`[memory-audit] Failed to write audit report: ${err instanceof Error ? err.message : String(err)}`,
);
}
return auditReport;
}

View File

@@ -276,6 +276,120 @@ describe("CronRunner", () => {
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.output).toContain("err"); expect(result.output).toContain("err");
}); });
it("calls recordRun before callback on success", async () => {
const store = createMockStore();
const schedule = createMockSchedule({ command: "echo success" });
const automationStore = createMockAutomationStore([schedule]);
const callOrder: string[] = [];
const onProcessed = vi.fn().mockImplementation(() => {
callOrder.push("callback");
});
runner = new CronRunner(store, automationStore, { onScheduleRunProcessed: onProcessed });
await runner.executeSchedule(schedule);
expect(callOrder).toContain("callback");
// recordRun should have been called first
expect(automationStore.recordRun).toHaveBeenCalledBefore(onProcessed);
});
it("invokes callback on successful execution", async () => {
const store = createMockStore();
const schedule = createMockSchedule({ command: "echo success" });
const automationStore = createMockAutomationStore([schedule]);
const onProcessed = vi.fn();
runner = new CronRunner(store, automationStore, { onScheduleRunProcessed: onProcessed });
await runner.executeSchedule(schedule);
expect(onProcessed).toHaveBeenCalledTimes(1);
expect(onProcessed).toHaveBeenCalledWith(
schedule,
expect.objectContaining({ success: true }),
);
});
it("invokes callback on failed execution", async () => {
const store = createMockStore();
const schedule = createMockSchedule({ command: "exit 1" });
const automationStore = createMockAutomationStore([schedule]);
const onProcessed = vi.fn();
runner = new CronRunner(store, automationStore, { onScheduleRunProcessed: onProcessed });
await runner.executeSchedule(schedule);
expect(onProcessed).toHaveBeenCalledTimes(1);
expect(onProcessed).toHaveBeenCalledWith(
schedule,
expect.objectContaining({ success: false }),
);
});
it("does not invoke callback when not provided", async () => {
const store = createMockStore();
const schedule = createMockSchedule({ command: "echo test" });
const automationStore = createMockAutomationStore([schedule]);
runner = new CronRunner(store, automationStore);
// Should not throw
await runner.executeSchedule(schedule);
});
it("callback errors do not throw or alter returned result", async () => {
const store = createMockStore();
const schedule = createMockSchedule({ command: "echo success" });
const automationStore = createMockAutomationStore([schedule]);
const onProcessed = vi.fn().mockRejectedValue(new Error("callback failed"));
runner = new CronRunner(store, automationStore, { onScheduleRunProcessed: onProcessed });
// Should not throw
const result = await runner.executeSchedule(schedule);
expect(result.success).toBe(true);
expect(result.output).toContain("success");
});
it("callback errors do not prevent recordRun", async () => {
const store = createMockStore();
const schedule = createMockSchedule({ command: "echo test" });
const automationStore = createMockAutomationStore([schedule]);
const onProcessed = vi.fn().mockRejectedValue(new Error("callback failed"));
runner = new CronRunner(store, automationStore, { onScheduleRunProcessed: onProcessed });
await runner.executeSchedule(schedule);
expect(automationStore.recordRun).toHaveBeenCalledTimes(1);
});
it("passes schedule and result to callback", async () => {
const store = createMockStore();
const schedule = createMockSchedule({
id: "custom-id",
name: "Custom Schedule",
command: "echo custom",
});
const automationStore = createMockAutomationStore([schedule]);
let receivedSchedule: ScheduledTask | undefined;
let receivedResult: AutomationRunResult | undefined;
const onProcessed = vi.fn().mockImplementation((s: ScheduledTask, r: AutomationRunResult) => {
receivedSchedule = s;
receivedResult = r;
});
runner = new CronRunner(store, automationStore, { onScheduleRunProcessed: onProcessed });
await runner.executeSchedule(schedule);
expect(receivedSchedule).toEqual(schedule);
expect(receivedResult).toBeDefined();
expect(receivedResult!.success).toBe(true);
});
}); });
describe("concurrent schedule prevention in tick", () => { describe("concurrent schedule prevention in tick", () => {

View File

@@ -34,6 +34,26 @@ export interface CronRunnerOptions {
pollIntervalMs?: number; pollIntervalMs?: number;
/** Optional AI prompt executor. When not provided, ai-prompt steps return a configuration error. */ /** Optional AI prompt executor. When not provided, ai-prompt steps return a configuration error. */
aiPromptExecutor?: AiPromptExecutor; aiPromptExecutor?: AiPromptExecutor;
/**
* Optional post-run callback invoked after schedule execution and run recording.
*
* Called as a best-effort side effect — callback errors are logged but do not
* alter the returned `AutomationRunResult` or flip successful runs to failed.
* This keeps post-processing isolated from the core execution contract.
*
* The callback receives:
* - `schedule`: The schedule that was executed
* - `result`: The `AutomationRunResult` from execution (success/failure)
*
* Use this for schedule-specific processing such as:
* - Persisting memory insight extraction results
* - Updating external systems based on automation output
* - Triggering downstream side effects
*/
onScheduleRunProcessed?: (
schedule: ScheduledTask,
result: AutomationRunResult,
) => void | Promise<void>;
} }
/** /**
@@ -50,6 +70,7 @@ export class CronRunner {
private pollInterval: ReturnType<typeof setInterval> | null = null; private pollInterval: ReturnType<typeof setInterval> | null = null;
private pollIntervalMs: number; private pollIntervalMs: number;
private aiPromptExecutor?: AiPromptExecutor; private aiPromptExecutor?: AiPromptExecutor;
private onScheduleRunProcessed?: CronRunnerOptions["onScheduleRunProcessed"];
/** Schedule IDs currently being executed — prevents concurrent runs of the same schedule. */ /** Schedule IDs currently being executed — prevents concurrent runs of the same schedule. */
private inFlight = new Set<string>(); private inFlight = new Set<string>();
@@ -63,6 +84,7 @@ export class CronRunner {
options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS, options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
); );
this.aiPromptExecutor = options.aiPromptExecutor; this.aiPromptExecutor = options.aiPromptExecutor;
this.onScheduleRunProcessed = options.onScheduleRunProcessed;
} }
/** Start the polling loop. */ /** Start the polling loop. */
@@ -163,6 +185,18 @@ export class CronRunner {
log.error(`Failed to record run for ${schedule.id}: ${(recordErr as Error).message}`); log.error(`Failed to record run for ${schedule.id}: ${(recordErr as Error).message}`);
} }
// Invoke post-run callback (best-effort side effect)
// Errors here do not alter the returned result or flip success/failure
if (this.onScheduleRunProcessed) {
try {
await this.onScheduleRunProcessed(schedule, result);
} catch (callbackErr) {
log.error(
`Post-run callback failed for ${schedule.name} (${schedule.id}): ${(callbackErr as Error).message}`,
);
}
}
return result; return result;
} }