diff --git a/crates/mc-module/src/memory_render.rs b/crates/mc-module/src/memory_render.rs index 6a418a71b..e2db4231a 100644 --- a/crates/mc-module/src/memory_render.rs +++ b/crates/mc-module/src/memory_render.rs @@ -15,7 +15,9 @@ //! is the slice-4d integration decision, already ruled; the byte render here is pure. use crate::decay_render::{render_decayed_compartments, DecayRenderCompartment}; -use mc_store::{StoredMemory, StoredMemoryMutation, WorkspaceMembership}; +use mc_store::{ + StoredMemory, StoredMemoryMutation, WorkspaceMembership, MEMORY_VISIBILITY_MUTATION_CATEGORY, +}; use std::cmp::Ordering; use std::collections::HashSet; @@ -325,11 +327,23 @@ pub fn render_memory_updates( vec!["These memories changed since the snapshot below — trust these:".to_string()]; for m in mutations { match m.mutation_type.as_str() { - "update" => lines.push(format!( - " {}", - m.target_memory_id, - escape_xml_content(m.new_content.as_deref().unwrap_or("")) - )), + "update" => { + let category_attr = match &m.category { + Some(category) + if category != MEMORY_VISIBILITY_MUTATION_CATEGORY + && !category.is_empty() => + { + format!(" category=\"{}\"", escape_xml_attr(category)) + } + _ => String::new(), + }; + lines.push(format!( + " {}", + m.target_memory_id, + category_attr, + escape_xml_content(m.new_content.as_deref().unwrap_or("")) + )); + } "superseded" => match m.superseded_by_id { Some(by) if resolvable_ids.contains(&by) => lines.push(format!( " ", diff --git a/crates/mc-module/testdata/memory-update-delta-parity.json b/crates/mc-module/testdata/memory-update-delta-parity.json index 4311b811e..f9b799856 100644 --- a/crates/mc-module/testdata/memory-update-delta-parity.json +++ b/crates/mc-module/testdata/memory-update-delta-parity.json @@ -1,5 +1,5 @@ { - "first_delta": "\nThese memories changed since the snapshot below — trust these:\n updated <alpha> & stable\n \n", - "second_delta": "\nThese memories changed since the snapshot below — trust these:\n updated <alpha> & stable\n \n \n merged <delta> & sources\n", + "first_delta": "\nThese memories changed since the snapshot below — trust these:\n updated <alpha> & stable\n \n", + "second_delta": "\nThese memories changed since the snapshot below — trust these:\n updated <alpha> & stable\n \n \n merged <delta> & sources\n", "reconciled_m0": "\n\n#1: updated <alpha> & stable\n#4: merged <delta> & sources\n\n" } diff --git a/docs/specs/prompt-surface/budget-fixture.json b/docs/specs/prompt-surface/budget-fixture.json index 47df584d7..f925f7eb1 100644 --- a/docs/specs/prompt-surface/budget-fixture.json +++ b/docs/specs/prompt-surface/budget-fixture.json @@ -58,11 +58,11 @@ "ctx_reduce": { "chars": 91, "tokens": 31 }, "ctx_expand": { "chars": 791, "tokens": 177 }, "ctx_note": { "chars": 1574, "tokens": 378 }, - "ctx_memory": { "chars": 840, "tokens": 201 }, + "ctx_memory": { "chars": 912, "tokens": 216 }, "ctx_search": { "chars": 777, "tokens": 184 }, - "totalTokens": 971 + "totalTokens": 986 }, - "builtInProviderVisibleTotal": 4720 + "builtInProviderVisibleTotal": 4735 }, "mutableProseBaseline": 3749, "integerLightCeiling": 1825, diff --git a/packages/pi-plugin/src/inject-compartments-pi.ts b/packages/pi-plugin/src/inject-compartments-pi.ts index 262eeb83b..44180bdd9 100644 --- a/packages/pi-plugin/src/inject-compartments-pi.ts +++ b/packages/pi-plugin/src/inject-compartments-pi.ts @@ -37,6 +37,7 @@ import { isNoContentCompartment } from "@magic-context/core/features/magic-conte import { type ContextDatabase, clearCachedM0M1, + escapeXmlAttr, escapeXmlContent, GLOBAL_USER_PROFILE_PROJECT_PATH, getCompartments, @@ -1892,8 +1893,12 @@ function renderMemoryUpdatesBlockPi(args: { } if (mutation.visibilityChanged && mutation.newContent === null) continue; if (mutation.mutationType === "update") { + const categoryAttr = + mutation.category && mutation.category !== "__mc_visibility__" + ? ` category="${escapeXmlAttr(mutation.category)}"` + : ""; lines.push( - ` ${escapeXmlContent(mutation.newContent ?? "")}`, + ` ${escapeXmlContent(mutation.newContent ?? "")}`, ); continue; } diff --git a/packages/plugin/src/features/magic-context/storage-memory-mutation-log.ts b/packages/plugin/src/features/magic-context/storage-memory-mutation-log.ts index a33ba959c..35231f731 100644 --- a/packages/plugin/src/features/magic-context/storage-memory-mutation-log.ts +++ b/packages/plugin/src/features/magic-context/storage-memory-mutation-log.ts @@ -3,7 +3,7 @@ import type { Database } from "../../shared/sqlite"; export type MemoryMutationType = "archive" | "delete" | "update" | "superseded"; const MEMORY_MUTATION_TYPES = new Set(["archive", "delete", "update", "superseded"]); -const MEMORY_VISIBILITY_MUTATION_CATEGORY = "__mc_visibility__"; +export const MEMORY_VISIBILITY_MUTATION_CATEGORY = "__mc_visibility__"; const MAX_MEMORY_REPLACEMENT_DEPTH = 8; // Terminal mutations mean the memory LEFT the active set (renders as diff --git a/packages/plugin/src/features/magic-context/storage.ts b/packages/plugin/src/features/magic-context/storage.ts index c1d154acd..15df3b630 100644 --- a/packages/plugin/src/features/magic-context/storage.ts +++ b/packages/plugin/src/features/magic-context/storage.ts @@ -113,6 +113,7 @@ export { getMemoryMutation, getMemoryMutationsForRender, getMemoryMutationsForRenderByProjects, + MEMORY_VISIBILITY_MUTATION_CATEGORY, type MemoryMutationLogRow, type MemoryMutationType, queueMemoryMutation, diff --git a/packages/plugin/src/hooks/magic-context/inject-compartments.test.ts b/packages/plugin/src/hooks/magic-context/inject-compartments.test.ts index 638059e71..72dd88fb5 100644 --- a/packages/plugin/src/hooks/magic-context/inject-compartments.test.ts +++ b/packages/plugin/src/hooks/magic-context/inject-compartments.test.ts @@ -3157,6 +3157,55 @@ describe("m[0]/m[1] materialization", () => { expect(renderedText(bust[1])).not.toContain("Updated but not resident."); }); + it("renders the recategorize category on in memory-updates", () => { + db = makeDb(); + const projectDirectory = makeProjectDir(); + const memory = insertMemory(db, { + projectPath: PROJECT_PATH, + category: "CONFIG_VALUES", + content: "old category fact", + }); + const state = readStateFromMeta(); + const hard = materializeM0({ + db, + sessionId: SESSION_ID, + state, + projectPath: PROJECT_PATH, + projectDirectory, + injectDocs: false, + memoryInjectionBudgetTokens: 8_000, + }); + expect(hard.renderedMemoryIds).toEqual([memory.id]); + + db.prepare( + "UPDATE memories SET content = ?, category = ?, normalized_hash = ?, updated_at = ? WHERE id = ?", + ).run("new category fact", "CONSTRAINTS", "new-category-fact", Date.now(), memory.id); + queueMemoryMutation(db, { + projectPath: PROJECT_PATH, + mutationType: "update", + targetMemoryId: memory.id, + category: "CONSTRAINTS", + newContent: "new category fact", + queuedAt: 10, + }); + + const m1 = renderM1( + { + db, + sessionId: SESSION_ID, + state, + projectPath: PROJECT_PATH, + memoryInjectionBudgetTokens: 8_000, + }, + hard.snapshotMarkers, + hard.renderedMemoryIds, + ); + const updates = m1.match(/[\s\S]*?<\/memory-updates>/)?.[0]; + expect(updates).toContain( + `new category fact`, + ); + }); + it("reconcile rematerialization advances the memory mutation cursor and omits memory-updates", () => { db = makeDb(); const projectDirectory = makeProjectDir(); diff --git a/packages/plugin/src/hooks/magic-context/inject-compartments.ts b/packages/plugin/src/hooks/magic-context/inject-compartments.ts index b5fd7ad3c..72f8b8bf6 100644 --- a/packages/plugin/src/hooks/magic-context/inject-compartments.ts +++ b/packages/plugin/src/hooks/magic-context/inject-compartments.ts @@ -21,6 +21,7 @@ import type { MuralWireOptions } from "../../features/magic-context/mural/resolv import { isNoContentCompartment } from "../../features/magic-context/no-content-compartment"; import { GLOBAL_USER_PROFILE_PROJECT_PATH, + MEMORY_VISIBILITY_MUTATION_CATEGORY, getMaxM0MutationId, getMaxMemoryMutationId, getMaxMemoryMutationIdForProjects, @@ -2570,8 +2571,12 @@ function renderMemoryUpdatesBlock(args: { } if (mutation.visibilityChanged && mutation.newContent === null) continue; if (mutation.mutationType === "update") { + const categoryAttr = + mutation.category && mutation.category !== MEMORY_VISIBILITY_MUTATION_CATEGORY + ? ` category="${escapeXmlAttr(mutation.category)}"` + : ""; lines.push( - ` ${escapeXmlContent(mutation.newContent ?? "")}`, + ` ${escapeXmlContent(mutation.newContent ?? "")}`, ); continue; } diff --git a/packages/plugin/src/shared/prompt-surface-a1-golden.md b/packages/plugin/src/shared/prompt-surface-a1-golden.md index cba03e49c..a073903c7 100644 --- a/packages/plugin/src/shared/prompt-surface-a1-golden.md +++ b/packages/plugin/src/shared/prompt-surface-a1-golden.md @@ -336,7 +336,7 @@ Example: ctx_note(action="write", content="Re-run the perf benchmark once the bo } ``` -### ctx_memory — description ~234 tokens, params ~201 tokens (total ~435) +### ctx_memory — description ~234 tokens, params ~216 tokens (total ~450) **Description:** @@ -376,7 +376,7 @@ Example: ctx_memory(action="write", category="CONSTRAINTS", content="Pi stores s "type": "string" }, "category": { - "description": "What kind of fact this is (required for write; optional merge override)", + "description": "What kind of fact this is (required for write; optional on update to recategorize, omitted keeps the current category; optional merge override)", "type": "string", "enum": [ "PROJECT_RULES", diff --git a/packages/plugin/src/tools/ctx-memory/tools.test.ts b/packages/plugin/src/tools/ctx-memory/tools.test.ts index 909640906..a107db3c3 100644 --- a/packages/plugin/src/tools/ctx-memory/tools.test.ts +++ b/packages/plugin/src/tools/ctx-memory/tools.test.ts @@ -1690,6 +1690,422 @@ describe("createCtxMemoryTools", () => { expect(getMemoryById(db, memory.id)?.status).toBe("archived"); }); + it("rejects recategorizing a legacy raw-path memory onto an existing duplicate", async () => { + const rawProjectPath = "/legacy/raw-project"; + const projectIdentity = normalizeStoredProjectPath(rawProjectPath); + const legacyTools = createCtxMemoryTools({ + db, + resolveProjectPath: () => projectIdentity, + memoryEnabled: true, + embeddingEnabled: false, + }); + const existing = insertMemory(db, { + projectPath: rawProjectPath, + category: "CONSTRAINTS", + content: "timeout=5s", + }); + const memory = insertMemory(db, { + projectPath: rawProjectPath, + category: "CONFIG_DEFAULTS", + content: "timeout=5s", + }); + + const result = await legacyTools.ctx_memory.execute( + { + action: "update", + ids: [memory.id], + category: "CONSTRAINTS", + content: "timeout=5s", + }, + toolContext("ses-primary", "general"), + ); + + expect(result).toBe( + `Error: Memory content already exists as ID ${existing.id}; merge or archive duplicates instead.`, + ); + expect(getMemoryById(db, memory.id)).toMatchObject({ + category: "CONFIG_DEFAULTS", + content: "timeout=5s", + projectPath: rawProjectPath, + }); + }); + + it("recategorizes a legacy raw-path memory when no duplicate exists under the stored path", async () => { + const rawProjectPath = "/legacy/raw-project"; + const projectIdentity = normalizeStoredProjectPath(rawProjectPath); + const legacyTools = createCtxMemoryTools({ + db, + resolveProjectPath: () => projectIdentity, + memoryEnabled: true, + embeddingEnabled: false, + }); + const memory = insertMemory(db, { + projectPath: rawProjectPath, + category: "CONFIG_DEFAULTS", + content: "timeout=5s", + }); + + const result = await legacyTools.ctx_memory.execute( + { + action: "update", + ids: [memory.id], + category: "CONSTRAINTS", + content: "timeout=5s", + }, + toolContext("ses-primary", "general"), + ); + + expect(result).toBe(`Updated memory [ID: ${memory.id}] in CONSTRAINTS.`); + expect(getMemoryById(db, memory.id)).toMatchObject({ + category: "CONSTRAINTS", + content: "timeout=5s", + projectPath: rawProjectPath, + }); + }); + + it("persists a valid category change on update", async () => { + const memory = insertMemory(db, { + projectPath: "/repo/project", + category: "CONFIG_VALUES", + content: "cache_ttl=5m", + }); + + const result = await tools.ctx_memory.execute( + { + action: "update", + ids: [memory.id], + category: "CONSTRAINTS", + content: "cache_ttl=10m", + }, + toolContext("ses-primary", "general"), + ); + + expect(result).toBe(`Updated memory [ID: ${memory.id}] in CONSTRAINTS.`); + expect(getMemoryById(db, memory.id)).toMatchObject({ + category: "CONSTRAINTS", + content: "cache_ttl=10m", + }); + expect(getMutationRows(db, "/repo/project", [memory.id])).toMatchObject([ + { + mutationType: "update", + targetMemoryId: memory.id, + category: "CONSTRAINTS", + newContent: "cache_ttl=10m", + }, + ]); + }); + + it("keeps the current category when update omits category", async () => { + const memory = insertMemory(db, { + projectPath: "/repo/project", + category: "CONFIG_VALUES", + content: "cache_ttl=5m", + }); + + const result = await tools.ctx_memory.execute( + { + action: "update", + ids: [memory.id], + content: "cache_ttl=10m", + }, + toolContext("ses-primary", "general"), + ); + + expect(result).toBe(`Updated memory [ID: ${memory.id}] in CONFIG_VALUES.`); + expect(getMemoryById(db, memory.id)?.category).toBe("CONFIG_VALUES"); + expect(getMutationRows(db, "/repo/project", [memory.id])).toMatchObject([ + { mutationType: "update", category: "CONFIG_VALUES" }, + ]); + }); + + it("keeps the current category when update receives an invalid category", async () => { + const memory = insertMemory(db, { + projectPath: "/repo/project", + category: "CONFIG_VALUES", + content: "cache_ttl=5m", + }); + + const result = await tools.ctx_memory.execute( + { + action: "update", + ids: [memory.id], + category: "NOT_A_CATEGORY", + content: "cache_ttl=10m", + }, + toolContext("ses-primary", "general"), + ); + + expect(result).toBe(`Updated memory [ID: ${memory.id}] in CONFIG_VALUES.`); + expect(getMemoryById(db, memory.id)).toMatchObject({ + category: "CONFIG_VALUES", + content: "cache_ttl=10m", + }); + expect(getMutationRows(db, "/repo/project", [memory.id])).toMatchObject([ + { mutationType: "update", category: "CONFIG_VALUES" }, + ]); + }); + + it("still rewrites content when recategorizing or omitting category", async () => { + const recategorized = insertMemory(db, { + projectPath: "/repo/project", + category: "CONFIG_VALUES", + content: "old recategorize content", + }); + const omitted = insertMemory(db, { + projectPath: "/repo/project", + category: "NAMING", + content: "old omitted-category content", + }); + + const recategorizeResult = await tools.ctx_memory.execute( + { + action: "update", + ids: [recategorized.id], + category: "PROJECT_RULES", + content: "new recategorize content", + }, + toolContext("ses-primary", "general"), + ); + const omitResult = await tools.ctx_memory.execute( + { + action: "update", + ids: [omitted.id], + content: "new omitted-category content", + }, + toolContext("ses-primary", "general"), + ); + + expect(recategorizeResult).toContain("in PROJECT_RULES"); + expect(omitResult).toContain("in NAMING"); + expect(getMemoryById(db, recategorized.id)).toMatchObject({ + category: "PROJECT_RULES", + content: "new recategorize content", + }); + expect(getMemoryById(db, omitted.id)).toMatchObject({ + category: "NAMING", + content: "new omitted-category content", + }); + }); + + it("rejects recategorizing onto an existing duplicate without throwing a constraint", async () => { + const existing = insertMemory(db, { + projectPath: "/repo/project", + category: "CONSTRAINTS", + content: "timeout=5s", + }); + const memory = insertMemory(db, { + projectPath: "/repo/project", + category: "CONFIG_VALUES", + content: "timeout=5s", + }); + + const result = await tools.ctx_memory.execute( + { + action: "update", + ids: [memory.id], + category: "CONSTRAINTS", + content: "timeout=5s", + }, + toolContext("ses-primary", "general"), + ); + + expect(result).toBe( + `Error: Memory content already exists as ID ${existing.id}; merge or archive duplicates instead.`, + ); + expect(String(result)).not.toContain("UNIQUE constraint failed"); + expect(getMemoryById(db, memory.id)).toMatchObject({ + category: "CONFIG_VALUES", + content: "timeout=5s", + }); + }); + + it("returns a friendly duplicate error after a unique-constraint fallback", async () => { + const originalPrepare = db.prepare.bind(db); + const originalExec = db.exec.bind(db); + let inTx = false; + (db as { exec: (sql: string) => unknown }).exec = (sql: string) => { + const text = String(sql); + if (/\bBEGIN\b/i.test(text)) inTx = true; + try { + return originalExec(sql); + } catch (error) { + inTx = false; + throw error; + } finally { + if (/\bCOMMIT\b/i.test(text) || /\bROLLBACK\b/i.test(text)) inTx = false; + } + }; + (db as { prepare: (sql: string) => unknown }).prepare = (sql: string) => { + const stmt = originalPrepare(sql); + if ( + sql.includes( + "FROM memories WHERE project_path = ? AND category = ? AND normalized_hash = ?", + ) + ) { + const originalGet = stmt.get.bind(stmt); + stmt.get = (...args: unknown[]) => (inTx ? undefined : originalGet(...args)); + } + return stmt; + }; + + try { + const existing = insertMemory(db, { + projectPath: "/repo/project", + category: "CONSTRAINTS", + content: "timeout=5s", + }); + const memory = insertMemory(db, { + projectPath: "/repo/project", + category: "CONFIG_VALUES", + content: "cache_ttl=5m", + }); + const result = await tools.ctx_memory.execute( + { + action: "update", + ids: [memory.id], + category: "CONSTRAINTS", + content: "timeout=5s", + }, + toolContext("ses-primary", "general"), + ); + + expect(result).toBe( + `Error: Memory content already exists as ID ${existing.id}; merge or archive duplicates instead.`, + ); + expect(String(result)).not.toContain("UNIQUE constraint failed"); + expect(getMemoryById(db, memory.id)).toMatchObject({ + category: "CONFIG_VALUES", + content: "cache_ttl=5m", + }); + } finally { + (db as { prepare: typeof originalPrepare }).prepare = originalPrepare; + (db as { exec: typeof originalExec }).exec = originalExec; + } + }); + + it("returns a friendly duplicate error when the constraint code is remapped but the message matches", async () => { + const originalPrepare = db.prepare.bind(db); + const originalExec = db.exec.bind(db); + let inTx = false; + (db as { exec: (sql: string) => unknown }).exec = (sql: string) => { + const text = String(sql); + if (/\bBEGIN\b/i.test(text)) inTx = true; + try { + return originalExec(sql); + } catch (error) { + inTx = false; + throw error; + } finally { + if (/\bCOMMIT\b/i.test(text) || /\bROLLBACK\b/i.test(text)) inTx = false; + } + }; + (db as { prepare: (sql: string) => unknown }).prepare = (sql: string) => { + const stmt = originalPrepare(sql); + if ( + sql.includes( + "FROM memories WHERE project_path = ? AND category = ? AND normalized_hash = ?", + ) + ) { + const originalGet = stmt.get.bind(stmt); + stmt.get = (...args: unknown[]) => (inTx ? undefined : originalGet(...args)); + } + if (sql.includes("UPDATE memories SET content = ?")) { + return { + run: () => { + const error = new Error( + "UNIQUE constraint failed: memories.project_path, memories.category, memories.normalized_hash", + ) as Error & { code?: string }; + error.code = "SQLITE_ERROR"; + throw error; + }, + }; + } + return stmt; + }; + + try { + const existing = insertMemory(db, { + projectPath: "/repo/project", + category: "CONSTRAINTS", + content: "timeout=5s", + }); + const memory = insertMemory(db, { + projectPath: "/repo/project", + category: "CONFIG_VALUES", + content: "cache_ttl=5m", + }); + const result = await tools.ctx_memory.execute( + { + action: "update", + ids: [memory.id], + category: "CONSTRAINTS", + content: "timeout=5s", + }, + toolContext("ses-primary", "general"), + ); + + expect(result).toBe( + `Error: Memory content already exists as ID ${existing.id}; merge or archive duplicates instead.`, + ); + expect(String(result)).not.toContain("UNIQUE constraint failed"); + expect(getMemoryById(db, memory.id)).toMatchObject({ + category: "CONFIG_VALUES", + content: "cache_ttl=5m", + }); + } finally { + (db as { prepare: typeof originalPrepare }).prepare = originalPrepare; + (db as { exec: typeof originalExec }).exec = originalExec; + } + }); + + it("rethrows authority errors instead of treating them as duplicates", async () => { + const originalPrepare = db.prepare.bind(db); + const memory = insertMemory(db, { + projectPath: "/repo/project", + category: "CONFIG_VALUES", + content: "cache_ttl=5m", + }); + (db as { prepare: (sql: string) => unknown }).prepare = (sql: string) => { + const stmt = originalPrepare(sql); + if (sql.includes("UPDATE memories SET content = ?")) { + return { + run: () => { + const error = new Error("authority is draining") as Error & { + code: string; + }; + error.code = "authority_draining"; + throw error; + }, + }; + } + return stmt; + }; + + let thrown: unknown; + try { + await tools.ctx_memory.execute( + { + action: "update", + ids: [memory.id], + content: "cache_ttl=10m", + }, + toolContext("ses-primary", "general"), + ); + } catch (error) { + thrown = error; + } finally { + (db as { prepare: typeof originalPrepare }).prepare = originalPrepare; + } + + expect(thrown).toBeInstanceOf(Error); + expect(String(thrown)).toContain("authority is draining"); + expect(String(thrown)).not.toContain("already exists as ID"); + expect(getMemoryById(db, memory.id)).toMatchObject({ + category: "CONFIG_VALUES", + content: "cache_ttl=5m", + }); + }); + it("rolls back content updates when queueing the mutation fails", async () => { const memory = insertMemory(db, { projectPath: "/repo/project", diff --git a/packages/plugin/src/tools/ctx-memory/tools.ts b/packages/plugin/src/tools/ctx-memory/tools.ts index dc4f610d5..36563fbc8 100644 --- a/packages/plugin/src/tools/ctx-memory/tools.ts +++ b/packages/plugin/src/tools/ctx-memory/tools.ts @@ -389,15 +389,32 @@ function inactiveMemoryError(id: number, action: "updating" | "merging" | "archi return `Error: Memory with ID ${id} is archived or superseded; restore it before ${action}.`; } +function isUniqueConstraintError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + const code = "code" in error ? (error as { code?: unknown }).code : undefined; + if (code === "SQLITE_CONSTRAINT_UNIQUE") { + return true; + } + // bun:sqlite sets SQLITE_CONSTRAINT_UNIQUE; node:sqlite may omit or remap + // the code. sqlite3_errmsg text is stable across adapters. + return /UNIQUE constraint failed/i.test(error.message); +} + +const DUPLICATE_MEMORY_ERROR = (id: number): string => + `Error: Memory content already exists as ID ${id}; merge or archive duplicates instead.`; + function updateMemoryContentInCurrentTransaction( db: CtxMemoryToolDeps["db"], memory: Memory, content: string, normalizedHash: string, + targetCategory: MemoryCategory = memory.category, ): void { db.prepare( - "UPDATE memories SET content = ?, normalized_hash = ?, updated_at = ? WHERE id = ?", - ).run(content, normalizedHash, Date.now(), memory.id); + "UPDATE memories SET content = ?, category = ?, normalized_hash = ?, updated_at = ? WHERE id = ?", + ).run(content, targetCategory, normalizedHash, Date.now(), memory.id); // The classify `shareable` verdict was scored against the OLD content; new // content invalidates it. Fail closed → private; the dreamer re-scores later. if (hasMemoryShareableColumn(db)) { @@ -428,7 +445,9 @@ const ctxMemoryArgsShape = { category: tool.schema .enum([...V2_MEMORY_CATEGORIES]) .optional() - .describe("What kind of fact this is (required for write; optional merge override)"), + .describe( + "What kind of fact this is (required for write; optional on update to recategorize, omitted keeps the current category; optional merge override)", + ), ids: tool.schema .array(tool.schema.number()) .optional() @@ -752,32 +771,64 @@ function createCtxMemoryTool(deps: CtxMemoryToolDeps): ToolDefinition { } const normalizedHash = computeNormalizedHash(content); - const duplicate = getMemoryByHash( - deps.db, - targetIdentityForStoredPath(rawProjectPath), - memory.category, - normalizedHash, - ); - if (duplicate && duplicate.id !== memory.id) { - return `Error: Memory content already exists as ID ${duplicate.id}; merge or archive duplicates instead.`; - } - + const targetCategory = + args.category && + (V2_MEMORY_CATEGORIES as readonly string[]).includes(args.category) + ? (args.category as MemoryCategory) + : memory.category; + // UNIQUE(project_path, category, normalized_hash) is on the + // stored path, which UPDATE leaves unchanged (legacy raw paths + // stay raw). Lookup with that same identity so a recategorize + // collision returns the duplicate error instead of throwing. + // Probe + write share one BEGIN IMMEDIATE so a concurrent insert + // cannot slip in between; UNIQUE remains the friendly fallback. const projectIdentity = targetIdentityForStoredPath(rawProjectPath); - runImmediateTransaction(deps.db, () => { - updateMemoryContentInCurrentTransaction( + let duplicateId: number | null = null; + try { + runImmediateTransaction(deps.db, () => { + const duplicate = getMemoryByHash( + deps.db, + rawProjectPath, + targetCategory, + normalizedHash, + ); + if (duplicate && duplicate.id !== memory.id) { + duplicateId = duplicate.id; + return; + } + updateMemoryContentInCurrentTransaction( + deps.db, + memory, + content, + normalizedHash, + targetCategory, + ); + queueMemoryMutation(deps.db, { + projectPath: projectIdentity, + mutationType: "update", + targetMemoryId: memory.id, + category: targetCategory, + newContent: content, + }); + }); + } catch (error) { + if (!isUniqueConstraintError(error)) { + throw error; + } + const raced = getMemoryByHash( deps.db, - memory, - content, + rawProjectPath, + targetCategory, normalizedHash, ); - queueMemoryMutation(deps.db, { - projectPath: projectIdentity, - mutationType: "update", - targetMemoryId: memory.id, - category: memory.category, - newContent: content, - }); - }); + if (raced && raced.id !== memory.id) { + return DUPLICATE_MEMORY_ERROR(raced.id); + } + throw error; + } + if (duplicateId !== null) { + return DUPLICATE_MEMORY_ERROR(duplicateId); + } queueMemoryEmbedding({ deps, sessionId: toolContext.sessionID, @@ -787,7 +838,7 @@ function createCtxMemoryTool(deps: CtxMemoryToolDeps): ToolDefinition { }); requestRustMemorySync(deps, toolContext.sessionID); - return `Updated memory [ID: ${memory.id}] in ${memory.category}.`; + return `Updated memory [ID: ${memory.id}] in ${targetCategory}.`; } if (args.action === "merge") {