Files
wehub-resource-sync 555e282cc4
pi-agent-plugin checks / lint (push) Has been cancelled
pi-agent-plugin checks / test (20) (push) Has been cancelled
pi-agent-plugin checks / test (22) (push) Has been cancelled
pi-agent-plugin checks / build (push) Has been cancelled
TypeScript SDK CI / check_changes (push) Has been cancelled
TypeScript SDK CI / changelog_check (push) Has been cancelled
ci / changelog_check (push) Has been cancelled
ci / check_changes (push) Has been cancelled
ci / build_mem0 (3.10) (push) Has been cancelled
ci / build_mem0 (3.11) (push) Has been cancelled
ci / build_mem0 (3.12) (push) Has been cancelled
CLI Node CI / lint (push) Has been cancelled
CLI Node CI / test (20) (push) Has been cancelled
CLI Node CI / test (22) (push) Has been cancelled
CLI Node CI / build (push) Has been cancelled
CLI Python CI / lint (push) Has been cancelled
CLI Python CI / test (3.10) (push) Has been cancelled
CLI Python CI / test (3.11) (push) Has been cancelled
CLI Python CI / test (3.12) (push) Has been cancelled
CLI Python CI / build (push) Has been cancelled
openclaw checks / lint (push) Has been cancelled
openclaw checks / test (20) (push) Has been cancelled
openclaw checks / test (22) (push) Has been cancelled
openclaw checks / build (push) Has been cancelled
opencode-plugin checks / build (push) Has been cancelled
TypeScript SDK CI / build_ts_sdk (20) (push) Has been cancelled
TypeScript SDK CI / build_ts_sdk (22) (push) Has been cancelled
TypeScript SDK CI / integration_ts_sdk (20) (push) Has been cancelled
TypeScript SDK CI / integration_ts_sdk (22) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:45 +08:00

313 lines
9.1 KiB
TypeScript

/**
* Token-budgeted, category-ranked recall engine.
*
* Replaces naive "dump all search results" with:
* 1. Search memories with threshold filtering
* 2. Rank by category priority (identity first)
* 3. Token-budget the results
* 4. Format by category with importance scores
*/
import type {
Mem0Provider,
MemoryItem,
SkillsConfig,
SearchOptions,
} from "./types.ts";
// ============================================================================
// Defaults
// ============================================================================
const DEFAULT_TOKEN_BUDGET = 1500;
const DEFAULT_MAX_MEMORIES = 15;
const DEFAULT_THRESHOLD = 0.4;
const DEFAULT_CATEGORY_ORDER = [
"identity",
"configuration",
"rule",
"preference",
"decision",
"technical",
"relationship",
"project",
"operational",
];
// Rough token estimate: ~4 chars per token for English text
const CHARS_PER_TOKEN = 4;
// ============================================================================
// Types
// ============================================================================
interface RecallResult {
/** Formatted context string for injection */
context: string;
/** Raw memories retrieved */
memories: MemoryItem[];
/** Token count estimate */
tokenEstimate: number;
}
// ============================================================================
// Category Detection
// ============================================================================
function getMemoryCategory(memory: MemoryItem): string {
// Check metadata first (skill-stored memories have explicit category)
if (
memory.metadata?.category &&
typeof memory.metadata.category === "string"
) {
return memory.metadata.category;
}
// Check categories array (mem0-extracted memories)
if (memory.categories?.length) {
return memory.categories[0];
}
return "uncategorized";
}
function getMemoryImportance(memory: MemoryItem): number {
if (
memory.metadata?.importance &&
typeof memory.metadata.importance === "number"
) {
return memory.metadata.importance;
}
// Default importance by category
const cat = getMemoryCategory(memory);
const defaults: Record<string, number> = {
identity: 0.95,
configuration: 0.95,
rule: 0.9,
preference: 0.85,
decision: 0.8,
technical: 0.8,
relationship: 0.75,
project: 0.75,
operational: 0.6,
};
return defaults[cat] ?? 0.5;
}
// ============================================================================
// Token Estimation
// ============================================================================
function estimateTokens(text: string): number {
return Math.ceil(text.length / CHARS_PER_TOKEN);
}
// ============================================================================
// Memory Ranking
// ============================================================================
function rankMemories(
memories: MemoryItem[],
categoryOrder: string[],
): MemoryItem[] {
const orderMap = new Map(categoryOrder.map((cat, i) => [cat, i]));
return [...memories].sort((a, b) => {
const catA = getMemoryCategory(a);
const catB = getMemoryCategory(b);
const orderA = orderMap.get(catA) ?? 999;
const orderB = orderMap.get(catB) ?? 999;
// Primary sort: category priority
if (orderA !== orderB) return orderA - orderB;
// Secondary sort: importance (higher first)
const impA = getMemoryImportance(a);
const impB = getMemoryImportance(b);
if (impA !== impB) return impB - impA;
// Tertiary sort: search relevance score
return (b.score ?? 0) - (a.score ?? 0);
});
}
// ============================================================================
// Token Budgeting
// ============================================================================
function budgetMemories(
rankedMemories: MemoryItem[],
tokenBudget: number,
maxMemories: number,
identityAlwaysInclude: boolean,
): MemoryItem[] {
const selected: MemoryItem[] = [];
let usedTokens = 0;
for (const memory of rankedMemories) {
if (selected.length >= maxMemories) break;
const memTokens = estimateTokens(memory.memory);
const isIdentity =
getMemoryCategory(memory) === "identity" ||
getMemoryCategory(memory) === "configuration";
// Identity/config always included if flag is set
if (identityAlwaysInclude && isIdentity) {
selected.push(memory);
usedTokens += memTokens;
continue;
}
// Budget check for non-identity memories
if (usedTokens + memTokens > tokenBudget) continue;
selected.push(memory);
usedTokens += memTokens;
}
return selected;
}
// ============================================================================
// Formatting
// ============================================================================
function formatRecalledMemories(
memories: MemoryItem[],
userId: string,
): string {
if (memories.length === 0) {
return `<recalled-memories>\nNo stored memories found for "${userId}".\n</recalled-memories>`;
}
// Group by category
const grouped = new Map<string, MemoryItem[]>();
for (const mem of memories) {
const cat = getMemoryCategory(mem);
const existing = grouped.get(cat) || [];
existing.push(mem);
grouped.set(cat, existing);
}
const lines: string[] = [
`<recalled-memories>`,
`Stored memories for "${userId}" (${memories.length} total, ranked by importance):`,
"",
];
// Format each category group
for (const [category, mems] of grouped.entries()) {
const label = category.charAt(0).toUpperCase() + category.slice(1);
lines.push(`${label}:`);
for (const mem of mems) {
const imp = getMemoryImportance(mem);
const cats = mem.categories?.length
? ` [${mem.categories.join(", ")}]`
: "";
lines.push(`- ${mem.memory}${cats} (${Math.round(imp * 100)}%)`);
}
lines.push("");
}
lines.push("</recalled-memories>");
return lines.join("\n");
}
// ============================================================================
// Query Sanitization
// ============================================================================
/**
* Strip OpenClaw metadata prefix from event.prompt before using as search query.
* This only removes framework noise (sender metadata, timestamps) — NOT
* conversational rewriting. Query rewriting is the agent's responsibility
* via the skill protocol (the agent formulates search queries with context).
*/
export function sanitizeQuery(raw: string): string {
let cleaned = raw.replace(
/Sender\s*\(untrusted metadata\):\s*```json[\s\S]*?```\s*/gi,
"",
);
cleaned = cleaned.replace(/^\[.*?\]\s*/g, "");
cleaned = cleaned.trim();
return cleaned || raw;
}
// ============================================================================
// Public API
// ============================================================================
/**
* Perform token-budgeted, category-ranked recall.
*/
export async function recall(
provider: Mem0Provider,
query: string,
userId: string,
config: SkillsConfig = {},
sessionId?: string,
): Promise<RecallResult> {
const recallConfig = config.recall ?? {};
const tokenBudget = recallConfig.tokenBudget ?? DEFAULT_TOKEN_BUDGET;
const maxMemories = recallConfig.maxMemories ?? DEFAULT_MAX_MEMORIES;
const threshold = recallConfig.threshold ?? DEFAULT_THRESHOLD;
const categoryOrder = recallConfig.categoryOrder ?? DEFAULT_CATEGORY_ORDER;
const identityAlwaysInclude = recallConfig.identityAlwaysInclude !== false;
// Build search options (v3.0.0: keyword_search, reranking, filter_memories removed)
const searchOpts: SearchOptions = {
user_id: userId,
top_k: maxMemories * 2, // Over-fetch for ranking
threshold,
source: "OPENCLAW",
};
// Sanitize query: strip OpenClaw metadata prefix before searching
const cleanQuery = sanitizeQuery(query);
// Search long-term memories
let longTermMemories: MemoryItem[] = [];
try {
longTermMemories = await provider.search(cleanQuery, searchOpts);
} catch (err) {
// Graceful degradation — recall failure shouldn't block the agent
console.warn(
"[mem0] Recall search failed:",
err instanceof Error ? err.message : err,
);
}
// Search session memories if we have a session
let sessionMemories: MemoryItem[] = [];
if (sessionId) {
try {
sessionMemories = await provider.search(cleanQuery, {
...searchOpts,
run_id: sessionId,
top_k: 5,
});
} catch {
// Session search failure is non-critical
}
}
// Deduplicate: session memories that are also in long-term
const longTermIds = new Set(longTermMemories.map((m) => m.id));
const uniqueSession = sessionMemories.filter((m) => !longTermIds.has(m.id));
// Combine and rank
const allMemories = [...longTermMemories, ...uniqueSession];
const ranked = rankMemories(allMemories, categoryOrder);
const budgeted = budgetMemories(
ranked,
tokenBudget,
maxMemories,
identityAlwaysInclude,
);
// Format for injection
const context = formatRecalledMemories(budgeted, userId);
const tokenEstimate = estimateTokens(context);
return { context, memories: budgeted, tokenEstimate };
}