Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 21 additions & 5 deletions src/agent-memory/adapters/agent-memory/openclaw/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,11 +333,11 @@ export default definePluginEntry({
api.on(
"agent_end",
async (
event: { messages?: Array<{ role: string; content: string }> },
event: { messages?: unknown[] },
_ctx: Record<string, unknown>,
) => {
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.
Expand All @@ -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<string, unknown>) =>
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;
Expand All @@ -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
Expand Down
60 changes: 60 additions & 0 deletions src/agent-memory/src/index/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>()
.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<SearchHit> = rows
.into_iter()
.map(|(path, snippet, bm25_score, _body, mtime_ms)| {
Expand Down
8 changes: 4 additions & 4 deletions src/agent-memory/src/tools/memory_search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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}"))
Expand All @@ -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),
);
Expand Down
Loading