diff --git a/apps/memos-local-openclaw/index.ts b/apps/memos-local-openclaw/index.ts index d84d94dcd..f91e25e96 100644 --- a/apps/memos-local-openclaw/index.ts +++ b/apps/memos-local-openclaw/index.ts @@ -22,6 +22,7 @@ import { SkillInstaller } from "./src/skill/installer"; import { Summarizer } from "./src/ingest/providers"; import { MEMORY_GUIDE_SKILL_MD } from "./src/skill/bundled-memory-guide"; import { Telemetry } from "./src/telemetry"; +import { executeIntentJudge, resolveAutoRecallMaxResults } from "./src/intent-filter"; /** Remove near-duplicate hits based on summary word overlap (>70%). Keeps first (highest-scored) hit. */ @@ -62,6 +63,26 @@ const pluginConfigSchema = { }, }, }, + intentFilter: { + type: "object" as const, + description: + "Intent filter configuration. Controls LLM timeout and automatic recall behavior for memory search.", + additionalProperties: true, + properties: { + llmTimeoutMs: { + type: "number" as const, + description: + "Timeout in milliseconds for the intent-judging LLM call used by the intent filter (default: implementation-specific).", + minimum: 0, + }, + autoRecallMaxResults: { + type: "number" as const, + description: + "Maximum number of memory results that automatic recall may return when the intent filter triggers recall.", + minimum: 0, + }, + }, + }, }, }; @@ -157,6 +178,7 @@ const memosLocalPlugin = { } const pluginCfg = (api.pluginConfig ?? {}) as Record; + const intentFilterOptions = ((pluginCfg as any).intentFilter ?? {}) as any; const stateDir = api.resolvePath("~/.openclaw"); const ctx = buildContext(stateDir, process.cwd(), pluginCfg as any, { debug: (msg: string) => api.logger.info(`[debug] ${msg}`), @@ -890,7 +912,23 @@ const memosLocalPlugin = { } ctx.log.debug(`auto-recall: query="${query.slice(0, 80)}"`); - const result = await engine.search({ query, maxResults: 20, minScore: 0.45, ownerFilter: recallOwnerFilter }); + const { shouldSearch } = await executeIntentJudge({ + query, + summarizer, + ctx, + store, + recallT0, + performance, + options: intentFilterOptions, + }); + if (!shouldSearch) return; + + const result = await engine.search({ + query, + maxResults: resolveAutoRecallMaxResults(intentFilterOptions), + minScore: 0.45, + ownerFilter: recallOwnerFilter, + }); if (result.hits.length === 0) { ctx.log.debug("auto-recall: no candidates found"); const dur = performance.now() - recallT0; diff --git a/apps/memos-local-openclaw/src/intent-filter.ts b/apps/memos-local-openclaw/src/intent-filter.ts new file mode 100644 index 000000000..d0733733b --- /dev/null +++ b/apps/memos-local-openclaw/src/intent-filter.ts @@ -0,0 +1,307 @@ +/** + * Intent Filter Module + * Determines if a user query requires memory retrieval. + * + * Design Principle: Rather skip than pollute context with irrelevant hits. + */ + +import { + compilePatterns, + matchPatterns, + MEMORY_QUERY_PATTERN_SOURCES, + SKIP_RECALL_PATTERN_SOURCES, +} from "./intent-patterns"; + +const DEFAULT_AUTO_RECALL_MAX_RESULTS = 10; + +export type IntentFilterOptions = { + /** LLM intent judgment timeout (ms) */ + llmTimeoutMs?: number; + /** Fallback strategy on LLM error/timeout */ + onLlmError?: "skip" | "search"; + /** Minimum confidence level to trigger search */ + minConfidenceForSearch?: "high" | "medium"; + /** Maximum LLM output length, exceeds treated as abnormal */ + maxLlmOutputLength?: number; + /** Max results for auto-recall (provided as a config interface for callers) */ + autoRecallMaxResults?: number; +}; + +export const DEFAULT_INTENT_FILTER_OPTIONS: Required< + Pick +> = { + llmTimeoutMs: 2000, + onLlmError: "skip", + minConfidenceForSearch: "high", + maxLlmOutputLength: 400, +}; + +// ====== Pattern Matching Rules ====== + +/** Conversation continuation: No memory/search needed, let LLM reply based on current context */ +const compiledSkipRecallPatterns = compilePatterns(SKIP_RECALL_PATTERN_SOURCES); + +/** Explicit memory retrieval: High confidence memory queries */ +const compiledMemoryQueryPatterns = compilePatterns(MEMORY_QUERY_PATTERN_SOURCES); + +// ====== LLM Prompt ====== + +const intentPromptTemplate = (query: string) => `You are a query intent analyzer. Determine if the following user query requires "Memory Retrieval". + +Judgment Criteria (Strictly Follow): +- "Memory Retrieval (High)": User explicitly refers to specific past conversation details (e.g., "the bug fix we discussed last time", "the pricing strategy mentioned yesterday"). Must contain a specific subject. +- "Memory Retrieval (Medium)": User refers to the past but vaguely (e.g., "last news", "previous issues"). Clear temporal pointer but blurry content. +- "Real-time Search": User wants current data (weather, news, stock prices) or just general search (e.g., "search for news"). +- "Skip": Vague or ambiguous queries (e.g., "optimise this", "what should I do", "continue", single-word replies). + +User Query: "${query}" + +Output Format (Strictly Follow): +Intent: +Confidence: +Reason: `; + +// ====== Type Definitions ====== + +export type IntentResult = { + action: 'skip' | 'search' | 'llm_judge'; + reason: string; + /** LLM prompt (only if action='llm_judge') */ + llmPrompt?: string; +}; + +export type LLMJudgeResult = { + action: 'skip' | 'search'; + reason: string; + /** Raw LLM output (for debugging) */ + raw?: string; +}; + +function normalizeText(text: string): string { + return text + .replace(/\r\n/g, "\n") + .replace(/[:﹕︰]/g, ":") + .trim(); +} + +function pickFieldFromObject(obj: Record, keys: string[]): string { + for (const k of keys) { + const v = obj[k]; + if (typeof v === "string" && v.trim()) return v.trim(); + } + return ""; +} + +function parseJsonLikeIntent(output: string): { intent: string; confidence: string } | null { + // Try full string first + try { + const full = JSON.parse(output) as Record; + return { + intent: pickFieldFromObject(full, ["intent", "意图", "label", "category"]), + confidence: pickFieldFromObject(full, ["confidence", "置信度", "conf"]), + }; + } catch { + // Fallback to JSON snippets (non-greedy) for mixed text output. + } + + for (const m of output.matchAll(/\{[\s\S]*?\}/g)) { + try { + const parsed = JSON.parse(m[0]) as Record; + const intent = pickFieldFromObject(parsed, ["intent", "意图", "label", "category"]); + const confidence = pickFieldFromObject(parsed, ["confidence", "置信度", "conf"]); + if (intent || confidence) return { intent, confidence }; + } catch { + // continue + } + } + return null; +} + +function extractIntentAndConfidence(output: string): { intent: string; confidence: string } { + const jsonParsed = parseJsonLikeIntent(output); + if (jsonParsed) return jsonParsed; + + const intentLine = output.match(/^(?:意图|intent)\s*:\s*(.+)$/im)?.[1]?.trim() ?? ""; + const confidenceLine = output.match(/^(?:置信度|confidence)\s*:\s*(.+)$/im)?.[1]?.trim() ?? ""; + return { intent: intentLine, confidence: confidenceLine }; +} + +function isMemoryIntent(intent: string): boolean { + const v = intent.toLowerCase(); + return ( + intent.includes("记忆检索") || + intent.includes("回忆") || + /memory\s*(retrieval|search|recall)/i.test(v) || + /search\s*memory/i.test(v) + ); +} + +function confidenceRank(confidence: string): number { + const v = confidence.toLowerCase(); + if (confidence.includes("高") || /high/.test(v)) return 3; + if (confidence.includes("中") || /medium|med/.test(v)) return 2; + if (confidence.includes("低") || /low/.test(v)) return 1; + return 0; +} + +function requiredConfidenceRank(level: "high" | "medium"): number { + return level === "high" ? 3 : 2; +} + +// ====== Main Functions ====== + +/** + * Determines if query should be skipped, searched directly, or judged by LLM + * @param query User query + * @returns Result + */ +export function shouldSkipOrSearch(query: string): IntentResult { + const normalizedQuery = query.trim(); + + // 1. Continuation command -> Skip + if (matchPatterns(normalizedQuery, compiledSkipRecallPatterns)) { + return { action: 'skip', reason: 'skip_continue_command' }; + } + + // 2. Explicit memory query -> Search (skip LLM) + if (matchPatterns(normalizedQuery, compiledMemoryQueryPatterns)) { + return { action: 'search', reason: 'explicit_memory_query' }; + } + + // 3. Others -> LLM judge + return { + action: 'llm_judge', + reason: 'needs_llm_judgment', + llmPrompt: intentPromptTemplate(normalizedQuery), + }; +} + +/** + * Parses LLM intent judgment output + * @param llmOutput Raw LLM output + * @param query User query (for logging) + * @returns Judgment result + */ +export function parseLLMIntent(llmOutput: string, query: string, options?: IntentFilterOptions): LLMJudgeResult { + const output = normalizeText(llmOutput ?? ''); + const maxOutputLength = options?.maxLlmOutputLength ?? DEFAULT_INTENT_FILTER_OPTIONS.maxLlmOutputLength; + + // Error tolerance: LLM failed (returns prompt or too long) + const llmFailed = + output.includes('You are a query intent analyzer') || + output.toLowerCase().includes('you are a query intent analyzer') || + output.length > maxOutputLength; + if (llmFailed) { + const fallbackAction = options?.onLlmError ?? 'skip'; + return { action: fallbackAction, reason: 'llm_failed_skipped', raw: output }; + } + + // Parse fields + const { intent, confidence } = extractIntentAndConfidence(output); + const threshold = options?.minConfidenceForSearch ?? DEFAULT_INTENT_FILTER_OPTIONS.minConfidenceForSearch; + const shouldSearchMemory = + isMemoryIntent(intent) && + confidenceRank(confidence) >= requiredConfidenceRank(threshold); + + if (shouldSearchMemory) { + return { action: 'search', reason: `intent=${intent},confidence=${confidence}`, raw: output }; + } + + const reason = intent || confidence + ? `intent=${intent},confidence=${confidence}` + : `intent=unknown,confidence=unknown,query=${query.slice(0, 40)}`; + return { action: 'skip', reason, raw: output }; +} + +/** + * Executes intent judgment logic (used in index.ts) + */ +export async function executeIntentJudge(params: { + query: string; + summarizer: { summarize: (prompt: string) => Promise }; + ctx: { log: { debug: (m: string) => void; info: (m: string) => void; warn: (m: string) => void } }; + store: { + recordToolCall: (name: string, duration: number, success: boolean) => void; + recordApiLog: (name: string, payload: any, result: string, duration: number, success: boolean) => void; + }; + recallT0: number; + performance: { now: () => number }; + options?: IntentFilterOptions; +}): Promise<{ shouldSearch: boolean }> { + const { query, summarizer, ctx, store, recallT0, performance, options } = params; + const policy = { + ...DEFAULT_INTENT_FILTER_OPTIONS, + ...(options ?? {}), + }; + const timerApi = globalThis as any; + + const intentCheck = shouldSkipOrSearch(query); + + // 1. Direct Skip + if (intentCheck.action === 'skip') { + ctx.log.debug(`auto-recall: skipped query "${query}" reason=${intentCheck.reason}`); + const dur = performance.now() - recallT0; + store.recordApiLog("auto_recall_intent_skip", { type: "auto_recall", query, reason: intentCheck.reason }, "skipped", dur, true); + return { shouldSearch: false }; + } + + // 2. Explicit memory query -> Search + if (intentCheck.action === 'search') { + ctx.log.debug(`auto-recall: explicit memory query "${query}"`); + return { shouldSearch: true }; + } + + // 3. Others -> LLM Judge + try { + const timeoutError = new Error("intent_judge_timeout"); + let tid: any; + const timeoutPromise = new Promise((_, reject) => { + tid = timerApi.setTimeout(() => reject(timeoutError), policy.llmTimeoutMs); + }); + + const intentResult = await Promise.race([ + summarizer.summarize(intentCheck.llmPrompt!), + timeoutPromise, + ]).finally(() => { + if (tid !== undefined) timerApi.clearTimeout(tid); + }); + + ctx.log.debug(`auto-recall: LLM intent result="${intentResult}"`); + + const parsed = parseLLMIntent(intentResult ?? '', query, policy); + + if (parsed.action === 'skip') { + if (parsed.reason === 'llm_failed_skipped') { + ctx.log.warn(`auto-recall: LLM call failed, skipping memory retrieval by default (fallback policy)`); + } else { + ctx.log.info(`auto-recall: skipped query "${query.slice(0, 50)}" reason=${parsed.reason}`); + } + const dur = performance.now() - recallT0; + store.recordApiLog("auto_recall_intent_skip", { type: "auto_recall", query, reason: parsed.reason }, "skipped", dur, true); + return { shouldSearch: false }; + } + + return { shouldSearch: true }; + } catch (intentErr) { + if (policy.onLlmError === "search") { + ctx.log.warn(`auto-recall: LLM intent judgment failed, proceeding with retrieval (config policy): ${intentErr}`); + return { shouldSearch: true }; + } + ctx.log.warn(`auto-recall: LLM intent judgment failed: ${intentErr}`); + const dur = performance.now() - recallT0; + store.recordApiLog("auto_recall_intent_skip", { type: "auto_recall", query, reason: "llm_error_skipped" }, "skipped", dur, true); + return { shouldSearch: false }; + } +} + +/** + * Resolves auto-recall max results configuration + */ +export function resolveAutoRecallMaxResults(options?: IntentFilterOptions): number { + const raw = options?.autoRecallMaxResults; + if (typeof raw !== "number" || !Number.isFinite(raw)) return DEFAULT_AUTO_RECALL_MAX_RESULTS; + const n = Math.floor(raw); + if (n < 1) return 1; + if (n > 20) return 20; + return n; +} diff --git a/apps/memos-local-openclaw/src/intent-patterns.ts b/apps/memos-local-openclaw/src/intent-patterns.ts new file mode 100644 index 000000000..2ab5df1e9 --- /dev/null +++ b/apps/memos-local-openclaw/src/intent-patterns.ts @@ -0,0 +1,102 @@ +export interface RegexPatternSource { + pattern: string; + flags?: string; +} + +export interface CompiledRegexPattern { + regex: RegExp; +} + +/** + * Skip memory recall patterns + */ +export const SKIP_RECALL_PATTERN_SOURCES: RegexPatternSource[] = [ + // continuation + { + pattern: "^(继续|接着|然后|下一步|continue|next|go\\s+on)$", + flags: "i", + }, + + // acknowledgement + { + pattern: "^(好(的)?|行|嗯+|啊+|哦+|ok(ay)?|sure|got\\s+it|收到|明白了)$", + flags: "i", + }, + + // session start events - should never trigger memory search + { + pattern: "(new session|Session Startup|greet the user|read the required files)", + flags: "i", + }, + + // slash commands - should never trigger memory search + { + pattern: "/(new|reset|status|reasoning|model|help|clear|undo|continue)(\\s|$)", + flags: "i", + }, + // real-time information queries + { + pattern: + "(今|刚|最新|最近|新闻|消息|资讯|情况|发生|动态|网|浏览)", + }, + + // english realtime queries + { + pattern: "^(latest|today|recent).{0,12}(news|updates|events)", + flags: "i", + }, +]; + +/** + * Explicit memory recall patterns + */ +export const MEMORY_QUERY_PATTERN_SOURCES: RegexPatternSource[] = [ + // reference to past conversation + { + pattern: "(上次|之前|以前|刚才|刚刚).{0,10}(说|讲|问|聊|讨论|提到)", + }, + + // asking if assistant remembers + { + pattern: "(还记得|记得吗|do\\s+you\\s+remember)", + flags: "i", + }, + + // explicit history search + { + pattern: "(聊天|对话|历史).{0,6}(记录|内容)?.{0,6}(查|找|看|搜索|查询)", + }, + + // english past reference + { + pattern: "(last\\s+time|previously|earlier|before\\s+we)", + flags: "i", + }, + + // implicit reference (那个代码 / 刚才那个函数) + { + pattern: + "(那个|刚才那个|刚刚那个).{0,8}(代码|函数|问题|回答|内容)", + }, +]; + +/** + * compile regex patterns + */ +export function compilePatterns( + sources: RegexPatternSource[] +): CompiledRegexPattern[] { + return sources.map((s) => ({ + regex: new RegExp(s.pattern, s.flags ?? ""), + })); +} + +/** + * test input against compiled patterns + */ +export function matchPatterns( + input: string, + patterns: CompiledRegexPattern[] +): boolean { + return patterns.some((p) => p.regex.test(input)); +} diff --git a/apps/memos-local-openclaw/tests/intent-filter.test.ts b/apps/memos-local-openclaw/tests/intent-filter.test.ts new file mode 100644 index 000000000..559e22331 --- /dev/null +++ b/apps/memos-local-openclaw/tests/intent-filter.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect } from "vitest"; +import { + shouldSkipOrSearch, + parseLLMIntent, + executeIntentJudge, + resolveAutoRecallMaxResults, +} from "../src/intent-filter"; + +describe("intent-filter: shouldSkipOrSearch", () => { + it("should skip simple continue command (zh)", () => { + expect(shouldSkipOrSearch("继续").action).toBe("skip"); + }); + + it("should skip continue command with surrounding spaces", () => { + expect(shouldSkipOrSearch(" continue ").action).toBe("skip"); + }); + + it("should skip simple continue command (en)", () => { + expect(shouldSkipOrSearch("continue").action).toBe("skip"); + }); + + it("should search for explicit memory query (en)", () => { + expect(shouldSkipOrSearch("Do you remember what we discussed last time about pricing?").action).toBe("search"); + }); +}); + +describe("intent-filter: parseLLMIntent", () => { + it("parses Chinese label output", () => { + const out = `意图: 记忆检索\n置信度: 高\n原因: 明确提到过去对话`; + const parsed = parseLLMIntent(out, "explicit search (zh)"); + expect(parsed.action).toBe("search"); + }); + + it("parses English label output", () => { + const out = `Intent: Memory Retrieval\nConfidence: High\nReason: asks about previous discussion`; + const parsed = parseLLMIntent(out, "last discussion"); + expect(parsed.action).toBe("search"); + }); + + it("parses JSON output", () => { + const out = JSON.stringify({ intent: "Memory Retrieval", confidence: "High", reason: "explicit past reference" }); + const parsed = parseLLMIntent(out, "previous topic"); + expect(parsed.action).toBe("search"); + }); + + it("parses JSON snippet from mixed text", () => { + const out = `Some preface text\n{"intent":"Memory Retrieval","confidence":"High"}\nSome suffix text`; + const parsed = parseLLMIntent(out, "previous topic"); + expect(parsed.action).toBe("search"); + }); + + it("supports medium threshold by option", () => { + const out = `Intent: Memory Retrieval\nConfidence: medium\nReason: mentions previous issue`; + const parsedDefault = parseLLMIntent(out, "previous issue"); + const parsedMedium = parseLLMIntent(out, "previous issue", { minConfidenceForSearch: "medium" }); + expect(parsedDefault.action).toBe("skip"); + expect(parsedMedium.action).toBe("search"); + }); +}); + +describe("intent-filter: executeIntentJudge", () => { + it("returns shouldSearch=false when LLM fails and fallback=skip", async () => { + const summarizer = { + summarize: async () => { + throw new Error("network"); + }, + }; + const logs: string[] = []; + const ctx = { + log: { + debug: (_m: string) => {}, + info: (_m: string) => {}, + warn: (m: string) => logs.push(m), + }, + }; + const store = { + recordToolCall: (_name: string, _dur: number, _success: boolean) => {}, + recordApiLog: (_name: string, _payload: unknown, _result: string, _dur: number, _success: boolean) => {}, + }; + const perf = { now: () => 1000 }; + + const result = await executeIntentJudge({ + query: "optimise this", + summarizer, + ctx, + store, + recallT0: 900, + performance: perf, + options: { onLlmError: "skip" }, + }); + + expect(result.shouldSearch).toBe(false); + expect(logs.length).toBeGreaterThan(0); + }); + + it("returns shouldSearch=true when LLM fails and fallback=search", async () => { + const summarizer = { + summarize: async () => { + throw new Error("timeout"); + }, + }; + const ctx = { + log: { + debug: (_m: string) => {}, + info: (_m: string) => {}, + warn: (_m: string) => {}, + }, + }; + const store = { + recordToolCall: (_name: string, _dur: number, _success: boolean) => {}, + recordApiLog: (_name: string, _payload: unknown, _result: string, _dur: number, _success: boolean) => {}, + }; + const perf = { now: () => 1000 }; + + const result = await executeIntentJudge({ + query: "optimise this", + summarizer, + ctx, + store, + recallT0: 900, + performance: perf, + options: { onLlmError: "search" }, + }); + + expect(result.shouldSearch).toBe(true); + }); + + it("returns shouldSearch=false on timeout when fallback=skip", async () => { + const summarizer = { + summarize: async () => await new Promise(() => {}), + }; + const ctx = { + log: { + debug: (_m: string) => {}, + info: (_m: string) => {}, + warn: (_m: string) => {}, + }, + }; + const store = { + recordToolCall: (_name: string, _dur: number, _success: boolean) => {}, + recordApiLog: (_name: string, _payload: unknown, _result: string, _dur: number, _success: boolean) => {}, + }; + const perf = { now: () => 1000 }; + + const result = await executeIntentJudge({ + query: "please optimize this", + summarizer, + ctx, + store, + recallT0: 900, + performance: perf, + options: { llmTimeoutMs: 1, onLlmError: "skip" }, + }); + + expect(result.shouldSearch).toBe(false); + }); +}); + +describe("intent-filter: resolveAutoRecallMaxResults", () => { + it("clamps range to 1..20", () => { + expect(resolveAutoRecallMaxResults({ autoRecallMaxResults: 0 })).toBe(1); + expect(resolveAutoRecallMaxResults({ autoRecallMaxResults: 99 })).toBe(20); + expect(resolveAutoRecallMaxResults({ autoRecallMaxResults: 12 })).toBe(12); + }); + + it("uses default on invalid input", () => { + expect(resolveAutoRecallMaxResults({ autoRecallMaxResults: Number.NaN })).toBe(10); + expect(resolveAutoRecallMaxResults()).toBe(10); + }); +});