From 6b4accd05ee0b3a3547afda3c5a3f025f2970efb Mon Sep 17 00:00:00 2001 From: can1357 Date: Sat, 30 May 2026 16:48:02 +0200 Subject: [PATCH] feat(memory): added memory_edit tool and stats/diagnose commands - Added `memory_edit` tool for update, forget, and invalidate operations on Mnemosyne memories by id. - Added `stats` and `diagnose` methods to `MemoryBackend` interface with Mnemosyne implementations. - Exposed `/memory stats` and `/memory diagnose` slash commands in TUI and ACP modes. - Refactored recall output to include memory ids via `formatScopedRecallWithIds`. --- .../coding-agent/src/memory-backend/types.ts | 6 + .../coding-agent/src/mnemosyne/backend.ts | 134 ++++++++++++++++++ packages/coding-agent/src/mnemosyne/state.ts | 93 +++++++++++- .../modes/controllers/command-controller.ts | 18 ++- .../src/prompts/system/system-prompt.md | 2 + .../src/prompts/tools/memory-edit.md | 8 ++ .../src/slash-commands/builtin-registry.ts | 11 +- packages/coding-agent/src/system-prompt.ts | 4 + packages/coding-agent/src/tools/index.ts | 3 + .../coding-agent/src/tools/memory-edit.ts | 59 ++++++++ .../coding-agent/src/tools/memory-recall.ts | 2 +- .../coding-agent/test/memory-tools.test.ts | 133 ++++++++++++++++- .../test/system-prompt-templates.test.ts | 25 ++++ packages/mnemosyne/package.json | 4 + 14 files changed, 489 insertions(+), 13 deletions(-) create mode 100644 packages/coding-agent/src/prompts/tools/memory-edit.md create mode 100644 packages/coding-agent/src/tools/memory-edit.ts diff --git a/packages/coding-agent/src/memory-backend/types.ts b/packages/coding-agent/src/memory-backend/types.ts index 162f9f727..d90e9862c 100644 --- a/packages/coding-agent/src/memory-backend/types.ts +++ b/packages/coding-agent/src/memory-backend/types.ts @@ -53,6 +53,12 @@ export interface MemoryBackend { /** Force consolidation/retain to happen now (slash `/memory enqueue`). */ enqueue(agentDir: string, cwd: string, session?: AgentSession): Promise; + + /** Render backend-specific memory statistics as markdown (`/memory stats`). */ + stats?(agentDir: string, cwd: string, session?: AgentSession): Promise; + + /** Render backend-specific memory diagnostics as markdown (`/memory diagnose`). */ + diagnose?(agentDir: string, cwd: string, session?: AgentSession): Promise; /** * Optional hook to inject a backend-specific block into the current turn's * system prompt before the agent starts generating. diff --git a/packages/coding-agent/src/mnemosyne/backend.ts b/packages/coding-agent/src/mnemosyne/backend.ts index b59d78d0a..a715c5de1 100644 --- a/packages/coding-agent/src/mnemosyne/backend.ts +++ b/packages/coding-agent/src/mnemosyne/backend.ts @@ -1,5 +1,8 @@ import { rm } from "node:fs/promises"; import * as path from "node:path"; +import { inspectDatabase, type DiagnosticSummary } from "@oh-my-pi/pi-mnemosyne/diagnose"; +import { BankManager } from "@oh-my-pi/pi-mnemosyne/core"; +import { Mnemosyne } from "@oh-my-pi/pi-mnemosyne"; import { completeSimple } from "@oh-my-pi/pi-ai"; import { logger } from "@oh-my-pi/pi-utils"; import type { ModelRegistry } from "../config/model-registry"; @@ -13,11 +16,13 @@ import { truncateApproxTokens, } from "./config"; import { + getMnemosyneScopedBanks, getMnemosyneScopedDbPaths, getMnemosyneSessionState, MnemosyneSessionState, setMnemosyneSessionState, } from "./state"; +import { shortenPath } from "../tools/render-utils"; const STATIC_INSTRUCTIONS = [ "# Memory", @@ -110,12 +115,141 @@ export const mnemosyneBackend: MemoryBackend = { } }, + async stats(agentDir, _cwd, session): Promise { + const { targets, owned } = createStatsTargets(agentDir, session); + try { + if (targets.length === 0) return undefined; + return renderMnemosyneStats(targets); + } finally { + for (const memory of owned) memory.close(); + } + }, + + async diagnose(agentDir, _cwd, session): Promise { + const state = getMnemosyneSessionState(session); + const config = state?.config ?? (session ? loadMnemosyneConfig(session.settings, agentDir) : undefined); + if (!config) return undefined; + const banks = getMnemosyneScopedBanks(config); + const dbPaths = getMnemosyneScopedDbPaths(config); + const summaries = dbPaths.map((dbPath, index) => ({ + bank: banks[index] ?? "unknown", + summary: inspectDatabase({ dbPath, initialize: false }), + })); + return renderMnemosyneDiagnostics(summaries); + }, + async preCompactionContext(messages, _settings, session): Promise { const state = getMnemosyneSessionState(session); return await state?.recallForCompaction(messages); }, }; +interface MnemosyneStatsTarget { + bank: string; + memory: Mnemosyne; +} + +function createStatsTargets( + agentDir: string, + session: AgentSession | undefined, +): { targets: MnemosyneStatsTarget[]; owned: Mnemosyne[] } { + const state = getMnemosyneSessionState(session); + if (state) { + return { + targets: dedupeStatsTargets([state.getScopedRetainTarget(), ...state.getScopedRecallTargets()]), + owned: [], + }; + } + if (!session) return { targets: [], owned: [] }; + const config = loadMnemosyneConfig(session.settings, agentDir); + const targets = getMnemosyneScopedBanks(config).map(bank => ({ + bank, + memory: createStatsMemory(config, bank), + })); + return { targets, owned: targets.map(target => target.memory) }; +} + +function createStatsMemory(config: MnemosyneBackendConfig, bank: string): Mnemosyne { + const providerOptions = config.providerOptions as Record; + return new Mnemosyne({ + dbPath: resolveBankDbPath(config, bank), + bank, + sessionId: bank, + authorId: "coding-agent", + authorType: "agent", + channelId: bank, + ...providerOptions, + } as ConstructorParameters[0]); +} + +function resolveBankDbPath(config: MnemosyneBackendConfig, bank: string): string { + const sharedBank = config.globalBank ?? config.baseBank ?? "default"; + if (bank === sharedBank) return config.dbPath; + return new BankManager(path.dirname(config.dbPath)).getBankDbPath(bank); +} + +function dedupeStatsTargets(targets: readonly MnemosyneStatsTarget[]): MnemosyneStatsTarget[] { + const seen = new Set(); + const unique: MnemosyneStatsTarget[] = []; + for (const target of targets) { + if (seen.has(target.bank)) continue; + seen.add(target.bank); + unique.push(target); + } + return unique; +} + +function renderMnemosyneStats(targets: readonly MnemosyneStatsTarget[]): string { + const lines = [ + "# Mnemosyne Memory Stats", + "", + "| Bank | Working | Episodic | Triples | Last memory | Database |", + "|---|---:|---:|---:|---|---|", + ]; + for (const target of targets) { + const stats = target.memory.getStats(); + lines.push( + `| ${escapeMarkdownTableCell(target.bank)} | ${statCount(stats.beam.working_memory)} | ${statCount( + stats.beam.episodic_memory, + )} | ${stats.beam.triples.total} | ${escapeMarkdownTableCell(stats.last_memory ?? "never")} | ${escapeMarkdownTableCell(shortenPath(stats.database))} |`, + ); + } + return lines.join("\n"); +} + +function renderMnemosyneDiagnostics(entries: readonly { bank: string; summary: DiagnosticSummary }[]): string { + const lines = [ + "# Mnemosyne Memory Diagnostics", + "", + "| Bank | Passed | Failed | Integrity | Database |", + "|---|---:|---:|---|---|", + ]; + for (const { bank, summary } of entries) { + const integrity = summary.entries.find(entry => entry.check === "integrity_check")?.status ?? "unknown"; + lines.push( + `| ${escapeMarkdownTableCell(bank)} | ${summary.checks_passed}/${summary.checks_total} | ${summary.checks_failed} | ${escapeMarkdownTableCell(integrity)} | ${escapeMarkdownTableCell(shortenPath(summary.database))} |`, + ); + } + const findings = entries.flatMap(({ bank, summary }) => + summary.key_findings.map(finding => `- ${bank}: ${finding}`), + ); + lines.push("", "## Key Findings"); + lines.push(...(findings.length > 0 ? findings : ["- none"])); + return lines.join("\n"); +} + +function statCount(value: unknown): number { + if (typeof value !== "object" || value === null) return 0; + const record = value as { total?: unknown; count?: unknown }; + if (typeof record.total === "number") return record.total; + if (typeof record.count === "number") return record.count; + return 0; +} + +function escapeMarkdownTableCell(value: string): string { + return value.replaceAll("|", "\\|").replaceAll("\n", " "); +} + async function loadMnemosyneConfigWithProviders( settings: MemoryBackendStartOptions["settings"], agentDir: string, diff --git a/packages/coding-agent/src/mnemosyne/state.ts b/packages/coding-agent/src/mnemosyne/state.ts index a3b56c4a3..b6e65786d 100644 --- a/packages/coding-agent/src/mnemosyne/state.ts +++ b/packages/coding-agent/src/mnemosyne/state.ts @@ -34,6 +34,26 @@ interface MnemosyneScopedResources { type MnemosyneRememberInput = Parameters[0]; type MnemosyneRememberOptions = Parameters[1]; +export type MnemosyneMemoryEditOperation = "update" | "forget" | "invalidate"; + +export interface MnemosyneMemoryEditOptions { + content?: string; + importance?: number; + replacementId?: string; +} + +export interface MnemosyneMemoryEditResult { + status: "updated" | "deleted" | "invalidated" | "not_found"; + bank?: string; + store?: "working" | "episodic"; +} + +interface MnemosyneStoredMemoryRow { + memory_store?: unknown; + session_id?: unknown; +} + + export function getMnemosyneSessionState(session: AgentSession | undefined): MnemosyneSessionState | undefined { return session ? (session as AgentSessionWithMnemosyneState)[kMnemosyneSessionState] : undefined; } @@ -101,6 +121,60 @@ export class MnemosyneSessionState { return this.scoped.retain; } + editScopedMemory( + op: MnemosyneMemoryEditOperation, + id: string, + options: MnemosyneMemoryEditOptions = {}, + ): MnemosyneMemoryEditResult { + const targets = dedupeScopedTargets([ + this.scoped.retain, + ...this.scoped.recall, + ...(this.scoped.global ? [this.scoped.global] : []), + ]); + let ineligible: MnemosyneMemoryEditResult | undefined; + for (const target of targets) { + const row = target.memory.get(id) as MnemosyneStoredMemoryRow | null; + if (!row) continue; + const store = row.memory_store === "episodic" ? "episodic" : "working"; + const resultContext = { bank: target.bank, store }; + if ((op === "update" || op === "forget") && store !== "working") { + ineligible ??= { status: "not_found", ...resultContext }; + continue; + } + if (op === "update") { + if (target.memory.update(id, options.content ?? null, options.importance ?? null)) { + return { status: "updated", ...resultContext }; + } + ineligible ??= { status: "not_found", ...resultContext }; + continue; + } + if (op === "forget") { + if (target.memory.forget(id)) return { status: "deleted", ...resultContext }; + ineligible ??= { status: "not_found", ...resultContext }; + continue; + } + if (target.memory.beam.invalidate(id, options.replacementId ?? null)) { + return { status: "invalidated", ...resultContext }; + } + ineligible ??= { status: "not_found", ...resultContext }; + } + return ineligible ?? { status: "not_found" }; + } + + formatScopedRecallWithIds(results: readonly RecallResult[]): string { + if (results.length === 0) return ""; + const lines = results.map(result => { + const id = result.id ? ` (id: ${result.id})` : " (id unavailable)"; + const source = result.source ? ` [${result.source}]` : ""; + const date = result.timestamp ? ` (${result.timestamp.slice(0, 10)})` : ""; + const score = result.score ?? result.importance; + const confidence = typeof score === "number" ? ` c:${score.toFixed(1)}` : ""; + return `- ${result.content}${id}${source}${date}${confidence}`; + }); + return lines.join("\n\n"); + } + + collectScopedRecallResults(query: string): RecallResult[] { const merged: RecallResult[] = []; const byId = new Map(); @@ -311,10 +385,23 @@ function resolveScopedBanks(config: MnemosyneBackendConfig): { } export function getMnemosyneScopedDbPaths(config: MnemosyneBackendConfig): readonly string[] { + return getMnemosyneScopedBanks(config).map(bank => resolveBankDbPath(config, bank)); +} + +export function getMnemosyneScopedBanks(config: MnemosyneBackendConfig): readonly string[] { const banks = resolveScopedBanks(config); - return uniqueBanks([banks.retainBank, banks.globalBank, ...banks.recallBanks]).map(bank => - resolveBankDbPath(config, bank), - ); + return uniqueBanks([banks.retainBank, banks.globalBank, ...banks.recallBanks]); +} + +function dedupeScopedTargets(targets: readonly MnemosyneScopedMemory[]): readonly MnemosyneScopedMemory[] { + const seen = new Set(); + const unique: MnemosyneScopedMemory[] = []; + for (const target of targets) { + if (seen.has(target.bank)) continue; + seen.add(target.bank); + unique.push(target); + } + return unique; } function uniqueBanks(banks: readonly string[]): readonly string[] { diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index 1f9b080e9..3c87483ca 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -620,12 +620,28 @@ export class CommandController { return; } + + if (action === "stats" || action === "diagnose") { + const hook = action === "stats" ? backend.stats : backend.diagnose; + try { + const payload = await hook?.(agentDir, this.ctx.sessionManager.getCwd(), this.ctx.session); + if (!payload) { + this.ctx.showWarning(`Memory ${action} is not available for the ${backend.id} backend.`); + return; + } + showMarkdownPanel(this.ctx, `Memory ${action === "stats" ? "Stats" : "Diagnostics"}`, payload); + } catch (error) { + this.ctx.showError(`Memory ${action} failed: ${error instanceof Error ? error.message : String(error)}`); + } + return; + } + if (action === "mm") { await this.#handleMentalModelsSubcommand(argumentText); return; } - this.ctx.showError("Usage: /memory "); + this.ctx.showError("Usage: /memory "); } async #handleMentalModelsSubcommand(argumentText: string): Promise { diff --git a/packages/coding-agent/src/prompts/system/system-prompt.md b/packages/coding-agent/src/prompts/system/system-prompt.md index 1047093e2..29c3d027c 100644 --- a/packages/coding-agent/src/prompts/system/system-prompt.md +++ b/packages/coding-agent/src/prompts/system/system-prompt.md @@ -54,7 +54,9 @@ With most FS/bash-like tools, static references to them will automatically resol - `skill://`: Skill instructions - `/`: File within a skill - `rule://`: Rule details +{{#if hasMemoryRoot}} - `memory://root`: Project memory summary +{{/if}} - `agent://`: Full agent output artifact - `/`: JSON field extraction - `artifact://`: Artifact content diff --git a/packages/coding-agent/src/prompts/tools/memory-edit.md b/packages/coding-agent/src/prompts/tools/memory-edit.md new file mode 100644 index 000000000..fd0523579 --- /dev/null +++ b/packages/coding-agent/src/prompts/tools/memory-edit.md @@ -0,0 +1,8 @@ +Edit Mnemosyne long-term memories by id. + +Use only with ids returned by the `recall` tool. Operations: +- `update`: replace content and/or importance for a working memory. +- `forget`: permanently delete a working memory. +- `invalidate`: softly supersede a working or episodic memory, optionally pointing at `replacement_id`. + +Prefer `invalidate` when a memory became stale but its history may still be useful. Use `forget` only for content that should be hard-deleted. diff --git a/packages/coding-agent/src/slash-commands/builtin-registry.ts b/packages/coding-agent/src/slash-commands/builtin-registry.ts index d02ac0c41..a097856de 100644 --- a/packages/coding-agent/src/slash-commands/builtin-registry.ts +++ b/packages/coding-agent/src/slash-commands/builtin-registry.ts @@ -891,6 +891,8 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ acpInputHint: "", subcommands: [ { name: "view", description: "Show current memory injection payload" }, + { name: "stats", description: "Show memory backend statistics" }, + { name: "diagnose", description: "Run memory backend diagnostics" }, { name: "clear", description: "Clear persisted memory data and artifacts" }, { name: "reset", description: "Alias for clear" }, { name: "enqueue", description: "Enqueue memory consolidation maintenance" }, @@ -933,13 +935,20 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ await runtime.output("Memory consolidation enqueued."); return commandConsumed(); } + case "stats": + case "diagnose": { + const hook = verb === "stats" ? backend.stats : backend.diagnose; + const payload = await hook?.(runtime.settings.getAgentDir(), runtime.cwd, runtime.session); + await runtime.output(payload ?? `Memory ${verb} is not available for the ${backend.id} backend.`); + return commandConsumed(); + } case "mm": return usage( "Mental-model maintenance via /memory mm is unsupported in ACP mode; use the hindsight HTTP API directly.", runtime, ); default: - return usage("Usage: /memory ", runtime); + return usage("Usage: /memory ", runtime); } }, handleTui: async (command, runtime) => { diff --git a/packages/coding-agent/src/system-prompt.ts b/packages/coding-agent/src/system-prompt.ts index acff19b92..a8b525b06 100644 --- a/packages/coding-agent/src/system-prompt.ts +++ b/packages/coding-agent/src/system-prompt.ts @@ -361,6 +361,8 @@ export interface BuildSystemPromptOptions { secretsEnabled?: boolean; /** Pre-loaded workspace tree (skips discovery if provided). May be a Promise to allow early kick-off. */ workspaceTree?: WorkspaceTree | Promise; + /** Whether the local memory://root summary is active. */ + memoryRootEnabled?: boolean; } /** Result of building provider-facing system prompt messages. */ @@ -393,6 +395,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): eagerTasks = false, secretsEnabled = false, workspaceTree: providedWorkspaceTree, + memoryRootEnabled = false, } = options; const resolvedCwd = cwd ?? getProjectDir(); @@ -570,6 +573,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): mcpDiscoveryServerSummaries, eagerTasks, secretsEnabled, + hasMemoryRoot: memoryRootEnabled, hasObsidian: hasObsidian(), }; const rendered = prompt.render(resolvedCustomPrompt ? customSystemPromptTemplate : systemPromptTemplate, data); diff --git a/packages/coding-agent/src/tools/index.ts b/packages/coding-agent/src/tools/index.ts index 2dfd3334b..59dac87e3 100644 --- a/packages/coding-agent/src/tools/index.ts +++ b/packages/coding-agent/src/tools/index.ts @@ -37,6 +37,7 @@ import { GithubTool } from "./gh"; import { InspectImageTool } from "./inspect-image"; import { IrcTool } from "./irc"; import { JobTool } from "./job"; +import { MemoryEditTool } from "./memory-edit"; import { MemoryRecallTool } from "./memory-recall"; import { MemoryReflectTool } from "./memory-reflect"; import { MemoryRetainTool } from "./memory-retain"; @@ -78,6 +79,7 @@ export * from "./image-gen"; export * from "./inspect-image"; export * from "./irc"; export * from "./job"; +export * from "./memory-edit"; export * from "./memory-recall"; export * from "./memory-reflect"; export * from "./memory-retain"; @@ -304,6 +306,7 @@ export const BUILTIN_TOOLS: Record = { web_search: s => new WebSearchTool(s), search_tool_bm25: SearchToolBm25Tool.createIf, write: s => new WriteTool(s), + memory_edit: MemoryEditTool.createIf, retain: MemoryRetainTool.createIf, recall: MemoryRecallTool.createIf, reflect: MemoryReflectTool.createIf, diff --git a/packages/coding-agent/src/tools/memory-edit.ts b/packages/coding-agent/src/tools/memory-edit.ts new file mode 100644 index 000000000..afe748f62 --- /dev/null +++ b/packages/coding-agent/src/tools/memory-edit.ts @@ -0,0 +1,59 @@ +import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core"; +import * as z from "zod/v4"; +import memoryEditDescription from "../prompts/tools/memory-edit.md" with { type: "text" }; +import type { ToolSession } from "."; + +const memoryEditSchema = z.object({ + op: z.enum(["update", "forget", "invalidate"]).describe("memory edit operation"), + id: z.string().describe("memory id from recall output"), + content: z.string().optional().describe("replacement content for update"), + importance: z.number().optional().describe("replacement importance for update, clamped to [0, 1]"), + replacement_id: z.string().optional().describe("replacement memory id for invalidate"), +}); + +export type MemoryEditParams = z.infer; + +export class MemoryEditTool implements AgentTool { + readonly name = "memory_edit"; + readonly approval = "read" as const; + readonly label = "Memory Edit"; + readonly description = memoryEditDescription; + readonly parameters = memoryEditSchema; + readonly strict = true; + readonly loadMode = "discoverable"; + readonly summary = "Update, forget, or invalidate Mnemosyne memories"; + + constructor(private readonly session: ToolSession) {} + + static createIf(session: ToolSession): MemoryEditTool | null { + const backend = session.settings.get("memory.backend"); + if (backend !== "mnemosyne") return null; + return new MemoryEditTool(session); + } + + async execute(_id: string, params: MemoryEditParams): Promise { + const state = this.session.getMnemosyneSessionState?.(); + if (!state) { + throw new Error("Mnemosyne backend is not initialised for this session."); + } + if (params.op === "update" && params.content === undefined && params.importance === undefined) { + throw new Error("memory_edit update requires content or importance."); + } + + const importance = params.importance === undefined ? undefined : Math.max(0, Math.min(1, params.importance)); + const result = state.editScopedMemory(params.op, params.id, { + content: params.content, + importance, + replacementId: params.replacement_id, + }); + const location = result.bank ? ` in bank ${result.bank}${result.store ? ` (${result.store})` : ""}` : ""; + const text = + result.status === "not_found" + ? `Memory ${params.id} was not found${location}.` + : `Memory ${params.id} ${result.status}${location}.`; + return { + content: [{ type: "text", text }], + details: result, + }; + } +} diff --git a/packages/coding-agent/src/tools/memory-recall.ts b/packages/coding-agent/src/tools/memory-recall.ts index 0e3a14914..7cbd7fde9 100644 --- a/packages/coding-agent/src/tools/memory-recall.ts +++ b/packages/coding-agent/src/tools/memory-recall.ts @@ -45,7 +45,7 @@ export class MemoryRecallTool implements AgentTool { details: {}, }; } - const formatted = state.formatContextScoped(results); + const formatted = state.formatScopedRecallWithIds(results); return { content: [ { diff --git a/packages/coding-agent/test/memory-tools.test.ts b/packages/coding-agent/test/memory-tools.test.ts index 4c5d8b624..e48ab79b5 100644 --- a/packages/coding-agent/test/memory-tools.test.ts +++ b/packages/coding-agent/test/memory-tools.test.ts @@ -24,6 +24,7 @@ import { setMnemosyneSessionState, } from "@oh-my-pi/pi-coding-agent/mnemosyne/state"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools/index"; +import { MemoryEditTool } from "@oh-my-pi/pi-coding-agent/tools/memory-edit"; import { MemoryRecallTool } from "@oh-my-pi/pi-coding-agent/tools/memory-recall"; import { MemoryReflectTool } from "@oh-my-pi/pi-coding-agent/tools/memory-reflect"; import { MemoryRetainTool } from "@oh-my-pi/pi-coding-agent/tools/memory-retain"; @@ -214,20 +215,24 @@ describe("Mnemosyne tool factories", () => { } }); - it("retain/recall/reflect factories return null when memory.backend !== mnemosyne", () => { - const settings = Settings.isolated({ "memory.backend": "local", "memories.enabled": false }); - const session = makeSession(settings); - expect(MemoryRetainTool.createIf(session)).toBeNull(); - expect(MemoryRecallTool.createIf(session)).toBeNull(); - expect(MemoryReflectTool.createIf(session)).toBeNull(); + it("memory tool factories gate on supported backends", () => { + const offSettings = Settings.isolated({ "memory.backend": "off", "memories.enabled": false }); + const hindsightSettings = Settings.isolated({ "memory.backend": "hindsight" }); + const localSession = makeSession(Settings.isolated({ "memory.backend": "local", "memories.enabled": false })); + expect(MemoryRetainTool.createIf(localSession)).toBeNull(); + expect(MemoryRecallTool.createIf(localSession)).toBeNull(); + expect(MemoryReflectTool.createIf(localSession)).toBeNull(); + expect(MemoryEditTool.createIf(makeSession(offSettings))).toBeNull(); + expect(MemoryEditTool.createIf(makeSession(hindsightSettings))).toBeNull(); }); - it("retain/recall/reflect factories return tool instances when memory.backend === mnemosyne", () => { + it("retain/recall/reflect/edit factories return tool instances when memory.backend === mnemosyne", () => { const settings = Settings.isolated({ "memory.backend": "mnemosyne" }); const session = makeSession(settings); expect(MemoryRetainTool.createIf(session)).toBeInstanceOf(MemoryRetainTool); expect(MemoryRecallTool.createIf(session)).toBeInstanceOf(MemoryRecallTool); expect(MemoryReflectTool.createIf(session)).toBeInstanceOf(MemoryReflectTool); + expect(MemoryEditTool.createIf(session)).toBeInstanceOf(MemoryEditTool); }); }); @@ -656,6 +661,7 @@ describe("recall.execute (Mnemosyne backend)", () => { const result = await recallTool.execute("call-mnemosyne-query", { query: "editor preferences" }); const text = (result.content[0] as { text: string }).text; + expect(text).toMatch(/\(id: [^)]+\)/); expect(text).toContain("Found 1 relevant memory"); expect(text).toContain("the user prefers dark mode in their editor"); }); @@ -731,6 +737,119 @@ describe("recall.execute (Mnemosyne backend)", () => { }); }); +describe("memory_edit.execute (Mnemosyne backend)", () => { + beforeEach(() => { + resetSettingsForTest(); + registeredMnemosyneState = undefined; + tempDbPath = undefined; + }); + + afterEach(() => { + vi.restoreAllMocks(); + registeredMnemosyneState?.dispose(); + registeredMnemosyneState = undefined; + if (tempDbPath) { + try { + const tempDir = path.dirname(tempDbPath); + rmSync(tempDir, { recursive: true, force: true }); + } catch {} + tempDbPath = undefined; + } + }); + + async function retainAndRecallId(settings: Settings, content: string, query: string): Promise { + await MemoryRetainTool.createIf(makeSession(settings))!.execute("call-memory-edit-store", { + items: [{ content }], + }); + const id = registeredMnemosyneState?.recallResultsScoped(query)[0]?.id; + expect(id).toBeString(); + return id!; + } + + it("updates a working memory by recall id", async () => { + const settings = Settings.isolated({ "memory.backend": "mnemosyne" }); + registerMnemosyneState(); + const id = await retainAndRecallId(settings, "editor accent color is blue", "accent color"); + + const result = await MemoryEditTool.createIf(makeSession(settings))!.execute("call-memory-edit-update", { + op: "update", + id, + content: "editor accent color is green", + importance: 2, + }); + + expect((result.content[0] as { text: string }).text).toContain("updated"); + const recalled = registeredMnemosyneState!.recallResultsScoped("accent color"); + expect(recalled.map(memory => memory.content)).toContain("editor accent color is green"); + }); + + it("forgets a working memory by recall id", async () => { + const settings = Settings.isolated({ "memory.backend": "mnemosyne" }); + registerMnemosyneState(); + const id = await retainAndRecallId(settings, "temporary deployment note can be deleted", "deployment note"); + + const result = await MemoryEditTool.createIf(makeSession(settings))!.execute("call-memory-edit-forget", { + op: "forget", + id, + }); + + expect((result.content[0] as { text: string }).text).toContain("deleted"); + const recalled = registeredMnemosyneState!.recallResultsScoped("deployment note"); + expect(recalled.map(memory => memory.content)).not.toContain("temporary deployment note can be deleted"); + }); + + it("invalidates a working memory by recall id", async () => { + const settings = Settings.isolated({ "memory.backend": "mnemosyne" }); + registerMnemosyneState(); + const id = await retainAndRecallId(settings, "stale api key rotation policy", "api key rotation"); + + const result = await MemoryEditTool.createIf(makeSession(settings))!.execute("call-memory-edit-invalidate", { + op: "invalidate", + id, + }); + + expect((result.content[0] as { text: string }).text).toContain("invalidated"); + const recalled = registeredMnemosyneState!.recallResultsScoped("api key rotation"); + expect(recalled.map(memory => memory.content)).not.toContain("stale api key rotation policy"); + }); + + it("reports not_found for unknown ids", async () => { + const settings = Settings.isolated({ "memory.backend": "mnemosyne" }); + registerMnemosyneState(); + + const result = await MemoryEditTool.createIf(makeSession(settings))!.execute("call-memory-edit-missing", { + op: "forget", + id: "missing-memory-id", + }); + + expect(result.details).toEqual({ status: "not_found" }); + expect((result.content[0] as { text: string }).text).toContain("not found"); + }); + + it("throws when no per-session Mnemosyne state is registered", async () => { + const settings = Settings.isolated({ "memory.backend": "mnemosyne" }); + const tool = MemoryEditTool.createIf(makeSession(settings))!; + await expect(tool.execute("call-memory-edit-no-state", { op: "forget", id: "anything" })).rejects.toThrow( + /not initialised/i, + ); + }); + + it("renders backend stats and diagnostics for scoped banks", async () => { + const settings = Settings.isolated({ "memory.backend": "mnemosyne" }); + const state = registerMnemosyneState(); + await retainAndRecallId(settings, "stats fixture memory for mnemosyne", "stats fixture"); + + const stats = await mnemosyneBackend.stats?.("/tmp/agent", "/tmp", state.session); + const diagnose = await mnemosyneBackend.diagnose?.("/tmp/agent", "/tmp", state.session); + + expect(stats).toContain("# Mnemosyne Memory Stats"); + expect(stats).toContain("test-bank"); + expect(diagnose).toContain("# Mnemosyne Memory Diagnostics"); + expect(diagnose).toContain("test-bank"); + }); +}); + + describe("reflect.execute", () => { beforeEach(() => { resetSettingsForTest(); diff --git a/packages/coding-agent/test/system-prompt-templates.test.ts b/packages/coding-agent/test/system-prompt-templates.test.ts index deed5199b..90cd475ba 100644 --- a/packages/coding-agent/test/system-prompt-templates.test.ts +++ b/packages/coding-agent/test/system-prompt-templates.test.ts @@ -205,6 +205,31 @@ describe("system Handlebars prompt templates", () => { expect(rendered).toContain("call `search_tool_bm25` before concluding no such tool exists"); }); + test("buildSystemPrompt gates memory root URL advertisement", async () => { + const baseOptions = { + cwd: os.tmpdir(), + contextFiles: [], + skills: [], + rules: [], + toolNames: ["read"], + }; + + const enabled = await buildSystemPrompt({ + ...baseOptions, + memoryRootEnabled: true, + }); + const disabled = await buildSystemPrompt({ + ...baseOptions, + memoryRootEnabled: false, + }); + const omitted = await buildSystemPrompt(baseOptions); + + expect(enabled.systemPrompt.join("\n\n")).toContain("memory://root"); + expect(disabled.systemPrompt.join("\n\n")).not.toContain("memory://root"); + expect(omitted.systemPrompt.join("\n\n")).not.toContain("memory://root"); + }); + + test("buildSystemPrompt keeps system and project as separate ordered blocks with date context in project", async () => { await withTempDir(async dir => { const { systemPrompt } = await buildSystemPrompt({ diff --git a/packages/mnemosyne/package.json b/packages/mnemosyne/package.json index b0fa95104..97b139dc0 100644 --- a/packages/mnemosyne/package.json +++ b/packages/mnemosyne/package.json @@ -66,6 +66,10 @@ "types": "./src/core/beam/index.ts", "import": "./src/core/beam/index.ts" }, + "./diagnose": { + "types": "./src/diagnose.ts", + "import": "./src/diagnose.ts" + }, "./mcp": { "types": "./src/mcp-tools.ts", "import": "./src/mcp-tools.ts"