fix(FN-000): process agent memory dreams

This commit is contained in:
gsxdsm
2026-04-17 10:03:46 -07:00
parent d5d5c13781
commit 0515882da1
6 changed files with 402 additions and 36 deletions

View File

@@ -143,7 +143,7 @@ Documents persist across sessions and are visible in the dashboard's Documents t
## Memory Boundaries
You may receive an Agent Memory section and a Project Memory section.
- Agent Memory is specific to you, including imported and user-created agents such as CEO-style coordinator agents. Use it for your durable operating preferences and role context.
- Agent Memory is specific to you, including imported and user-created agents such as CEO-style coordinator agents. It has its own long-term memory, daily notes, dreams, and qmd-backed retrieval under .fusion/agent-memory/{agentId}/.
- Project Memory is the workspace memory system under .fusion/memory/ with long-term memory, daily notes, dreams, and qmd-backed retrieval.
- Keep these separate: do not copy personal agent operating notes into Project Memory unless they are genuinely useful to every future agent in this workspace.

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
@@ -84,6 +84,74 @@ describe("createMemoryTools", () => {
expect(getResult.content[0]!.text).toContain("roadmap sequencing");
});
it("creates daily and dreams files for per-agent memory lookup", async () => {
const [searchTool] = createMemoryTools(tempDir, { memoryBackendType: "file" }, {
agentMemory: {
agentId: "ceo-agent",
agentName: "CEO",
memory: "The CEO agent should prioritize roadmap sequencing and delegation.",
},
});
await (searchTool as any).execute("call-1", {
query: "roadmap",
limit: 5,
}, undefined, undefined, undefined);
const today = new Date().toISOString().slice(0, 10);
await expect(readFile(join(tempDir, ".fusion", "agent-memory", "ceo-agent", "MEMORY.md"), "utf-8"))
.resolves.toContain("Agent Memory: CEO");
await expect(readFile(join(tempDir, ".fusion", "agent-memory", "ceo-agent", "DREAMS.md"), "utf-8"))
.resolves.toContain("Agent Memory Dreams");
await expect(readFile(join(tempDir, ".fusion", "agent-memory", "ceo-agent", `${today}.md`), "utf-8"))
.resolves.toContain("Agent Daily Memory");
});
it("appends to this agent's daily memory through memory_append", async () => {
const tools = createMemoryTools(tempDir, { memoryBackendType: "file" }, {
agentMemory: {
agentId: "ceo-agent",
agentName: "CEO",
memory: "The CEO agent should prioritize roadmap sequencing and delegation.",
},
});
const appendTool = tools.find((tool) => tool.name === "memory_append")!;
const result = await (appendTool as any).execute("call-1", {
scope: "agent",
layer: "daily",
content: "- Follow up with execution agents after roadmap planning.",
}, undefined, undefined, undefined);
const today = new Date().toISOString().slice(0, 10);
await expect(readFile(join(tempDir, ".fusion", "agent-memory", "ceo-agent", `${today}.md`), "utf-8"))
.resolves.toContain("Follow up with execution agents");
expect(result.details).toEqual({ scope: "agent", layer: "daily" });
});
it("memory_get reads agent dreams returned by memory_search", async () => {
const [, getTool, appendTool] = createMemoryTools(tempDir, { memoryBackendType: "file" }, {
agentMemory: {
agentId: "ceo-agent",
agentName: "CEO",
memory: "The CEO agent should prioritize roadmap sequencing and delegation.",
},
});
await (appendTool as any).execute("call-1", {
scope: "agent",
layer: "daily",
content: "- Daily note",
}, undefined, undefined, undefined);
const getResult = await (getTool as any).execute("call-2", {
path: ".fusion/agent-memory/ceo-agent/DREAMS.md",
startLine: 1,
lineCount: 10,
}, undefined, undefined, undefined);
expect(getResult.content[0]!.text).toContain("Agent Memory Dreams");
});
it("builds qmd collection and search args for separate agent memory", () => {
expect(buildQmdAgentMemoryCollectionAddArgs(tempDir, "ceo-agent")).toEqual([
"collection",

View File

@@ -7,7 +7,8 @@
* The parameter schemas are canonical here — executor.ts imports and reuses them.
*/
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
import { appendFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { createHash } from "node:crypto";
import { join } from "node:path";
import type { AgentStore, AgentState, AgentCapability, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message } from "@fusion/core";
@@ -96,6 +97,10 @@ export const memoryGetParams = Type.Object({
});
export const memoryAppendParams = Type.Object({
scope: Type.Optional(Type.Union([
Type.Literal("project"),
Type.Literal("agent"),
], { description: "project for workspace memory, agent for this agent's private memory" })),
layer: Type.Union([
Type.Literal("long-term"),
Type.Literal("daily"),
@@ -129,8 +134,10 @@ type MemorySearchHit = {
const AGENT_MEMORY_ROOT = ".fusion/agent-memory";
const AGENT_MEMORY_FILENAME = "MEMORY.md";
const AGENT_DREAMS_FILENAME = "DREAMS.md";
const agentQmdRefreshState = new Map<string, { lastStartedAt: number; inFlight?: Promise<void> }>();
const AGENT_QMD_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
const DAILY_AGENT_MEMORY_RE = /^\d{4}-\d{2}-\d{2}\.md$/;
function sanitizeAgentMemoryId(agentId: string): string {
return agentId.trim().replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "agent";
@@ -140,6 +147,14 @@ function agentMemoryDisplayPath(agentId: string): string {
return `${AGENT_MEMORY_ROOT}/${sanitizeAgentMemoryId(agentId)}/${AGENT_MEMORY_FILENAME}`;
}
function agentDreamsDisplayPath(agentId: string): string {
return `${AGENT_MEMORY_ROOT}/${sanitizeAgentMemoryId(agentId)}/${AGENT_DREAMS_FILENAME}`;
}
function agentDailyDisplayPath(agentId: string, date = new Date()): string {
return `${AGENT_MEMORY_ROOT}/${sanitizeAgentMemoryId(agentId)}/${date.toISOString().slice(0, 10)}.md`;
}
function agentMemoryDirectory(rootDir: string, agentId: string): string {
return join(rootDir, AGENT_MEMORY_ROOT, sanitizeAgentMemoryId(agentId));
}
@@ -148,6 +163,14 @@ function agentMemoryFilePath(rootDir: string, agentId: string): string {
return join(agentMemoryDirectory(rootDir, agentId), AGENT_MEMORY_FILENAME);
}
function agentDreamsFilePath(rootDir: string, agentId: string): string {
return join(agentMemoryDirectory(rootDir, agentId), AGENT_DREAMS_FILENAME);
}
function agentDailyFilePath(rootDir: string, agentId: string, date = new Date()): string {
return join(agentMemoryDirectory(rootDir, agentId), `${date.toISOString().slice(0, 10)}.md`);
}
export function qmdAgentMemoryCollectionName(rootDir: string, agentId: string): string {
const hash = createHash("sha1").update(`${rootDir}:${agentId}`).digest("hex").slice(0, 12);
return `fusion-agent-memory-${sanitizeAgentMemoryId(agentId).toLowerCase()}-${hash}`;
@@ -179,20 +202,52 @@ export function buildQmdAgentMemorySearchArgs(rootDir: string, agentId: string,
async function syncAgentMemoryFile(rootDir: string, agentMemory?: AgentMemoryContext): Promise<string | null> {
const content = agentMemory?.memory?.trim();
if (!agentMemory?.agentId || !content) {
if (!agentMemory?.agentId) {
return null;
}
const dir = agentMemoryDirectory(rootDir, agentMemory.agentId);
await mkdir(dir, { recursive: true });
const title = agentMemory.agentName?.trim()
? `# Agent Memory: ${agentMemory.agentName.trim()}`
: "# Agent Memory";
const fileContent = `${title}\n\n<!-- Per-agent memory. Keep separate from workspace Project Memory. -->\n\n${content}\n`;
await writeFile(agentMemoryFilePath(rootDir, agentMemory.agentId), fileContent, "utf-8");
const longTermPath = agentMemoryFilePath(rootDir, agentMemory.agentId);
if (!existsSync(longTermPath)) {
const title = agentMemory.agentName?.trim()
? `# Agent Memory: ${agentMemory.agentName.trim()}`
: "# Agent Memory";
const fileContent = `${title}\n\n<!-- Per-agent memory. Keep separate from workspace Project Memory. -->\n\n${content || ""}\n`;
await writeFile(longTermPath, fileContent, "utf-8");
}
const dreamsPath = agentDreamsFilePath(rootDir, agentMemory.agentId);
if (!existsSync(dreamsPath)) {
await writeFile(dreamsPath, "# Agent Memory Dreams\n\n<!-- Synthesized patterns from this agent's daily notes. -->\n", "utf-8");
}
const dailyPath = agentDailyFilePath(rootDir, agentMemory.agentId);
if (!existsSync(dailyPath)) {
await writeFile(dailyPath, `# Agent Daily Memory ${new Date().toISOString().slice(0, 10)}\n\n<!-- Running observations for this agent. -->\n`, "utf-8");
}
return agentMemoryDisplayPath(agentMemory.agentId);
}
async function listAgentMemoryFiles(rootDir: string, agentMemory: AgentMemoryContext): Promise<Array<{ absPath: string; displayPath: string }>> {
await syncAgentMemoryFile(rootDir, agentMemory);
const dir = agentMemoryDirectory(rootDir, agentMemory.agentId);
const files = [
{ absPath: agentMemoryFilePath(rootDir, agentMemory.agentId), displayPath: agentMemoryDisplayPath(agentMemory.agentId) },
{ absPath: agentDreamsFilePath(rootDir, agentMemory.agentId), displayPath: agentDreamsDisplayPath(agentMemory.agentId) },
];
for (const entry of await readdir(dir).catch(() => [] as string[])) {
if (!DAILY_AGENT_MEMORY_RE.test(entry)) continue;
const absPath = join(dir, entry);
const fileStat = await stat(absPath);
if (fileStat.isFile()) {
files.push({
absPath,
displayPath: `${AGENT_MEMORY_ROOT}/${sanitizeAgentMemoryId(agentMemory.agentId)}/${entry}`,
});
}
}
return files;
}
function scoreAgentMemorySnippet(snippet: string, query: string): number {
const terms = query.toLowerCase().split(/[^a-z0-9_-]+/i).filter((term) => term.length >= 2);
const normalized = snippet.toLowerCase();
@@ -205,22 +260,24 @@ async function searchAgentMemoryFile(rootDir: string, agentMemory: AgentMemoryCo
return [];
}
const content = await readFile(agentMemoryFilePath(rootDir, agentMemory.agentId), "utf-8");
const lines = content.split("\n");
const results: MemorySearchHit[] = [];
for (let index = 0; index < lines.length; index += 8) {
const chunk = lines.slice(index, index + 12).join("\n").trim();
if (!chunk) continue;
const score = scoreAgentMemorySnippet(chunk, query);
if (score === 0) continue;
results.push({
path: displayPath,
lineStart: index + 1,
lineEnd: Math.min(index + 12, lines.length),
snippet: chunk.slice(0, 1200),
score: score + 1000,
backend: "agent-memory",
});
for (const file of await listAgentMemoryFiles(rootDir, agentMemory)) {
const content = await readFile(file.absPath, "utf-8");
const lines = content.split("\n");
for (let index = 0; index < lines.length; index += 8) {
const chunk = lines.slice(index, index + 12).join("\n").trim();
if (!chunk) continue;
const score = scoreAgentMemorySnippet(chunk, query);
if (score === 0) continue;
results.push({
path: file.displayPath,
lineStart: index + 1,
lineEnd: Math.min(index + 12, lines.length),
snippet: chunk.slice(0, 1200),
score: score + 1000,
backend: "agent-memory",
});
}
}
return results.slice(0, limit);
}
@@ -298,19 +355,36 @@ async function searchAgentMemoryWithQmd(rootDir: string, agentMemory: AgentMemor
}
}
function resolveAgentMemoryPath(rootDir: string, agentId: string, path: string): { absPath: string; displayPath: string } | null {
const safeAgentId = sanitizeAgentMemoryId(agentId);
const prefix = `${AGENT_MEMORY_ROOT}/${safeAgentId}/`;
if (!path.startsWith(prefix)) {
return null;
}
const filename = path.slice(prefix.length);
if (filename !== AGENT_MEMORY_FILENAME && filename !== AGENT_DREAMS_FILENAME && !DAILY_AGENT_MEMORY_RE.test(filename)) {
return null;
}
return {
absPath: join(agentMemoryDirectory(rootDir, agentId), filename),
displayPath: `${prefix}${filename}`,
};
}
async function getAgentMemoryWindow(rootDir: string, agentMemory: AgentMemoryContext, path: string, startLine = 1, lineCount = 40) {
if (path !== agentMemoryDisplayPath(agentMemory.agentId) || !agentMemory.memory?.trim()) {
const resolved = resolveAgentMemoryPath(rootDir, agentMemory.agentId, path);
if (!resolved) {
return null;
}
await syncAgentMemoryFile(rootDir, agentMemory);
const content = await readFile(agentMemoryFilePath(rootDir, agentMemory.agentId), "utf-8");
const content = await readFile(resolved.absPath, "utf-8");
const lines = content.split("\n");
const start = Math.max(1, Math.floor(startLine));
const count = Math.max(1, Math.min(Math.floor(lineCount), 200));
const startIndex = Math.min(start - 1, lines.length);
const endIndex = Math.min(startIndex + count, lines.length);
return {
path: agentMemoryDisplayPath(agentMemory.agentId),
path: resolved.displayPath,
content: lines.slice(startIndex, endIndex).join("\n"),
startLine: start,
endLine: endIndex,
@@ -595,7 +669,7 @@ export function createMemoryGetTool(rootDir: string, settings?: MemoryToolSettin
};
}
export function createMemoryAppendTool(rootDir: string, settings?: MemoryToolSettings): ToolDefinition {
export function createMemoryAppendTool(rootDir: string, settings?: MemoryToolSettings, options?: MemoryToolOptions): ToolDefinition {
return {
name: "memory_append",
label: "Append Memory",
@@ -604,20 +678,39 @@ export function createMemoryAppendTool(rootDir: string, settings?: MemoryToolSet
"use daily for running observations and open loops. Skip this tool when there is no reusable memory.",
parameters: memoryAppendParams,
execute: async (_id: string, params: Static<typeof memoryAppendParams>) => {
await ensureOpenClawMemoryFiles(rootDir);
const targetPath = params.layer === "long-term" ? memoryLongTermPath(rootDir) : dailyMemoryPath(rootDir);
const content = params.content.trim();
if (!content) {
return { content: [{ type: "text" as const, text: "ERROR: memory content cannot be empty" }], details: {} };
}
const scope = params.scope ?? "project";
if (scope === "agent") {
if (!options?.agentMemory) {
return { content: [{ type: "text" as const, text: "ERROR: agent memory is not available in this session" }], details: {} };
}
await syncAgentMemoryFile(rootDir, options.agentMemory);
const targetPath = params.layer === "long-term"
? agentMemoryFilePath(rootDir, options.agentMemory.agentId)
: agentDailyFilePath(rootDir, options.agentMemory.agentId);
await appendFile(targetPath, `\n${content}\n`, "utf-8");
if (resolveMemoryBackend(settings).type === "qmd") {
void refreshAgentMemoryQmdIndex(rootDir, options.agentMemory).catch(() => {});
}
return {
content: [{ type: "text" as const, text: `Appended to agent ${params.layer} memory.` }],
details: { scope, layer: params.layer },
};
}
await ensureOpenClawMemoryFiles(rootDir);
const targetPath = params.layer === "long-term" ? memoryLongTermPath(rootDir) : dailyMemoryPath(rootDir);
await appendFile(targetPath, `\n${content}\n`, "utf-8");
if (resolveMemoryBackend(settings).type === "qmd") {
scheduleQmdProjectMemoryRefresh(rootDir);
}
return {
content: [{ type: "text" as const, text: `Appended to ${params.layer} memory.` }],
details: { layer: params.layer },
details: { scope, layer: params.layer },
};
},
};
@@ -632,7 +725,7 @@ export function createMemoryTools(rootDir: string, settings?: MemoryToolSettings
createMemoryGetTool(rootDir, settings, options),
];
if (getMemoryBackendCapabilities(settings).writable) {
tools.push(createMemoryAppendTool(rootDir, settings));
tools.push(createMemoryAppendTool(rootDir, settings, options));
}
return tools;
}