feat(FN-3718): add agent workspace memory reader and include workspace memo

Merged branch lands two features: FN-3718 wires workspace memory into the agent instruction pipeline — adding a reader helper, injecting workspace memory into agent instructions and identity snapshots, and documenting the resolution order — and FN-3716 adds planning mode priority controls for task r

Fusion-Task-Id: FN-3718
This commit is contained in:
Fusion
2026-05-07 16:06:31 -07:00
committed by gsxdsm
parent 9e638f1902
commit c1ba48f07b
9 changed files with 234 additions and 28 deletions

View File

@@ -0,0 +1,8 @@
---
"@runfusion/fusion": patch
---
Fix agent memory lookup: the system prompt's "## Agent Memory" section and the
heartbeat Identity Snapshot now read from the on-disk agent-memory workspace
(`.fusion/agent-memory/{agentId}/MEMORY.md`) when the inline `agent.memory`
field is empty, matching the documented contract.

View File

@@ -285,7 +285,11 @@ Runtime behavior:
- `.fusion/agent-memory/{agentId}/DREAMS.md` (synthesized patterns) - `.fusion/agent-memory/{agentId}/DREAMS.md` (synthesized patterns)
- `.fusion/agent-memory/{agentId}/YYYY-MM-DD.md` (daily notes) - `.fusion/agent-memory/{agentId}/YYYY-MM-DD.md` (daily notes)
- `fn_memory_get` is intentionally bounded to those same files only. - `fn_memory_get` is intentionally bounded to those same files only.
- Empty inline `agent.memory` does **not** disable search/read of existing dreams/daily files once the agent-memory workspace exists. - Agent memory resolution order is:
1. Inline `agent.memory` (highest priority)
2. `.fusion/agent-memory/{agentId}/MEMORY.md` (fallback when inline is empty, and supplemental long-term section when inline is present)
3. Additional `.fusion/agent-memory/{agentId}/DREAMS.md` and daily files surfaced via `fn_memory_search`/`fn_memory_get`
- Empty inline `agent.memory` does **not** disable search/read of existing workspace files once the agent-memory workspace exists.
This layered behavior is shared by heartbeat agents and task-scoped sessions that inherit agent identity. This layered behavior is shared by heartbeat agents and task-scoped sessions that inherit agent identity.
@@ -648,10 +652,10 @@ Heartbeat runs are composed from multiple prompt layers so each wake has full id
- Inline instructions (`instructionsText`) - Inline instructions (`instructionsText`)
- File-backed instructions (`instructionsPath`) - File-backed instructions (`instructionsPath`)
- Soul/personality (`soul`) - Soul/personality (`soul`)
- Agent memory (`memory`) - Agent memory resolved from inline `agent.memory` first, then `.fusion/agent-memory/{agentId}/MEMORY.md` as fallback/supplement
- Optional project memory guidance (when memory is enabled) - Optional project memory guidance (when memory is enabled)
3. **Execution prompt framing** 3. **Execution prompt framing**
- `Identity Snapshot` block (agent ID/role + loaded soul/instructions/memory preview) - `Identity Snapshot` block (agent ID/role + loaded soul/instructions/memory preview; `memory: loaded` when either inline memory or workspace `MEMORY.md` is present)
- `Wake Delta` block (source, trigger detail, wake reason, assignment/comments/messages) - `Wake Delta` block (source, trigger detail, wake reason, assignment/comments/messages)
- Heartbeat procedure block (task-scoped or no-task variant, plus optional per-agent procedure override file) - Heartbeat procedure block (task-scoped or no-task variant, plus optional per-agent procedure override file)

View File

@@ -103,6 +103,60 @@ describe("resolveAgentInstructions", () => {
expect(result).not.toContain("## Agent Memory"); expect(result).not.toContain("## Agent Memory");
}); });
it("uses workspace MEMORY.md when inline memory is empty", async () => {
await mkdir(join(testDir, ".fusion", "agent-memory", "agent-test"), { recursive: true });
await writeFile(
join(testDir, ".fusion", "agent-memory", "agent-test", "MEMORY.md"),
"\nworkspace memory content\n",
"utf-8",
);
const result = await resolveAgentInstructions(makeAgent({ memory: "" }), testDir);
expect(result).toContain("## Agent Memory");
expect(result).toContain("workspace memory content");
expect(result).toContain("_Source: .fusion/agent-memory/agent-test/MEMORY.md_");
});
it("renders both inline and workspace memory when both exist", async () => {
await mkdir(join(testDir, ".fusion", "agent-memory", "agent-test"), { recursive: true });
await writeFile(
join(testDir, ".fusion", "agent-memory", "agent-test", "MEMORY.md"),
"workspace memory content",
"utf-8",
);
const result = await resolveAgentInstructions(makeAgent({ memory: "inline memory content" }), testDir);
expect(result).toContain("inline memory content");
expect(result).toContain("### Long-term Workspace Memory");
expect(result).toContain("workspace memory content");
});
it("reads workspace memory using sanitized agent id", async () => {
const weirdId = "Agent X/1";
await mkdir(join(testDir, ".fusion", "agent-memory", "Agent-X-1"), { recursive: true });
await writeFile(
join(testDir, ".fusion", "agent-memory", "Agent-X-1", "MEMORY.md"),
"sanitized workspace memory",
"utf-8",
);
const result = await resolveAgentInstructions(makeAgent({ id: weirdId, memory: "" }), testDir);
expect(result).toContain("sanitized workspace memory");
expect(result).toContain("_Source: .fusion/agent-memory/Agent-X-1/MEMORY.md_");
});
it("clamps oversized workspace memory", async () => {
await mkdir(join(testDir, ".fusion", "agent-memory", "agent-test"), { recursive: true });
await writeFile(
join(testDir, ".fusion", "agent-memory", "agent-test", "MEMORY.md"),
"x".repeat(60000),
"utf-8",
);
const result = await resolveAgentInstructions(makeAgent({ memory: "" }), testDir);
expect(result).toContain("x".repeat(50000));
});
it("returns instructionsText when set", async () => { it("returns instructionsText when set", async () => {
const agent = makeAgent({ instructionsText: "Always write tests." }); const agent = makeAgent({ instructionsText: "Always write tests." });
const result = await resolveAgentInstructions(agent, testDir); const result = await resolveAgentInstructions(agent, testDir);

View File

@@ -14,6 +14,7 @@ import {
createReadMessagesTool, createReadMessagesTool,
createResearchTools, createResearchTools,
qmdAgentMemoryCollectionName, qmdAgentMemoryCollectionName,
readAgentMemoryWorkspaceLongTerm,
sendMessageParams, sendMessageParams,
readMessagesParams, readMessagesParams,
} from "../agent-tools.js"; } from "../agent-tools.js";
@@ -418,6 +419,45 @@ describe("createMemoryTools", () => {
]); ]);
}); });
it("readAgentMemoryWorkspaceLongTerm returns empty string when MEMORY.md is missing", async () => {
await expect(readAgentMemoryWorkspaceLongTerm(tempDir, "ceo-agent")).resolves.toBe("");
});
it("readAgentMemoryWorkspaceLongTerm returns trimmed MEMORY.md contents", async () => {
const tools = createMemoryTools(tempDir, { memoryBackendType: "file" }, {
agentMemory: {
agentId: "ceo-agent",
agentName: "CEO",
memory: "",
},
});
const appendTool = tools.find((tool) => tool.name === "fn_memory_append")!;
await (appendTool as any).execute("call-1", {
scope: "agent",
layer: "long-term",
content: " durable memory content ",
}, undefined, undefined, undefined);
await expect(readAgentMemoryWorkspaceLongTerm(tempDir, "ceo-agent")).resolves.toContain("durable memory content");
});
it("readAgentMemoryWorkspaceLongTerm reads sanitized agent ids", async () => {
const tools = createMemoryTools(tempDir, { memoryBackendType: "file" }, {
agentMemory: {
agentId: "Agent X/1",
agentName: "CEO",
memory: "",
},
});
const appendTool = tools.find((tool) => tool.name === "fn_memory_append")!;
await (appendTool as any).execute("call-1", {
scope: "agent",
layer: "long-term",
content: "- sanitized id memory",
}, undefined, undefined, undefined);
await expect(readAgentMemoryWorkspaceLongTerm(tempDir, "Agent X/1")).resolves.toContain("sanitized id memory");
});
it("logs a warning and continues when agent memory directory read fails", async () => { it("logs a warning and continues when agent memory directory read fails", async () => {
readdirMock.mockRejectedValueOnce(new Error("EACCES")); readdirMock.mockRejectedValueOnce(new Error("EACCES"));

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { appendFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { import {
@@ -1881,6 +1881,41 @@ describe("executeHeartbeat", () => {
expect(callArgs.customTools![13]!.name).toBe("fn_heartbeat_done"); expect(callArgs.customTools![13]!.name).toBe("fn_heartbeat_done");
}); });
it("loads workspace memory into system prompt and identity snapshot when inline memory is empty", async () => {
const rootDir = mkdtempSync(join(tmpdir(), "heartbeat-workspace-memory-"));
mkdirSync(join(rootDir, ".fusion", "agent-memory", "agent-001"), { recursive: true });
writeFileSync(
join(rootDir, ".fusion", "agent-memory", "agent-001", "MEMORY.md"),
"workspace memory for heartbeat",
"utf-8",
);
try {
const store = createStoreWithAgentForExec({
memory: "",
instructionsText: undefined,
instructionsPath: undefined,
});
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({
session: mockSession as any,
});
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir });
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
const callArgs = mockedCreateFnAgent.mock.calls[0]![0];
expect(callArgs.systemPrompt).toContain("## Agent Memory");
expect(callArgs.systemPrompt).toContain("workspace memory for heartbeat");
const executionPrompt = mockSession.prompt.mock.calls.at(-1)?.[0] as string;
expect(executionPrompt).toMatch(/- memory: loaded \(\d+ chars, sha256:[a-f0-9]{8}, source: workspace\)/);
} finally {
rmSync(rootDir, { recursive: true, force: true });
}
});
it("includes memory instructions even when agent has no custom instructions", async () => { it("includes memory instructions even when agent has no custom instructions", async () => {
const store = createStoreWithAgentForExec({ const store = createStoreWithAgentForExec({
soul: undefined, soul: undefined,

View File

@@ -637,7 +637,7 @@ describe("HeartbeatMonitor observability — prompt persistence + run-scoped log
expect(exec).toContain("## Identity Snapshot"); expect(exec).toContain("## Identity Snapshot");
expect(exec).toContain("- agentId: agent-001"); expect(exec).toContain("- agentId: agent-001");
expect(exec).toMatch(/- soul: loaded \(\d+ chars, sha256:[0-9a-f]{8}\)/); expect(exec).toMatch(/- soul: loaded \(\d+ chars, sha256:[0-9a-f]{8}\)/);
expect(exec).toMatch(/- memory: loaded \(\d+ chars, sha256:[0-9a-f]{8}\)/); expect(exec).toMatch(/- memory: loaded \(\d+ chars, sha256:[0-9a-f]{8}(, source: (inline|workspace))?\)/);
// Snapshot must NOT contain full preview content (that lives in the system prompt) // Snapshot must NOT contain full preview content (that lives in the system prompt)
expect(exec).not.toContain("I keep momentum across stalled tasks."); expect(exec).not.toContain("I keep momentum across stalled tasks.");

View File

@@ -22,7 +22,7 @@ import { ApprovalRequestStore, buildExecutionMemoryInstructions, isEphemeralAgen
import type { ToolDefinition } from "@mariozechner/pi-coding-agent"; import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
import { Type, type Static } from "@mariozechner/pi-ai"; import { Type, type Static } from "@mariozechner/pi-ai";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { createTaskCreateTool, createTaskLogToolWithContext, createTaskDocumentWriteTool, createTaskDocumentReadTool, createListAgentsTool, createDelegateTaskTool, createGetAgentConfigTool, createUpdateAgentConfigTool, createSendMessageTool, createReadMessagesTool, createMemoryTools, createReadEvaluationsTool, createUpdateIdentityTool, createReflectOnPerformanceTool, taskCreateParams } from "./agent-tools.js"; import { createTaskCreateTool, createTaskLogToolWithContext, createTaskDocumentWriteTool, createTaskDocumentReadTool, createListAgentsTool, createDelegateTaskTool, createGetAgentConfigTool, createUpdateAgentConfigTool, createSendMessageTool, createReadMessagesTool, createMemoryTools, createReadEvaluationsTool, createUpdateIdentityTool, createReflectOnPerformanceTool, readAgentMemoryWorkspaceLongTerm, taskCreateParams } from "./agent-tools.js";
import { AgentLogger } from "./agent-logger.js"; import { AgentLogger } from "./agent-logger.js";
import { import {
resolveAgentInstructionsWithRatings, resolveAgentInstructionsWithRatings,
@@ -490,16 +490,21 @@ function shortContentHash(value: string): string {
function buildIdentitySnapshot(args: { function buildIdentitySnapshot(args: {
agent: Agent; agent: Agent;
resolvedInstructions: string; resolvedInstructions: string;
workspaceMemory: string;
}): string { }): string {
const { agent, resolvedInstructions } = args; const { agent, resolvedInstructions, workspaceMemory } = args;
const soulTrimmed = typeof agent.soul === "string" ? agent.soul.trim() : ""; const soulTrimmed = typeof agent.soul === "string" ? agent.soul.trim() : "";
const instrTrimmed = resolvedInstructions.trim(); const instrTrimmed = resolvedInstructions.trim();
const memTrimmed = typeof agent.memory === "string" ? agent.memory.trim() : ""; const inlineMemoryTrimmed = typeof agent.memory === "string" ? agent.memory.trim() : "";
const workspaceMemoryTrimmed = workspaceMemory.trim();
const memorySource = inlineMemoryTrimmed ? "inline" : workspaceMemoryTrimmed ? "workspace" : null;
const memTrimmed = inlineMemoryTrimmed || workspaceMemoryTrimmed;
const formatField = (trimmed: string): string => { const formatField = (trimmed: string, source?: "inline" | "workspace"): string => {
if (!trimmed) return "absent"; if (!trimmed) return "absent";
return `loaded (${trimmed.length} chars, sha256:${shortContentHash(trimmed)})`; const sourceLabel = source ? `, source: ${source}` : "";
return `loaded (${trimmed.length} chars, sha256:${shortContentHash(trimmed)}${sourceLabel})`;
}; };
return [ return [
@@ -512,7 +517,7 @@ function buildIdentitySnapshot(args: {
`- role: ${agent.role}`, `- role: ${agent.role}`,
`- soul: ${formatField(soulTrimmed)}`, `- soul: ${formatField(soulTrimmed)}`,
`- instructions: ${formatField(instrTrimmed)}`, `- instructions: ${formatField(instrTrimmed)}`,
`- memory: ${formatField(memTrimmed)}`, `- memory: ${formatField(memTrimmed, memorySource ?? undefined)}`,
].join("\n"); ].join("\n");
} }
@@ -1721,6 +1726,7 @@ export class HeartbeatMonitor {
? HEARTBEAT_NO_TASK_SYSTEM_PROMPT ? HEARTBEAT_NO_TASK_SYSTEM_PROMPT
: HEARTBEAT_SYSTEM_PROMPT; : HEARTBEAT_SYSTEM_PROMPT;
let resolvedInstructionsForIdentity = ""; let resolvedInstructionsForIdentity = "";
let workspaceMemoryForIdentity = "";
try { try {
resolvedInstructionsForIdentity = await resolveAgentInstructionsWithRatings(agent, rootDir, this.store); resolvedInstructionsForIdentity = await resolveAgentInstructionsWithRatings(agent, rootDir, this.store);
} catch (instructionError) { } catch (instructionError) {
@@ -1728,6 +1734,13 @@ export class HeartbeatMonitor {
heartbeatLog.warn(`Failed to resolve agent instructions for heartbeat ${agentId}: ${message}`); heartbeatLog.warn(`Failed to resolve agent instructions for heartbeat ${agentId}: ${message}`);
} }
try {
workspaceMemoryForIdentity = await readAgentMemoryWorkspaceLongTerm(rootDir, agent.id);
} catch (memoryReadErr) {
const message = memoryReadErr instanceof Error ? memoryReadErr.message : String(memoryReadErr);
heartbeatLog.warn(`Failed to resolve workspace memory for heartbeat ${agentId}: ${message}`);
}
let memoryInstructions = ""; let memoryInstructions = "";
if (memorySettings?.memoryEnabled !== false) { if (memorySettings?.memoryEnabled !== false) {
try { try {
@@ -1895,7 +1908,11 @@ export class HeartbeatMonitor {
`Heartbeat execution for agent "${agent.name}" (ID: ${agent.id})`, `Heartbeat execution for agent "${agent.name}" (ID: ${agent.id})`,
`Source: ${source}${triggerDetail ? ` (${triggerDetail})` : ""}`, `Source: ${source}${triggerDetail ? ` (${triggerDetail})` : ""}`,
"", "",
buildIdentitySnapshot({ agent, resolvedInstructions: resolvedInstructionsForIdentity }), buildIdentitySnapshot({
agent,
resolvedInstructions: resolvedInstructionsForIdentity,
workspaceMemory: workspaceMemoryForIdentity,
}),
"", "",
"## Wake Delta", "## Wake Delta",
`- source: ${source}${triggerDetail ? ` (${triggerDetail})` : ""}`, `- source: ${source}${triggerDetail ? ` (${triggerDetail})` : ""}`,
@@ -2003,7 +2020,11 @@ export class HeartbeatMonitor {
`Source: ${source}${triggerDetail ? ` (${triggerDetail})` : ""}`, `Source: ${source}${triggerDetail ? ` (${triggerDetail})` : ""}`,
`Assigned task: ${taskId}${taskTitle}`, `Assigned task: ${taskId}${taskTitle}`,
"", "",
buildIdentitySnapshot({ agent, resolvedInstructions: resolvedInstructionsForIdentity }), buildIdentitySnapshot({
agent,
resolvedInstructions: resolvedInstructionsForIdentity,
workspaceMemory: workspaceMemoryForIdentity,
}),
"", "",
"## Wake Delta", "## Wake Delta",
`- source: ${source}${triggerDetail ? ` (${triggerDetail})` : ""}`, `- source: ${source}${triggerDetail ? ` (${triggerDetail})` : ""}`,

View File

@@ -10,6 +10,7 @@ import {
} from "@fusion/core"; } from "@fusion/core";
import type { PluginRunner } from "./plugin-runner.js"; import type { PluginRunner } from "./plugin-runner.js";
import { createLogger } from "./logger.js"; import { createLogger } from "./logger.js";
import { readAgentMemoryWorkspaceLongTerm } from "./agent-tools.js";
const log = createLogger("agent-instructions"); const log = createLogger("agent-instructions");
@@ -182,18 +183,39 @@ function formatSoulSection(soul: string, agentId: string): string {
return `## Soul\n\n${trimmed}`; return `## Soul\n\n${trimmed}`;
} }
function formatMemorySection(memory: string, agentId: string): string { function memoryWorkspaceDisplayPath(agentId: string): string {
const trimmed = trimAndClamp(memory, MAX_MEMORY_LENGTH, "memory", agentId); const safeAgentId = agentId.trim().replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "agent";
if (!trimmed) { return `.fusion/agent-memory/${safeAgentId}/MEMORY.md`;
}
function formatMemorySection(memory: string, workspaceMemory: string, agentId: string): string {
const inlineTrimmed = trimAndClamp(memory, MAX_MEMORY_LENGTH, "memory", agentId);
const workspaceTrimmed = trimAndClamp(workspaceMemory, MAX_MEMORY_LENGTH, "workspace memory", agentId);
if (!inlineTrimmed && !workspaceTrimmed) {
return ""; return "";
} }
return [
const lines = [
"## Agent Memory", "## Agent Memory",
"", "",
"This is memory for this agent only. Keep it separate from workspace Project Memory; use it for durable preferences, operating habits, and context that should follow this agent across tasks.", "This is memory for this agent only. Keep it separate from workspace Project Memory; use it for durable preferences, operating habits, and context that should follow this agent across tasks.",
"Additional daily/dream files under .fusion/agent-memory/{agentId}/ are searchable with fn_memory_search.",
"", "",
trimmed, ];
].join("\n");
if (inlineTrimmed) {
lines.push(inlineTrimmed);
}
if (workspaceTrimmed) {
if (!inlineTrimmed) {
lines.push(`_Source: ${memoryWorkspaceDisplayPath(agentId)}_`, "", workspaceTrimmed);
} else {
lines.push("", "### Long-term Workspace Memory", "", workspaceTrimmed);
}
}
return lines.join("\n");
} }
function formatPerformanceFeedbackSection(ratingSummary: AgentRatingSummary): string { function formatPerformanceFeedbackSection(ratingSummary: AgentRatingSummary): string {
@@ -293,11 +315,10 @@ export async function resolveAgentInstructions(
} }
} }
if (agent.memory?.trim()) { const workspaceMemory = await readAgentMemoryWorkspaceLongTerm(rootDir, agent.id);
const memorySection = formatMemorySection(agent.memory, agent.id); const memorySection = formatMemorySection(agent.memory ?? "", workspaceMemory, agent.id);
if (memorySection) { if (memorySection) {
parts.push(memorySection); parts.push(memorySection);
}
} }
if (ratingSummary && ratingSummary.totalRatings > 0) { if (ratingSummary && ratingSummary.totalRatings > 0) {

View File

@@ -19,7 +19,6 @@ import { ResearchStepRunner } from "./research-step-runner.js";
import type { ToolDefinition } from "@mariozechner/pi-coding-agent"; import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
import { Type, type Static } from "@mariozechner/pi-ai"; import { Type, type Static } from "@mariozechner/pi-ai";
import type { AgentReflectionService } from "./agent-reflection.js"; import type { AgentReflectionService } from "./agent-reflection.js";
import { MAX_INSTRUCTIONS_TEXT_LENGTH, MAX_MEMORY_LENGTH, MAX_SOUL_LENGTH } from "./agent-instructions.js";
import { createLogger } from "./logger.js"; import { createLogger } from "./logger.js";
// ── Tool parameter schemas (canonical definitions) ──────────────────────── // ── Tool parameter schemas (canonical definitions) ────────────────────────
@@ -194,6 +193,10 @@ type MemorySearchHit = {
const log = createLogger("agent-tools"); const log = createLogger("agent-tools");
const MAX_INSTRUCTIONS_TEXT_LENGTH = 50_000;
const MAX_MEMORY_LENGTH = 50_000;
const MAX_SOUL_LENGTH = 10_000;
const AGENT_MEMORY_ROOT = ".fusion/agent-memory"; const AGENT_MEMORY_ROOT = ".fusion/agent-memory";
const AGENT_MEMORY_FILENAME = "MEMORY.md"; const AGENT_MEMORY_FILENAME = "MEMORY.md";
const AGENT_DREAMS_FILENAME = "DREAMS.md"; const AGENT_DREAMS_FILENAME = "DREAMS.md";
@@ -201,11 +204,11 @@ const agentQmdRefreshState = new Map<string, { lastStartedAt: number; inFlight?:
const AGENT_QMD_REFRESH_INTERVAL_MS = 5 * 60 * 1000; const AGENT_QMD_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
const DAILY_AGENT_MEMORY_RE = /^\d{4}-\d{2}-\d{2}\.md$/; const DAILY_AGENT_MEMORY_RE = /^\d{4}-\d{2}-\d{2}\.md$/;
function sanitizeAgentMemoryId(agentId: string): string { export function sanitizeAgentMemoryId(agentId: string): string {
return agentId.trim().replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "agent"; return agentId.trim().replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "agent";
} }
function agentMemoryDisplayPath(agentId: string): string { export function agentMemoryDisplayPath(agentId: string): string {
return `${AGENT_MEMORY_ROOT}/${sanitizeAgentMemoryId(agentId)}/${AGENT_MEMORY_FILENAME}`; return `${AGENT_MEMORY_ROOT}/${sanitizeAgentMemoryId(agentId)}/${AGENT_MEMORY_FILENAME}`;
} }
@@ -217,7 +220,7 @@ function agentMemoryDirectory(rootDir: string, agentId: string): string {
return join(rootDir, AGENT_MEMORY_ROOT, sanitizeAgentMemoryId(agentId)); return join(rootDir, AGENT_MEMORY_ROOT, sanitizeAgentMemoryId(agentId));
} }
function agentMemoryFilePath(rootDir: string, agentId: string): string { export function agentMemoryFilePath(rootDir: string, agentId: string): string {
return join(agentMemoryDirectory(rootDir, agentId), AGENT_MEMORY_FILENAME); return join(agentMemoryDirectory(rootDir, agentId), AGENT_MEMORY_FILENAME);
} }
@@ -229,6 +232,26 @@ function agentDailyFilePath(rootDir: string, agentId: string, date = new Date())
return join(agentMemoryDirectory(rootDir, agentId), `${date.toISOString().slice(0, 10)}.md`); return join(agentMemoryDirectory(rootDir, agentId), `${date.toISOString().slice(0, 10)}.md`);
} }
export async function readAgentMemoryWorkspaceLongTerm(rootDir: string, agentId: string): Promise<string> {
const safeRoot = typeof rootDir === "string" ? rootDir.trim() : "";
const safeAgentId = typeof agentId === "string" ? agentId.trim() : "";
if (!safeRoot || !safeAgentId) {
return "";
}
const filePath = agentMemoryFilePath(safeRoot, safeAgentId);
try {
const fileStat = await stat(filePath);
if (!fileStat.isFile()) {
return "";
}
const content = await readFile(filePath, "utf-8");
return typeof content === "string" ? content.trim() : "";
} catch {
return "";
}
}
export function qmdAgentMemoryCollectionName(rootDir: string, agentId: string): string { export function qmdAgentMemoryCollectionName(rootDir: string, agentId: string): string {
const hash = createHash("sha1").update(`${rootDir}:${agentId}`).digest("hex").slice(0, 12); const hash = createHash("sha1").update(`${rootDir}:${agentId}`).digest("hex").slice(0, 12);
return `fusion-agent-memory-${sanitizeAgentMemoryId(agentId).toLowerCase()}-${hash}`; return `fusion-agent-memory-${sanitizeAgentMemoryId(agentId).toLowerCase()}-${hash}`;