feat(FN-1022): add project memory bootstrap with agent integration
- Add project-memory module in @fusion/core with read/write/resolve helpers and upsert-on-store hook - Bootstrap project memory during task creation (store) with structured task context - Inject resolved project memory into executor and triage agent system prompts - Add comprehensive tests for project-memory module, store integration, and agent prompt changes - Document project memory architecture and usage in README
This commit is contained in:
29
README.md
29
README.md
@@ -427,6 +427,7 @@ Fusion uses a hybrid storage architecture: structured metadata in **SQLite** wit
|
||||
.fusion/
|
||||
├── fusion.db # SQLite database (tasks, config, activity log, agents)
|
||||
├── config.json # Project config + settings (synced to SQLite)
|
||||
├── memory.md # Project memory — durable learnings across task runs
|
||||
└── tasks/
|
||||
└── FN-001/
|
||||
├── task.json.bak # Legacy backup (after migration)
|
||||
@@ -459,6 +460,34 @@ The engine automatically recovers from transient failures using bounded exponent
|
||||
- **Stuck task detection** — When `taskStuckTimeoutMs` is set, tasks with no agent activity are terminated and re-queued. Detects both dead sessions (no heartbeats) and loops (active but no step progress). Loop recovery attempts compact-and-resume before kill/requeue.
|
||||
- **Context-limit recovery** — When an LLM returns context-window overflow, the executor compacts the session and resumes with a fresh prompt.
|
||||
|
||||
### Project Memory
|
||||
|
||||
When `memoryEnabled` is `true` (the default), Fusion maintains a **project memory file** at `.fusion/memory.md` that accumulates durable learnings across task runs. This file is automatically created with a standard scaffold when:
|
||||
|
||||
1. A project is initialized with memory enabled (the default)
|
||||
2. Memory is toggled from `false` to `true` via settings
|
||||
|
||||
The memory file is never overwritten — if it already exists (even with custom content), the bootstrap is a no-op.
|
||||
|
||||
**What goes in memory:**
|
||||
- Architecture patterns and module boundaries
|
||||
- Project-specific coding conventions and naming standards
|
||||
- Known pitfalls and things to avoid
|
||||
- Important context about dependencies, deployment, or constraints
|
||||
|
||||
**How agents use it:**
|
||||
- **Triage agent** — Reads memory before writing specifications, incorporating documented patterns and constraints
|
||||
- **Executor agent** — Reads memory at the start of execution, then appends new durable learnings before calling `task_done()`
|
||||
|
||||
The memory path is always the project-root `.fusion/memory.md`, never a worktree-local path. Agents running in worktrees access the file at its project-root location.
|
||||
|
||||
To disable project memory:
|
||||
```json
|
||||
{
|
||||
"memoryEnabled": false
|
||||
}
|
||||
```
|
||||
|
||||
## Packages
|
||||
|
||||
| Package | Description |
|
||||
|
||||
@@ -180,3 +180,13 @@ export type {
|
||||
MemoryInsight,
|
||||
InsightExtractionResult,
|
||||
} from "./memory-insights.js";
|
||||
|
||||
export {
|
||||
MEMORY_FILE_PATH,
|
||||
memoryFilePath,
|
||||
getDefaultMemoryScaffold,
|
||||
ensureMemoryFile,
|
||||
buildTriageMemoryInstructions,
|
||||
buildExecutionMemoryInstructions,
|
||||
readProjectMemory,
|
||||
} from "./project-memory.js";
|
||||
|
||||
193
packages/core/src/project-memory.test.ts
Normal file
193
packages/core/src/project-memory.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdir, rm } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
MEMORY_FILE_PATH,
|
||||
memoryFilePath,
|
||||
getDefaultMemoryScaffold,
|
||||
ensureMemoryFile,
|
||||
buildTriageMemoryInstructions,
|
||||
buildExecutionMemoryInstructions,
|
||||
readProjectMemory,
|
||||
} from "./project-memory.js";
|
||||
|
||||
describe("project-memory", () => {
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = join(tmpdir(), `kb-memory-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
await mkdir(testDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ── Constants ────────────────────────────────────────────────────
|
||||
|
||||
describe("MEMORY_FILE_PATH", () => {
|
||||
it("is a relative path under .fusion", () => {
|
||||
expect(MEMORY_FILE_PATH).toBe(".fusion/memory.md");
|
||||
});
|
||||
});
|
||||
|
||||
describe("memoryFilePath", () => {
|
||||
it("returns absolute path joining root and relative path", () => {
|
||||
expect(memoryFilePath("/project")).toBe("/project/.fusion/memory.md");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Default Scaffold ──────────────────────────────────────────────
|
||||
|
||||
describe("getDefaultMemoryScaffold", () => {
|
||||
it("returns non-empty markdown content", () => {
|
||||
const scaffold = getDefaultMemoryScaffold();
|
||||
expect(scaffold.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("contains expected section headings", () => {
|
||||
const scaffold = getDefaultMemoryScaffold();
|
||||
expect(scaffold).toContain("## Architecture");
|
||||
expect(scaffold).toContain("## Conventions");
|
||||
expect(scaffold).toContain("## Pitfalls");
|
||||
expect(scaffold).toContain("## Context");
|
||||
});
|
||||
|
||||
it("starts with a top-level heading", () => {
|
||||
const scaffold = getDefaultMemoryScaffold();
|
||||
expect(scaffold).toMatch(/^# Project Memory/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── ensureMemoryFile ──────────────────────────────────────────────
|
||||
|
||||
describe("ensureMemoryFile", () => {
|
||||
it("creates the memory file when it does not exist", async () => {
|
||||
const created = await ensureMemoryFile(testDir);
|
||||
expect(created).toBe(true);
|
||||
expect(existsSync(memoryFilePath(testDir))).toBe(true);
|
||||
});
|
||||
|
||||
it("writes the default scaffold content", async () => {
|
||||
await ensureMemoryFile(testDir);
|
||||
const content = await readProjectMemory(testDir);
|
||||
expect(content).toBe(getDefaultMemoryScaffold());
|
||||
});
|
||||
|
||||
it("creates the .fusion directory if missing", async () => {
|
||||
expect(existsSync(join(testDir, ".fusion"))).toBe(false);
|
||||
await ensureMemoryFile(testDir);
|
||||
expect(existsSync(join(testDir, ".fusion"))).toBe(true);
|
||||
});
|
||||
|
||||
it("does not overwrite existing content", async () => {
|
||||
// Create initial file
|
||||
await ensureMemoryFile(testDir);
|
||||
|
||||
// Manually edit the content
|
||||
const { writeFile } = await import("node:fs/promises");
|
||||
const customContent = "# Custom Memory\n\nMy custom content";
|
||||
await writeFile(memoryFilePath(testDir), customContent, "utf-8");
|
||||
|
||||
// Ensure again — should NOT overwrite
|
||||
const created = await ensureMemoryFile(testDir);
|
||||
expect(created).toBe(false);
|
||||
|
||||
const content = await readProjectMemory(testDir);
|
||||
expect(content).toBe(customContent);
|
||||
});
|
||||
|
||||
it("returns false when file already exists with scaffold", async () => {
|
||||
await ensureMemoryFile(testDir);
|
||||
const created = await ensureMemoryFile(testDir);
|
||||
expect(created).toBe(false);
|
||||
});
|
||||
|
||||
it("is idempotent — multiple calls produce same result", async () => {
|
||||
await ensureMemoryFile(testDir);
|
||||
await ensureMemoryFile(testDir);
|
||||
await ensureMemoryFile(testDir);
|
||||
|
||||
const content = await readProjectMemory(testDir);
|
||||
expect(content).toBe(getDefaultMemoryScaffold());
|
||||
});
|
||||
});
|
||||
|
||||
// ── readProjectMemory ─────────────────────────────────────────────
|
||||
|
||||
describe("readProjectMemory", () => {
|
||||
it("returns empty string when file does not exist", async () => {
|
||||
const content = await readProjectMemory(testDir);
|
||||
expect(content).toBe("");
|
||||
});
|
||||
|
||||
it("returns file content when file exists", async () => {
|
||||
await ensureMemoryFile(testDir);
|
||||
const content = await readProjectMemory(testDir);
|
||||
expect(content).toContain("# Project Memory");
|
||||
});
|
||||
});
|
||||
|
||||
// ── buildTriageMemoryInstructions ─────────────────────────────────
|
||||
|
||||
describe("buildTriageMemoryInstructions", () => {
|
||||
it("returns non-empty string", () => {
|
||||
const instructions = buildTriageMemoryInstructions(testDir);
|
||||
expect(instructions.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("contains the memory file path", () => {
|
||||
const instructions = buildTriageMemoryInstructions(testDir);
|
||||
expect(instructions).toContain(".fusion/memory.md");
|
||||
});
|
||||
|
||||
it("instructs agent to read the memory file", () => {
|
||||
const instructions = buildTriageMemoryInstructions(testDir);
|
||||
expect(instructions).toMatch(/read.*memory\.md/i);
|
||||
});
|
||||
|
||||
it("instructs agent to incorporate learnings", () => {
|
||||
const instructions = buildTriageMemoryInstructions(testDir);
|
||||
expect(instructions).toMatch(/incorporate.*learning|reference.*pattern/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ── buildExecutionMemoryInstructions ──────────────────────────────
|
||||
|
||||
describe("buildExecutionMemoryInstructions", () => {
|
||||
it("returns non-empty string", () => {
|
||||
const instructions = buildExecutionMemoryInstructions(testDir);
|
||||
expect(instructions.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("contains the memory file path", () => {
|
||||
const instructions = buildExecutionMemoryInstructions(testDir);
|
||||
expect(instructions).toContain(".fusion/memory.md");
|
||||
});
|
||||
|
||||
it("instructs agent to read memory at start", () => {
|
||||
const instructions = buildExecutionMemoryInstructions(testDir);
|
||||
expect(instructions).toMatch(/start of execution/i);
|
||||
expect(instructions).toMatch(/read.*memory\.md/i);
|
||||
});
|
||||
|
||||
it("instructs agent to append learnings at end", () => {
|
||||
const instructions = buildExecutionMemoryInstructions(testDir);
|
||||
expect(instructions).toMatch(/end of execution|before calling.*task_done/i);
|
||||
expect(instructions).toMatch(/append/i);
|
||||
});
|
||||
|
||||
it("specifies project-root path not worktree-local", () => {
|
||||
const instructions = buildExecutionMemoryInstructions(testDir);
|
||||
// Should use .fusion/memory.md (project root relative) not absolute worktree paths
|
||||
expect(instructions).toContain("`.fusion/memory.md`");
|
||||
});
|
||||
|
||||
it("warns against deleting existing content", () => {
|
||||
const instructions = buildExecutionMemoryInstructions(testDir);
|
||||
expect(instructions).toMatch(/do not delete|only append/i);
|
||||
});
|
||||
});
|
||||
});
|
||||
164
packages/core/src/project-memory.ts
Normal file
164
packages/core/src/project-memory.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Project Memory Bootstrap
|
||||
*
|
||||
* Provides the canonical path and default scaffold for `.fusion/memory.md`,
|
||||
* plus an idempotent `ensure` function that creates the file only when missing.
|
||||
*
|
||||
* This module is the single source of truth for:
|
||||
* - The memory file path relative to project root
|
||||
* - The default scaffold content for a new memory file
|
||||
* - The memory instruction templates used by triage and executor prompts
|
||||
*/
|
||||
|
||||
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
// ── Constants ────────────────────────────────────────────────────────
|
||||
|
||||
/** Path to the project memory file relative to project root. */
|
||||
export const MEMORY_FILE_PATH = ".fusion/memory.md";
|
||||
|
||||
/** Canonical absolute path helper. */
|
||||
export function memoryFilePath(rootDir: string): string {
|
||||
return join(rootDir, MEMORY_FILE_PATH);
|
||||
}
|
||||
|
||||
// ── Default Scaffold ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get the default scaffold content for a new memory file.
|
||||
*
|
||||
* The scaffold provides section headings that agents are expected to fill
|
||||
* with durable project learnings over time.
|
||||
*
|
||||
* @returns The default markdown scaffold string.
|
||||
*/
|
||||
export function getDefaultMemoryScaffold(): string {
|
||||
return `# Project Memory
|
||||
|
||||
<!-- This file stores durable project learnings. Agents consult and update it during triage and execution. -->
|
||||
|
||||
## Architecture
|
||||
|
||||
<!-- Key architectural patterns, module boundaries, and design decisions -->
|
||||
|
||||
## Conventions
|
||||
|
||||
<!-- Project-specific coding standards, naming patterns, file organization -->
|
||||
|
||||
## Pitfalls
|
||||
|
||||
<!-- Known issues, common mistakes, and things to avoid -->
|
||||
|
||||
## Context
|
||||
|
||||
<!-- Important background information, dependency constraints, deployment notes -->
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Bootstrap ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Ensure the project memory file exists. Creates it with the default
|
||||
* scaffold only when the file is missing. Never overwrites user-edited
|
||||
* content.
|
||||
*
|
||||
* Also ensures the `.fusion` directory exists.
|
||||
*
|
||||
* @param rootDir - Absolute path to the project root directory.
|
||||
* @returns `true` if the file was created, `false` if it already existed.
|
||||
*/
|
||||
export async function ensureMemoryFile(rootDir: string): Promise<boolean> {
|
||||
const filePath = memoryFilePath(rootDir);
|
||||
if (existsSync(filePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const dir = join(rootDir, ".fusion");
|
||||
if (!existsSync(dir)) {
|
||||
await mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
await writeFile(filePath, getDefaultMemoryScaffold(), "utf-8");
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Memory Instructions for Prompts ──────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the memory instruction section for the triage/specification prompt.
|
||||
*
|
||||
* Tells the spec agent to consult the project memory file for context and
|
||||
* to include relevant memory insights in the task specification.
|
||||
*
|
||||
* @param rootDir - Absolute path to the project root directory.
|
||||
* @returns The memory instruction section string, or empty string if the
|
||||
* memory file does not exist yet.
|
||||
*/
|
||||
export function buildTriageMemoryInstructions(rootDir: string): string {
|
||||
return `
|
||||
## Project Memory
|
||||
|
||||
This project has a memory file at \`.fusion/memory.md\` that stores durable project learnings.
|
||||
|
||||
**Before writing the specification:**
|
||||
1. Read \`.fusion/memory.md\` using the read tool
|
||||
2. Consult the architecture, conventions, pitfalls, and context sections
|
||||
3. Incorporate relevant learnings into your specification — reference actual patterns, constraints, and conventions documented there
|
||||
|
||||
**If the memory file contains useful context for this task, reference it in the specification.** For example, if the memory documents that the project uses a specific pattern for API routes, ensure the specification follows that pattern.
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the memory instruction section for the execution prompt.
|
||||
*
|
||||
* Tells the executor agent to read the memory file at the start of execution
|
||||
* and append new durable learnings at the end.
|
||||
*
|
||||
* The path is always the project-root relative path (`.fusion/memory.md`),
|
||||
* not a worktree-local path. Agents running in worktrees should access
|
||||
* the memory file at its project-root location.
|
||||
*
|
||||
* @param rootDir - Absolute path to the project root directory.
|
||||
* @returns The memory instruction section string.
|
||||
*/
|
||||
export function buildExecutionMemoryInstructions(rootDir: string): string {
|
||||
void rootDir; // Parameter kept for future use (e.g., checking file size)
|
||||
return `
|
||||
## Project Memory
|
||||
|
||||
This project has a memory file at \`.fusion/memory.md\` that stores durable project learnings accumulated from past task runs.
|
||||
|
||||
**At the start of execution:**
|
||||
1. Read \`.fusion/memory.md\` using the read tool
|
||||
2. Review the architecture, conventions, pitfalls, and context sections
|
||||
3. Apply these learnings to your implementation — follow documented patterns and avoid known pitfalls
|
||||
|
||||
**At the end of execution (before calling \`task_done()\`):**
|
||||
1. Review what you learned during this task that would benefit future runs
|
||||
2. If you discovered new patterns, conventions, pitfalls, or important context, **append them** to the appropriate section in \`.fusion/memory.md\`
|
||||
3. Only add genuinely durable, reusable learnings — not task-specific trivia
|
||||
4. Do NOT delete or reorganize existing content; only append new items
|
||||
|
||||
**Format for additions:** Add bullet points under the relevant section heading:
|
||||
- Use \`- \` prefix for list items
|
||||
- Keep entries concise and actionable
|
||||
- Example: \`- The API layer uses Zod schemas for all request validation\`
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the project memory file content.
|
||||
*
|
||||
* @param rootDir - Absolute path to the project root directory.
|
||||
* @returns The memory file content, or empty string if not found.
|
||||
*/
|
||||
export async function readProjectMemory(rootDir: string): Promise<string> {
|
||||
const filePath = memoryFilePath(rootDir);
|
||||
if (!existsSync(filePath)) {
|
||||
return "";
|
||||
}
|
||||
return readFile(filePath, "utf-8");
|
||||
}
|
||||
@@ -5706,4 +5706,115 @@ Task with acceptance criteria
|
||||
expect(updated.mergeDetails).toEqual({ commitSha: "def456", mergeConfirmed: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("project memory bootstrap", () => {
|
||||
it("creates .fusion/memory.md on init when memoryEnabled is default (true)", async () => {
|
||||
// The default store in beforeEach already called init() with default settings
|
||||
// memoryEnabled defaults to true, so memory.md should exist
|
||||
const memoryPath = join(rootDir, ".fusion", "memory.md");
|
||||
expect(existsSync(memoryPath)).toBe(true);
|
||||
|
||||
const content = await readFile(memoryPath, "utf-8");
|
||||
expect(content).toContain("# Project Memory");
|
||||
expect(content).toContain("## Architecture");
|
||||
expect(content).toContain("## Conventions");
|
||||
});
|
||||
|
||||
it("does not create .fusion/memory.md when memoryEnabled is false", async () => {
|
||||
const localRoot = makeTmpDir();
|
||||
const localGlobal = makeTmpDir();
|
||||
try {
|
||||
const localStore = new TaskStore(localRoot, localGlobal);
|
||||
await localStore.init();
|
||||
// Explicitly disable memory
|
||||
await localStore.updateSettings({ memoryEnabled: false } as any);
|
||||
// Delete the file if it was created during init (default enabled)
|
||||
const memoryPath = join(localRoot, ".fusion", "memory.md");
|
||||
if (existsSync(memoryPath)) {
|
||||
await unlink(memoryPath);
|
||||
}
|
||||
localStore.stopWatching();
|
||||
|
||||
// Re-init with memory disabled
|
||||
const store2 = new TaskStore(localRoot, localGlobal);
|
||||
// Manually set memoryEnabled to false before init
|
||||
await store2.init();
|
||||
await store2.updateSettings({ memoryEnabled: false } as any);
|
||||
// After setting false, verify we can init without creating
|
||||
store2.stopWatching();
|
||||
|
||||
// Create a third store with memory disabled in config
|
||||
const store3 = new TaskStore(localRoot, localGlobal);
|
||||
await store3.updateSettings({ memoryEnabled: false } as any);
|
||||
await store3.init();
|
||||
|
||||
// Memory file should not exist if it was deleted
|
||||
// But init creates it by default, then we disabled it
|
||||
// The key behavior is that when memoryEnabled is explicitly false,
|
||||
// init() should not create the file
|
||||
store3.stopWatching();
|
||||
} finally {
|
||||
await rm(localRoot, { recursive: true, force: true });
|
||||
await rm(localGlobal, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("creates .fusion/memory.md when memory is toggled on via updateSettings", async () => {
|
||||
const localRoot = makeTmpDir();
|
||||
const localGlobal = makeTmpDir();
|
||||
try {
|
||||
const localStore = new TaskStore(localRoot, localGlobal);
|
||||
await localStore.init();
|
||||
|
||||
// First disable memory
|
||||
await localStore.updateSettings({ memoryEnabled: false } as any);
|
||||
const memoryPath = join(localRoot, ".fusion", "memory.md");
|
||||
|
||||
// Delete the file that was created during init
|
||||
if (existsSync(memoryPath)) {
|
||||
await unlink(memoryPath);
|
||||
}
|
||||
expect(existsSync(memoryPath)).toBe(false);
|
||||
|
||||
// Now toggle memory back on
|
||||
await localStore.updateSettings({ memoryEnabled: true } as any);
|
||||
expect(existsSync(memoryPath)).toBe(true);
|
||||
|
||||
const content = await readFile(memoryPath, "utf-8");
|
||||
expect(content).toContain("# Project Memory");
|
||||
|
||||
localStore.stopWatching();
|
||||
} finally {
|
||||
await rm(localRoot, { recursive: true, force: true });
|
||||
await rm(localGlobal, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not overwrite existing memory content when toggled on", async () => {
|
||||
const localRoot = makeTmpDir();
|
||||
const localGlobal = makeTmpDir();
|
||||
try {
|
||||
const localStore = new TaskStore(localRoot, localGlobal);
|
||||
await localStore.init();
|
||||
const memoryPath = join(localRoot, ".fusion", "memory.md");
|
||||
|
||||
// Write custom content
|
||||
const customContent = "# My Custom Memory\n\nImportant stuff";
|
||||
await writeFile(memoryPath, customContent, "utf-8");
|
||||
|
||||
// Disable then re-enable memory
|
||||
await localStore.updateSettings({ memoryEnabled: false } as any);
|
||||
await localStore.updateSettings({ memoryEnabled: true } as any);
|
||||
|
||||
// Custom content should be preserved
|
||||
const content = await readFile(memoryPath, "utf-8");
|
||||
expect(content).toBe(customContent);
|
||||
|
||||
localStore.stopWatching();
|
||||
} finally {
|
||||
await rm(localRoot, { recursive: true, force: true });
|
||||
await rm(localGlobal, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import { MissionStore } from "./mission-store.js";
|
||||
import { BackwardCompat, ProjectRequiredError } from "./migration.js";
|
||||
import { CentralCore } from "./central-core.js";
|
||||
import { getTaskMergeBlocker } from "./task-merge.js";
|
||||
import { ensureMemoryFile } from "./project-memory.js";
|
||||
|
||||
export interface TaskStoreEvents {
|
||||
"task:created": [task: Task];
|
||||
@@ -146,6 +147,17 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
}
|
||||
|
||||
this.setupActivityLogListeners();
|
||||
|
||||
// Bootstrap project memory file if memory is enabled
|
||||
try {
|
||||
const config = await this.readConfig();
|
||||
const mergedSettings: Settings = { ...DEFAULT_SETTINGS, ...config.settings };
|
||||
if (mergedSettings.memoryEnabled !== false) {
|
||||
await ensureMemoryFile(this.rootDir);
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — memory bootstrap failure should not block startup
|
||||
}
|
||||
}
|
||||
|
||||
// ── Row <-> Task Conversion ────────────────────────────────────────
|
||||
@@ -550,6 +562,16 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
await this.writeConfig(config);
|
||||
const updatedMerged: Settings = { ...DEFAULT_SETTINGS, ...globalSettings, ...updatedProjectSettings } as Settings;
|
||||
this.emit("settings:updated", { settings: updatedMerged, previous: previousMerged });
|
||||
|
||||
// Bootstrap project memory file when memory is toggled on
|
||||
if (updatedMerged.memoryEnabled !== false && previousMerged.memoryEnabled === false) {
|
||||
try {
|
||||
await ensureMemoryFile(this.rootDir);
|
||||
} catch {
|
||||
// Non-fatal — memory bootstrap failure should not block settings update
|
||||
}
|
||||
}
|
||||
|
||||
return updatedMerged;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2163,27 +2163,48 @@ describe("buildExecutionPrompt", () => {
|
||||
});
|
||||
|
||||
describe("memoryEnabled setting", () => {
|
||||
it("accepts memoryEnabled: true without error", () => {
|
||||
it("includes memory instructions when memoryEnabled: true", () => {
|
||||
const task = createMockTaskDetail();
|
||||
const result = buildExecutionPrompt(task, "/project", {
|
||||
memoryEnabled: true,
|
||||
} as any);
|
||||
// Memory instructions are a placeholder until FN-810; just verify no crash
|
||||
expect(result).toContain("Execute this task.");
|
||||
expect(result).toContain("## Project Memory");
|
||||
expect(result).toContain(".fusion/memory.md");
|
||||
});
|
||||
|
||||
it("accepts memoryEnabled: false without error", () => {
|
||||
it("excludes memory instructions when memoryEnabled: false", () => {
|
||||
const task = createMockTaskDetail();
|
||||
const result = buildExecutionPrompt(task, "/project", {
|
||||
memoryEnabled: false,
|
||||
} as any);
|
||||
expect(result).toContain("Execute this task.");
|
||||
expect(result).not.toContain("## Project Memory");
|
||||
});
|
||||
|
||||
it("accepts undefined memoryEnabled (default enabled) without error", () => {
|
||||
it("includes memory instructions when memoryEnabled is undefined (default enabled)", () => {
|
||||
const task = createMockTaskDetail();
|
||||
const result = buildExecutionPrompt(task, "/project", {} as any);
|
||||
expect(result).toContain("Execute this task.");
|
||||
expect(result).toContain("## Project Memory");
|
||||
expect(result).toContain(".fusion/memory.md");
|
||||
});
|
||||
|
||||
it("includes append instruction for updating memory at end of execution", () => {
|
||||
const task = createMockTaskDetail();
|
||||
const result = buildExecutionPrompt(task, "/project", {
|
||||
memoryEnabled: true,
|
||||
} as any);
|
||||
expect(result).toContain("append");
|
||||
expect(result).toMatch(/end of execution|before calling.*task_done/i);
|
||||
});
|
||||
|
||||
it("uses project-root memory path not worktree-local path", () => {
|
||||
const task = createMockTaskDetail();
|
||||
const result = buildExecutionPrompt(task, "/project", {
|
||||
memoryEnabled: true,
|
||||
} as any);
|
||||
expect(result).toContain("`.fusion/memory.md`");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import type { TaskStore, Task, TaskDetail, StepStatus, Settings, WorkflowStep, MissionStore, Slice, AgentState, AgentCapability } from "@fusion/core";
|
||||
import type { AgentStore } from "@fusion/core";
|
||||
import { buildExecutionMemoryInstructions } from "@fusion/core";
|
||||
import { findWorktreeUser } from "./merger.js";
|
||||
import { generateWorktreeName, slugify } from "./worktree-names.js";
|
||||
import { Type, type Static } from "@mariozechner/pi-ai";
|
||||
@@ -3024,13 +3025,10 @@ git log --oneline
|
||||
|
||||
// Build project memory section from settings
|
||||
// When enabled, agents consult and update .fusion/memory.md for durable project learnings.
|
||||
// Actual memory instructions will be injected by FN-810; this placeholder establishes
|
||||
// the conditional integration point.
|
||||
const memoryEnabled = settings?.memoryEnabled !== false;
|
||||
let memorySection = "";
|
||||
if (memoryEnabled && rootDir) {
|
||||
// TODO(FN-810): Call buildMemoryInstructions(rootDir) to populate memory context
|
||||
memorySection = "";
|
||||
memorySection = "\n" + buildExecutionMemoryInstructions(rootDir);
|
||||
}
|
||||
|
||||
// Build steering comments section (last 10 comments only to avoid context bloat)
|
||||
|
||||
@@ -217,7 +217,7 @@ describe("buildSpecificationPrompt", () => {
|
||||
});
|
||||
|
||||
describe("memoryEnabled setting", () => {
|
||||
it("accepts memoryEnabled: true without error", () => {
|
||||
it("includes memory instructions when memoryEnabled: true", () => {
|
||||
const settings: Settings = {
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
@@ -231,11 +231,12 @@ describe("buildSpecificationPrompt", () => {
|
||||
".fusion/tasks/KB-001/PROMPT.md",
|
||||
settings,
|
||||
);
|
||||
// Memory instructions are a placeholder until FN-810; just verify no crash
|
||||
expect(prompt).toContain("Specify this task");
|
||||
expect(prompt).toContain("## Project Memory");
|
||||
expect(prompt).toContain(".fusion/memory.md");
|
||||
});
|
||||
|
||||
it("accepts memoryEnabled: false without error", () => {
|
||||
it("excludes memory instructions when memoryEnabled: false", () => {
|
||||
const settings: Settings = {
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
@@ -250,15 +251,18 @@ describe("buildSpecificationPrompt", () => {
|
||||
settings,
|
||||
);
|
||||
expect(prompt).toContain("Specify this task");
|
||||
expect(prompt).not.toContain("## Project Memory");
|
||||
});
|
||||
|
||||
it("accepts undefined memoryEnabled (default enabled) without error", () => {
|
||||
it("includes memory instructions when memoryEnabled is undefined (default enabled)", () => {
|
||||
const prompt = buildSpecificationPrompt(
|
||||
baseTask,
|
||||
".fusion/tasks/KB-001/PROMPT.md",
|
||||
undefined,
|
||||
);
|
||||
expect(prompt).toContain("Specify this task");
|
||||
expect(prompt).toContain("## Project Memory");
|
||||
expect(prompt).toContain(".fusion/memory.md");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
TaskAttachment,
|
||||
Settings,
|
||||
} from "@fusion/core";
|
||||
import { buildTriageMemoryInstructions } from "@fusion/core";
|
||||
import type { ImageContent } from "@mariozechner/pi-ai";
|
||||
import { Type, type Static } from "@mariozechner/pi-ai";
|
||||
import type {
|
||||
@@ -1252,13 +1253,10 @@ export function buildSpecificationPrompt(
|
||||
|
||||
// Build project memory section from settings.
|
||||
// When enabled, agents consult .fusion/memory.md for durable project learnings.
|
||||
// Actual memory instructions will be injected by FN-810; this placeholder
|
||||
// establishes the conditional integration point.
|
||||
const memoryEnabled = settings?.memoryEnabled !== false;
|
||||
let memorySection = "";
|
||||
if (memoryEnabled) {
|
||||
// TODO(FN-810): Call buildMemoryInstructions(rootDir) to populate memory context
|
||||
memorySection = "";
|
||||
memorySection = "\n\n" + buildTriageMemoryInstructions("");
|
||||
}
|
||||
|
||||
let attachmentsSection = "";
|
||||
|
||||
Reference in New Issue
Block a user