Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@agentmemory/agentmemory",
"version": "0.9.27",
"description": "Persistent memory for AI coding agents, powered by iii-engine's three primitives",
"name": "ziiagentmemory",
"version": "0.2.0",
"description": "Persistent memory for AI coding agents — CJK-aware dedup, memory_update versioning, standalone MCP server",
Comment on lines +2 to +4

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Update downstream consumer files to match the rebrand.

The package name, bin executable, and repository metadata have been updated in package.json, but user-facing surfaces still reference the old identity:

  • README.md:49-54 — npm badge, CI badge, license badge, and stars badge all hardcode @agentmemory/agentmemory and rohitg00/agentmemory.
  • website/components/Install.tsx:13-35 — install commands use npm install -g @agentmemory/agentmemory`` and the CLI executable is invoked as agentmemory instead of `ziiagentmemory`.

These will direct users to the old package and repo after the rebrand.

Also applies to: 17-17

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@package.json` around lines 2 - 4, Update the downstream user-facing
references to match the rebrand in the affected consumer files: in README.md and
website/components/Install.tsx, replace all hardcoded old package/repo/CLI
identifiers with the new package name, repository identity, and executable used
by package.json. Check the badge links and install snippets so they no longer
reference `@agentmemory/agentmemory`, rohitg00/agentmemory, or the old agentmemory
binary, and keep the changes consistent with the metadata updated in
package.json.

"type": "module",
"main": "dist/index.mjs",
"types": "dist/index.d.mts",
Expand All @@ -14,6 +14,7 @@
"./package.json": "./package.json"
},
"bin": {
"ziiagentmemory": "dist/cli.mjs",
"agentmemory": "dist/cli.mjs"
},
"scripts": {
Expand Down Expand Up @@ -53,11 +54,16 @@
"README.md",
"AGENTS.md"
],
"author": "Rohit Ghumare <ghumare64@gmail.com>",
"author": "Zeeshan Ahmad <ziishanahmad@gmail.com>",
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "https://github.com/rohitg00/agentmemory"
"url": "https://github.com/ziishanahmad/ziiagentmemory"
},
"homepage": "https://github.com/ziishanahmad/ziiagentmemory",
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.142",
Expand Down
126 changes: 125 additions & 1 deletion src/functions/remember.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { TriggerAction, type ISdk } from "iii-sdk";
import type { Memory } from "../types.js";
import { KV, generateId, jaccardSimilarity } from "../state/schema.js";
import { isNegationConflict } from "../state/memory-dedup.js";
import { StateKV } from "../state/kv.js";
import { withKeyedLock } from "../state/keyed-mutex.js";
import { memoryToObservation } from "../state/memory-utils.js";
Expand Down Expand Up @@ -78,7 +79,7 @@ export function registerRememberFunction(sdk: ISdk, kv: StateKV): void {
lowerContent,
existing.content.toLowerCase(),
);
if (similarity > 0.7) {
if (similarity > 0.7 && !isNegationConflict(lowerContent, existing.content.toLowerCase())) {
supersededId = existing.id;
supersededVersion = existing.version ?? 1;
supersededMemory = existing;
Expand Down Expand Up @@ -167,6 +168,129 @@ export function registerRememberFunction(sdk: ISdk, kv: StateKV): void {
},
);

// ── mem::update ─────────────────────────────────────────────────────
// Updates an existing memory by ID: sets new content, increments version,
// and marks the old memory as superseded. This fills the gap identified
// in #1018 where calling memory_save twice with the same concepts created
// two separate v1 memories instead of versioning.
sdk.registerFunction("mem::update",
async (data: {
memoryId: string;
content: string;
type?: string;
concepts?: string[];
files?: string[];
ttlDays?: number;
agentId?: string;
}) => {
if (!data.memoryId || typeof data.memoryId !== "string") {
return { success: false, error: "memoryId is required" };
}
if (
!data.content ||
typeof data.content !== "string" ||
!data.content.trim()
) {
return { success: false, error: "content is required" };
}
Comment on lines +186 to +195

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

mem::update missing array validation for concepts and files.

Unlike mem::remember (Lines 33-38), mem::update doesn't validate that concepts and files are arrays. A non-array value would be stored directly, violating the Memory interface and potentially causing runtime errors in downstream consumers. As per coding guidelines, inputs must be validated.

🛡️ Proposed validation additions
      if (!data.memoryId || typeof data.memoryId !== "string") {
        return { success: false, error: "memoryId is required" };
      }
      if (
        !data.content ||
        typeof data.content !== "string" ||
        !data.content.trim()
      ) {
        return { success: false, error: "content is required" };
      }
+     if (data.concepts && !Array.isArray(data.concepts)) {
+       return { success: false, error: "concepts must be an array" };
+     }
+     if (data.files && !Array.isArray(data.files)) {
+       return { success: false, error: "files must be an array" };
+     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (!data.memoryId || typeof data.memoryId !== "string") {
return { success: false, error: "memoryId is required" };
}
if (
!data.content ||
typeof data.content !== "string" ||
!data.content.trim()
) {
return { success: false, error: "content is required" };
}
if (!data.memoryId || typeof data.memoryId !== "string") {
return { success: false, error: "memoryId is required" };
}
if (
!data.content ||
typeof data.content !== "string" ||
!data.content.trim()
) {
return { success: false, error: "content is required" };
}
if (data.concepts && !Array.isArray(data.concepts)) {
return { success: false, error: "concepts must be an array" };
}
if (data.files && !Array.isArray(data.files)) {
return { success: false, error: "files must be an array" };
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/functions/remember.ts` around lines 186 - 195, The mem::update validation
in the update handler currently checks memoryId and content but omits array type
checks for concepts and files, unlike mem::remember. Update the same validation
block in the mem::update flow to verify concepts and files are arrays before
storing them, and reject non-array values with a clear validation error. Use the
existing update/validation logic around the mem::update handler and the Memory
shape as the reference points so downstream consumers only receive correctly
typed data.

Source: Coding guidelines


return withKeyedLock("mem:update", async () => {
const existing = await kv.get<Memory>(KV.memories, data.memoryId);
if (!existing) {
return { success: false, error: "memory not found" };
}

const now = new Date().toISOString();
const newVersion = (existing.version ?? 1) + 1;
const validTypes = new Set([
"pattern",
"preference",
"architecture",
"bug",
"workflow",
"fact",
]);
const memType = validTypes.has(data.type || "")
? (data.type as Memory["type"])
: existing.type;

const callAgentId =
typeof data.agentId === "string" && data.agentId.trim().length > 0
? data.agentId.trim().slice(0, 128)
: getAgentId();

const updated: Memory = {
id: existing.id,
createdAt: existing.createdAt,
updatedAt: now,
type: memType,
title: data.content.slice(0, 80),
content: data.content,
concepts: data.concepts ?? existing.concepts,
files: data.files ?? existing.files,
sessionIds: existing.sessionIds,
strength: existing.strength,
version: newVersion,
parentId: existing.parentId,
supersedes: [...(existing.supersedes ?? []), existing.id],
sourceObservationIds: existing.sourceObservationIds,
isLatest: true,
forgetAfter: existing.forgetAfter,
...(existing.imageRef ? { imageRef: existing.imageRef } : {}),
...(existing.imageData ? { imageData: existing.imageData } : {}),
...(callAgentId ? { agentId: callAgentId } : {}),
...(existing.project !== undefined && { project: existing.project }),
};

if (data.ttlDays && typeof data.ttlDays === "number" && data.ttlDays > 0) {
updated.forgetAfter = new Date(
Date.now() + data.ttlDays * 86400000,
).toISOString();
}

// Mark the old version as no longer latest.
existing.isLatest = false;
existing.updatedAt = now;
await kv.set(KV.memories, existing.id, existing);

// Save the updated memory in-place (same ID, new version).
await kv.set(KV.memories, updated.id, updated);
Comment on lines +171 to +257

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

mem::update versioning logic has data integrity issues.

  1. Lines 251-257 — Same-ID overwrite loses old version: existing is saved with isLatest: false then immediately overwritten by updated (same existing.id). The first kv.set is a wasted write and the old version is permanently lost. mem::remember avoids this by using generateId("mem") for the new version, preserving both records.

  2. Line 235 — Self-referencing supersedes: supersedes: [...(existing.supersedes ?? []), existing.id] adds the memory's own ID since updated.id === existing.id. In mem::remember, supersedes references a different memory's ID.

  3. Lines 197-201 — No isLatest guard: Updating a memory with isLatest: false (already superseded) would mark it as latest again, creating two "latest" entries for the same logical entity.

  4. Line 234 — parentId points to grandparent: parentId: existing.parentId preserves the original parent, not the immediate predecessor. For a new version, parentId should be existing.id.

If the intent is true versioning (preserving old versions), use generateId("mem") for the new version. If the intent is in-place update, remove the first kv.set and the isLatest/supersedes logic entirely.

🔧 Proposed fix: use new ID for versioned update (consistent with mem::remember)
 const updated: Memory = {
-  id: existing.id,
+  id: generateId("mem"),
   createdAt: existing.createdAt,
   updatedAt: now,
   type: memType,
   title: data.content.slice(0, 80),
   content: data.content,
   concepts: data.concepts ?? existing.concepts,
   files: data.files ?? existing.files,
   sessionIds: existing.sessionIds,
   strength: existing.strength,
   version: newVersion,
-  parentId: existing.parentId,
+  parentId: existing.id,
   supersedes: [...(existing.supersedes ?? []), existing.id],
   sourceObservationIds: existing.sourceObservationIds,
   isLatest: true,
   forgetAfter: existing.forgetAfter,
   ...(existing.imageRef ? { imageRef: existing.imageRef } : {}),
   ...(existing.imageData ? { imageData: existing.imageData } : {}),
   ...(callAgentId ? { agentId: callAgentId } : {}),
   ...(existing.project !== undefined && { project: existing.project }),
 };

And add an isLatest guard:

 const existing = await kv.get<Memory>(KV.memories, data.memoryId);
 if (!existing) {
   return { success: false, error: "memory not found" };
 }
+if (existing.isLatest === false) {
+  return { success: false, error: "memory is not the latest version" };
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// ── mem::update ─────────────────────────────────────────────────────
// Updates an existing memory by ID: sets new content, increments version,
// and marks the old memory as superseded. This fills the gap identified
// in #1018 where calling memory_save twice with the same concepts created
// two separate v1 memories instead of versioning.
sdk.registerFunction("mem::update",
async (data: {
memoryId: string;
content: string;
type?: string;
concepts?: string[];
files?: string[];
ttlDays?: number;
agentId?: string;
}) => {
if (!data.memoryId || typeof data.memoryId !== "string") {
return { success: false, error: "memoryId is required" };
}
if (
!data.content ||
typeof data.content !== "string" ||
!data.content.trim()
) {
return { success: false, error: "content is required" };
}
return withKeyedLock("mem:update", async () => {
const existing = await kv.get<Memory>(KV.memories, data.memoryId);
if (!existing) {
return { success: false, error: "memory not found" };
}
const now = new Date().toISOString();
const newVersion = (existing.version ?? 1) + 1;
const validTypes = new Set([
"pattern",
"preference",
"architecture",
"bug",
"workflow",
"fact",
]);
const memType = validTypes.has(data.type || "")
? (data.type as Memory["type"])
: existing.type;
const callAgentId =
typeof data.agentId === "string" && data.agentId.trim().length > 0
? data.agentId.trim().slice(0, 128)
: getAgentId();
const updated: Memory = {
id: existing.id,
createdAt: existing.createdAt,
updatedAt: now,
type: memType,
title: data.content.slice(0, 80),
content: data.content,
concepts: data.concepts ?? existing.concepts,
files: data.files ?? existing.files,
sessionIds: existing.sessionIds,
strength: existing.strength,
version: newVersion,
parentId: existing.parentId,
supersedes: [...(existing.supersedes ?? []), existing.id],
sourceObservationIds: existing.sourceObservationIds,
isLatest: true,
forgetAfter: existing.forgetAfter,
...(existing.imageRef ? { imageRef: existing.imageRef } : {}),
...(existing.imageData ? { imageData: existing.imageData } : {}),
...(callAgentId ? { agentId: callAgentId } : {}),
...(existing.project !== undefined && { project: existing.project }),
};
if (data.ttlDays && typeof data.ttlDays === "number" && data.ttlDays > 0) {
updated.forgetAfter = new Date(
Date.now() + data.ttlDays * 86400000,
).toISOString();
}
// Mark the old version as no longer latest.
existing.isLatest = false;
existing.updatedAt = now;
await kv.set(KV.memories, existing.id, existing);
// Save the updated memory in-place (same ID, new version).
await kv.set(KV.memories, updated.id, updated);
// ── mem::update ─────────────────────────────────────────────────────
// Updates an existing memory by ID: sets new content, increments version,
// and marks the old memory as superseded. This fills the gap identified
// in `#1018` where calling memory_save twice with the same concepts created
// two separate v1 memories instead of versioning.
sdk.registerFunction("mem::update",
async (data: {
memoryId: string;
content: string;
type?: string;
concepts?: string[];
files?: string[];
ttlDays?: number;
agentId?: string;
}) => {
if (!data.memoryId || typeof data.memoryId !== "string") {
return { success: false, error: "memoryId is required" };
}
if (
!data.content ||
typeof data.content !== "string" ||
!data.content.trim()
) {
return { success: false, error: "content is required" };
}
return withKeyedLock("mem:update", async () => {
const existing = await kv.get<Memory>(KV.memories, data.memoryId);
if (!existing) {
return { success: false, error: "memory not found" };
}
if (existing.isLatest === false) {
return { success: false, error: "memory is not the latest version" };
}
const now = new Date().toISOString();
const newVersion = (existing.version ?? 1) + 1;
const validTypes = new Set([
"pattern",
"preference",
"architecture",
"bug",
"workflow",
"fact",
]);
const memType = validTypes.has(data.type || "")
? (data.type as Memory["type"])
: existing.type;
const callAgentId =
typeof data.agentId === "string" && data.agentId.trim().length > 0
? data.agentId.trim().slice(0, 128)
: getAgentId();
const updated: Memory = {
id: generateId("mem"),
createdAt: existing.createdAt,
updatedAt: now,
type: memType,
title: data.content.slice(0, 80),
content: data.content,
concepts: data.concepts ?? existing.concepts,
files: data.files ?? existing.files,
sessionIds: existing.sessionIds,
strength: existing.strength,
version: newVersion,
parentId: existing.id,
supersedes: [...(existing.supersedes ?? []), existing.id],
sourceObservationIds: existing.sourceObservationIds,
isLatest: true,
forgetAfter: existing.forgetAfter,
...(existing.imageRef ? { imageRef: existing.imageRef } : {}),
...(existing.imageData ? { imageData: existing.imageData } : {}),
...(callAgentId ? { agentId: callAgentId } : {}),
...(existing.project !== undefined && { project: existing.project }),
};
if (data.ttlDays && typeof data.ttlDays === "number" && data.ttlDays > 0) {
updated.forgetAfter = new Date(
Date.now() + data.ttlDays * 86400000,
).toISOString();
}
// Mark the old version as no longer latest.
existing.isLatest = false;
existing.updatedAt = now;
await kv.set(KV.memories, existing.id, existing);
// Save the updated memory in-place (same ID, new version).
await kv.set(KV.memories, updated.id, updated);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/functions/remember.ts` around lines 171 - 257, `mem::update` is mixing
in-place overwrite with versioning, which breaks memory history and latest-state
integrity. Update the `sdk.registerFunction("mem::update")` flow to create a new
memory record with a fresh ID like `mem::remember` does, set `parentId` to the
current `existing.id`, and append the previous ID to `supersedes` without
self-referencing. Also add a guard so already superseded records (`isLatest:
false`) cannot be promoted back to latest, and preserve the old record instead
of overwriting it.


// Re-index: remove old content, add new.
try {
getSearchIndex().remove(existing.id);
getSearchIndex().add(memoryToObservation(updated));
} catch (err) {
logger.warn("Failed to re-index updated memory in BM25", {
memId: updated.id,
error: err instanceof Error ? err.message : String(err),
});
}
await vectorIndexRemove(updated.id);
await vectorIndexAddGuarded(
updated.id,
updated.sessionIds?.[0] ?? "memory",
updated.title + " " + updated.content,
{ kind: "memory", logId: updated.id },
);

await recordAudit(kv, "update", "mem::update", [updated.id], {
memoryId: updated.id,
oldVersion: existing.version ?? 1,
newVersion,
reason: "user-initiated update",
});

logger.info("Memory updated", {
memId: updated.id,
oldVersion: existing.version ?? 1,
newVersion,
});
return { success: true, memory: updated };
});
},
);

sdk.registerFunction("mem::forget",
async (data: {
sessionId?: string;
Expand Down
2 changes: 1 addition & 1 deletion src/functions/summarize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ export function registerSummarizeFunction(
return { success: false, error: "no_observations" };
}

if (provider.name === "noop") {
if (provider.name === "noop" || provider.name === "resilient(noop)") {
logger.info("Summarize skipped — no LLM provider configured", {
sessionId,
});
Expand Down
10 changes: 10 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,16 @@ async function main() {
bootLog(
`Git snapshots: ${snapshotConfig.dir} (every ${snapshotConfig.interval}s)`,
);
// #1006: actually schedule the periodic snapshot timer. Previously
// the interval was read and logged but never passed to setInterval,
// so snapshots only happened on manual trigger.
const snapshotIntervalMs = snapshotConfig.interval * 1000;
const snapshotTimer = setInterval(async () => {
try {
await sdk.trigger({ function_id: "mem::snapshot-create", payload: {} });
} catch {}
}, snapshotIntervalMs);
snapshotTimer.unref();
}

const bm25Index = getSearchIndex();
Expand Down
75 changes: 74 additions & 1 deletion src/mcp/standalone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {

const IMPLEMENTED_TOOLS = new Set([
"memory_save",
"memory_update",
"memory_recall",
"memory_smart_search",
"memory_sessions",
Expand Down Expand Up @@ -96,6 +97,7 @@ function textResponse(payload: unknown, pretty = false): {
interface Validated {
tool: string;
content?: string;
memoryId?: string;
type?: string;
concepts?: string[];
files?: string[];
Expand Down Expand Up @@ -124,6 +126,24 @@ function validate(toolName: string, args: Record<string, unknown>): Validated {
v.files = normalizeList(args["files"]);
return v;
}
case "memory_update": {
const memoryId = args["memoryId"];
if (typeof memoryId !== "string" || !memoryId.trim()) {
throw new Error("memoryId is required");
}
const content = args["content"];
if (typeof content !== "string" || !content.trim()) {
throw new Error("content is required");
}
v.memoryId = memoryId;
v.content = content;
if (typeof args["type"] === "string" && args["type"].trim()) {
v.type = args["type"] as string;
}
v.concepts = normalizeList(args["concepts"]);
v.files = normalizeList(args["files"]);
return v;
}
case "memory_recall":
case "memory_smart_search": {
const query = args["query"];
Expand Down Expand Up @@ -182,7 +202,21 @@ async function handleProxy(
files: v.files,
}),
});
return textResponse(result);
return textResponse(result, true);
}
case "memory_update": {
const body: Record<string, unknown> = {
memoryId: v.memoryId,
content: v.content,
};
if (v.type) body["type"] = v.type;
if (v.concepts) body["concepts"] = v.concepts;
if (v.files) body["files"] = v.files;
const result = await handle.call("/agentmemory/memories/update", {
method: "PUT",
body: JSON.stringify(body),
});
return textResponse(result, true);
}
case "memory_recall": {
const body: Record<string, unknown> = {
Expand Down Expand Up @@ -263,6 +297,45 @@ async function handleLocal(
return textResponse({ saved: id });
}

case "memory_update": {
const existing = await kvInstance.get<Record<string, unknown>>(
"mem:memories",
v.memoryId!,
);
if (!existing) {
return textResponse({ success: false, error: "memory not found" });
}
const oldVersion =
typeof existing["version"] === "number" ? existing["version"] : 1;
const newVersion = oldVersion + 1;
const isoNow = new Date().toISOString();
const updated = {
...existing,
type: v.type ?? existing["type"],
title: (v.content || "").slice(0, 80),
content: v.content,
concepts: v.concepts ?? existing["concepts"],
files: v.files ?? existing["files"],
updatedAt: isoNow,
version: newVersion,
isLatest: true,
supersedes: [
...(Array.isArray(existing["supersedes"])
? (existing["supersedes"] as string[])
: []),
v.memoryId!,
],
};
await kvInstance.set("mem:memories", v.memoryId!, updated);
kvInstance.persist();
return textResponse({
success: true,
memory: updated,
oldVersion,
newVersion,
});
}

case "memory_recall":
case "memory_smart_search": {
const query = (v.query || "").toLowerCase();
Expand Down
34 changes: 34 additions & 0 deletions src/mcp/tools-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,40 @@ export const CORE_TOOLS: McpToolDef[] = [
required: ["content"],
},
},
{
name: "memory_update",
description:
"Update an existing memory by ID with new content. Increments the version " +
"and supersedes the old content. Use this instead of calling memory_save twice " +
"when you want to revise a memory rather than create a duplicate.",
inputSchema: {
type: "object",
properties: {
memoryId: {
type: "string",
description: "The ID of the memory to update (e.g. mem_xxx)",
},
content: {
type: "string",
description: "The new content for the memory",
},
type: {
type: "string",
description:
"New memory type (optional, keeps existing if omitted): pattern, preference, architecture, bug, workflow, or fact",
},
concepts: {
type: "string",
description: "Comma-separated key concepts (optional, keeps existing if omitted)",
},
files: {
type: "string",
description: "Comma-separated relevant file paths (optional, keeps existing if omitted)",
},
},
required: ["memoryId", "content"],
},
},
{
name: "memory_file_history",
description: "Get past observations about specific files.",
Expand Down
32 changes: 32 additions & 0 deletions src/state/memory-dedup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* Negation guard for memory dedup.
*
* Prevents "do X" from superseding "do not X" (and vice versa) even when
* the two strings share enough tokens to exceed the Jaccard threshold.
*
* The guard is deliberately narrow and conservative:
* - Only triggers when one side has a negation marker and the other does not.
* - Supports English ("not", "never", "don't", "do not") and CJK
* ("不要", "别", "無", "なし", "않") markers.
* - If BOTH sides have negation markers, they are treated as compatible
* (both say "don't do X") and the guard does not fire.
*/

const EN_NEGATION = /\b(not|never|don't|do not|cannot|should not|must not|no)\b/i;
const CJK_NEGATION = /不要|别|勿|無|无|不|なし|않|안|못/i;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Bare in CJK_NEGATION causes false positives in Chinese text.

appears in many common non-negation compound words (不同 = "different", 不断 = "continuous", 不久 = "soon", 不过 = "but"). This will incorrectly trigger the negation guard, blocking legitimate superseding and creating duplicate memories. For example, "不断改进代码质量" vs "持续改进代码质量" (same meaning) would be prevented from superseding due to in "不断".

🔧 Proposed fix: remove bare `不`, keep specific negation markers
-const CJK_NEGATION = /不要|别|勿|無|无|不|なし|않|안|못/i;
+const CJK_NEGATION = /不要|别|勿|無|无|なし|않|안|못/i;

不要 (already in the regex) covers the explicit "do not" imperative. The bare is too broad for compound-word false positives.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const CJK_NEGATION = /|||||||||/i;
const CJK_NEGATION = /||||||||/i;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/state/memory-dedup.ts` at line 16, The negation pattern in CJK_NEGATION
is too broad because it matches bare “不”, which causes false positives in common
Chinese compound words and blocks valid superseding in memory deduplication.
Update the CJK_NEGATION regex in memory-dedup.ts to remove the standalone “不”
while keeping the explicit negation markers already listed, and ensure the
negation guard in the dedup logic continues to rely on the refined pattern.


function hasNegation(text: string): boolean {
return EN_NEGATION.test(text) || CJK_NEGATION.test(text);
}

/**
* Returns true if the two texts appear to be in a negation conflict
* (one says do X, the other says do NOT do X) and should NOT be superseded.
*/
export function isNegationConflict(a: string, b: string): boolean {
const aNeg = hasNegation(a);
const bNeg = hasNegation(b);
// If both have negation or neither does, no conflict.
if (aNeg === bNeg) return false;
return true;
}
Loading