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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions dist/src/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
136 changes: 84 additions & 52 deletions dist/src/tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -322,19 +322,65 @@ 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, options) {
// 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,
message: "memoryId/query 不能为空。",
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 },
};
}
// Supported legacy ids (older memory-lancedb-pro versions) pass through
// untouched: MemoryStore.delete/getById carry exact handling for this shape,
// and routing them into semantic retrieval let a sole low-score result
// resolve to an unrelated row.
if (/^mem-md-\d+$/i.test(trimmed)) {
return { ok: true, id: trimmed };
}
// Destructive callers pass a DIRECT id reference; anything that is not a
// full UUID, a validated prefix, or a supported legacy id is malformed for
// them, never a semantic query. Semantic resolution stays reserved for the
// query flow with its confidence and confirmation safeguards.
if (options?.requireExactRef) {
return {
ok: false,
message: `"${trimmed}" is not a memory id. Pass a full UUID, an 8+ character id prefix, or search by content via the query flow.`,
details: { error: "invalid_memory_ref", ref: trimmed },
};
}
const results = await retrieveWithRetry(context.retriever, {
query: trimmed,
limit: 5,
Expand Down Expand Up @@ -1178,25 +1224,32 @@ export function registerMemoryForgetTool(api, context) {
}
}
if (memoryId) {
const deleted = await context.store.delete(memoryId, scopeFilter);
const resolved = await resolveMemoryId(context, memoryId, scopeFilter, { requireExactRef: true });
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 },
};
}
}
Expand Down Expand Up @@ -1310,48 +1363,21 @@ 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),
},
};
}
// memoryId promises a UUID or an 8+ char id prefix; both resolve
// exactly. Anything else is rejected instead of falling through to
// semantic retrieval: update mutates (and can supersede) whatever
// row it resolves, so a malformed id-shaped input must never be
// allowed to select an unrelated row by low-score similarity.
const resolution = await resolveMemoryId(context, memoryId, scopeFilter, {
requireExactRef: true,
});
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) {
Expand Down Expand Up @@ -1860,7 +1886,10 @@ export function registerMemoryPromoteTool(api, context) {
}
scopeFilter = [scope];
}
const resolved = await resolveMemoryId(runtimeContext, memoryId ?? query ?? "", scopeFilter);
// Dual selector: memoryId is the exact reference (UUID or 8+ char
// prefix, resolved exactly — this path mutates state); query is the
// explicit semantic selector and keeps retrieval-based resolution.
const resolved = await resolveMemoryId(runtimeContext, memoryId ?? query ?? "", scopeFilter, memoryId ? { requireExactRef: true } : undefined);
if (resolved.ok === false) {
return {
content: [{ type: "text", text: resolved.message }],
Expand Down Expand Up @@ -1941,7 +1970,10 @@ export function registerMemoryArchiveTool(api, context) {
}
scopeFilter = [scope];
}
const resolved = await resolveMemoryId(runtimeContext, memoryId ?? query ?? "", scopeFilter);
// Dual selector: memoryId is the exact reference (UUID or 8+ char
// prefix, resolved exactly — this path mutates state); query is the
// explicit semantic selector and keeps retrieval-based resolution.
const resolved = await resolveMemoryId(runtimeContext, memoryId ?? query ?? "", scopeFilter, memoryId ? { requireExactRef: true } : undefined);
if (resolved.ok === false) {
return {
content: [{ type: "text", text: resolved.message }],
Expand Down
Loading
Loading