From a72ac67947adab7377dd3f7bccc5887ea775baca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=91=E5=AD=9F?= Date: Thu, 9 Jul 2026 05:59:23 +0800 Subject: [PATCH] fix(memory): normalize OpenClaw content blocks + BM25 OR fallback + audit_log sanitization --- .../agent-memory/openclaw/src/index.ts | 26 ++++++-- src/agent-memory/src/index/store.rs | 60 +++++++++++++++++++ src/agent-memory/src/tools/memory_search.rs | 8 +-- 3 files changed, 85 insertions(+), 9 deletions(-) diff --git a/src/agent-memory/adapters/agent-memory/openclaw/src/index.ts b/src/agent-memory/adapters/agent-memory/openclaw/src/index.ts index 5a8eec36f..8c86bd8e0 100644 --- a/src/agent-memory/adapters/agent-memory/openclaw/src/index.ts +++ b/src/agent-memory/adapters/agent-memory/openclaw/src/index.ts @@ -333,11 +333,11 @@ export default definePluginEntry({ api.on( "agent_end", async ( - event: { messages?: Array<{ role: string; content: string }> }, + event: { messages?: unknown[] }, _ctx: Record, ) => { try { - const messages = event.messages; + const messages = event.messages as Array<{ role: string; content: unknown }>; if (!messages || messages.length === 0) return; // Find the last assistant message. @@ -346,9 +346,25 @@ export default definePluginEntry({ .find((m) => m.role === "assistant"); if (!lastAsst?.content) return; + // Normalize content: OpenClaw sends content as an array of + // content blocks ({type:"text", text:"..."}), but the trigger + // regexes expect a string. Without normalization, + // re.test(lastAsst.content) coerces the array to + // "[object Object]" and no trigger ever matches. + const rawContent = lastAsst.content; + const contentStr = typeof rawContent === "string" + ? rawContent + : Array.isArray(rawContent) + ? rawContent + .map((b: Record) => + typeof b === "string" ? b : (b?.text as string ?? "")) + .join("\n") + : String(rawContent); + if (!contentStr) return; + // Dedup by content hash to avoid re-capturing across turns. const hash = createHash("sha256") - .update(lastAsst.content) + .update(contentStr) .digest("hex") .slice(0, 16); if (hash === lastCaptureHash) return; @@ -363,9 +379,9 @@ export default definePluginEntry({ /\b(important|critical|key|notable|significant)\b/i, /\b(I should note|I should remember|notable observation)\b/i, ]; - if (!triggers.some((re) => re.test(lastAsst.content))) return; + if (!triggers.some((re) => re.test(contentStr))) return; - const content = lastAsst.content.slice(0, 2000); + const content = contentStr.slice(0, 2000); // Refuse to persist content that looks like a prompt injection — // an attacker could coerce the agent into emitting a message diff --git a/src/agent-memory/src/index/store.rs b/src/agent-memory/src/index/store.rs index 488fda4ac..535d83c5c 100644 --- a/src/agent-memory/src/index/store.rs +++ b/src/agent-memory/src/index/store.rs @@ -401,6 +401,66 @@ impl BM25Store { .collect() }; + // OR fallback: FTS5 implicit-AND semantics return 0 results + // when any single query term is absent from the corpus. + // Natural-language prompts (e.g. auto-recall queries) routinely + // contain common words ("What", "is", "Answer") that don't + // appear in any memory file, causing every AND query to fail. + // When the AND path returns nothing and we have multiple tokens, + // retry with OR-joined quoted tokens so partial matches still + // surface. + let rows: Vec<(String, String, f64, String, i64)> = if rows.is_empty() && tokens.len() > 1 { + let or_q = tokens + .iter() + .map(|t| format!("\"{}\"", t)) + .collect::>() + .join(" OR "); + let or_sql = format!( + r#" + SELECT f.path, + snippet(files_fts, 1, '«', '»', '…', 16) AS snip, + bm25(files_fts) AS rank, + body, + f.mtime_ms + FROM files_fts + JOIN files f ON f.rowid = files_fts.rowid + WHERE files_fts MATCH ?1 {cold_filter} {superseded_filter} {agent_filter} + ORDER BY rank + LIMIT ?2 + "#, + cold_filter = cold_filter, + superseded_filter = superseded_filter, + agent_filter = agent_filter, + ); + let mut or_stmt = self.conn.prepare(&or_sql)?; + if let Some(ref agent_id) = agent_param { + or_stmt.query_map(params![or_q, top_k as i64, agent_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, f64>(2)?, + row.get::<_, String>(3)?, + row.get::<_, i64>(4)?, + )) + })? + .flatten() + .collect() + } else { + or_stmt.query_map(params![or_q, top_k as i64], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, f64>(2)?, + row.get::<_, String>(3)?, + row.get::<_, i64>(4)?, + )) + })? + .flatten() + .collect() + } + } else { + rows + }; let mut out: Vec = rows .into_iter() .map(|(path, snippet, bm25_score, _body, mtime_ms)| { diff --git a/src/agent-memory/src/tools/memory_search.rs b/src/agent-memory/src/tools/memory_search.rs index 8a9c288ff..2afeb4e29 100644 --- a/src/agent-memory/src/tools/memory_search.rs +++ b/src/agent-memory/src/tools/memory_search.rs @@ -81,7 +81,7 @@ pub fn memory_search( .sum(); svc.audit_log( AuditEntry::new(TOOL) - .path(format!("bm25:{:.120}", query)) + .path(format!("bm25:len={}", query.len())) .bytes(hits.len() as u64) .tokens(tokens), ); @@ -119,7 +119,7 @@ pub fn memory_search( let hits = index.search_scoped(query, top_k.max(1), scope_ref)?; svc.audit_log( AuditEntry::new(TOOL) - .path(format!("bm25(fallback from {mode}):{:.120}", query)) + .path(format!("bm25(fallback from {mode}):len={}", query.len())) .bytes(hits.len() as u64), ); return Ok(hits); @@ -147,7 +147,7 @@ pub fn memory_search( .map_err(|e| { svc.audit_log( AuditEntry::new(TOOL) - .path(format!("embed:{:.120}", query)) + .path(format!("embed:len={}", query.len())) .error(e.to_string()), ); MemoryError::Other(format!("embedding failed: {e}")) @@ -166,7 +166,7 @@ pub fn memory_search( svc.audit_log( AuditEntry::new(TOOL) - .path(format!("{mode}:{:.120}", query)) + .path(format!("{mode}:len={}", query.len())) .bytes(hits.len() as u64) .tokens(tokens), );