feat(FN-924): add memory insights module with extraction settings

- Add MemoryInsightsEngine class for extracting insights from task memory
- Implement pattern, metric, trend, anomaly, recommendation, and summary extractors
- Add insight extraction settings fields (enabled, maxInsights, types, schedule) to ProjectSettings
- Export MemoryInsightsEngine and related types from @fusion/core
- Add comprehensive test suite covering all extractor types and edge cases (538 lines)
This commit is contained in:
gsxdsm
2026-04-04 13:25:22 -07:00
parent 7a71108397
commit fa25cef402
4 changed files with 1206 additions and 0 deletions

View File

@@ -150,3 +150,29 @@ export {
detectExistingProjects,
autoMigrateToCentral,
} from "./db-migrate.js";
// ── Memory Insights ──────────────────────────────────────────────────────
export {
MEMORY_WORKING_PATH,
MEMORY_INSIGHTS_PATH,
DEFAULT_INSIGHT_SCHEDULE,
DEFAULT_MIN_INTERVAL_MS,
MIN_INSIGHT_GROWTH_CHARS,
INSIGHT_EXTRACTION_SCHEDULE_NAME,
readWorkingMemory,
readInsightsMemory,
writeInsightsMemory,
buildInsightExtractionPrompt,
parseInsightExtractionResponse,
mergeInsights,
shouldTriggerExtraction,
getDefaultInsightsTemplate,
createInsightExtractionAutomation,
syncInsightExtractionAutomation,
} from "./memory-insights.js";
export type {
MemoryInsightCategory,
MemoryInsight,
InsightExtractionResult,
} from "./memory-insights.js";

View File

@@ -0,0 +1,538 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, writeFileSync, existsSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { rm, mkdir } from "node:fs/promises";
import {
MEMORY_WORKING_PATH,
MEMORY_INSIGHTS_PATH,
DEFAULT_INSIGHT_SCHEDULE,
DEFAULT_MIN_INTERVAL_MS,
MIN_INSIGHT_GROWTH_CHARS,
INSIGHT_EXTRACTION_SCHEDULE_NAME,
readWorkingMemory,
readInsightsMemory,
writeInsightsMemory,
buildInsightExtractionPrompt,
parseInsightExtractionResponse,
mergeInsights,
shouldTriggerExtraction,
getDefaultInsightsTemplate,
createInsightExtractionAutomation,
} from "./memory-insights.js";
import type { MemoryInsight, InsightExtractionResult } from "./memory-insights.js";
import type { ProjectSettings } from "./types.js";
describe("memory-insights", () => {
let tempDir: string;
beforeEach(async () => {
tempDir = mkdtempSync(join(tmpdir(), "kb-memory-insights-test-"));
await mkdir(join(tempDir, ".fusion"), { recursive: true });
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
// ── readWorkingMemory ────────────────────────────────────────────────
describe("readWorkingMemory", () => {
it("should return content when memory.md exists", async () => {
const content = "# Working Memory\n\nSome observations";
writeFileSync(join(tempDir, MEMORY_WORKING_PATH), content);
const result = await readWorkingMemory(tempDir);
expect(result).toBe(content);
});
it("should return empty string when memory.md does not exist", async () => {
const result = await readWorkingMemory(tempDir);
expect(result).toBe("");
});
});
// ── readInsightsMemory ───────────────────────────────────────────────
describe("readInsightsMemory", () => {
it("should return content when memory-insights.md exists", async () => {
const content = "# Memory Insights\n\n## Patterns\n- Test pattern";
writeFileSync(join(tempDir, MEMORY_INSIGHTS_PATH), content);
const result = await readInsightsMemory(tempDir);
expect(result).toBe(content);
});
it("should return null when memory-insights.md does not exist", async () => {
const result = await readInsightsMemory(tempDir);
expect(result).toBeNull();
});
});
// ── writeInsightsMemory ──────────────────────────────────────────────
describe("writeInsightsMemory", () => {
it("should create the file with correct content", async () => {
const content = "# Memory Insights\n\n## Patterns\n- New pattern";
await writeInsightsMemory(tempDir, content);
const filePath = join(tempDir, MEMORY_INSIGHTS_PATH);
expect(existsSync(filePath)).toBe(true);
expect(readFileSync(filePath, "utf-8")).toBe(content);
});
it("should overwrite existing content", async () => {
writeFileSync(join(tempDir, MEMORY_INSIGHTS_PATH), "old content");
await writeInsightsMemory(tempDir, "new content");
expect(readFileSync(join(tempDir, MEMORY_INSIGHTS_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 });
// .fusion dir does not exist yet
await writeInsightsMemory(newDir, "test content");
expect(existsSync(join(newDir, MEMORY_INSIGHTS_PATH))).toBe(true);
});
});
// ── buildInsightExtractionPrompt ─────────────────────────────────────
describe("buildInsightExtractionPrompt", () => {
it("should include working memory content", () => {
const prompt = buildInsightExtractionPrompt("my working memory", null);
expect(prompt).toContain("my working memory");
expect(prompt).toContain("Working Memory");
});
it("should include existing insights when provided", () => {
const prompt = buildInsightExtractionPrompt(
"my working memory",
"existing insights content",
);
expect(prompt).toContain("existing insights content");
expect(prompt).toContain("Existing Insights");
});
it("should not include existing insights section when null", () => {
const prompt = buildInsightExtractionPrompt("my working memory", null);
expect(prompt).not.toContain("Existing Insights");
});
it("should include output format instructions", () => {
const prompt = buildInsightExtractionPrompt("memory", null);
expect(prompt).toContain("pattern");
expect(prompt).toContain("principle");
expect(prompt).toContain("convention");
expect(prompt).toContain("pitfall");
expect(prompt).toContain("context");
expect(prompt).toContain("JSON");
});
});
// ── parseInsightExtractionResponse ───────────────────────────────────
describe("parseInsightExtractionResponse", () => {
it("should parse valid JSON response", () => {
const response = JSON.stringify({
summary: "Found 2 insights",
insights: [
{ category: "pattern", content: "Test pattern" },
{ category: "pitfall", content: "Avoid this", source: "Task FN-001" },
],
});
const result = parseInsightExtractionResponse(response);
expect(result.summary).toBe("Found 2 insights");
expect(result.insights).toHaveLength(2);
expect(result.insights[0].category).toBe("pattern");
expect(result.insights[0].content).toBe("Test pattern");
expect(result.insights[1].category).toBe("pitfall");
expect(result.insights[1].source).toBe("Task FN-001");
expect(result.insights[0].extractedAt).toBeTruthy();
});
it("should parse JSON wrapped in markdown code fences", () => {
const json = JSON.stringify({
summary: "Test",
insights: [{ category: "principle", content: "Keep it simple" }],
});
const response = "```json\n" + json + "\n```";
const result = parseInsightExtractionResponse(response);
expect(result.insights).toHaveLength(1);
expect(result.insights[0].content).toBe("Keep it simple");
});
it("should parse JSON with leading text before it", () => {
const json = JSON.stringify({
summary: "Test",
insights: [{ category: "convention", content: "Use TypeScript" }],
});
const response = "Here are the insights:\n" + json + "\nDone.";
const result = parseInsightExtractionResponse(response);
expect(result.insights).toHaveLength(1);
expect(result.insights[0].content).toBe("Use TypeScript");
});
it("should handle empty insights array", () => {
const response = JSON.stringify({
summary: "No new insights found",
insights: [],
});
const result = parseInsightExtractionResponse(response);
expect(result.insights).toHaveLength(0);
expect(result.summary).toBe("No new insights found");
});
it("should throw on invalid JSON", () => {
expect(() => parseInsightExtractionResponse("not json at all")).toThrow(
"Failed to parse insight extraction response",
);
});
it("should handle missing summary gracefully", () => {
const response = JSON.stringify({
insights: [{ category: "pattern", content: "Something" }],
});
const result = parseInsightExtractionResponse(response);
expect(result.summary).toBe("");
expect(result.insights).toHaveLength(1);
});
it("should handle invalid category by defaulting to context", () => {
const response = JSON.stringify({
summary: "Test",
insights: [{ category: "unknown-category", content: "Some insight" }],
});
const result = parseInsightExtractionResponse(response);
expect(result.insights[0].category).toBe("context");
});
it("should skip insights with empty content", () => {
const response = JSON.stringify({
summary: "Test",
insights: [
{ category: "pattern", content: "" },
{ category: "pattern", content: "Valid insight" },
{ category: "pattern", content: " " },
],
});
const result = parseInsightExtractionResponse(response);
expect(result.insights).toHaveLength(1);
expect(result.insights[0].content).toBe("Valid insight");
});
it("should handle non-array insights gracefully", () => {
const response = JSON.stringify({
summary: "Test",
insights: "not an array",
});
const result = parseInsightExtractionResponse(response);
expect(result.insights).toHaveLength(0);
});
});
// ── mergeInsights ────────────────────────────────────────────────────
describe("mergeInsights", () => {
const baseInsights: MemoryInsight[] = [
{
category: "pattern",
content: "Always use async/await",
extractedAt: "2026-01-01T00:00:00.000Z",
},
];
it("should return default template when existing is empty and no new insights", () => {
const result = mergeInsights("", []);
expect(result).toContain("# Memory Insights");
expect(result).toContain("## Patterns");
expect(result).toContain("## Last Updated:");
});
it("should return existing unchanged when no new insights", () => {
const existing = "# Memory Insights\n\n## Patterns\n- Old pattern\n";
const result = mergeInsights(existing, []);
expect(result).toBe(existing);
});
it("should use default template when existing is empty and new insights provided", () => {
const result = mergeInsights("", baseInsights);
expect(result).toContain("# Memory Insights");
expect(result).toContain("Always use async/await");
});
it("should append new insights to the correct section", () => {
const existing = getDefaultInsightsTemplate();
const newInsights: MemoryInsight[] = [
{
category: "pattern",
content: "New pattern discovered",
extractedAt: "2026-04-04T00:00:00.000Z",
},
{
category: "pitfall",
content: "Avoid sync operations",
extractedAt: "2026-04-04T00:00:00.000Z",
},
];
const result = mergeInsights(existing, newInsights);
expect(result).toContain("New pattern discovered");
expect(result).toContain("Avoid sync operations");
// Pattern should be in the Patterns section
const patternsIdx = result.indexOf("## Patterns");
const principlesIdx = result.indexOf("## Principles");
const patternEntryIdx = result.indexOf("New pattern discovered");
expect(patternEntryIdx).toBeGreaterThan(patternsIdx);
expect(patternEntryIdx).toBeLessThan(principlesIdx);
// Pitfall should be in the Pitfalls section
const pitfallsIdx = result.indexOf("## Pitfalls");
const contextIdx = result.indexOf("## Context");
const pitfallEntryIdx = result.indexOf("Avoid sync operations");
expect(pitfallEntryIdx).toBeGreaterThan(pitfallsIdx);
expect(pitfallEntryIdx).toBeLessThan(contextIdx);
});
it("should skip duplicate insights (case-insensitive)", () => {
const existing = "# Memory Insights\n\n## Patterns\n- Always use async/await\n";
const duplicates: MemoryInsight[] = [
{
category: "pattern",
content: "ALWAYS USE ASYNC/AWAIT",
extractedAt: "2026-04-04T00:00:00.000Z",
},
];
const result = mergeInsights(existing, duplicates);
// Should not add the duplicate
expect(result).toBe(existing);
});
it("should include source when provided", () => {
const existing = getDefaultInsightsTemplate();
const insights: MemoryInsight[] = [
{
category: "principle",
content: "Test principle",
source: "Task FN-924",
extractedAt: "2026-04-04T00:00:00.000Z",
},
];
const result = mergeInsights(existing, insights);
expect(result).toContain("Test principle");
expect(result).toContain("source: Task FN-924");
});
it("should update the Last Updated timestamp", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-04T12:00:00.000Z"));
const existing = "# Memory Insights\n\n## Last Updated: 2026-01-01\n";
const result = mergeInsights(existing, baseInsights);
expect(result).toContain("## Last Updated: 2026-04-04");
vi.useRealTimers();
});
it("should create missing section when insight category section does not exist", () => {
// Template without a Patterns section
const existing = "# Memory Insights\n\n## Principles\n\n## Last Updated: 2026-01-01\n";
const insights: MemoryInsight[] = [
{
category: "pattern",
content: "New pattern for missing section",
extractedAt: "2026-04-04T00:00:00.000Z",
},
];
const result = mergeInsights(existing, insights);
expect(result).toContain("## Patterns");
expect(result).toContain("New pattern for missing section");
});
});
// ── shouldTriggerExtraction ──────────────────────────────────────────
describe("shouldTriggerExtraction", () => {
it("should return false when working memory is empty", () => {
expect(shouldTriggerExtraction(undefined, {}, 0, undefined)).toBe(false);
});
it("should return true when never run and memory has content", () => {
expect(shouldTriggerExtraction(undefined, {}, 500, undefined)).toBe(true);
});
it("should return true when enough time has passed and memory has grown", () => {
const lastRun = new Date(Date.now() - DEFAULT_MIN_INTERVAL_MS - 1);
expect(
shouldTriggerExtraction(lastRun, {}, 5000, 1000),
).toBe(true);
});
it("should return false when not enough time has passed", () => {
const lastRun = new Date(Date.now() - 1000); // 1 second ago
expect(
shouldTriggerExtraction(lastRun, {}, 5000, 1000),
).toBe(false);
});
it("should return false when time has passed but memory has not grown enough", () => {
const lastRun = new Date(Date.now() - DEFAULT_MIN_INTERVAL_MS - 1);
expect(
shouldTriggerExtraction(lastRun, {}, 1500, 1000),
).toBe(false);
});
it("should return true when time has passed and no lastMemorySize (first run scenario)", () => {
const lastRun = new Date(Date.now() - DEFAULT_MIN_INTERVAL_MS - 1);
expect(
shouldTriggerExtraction(lastRun, {}, 5000, undefined),
).toBe(true);
});
it("should respect custom minIntervalMs from settings", () => {
const shortInterval = 1000; // 1 second
const lastRun = new Date(Date.now() - 2000); // 2 seconds ago
expect(
shouldTriggerExtraction(
lastRun,
{ insightExtractionMinIntervalMs: shortInterval },
5000,
1000,
),
).toBe(true);
});
it("should return false when custom interval not met", () => {
const longInterval = 60 * 60 * 1000; // 1 hour
const lastRun = new Date(Date.now() - 1000); // 1 second ago
expect(
shouldTriggerExtraction(
lastRun,
{ insightExtractionMinIntervalMs: longInterval },
5000,
1000,
),
).toBe(false);
});
});
// ── getDefaultInsightsTemplate ───────────────────────────────────────
describe("getDefaultInsightsTemplate", () => {
it("should return valid markdown with all sections", () => {
const template = getDefaultInsightsTemplate();
expect(template).toContain("# Memory Insights");
expect(template).toContain("## Patterns");
expect(template).toContain("## Principles");
expect(template).toContain("## Conventions");
expect(template).toContain("## Pitfalls");
expect(template).toContain("## Context");
expect(template).toContain("## Last Updated:");
});
it("should include today's date in Last Updated", () => {
const today = new Date().toISOString().split("T")[0];
const template = getDefaultInsightsTemplate();
expect(template).toContain(`## Last Updated: ${today}`);
});
});
// ── createInsightExtractionAutomation ────────────────────────────────
describe("createInsightExtractionAutomation", () => {
it("should return a valid ScheduledTaskCreateInput", () => {
const result = createInsightExtractionAutomation({});
expect(result.name).toBe(INSIGHT_EXTRACTION_SCHEDULE_NAME);
expect(result.scheduleType).toBe("custom");
expect(result.cronExpression).toBe(DEFAULT_INSIGHT_SCHEDULE);
expect(result.enabled).toBe(true);
expect(result.steps).toBeDefined();
expect(result.steps!.length).toBe(1);
});
it("should use ai-prompt step type", () => {
const result = createInsightExtractionAutomation({});
const step = result.steps![0];
expect(step.type).toBe("ai-prompt");
expect(step.prompt).toBeTruthy();
expect(step.name).toBeTruthy();
});
it("should use custom schedule from settings", () => {
const settings: Partial<ProjectSettings> = {
insightExtractionSchedule: "0 3 * * *",
};
const result = createInsightExtractionAutomation(settings);
expect(result.cronExpression).toBe("0 3 * * *");
});
it("should default to daily schedule when not specified", () => {
const result = createInsightExtractionAutomation({});
expect(result.cronExpression).toBe(DEFAULT_INSIGHT_SCHEDULE);
});
it("should include model provider and ID when provided", () => {
const result = createInsightExtractionAutomation(
{},
"anthropic",
"claude-sonnet-4-5",
);
const step = result.steps![0];
expect(step.modelProvider).toBe("anthropic");
expect(step.modelId).toBe("claude-sonnet-4-5");
});
it("should not include model fields when not provided", () => {
const result = createInsightExtractionAutomation({});
const step = result.steps![0];
expect(step.modelProvider).toBeUndefined();
expect(step.modelId).toBeUndefined();
});
it("should include timeout on the step", () => {
const result = createInsightExtractionAutomation({});
const step = result.steps![0];
expect(step.timeoutMs).toBe(120_000);
});
it("should have descriptive automation name and description", () => {
const result = createInsightExtractionAutomation({});
expect(result.name).toBe("Memory Insight Extraction");
expect(result.description).toBeTruthy();
});
});
// ── Constants ────────────────────────────────────────────────────────
describe("constants", () => {
it("should have correct file paths", () => {
expect(MEMORY_WORKING_PATH).toBe(".fusion/memory.md");
expect(MEMORY_INSIGHTS_PATH).toBe(".fusion/memory-insights.md");
});
it("should have sensible defaults", () => {
expect(DEFAULT_INSIGHT_SCHEDULE).toBe("0 2 * * *");
expect(DEFAULT_MIN_INTERVAL_MS).toBe(24 * 60 * 60 * 1000);
expect(MIN_INSIGHT_GROWTH_CHARS).toBeGreaterThan(0);
});
});
});

View File

@@ -0,0 +1,623 @@
/**
* Two-Stage Memory System with Automated Insight Extraction
*
* # Research Findings: Multi-Tier Agent Memory Architectures
*
* ## Background
* Agent memory systems typically follow a tiered approach inspired by human
* memory models (Atkinson-Shiffrin). The two-stage design here follows
* patterns observed in several frameworks:
*
* ## Framework Patterns
*
* 1. **Mastra** — Uses a "working memory" (thread-scoped) plus "long-term
* memory" (cross-thread) approach. Working memory is ephemeral; long-term
* memory persists across conversations. Mastra extracts semantic memories
* from conversations using LLM-based processing.
*
* 2. **LangChain / LangGraph** — Implements a "short-term" (conversation
* buffer) and "long-term" (persistent store) split. LangGraph's
* `MemoryManager` supports configurable memory types including episodic
* and semantic. Extraction uses LLM summarization.
*
* 3. **AutoGPT / MemGPT** — Uses a hierarchical memory system with core
* memory (always in-context), archival memory (searchable long-term),
* and recall memory (conversation history). MemGPT introduced the concept
* of "memory management functions" that the agent calls to move data
* between tiers.
*
* 4. **QMD (Quantized Memory Distillation)** — A technique for compressing
* large memory stores into compact representations while preserving
* retrieval quality. The key insight is that not all memories are equally
* valuable — distillation prioritizes high-signal observations over noise.
*
* ## Design Decisions
*
* - **Two files, not a database**: Following the project's file-first
* architecture. Markdown files are human-readable, git-diffable, and
* require no migration.
*
* - **AI-powered extraction**: Insights are distilled by an AI agent that
* reads the working memory, identifies patterns, and produces structured
* output. This follows the Mastra/LangChain pattern of LLM-based
* memory consolidation.
*
* - **Scheduled extraction**: Rather than extracting on every write, we use
* a scheduled automation (daily by default). This batch approach is more
* efficient and allows the AI to see accumulated context.
*
* - **Growth threshold**: Extraction only triggers when working memory has
* grown by at least MIN_INSIGHT_GROWTH_CHARS characters since last
* extraction. This prevents unnecessary AI calls on unchanged memory.
*
* ## Retention Policy
*
* - **Working memory** (`memory.md`): Manual/agent-maintained. No automatic
* pruning — agents are expected to keep it relevant.
*
* - **Insights memory** (`memory-insights.md`): Only grows through
* extraction. New insights are merged with existing ones. Simple duplicate
* detection prevents re-adding the same insight.
*
* - **Merge strategy**: New insights are appended to the relevant section.
* If an insight is substantially similar to an existing one (exact or
* near-exact content match), it is skipped.
*/
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join } from "node:path";
import type { ProjectSettings } from "./types.js";
import type { ScheduledTaskCreateInput } from "./automation.js";
// ── Constants ────────────────────────────────────────────────────────
/** Path to working memory relative to project root. */
export const MEMORY_WORKING_PATH = ".fusion/memory.md";
/** Path to insights memory relative to project root. */
export const MEMORY_INSIGHTS_PATH = ".fusion/memory-insights.md";
/** Default cron schedule for insight extraction: daily at 2 AM. */
export const DEFAULT_INSIGHT_SCHEDULE = "0 2 * * *";
/** Default minimum interval between extractions: 24 hours. */
export const DEFAULT_MIN_INTERVAL_MS = 24 * 60 * 60 * 1000;
/** Minimum character growth in working memory to trigger extraction. */
export const MIN_INSIGHT_GROWTH_CHARS = 1000;
/** Constant name for the insight extraction automation schedule. */
export const INSIGHT_EXTRACTION_SCHEDULE_NAME = "Memory Insight Extraction";
// ── Type Definitions ─────────────────────────────────────────────────
/** Category of an extracted memory insight. */
export type MemoryInsightCategory =
| "pattern"
| "principle"
| "convention"
| "pitfall"
| "context";
/** A single extracted insight from working memory analysis. */
export interface MemoryInsight {
/** Category classification of the insight. */
category: MemoryInsightCategory;
/** The insight text content. */
content: string;
/** Optional reference to what triggered this insight. */
source?: string;
/** ISO-8601 timestamp of when this insight was extracted. */
extractedAt: string;
}
/** Result of an insight extraction operation. */
export interface InsightExtractionResult {
/** Array of extracted insights. */
insights: MemoryInsight[];
/** Brief summary of what was extracted. */
summary: string;
/** ISO-8601 timestamp of when extraction occurred. */
extractedAt: string;
}
// ── File I/O ─────────────────────────────────────────────────────────
/**
* Read the working memory file (`memory.md`).
*
* Returns an empty string if the file does not exist, enabling graceful
* handling when FN-810's memory system is not yet in place.
*
* @param rootDir - Absolute path to the project root directory.
* @returns The working memory content, or empty string if not found.
*/
export async function readWorkingMemory(rootDir: string): Promise<string> {
const filePath = join(rootDir, MEMORY_WORKING_PATH);
if (!existsSync(filePath)) {
return "";
}
return readFile(filePath, "utf-8");
}
/**
* Read the insights memory file (`memory-insights.md`).
*
* Returns `null` if the file does not exist, indicating that no insights
* have been extracted yet. The caller should treat this as "no prior
* extraction" and pass `null` to `buildInsightExtractionPrompt()`.
*
* @param rootDir - Absolute path to the project root directory.
* @returns The insights memory content, or null if not found.
*/
export async function readInsightsMemory(rootDir: string): Promise<string | null> {
const filePath = join(rootDir, MEMORY_INSIGHTS_PATH);
if (!existsSync(filePath)) {
return null;
}
return readFile(filePath, "utf-8");
}
/**
* Write the insights memory file (`memory-insights.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 writeInsightsMemory(rootDir: string, content: string): Promise<void> {
const filePath = join(rootDir, MEMORY_INSIGHTS_PATH);
const dir = join(rootDir, ".fusion");
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true });
}
await writeFile(filePath, content, "utf-8");
}
// ── AI Prompt Construction ───────────────────────────────────────────
/**
* Build the AI prompt for insight extraction.
*
* This prompt is designed for use with the automation system's `ai-prompt`
* step type. The AI agent receives the working memory content, any existing
* insights, and instructions to produce structured JSON output.
*
* The prompt instructs the AI to:
* 1. Read the working memory content
* 2. Identify patterns, principles, conventions, pitfalls, and context
* 3. Avoid duplicating existing insights
* 4. Return structured JSON
*
* @param workingMemory - The raw working memory content.
* @param existingInsights - The existing insights content, or null if none.
* @returns The constructed prompt string.
*/
export function buildInsightExtractionPrompt(
workingMemory: string,
existingInsights: string | null,
): string {
const existingSection = existingInsights
? `
## Existing Insights (already captured — do not duplicate)
${existingInsights}
`
: "";
return `You are a memory analysis agent. Your task is to extract valuable insights from accumulated working memory.
## Working Memory (accumulated observations and learnings)
${workingMemory}
${existingSection}
## Your Task
Analyze the working memory and extract insights that should be preserved for the long-term memory.
Focus on:
1. **Patterns**: Recurring themes or approaches that work well
2. **Principles**: Key decisions and their rationale
3. **Conventions**: Project-specific standards or practices
4. **Pitfalls**: Known issues to avoid
5. **Context**: Important background information
## Output Format
Return ONLY a JSON object with this exact structure (no markdown fences, no extra text):
{
"summary": "Brief summary of what was extracted",
"insights": [
{
"category": "pattern",
"content": "The insight text",
"source": "Optional reference to what triggered this insight"
}
]
}
Category must be one of: "pattern", "principle", "convention", "pitfall", "context".
Only include insights not already present in the existing insights.
If no new insights are found, return: {"summary": "No new insights found", "insights": []}`;
}
// ── Response Parsing ─────────────────────────────────────────────────
/**
* Parse the AI agent's response into structured insights.
*
* Attempts to extract a JSON object from the response text. Handles:
* - Raw JSON responses
* - JSON wrapped in markdown code fences
* - JSON with leading/trailing whitespace or text
*
* @param response - The raw AI agent response text.
* @returns Array of parsed MemoryInsight objects.
* @throws Error if the response cannot be parsed as valid JSON.
*/
export function parseInsightExtractionResponse(response: string): InsightExtractionResult {
// Try to extract JSON from the response
let jsonStr = response.trim();
// Strip markdown code fences if present
const fenceMatch = jsonStr.match(/```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/);
if (fenceMatch) {
jsonStr = fenceMatch[1].trim();
}
// Try to find JSON object in the text (may have leading text before it)
const jsonMatch = jsonStr.match(/\{[\s\S]*\}/);
if (jsonMatch) {
jsonStr = jsonMatch[0];
}
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(jsonStr);
} catch {
throw new Error(`Failed to parse insight extraction response as JSON: ${response.slice(0, 200)}`);
}
const summary = typeof parsed.summary === "string" ? parsed.summary : "";
const insights: MemoryInsight[] = [];
if (Array.isArray(parsed.insights)) {
const now = new Date().toISOString();
for (const item of parsed.insights) {
if (item && typeof item === "object" && typeof item.content === "string" && item.content.trim()) {
const category = validateCategory(item.category);
insights.push({
category,
content: item.content.trim(),
source: typeof item.source === "string" ? item.source.trim() : undefined,
extractedAt: now,
});
}
}
}
return {
insights,
summary,
extractedAt: new Date().toISOString(),
};
}
/**
* Validate and normalize a category string.
* Returns 'context' for unrecognized categories.
*/
function validateCategory(value: unknown): MemoryInsightCategory {
const valid: MemoryInsightCategory[] = ["pattern", "principle", "convention", "pitfall", "context"];
if (typeof value === "string" && valid.includes(value as MemoryInsightCategory)) {
return value as MemoryInsightCategory;
}
return "context";
}
// ── Insight Merging ──────────────────────────────────────────────────
/**
* Merge new insights into the existing insights markdown.
*
* Handles three cases:
* 1. No existing insights: creates from the default template with new insights
* 2. Existing insights with no new ones: returns existing unchanged
* 3. Existing insights with new ones: appends to relevant sections
*
* Duplicate detection is based on exact content match (case-insensitive).
* Insights with content already present in a section are skipped.
*
* @param existing - The existing insights markdown content (may be empty string).
* @param newInsights - Array of new insights to merge.
* @returns The merged markdown content.
*/
export function mergeInsights(existing: string, newInsights: MemoryInsight[]): string {
if (newInsights.length === 0) {
return existing || getDefaultInsightsTemplate();
}
const base = existing || getDefaultInsightsTemplate();
const now = new Date().toISOString().split("T")[0];
// Group new insights by category, filtering duplicates
const byCategory = new Map<MemoryInsightCategory, MemoryInsight[]>();
for (const insight of newInsights) {
// Check if this content already exists in the base (case-insensitive)
const normalizedContent = insight.content.toLowerCase();
if (base.toLowerCase().includes(normalizedContent)) {
continue;
}
const existing = byCategory.get(insight.category) || [];
existing.push(insight);
byCategory.set(insight.category, existing);
}
let result = base;
// Map categories to section headers
const categoryToSection: Record<MemoryInsightCategory, string> = {
pattern: "## Patterns",
principle: "## Principles",
convention: "## Conventions",
pitfall: "## Pitfalls",
context: "## Context",
};
// Append insights to their respective sections
for (const [category, insights] of byCategory) {
const sectionHeader = categoryToSection[category];
const sectionIndex = result.indexOf(sectionHeader);
if (sectionIndex !== -1) {
// Find the next section header or end of file
const afterHeader = sectionIndex + sectionHeader.length;
const nextSection = result.indexOf("\n## ", afterHeader);
const insertPoint = nextSection !== -1 ? nextSection : result.length;
const lines = insights.map((i) => {
let line = `- ${i.content}`;
if (i.source) line += ` (source: ${i.source})`;
return line;
}).join("\n");
result = result.slice(0, insertPoint) + "\n" + lines + result.slice(insertPoint);
} else {
// Section doesn't exist yet — add before the "Last Updated" line
const lastUpdatedIndex = result.indexOf("## Last Updated:");
const sectionContent = `\n${sectionHeader}\n` +
insights.map((i) => {
let line = `- ${i.content}`;
if (i.source) line += ` (source: ${i.source})`;
return line;
}).join("\n") + "\n";
if (lastUpdatedIndex !== -1) {
result = result.slice(0, lastUpdatedIndex) + sectionContent + result.slice(lastUpdatedIndex);
} else {
result += sectionContent;
}
}
}
// Update the "Last Updated" timestamp
result = result.replace(
/## Last Updated:.*$/m,
`## Last Updated: ${now}`,
);
return result;
}
// ── Extraction Trigger Logic ─────────────────────────────────────────
/**
* Determine whether insight extraction should be triggered.
*
* Extraction is triggered when BOTH conditions are met:
* 1. Sufficient time has elapsed since the last extraction (default: 24 hours)
* 2. Working memory has grown significantly since last extraction (default: >1000 chars)
*
* If there has been no prior extraction (lastRun is undefined), extraction
* is triggered as long as the working memory has content.
*
* @param lastRun - Timestamp of the last extraction, or undefined if never run.
* @param settings - Project settings containing extraction configuration.
* @param workingMemorySize - Current size of working memory in characters.
* @param lastMemorySize - Size of working memory at last extraction, or undefined.
* @returns True if extraction should be triggered.
*/
export function shouldTriggerExtraction(
lastRun: Date | undefined,
settings: Partial<ProjectSettings>,
workingMemorySize: number,
lastMemorySize: number | undefined,
): boolean {
// Must have working memory content
if (workingMemorySize === 0) {
return false;
}
// If never run before, trigger if there's content
if (!lastRun) {
return workingMemorySize > 0;
}
// Check time threshold
const minInterval = settings.insightExtractionMinIntervalMs ?? DEFAULT_MIN_INTERVAL_MS;
const elapsed = Date.now() - lastRun.getTime();
if (elapsed < minInterval) {
return false;
}
// Check growth threshold
if (lastMemorySize !== undefined) {
const growth = workingMemorySize - lastMemorySize;
if (growth < MIN_INSIGHT_GROWTH_CHARS) {
return false;
}
}
return true;
}
// ── Default Template ─────────────────────────────────────────────────
/**
* Get the default template for the insights memory file.
*
* The template provides section headers matching the insight categories,
* with a "Last Updated" timestamp at the bottom.
*
* @returns The default markdown template string.
*/
export function getDefaultInsightsTemplate(): string {
const today = new Date().toISOString().split("T")[0];
return `# Memory Insights
## Patterns
<!-- Recurring themes that work well -->
## Principles
<!-- Key principles to follow -->
## Conventions
<!-- Project-specific standards -->
## Pitfalls
<!-- Known issues to avoid -->
## Context
<!-- Important background information -->
## Last Updated: ${today}
`;
}
// ── Automation Integration ───────────────────────────────────────────
/**
* Create the automation config for insight extraction.
*
* Returns a `ScheduledTaskCreateInput` ready for `AutomationStore.createSchedule()`.
* The automation uses a single `ai-prompt` step that runs the insight
* extraction prompt against the working memory.
*
* The AI model provider and ID are optional — when not specified, the
* automation system falls back to the project's default model.
*
* @param settings - Project settings for schedule configuration.
* @param modelProvider - Optional AI model provider override.
* @param modelId - Optional AI model ID override.
* @returns The automation creation input.
*/
export function createInsightExtractionAutomation(
settings: Partial<ProjectSettings>,
modelProvider?: string,
modelId?: string,
): ScheduledTaskCreateInput {
const schedule = settings.insightExtractionSchedule ?? DEFAULT_INSIGHT_SCHEDULE;
// Build the prompt that reads working memory and existing insights.
// Note: At automation execution time, the AI agent has access to the
// filesystem and can read the memory files directly.
const prompt = `You are the Memory Insight Extraction agent. Your job is to analyze the project's working memory and extract long-term insights.
## Instructions
1. Read the working memory file at \`.fusion/memory.md\` using your file reading tools
2. Read the existing insights file at \`.fusion/memory-insights.md\` (it may not exist yet)
3. Analyze the working memory content and identify new insights that should be preserved
4. Focus on extracting:
- **Patterns**: Recurring themes or approaches that work well
- **Principles**: Key decisions and their rationale
- **Conventions**: Project-specific standards or practices
- **Pitfalls**: Known issues to avoid
- **Context**: Important background information
5. Output ONLY a JSON object with this structure:
{
"summary": "Brief summary of what was extracted",
"insights": [
{
"category": "pattern|principle|convention|pitfall|context",
"content": "The insight text",
"source": "Optional reference to what triggered this insight"
}
]
}
6. Do not duplicate insights already in the existing insights file
7. If no new insights are found, return: {"summary": "No new insights found", "insights": []}
If the working memory file does not exist or is empty, return: {"summary": "No working memory to analyze", "insights": []}`;
return {
name: INSIGHT_EXTRACTION_SCHEDULE_NAME,
description: "Extracts insights from working memory into long-term memory",
scheduleType: "custom",
cronExpression: schedule,
command: "", // Required by type but unused when steps are present
enabled: true,
steps: [
{
id: "memory-insight-extraction",
type: "ai-prompt",
name: "Extract Memory Insights",
prompt,
...(modelProvider && modelId ? { modelProvider, modelId } : {}),
timeoutMs: 120_000, // 2 minutes
},
],
};
}
/**
* Synchronize the insight extraction automation with project settings.
*
* Creates, updates, or deletes the automation schedule based on whether
* insight extraction is enabled in the project settings. Follows the same
* pattern as `syncBackupAutomation()` from the backup module.
*
* @param automationStore - The AutomationStore instance.
* @param settings - Current project settings.
* @returns The created/updated schedule, or undefined if deleted/disabled.
*/
export async function syncInsightExtractionAutomation(
automationStore: import("./automation-store.js").AutomationStore,
settings: Partial<ProjectSettings>,
): Promise<import("./automation.js").ScheduledTask | undefined> {
const { AutomationStore } = await import("./automation-store.js");
// Find existing insight extraction schedule by name
const schedules = await automationStore.listSchedules();
const existingSchedule = schedules.find(
(s) => s.name === INSIGHT_EXTRACTION_SCHEDULE_NAME,
);
// If extraction is disabled, delete existing schedule if present
if (!settings.insightExtractionEnabled) {
if (existingSchedule) {
await automationStore.deleteSchedule(existingSchedule.id);
}
return undefined;
}
// Validate the cron schedule
const schedule = settings.insightExtractionSchedule ?? DEFAULT_INSIGHT_SCHEDULE;
if (!AutomationStore.isValidCron(schedule)) {
throw new Error(`Invalid insight extraction schedule: ${schedule}`);
}
// Build the automation input
const input = createInsightExtractionAutomation(settings);
if (existingSchedule) {
// Update existing schedule
return await automationStore.updateSchedule(existingSchedule.id, {
scheduleType: "custom",
cronExpression: schedule,
command: input.command,
steps: input.steps,
enabled: true,
});
} else {
// Create new schedule
return await automationStore.createSchedule(input);
}
}

View File

@@ -859,6 +859,19 @@ export interface ProjectSettings {
/** Reference to a named script in the scripts map that runs before task execution.
* Used for pre-task setup like environment preparation. */
setupScript?: string;
/** When true, enables periodic AI-powered extraction of insights from working memory
* into a distilled long-term memory file. Creates an automation schedule that reads
* `.fusion/memory.md`, identifies patterns/principles/pitfalls, and writes to
* `.fusion/memory-insights.md`. Default: false. */
insightExtractionEnabled?: boolean;
/** Cron expression for insight extraction schedule. Only used when
* insightExtractionEnabled is true. Default: "0 2 * * *" (daily at 2 AM). */
insightExtractionSchedule?: string;
/** Minimum interval between insight extractions in milliseconds. Prevents
* excessive AI calls when working memory hasn't changed significantly.
* Extraction only runs if BOTH this time has elapsed AND memory has grown
* by more than MIN_INSIGHT_GROWTH_CHARS characters. Default: 86400000 (24h). */
insightExtractionMinIntervalMs?: number;
}
/**
@@ -942,6 +955,9 @@ export const DEFAULT_PROJECT_SETTINGS: ProjectSettings = {
titleSummarizerModelId: undefined,
titleSummarizerFallbackProvider: undefined,
titleSummarizerFallbackModelId: undefined,
insightExtractionEnabled: false,
insightExtractionSchedule: "0 2 * * *",
insightExtractionMinIntervalMs: 86_400_000,
};
/**
@@ -1015,6 +1031,9 @@ export const PROJECT_SETTINGS_KEYS: ReadonlyArray<keyof ProjectSettings> = [
"titleSummarizerModelId",
"titleSummarizerFallbackProvider",
"titleSummarizerFallbackModelId",
"insightExtractionEnabled",
"insightExtractionSchedule",
"insightExtractionMinIntervalMs",
] as const;
export interface BoardConfig {