fix(FN-000): process agent memory dreams
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user