diff --git a/package.json b/package.json index 4e2bc8c4c..1cd2ae9d7 100644 --- a/package.json +++ b/package.json @@ -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.1.0", + "description": "Persistent memory for AI coding agents — CJK-aware dedup, memory_update versioning, standalone MCP server", "type": "module", "main": "dist/index.mjs", "types": "dist/index.d.mts", @@ -14,6 +14,7 @@ "./package.json": "./package.json" }, "bin": { + "ziiagentmemory": "dist/cli.mjs", "agentmemory": "dist/cli.mjs" }, "scripts": { @@ -53,11 +54,16 @@ "README.md", "AGENTS.md" ], - "author": "Rohit Ghumare ", + "author": "Zeeshan Ahmad ", "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", diff --git a/src/functions/remember.ts b/src/functions/remember.ts index 5735b4f23..b69e6187b 100644 --- a/src/functions/remember.ts +++ b/src/functions/remember.ts @@ -167,6 +167,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" }; + } + + return withKeyedLock("mem:update", async () => { + const existing = await kv.get(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); + + // 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; diff --git a/src/mcp/standalone.ts b/src/mcp/standalone.ts index dd66ecb1d..81a63650b 100644 --- a/src/mcp/standalone.ts +++ b/src/mcp/standalone.ts @@ -15,6 +15,7 @@ import { const IMPLEMENTED_TOOLS = new Set([ "memory_save", + "memory_update", "memory_recall", "memory_smart_search", "memory_sessions", @@ -96,6 +97,7 @@ function textResponse(payload: unknown, pretty = false): { interface Validated { tool: string; content?: string; + memoryId?: string; type?: string; concepts?: string[]; files?: string[]; @@ -124,6 +126,24 @@ function validate(toolName: string, args: Record): 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"]; @@ -182,7 +202,21 @@ async function handleProxy( files: v.files, }), }); - return textResponse(result); + return textResponse(result, true); + } + case "memory_update": { + const body: Record = { + 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 = { @@ -263,6 +297,45 @@ async function handleLocal( return textResponse({ saved: id }); } + case "memory_update": { + const existing = await kvInstance.get>( + "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(); diff --git a/src/mcp/tools-registry.ts b/src/mcp/tools-registry.ts index c4df3499c..9ffa35fb4 100644 --- a/src/mcp/tools-registry.ts +++ b/src/mcp/tools-registry.ts @@ -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.", diff --git a/src/triggers/api.ts b/src/triggers/api.ts index 7b6c2bf2b..51fad2c61 100644 --- a/src/triggers/api.ts +++ b/src/triggers/api.ts @@ -1001,7 +1001,56 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/remember", http_method: "POST" }, }); - sdk.registerFunction("api::forget", + sdk.registerFunction("api::memory-update", + async ( + req: ApiRequest<{ + memoryId: string; + content: string; + type?: string; + concepts?: string[]; + files?: string[]; + ttlDays?: number; + agentId?: string; + }>, + ): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + if (!req.body?.memoryId) { + return { status_code: 400, body: { error: "memoryId is required" } }; + } + if ( + !req.body?.content || + typeof req.body.content !== "string" || + !req.body.content.trim() + ) { + return { status_code: 400, body: { error: "content is required" } }; + } + const result = await sdk.trigger({ + function_id: "mem::update", + payload: { + memoryId: req.body.memoryId, + content: req.body.content, + ...(req.body.type !== undefined && { type: req.body.type }), + ...(req.body.concepts !== undefined && { concepts: req.body.concepts }), + ...(req.body.files !== undefined && { files: req.body.files }), + ...(req.body.ttlDays !== undefined && { ttlDays: req.body.ttlDays }), + ...(req.body.agentId !== undefined && { agentId: req.body.agentId }), + }, + }); + const r = result as { success: boolean; error?: string }; + if (!r.success && r.error === "memory not found") { + return { status_code: 404, body: result }; + } + return { status_code: 200, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::memory-update", + config: { api_path: "/agentmemory/memories/update", http_method: "PUT" }, + }); + + sdk.registerFunction("api::forget", async ( req: ApiRequest<{ sessionId?: string; diff --git a/test/mcp-standalone-proxy.test.ts b/test/mcp-standalone-proxy.test.ts index dc08a024e..d08c079d1 100644 --- a/test/mcp-standalone-proxy.test.ts +++ b/test/mcp-standalone-proxy.test.ts @@ -339,7 +339,7 @@ describe("@agentmemory/mcp standalone — server proxy (issue #159)", () => { expect(joined).toMatch(/AGENTMEMORY_FORCE_PROXY/); }); - it("local fallback tools/list returns all 7 IMPLEMENTED_TOOLS regardless of AGENTMEMORY_TOOLS env (#234)", async () => { + it("local fallback tools/list returns all 8 IMPLEMENTED_TOOLS regardless of AGENTMEMORY_TOOLS env (#234)", async () => { const { handleToolsList } = await import("../src/mcp/standalone.js"); installFetch(() => { throw new Error("ECONNREFUSED"); @@ -355,13 +355,14 @@ describe("@agentmemory/mcp standalone — server proxy (issue #159)", () => { "memory_save", "memory_sessions", "memory_smart_search", + "memory_update", ]); - expect(beforeTools).toHaveLength(7); + expect(beforeTools).toHaveLength(8); resetHandleForTests(); process.env["AGENTMEMORY_TOOLS"] = "core"; const core = await handleToolsList(); - expect((core.tools as unknown[]).length).toBe(7); + expect((core.tools as unknown[]).length).toBe(8); delete process.env["AGENTMEMORY_TOOLS"]; }); diff --git a/test/mcp-standalone.test.ts b/test/mcp-standalone.test.ts index b48eade96..802621885 100644 --- a/test/mcp-standalone.test.ts +++ b/test/mcp-standalone.test.ts @@ -68,8 +68,8 @@ describe("Tools Registry", () => { } }); - it("CORE_TOOLS has 14 items", () => { - expect(CORE_TOOLS.length).toBe(14); + it("CORE_TOOLS has 15 items", () => { + expect(CORE_TOOLS.length).toBe(15); }); it("V040_TOOLS has 8 items", () => { diff --git a/test/memory-update.test.ts b/test/memory-update.test.ts new file mode 100644 index 000000000..2e1044c21 --- /dev/null +++ b/test/memory-update.test.ts @@ -0,0 +1,247 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +vi.mock("../src/state/keyed-mutex.js", () => ({ + withKeyedLock: (_key: string, fn: () => Promise) => fn(), +})); + +vi.mock("../src/functions/audit.js", () => ({ + recordAudit: vi.fn(), +})); + +vi.mock("../src/functions/access-tracker.js", () => ({ + recordAccessBatch: vi.fn(), + deleteAccessLog: vi.fn(), +})); + +vi.mock("../src/config.js", () => ({ + getAgentId: () => undefined, + isAgentScopeIsolated: () => false, +})); + +import { registerRememberFunction } from "../src/functions/remember.js"; +import { getSearchIndex, setIndexPersistence } from "../src/functions/search.js"; +import { KV } from "../src/state/schema.js"; + +function makeMockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => + (store.get(scope)?.get(key) as T) ?? null, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function makeMockSdk() { + const functions = new Map(); + return { + registerFunction: (id: string, handler: Function) => { + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async (input: { function_id: string; payload: unknown }) => { + const fn = functions.get(input.function_id); + if (!fn) return {}; + return fn(input.payload); + }, + }; +} + +describe("mem::update — memory versioning (#1018)", () => { + let sdk: ReturnType; + let kv: ReturnType; + + beforeEach(() => { + sdk = makeMockSdk(); + kv = makeMockKV(); + getSearchIndex().clear(); + setIndexPersistence(null); + registerRememberFunction(sdk as never, kv as never); + }); + + it("updates an existing memory and increments version", async () => { + const saved = await sdk.trigger({ + function_id: "mem::remember", + payload: { + content: "Pipeline: 3 stages", + type: "architecture", + concepts: ["pipeline"], + }, + }) as { memory: { id: string; version: number; content: string } }; + + expect(saved.memory.version).toBe(1); + + const updated = await sdk.trigger({ + function_id: "mem::update", + payload: { + memoryId: saved.memory.id, + content: "Pipeline: 5 stages", + }, + }) as { + success: boolean; + memory: { id: string; version: number; content: string; isLatest: boolean }; + }; + + expect(updated.success).toBe(true); + expect(updated.memory.id).toBe(saved.memory.id); + expect(updated.memory.version).toBe(2); + expect(updated.memory.content).toBe("Pipeline: 5 stages"); + expect(updated.memory.isLatest).toBe(true); + }); + + it("preserves the original memory ID (in-place update)", async () => { + const saved = await sdk.trigger({ + function_id: "mem::remember", + payload: { content: "original content", type: "fact" }, + }) as { memory: { id: string } }; + + const updated = await sdk.trigger({ + function_id: "mem::update", + payload: { memoryId: saved.memory.id, content: "updated content" }, + }) as { memory: { id: string } }; + + expect(updated.memory.id).toBe(saved.memory.id); + }); + + it("preserves fields not provided in the update", async () => { + const saved = await sdk.trigger({ + function_id: "mem::remember", + payload: { + content: "use eslint for linting", + type: "preference", + concepts: ["eslint", "linting"], + files: ["package.json"], + project: "frontend", + }, + }) as { memory: { id: string } }; + + const updated = await sdk.trigger({ + function_id: "mem::update", + payload: { + memoryId: saved.memory.id, + content: "use eslint and prettier for linting", + }, + }) as { + memory: { + type: string; + concepts: string[]; + files: string[]; + project: string; + }; + }; + + // type, concepts, files, project should be preserved + expect(updated.memory.type).toBe("preference"); + expect(updated.memory.concepts).toEqual(["eslint", "linting"]); + expect(updated.memory.files).toEqual(["package.json"]); + expect(updated.memory.project).toBe("frontend"); + }); + + it("overwrites type and concepts when provided", async () => { + const saved = await sdk.trigger({ + function_id: "mem::remember", + payload: { + content: "deploy on merge to main", + type: "workflow", + concepts: ["deploy"], + }, + }) as { memory: { id: string } }; + + const updated = await sdk.trigger({ + function_id: "mem::update", + payload: { + memoryId: saved.memory.id, + content: "deploy on merge to main with approval", + type: "pattern", + concepts: ["deploy", "approval", "ci-cd"], + }, + }) as { memory: { type: string; concepts: string[] } }; + + expect(updated.memory.type).toBe("pattern"); + expect(updated.memory.concepts).toEqual(["deploy", "approval", "ci-cd"]); + }); + + it("returns error for non-existent memory ID", async () => { + const result = await sdk.trigger({ + function_id: "mem::update", + payload: { memoryId: "mem_nonexistent", content: "new content" }, + }) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toBe("memory not found"); + }); + + it("returns error when memoryId is missing", async () => { + const result = await sdk.trigger({ + function_id: "mem::update", + payload: { content: "new content" }, + }) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toBe("memoryId is required"); + }); + + it("returns error when content is missing", async () => { + const saved = await sdk.trigger({ + function_id: "mem::remember", + payload: { content: "original", type: "fact" }, + }) as { memory: { id: string } }; + + const result = await sdk.trigger({ + function_id: "mem::update", + payload: { memoryId: saved.memory.id }, + }) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toBe("content is required"); + }); + + it("can be called multiple times, incrementing version each time", async () => { + const saved = await sdk.trigger({ + function_id: "mem::remember", + payload: { content: "v1 content", type: "fact" }, + }) as { memory: { id: string } }; + + const u1 = await sdk.trigger({ + function_id: "mem::update", + payload: { memoryId: saved.memory.id, content: "v2 content" }, + }) as { memory: { version: number } }; + expect(u1.memory.version).toBe(2); + + const u2 = await sdk.trigger({ + function_id: "mem::update", + payload: { memoryId: saved.memory.id, content: "v3 content" }, + }) as { memory: { version: number } }; + expect(u2.memory.version).toBe(3); + }); + + it("does not create a duplicate memory (same ID, not a new one)", async () => { + const saved = await sdk.trigger({ + function_id: "mem::remember", + payload: { content: "original", type: "fact" }, + }) as { memory: { id: string } }; + + await sdk.trigger({ + function_id: "mem::update", + payload: { memoryId: saved.memory.id, content: "updated" }, + }); + + const allMemories = await kv.list<{ id: string }>(KV.memories); + // Should still be exactly 1 memory — no duplicate + expect(allMemories).toHaveLength(1); + }); +}); \ No newline at end of file