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") {