diff --git a/integrations/openclaw/index.ts b/integrations/openclaw/index.ts index 3aa2a3314..58f59b6f9 100644 --- a/integrations/openclaw/index.ts +++ b/integrations/openclaw/index.ts @@ -42,7 +42,7 @@ import { isSubagentSession, } from "./isolation.ts"; import { - loadTriagePrompt, + loadCompactTriagePrompt, loadDreamPrompt, isSkillsMode, } from "./skill-loader.ts"; @@ -464,7 +464,7 @@ function registerHooks( "openclaw-mem0: skills-mode skipping recall for system/bootstrap prompt", ); // Still inject the protocol, just skip recall search - const systemContext = loadTriagePrompt(cfg.skills ?? {}); + const systemContext = loadCompactTriagePrompt(cfg.skills ?? {}); return { prependSystemContext: systemContext }; } @@ -474,7 +474,7 @@ function registerHooks( const userId = _effectiveUserId(isSubagent ? undefined : sessionId); // Static protocol goes in prependSystemContext (cacheable across turns) - let systemContext = loadTriagePrompt(cfg.skills ?? {}); + let systemContext = loadCompactTriagePrompt(cfg.skills ?? {}); if (isSubagent) { systemContext = "You are a subagent — use these memories for context but do not assume you are this user. Do NOT store new memories.\n\n" + diff --git a/integrations/openclaw/skill-loader.test.ts b/integrations/openclaw/skill-loader.test.ts index ab2125e54..ceb970884 100644 --- a/integrations/openclaw/skill-loader.test.ts +++ b/integrations/openclaw/skill-loader.test.ts @@ -2,7 +2,12 @@ * Tests for path traversal prevention in skill-loader. */ import { describe, it, expect } from "vitest"; -import { safePath, loadSkill } from "./skill-loader.ts"; +import { + safePath, + loadSkill, + loadTriagePrompt, + loadCompactTriagePrompt, +} from "./skill-loader.ts"; // --------------------------------------------------------------------------- // safePath — path containment @@ -68,3 +73,71 @@ describe("loadSkill path traversal", () => { expect(result?.prompt).toBeTruthy(); }); }); + +describe("loadCompactTriagePrompt", () => { + it("keeps the core triage instructions without inlining the full skill body", () => { + const prompt = loadCompactTriagePrompt(); + + expect(prompt).toContain("Use `memory_add` tool for ALL user facts"); + expect(prompt).toContain("Batch facts by CATEGORY"); + expect(prompt).toContain("ALWAYS rewrite the query"); + expect(prompt).not.toContain("## Worked Examples"); + expect(prompt).not.toContain("### memory_search"); + expect(prompt).not.toContain("Conference requires at least 4 breakout rooms"); + expect(prompt.length).toBeLessThan(3500); + }); + + it("keeps the always-recall guidance aligned with the full triage prompt", () => { + const config = { recall: { strategy: "always" as const } }; + const expected = + "Automatic recall runs for both long-term and session memory. Use manual searches only when you need more specific context."; + + expect(loadCompactTriagePrompt(config)).toContain(expected); + expect(loadTriagePrompt(config)).toContain(expected); + }); + + it("keeps the manual-recall guidance in compact mode", () => { + const prompt = loadCompactTriagePrompt({ + recall: { strategy: "manual" }, + }); + + expect(prompt).toContain("No automatic recall happens in manual mode."); + }); + + it("includes short config summaries and truncates oversized custom rules", () => { + const prompt = loadCompactTriagePrompt({ + categories: { + travel: { importance: 0.7, ttl: "30d" }, + }, + customRules: { + include: [ + "Always remember workshop venue requirements.", + "Keep track of recurring conference planning constraints.", + "Capture every catering preference, transit note, and presenter dependency in detail.", + ], + exclude: [ + "Never store one-off demo logs, temporary ETA chatter, or transitory checklist updates.", + "Skip verbose retrospectives unless the user explicitly says the lesson should persist.", + ], + }, + }); + + expect(prompt).toContain("travel (importance 0.7, expires: 30d)"); + expect(prompt).toContain("include rule(s) and 2 exclude rule(s) are configured"); + expect(prompt).toContain("Prompt kept compact, full rule text omitted"); + expect(prompt).toContain('Preview: include "Always remember workshop venue requirements."'); + }); + + it("omits default credential patterns in compact mode unless custom patterns are configured", () => { + const defaultPrompt = loadCompactTriagePrompt(); + const customPrompt = loadCompactTriagePrompt({ + triage: { + credentialPatterns: ["mem0-secret=", "Bearer "], + }, + }); + + expect(defaultPrompt).not.toContain("Credential patterns to scan"); + expect(customPrompt).toContain("Credential patterns to scan"); + expect(customPrompt).toContain("mem0-secret="); + }); +}); diff --git a/integrations/openclaw/skill-loader.ts b/integrations/openclaw/skill-loader.ts index 616194266..b4958cb09 100644 --- a/integrations/openclaw/skill-loader.ts +++ b/integrations/openclaw/skill-loader.ts @@ -191,8 +191,16 @@ function renderCategoriesBlock( return lines.join("\n"); } -function renderTriageKnobs(config: SkillsConfig): string { +function renderTriageKnobs( + config: SkillsConfig, + options: { + includeCredentialPatterns?: boolean; + includeDefaultCredentialPatterns?: boolean; + } = {}, +): string { const lines: string[] = []; + const includeCredentialPatterns = options.includeCredentialPatterns ?? true; + const includeDefaultCredentialPatterns = options.includeDefaultCredentialPatterns ?? true; if (config.triage?.importanceThreshold !== undefined) { lines.push( @@ -200,13 +208,82 @@ function renderTriageKnobs(config: SkillsConfig): string { ); } - const patterns = resolveCredentialPatterns(config); - lines.push(`- Credential patterns to scan: ${patterns.map((p) => `\`${p}\``).join(", ")}`); + const hasCustomCredentialPatterns = config.triage?.credentialPatterns !== undefined; + if (includeCredentialPatterns && (includeDefaultCredentialPatterns || hasCustomCredentialPatterns)) { + const patterns = resolveCredentialPatterns(config); + lines.push(`- Credential patterns to scan: ${patterns.map((p) => `\`${p}\``).join(", ")}`); + } if (lines.length === 0) return ""; return "\n## Active Configuration Overrides\n\n" + lines.join("\n"); } +const COMPACT_CUSTOM_RULE_CHAR_BUDGET = 280; +const COMPACT_CUSTOM_RULE_PREVIEW_LIMIT = 2; + +function formatCategoryConfig( + name: string, + cat: CategoryConfig, +): string { + const ttlLabel = cat.ttl ? `expires: ${cat.ttl}` : "permanent"; + const immLabel = cat.immutable ? ", immutable" : ""; + return `${name} (importance ${cat.importance}, ${ttlLabel}${immLabel})`; +} + +function renderCompactCategories(config: SkillsConfig): string[] { + if (!config.categories || Object.keys(config.categories).length === 0) { + return []; + } + + const mergedCategories = resolveCategories(config); + return [ + "## Active Category Overrides", + "", + ...Object.entries(config.categories).map(([name]) => + `- ${formatCategoryConfig(name, mergedCategories[name]!)}`, + ), + ]; +} + +function renderCompactCustomRules(config: SkillsConfig): string[] { + const includeRules = config.customRules?.include ?? []; + const excludeRules = config.customRules?.exclude ?? []; + if (includeRules.length === 0 && excludeRules.length === 0) { + return []; + } + + const formatRuleList = (label: string, rules: string[]) => + `${label}: ${rules.map((rule) => `"${rule}"`).join("; ")}`; + + const lines: string[] = []; + if (includeRules.length > 0) { + lines.push(formatRuleList("Include", includeRules)); + } + if (excludeRules.length > 0) { + lines.push(formatRuleList("Exclude", excludeRules)); + } + + const combined = lines.join(" "); + if (combined.length <= COMPACT_CUSTOM_RULE_CHAR_BUDGET) { + return ["## Active Custom Rules", "", ...lines.map((line) => `- ${line}`)]; + } + + const preview = [ + ...includeRules.slice(0, COMPACT_CUSTOM_RULE_PREVIEW_LIMIT).map((rule) => `include "${rule}"`), + ...excludeRules.slice(0, COMPACT_CUSTOM_RULE_PREVIEW_LIMIT).map((rule) => `exclude "${rule}"`), + ]; + + return [ + "## Active Custom Rules", + "", + `- ${includeRules.length} include rule(s) and ${excludeRules.length} exclude rule(s) are configured.`, + `- Prompt kept compact, full rule text omitted because it exceeded ${COMPACT_CUSTOM_RULE_CHAR_BUDGET} characters.`, + ...(preview.length > 0 + ? [`- Preview: ${preview.join("; ")}`] + : []), + ]; +} + // ============================================================================ // TTL Helpers // ============================================================================ @@ -358,6 +435,11 @@ export function loadTriagePrompt(config: SkillsConfig = {}): string { "- Before updating a memory, search to find the existing version.", ); parts.push(""); + } else if (strategy === "always") { + parts.push( + "Automatic recall runs for both long-term and session memory. Use manual searches only when you need more specific context.", + ); + parts.push(""); } parts.push( @@ -453,6 +535,122 @@ export function loadTriagePrompt(config: SkillsConfig = {}): string { return parts.join("\n"); } +/** + * Build a compact memory system prompt for skills mode turns. + * + * This keeps the required storage and search protocol, plus short config + * summaries, without inlining the full memory-triage skill body every turn. + * If the skill file cannot be read, delegate to loadTriagePrompt(), which has + * its own minimal inline fallback. + */ +export function loadCompactTriagePrompt(config: SkillsConfig = {}): string { + if (!readSkillFile("memory-triage")) { + return loadTriagePrompt(config); + } + + const parts: string[] = []; + parts.push(""); + parts.push( + "IMPORTANT: Use `memory_add` tool for ALL user facts. NEVER write user info to workspace files (USER.md, memory/).", + ); + parts.push( + "After every response, evaluate whether a new agent would benefit from remembering this days later. Most turns should produce zero memory operations.", + ); + parts.push( + "Only store durable, self-contained, third-person facts: identity, preferences with rationale, standing rules, decisions, projects, configurations, technical context, and relationships.", + ); + parts.push( + "Never store credentials, tokens, webhook secrets, or raw tool output. If a secret was configured, store only that the credential was configured.", + ); + parts.push( + "When a recalled fact materially changes, search for the existing memory and update it in place. Skip cosmetic rewording.", + ); + parts.push(""); + parts.push("## Tool Usage"); + parts.push(""); + parts.push( + "Batch facts by CATEGORY. All facts in one memory_add call must share the same category because category determines retention policy (TTL, immutability). If a turn has facts in different categories, make one call per category.", + ); + parts.push( + 'Format: memory_add(facts: ["User is Alex, backend engineer at Stripe, PST timezone"], category: "identity")', + ); + parts.push( + 'Mixed categories: memory_add(..., category: "identity") and memory_add(..., category: "decision") in separate calls.', + ); + parts.push( + "Categories: identity, configuration, rule, preference, decision, technical, relationship, project.", + ); + + const categoryLines = renderCompactCategories(config); + if (categoryLines.length > 0) { + parts.push(""); + parts.push(...categoryLines); + } + + const knobLines = renderTriageKnobs(config, { + includeDefaultCredentialPatterns: false, + }) + .split("\n") + .filter(Boolean); + if (knobLines.length > 0) { + parts.push(""); + parts.push(...knobLines); + } + + const customRuleLines = renderCompactCustomRules(config); + if (customRuleLines.length > 0) { + parts.push(""); + parts.push(...customRuleLines); + } + + if (config.recall?.enabled !== false) { + const strategy = config.recall?.strategy ?? "smart"; + parts.push(""); + parts.push("## Searching Memory"); + parts.push(""); + + if (strategy === "manual") { + parts.push( + "No automatic recall happens in manual mode. Use memory_search proactively at conversation start, when context is missing, when topics shift, and before updating a memory.", + ); + } else if (strategy === "always") { + parts.push( + "Automatic recall runs for both long-term and session memory. Use manual searches only when you need more specific context.", + ); + } else { + parts.push( + "Automatic recall runs for long-term memory. Use manual searches when the injected context is not enough.", + ); + } + + parts.push( + "When calling memory_search, ALWAYS rewrite the query. NEVER pass the user's raw message.", + ); + parts.push( + "Convert the request into 3-6 factual keywords that match stored memory language: user, decided, prefers, rule, configured, based in, plus the concrete nouns and names from the request.", + ); + parts.push( + 'WRONG: memory_search("Who was that nutritionist my wife recommended?")', + ); + parts.push( + 'RIGHT: memory_search("nutritionist wife recommended relationship")', + ); + parts.push('WRONG: memory_search("What timezone am I in?")'); + parts.push('RIGHT: memory_search("user timezone location based")'); + // Intentionally omitted from the compact path: ENTITY SCOPING and SEARCH SCOPE. + // loadTriagePrompt() keeps the full explanatory sections for the non-compact path. + parts.push( + 'Scope: use "long-term" for durable user context, "session" for this conversation, and "all" only when you truly need both.', + ); + parts.push( + "When the request implies a time range or category, add filters or categories instead of broadening the query text.", + ); + } + + parts.push(""); + return parts.join("\n"); +} + /** * Load the dream skill prompt for consolidation sessions. */