-
Notifications
You must be signed in to change notification settings - Fork 2.1k
feat: add memory_update tool for memory versioning (#1018) #1038
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<Memory>(KV.memories, data.memoryId); | ||
| if (!existing) { | ||
| return { success: false, error: "memory not found" }; | ||
| } | ||
|
|
||
|
Comment on lines
+196
to
+201
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
rg -n "withKeyedLock" src --type=ts -C2Repository: rohitg00/agentmemory Length of output: 27870 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Inspect the memory function around remember/update and the keyed lock helper.
sed -n '1,260p' src/functions/remember.ts
printf '\n--- keyed mutex ---\n'
sed -n '1,220p' src/state/keyed-mutex.tsRepository: rohitg00/agentmemory Length of output: 10354 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Show the rest of the mem::update implementation and any related helpers in remember.ts.
sed -n '260,420p' src/functions/remember.tsRepository: rohitg00/agentmemory Length of output: 4365 Use a shared lock for memory writes 🤖 Prompt for AI Agents |
||
| 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(); | ||
|
Comment on lines
+216
to
+219
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
When 🛡️ Proposed fix const callAgentId =
typeof data.agentId === "string" && data.agentId.trim().length > 0
? data.agentId.trim().slice(0, 128)
- : getAgentId();
+ : existing.agentId;Also applies to: 240-240 🤖 Prompt for AI Agents |
||
|
|
||
| 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], | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
# Locate the relevant file and nearby usages of `supersedes`
git ls-files 'src/functions/remember.ts' 'src/**/*.ts' | sed -n '1,20p'
echo '--- remember.ts excerpt ---'
nl -ba src/functions/remember.ts | sed -n '180,280p'
echo '--- search supersedes usages ---'
rg -n "supersedes" src --glob '*.ts'Repository: rohitg00/agentmemory Length of output: 776 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo '--- remember.ts lines 210-270 ---'
sed -n '210,270p' src/functions/remember.ts
echo '--- all supersedes usages ---'
grep -RIn --include='*.ts' 'supersedes' src || trueRepository: rohitg00/agentmemory Length of output: 4942 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo '--- remember.ts lines 80-130 ---'
sed -n '80,130p' src/functions/remember.ts
echo '--- relations.ts lines 130-180 ---'
sed -n '130,180p' src/functions/relations.ts
echo '--- diagnostics.ts supersedes checks ---'
sed -n '320,340p' src/functions/diagnostics.tsRepository: rohitg00/agentmemory Length of output: 4670 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo '--- remember.ts update path around in-place save ---'
sed -n '220,255p' src/functions/remember.ts
echo '--- docs/comments mentioning same ID or supersedes semantics ---'
grep -RIn --include='*.ts' -E 'same ID|same id|supersedes.*self|lineage|version' src/functions src/mcp src/types.ts | sed -n '1,200p'Repository: rohitg00/agentmemory Length of output: 6279 Avoid appending the current ID to 🤖 Prompt for AI Agents |
||
| 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
+250
to
+257
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
# Inspect the target file around the referenced lines.
file='src/functions/remember.ts'
wc -l "$file"
sed -n '200,320p' "$file"
# Find the update function and any lock / search-list code that may observe intermediate state.
rg -n "mem:update|isLatest|updatedAt|kv\.set\(KV\.memories|KV\.memories|search|list" src/functions/remember.ts src -g '*.ts'Repository: rohitg00/agentmemory Length of output: 50376 Remove the redundant pre-write. 🤖 Prompt for AI Agents |
||
| // 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; | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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<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; | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
Comment on lines
+129
to
+146
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win
This defeats the field-preservation behavior that the SDK-layer tests ( 🛡️ Proposed fix 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"]);
+ if (args["concepts"] !== undefined) {
+ v.concepts = normalizeList(args["concepts"]);
+ }
+ if (args["files"] !== undefined) {
+ v.files = normalizeList(args["files"]);
+ }
return v;
}Also applies to: 207-220, 317-318 🤖 Prompt for AI Agents |
||||||||||||||||||||
| 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<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> = { | ||||||||||||||||||||
|
|
@@ -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!, | ||||||||||||||||||||
| ], | ||||||||||||||||||||
|
Comment on lines
+322
to
+327
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Self-referential The updated record is stored under the same key ( 🛡️ Proposed fix- supersedes: [
- ...(Array.isArray(existing["supersedes"])
- ? (existing["supersedes"] as string[])
- : []),
- v.memoryId!,
- ],
+ supersedes: Array.isArray(existing["supersedes"])
+ ? (existing["supersedes"] as string[])
+ : [],📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||
| }; | ||||||||||||||||||||
| 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(); | ||||||||||||||||||||
|
|
||||||||||||||||||||
There was a problem hiding this comment.
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
🧩 Analysis chain
🏁 Script executed:
Repository: rohitg00/agentmemory
Length of output: 27900
🏁 Script executed:
Repository: rohitg00/agentmemory
Length of output: 23234
🏁 Script executed:
Repository: rohitg00/agentmemory
Length of output: 6312
🏁 Script executed:
Repository: rohitg00/agentmemory
Length of output: 9364
🏁 Script executed:
Repository: rohitg00/agentmemory
Length of output: 4441
Validate
conceptsandfilesinmem::updatesrc/functions/remember.ts:176-194Mirror the
mem::rememberarray checks here.api::memory-updateforwardsconcepts/filesdirectly, so a non-array payload can still be persisted and later break search/index behavior.🤖 Prompt for AI Agents
Source: Path instructions