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:
@@ -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";
|
||||
|
||||
538
packages/core/src/memory-insights.test.ts
Normal file
538
packages/core/src/memory-insights.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
623
packages/core/src/memory-insights.ts
Normal file
623
packages/core/src/memory-insights.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user