From 57162dea39a0cc6adc523c62183e0b62ff07e2f4 Mon Sep 17 00:00:00 2001 From: Gorkem Date: Sat, 18 Jul 2026 00:26:25 +0300 Subject: [PATCH 1/2] fix(tools): resolve 8+ char id prefixes in memory_forget and memory_update memory_update's contract has always said 'full UUID or 8+ char prefix', and injected context shows agents truncated row ids, but the uuid detection regex accepted a bare 8-char prefix as a complete id, so prefix-addressed forget/update went to the store as an exact-id lookup and always failed 'not found or access denied' (live-caught: an agent honestly reported its own failed deletions). The shared resolver now strips the trailing ellipsis agents copy from context, passes full UUIDs through unchanged, resolves hex-shaped 8-35 char refs via the new scope-filtered store.findByIdPrefix (unique match wins, multiple matches list candidates, zero is an honest not-found), and falls back to semantic search for everything else. memory_forget's direct-ID branch and memory_update's inline copy both route through it. Co-Authored-By: Claude Fable 5 (cherry picked from commit 91b75f536830a1c2aabb1c8fbb8e284550b9a087) --- package.json | 5 +- scripts/ci-test-manifest.mjs | 3 +- src/store.ts | 45 ++++++ src/tools.ts | 111 +++++++------ test/memory-id-prefix-resolution.test.mjs | 189 ++++++++++++++++++++++ 5 files changed, 298 insertions(+), 55 deletions(-) create mode 100644 test/memory-id-prefix-resolution.test.mjs diff --git a/package.json b/package.json index e07e666dd..5f6deaa7c 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,10 @@ "skills/**/*.md" ], "scripts": { - "test": "node test/embedder-error-hints.test.mjs && node --test test/embedder-max-input-chars.test.mjs && node test/cjk-recursion-regression.test.mjs && node test/extraction-prompt-structural-noise.test.mjs && node test/i18n-memory-triggers.test.mjs && node test/migrate-legacy-schema.test.mjs && node --test test/config-session-strategy-migration.test.mjs && node --test test/scope-access-undefined.test.mjs && node --test test/reflection-bypass-hook.test.mjs && node --test test/reflection-unattributed-session-read.test.mjs && node --test test/smart-extractor-scope-filter.test.mjs && node --test test/store-empty-scope-filter.test.mjs && node --test test/recall-text-cleanup.test.mjs && node test/update-consistency-lancedb.test.mjs && node --test test/strip-envelope-metadata.test.mjs && node test/cli-smoke.mjs && node test/functional-e2e.mjs && node --test test/per-agent-auto-recall.test.mjs && node test/retriever-rerank-regression.mjs && node test/smart-memory-lifecycle.mjs && node test/smart-extractor-branches.mjs && node --test test/smart-extractor-noise-gating.test.mjs && node test/memory-capability-runtime.test.mjs && node --test test/startup-health-diagnostics.test.mjs && node test/corpus-indexer.test.mjs && node --test test/regex-fallback-bulk-store.test.mjs && node test/plugin-manifest-regression.mjs && node --test test/dreaming-engine.test.mjs && node --test test/session-summary-before-reset.test.mjs && node --test test/sync-plugin-version.test.mjs && node test/smart-metadata-v2.mjs && node test/vector-search-cosine.test.mjs && node test/context-support-e2e.mjs && node test/temporal-facts.test.mjs && node test/memory-update-supersede.test.mjs && node test/memory-update-metadata-refresh.test.mjs && node test/memory-upgrader-diagnostics.test.mjs && node --test test/llm-api-key-client.test.mjs && node --test test/llm-oauth-client.test.mjs && node --test test/cli-oauth-login.test.mjs && node --test test/workflow-fork-guards.test.mjs && node --test test/clawteam-scope.test.mjs && node --test test/cross-process-lock.test.mjs && node --test test/preference-slots.test.mjs && node test/is-latest-auto-supersede.test.mjs && node --test test/temporal-awareness.test.mjs && node --test test/command-reflection-guard.test.mjs && node --test test/tier1-counters.test.mjs && node --test test/startup-check-timeout.test.mjs && node --test test/memory-subsession-prompt-hooks.test.mjs && node --test test/read-consistency-interval.test.mjs && node --test test/reflection-distiller-hook-skip.test.mjs && node --test test/register-scope-dedup.test.mjs && node --test test/raw-run-distiller-hooks.test.mjs && node --test test/autocapture-watermark-reset.test.mjs && node --test test/autocapture-internal-session-guard.test.mjs && node --test test/memory-categories-storage-map.test.mjs && node --test test/delete-invalidate-reflection-caches.test.mjs && node --test test/reflection-mapped-rows-admission.test.mjs && node --test test/smart-metadata-source-classification.test.mjs && node --test test/reflection-embed-transient-retry.test.mjs && node --test test/scope-owner-leak-hardening.test.mjs && node --test test/isOwnedByAgent.test.mjs && node --test test/typed-array-vector-fetch.test.mjs", "test:cli-smoke": "node scripts/run-ci-tests.mjs --group cli-smoke", "test:core-regression": "node scripts/run-ci-tests.mjs --group core-regression", "test:storage-and-schema": "node scripts/run-ci-tests.mjs --group storage-and-schema", + "test": "node test/embedder-error-hints.test.mjs && node --test test/embedder-max-input-chars.test.mjs && node test/cjk-recursion-regression.test.mjs && node test/extraction-prompt-structural-noise.test.mjs && node test/i18n-memory-triggers.test.mjs && node test/migrate-legacy-schema.test.mjs && node --test test/config-session-strategy-migration.test.mjs && node --test test/scope-access-undefined.test.mjs && node --test test/reflection-bypass-hook.test.mjs && node --test test/reflection-unattributed-session-read.test.mjs && node --test test/smart-extractor-scope-filter.test.mjs && node --test test/store-empty-scope-filter.test.mjs && node --test test/recall-text-cleanup.test.mjs && node test/update-consistency-lancedb.test.mjs && node --test test/strip-envelope-metadata.test.mjs && node test/cli-smoke.mjs && node test/functional-e2e.mjs && node --test test/per-agent-auto-recall.test.mjs && node test/retriever-rerank-regression.mjs && node test/smart-memory-lifecycle.mjs && node test/smart-extractor-branches.mjs && node --test test/smart-extractor-noise-gating.test.mjs && node test/memory-capability-runtime.test.mjs && node --test test/startup-health-diagnostics.test.mjs && node test/corpus-indexer.test.mjs && node --test test/regex-fallback-bulk-store.test.mjs && node test/plugin-manifest-regression.mjs && node --test test/dreaming-engine.test.mjs && node --test test/session-summary-before-reset.test.mjs && node --test test/sync-plugin-version.test.mjs && node test/smart-metadata-v2.mjs && node test/vector-search-cosine.test.mjs && node test/context-support-e2e.mjs && node test/temporal-facts.test.mjs && node test/memory-update-supersede.test.mjs && node test/memory-update-metadata-refresh.test.mjs && node test/memory-upgrader-diagnostics.test.mjs && node --test test/llm-api-key-client.test.mjs && node --test test/llm-oauth-client.test.mjs && node --test test/cli-oauth-login.test.mjs && node --test test/workflow-fork-guards.test.mjs && node --test test/clawteam-scope.test.mjs && node --test test/cross-process-lock.test.mjs && node --test test/preference-slots.test.mjs && node test/is-latest-auto-supersede.test.mjs && node --test test/temporal-awareness.test.mjs && node --test test/command-reflection-guard.test.mjs && node --test test/tier1-counters.test.mjs && node --test test/startup-check-timeout.test.mjs && node --test test/memory-subsession-prompt-hooks.test.mjs && node --test test/read-consistency-interval.test.mjs && node --test test/reflection-distiller-hook-skip.test.mjs && node --test test/register-scope-dedup.test.mjs && node --test test/raw-run-distiller-hooks.test.mjs && node --test test/autocapture-watermark-reset.test.mjs && node --test test/autocapture-internal-session-guard.test.mjs && node --test test/memory-categories-storage-map.test.mjs && node --test test/delete-invalidate-reflection-caches.test.mjs && node --test test/reflection-mapped-rows-admission.test.mjs && node --test test/smart-metadata-source-classification.test.mjs && node --test test/reflection-embed-transient-retry.test.mjs && node --test test/scope-owner-leak-hardening.test.mjs && node --test test/isOwnedByAgent.test.mjs && node --test test/typed-array-vector-fetch.test.mjs && node --test test/memory-id-prefix-resolution.test.mjs", + "test:cli-smoke": "node scripts/run-ci-tests.mjs --group cli-smoke", + "test:core-regression": "node scripts/run-ci-tests.mjs --group core-regression", + "test:storage-and-schema": "node scripts/run-ci-tests.mjs --group storage-and-schema", "test:llm-clients-and-auth": "node scripts/run-ci-tests.mjs --group llm-clients-and-auth", "test:packaging-and-workflow": "node scripts/verify-ci-test-manifest.mjs && node scripts/run-ci-tests.mjs --group packaging-and-workflow", "build": "tsc -p tsconfig.json", diff --git a/scripts/ci-test-manifest.mjs b/scripts/ci-test-manifest.mjs index a979f599a..8be0f7496 100644 --- a/scripts/ci-test-manifest.mjs +++ b/scripts/ci-test-manifest.mjs @@ -118,12 +118,11 @@ export const CI_TEST_MANIFEST = [ { group: "core-regression", runner: "node", file: "test/scope-owner-leak-hardening.test.mjs", args: ["--test"] }, { group: "core-regression", runner: "node", file: "test/isOwnedByAgent.test.mjs", args: ["--test"] }, { group: "storage-and-schema", runner: "node", file: "test/typed-array-vector-fetch.test.mjs", args: ["--test"] }, + { group: "core-regression", runner: "node", file: "test/memory-id-prefix-resolution.test.mjs", args: ["--test"] }, ]; - export function getEntriesForGroup(group) { if (!CI_TEST_GROUPS.includes(group)) { throw new Error(`Unknown CI test group: ${group}`); } - return CI_TEST_MANIFEST.filter((entry) => entry.group === group); } diff --git a/src/store.ts b/src/store.ts index d095f100e..c56e7d5be 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1754,6 +1754,51 @@ export class MemoryStore { return await this.table!.countRows(); } + /** + * Finds rows whose id starts with `prefix`, restricted to accessible + * scopes. Backs the documented "full UUID or 8+ char prefix" contract on + * memory_forget/memory_update: injected context shows agents truncated ids, + * so a unique-prefix lookup is the only way those handles can ever resolve. + * The prefix must be hex/dash shaped (validated here, defense in depth on + * top of the tool-layer classification) and at least 8 chars, so a short + * or malformed ref can never scan-match. Capped at `limit` matches: the + * caller only distinguishes zero / one / many. + */ + async findByIdPrefix( + prefix: string, + scopeFilter?: string[], + limit = 5, + ): Promise { + await this.ensureInitialized(); + + if (isExplicitDenyAllScopeFilter(scopeFilter)) return []; + const normalized = prefix.trim().toLowerCase(); + if (!/^[0-9a-f][0-9a-f-]{7,35}$/.test(normalized)) return []; + + const safePrefix = escapeSqlLiteral(normalized); + const rows = await this.table! + .query() + .where(`id LIKE '${safePrefix}%'`) + .limit(Math.max(1, limit)) + .toArray(); + + return rows + .filter((row) => { + const rowScope = (row.scope as string | undefined) ?? "global"; + return !scopeFilter || scopeFilter.length === 0 || scopeFilter.includes(rowScope); + }) + .map((row) => ({ + id: row.id as string, + text: row.text as string, + vector: Array.from(row.vector as Iterable), + category: row.category as MemoryEntry["category"], + scope: (row.scope as string | null | undefined) ?? "global", + importance: clampImportance(Number(row.importance)), + timestamp: normalizeMemoryTimestamp(row.timestamp, 0), + metadata: (row.metadata as string) || "{}", + })); + } + async getById(id: string, scopeFilter?: string[]): Promise { await this.ensureInitialized(); diff --git a/src/tools.ts b/src/tools.ts index 5eba48555..de6c19e8a 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -457,7 +457,7 @@ function formatIgnoredScopeNotice(resolvedScopes: { return `Ignored inaccessible scope "${resolvedScopes.ignoredScope}" and searched accessible scopes instead: ${scopes}.`; } -async function resolveMemoryId( +export async function resolveMemoryId( context: ToolContext, memoryRef: string, scopeFilter: string[], @@ -465,7 +465,9 @@ async function resolveMemoryId( | { ok: true; id: string } | { ok: false; message: string; details?: Record } > { - const trimmed = memoryRef.trim(); + // Agents copy ids out of injected context, which truncates them and often + // appends an ellipsis ("407dec9c..."); strip that before classifying. + const trimmed = memoryRef.trim().replace(/[.…]+$/u, ""); if (!trimmed) { return { ok: false, @@ -474,11 +476,41 @@ async function resolveMemoryId( }; } - const uuidLike = /^[0-9a-f]{8}(-[0-9a-f]{4}){0,4}/i.test(trimmed); - if (uuidLike) { + const isFullUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(trimmed); + if (isFullUuid) { return { ok: true, id: trimmed }; } + // Documented contract: "full UUID or 8+ char prefix". A hex-shaped ref + // that is not a complete UUID resolves as an id prefix within accessible + // scopes — unique match wins, multiple matches list candidates, and zero + // matches is an honest not-found (never a scan-match or a semantic guess). + const isIdPrefix = /^[0-9a-f][0-9a-f-]{7,35}$/i.test(trimmed); + if (isIdPrefix) { + const matches = await context.store.findByIdPrefix(trimmed, scopeFilter); + if (matches.length === 1) { + return { ok: true, id: matches[0].id }; + } + if (matches.length > 1) { + const list = matches + .map( + (entry) => + `- [${entry.id.slice(0, 8)}] ${entry.text.slice(0, 60)}${entry.text.length > 60 ? "..." : ""}`, + ) + .join("\n"); + return { + ok: false, + message: `Id prefix "${trimmed}" matches multiple memories. Use a longer prefix or the full id:\n${list}`, + details: { error: "ambiguous_id_prefix", prefix: trimmed }, + }; + } + return { + ok: false, + message: `Memory ${trimmed} not found or access denied.`, + details: { error: "not_found", id: trimmed }, + }; + } + const results = await retrieveWithRetry(context.retriever, { query: trimmed, limit: 5, @@ -1555,24 +1587,31 @@ export function registerMemoryForgetTool( } if (memoryId) { - const deleted = await context.store.delete(memoryId, scopeFilter); + const resolved = await resolveMemoryId(context, memoryId, scopeFilter); + if (resolved.ok === false) { + return { + content: [{ type: "text", text: resolved.message }], + details: resolved.details ?? { error: "not_found", id: memoryId }, + }; + } + const deleted = await context.store.delete(resolved.id, scopeFilter); if (deleted) { context.onMemoriesDeleted?.({ scopeFilter }); return { content: [ - { type: "text", text: `Memory ${memoryId} forgotten.` }, + { type: "text", text: `Memory ${resolved.id} forgotten.` }, ], - details: { action: "deleted", id: memoryId }, + details: { action: "deleted", id: resolved.id }, }; } else { return { content: [ { type: "text", - text: `Memory ${memoryId} not found or access denied.`, + text: `Memory ${resolved.id} not found or access denied.`, }, ], - details: { error: "not_found", id: memoryId }, + details: { error: "not_found", id: resolved.id }, }; } } @@ -1719,50 +1758,18 @@ export function registerMemoryUpdateTool( const agentId = resolveRuntimeAgentId(runtimeContext.agentId, runtimeCtx); const scopeFilter = resolveScopeFilter(runtimeContext.scopeManager, agentId); - // Resolve memoryId: if it doesn't look like a UUID, try search - let resolvedId = memoryId; - const uuidLike = /^[0-9a-f]{8}(-[0-9a-f]{4}){0,4}/i.test(memoryId); - if (!uuidLike) { - // Treat as search query - const results = await retrieveWithRetry(context.retriever, { - query: memoryId, - limit: 3, - scopeFilter, - }, () => context.store.count()); - if (results.length === 0) { - return { - content: [ - { - type: "text", - text: `No memory found matching "${memoryId}".`, - }, - ], - details: { error: "not_found", query: memoryId }, - }; - } - if (results.length === 1 || results[0].score > 0.85) { - resolvedId = results[0].entry.id; - } else { - const list = results - .map( - (r) => - `- [${r.entry.id.slice(0, 8)}] ${r.entry.text.slice(0, 60)}${r.entry.text.length > 60 ? "..." : ""}`, - ) - .join("\n"); - return { - content: [ - { - type: "text", - text: `Multiple matches. Specify memoryId:\n${list}`, - }, - ], - details: { - action: "candidates", - candidates: sanitizeMemoryForSerialization(results), - }, - }; - } + // Resolve memoryId through the shared resolver: full UUID passes + // through, an 8+ char id prefix resolves against accessible rows + // (the tool's documented contract), anything else falls back to + // semantic search. + const resolution = await resolveMemoryId(context, memoryId, scopeFilter); + if (resolution.ok === false) { + return { + content: [{ type: "text", text: resolution.message }], + details: resolution.details ?? { error: "not_found", id: memoryId }, + }; } + const resolvedId = resolution.id; // If text changed, re-embed; reject noise let newVector: number[] | undefined; diff --git a/test/memory-id-prefix-resolution.test.mjs b/test/memory-id-prefix-resolution.test.mjs new file mode 100644 index 000000000..399004bfa --- /dev/null +++ b/test/memory-id-prefix-resolution.test.mjs @@ -0,0 +1,189 @@ +// Live-caught H1 bug (2026-07-18 night shift): memory_update's contract says +// "full UUID or 8+ char prefix", and injected context shows agents truncated +// row ids — but the uuid-detection regex treated an 8-char prefix as a FULL +// id, so prefix-based forget/update always failed "not found or access +// denied". These tests drive the real registered tools against a real temp +// LanceDB store and pin the prefix contract end to end. +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import jitiFactory from "jiti"; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const pluginSdkStubPath = path.resolve(testDir, "helpers", "openclaw-plugin-sdk-stub.mjs"); +const jiti = jitiFactory(import.meta.url, { + interopDefault: true, + alias: { + "openclaw/plugin-sdk": pluginSdkStubPath, + }, +}); + +const embedderModuleForMock = jiti("../src/embedder.js"); +embedderModuleForMock.createEmbedder = () => ({ + async embedPassage() { return [0.5, 0.5, 0.5, 0.5]; }, + async embedQuery() { return [0.5, 0.5, 0.5, 0.5]; }, +}); + +const pluginModule = jiti("../index.ts"); +const memoryLanceDBProPlugin = pluginModule.default || pluginModule; +const resetRegistration = pluginModule.resetRegistration ?? (() => {}); +const { MemoryStore } = jiti("../src/store.ts"); +const { resolveMemoryId } = jiti("../src/tools.ts"); + +const EMBEDDING_DIMENSIONS = 4; +const FIXED_VECTOR = [0.5, 0.5, 0.5, 0.5]; + +function createPluginApiHarness({ pluginConfig, resolveRoot }) { + const toolFactories = []; + const api = { + pluginConfig, + resolvePath(target) { + if (typeof target !== "string") return target; + if (path.isAbsolute(target)) return target; + return path.join(resolveRoot, target); + }, + logger: { + info() {}, + warn() {}, + error() {}, + debug() {}, + }, + on() {}, + registerCli() {}, + registerService() {}, + registerCommand() {}, + registerMemoryCapability() {}, + registerTool(toolFactory, meta) { + toolFactories.push({ toolFactory, meta }); + }, + }; + return { api, toolFactories }; +} + +function makePluginConfig(workDir) { + return { + dbPath: path.join(workDir, "db"), + embedding: { + apiKey: "test-api-key", + dimensions: EMBEDDING_DIMENSIONS, + }, + smartExtraction: false, + autoCapture: false, + autoRecall: false, + selfImprovement: { enabled: false, beforeResetNote: false, ensureLearningFiles: false }, + }; +} + +async function callTool(toolFactories, name, params) { + const entry = toolFactories.find(({ meta }) => meta?.name === name); + assert.ok(entry, `expected a registered ${name} tool`); + const tool = entry.toolFactory({}); + return tool.execute("test-call-id", params, undefined, undefined, { agentId: "agent-one" }); +} + +describe("memory id-prefix resolution (forget/update contract)", () => { + let workDir; + let harness; + let store; + let seeded; + + beforeEach(async () => { + workDir = mkdtempSync(path.join(tmpdir(), "lancedb-prefix-test-")); + resetRegistration(); + harness = createPluginApiHarness({ + pluginConfig: makePluginConfig(workDir), + resolveRoot: workDir, + }); + await memoryLanceDBProPlugin.register(harness.api); + + store = new MemoryStore({ dbPath: path.join(workDir, "db"), vectorDim: EMBEDDING_DIMENSIONS }); + seeded = await store.store({ + text: "Spice jars are labeled in the kitchen drawer", + vector: FIXED_VECTOR, + category: "entity", + scope: "agent:agent-one", + importance: 0.8, + metadata: JSON.stringify({ memory_category: "entities", l0_abstract: "Spice jars labeled" }), + }); + assert.ok(seeded?.id, "seed row must store"); + }); + + afterEach(() => { + rmSync(workDir, { recursive: true, force: true }); + }); + + it("memory_forget deletes a row addressed by an 8-char id prefix", async () => { + const result = await callTool(harness.toolFactories, "memory_forget", { + memoryId: seeded.id.slice(0, 8), + }); + assert.equal(result?.details?.action, "deleted", JSON.stringify(result?.content)); + // Fresh store instance: the plugin deleted through its own handle, and a + // second handle's read view can lag; a new instance sees current state. + const reader = new MemoryStore({ dbPath: path.join(workDir, "db"), vectorDim: EMBEDDING_DIMENSIONS }); + assert.equal(await reader.getById(seeded.id, ["agent:agent-one"]), null); + }); + + it("memory_forget tolerates the trailing ellipsis agents copy from injected context", async () => { + const result = await callTool(harness.toolFactories, "memory_forget", { + memoryId: `${seeded.id.slice(0, 8)}...`, + }); + assert.equal(result?.details?.action, "deleted", JSON.stringify(result?.content)); + }); + + it("memory_forget still deletes by full UUID exactly as before", async () => { + const result = await callTool(harness.toolFactories, "memory_forget", { + memoryId: seeded.id, + }); + assert.equal(result?.details?.action, "deleted"); + }); + + it("resolveMemoryId reports ambiguity when a prefix matches multiple rows, resolving nothing", async () => { + const rows = [ + { id: "aabbccdd-1111-4111-8111-111111111111", text: "row one", vector: [], category: "entity", scope: "agent:agent-one", importance: 0.5, timestamp: 1, metadata: "{}" }, + { id: "aabbccdd-2222-4222-8222-222222222222", text: "row two", vector: [], category: "entity", scope: "agent:agent-one", importance: 0.5, timestamp: 2, metadata: "{}" }, + ]; + const stubContext = { + store: { + async findByIdPrefix() { return rows; }, + async count() { return rows.length; }, + }, + retriever: { async retrieve() { throw new Error("semantic search must not run for a hex prefix"); } }, + }; + const resolution = await resolveMemoryId(stubContext, "aabbccdd", ["agent:agent-one"]); + assert.equal(resolution.ok, false); + assert.match(resolution.message, /matches multiple memories/); + assert.match(resolution.message, /aabbccdd/); + }); + + it("memory_update resolves an id prefix and supersedes the row with the new text", async () => { + const result = await callTool(harness.toolFactories, "memory_update", { + memoryId: seeded.id.slice(0, 13), + text: "Spice jars are labeled and alphabetized in the kitchen drawer", + }); + // A text change supersedes (temporal versioning): the reply names the + // RESOLVED id, proving the prefix reached the real row. + const replyText = JSON.stringify(result?.content ?? ""); + assert.ok( + replyText.includes(seeded.id.slice(0, 8)), + `update reply must reference the prefix-resolved row: ${replyText}`, + ); + const reader = new MemoryStore({ dbPath: path.join(workDir, "db"), vectorDim: EMBEDDING_DIMENSIONS }); + const rows = await reader.list(["agent:agent-one"], undefined, 50, 0); + assert.ok( + rows.some((row) => /alphabetized/.test(row.text)), + "the superseding row must carry the new text", + ); + }); + + it("a prefix matching nothing reports not-found without touching other rows", async () => { + const before = await store.count(); + const result = await callTool(harness.toolFactories, "memory_forget", { + memoryId: "ffffffff", + }); + assert.notEqual(result?.details?.action, "deleted"); + assert.equal(await store.count(), before); + }); +}); From 0e3288a39f802538813cb13ee72b939e7acfb7ec Mon Sep 17 00:00:00 2001 From: Gorkem Date: Sat, 18 Jul 2026 00:49:46 +0300 Subject: [PATCH 2/2] chore(dist): rebuild for id-prefix resolution Co-Authored-By: Claude Fable 5 --- dist/src/store.js | 39 +++++++++++++++++ dist/src/tools.js | 105 ++++++++++++++++++++++++---------------------- 2 files changed, 94 insertions(+), 50 deletions(-) diff --git a/dist/src/store.js b/dist/src/store.js index f345922d5..a9061d470 100644 --- a/dist/src/store.js +++ b/dist/src/store.js @@ -1455,6 +1455,45 @@ export class MemoryStore { await this.ensureInitialized(); return await this.table.countRows(); } + /** + * Finds rows whose id starts with `prefix`, restricted to accessible + * scopes. Backs the documented "full UUID or 8+ char prefix" contract on + * memory_forget/memory_update: injected context shows agents truncated ids, + * so a unique-prefix lookup is the only way those handles can ever resolve. + * The prefix must be hex/dash shaped (validated here, defense in depth on + * top of the tool-layer classification) and at least 8 chars, so a short + * or malformed ref can never scan-match. Capped at `limit` matches: the + * caller only distinguishes zero / one / many. + */ + async findByIdPrefix(prefix, scopeFilter, limit = 5) { + await this.ensureInitialized(); + if (isExplicitDenyAllScopeFilter(scopeFilter)) + return []; + const normalized = prefix.trim().toLowerCase(); + if (!/^[0-9a-f][0-9a-f-]{7,35}$/.test(normalized)) + return []; + const safePrefix = escapeSqlLiteral(normalized); + const rows = await this.table + .query() + .where(`id LIKE '${safePrefix}%'`) + .limit(Math.max(1, limit)) + .toArray(); + return rows + .filter((row) => { + const rowScope = row.scope ?? "global"; + return !scopeFilter || scopeFilter.length === 0 || scopeFilter.includes(rowScope); + }) + .map((row) => ({ + id: row.id, + text: row.text, + vector: Array.from(row.vector), + category: row.category, + scope: row.scope ?? "global", + importance: clampImportance(Number(row.importance)), + timestamp: normalizeMemoryTimestamp(row.timestamp, 0), + metadata: row.metadata || "{}", + })); + } async getById(id, scopeFilter) { await this.ensureInitialized(); if (isExplicitDenyAllScopeFilter(scopeFilter)) diff --git a/dist/src/tools.js b/dist/src/tools.js index 155ed1918..a0042c4e0 100644 --- a/dist/src/tools.js +++ b/dist/src/tools.js @@ -322,8 +322,10 @@ function formatIgnoredScopeNotice(resolvedScopes) { : "(none)"; return `Ignored inaccessible scope "${resolvedScopes.ignoredScope}" and searched accessible scopes instead: ${scopes}.`; } -async function resolveMemoryId(context, memoryRef, scopeFilter) { - const trimmed = memoryRef.trim(); +export async function resolveMemoryId(context, memoryRef, scopeFilter) { + // Agents copy ids out of injected context, which truncates them and often + // appends an ellipsis ("407dec9c..."); strip that before classifying. + const trimmed = memoryRef.trim().replace(/[.…]+$/u, ""); if (!trimmed) { return { ok: false, @@ -331,10 +333,36 @@ async function resolveMemoryId(context, memoryRef, scopeFilter) { details: { error: "empty_memory_ref" }, }; } - const uuidLike = /^[0-9a-f]{8}(-[0-9a-f]{4}){0,4}/i.test(trimmed); - if (uuidLike) { + const isFullUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(trimmed); + if (isFullUuid) { return { ok: true, id: trimmed }; } + // Documented contract: "full UUID or 8+ char prefix". A hex-shaped ref + // that is not a complete UUID resolves as an id prefix within accessible + // scopes — unique match wins, multiple matches list candidates, and zero + // matches is an honest not-found (never a scan-match or a semantic guess). + const isIdPrefix = /^[0-9a-f][0-9a-f-]{7,35}$/i.test(trimmed); + if (isIdPrefix) { + const matches = await context.store.findByIdPrefix(trimmed, scopeFilter); + if (matches.length === 1) { + return { ok: true, id: matches[0].id }; + } + if (matches.length > 1) { + const list = matches + .map((entry) => `- [${entry.id.slice(0, 8)}] ${entry.text.slice(0, 60)}${entry.text.length > 60 ? "..." : ""}`) + .join("\n"); + return { + ok: false, + message: `Id prefix "${trimmed}" matches multiple memories. Use a longer prefix or the full id:\n${list}`, + details: { error: "ambiguous_id_prefix", prefix: trimmed }, + }; + } + return { + ok: false, + message: `Memory ${trimmed} not found or access denied.`, + details: { error: "not_found", id: trimmed }, + }; + } const results = await retrieveWithRetry(context.retriever, { query: trimmed, limit: 5, @@ -1178,14 +1206,21 @@ export function registerMemoryForgetTool(api, context) { } } if (memoryId) { - const deleted = await context.store.delete(memoryId, scopeFilter); + const resolved = await resolveMemoryId(context, memoryId, scopeFilter); + if (resolved.ok === false) { + return { + content: [{ type: "text", text: resolved.message }], + details: resolved.details ?? { error: "not_found", id: memoryId }, + }; + } + const deleted = await context.store.delete(resolved.id, scopeFilter); if (deleted) { context.onMemoriesDeleted?.({ scopeFilter }); return { content: [ - { type: "text", text: `Memory ${memoryId} forgotten.` }, + { type: "text", text: `Memory ${resolved.id} forgotten.` }, ], - details: { action: "deleted", id: memoryId }, + details: { action: "deleted", id: resolved.id }, }; } else { @@ -1193,10 +1228,10 @@ export function registerMemoryForgetTool(api, context) { content: [ { type: "text", - text: `Memory ${memoryId} not found or access denied.`, + text: `Memory ${resolved.id} not found or access denied.`, }, ], - details: { error: "not_found", id: memoryId }, + details: { error: "not_found", id: resolved.id }, }; } } @@ -1310,48 +1345,18 @@ export function registerMemoryUpdateTool(api, context) { // Determine accessible scopes const agentId = resolveRuntimeAgentId(runtimeContext.agentId, runtimeCtx); const scopeFilter = resolveScopeFilter(runtimeContext.scopeManager, agentId); - // Resolve memoryId: if it doesn't look like a UUID, try search - let resolvedId = memoryId; - const uuidLike = /^[0-9a-f]{8}(-[0-9a-f]{4}){0,4}/i.test(memoryId); - if (!uuidLike) { - // Treat as search query - const results = await retrieveWithRetry(context.retriever, { - query: memoryId, - limit: 3, - scopeFilter, - }, () => context.store.count()); - if (results.length === 0) { - return { - content: [ - { - type: "text", - text: `No memory found matching "${memoryId}".`, - }, - ], - details: { error: "not_found", query: memoryId }, - }; - } - if (results.length === 1 || results[0].score > 0.85) { - resolvedId = results[0].entry.id; - } - else { - const list = results - .map((r) => `- [${r.entry.id.slice(0, 8)}] ${r.entry.text.slice(0, 60)}${r.entry.text.length > 60 ? "..." : ""}`) - .join("\n"); - return { - content: [ - { - type: "text", - text: `Multiple matches. Specify memoryId:\n${list}`, - }, - ], - details: { - action: "candidates", - candidates: sanitizeMemoryForSerialization(results), - }, - }; - } + // Resolve memoryId through the shared resolver: full UUID passes + // through, an 8+ char id prefix resolves against accessible rows + // (the tool's documented contract), anything else falls back to + // semantic search. + const resolution = await resolveMemoryId(context, memoryId, scopeFilter); + if (resolution.ok === false) { + return { + content: [{ type: "text", text: resolution.message }], + details: resolution.details ?? { error: "not_found", id: memoryId }, + }; } + const resolvedId = resolution.id; // If text changed, re-embed; reject noise let newVector; if (text) {