diff --git a/src/libravdb-client.ts b/src/libravdb-client.ts index bbd4fad7..d15b8d66 100644 --- a/src/libravdb-client.ts +++ b/src/libravdb-client.ts @@ -78,10 +78,12 @@ export interface LibravDBClientOptions { tlsClientCertPath?: string; tlsClientKeyPath?: string; /** Stable tenant key for multi-agent DB routing. Attached as the - * `libravdb-tenant-key` gRPC metadata header on every call. */ + * `x-libravdb-tenant-key` gRPC metadata header on every call. */ tenantKey?: string; } +export const TENANT_KEY_HEADER = "x-libravdb-tenant-key"; + export function resolveClientEndpoint(configuredEndpoint?: string): string { if (configuredEndpoint && configuredEndpoint !== "auto") return configuredEndpoint; if (process.env.LIBRAVDB_GRPC_ENDPOINT) return process.env.LIBRAVDB_GRPC_ENDPOINT; @@ -243,6 +245,18 @@ export function createAuthInterceptor( }; } +export function createTenantInterceptor( + getTenantKey: () => string | undefined, +): Interceptor { + return (next) => async (req) => { + const tenantKey = getTenantKey(); + if (tenantKey) { + req.header.set(TENANT_KEY_HEADER, tenantKey); + } + return next(req); + }; +} + export class LibravDBClient { private client: PromiseClient; private readonly secret: string | undefined; @@ -293,12 +307,7 @@ export class LibravDBClient { this.tenantKey = options.tenantKey; const interceptors: Interceptor[] = []; - interceptors.push((next) => async (req) => { - if (self.tenantKey) { - req.header.set("libravdb-tenant-key", self.tenantKey); - } - return next(req); - }); + interceptors.push(createTenantInterceptor(() => self.tenantKey)); interceptors.push(authInterceptor); const transport = createGrpcTransport({ diff --git a/src/memory-provider.ts b/src/memory-provider.ts index bdfeaf77..71e7059b 100644 --- a/src/memory-provider.ts +++ b/src/memory-provider.ts @@ -16,7 +16,8 @@ const MEMORY_PROMPT_HEADER = [ "('who is X', 'what is X', 'do I have X', 'tell me about X', 'what kind of X'):", "you MUST call `list_user_cards` or `get_user_card`. This is not optional.", "Do NOT answer from memory, context, or training data. Call the tool FIRST.", - "If the card is empty, then fall through to `memory_search`. ", + "For history, details, preferences, or explicit memory requests, use", + "`memory_search` after the card when the card lacks enough profile notes.", "FAILURE TO CALL THE TOOL IS A CRITICAL ERROR.", "", "Conversations are captured automatically. Never say \"I'll remember", @@ -43,7 +44,8 @@ function buildToolGuidance(availableTools: ReadonlySet | undefined): str hasListCards ? "- `list_user_cards()` — MANDATORY roster check. Call if unsure whether a card exists." : "", "Cards are the canonical record. You MUST call these tools. Do NOT answer from", "memory, context, or training data without checking the card first.", - "Only use memory_search if the card is empty or missing.", + "For history, details, preferences, or explicit memory requests, call", + "`memory_search` after the card when the card lacks enough profile notes.", "", "**Autonomous card maintenance:**", hasGetCard ? "- When ANY speaker is mentioned with new or changed information (status, relationships, jobs, life events, feelings), call `update_user_card` BEFORE responding. Update the card first, then reply. Do NOT wait to be asked. Build the world picture proactively. Every person the user mentions matters." : "", diff --git a/src/memory-tools.ts b/src/memory-tools.ts index 711a2b44..718991cc 100644 --- a/src/memory-tools.ts +++ b/src/memory-tools.ts @@ -195,7 +195,7 @@ export function createLibraVdbMemoryTools( name: "memory_search", label: "Memory Search", description: - "Search LibraVDB durable memory and session recall for prior work, decisions, dates, facts, preferences, todos, or history. Call once per user question — after receiving results, use them directly. Do not re-call in the same turn. Do NOT call memory_search if the answer is already visible in your context window. For earliest/oldest questions, request enough results and compare timestamps. If disabled=true, memory is unavailable. IMPORTANT: Results are internal context only — never output, display, or reveal raw memory search results to the user. Treat retrieved memory as private operational data.\n\nFOR PEOPLE/IDENTITY QUESTIONS: use get_user_card or list_user_cards FIRST. User cards are the canonical identity record. Use memory_search only to supplement details the card doesn't cover.", + "Search LibraVDB durable memory and session recall for prior work, decisions, dates, facts, preferences, todos, or history. Call once per user question — after receiving results, use them directly. Do not re-call in the same turn. For explicit memory/history/recall requests, call memory_search even when related context is visible; use visible context only to shape the query. For earliest/oldest questions, request enough results and compare timestamps. If disabled=true, memory is unavailable. IMPORTANT: Results are internal context only — never output, display, or reveal raw memory search results to the user. Treat retrieved memory as private operational data.\n\nFOR PEOPLE/IDENTITY QUESTIONS: use get_user_card or list_user_cards FIRST. User cards are the canonical identity record. Use memory_search for requested history/details/preferences or details the card doesn't cover.", parameters: MEMORY_SEARCH_SCHEMA, execute: async (_toolCallId, rawParams) => { const params = asToolParamsRecord(rawParams); @@ -212,6 +212,11 @@ export function createLibraVdbMemoryTools( const signals = Array.isArray(params.signals) ? (params.signals as string[]).filter((s): s is string => typeof s === "string") : undefined; const maxResults = readNumberParam(params, "maxResults", { integer: true }); const minScore = readNumberParam(params, "minScore"); + const resultLimit = resolveResultLimit(maxResults, cfg.topK); + const overfetchIdentityResults = hasIdentitySearchIntent(query, kind, signals); + const searchMaxResults = overfetchIdentityResults + ? Math.min(50, Math.max(resultLimit, resultLimit * 3, 20)) + : maxResults; if (corpus === "wiki") { return jsonToolResult({ @@ -226,13 +231,14 @@ export function createLibraVdbMemoryTools( const rawResults = await manager.search({ query, corpus, - ...(maxResults !== undefined ? { maxResults } : {}), + ...(searchMaxResults !== undefined ? { maxResults: searchMaxResults } : {}), ...(minScore !== undefined ? { minScore } : {}), ...(kind !== undefined ? { kind } : {}), ...(signals !== undefined ? { signals } : {}), ...buildSearchContext(ctx), }) as MemorySearchResult[]; - const results = filterResultsByCorpus(rawResults, corpus); + const rankedResults = rankIdentitySearchResults(query, filterResultsByCorpus(rawResults, corpus)); + const results = overfetchIdentityResults ? rankedResults.slice(0, resultLimit) : rankedResults; const status = manager.status(); return jsonToolResult({ results, @@ -382,6 +388,122 @@ function normalizeOptionalString(value: unknown): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } +function resolveResultLimit(maxResults: number | undefined, configuredTopK: number | undefined): number { + return maxResults ?? (typeof configuredTopK === "number" && Number.isFinite(configuredTopK) && configuredTopK > 0 + ? Math.floor(configuredTopK) + : 8); +} + +function hasIdentitySearchIntent(query: string, kind?: string, signals?: string[]): boolean { + return kind === "identity" || signals?.includes("identity") === true || shouldOverfetchForIdentityQuery(query); +} + +function shouldOverfetchForIdentityQuery(query: string): boolean { + const trimmed = query.trim(); + const normalized = normalizeIdentityText(query); + if (normalized.length < 3 || normalized.length > 80) return false; + if (/^(?:who|what|where|when|why|how)(?:\s+(?:is|are|was|were))?\s+/u.test(normalized)) return true; + if (/(user|person|speaker|sender|author|profile|identity|discord|imessage)/u.test(normalized)) return true; + if (/\s/u.test(trimmed)) return false; + return /[@0-9_-]/u.test(trimmed) || /[a-z][A-Z]|[A-Z][a-z]/u.test(trimmed); +} + +function rankIdentitySearchResults(query: string, results: MemorySearchResult[]): MemorySearchResult[] { + if (results.length < 2) return results; + const queryTokens = identityEntityTokens(query); + if (queryTokens.length === 0) return results; + + const ranked = results.map((result, index) => { + const header = parseOpenClawContextHeader(result.snippet); + const speakerText = [header.sender, header.username, header.user_id, header.sender_id].filter(Boolean).join(" "); + const speakerTokens = identityTokens(speakerText); + const speakerMatch = queryTokens.some((token) => speakerTokens.includes(token)); + const userCardMatch = isUserCardResult(result) + && queryTokens.every((token) => identityTokens(extractUserCardIdentityText(result.snippet)).includes(token)); + const toolArtifact = isHistoricalToolArtifact(result.snippet); + return { result, index, speakerMatch, userCardMatch, toolArtifact }; + }); + + if (!ranked.some((item) => item.speakerMatch || item.userCardMatch || item.toolArtifact)) return results; + return ranked + .sort((a, b) => + Number(b.userCardMatch) - Number(a.userCardMatch) + || Number(b.speakerMatch) - Number(a.speakerMatch) + || Number(a.toolArtifact) - Number(b.toolArtifact) + || a.index - b.index + ) + .map((item) => item.result); +} + +function isUserCardResult(result: MemorySearchResult): boolean { + return /(?:^|:)user-card[:%]/u.test(result.citation ?? result.path); +} + +function isHistoricalToolArtifact(snippet: string): boolean { + return /^\s*(?:\[tool:[^\]]+\]| /^(?:[-*]\s*)?(?:user card|known aliases|visible names|display names|aliases|name|username|speaker|speaker id|user id|provider|account type|channel id)\s*:/iu.test(line.trim())) + .join("\n"); +} + +function parseOpenClawContextHeader(text: string): Record { + const firstLine = text.split("\n", 1)[0] ?? ""; + const match = /^\[OpenClaw context:\s*(.*)\]$/u.exec(firstLine.trim()); + if (!match) return {}; + const values: Record = {}; + for (const part of match[1].split(";")) { + const separator = part.indexOf("="); + if (separator <= 0) continue; + const key = part.slice(0, separator).trim(); + const value = part.slice(separator + 1).trim(); + if (key && value && value !== "") values[key] = value; + } + return values; +} + +function identityTokens(text: string): string[] { + const normalized = normalizeIdentityText(text); + return normalized.match(/[a-z0-9]+/gu)?.filter((token) => token.length >= 3) ?? []; +} + +function identityEntityTokens(text: string): string[] { + const ignored = new Set([ + "about", + "are", + "author", + "discord", + "history", + "identity", + "imessage", + "kind", + "kinds", + "know", + "person", + "profile", + "sender", + "speaker", + "tell", + "user", + "what", + "when", + "where", + "who", + "with", + "you", + ]); + const tokens = identityTokens(text); + const entityTokens = tokens.filter((token) => !ignored.has(token)); + return entityTokens.length > 0 ? entityTokens : tokens; +} + +function normalizeIdentityText(text: string): string { + return text.toLowerCase().replace(/[^a-z0-9]+/gu, " ").trim(); +} + function jsonToolResult(details: TDetails): ToolResult { return { content: [ diff --git a/src/tools/memory-recall.ts b/src/tools/memory-recall.ts index d592cd4c..73ff9fd0 100644 --- a/src/tools/memory-recall.ts +++ b/src/tools/memory-recall.ts @@ -56,6 +56,8 @@ const MAX_EXPAND_CHARS = MAX_EXPAND_TOKENS * 4; const MAX_GREP_RESULTS = 50; const MAX_GREP_CHARS = 40000; const MAX_SNIPPET_CHARS = 200; +const USER_CARD_INDEX_VARIANT_SUFFIX_RE = /:(?:64d|256d)$/u; +const USER_CARD_SOURCE = "openclaw-user-cards"; // ── Schemas ── @@ -573,6 +575,7 @@ const GET_USER_CARD_SCHEMA = { type UpdateUserCardDetails = { ok: boolean; error?: string }; type GetUserCardDetails = { card?: string | null; updatedAt?: number; version?: number; error?: string }; +type UserCardLookupHit = { card: string | null; updatedAt?: number; version?: number }; // ── User Card tool factories ── @@ -623,7 +626,8 @@ export function createGetUserCardTool( "about a person, pet, place, or named thing ('who/what is X', 'do I have X', " + "'tell me about X'). Returns the full prose identity card. " + "Do NOT answer from memory or training data. Call this tool FIRST. " + - "Only fall through to memory_search if the card is empty or missing.", + "For identity-only questions, answer from the card. For details/history/preferences " + + "questions, call memory_search after the card when the card lacks profile notes.", parameters: GET_USER_CARD_SCHEMA, execute: async (_toolCallId: string, rawParams: unknown): Promise> => { try { @@ -633,6 +637,14 @@ export function createGetUserCardTool( const client = await getClient(); const resp = await client.getUserCard({ userId }); + const aliasHit = resp.cardJson ? null : await findUserCardByAlias(client, userId); + if (aliasHit) { + return jsonResult({ + card: aliasHit.card, + updatedAt: aliasHit.updatedAt, + version: aliasHit.version, + }); + } return jsonResult({ card: resp.cardJson || null, updatedAt: resp.updatedAt ? Number(resp.updatedAt) : undefined, @@ -659,6 +671,118 @@ type ListUserCardsDetails = { error?: string; }; +type UserCardListEntry = ListUserCardsDetails["users"][number]; +type UserCardListCandidate = UserCardListEntry & { isCanonicalSource: boolean }; + +function canonicalUserCardId(userId: string): string { + return userId.replace(USER_CARD_INDEX_VARIANT_SUFFIX_RE, ""); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +} + +function userCardIdMatchesLookup(storedUserId: string, requestedUserId: string): boolean { + if (storedUserId === requestedUserId) return true; + if (requestedUserId.includes("|")) return false; + return new RegExp(`(?:^|\\|)sender=${escapeRegExp(requestedUserId)}(?:$|\\|)`, "u").test(storedUserId); +} + +function readUserCardLookupHit(result: { metadataJson?: Uint8Array }): UserCardLookupHit | null { + if (!result.metadataJson || result.metadataJson.length === 0) return null; + try { + const meta = JSON.parse(new TextDecoder().decode(result.metadataJson)) as Record; + const cardJson = typeof meta.card_json === "string" ? meta.card_json : null; + if (!cardJson) return null; + const card = JSON.parse(cardJson) as { card?: unknown; source?: unknown }; + if (!isOpenClawUserCardSource(card.source)) return null; + return { + card: typeof card.card === "string" ? card.card : cardJson, + updatedAt: typeof meta.updated_at === "number" ? meta.updated_at : undefined, + version: typeof meta.version === "number" ? meta.version : undefined, + }; + } catch { + return null; + } +} + +function shouldReplaceUserCardLookupHit(current: UserCardLookupHit, next: UserCardLookupHit): boolean { + if ((next.version ?? 0) !== (current.version ?? 0)) return (next.version ?? 0) > (current.version ?? 0); + if ((next.updatedAt ?? 0) !== (current.updatedAt ?? 0)) return (next.updatedAt ?? 0) > (current.updatedAt ?? 0); + return !current.card && !!next.card; +} + +function userCardAliasMatchesLookup(card: string | null, requestedUserId: string): boolean { + if (!card || requestedUserId.includes("|")) return false; + const requestedTokens = identityTokens(requestedUserId); + if (requestedTokens.length === 0) return false; + const cardTokens = identityTokens(extractUserCardAliasLookupText(card)); + if (cardTokens.length === 0) return false; + return requestedTokens.every((token) => cardTokens.includes(token)); +} + +function extractUserCardIdentityText(card: string): string { + return card + .split(/\r?\n/u) + .filter((line) => + /^(?:[-*]\s*)?(?:user card|known aliases|visible names|display names|aliases|name|username|speaker|speaker id|user id|provider|account type|channel id)\s*:/iu + .test(line.trim()) + ) + .join("\n"); +} + +function extractUserCardAliasLookupText(card: string): string { + return card + .split(/\r?\n/u) + .filter((line) => + /^(?:[-*]\s*)?(?:user card|known aliases|visible names|display names|aliases|name|username|speaker|speaker id|user id)\s*:/iu + .test(line.trim()) + ) + .join("\n"); +} + +function isOpenClawUserCardSource(source: unknown): boolean { + return source === USER_CARD_SOURCE; +} + +function identityTokens(text: string): string[] { + return text.toLowerCase().match(/[a-z0-9]+/gu)?.filter((token) => token.length >= 3) ?? []; +} + +async function findUserCardByAlias(client: Awaited>, userId: string): Promise { + const resp = await client.listByMeta({ + collection: "", + key: "type", + value: "user_card", + }); + let best: UserCardLookupHit | null = null; + for (const result of resp.results) { + if (!result.metadataJson || result.metadataJson.length === 0) continue; + let storedUserId = ""; + try { + const meta = JSON.parse(new TextDecoder().decode(result.metadataJson)) as Record; + storedUserId = typeof meta._user_id === "string" ? canonicalUserCardId(meta._user_id) : ""; + } catch { + continue; + } + const hit = readUserCardLookupHit(result); + if (!storedUserId || !hit) continue; + if (!userCardIdMatchesLookup(storedUserId, userId) && !userCardAliasMatchesLookup(hit.card, userId)) continue; + if (!best || shouldReplaceUserCardLookupHit(best, hit)) best = hit; + } + return best; +} + +function shouldReplaceUserCardListEntry( + current: UserCardListCandidate, + next: UserCardListCandidate, +): boolean { + if (next.isCanonicalSource !== current.isCanonicalSource) return next.isCanonicalSource; + if ((next.version ?? 0) !== (current.version ?? 0)) return (next.version ?? 0) > (current.version ?? 0); + if ((next.updated_at ?? 0) !== (current.updated_at ?? 0)) return (next.updated_at ?? 0) > (current.updated_at ?? 0); + return !current.preview && !!next.preview; +} + export function createListUserCardsTool( getClient: ClientGetter, logger: LoggerLike = console, @@ -680,7 +804,7 @@ export function createListUserCardsTool( key: "type", value: "user_card", }); - const users: ListUserCardsDetails["users"] = []; + const usersById = new Map(); for (const result of resp.results) { let userId = ""; let preview = ""; @@ -693,8 +817,18 @@ export function createListUserCardsTool( userId = typeof meta._user_id === "string" ? meta._user_id : ""; const cardJson = typeof meta.card_json === "string" ? meta.card_json : null; if (cardJson) { - try { preview = JSON.parse(cardJson).card ?? cardJson; } - catch { preview = cardJson; } + try { + const card = JSON.parse(cardJson) as { card?: unknown; source?: unknown }; + if (!isOpenClawUserCardSource(card.source)) { + userId = ""; + continue; + } + preview = typeof card.card === "string" ? card.card : cardJson; + } + catch { + userId = ""; + continue; + } preview = preview.slice(0, 200); } updatedAt = typeof meta.updated_at === "number" ? meta.updated_at : undefined; @@ -702,8 +836,20 @@ export function createListUserCardsTool( } catch { /* best-effort metadata parse */ } } if (!userId) continue; - users.push({ user_id: userId, preview, updated_at: updatedAt, version }); + const canonicalUserId = canonicalUserCardId(userId); + const entry = { + user_id: canonicalUserId, + preview, + updated_at: updatedAt, + version, + isCanonicalSource: userId === canonicalUserId, + }; + const current = usersById.get(canonicalUserId); + if (!current || shouldReplaceUserCardListEntry(current, entry)) { + usersById.set(canonicalUserId, entry); + } } + const users = Array.from(usersById.values(), ({ isCanonicalSource: _isCanonicalSource, ...user }) => user); return jsonResult({ users, total: users.length }); } catch (error) { logger.warn?.(`list_user_cards failed: ${formatError(error)}`); diff --git a/test/unit/libravdb-client.test.ts b/test/unit/libravdb-client.test.ts index 9bc9ff60..07393ff5 100644 --- a/test/unit/libravdb-client.test.ts +++ b/test/unit/libravdb-client.test.ts @@ -8,10 +8,12 @@ import path from "node:path"; import { resolveClientEndpoint, createAuthInterceptor, + createTenantInterceptor, detectLegacyJsonRpcDaemon, isLegacyJsonRpcHealthResponse, LibravDBClient, loadSecretFromEnv, + TENANT_KEY_HEADER, } from "../../src/libravdb-client.js"; import type { AuthInterceptorState } from "../../src/libravdb-client.js"; @@ -328,6 +330,19 @@ test("no auth headers without secret", async () => { assert.equal(sent.has("x-libravdb-auth"), false); }); +test("tenant interceptor sends daemon-recognized tenant metadata header", async () => { + const int = createTenantInterceptor(() => "agent-tenant"); + const { sent, header } = headerSink(); + + await (int as any)(async () => ({ + header: { get: () => null }, + trailer: { get: () => null }, + }))({ method: { name: "Status" }, header } as any); + + assert.equal(sent.get(TENANT_KEY_HEADER), "agent-tenant"); + assert.equal(sent.has("libravdb-tenant-key"), false); +}); + test("loadSecretFromEnv returns undefined when no env vars are set", () => { const result = loadSecretFromEnv(); assert.equal(result, undefined); diff --git a/test/unit/memory-provider.test.ts b/test/unit/memory-provider.test.ts index 942a0954..8ad82dd4 100644 --- a/test/unit/memory-provider.test.ts +++ b/test/unit/memory-provider.test.ts @@ -83,6 +83,22 @@ test("memory prompt section guides memory tool use when memory_search is availab assert.equal(rpc.calls.get("search_text") ?? 0, 0, "should not perform search_text calls"); }); +test("memory prompt section does not let sparse user cards suppress recall", async () => { + const rpc = new FakeRpc(); + const cfg: PluginConfig = { topK: 8 }; + const getRpc = async () => rpc as never; + + const memorySection = buildMemoryPromptSection(getRpc, cfg); + const result = memorySection({ + availableTools: new Set(["get_user_card", "list_user_cards", "memory_search"]), + }); + + const resultText = result.join("\n"); + assert.match(resultText, /memory_search` after the card/u); + assert.doesNotMatch(resultText, /Only use memory_search if the card is empty or missing/u); + assert.doesNotMatch(resultText, /If the card is empty, then fall through/u); +}); + test("memory prompt section works with citationsMode", async () => { const rpc = new FakeRpc(); const cfg: PluginConfig = { topK: 8 }; diff --git a/test/unit/memory-recall.test.ts b/test/unit/memory-recall.test.ts index 2bfffb99..88960cb3 100644 --- a/test/unit/memory-recall.test.ts +++ b/test/unit/memory-recall.test.ts @@ -2,7 +2,13 @@ import test from "node:test"; import assert from "node:assert/strict"; import { buildContextEngineFactory, consumeSubagentBudget } from "../../src/context-engine.js"; -import { createMemoryDescribeTool, createMemoryExpandTool, createMemoryGrepTool } from "../../src/tools/memory-recall.js"; +import { + createGetUserCardTool, + createListUserCardsTool, + createMemoryDescribeTool, + createMemoryExpandTool, + createMemoryGrepTool, +} from "../../src/tools/memory-recall.js"; import type { LibravDBClient } from "../../src/libravdb-client.js"; import type { PluginRuntime } from "../../src/plugin-runtime.js"; @@ -14,6 +20,8 @@ const silentLogger = { class FakeRecallClient { public calls: Array<{ method: string; params: Record }> = []; + public listResults: unknown[] = []; + public cards = new Map(); async expandSummary(params: Record) { this.calls.push({ method: "expandSummary", params }); @@ -43,8 +51,152 @@ class FakeRecallClient { }], }; } + + async listByMeta(params: Record) { + this.calls.push({ method: "listByMeta", params }); + return { results: this.listResults }; + } + + async getUserCard(params: Record) { + this.calls.push({ method: "getUserCard", params }); + return this.cards.get(String(params.userId)) ?? { cardJson: "", updatedAt: 0, version: 0 }; + } +} + +function userCardResult(userId: string, card: string, updatedAt = 100, version = 1, source: string | null = "openclaw-user-cards") { + return { + metadataJson: new TextEncoder().encode(JSON.stringify({ + _user_id: userId, + card_json: JSON.stringify(source === null ? { card } : { card, source }), + updated_at: updatedAt, + version, + })), + }; } +test("get_user_card guidance allows memory follow-up for sparse profile cards", () => { + const tool = createGetUserCardTool( + async () => new FakeRecallClient() as unknown as LibravDBClient, + silentLogger, + ); + + assert.match(tool.description, /call memory_search after the card/u); + assert.doesNotMatch(tool.description, /Only fall through to memory_search if the card is empty or missing/u); +}); + +test("get_user_card resolves raw sender IDs to scoped user-card projections", async () => { + const client = new FakeRecallClient(); + client.listResults = [ + userCardResult("discord|guild=g|channel=c|sender=399", "scoped human card", 200, 2, "openclaw-user-cards"), + userCardResult("discord|guild=g|channel=other|sender=399", "older card", 100, 1, "openclaw-user-cards"), + ]; + const tool = createGetUserCardTool( + async () => client as unknown as LibravDBClient, + silentLogger, + ); + + const result = await tool.execute("call-1", { user_id: "399" }); + + assert.deepEqual(result.details, { + card: "scoped human card", + updatedAt: 200, + version: 2, + }); + assert.deepEqual(client.calls.map((call) => call.method), ["getUserCard", "listByMeta"]); +}); + +test("get_user_card resolves visible aliases from card identity fields", async () => { + const client = new FakeRecallClient(); + client.listResults = [ + userCardResult( + "discord|channel=c|sender=1001", + [ + "Stable identity card projected by OpenClaw user-cards.", + "- speaker id: 1001", + "- visible names: ExampleUser-1001", + "Relevant high-signal notes:", + "- not part of identity lookup", + ].join("\n"), + 200, + 2, + "openclaw-user-cards", + ), + ]; + const tool = createGetUserCardTool( + async () => client as unknown as LibravDBClient, + silentLogger, + ); + + const result = await tool.execute("call-1", { user_id: "ExampleUser-1001" }); + + assert.equal(result.details.card?.includes("- visible names: ExampleUser-1001"), true); + assert.equal(result.details.updatedAt, 200); + assert.equal(result.details.version, 2); +}); + +test("get_user_card alias fallback ignores names that appear only in notes", async () => { + const client = new FakeRecallClient(); + client.listResults = [ + userCardResult( + "discord|channel=c|sender=1", + [ + "User card: Other Person", + "Known aliases: Other", + "Relevant high-signal notes:", + "- talked about ExampleUser-1001 once", + ].join("\n"), + 200, + 2, + "openclaw-user-cards", + ), + ]; + const tool = createGetUserCardTool( + async () => client as unknown as LibravDBClient, + silentLogger, + ); + + const result = await tool.execute("call-1", { user_id: "ExampleUser-1001" }); + + assert.deepEqual(result.details, { card: null, updatedAt: undefined, version: undefined }); +}); + +test("get_user_card alias fallback ignores source-less and metadata-only matches", async () => { + const client = new FakeRecallClient(); + client.listResults = [ + userCardResult( + "discord|channel=c|sender=1", + [ + "User card: Source Missing", + "Known aliases: Missing", + ].join("\n"), + 300, + 3, + null, + ), + userCardResult( + "discord|channel=c|sender=2", + [ + "User card: Other Person", + "Known aliases: Other", + "provider: discord", + "channel id: ExampleUser-1001", + ].join("\n"), + 200, + 2, + ), + ]; + const tool = createGetUserCardTool( + async () => client as unknown as LibravDBClient, + silentLogger, + ); + + const missingSource = await tool.execute("call-1", { user_id: "Missing" }); + const metadataOnly = await tool.execute("call-2", { user_id: "ExampleUser-1001" }); + + assert.deepEqual(missingSource.details, { card: null, updatedAt: undefined, version: undefined }); + assert.deepEqual(metadataOnly.details, { card: null, updatedAt: undefined, version: undefined }); +}); + function fakeRuntime(client: FakeRecallClient): PluginRuntime { return { getClient: async () => client as unknown as LibravDBClient, @@ -125,6 +277,31 @@ test("memory_expand explicit session id takes precedence over active session id" }); }); +test("list_user_cards deduplicates daemon index projection variants", async () => { + const client = new FakeRecallClient(); + client.listResults = [ + userCardResult("discord|channel=c|sender=1:256d", "projection 256d"), + userCardResult("discord|channel=c|sender=1", "canonical card"), + userCardResult("discord|channel=c|sender=1:64d", "projection 64d"), + userCardResult("discord|channel=c|sender=2:64d", "only projection"), + userCardResult("discord|channel=c|sender=3", "foreign source", 100, 1, "codex-test"), + ]; + const tool = createListUserCardsTool( + async () => client as unknown as LibravDBClient, + silentLogger, + ); + + const result = await tool.execute("call-1", {}); + + assert.deepEqual(result.details, { + users: [ + { user_id: "discord|channel=c|sender=1", preview: "canonical card", updated_at: 100, version: 1 }, + { user_id: "discord|channel=c|sender=2", preview: "only projection", updated_at: 100, version: 1 }, + ], + total: 2, + }); +}); + test("memory_expand uses remaining subagent budget instead of dropping the first oversized request", async () => { const client = new FakeRecallClient(); const engine = buildContextEngineFactory(fakeRuntime(client), { userId: "u1", subagentTokenBudget: 1000 }, silentLogger); diff --git a/test/unit/memory-tools.test.ts b/test/unit/memory-tools.test.ts index 6e351821..9242d156 100644 --- a/test/unit/memory-tools.test.ts +++ b/test/unit/memory-tools.test.ts @@ -94,6 +94,51 @@ class CorpusPriorityRpc extends FakeRpc { } } +class IdentityRpc extends FakeRpc { + constructor(private readonly subjectName = "ExampleUser-1001") { + super(); + } + + override async searchTextCollections(params: Record) { + this.calls.push({ method: "searchTextCollections", params }); + const encoder = new TextEncoder(); + const subjectName = this.subjectName; + const rows = [ + { + id: "ask-1", + score: 0.99, + text: `[OpenClaw context: sender=CurrentUser; username=current_user; user_id=2001]\n@Assistant who is ${subjectName}?`, + metadataJson: encoder.encode(JSON.stringify({ collection: "user:u1" })), + }, + { + id: "ask-2", + score: 0.98, + text: `[OpenClaw context: sender=CurrentUser; username=current_user; user_id=2001]\n@Assistant please use memory_search for ${subjectName}`, + metadataJson: encoder.encode(JSON.stringify({ collection: "user:u1" })), + }, + { + id: "subject", + score: 0.72, + text: `[OpenClaw context: sender=${subjectName}; username=example_user; user_id=1001]\nThis memory search is semantic.`, + metadataJson: encoder.encode(JSON.stringify({ collection: "user:u1" })), + }, + { + id: "tool-artifact", + score: 0.71, + text: `[tool:get_user_card] {"user_id":"${subjectName}"}`, + metadataJson: encoder.encode(JSON.stringify({ collection: "session:discord-session" })), + }, + { + id: "user-card:discord|channel=c|sender=141", + score: 0.7, + text: `Stable identity card projected by OpenClaw user-cards.\n- speaker id: 1001\n- visible names: ${subjectName}\nRelevant high-signal notes:\n- useful detail`, + metadataJson: encoder.encode(JSON.stringify({ collection: "user:u1" })), + }, + ]; + return { results: rows.slice(0, Number(params.k ?? rows.length)) }; + } +} + test("LibraVDB memory tools expose memory_search and memory_get through the runtime bridge", async () => { const rpc = new FakeRpc(); const cfg: PluginConfig = { userId: "u1", topK: 4 }; @@ -104,6 +149,8 @@ test("LibraVDB memory tools expose memory_search and memory_get through the runt assert.equal(searchTool.name, "memory_search"); assert.equal(getTool.name, "memory_get"); + assert.match(searchTool.description, /call memory_search even when related context is visible/u); + assert.doesNotMatch(searchTool.description, /Do NOT call memory_search if the answer is already visible/u); const search = await searchTool.execute("call-1", { query: "earliest memory", @@ -141,6 +188,53 @@ test("LibraVDB memory tools expose memory_search and memory_get through the runt }); }); +test("LibraVDB memory_search prefers user-card identity hits over prompt echoes", async () => { + const rpc = new IdentityRpc(); + const cfg: PluginConfig = { userId: "u1" }; + const tools = createLibraVdbMemoryTools(async () => rpc as never, cfg, silentLogger); + const searchTool = tools.createSearchTool({ + agentId: "spartacus", + sessionId: "discord-session", + sessionKey: "discord-key", + }); + + const search = await searchTool.execute("call-1", { + query: "ExampleUser-1001", + maxResults: 2, + }); + const details = search.details as { results: Array<{ citation: string; snippet: string }> }; + + assert.equal(rpc.calls[1]?.method, "searchTextCollections"); + assert.equal(rpc.calls[1]?.params.k, 20, "identity lookups should overfetch before reranking"); + assert.equal(details.results.length, 2); + assert.equal(details.results[0]?.citation, "user:u1:user-card:discord|channel=c|sender=141"); + assert.match(details.results[0]?.snippet ?? "", /visible names: ExampleUser-1001/u); + assert.equal(details.results[1]?.citation, "user:u1:subject"); + assert.equal(details.results.some((result) => result.citation === "session:discord-session:tool-artifact"), false); +}); + +test("LibraVDB memory_search overfetches explicit identity filters and matches entity tokens", async () => { + const rpc = new IdentityRpc("SampleName-1001"); + const cfg: PluginConfig = { userId: "u1" }; + const tools = createLibraVdbMemoryTools(async () => rpc as never, cfg, silentLogger); + const searchTool = tools.createSearchTool({ + agentId: "spartacus", + sessionId: "discord-session", + sessionKey: "discord-key-explicit", + }); + + const search = await searchTool.execute("call-1", { + query: "what kind of SampleName-1001", + kind: "identity", + maxResults: 2, + }); + const details = search.details as { results: Array<{ citation: string; snippet: string }> }; + + assert.equal(rpc.calls[1]?.params.k, 20, "kind=identity should overfetch even when the raw query is prose"); + assert.equal(details.results[0]?.citation, "user:u1:user-card:discord|channel=c|sender=141"); + assert.match(details.results[0]?.snippet ?? "", /visible names: SampleName-1001/u); +}); + test("LibraVDB memory_search supports sessions corpus filtering without memory-core", async () => { const rpc = new FakeRpc(); const cfg: PluginConfig = { userId: "u1" };