diff --git a/dist/index.js b/dist/index.js index baebf86a1..778cda260 100644 --- a/dist/index.js +++ b/dist/index.js @@ -4066,10 +4066,15 @@ const memoryLanceDBProPlugin = { const now = new Date(params.timestampMs ?? Date.now()); const dateStr = now.toISOString().split("T")[0]; const timeStr = now.toISOString().split("T")[1].split(".")[0]; + // Session key/id stay out of `text`: it is the FTS index surface, and + // the `simple` tokenizer splits a key like + // `agent:main:cron::run:` on its punctuation — so every session + // summary ends up indexed under `agent`, `main`, `cron`, `run`. A query + // mentioning any of those then BM25-matches every session summary in the + // store regardless of content. Both ids are already recorded structurally + // in metadata below, so provenance is unaffected. const memoryText = [ `Session: ${dateStr} ${timeStr} UTC`, - `Session Key: ${params.sessionKey}`, - `Session ID: ${params.sessionId}`, `Source: ${params.source}`, "", "Conversation Summary:", diff --git a/dist/src/store.js b/dist/src/store.js index dde800e87..ea7778ac9 100644 --- a/dist/src/store.js +++ b/dist/src/store.js @@ -1382,6 +1382,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 02b83e060..82f08afc2 100644 --- a/dist/src/tools.js +++ b/dist/src/tools.js @@ -321,8 +321,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, @@ -330,10 +332,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, @@ -1176,14 +1204,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 { @@ -1191,10 +1226,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 }, }; } } @@ -1308,48 +1343,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) { diff --git a/package.json b/package.json index dfeb2daa5..d2bffb94a 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "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/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", + "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/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/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", diff --git a/scripts/ci-test-manifest.mjs b/scripts/ci-test-manifest.mjs index c71de5313..177ee5aea 100644 --- a/scripts/ci-test-manifest.mjs +++ b/scripts/ci-test-manifest.mjs @@ -1,35 +1,35 @@ -export const CI_TEST_GROUPS = [ - "cli-smoke", - "core-regression", - "storage-and-schema", - "llm-clients-and-auth", - "packaging-and-workflow", -]; - +export const CI_TEST_GROUPS = [ + "cli-smoke", + "core-regression", + "storage-and-schema", + "llm-clients-and-auth", + "packaging-and-workflow", +]; + export const CI_TEST_MANIFEST = [ { group: "llm-clients-and-auth", runner: "node", file: "test/embedder-error-hints.test.mjs" }, { group: "llm-clients-and-auth", runner: "node", file: "test/embedder-max-input-chars.test.mjs", args: ["--test"] }, { group: "llm-clients-and-auth", runner: "node", file: "test/cjk-recursion-regression.test.mjs" }, - { group: "storage-and-schema", runner: "node", file: "test/migrate-legacy-schema.test.mjs" }, - { group: "storage-and-schema", runner: "node", file: "test/config-session-strategy-migration.test.mjs", args: ["--test"] }, - { group: "storage-and-schema", runner: "node", file: "test/scope-access-undefined.test.mjs", args: ["--test"] }, - { group: "storage-and-schema", runner: "node", file: "test/reflection-bypass-hook.test.mjs", args: ["--test"] }, - { group: "storage-and-schema", runner: "node", file: "test/smart-extractor-scope-filter.test.mjs", args: ["--test"] }, - { group: "storage-and-schema", runner: "node", file: "test/store-empty-scope-filter.test.mjs", args: ["--test"] }, - { group: "storage-and-schema", runner: "node", file: "test/store-timestamp-normalization.test.mjs", args: ["--test"] }, - { group: "storage-and-schema", runner: "node", file: "test/storage-path-normalization.test.mjs", args: ["--test"] }, - { group: "storage-and-schema", runner: "node", file: "test/storage-maintenance.test.mjs", args: ["--test"] }, - { group: "storage-and-schema", runner: "node", file: "test/read-consistency-interval.test.mjs", args: ["--test"] }, - { group: "storage-and-schema", runner: "node", file: "test/fts-index-fold.test.mjs", args: ["--test"] }, - { group: "storage-and-schema", runner: "node", file: "test/store-list-stats-projection-fallback.test.mjs", args: ["--test"] }, - { group: "core-regression", runner: "node", file: "test/recall-text-cleanup.test.mjs", args: ["--test"] }, - { group: "core-regression", runner: "node", file: "test/category-filter-normalization.test.mjs", args: ["--test"] }, - { group: "storage-and-schema", runner: "node", file: "test/update-consistency-lancedb.test.mjs" }, - { group: "core-regression", runner: "node", file: "test/strip-envelope-metadata.test.mjs", args: ["--test"] }, - { group: "core-regression", runner: "node", file: "test/auto-recall-timeout.test.mjs", args: ["--test"] }, - { group: "cli-smoke", runner: "node", file: "test/import-markdown/import-markdown.test.mjs", args: ["--test"] }, - { group: "cli-smoke", runner: "node", file: "test/cli-smoke.mjs" }, - { group: "cli-smoke", runner: "node", file: "test/functional-e2e.mjs" }, + { group: "storage-and-schema", runner: "node", file: "test/migrate-legacy-schema.test.mjs" }, + { group: "storage-and-schema", runner: "node", file: "test/config-session-strategy-migration.test.mjs", args: ["--test"] }, + { group: "storage-and-schema", runner: "node", file: "test/scope-access-undefined.test.mjs", args: ["--test"] }, + { group: "storage-and-schema", runner: "node", file: "test/reflection-bypass-hook.test.mjs", args: ["--test"] }, + { group: "storage-and-schema", runner: "node", file: "test/smart-extractor-scope-filter.test.mjs", args: ["--test"] }, + { group: "storage-and-schema", runner: "node", file: "test/store-empty-scope-filter.test.mjs", args: ["--test"] }, + { group: "storage-and-schema", runner: "node", file: "test/store-timestamp-normalization.test.mjs", args: ["--test"] }, + { group: "storage-and-schema", runner: "node", file: "test/storage-path-normalization.test.mjs", args: ["--test"] }, + { group: "storage-and-schema", runner: "node", file: "test/storage-maintenance.test.mjs", args: ["--test"] }, + { group: "storage-and-schema", runner: "node", file: "test/read-consistency-interval.test.mjs", args: ["--test"] }, + { group: "storage-and-schema", runner: "node", file: "test/fts-index-fold.test.mjs", args: ["--test"] }, + { group: "storage-and-schema", runner: "node", file: "test/store-list-stats-projection-fallback.test.mjs", args: ["--test"] }, + { group: "core-regression", runner: "node", file: "test/recall-text-cleanup.test.mjs", args: ["--test"] }, + { group: "core-regression", runner: "node", file: "test/category-filter-normalization.test.mjs", args: ["--test"] }, + { group: "storage-and-schema", runner: "node", file: "test/update-consistency-lancedb.test.mjs" }, + { group: "core-regression", runner: "node", file: "test/strip-envelope-metadata.test.mjs", args: ["--test"] }, + { group: "core-regression", runner: "node", file: "test/auto-recall-timeout.test.mjs", args: ["--test"] }, + { group: "cli-smoke", runner: "node", file: "test/import-markdown/import-markdown.test.mjs", args: ["--test"] }, + { group: "cli-smoke", runner: "node", file: "test/cli-smoke.mjs" }, + { group: "cli-smoke", runner: "node", file: "test/functional-e2e.mjs" }, { group: "storage-and-schema", runner: "node", file: "test/per-agent-auto-recall.test.mjs", args: ["--test"] }, { group: "core-regression", runner: "node", file: "test/retriever-rerank-regression.mjs" }, { group: "core-regression", runner: "node", file: "test/retriever-neighbor-enrichment.test.mjs", args: ["--test"] }, @@ -41,80 +41,81 @@ export const CI_TEST_MANIFEST = [ { group: "core-regression", runner: "node", file: "test/dreaming-engine.test.mjs", args: ["--test"] }, { group: "core-regression", runner: "node", file: "test/memory-governance-tools.test.mjs", args: ["--test"] }, { group: "core-regression", runner: "node", file: "test/corpus-indexer.test.mjs" }, - { group: "packaging-and-workflow", runner: "node", file: "test/plugin-manifest-regression.mjs" }, - { group: "packaging-and-workflow", runner: "node", file: "test/openclaw-twitter-source-recipe.test.mjs", args: ["--test"] }, - { group: "packaging-and-workflow", runner: "node", file: "test/package-runtime.test.mjs" }, - { group: "packaging-and-workflow", runner: "node", file: "test/release-readiness.test.mjs" }, - { group: "core-regression", runner: "node", file: "test/session-summary-before-reset.test.mjs", args: ["--test"] }, + { group: "packaging-and-workflow", runner: "node", file: "test/plugin-manifest-regression.mjs" }, + { group: "packaging-and-workflow", runner: "node", file: "test/openclaw-twitter-source-recipe.test.mjs", args: ["--test"] }, + { group: "packaging-and-workflow", runner: "node", file: "test/package-runtime.test.mjs" }, + { group: "packaging-and-workflow", runner: "node", file: "test/release-readiness.test.mjs" }, + { group: "core-regression", runner: "node", file: "test/session-summary-before-reset.test.mjs", args: ["--test"] }, { group: "core-regression", runner: "node", file: "test/self-improvement-reset-note.test.mjs", args: ["--test"] }, { group: "core-regression", runner: "node", file: "test/self-improvement.test.mjs", args: ["--test"] }, { group: "packaging-and-workflow", runner: "node", file: "test/sync-plugin-version.test.mjs", args: ["--test"] }, - { group: "core-regression", runner: "node", file: "test/smart-metadata-v2.mjs" }, - { group: "storage-and-schema", runner: "node", file: "test/vector-search-cosine.test.mjs" }, - { group: "core-regression", runner: "node", file: "test/context-support-e2e.mjs" }, + { group: "core-regression", runner: "node", file: "test/smart-metadata-v2.mjs" }, + { group: "storage-and-schema", runner: "node", file: "test/vector-search-cosine.test.mjs" }, + { group: "core-regression", runner: "node", file: "test/context-support-e2e.mjs" }, { group: "core-regression", runner: "node", file: "test/temporal-facts.test.mjs" }, { group: "core-regression", runner: "node", file: "test/memory-fact-query.test.mjs", args: ["--test"] }, { group: "core-regression", runner: "node", file: "test/memory-update-supersede.test.mjs" }, - { group: "llm-clients-and-auth", runner: "node", file: "test/memory-upgrader-diagnostics.test.mjs" }, - { group: "llm-clients-and-auth", runner: "node", file: "test/llm-api-key-client.test.mjs", args: ["--test"] }, - { group: "llm-clients-and-auth", runner: "node", file: "test/llm-oauth-client.test.mjs", args: ["--test"] }, - { group: "llm-clients-and-auth", runner: "node", file: "test/cli-oauth-login.test.mjs", args: ["--test"] }, - { group: "packaging-and-workflow", runner: "node", file: "test/workflow-fork-guards.test.mjs", args: ["--test"] }, - { group: "storage-and-schema", runner: "node", file: "test/clawteam-scope.test.mjs", args: ["--test"] }, + { group: "llm-clients-and-auth", runner: "node", file: "test/memory-upgrader-diagnostics.test.mjs" }, + { group: "llm-clients-and-auth", runner: "node", file: "test/llm-api-key-client.test.mjs", args: ["--test"] }, + { group: "llm-clients-and-auth", runner: "node", file: "test/llm-oauth-client.test.mjs", args: ["--test"] }, + { group: "llm-clients-and-auth", runner: "node", file: "test/cli-oauth-login.test.mjs", args: ["--test"] }, + { group: "packaging-and-workflow", runner: "node", file: "test/workflow-fork-guards.test.mjs", args: ["--test"] }, + { group: "storage-and-schema", runner: "node", file: "test/clawteam-scope.test.mjs", args: ["--test"] }, { group: "storage-and-schema", runner: "node", file: "test/cross-process-lock.test.mjs", args: ["--test"] }, { group: "storage-and-schema", runner: "node", file: "test/redis-lock.test.mjs", args: ["--test"] }, { group: "core-regression", runner: "node", file: "test/lock-stress-test.mjs", args: ["--test"] }, - { group: "core-regression", runner: "node", file: "test/lock-release-on-error.test.mjs", args: ["--test"] }, - { group: "core-regression", runner: "node", file: "test/preference-slots.test.mjs", args: ["--test"] }, - { group: "core-regression", runner: "node", file: "test/is-latest-auto-supersede.test.mjs" }, - { group: "core-regression", runner: "node", file: "test/temporal-awareness.test.mjs", args: ["--test"] }, - // Issue #598 regression tests - { group: "core-regression", runner: "node", file: "test/store-serialization.test.mjs" }, - { group: "core-regression", runner: "node", file: "test/mmr-tiny.test.mjs", args: ["--test"] }, - { group: "core-regression", runner: "node", file: "test/access-tracker-retry.test.mjs" }, - { group: "core-regression", runner: "node", file: "test/embedder-cache.test.mjs" }, - // Issue #629 batch embedding fix - { group: "llm-clients-and-auth", runner: "node", file: "test/embedder-ollama-batch-routing.test.mjs" }, - // Issue #665 bulkStore tests - // Issue #690 cross-call batch accumulator tests - { group: "storage-and-schema", runner: "node", file: "test/issue-690-cross-call-batch.test.mjs", args: ["--test"] }, - // Issue #665 bulkStore tests (from upstream) - { group: "storage-and-schema", runner: "node", file: "test/bulk-store.test.mjs", args: ["--test"] }, - { group: "storage-and-schema", runner: "node", file: "test/bulk-store-edge-cases.test.mjs", args: ["--test"] }, - { group: "storage-and-schema", runner: "node", file: "test/smart-extractor-bulk-store.test.mjs", args: ["--test"] }, - { group: "storage-and-schema", runner: "node", file: "test/smart-extractor-bulk-store-edge-cases.test.mjs", args: ["--test"] }, - { group: "storage-and-schema", runner: "node", file: "test/store-importance-normalization.test.mjs", args: ["--test"] }, - // Issue #680 regression tests (from upstream) - { group: "core-regression", runner: "node", file: "test/memory-reflection-issue680-tdd.test.mjs", args: ["--test"] }, - // Issue #606 SDK migration Bug 2 regression tests - { group: "core-regression", runner: "node", file: "test/issue606_sdk-migration.test.mjs" }, - // PR #713 inference regression tests - inferProviderFromBaseURL + model fallback - { group: "core-regression", runner: "node", file: "test/infer-provider-from-baseurl.test.mjs" }, - // Issue #736 recall governance - isRecallUsed() unit tests - { group: "core-regression", runner: "node", file: "test/is-recall-used.test.mjs", args: ["--test"] }, - // Issue #492 agentId validation tests - { group: "core-regression", runner: "node", file: "test/agentid-validation.test.mjs", args: ["--test"] }, - { group: "core-regression", runner: "node", file: "test/command-reflection-guard.test.mjs", args: ["--test"] }, - // Tier 1 memory counter fix - { group: "core-regression", runner: "node", file: "test/tier1-counters.test.mjs", args: ["--test"] }, - { group: "core-regression", runner: "node", file: "test/memory-subsession-prompt-hooks.test.mjs", args: ["--test"] }, - // Reflection distiller sub-session must not receive auto-recall/injected blocks - { group: "core-regression", runner: "node", file: "test/reflection-distiller-hook-skip.test.mjs", args: ["--test"] }, - // register() re-registration hardening (scope cache-miss log/handler dedup) + { group: "core-regression", runner: "node", file: "test/lock-release-on-error.test.mjs", args: ["--test"] }, + { group: "core-regression", runner: "node", file: "test/preference-slots.test.mjs", args: ["--test"] }, + { group: "core-regression", runner: "node", file: "test/is-latest-auto-supersede.test.mjs" }, + { group: "core-regression", runner: "node", file: "test/temporal-awareness.test.mjs", args: ["--test"] }, + // Issue #598 regression tests + { group: "core-regression", runner: "node", file: "test/store-serialization.test.mjs" }, + { group: "core-regression", runner: "node", file: "test/mmr-tiny.test.mjs", args: ["--test"] }, + { group: "core-regression", runner: "node", file: "test/access-tracker-retry.test.mjs" }, + { group: "core-regression", runner: "node", file: "test/embedder-cache.test.mjs" }, + // Issue #629 batch embedding fix + { group: "llm-clients-and-auth", runner: "node", file: "test/embedder-ollama-batch-routing.test.mjs" }, + // Issue #665 bulkStore tests + // Issue #690 cross-call batch accumulator tests + { group: "storage-and-schema", runner: "node", file: "test/issue-690-cross-call-batch.test.mjs", args: ["--test"] }, + // Issue #665 bulkStore tests (from upstream) + { group: "storage-and-schema", runner: "node", file: "test/bulk-store.test.mjs", args: ["--test"] }, + { group: "storage-and-schema", runner: "node", file: "test/bulk-store-edge-cases.test.mjs", args: ["--test"] }, + { group: "storage-and-schema", runner: "node", file: "test/smart-extractor-bulk-store.test.mjs", args: ["--test"] }, + { group: "storage-and-schema", runner: "node", file: "test/smart-extractor-bulk-store-edge-cases.test.mjs", args: ["--test"] }, + { group: "storage-and-schema", runner: "node", file: "test/store-importance-normalization.test.mjs", args: ["--test"] }, + // Issue #680 regression tests (from upstream) + { group: "core-regression", runner: "node", file: "test/memory-reflection-issue680-tdd.test.mjs", args: ["--test"] }, + // Issue #606 SDK migration Bug 2 regression tests + { group: "core-regression", runner: "node", file: "test/issue606_sdk-migration.test.mjs" }, + // PR #713 inference regression tests - inferProviderFromBaseURL + model fallback + { group: "core-regression", runner: "node", file: "test/infer-provider-from-baseurl.test.mjs" }, + // Issue #736 recall governance - isRecallUsed() unit tests + { group: "core-regression", runner: "node", file: "test/is-recall-used.test.mjs", args: ["--test"] }, + // Issue #492 agentId validation tests + { group: "core-regression", runner: "node", file: "test/agentid-validation.test.mjs", args: ["--test"] }, + { group: "core-regression", runner: "node", file: "test/command-reflection-guard.test.mjs", args: ["--test"] }, + // Tier 1 memory counter fix + { group: "core-regression", runner: "node", file: "test/tier1-counters.test.mjs", args: ["--test"] }, + { group: "core-regression", runner: "node", file: "test/memory-subsession-prompt-hooks.test.mjs", args: ["--test"] }, + // Reflection distiller sub-session must not receive auto-recall/injected blocks + { group: "core-regression", runner: "node", file: "test/reflection-distiller-hook-skip.test.mjs", args: ["--test"] }, + // register() re-registration hardening (scope cache-miss log/handler dedup) { group: "core-regression", runner: "node", file: "test/register-scope-dedup.test.mjs", args: ["--test"] }, - // Reflection distiller sub-run must request raw-run semantics (skip foreign before_prompt_build hooks) + // Reflection distiller sub-run must request raw-run semantics (skip foreign before_prompt_build hooks) { group: "core-regression", runner: "node", file: "test/raw-run-distiller-hooks.test.mjs", args: ["--test"] }, - { group: "core-regression", runner: "node", file: "test/autocapture-watermark-reset.test.mjs", args: ["--test"] }, - { group: "core-regression", runner: "node", file: "test/autocapture-internal-session-guard.test.mjs", args: ["--test"] }, - { group: "storage-and-schema", runner: "node", file: "test/memory-categories-storage-map.test.mjs", args: ["--test"] }, - // Delete/delete-bulk must synchronously invalidate in-process reflection read caches - { group: "core-regression", runner: "node", file: "test/delete-invalidate-reflection-caches.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); -} + { group: "core-regression", runner: "node", file: "test/autocapture-watermark-reset.test.mjs", args: ["--test"] }, + { group: "core-regression", runner: "node", file: "test/autocapture-internal-session-guard.test.mjs", args: ["--test"] }, + { group: "storage-and-schema", runner: "node", file: "test/memory-categories-storage-map.test.mjs", args: ["--test"] }, + // Delete/delete-bulk must synchronously invalidate in-process reflection read caches + { group: "core-regression", runner: "node", file: "test/delete-invalidate-reflection-caches.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 4a6d93b94..e0dabacee 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1654,6 +1654,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 acbe2be9a..9939eda2e 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -456,7 +456,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[], @@ -464,7 +464,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, @@ -473,11 +475,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, @@ -1556,24 +1588,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 }, }; } } @@ -1720,50 +1759,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); + }); +});