Files
fusion/packages/core/src/memory-insights.ts
Fusion a318f196bc feat(FN-4041): harden empty rawResponse handling in memory-insights
Hardened empty `rawResponse` handling in the memory insights module by adding defensive checks to prevent errors when the response is empty, accompanied by tests to cover those edge cases (FN-4041 Step 1).

Fusion-Task-Id: FN-4041
2026-05-11 17:46:42 -07:00

1750 lines
61 KiB
TypeScript

/**
* 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** (`.fusion/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, unlink } 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/memory-insights.md";
/** Path to memory audit report relative to project root. */
export const MEMORY_AUDIT_PATH = ".fusion/memory/memory-audit.md";
/** Path to persisted memory audit state (latest extraction/pruning metadata). */
export const MEMORY_AUDIT_STATE_PATH = ".fusion/memory/memory-audit-state.json";
const LEGACY_MEMORY_INSIGHTS_PATH = ".fusion/memory-insights.md";
const LEGACY_MEMORY_AUDIT_PATH = ".fusion/memory-audit.md";
const LEGACY_MEMORY_AUDIT_STATE_PATH = ".fusion/memory-audit-state.json";
/** 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;
}
/** Persisted extraction metadata used by audits/routes. */
export interface MemoryExtractionMetadata {
runAt: string;
success: boolean;
insightCount: number;
duplicateCount: number;
skippedCount: number;
summary: string;
error?: 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: MemoryExtractionMetadata;
/** 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";
}
/** Persisted state for audit continuity across requests. */
interface MemoryAuditState {
extraction?: MemoryExtractionMetadata;
pruning?: PruneOutcome;
updatedAt: string;
}
/** 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<string> {
const filePath = join(rootDir, MEMORY_WORKING_PATH);
if (!existsSync(filePath)) {
return "";
}
return readFile(filePath, "utf-8");
}
async function migrateLegacyArtifactIfNeeded(
rootDir: string,
canonicalPath: string,
legacyPath: string,
): Promise<void> {
const canonicalFilePath = join(rootDir, canonicalPath);
const legacyFilePath = join(rootDir, legacyPath);
if (existsSync(canonicalFilePath) || !existsSync(legacyFilePath)) {
return;
}
const content = await readFile(legacyFilePath, "utf-8");
const canonicalDir = dirname(canonicalFilePath);
if (!existsSync(canonicalDir)) {
await mkdir(canonicalDir, { recursive: true });
}
await writeFile(canonicalFilePath, content, "utf-8");
try {
await unlink(legacyFilePath);
} catch {
// Best effort cleanup; canonical write already preserves data.
}
}
async function removeLegacyArtifactIfPresent(rootDir: string, legacyPath: string): Promise<void> {
const legacyFilePath = join(rootDir, legacyPath);
if (!existsSync(legacyFilePath)) {
return;
}
try {
await unlink(legacyFilePath);
} catch {
// Best effort cleanup; inability to delete should not block writes.
}
}
/**
* Read the insights memory file (`.fusion/memory/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()`.
*
* Legacy compatibility: transparently migrates `.fusion/memory-insights.md`
* to the canonical memory workspace path on first read.
*
* @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> {
await migrateLegacyArtifactIfNeeded(rootDir, MEMORY_INSIGHTS_PATH, LEGACY_MEMORY_INSIGHTS_PATH);
const filePath = join(rootDir, MEMORY_INSIGHTS_PATH);
if (!existsSync(filePath)) {
return null;
}
return readFile(filePath, "utf-8");
}
/**
* Write the insights memory file (`.fusion/memory/memory-insights.md`).
*
* 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 writeInsightsMemory(rootDir: string, content: string): Promise<void> {
const filePath = join(rootDir, MEMORY_INSIGHTS_PATH);
const dir = dirname(filePath);
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true });
}
await writeFile(filePath, content, "utf-8");
await removeLegacyArtifactIfPresent(rootDir, LEGACY_MEMORY_INSIGHTS_PATH);
}
/**
* 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<void> {
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 (`.fusion/memory/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> {
await migrateLegacyArtifactIfNeeded(rootDir, MEMORY_AUDIT_PATH, LEGACY_MEMORY_AUDIT_PATH);
const filePath = join(rootDir, MEMORY_AUDIT_PATH);
if (!existsSync(filePath)) {
return null;
}
return readFile(filePath, "utf-8");
}
/**
* Write the memory audit file (`.fusion/memory/memory-audit.md`).
*
* 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 writeMemoryAudit(rootDir: string, content: string): Promise<void> {
const filePath = join(rootDir, MEMORY_AUDIT_PATH);
const dir = dirname(filePath);
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true });
}
await writeFile(filePath, content, "utf-8");
await removeLegacyArtifactIfPresent(rootDir, LEGACY_MEMORY_AUDIT_PATH);
}
/**
* Read persisted memory audit state (`.fusion/memory/memory-audit-state.json`).
*
* Returns `null` when no prior state exists.
*/
async function readMemoryAuditState(rootDir: string): Promise<MemoryAuditState | null> {
await migrateLegacyArtifactIfNeeded(rootDir, MEMORY_AUDIT_STATE_PATH, LEGACY_MEMORY_AUDIT_STATE_PATH);
const filePath = join(rootDir, MEMORY_AUDIT_STATE_PATH);
if (!existsSync(filePath)) {
return null;
}
try {
const raw = await readFile(filePath, "utf-8");
const parsed = JSON.parse(raw) as Partial<MemoryAuditState>;
const extraction = isValidExtractionMetadata(parsed.extraction) ? parsed.extraction : undefined;
const pruning = isValidPruneOutcome(parsed.pruning) ? parsed.pruning : undefined;
return {
extraction,
pruning,
updatedAt: typeof parsed.updatedAt === "string" && parsed.updatedAt.trim()
? parsed.updatedAt
: new Date().toISOString(),
};
} catch {
return null;
}
}
/**
* Persist memory audit state (`.fusion/memory/memory-audit-state.json`).
*/
async function writeMemoryAuditState(rootDir: string, state: MemoryAuditState): Promise<void> {
const filePath = join(rootDir, MEMORY_AUDIT_STATE_PATH);
const dir = dirname(filePath);
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true });
}
await writeFile(filePath, JSON.stringify(state, null, 2), "utf-8");
await removeLegacyArtifactIfPresent(rootDir, LEGACY_MEMORY_AUDIT_STATE_PATH);
}
function isValidExtractionMetadata(value: unknown): value is MemoryExtractionMetadata {
if (!value || typeof value !== "object") {
return false;
}
const candidate = value as Partial<MemoryExtractionMetadata>;
return (
typeof candidate.runAt === "string" &&
typeof candidate.success === "boolean" &&
typeof candidate.insightCount === "number" &&
typeof candidate.duplicateCount === "number" &&
typeof candidate.skippedCount === "number" &&
typeof candidate.summary === "string" &&
(candidate.error === undefined || typeof candidate.error === "string")
);
}
function isValidPruneOutcome(value: unknown): value is PruneOutcome {
if (!value || typeof value !== "object") {
return false;
}
const candidate = value as Partial<PruneOutcome>;
return (
typeof candidate.applied === "boolean" &&
typeof candidate.reason === "string" &&
typeof candidate.sizeDelta === "number" &&
typeof candidate.originalSize === "number" &&
typeof candidate.newSize === "number"
);
}
// ── 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<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,
});
}
}
}
// 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<PruneOutcome> {
// 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<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 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/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 (## <name>) 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<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);
}
}
// ── 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<InsightExtractionResult & { newInsightCount: number; duplicateCount: number; pruneOutcome: PruneOutcome }> {
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("<!--")) {
// 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
* - Pruning outcome (if applicable)
*
* @param rootDir - Absolute path to the project root directory.
* @param lastExtraction - Optional information about the last extraction run.
* @param pruningOutcome - Optional pruning outcome from the last extraction.
* @returns The generated audit report.
*/
export async function generateMemoryAudit(
rootDir: string,
lastExtraction?: MemoryExtractionMetadata,
pruningOutcome?: PruneOutcome,
): Promise<MemoryAuditReport> {
const checks: MemoryAuditCheck[] = [];
const now = new Date().toISOString();
const persistedState =
lastExtraction === undefined || pruningOutcome === undefined
? await readMemoryAuditState(rootDir)
: null;
const effectiveExtraction = lastExtraction ?? persistedState?.extraction;
const effectivePruning = pruningOutcome ?? persistedState?.pruning;
// ── 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/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/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 (effectiveExtraction) {
const extractionAge = Date.now() - new Date(effectiveExtraction.runAt).getTime();
const oneWeekMs = 7 * 24 * 60 * 60 * 1000;
checks.push({
id: "recent-extraction",
name: "Recent extraction activity",
passed: effectiveExtraction.success && extractionAge < oneWeekMs,
details: effectiveExtraction.success
? `Last successful extraction ${formatTimeAgo(effectiveExtraction.runAt)} (${effectiveExtraction.insightCount} insights, ${effectiveExtraction.duplicateCount} duplicates skipped)`
: `Last extraction failed: ${effectiveExtraction.error || "Unknown error"}`,
});
// Check 7: Extraction summary quality
checks.push({
id: "extraction-summary",
name: "Extraction produces meaningful summaries",
passed: effectiveExtraction.success && effectiveExtraction.summary.length > 10,
details: effectiveExtraction.success
? `Summary: "${effectiveExtraction.summary.slice(0, 100)}${effectiveExtraction.summary.length > 100 ? "..." : ""}"`
: "No meaningful summary available",
});
} else {
checks.push({
id: "recent-extraction",
name: "Recent extraction activity",
passed: false,
details: "No extraction runs recorded",
});
}
// ── Check 8: Pruning outcome ──────────────────────────────────────
if (effectivePruning) {
checks.push({
id: "pruning-applied",
name: "Memory pruning outcome",
passed: effectivePruning.applied,
details: effectivePruning.applied
? `Pruning applied: ${effectivePruning.originalSize}${effectivePruning.newSize} chars (${effectivePruning.sizeDelta >= 0 ? "+" : ""}${effectivePruning.sizeDelta} chars)`
: `Pruning skipped: ${effectivePruning.reason}`,
});
}
// ── 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: effectiveExtraction
? {
runAt: effectiveExtraction.runAt,
success: effectiveExtraction.success,
insightCount: effectiveExtraction.insightCount,
duplicateCount: effectiveExtraction.duplicateCount,
skippedCount: effectiveExtraction.skippedCount,
summary: effectiveExtraction.summary,
error: effectiveExtraction.error,
}
: {
runAt: "",
success: false,
insightCount: 0,
duplicateCount: 0,
skippedCount: 0,
summary: "No extraction runs recorded",
},
pruning: effectivePruning ?? {
applied: false,
reason: "No pruning run recorded",
sizeDelta: 0,
originalSize: workingMemorySize,
newSize: workingMemorySize,
},
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(``);
// Pruning Section
lines.push(`## Memory Pruning`);
if (report.pruning) {
lines.push(`- **Applied:** ${report.pruning.applied ? "✓ Yes" : "✗ No"}`);
lines.push(`- **Reason:** ${report.pruning.reason}`);
if (report.pruning.applied) {
lines.push(`- **Size Change:** ${report.pruning.originalSize}${report.pruning.newSize} characters`);
const deltaStr = report.pruning.sizeDelta >= 0 ? `+${report.pruning.sizeDelta}` : `${report.pruning.sizeDelta}`;
lines.push(`- **Delta:** ${deltaStr} characters`);
}
} else {
lines.push(`- No pruning 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, pruning)
* 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: MemoryExtractionMetadata;
let pruneOutcome: PruneOutcome | undefined;
try {
// Process the run (includes pruning)
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,
};
// Capture pruning outcome
pruneOutcome = result.pruneOutcome;
} 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),
};
// Get the current working memory size for the pruning report
const currentMemory = await readWorkingMemory(rootDir);
pruneOutcome = {
applied: false,
reason: `Processing failed before pruning: ${err instanceof Error ? err.message : String(err)}`,
sizeDelta: 0,
originalSize: currentMemory.length,
newSize: currentMemory.length,
};
}
// Persist latest extraction/pruning state for future audit reads (best-effort)
try {
await writeMemoryAuditState(rootDir, {
extraction: extractionInfo,
pruning: pruneOutcome,
updatedAt: new Date().toISOString(),
});
} catch (err) {
console.error(
`[memory-audit] Failed to persist audit state: ${err instanceof Error ? err.message : String(err)}`,
);
}
// Generate the audit report with pruning info
const auditReport = await generateMemoryAudit(rootDir, extractionInfo, pruneOutcome);
// 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;
}