/** * 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 { dirname, 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/MEMORY.md"; /** Path to insights memory relative to project root. */ 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. */ 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; /** Optional pruned working memory candidate (durable items only). */ prunedMemory?: string; } /** Validation result for a prune candidate. */ export interface PruneValidationResult { /** Whether the prune candidate is valid. */ valid: boolean; /** Human-readable reason if invalid. */ reason?: string; /** Size of the pruned content. */ size: number; /** Whether canonical section headers are preserved. */ hasRequiredSections: boolean; } /** Outcome of a pruning operation. */ export interface PruneOutcome { /** Whether pruning was applied. */ applied: boolean; /** Human-readable reason for the outcome. */ reason: string; /** Size delta (new size - old size) in characters. */ sizeDelta: number; /** Original size in characters. */ originalSize: number; /** New size in characters. */ newSize: number; } // ── 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; lastUpdated?: string; }; /** Extraction metadata. */ extraction: { runAt: string; success: boolean; insightCount: number; duplicateCount: number; skippedCount: number; summary: string; error?: string; }; /** Pruning operation outcome. */ pruning: { applied: boolean; reason: string; sizeDelta: number; originalSize: number; newSize: number; }; /** 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 ───────────────────────────────────────────────────────── /** * Read the long-term project memory file. * * Returns an empty string if the file does not exist, enabling graceful * handling when the memory system has not been initialized yet. * * @param rootDir - Absolute path to the project root directory. * @returns The long-term memory content, or empty string if not found. */ export async function readWorkingMemory(rootDir: string): Promise { 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 { 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 { 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"); } /** * Write the long-term project memory file. * * Creates the `.fusion/memory/` 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 writeWorkingMemory(rootDir: string, content: string): Promise { const filePath = join(rootDir, MEMORY_WORKING_PATH); const dir = dirname(filePath); if (!existsSync(dir)) { await mkdir(dir, { recursive: true }); } 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 { 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 { 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 ─────────────────────────────────────────── /** * 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 * * The response may include a `prunedMemory` field containing a candidate * for the pruned working memory. This field is optional and may be * present alongside insights. * * @param response - The raw AI agent response text. * @returns Parsed InsightExtractionResult with insights and optional prune candidate. * @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; 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, }); } } } // Extract optional pruned memory candidate // Handle various formats: string, empty string, null, undefined let prunedMemory: string | undefined; if (parsed.prunedMemory !== undefined && parsed.prunedMemory !== null) { if (typeof parsed.prunedMemory === "string" && parsed.prunedMemory.trim()) { prunedMemory = parsed.prunedMemory.trim(); } } return { insights, summary, extractedAt: new Date().toISOString(), prunedMemory, }; } /** * 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"; } // ── Prune Validation ───────────────────────────────────────────────── /** Canonical section headers that must be preserved in working memory. */ const REQUIRED_MEMORY_SECTIONS = ["Architecture", "Conventions", "Pitfalls"]; /** * Validate a prune candidate for safety. * * A valid prune candidate must: * 1. Not be empty * 2. Preserve at least 2 of the 3 required section headers (Architecture, Conventions, Pitfalls) * * @param candidate - The pruned memory content to validate. * @returns Validation result with details. */ export function validatePruneCandidate(candidate: string | undefined): PruneValidationResult { // Undefined is invalid if (!candidate || typeof candidate !== "string") { return { valid: false, reason: "Prune candidate is undefined or empty", size: 0, hasRequiredSections: false, }; } const trimmed = candidate.trim(); const size = trimmed.length; // Empty after trim is invalid if (size === 0) { return { valid: false, reason: "Prune candidate is empty after trimming", size: 0, hasRequiredSections: false, }; } // Check for required sections const foundSections: string[] = []; for (const section of REQUIRED_MEMORY_SECTIONS) { if (trimmed.includes(`## ${section}`)) { foundSections.push(section); } } const hasRequiredSections = foundSections.length >= 2; // Must have at least 2 of the 3 required sections if (!hasRequiredSections) { return { valid: false, reason: `Missing required sections: only ${foundSections.length} of ${REQUIRED_MEMORY_SECTIONS.length} required sections found (${foundSections.join(", ") || "none"})`, size, hasRequiredSections: false, }; } return { valid: true, size, hasRequiredSections: true, }; } /** * Safely apply pruning to working memory. * * This function validates the prune candidate before writing. If validation * fails, the existing working memory is preserved. * * @param rootDir - Absolute path to the project root directory. * @param pruneCandidate - The pruned memory content from AI. * @returns The prune outcome with details. */ export async function applyMemoryPruning( rootDir: string, pruneCandidate: string | undefined, ): Promise { // Read current working memory size const existingContent = await readWorkingMemory(rootDir); const originalSize = existingContent.length; // Validate the prune candidate const validation = validatePruneCandidate(pruneCandidate); if (!validation.valid) { return { applied: false, reason: validation.reason || "Invalid prune candidate", sizeDelta: 0, originalSize, newSize: originalSize, }; } // Validate passes — write the pruned content const newContent = pruneCandidate!.trim(); await writeWorkingMemory(rootDir, newContent); return { applied: true, reason: "Prune candidate validated and applied", sizeDelta: validation.size - originalSize, originalSize, newSize: validation.size, }; } // ── 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(); 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 = { 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, 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 ## Principles ## Conventions ## Pitfalls ## Context ## 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, 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 and Pruning agent. Your job is to analyze the project's working memory, extract long-term insights, and prune working memory to only durable items. ## Instructions 1. Read the working memory file at \`.fusion/memory/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: a) **New insights** that should be preserved in long-term memory b) **Durable content** that should remain in working memory c) **Transient content** that can be pruned (one-time observations, task-specific notes, outdated entries) ## Insight Extraction Guidelines 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 Do NOT duplicate insights already in the existing insights file. ## Working Memory Pruning Guidelines After extracting insights, also produce a PRUNED version of working memory containing ONLY durable items: **MUST PRESERVE (durable items):** - Architecture: Project structure, key abstractions, major components - Conventions: Coding standards, naming patterns, established practices - Pitfalls: Known issues to avoid, anti-patterns to watch for - Context: Important background that informs decision-making - Any section header (## ) should stay if it contains durable content **SHOULD PRUNE (transient items):** - One-time observations from completed tasks - Task-specific implementation notes - Outdated or superseded entries - Verbose explanations that can be condensed - Entries that are now obvious/common knowledge **CRITICAL REQUIREMENTS:** - You MUST preserve at least 2 of these 3 core sections: Architecture, Conventions, Pitfalls - If working memory doesn't have enough durable content to justify pruning, omit \`prunedMemory\` entirely (do not force a prune) - The \`prunedMemory\` field should be a complete, valid markdown file that can replace \`.fusion/memory/MEMORY.md\` ## Output Format Return ONLY a JSON object with this exact structure (no markdown fences, no extra text): \`\`\`json { "summary": "Brief summary of what was extracted and pruned", "insights": [ { "category": "pattern|principle|convention|pitfall|context", "content": "The insight text", "source": "Optional reference to what triggered this insight" } ], "prunedMemory": "## Architecture\\n\\nDurable architecture notes...\\n\\n## Conventions\\n\\nDurable conventions...\\n\\n## Pitfalls\\n\\nDurable pitfalls..." } \`\`\` **Important:** The \`prunedMemory\` field is OPTIONAL. Only include it if: 1. There is substantial transient content to prune (more than ~20% can be removed) 2. You can preserve at least 2 of the 3 core sections (Architecture, Conventions, Pitfalls) If no new insights are found AND nothing substantial can be pruned, return: \`\`\`json {"summary": "No new insights found, no pruning needed", "insights": [], "prunedMemory": null} \`\`\` If the working memory file does not exist or is empty, return: \`\`\`json {"summary": "No working memory to analyze", "insights": []} \`\`\``; return { name: INSIGHT_EXTRACTION_SCHEDULE_NAME, description: "Extracts insights from working memory and prunes transient content", 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 and Prune", 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, ): Promise { 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); } } // ── 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, applies * memory pruning if valid, and generates an audit report. * * **Safety guarantees:** * - Malformed AI output does not destroy existing insights content * - Malformed prune candidates never overwrite working memory * - 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 with prune outcome. */ export async function processInsightExtractionRun( rootDir: string, input: ProcessRunInput, ): Promise { 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 // Note: we don't re-throw here to preserve existing behavior 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); // Apply pruning if a prune candidate was provided const pruneOutcome = await applyMemoryPruning(rootDir, parsedResult.prunedMemory); return { ...parsedResult, newInsightCount, duplicateCount, pruneOutcome, }; } /** * 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("