Skip to content

Commit 57aaafd

Browse files
JohnnyJohnny
authored andcommitted
feat(openclaw-plugin): add topic-judge pre-filter to skip auto-recall on topic continuation
Add a lightweight LLM-based pre-filter before auto-recall search to avoid unnecessary embedding + vector search + LLM filter calls when the user is continuing the current conversation. - Extract topicJudgePreFilter() as a standalone function returning 'skip'|'proceed' - Configurable via recall.topicJudgeRounds (default: 4, set 0 to disable) - Uses existing Summarizer.judgeNewTopic() (already implemented in all providers) - Graceful fallback: too-few-lines → skip; LLM error → proceed with recall - Only 1 small LLM call (max_tokens=10) vs full search pipeline saved on SAME
1 parent 27c9e71 commit 57aaafd

File tree

3 files changed

+111
-0
lines changed

3 files changed

+111
-0
lines changed

apps/memos-local-openclaw/index.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1807,6 +1807,104 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
18071807

18081808
// ─── Auto-recall: inject relevant memories before agent starts ───
18091809

1810+
/**
1811+
* Pre-filter: use LLM to check if the new message continues the current topic.
1812+
* Returns "skip" to skip recall (same topic), "proceed" to run recall (new topic or disabled/error).
1813+
*
1814+
* Config:
1815+
* topicJudgeRounds = 0 → disabled, always returns "proceed"
1816+
* topicJudgeRounds > 0 → enabled, uses that many conversation rounds for context
1817+
*/
1818+
async function topicJudgePreFilter(opts: {
1819+
messages: unknown[] | undefined;
1820+
query: string;
1821+
topicJudgeRounds: number;
1822+
summarizer: Summarizer;
1823+
log: { info: (...args: any[]) => void; warn: (...args: any[]) => void; debug: (...args: any[]) => void };
1824+
}): Promise<"skip" | "proceed"> {
1825+
const { messages, query, topicJudgeRounds, summarizer, log } = opts;
1826+
1827+
if (topicJudgeRounds <= 0) return "proceed";
1828+
if (!Array.isArray(messages) || messages.length < 3) return "proceed";
1829+
1830+
try {
1831+
const msgs = messages as Array<Record<string, unknown>>;
1832+
const rawMsgs = msgs.slice(-20);
1833+
const merged: Array<{ role: string; text: string }> = [];
1834+
for (const m of rawMsgs) {
1835+
const role = m.role as string;
1836+
if (role === "tool") continue;
1837+
let text = "";
1838+
if (typeof m.content === "string") {
1839+
text = m.content;
1840+
} else if (Array.isArray(m.content)) {
1841+
for (const block of m.content as Array<Record<string, unknown>>) {
1842+
if (block.type === "text" && typeof block.text === "string") text += block.text + " ";
1843+
}
1844+
}
1845+
text = text.trim();
1846+
if (!text) continue;
1847+
if (merged.length > 0 && merged[merged.length - 1].role === role) {
1848+
merged[merged.length - 1].text += "\n\n" + text;
1849+
} else {
1850+
merged.push({ role, text });
1851+
}
1852+
}
1853+
if (merged.length > 0) merged.pop();
1854+
1855+
const sliceLen = topicJudgeRounds * 2;
1856+
const lastN = merged.length > sliceLen ? merged.slice(-sliceLen) : merged;
1857+
if (lastN.length > 0 && lastN[0].role !== "user") lastN.shift();
1858+
if (lastN.length > 0 && lastN[lastN.length - 1].role !== "assistant") lastN.pop();
1859+
1860+
const MAX_CONTEXT_LEN = 500;
1861+
const HEAD_TAIL = 150;
1862+
const contextLines = lastN.map((m) => {
1863+
const role = (m.role === "user") ? "USER" : "ASSISTANT";
1864+
let text = m.text;
1865+
if (role === "USER") {
1866+
const senderIdx = text.lastIndexOf("Sender (untrusted metadata):");
1867+
if (senderIdx > 0) text = text.slice(senderIdx);
1868+
const fenceStart = text.indexOf("```json");
1869+
const fenceEnd = fenceStart >= 0 ? text.indexOf("```\n", fenceStart + 7) : -1;
1870+
if (fenceEnd > 0) text = text.slice(fenceEnd + 4).replace(/^\s*\n/, "").trim();
1871+
if (senderIdx < 0) {
1872+
const injectEnd = text.indexOf("rephrased query to find more.\n\n");
1873+
if (injectEnd !== -1) {
1874+
text = text.slice(injectEnd + "rephrased query to find more.\n\n".length).trim();
1875+
} else {
1876+
const injectEnd2 = text.indexOf("Do NOT skip this step. Do NOT answer without searching first.\n\n");
1877+
if (injectEnd2 !== -1) {
1878+
text = text.slice(injectEnd2 + "Do NOT skip this step. Do NOT answer without searching first.\n\n".length).trim();
1879+
}
1880+
}
1881+
}
1882+
}
1883+
text = text.replace(/^\[.*?\]\s*/, "").trim();
1884+
if (text.length > MAX_CONTEXT_LEN) {
1885+
text = text.slice(0, HEAD_TAIL) + "..." + text.slice(-HEAD_TAIL);
1886+
}
1887+
return `${role}: ${text.trim()}`;
1888+
}).filter((l) => l.split(": ")[1]?.length > 0);
1889+
1890+
if (contextLines.length < 2) {
1891+
log.info(`[auto-recall] topic-judge: too-few-lines (${contextLines.length}), skip recall`);
1892+
return "skip";
1893+
}
1894+
1895+
const currentContext = contextLines.join("\n");
1896+
log.info(`[auto-recall] topic-judge: lines=${contextLines.length}, query="${query.slice(0, 60)}"`);
1897+
const isNew = await summarizer.judgeNewTopic(currentContext, query);
1898+
const topicResult = isNew === true ? "NEW" : isNew === false ? "SAME" : `ERROR(${isNew})`;
1899+
log.info(`[auto-recall] topic-judge: result=${topicResult}`);
1900+
1901+
return isNew === false ? "skip" : "proceed";
1902+
} catch (judgeErr) {
1903+
log.warn(`[auto-recall] topic-judge error="${judgeErr}", fallback=proceed`);
1904+
return "proceed";
1905+
}
1906+
}
1907+
18101908
api.on("before_prompt_build", async (event: { prompt?: string; messages?: unknown[] }, hookCtx?: { agentId?: string; sessionKey?: string }) => {
18111909
if (!allowPromptInjection) return {};
18121910
if (!event.prompt || event.prompt.length < 3) return;
@@ -1849,6 +1947,16 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
18491947
}
18501948
ctx.log.debug(`auto-recall: query="${query.slice(0, 80)}"`);
18511949

1950+
// ─── Pre-filter: topic-judge to skip recall on topic continuation ───
1951+
const shouldSkipRecall = await topicJudgePreFilter({
1952+
messages: event.messages,
1953+
query,
1954+
topicJudgeRounds: ctx.config.recall?.topicJudgeRounds ?? 4,
1955+
summarizer,
1956+
log: ctx.log,
1957+
});
1958+
if (shouldSkipRecall === "skip") return;
1959+
18521960
const result = await engine.search({ query, maxResults: 10, minScore: 0.45, ownerFilter: recallOwnerFilter });
18531961

18541962
// Hub fallback helper: search team shared memories when local search has no relevant results

apps/memos-local-openclaw/src/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export function resolveConfig(raw: Partial<MemosLocalConfig> | undefined, stateD
6666
mmrLambda: cfg.recall?.mmrLambda ?? DEFAULTS.mmrLambda,
6767
recencyHalfLifeDays: cfg.recall?.recencyHalfLifeDays ?? DEFAULTS.recencyHalfLifeDays,
6868
vectorSearchMaxChunks: cfg.recall?.vectorSearchMaxChunks ?? DEFAULTS.vectorSearchMaxChunks,
69+
topicJudgeRounds: cfg.recall?.topicJudgeRounds ?? 4,
6970
},
7071
dedup: {
7172
similarityThreshold: cfg.dedup?.similarityThreshold ?? DEFAULTS.dedupSimilarityThreshold,

apps/memos-local-openclaw/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,8 @@ export interface MemosLocalConfig {
312312
recencyHalfLifeDays?: number;
313313
/** Cap vector search to this many most recent chunks. 0 = no cap (search all; may get slower with 200k+ chunks). If you set a cap for performance, use a large value (e.g. 200000–300000) so older memories are still in the window; FTS always searches all. */
314314
vectorSearchMaxChunks?: number;
315+
/** Number of conversation rounds (user+assistant pairs) used by topic-judge to determine if recall should run. 0 = disabled (always recall). Default: 4. */
316+
topicJudgeRounds?: number;
315317
};
316318
dedup?: {
317319
similarityThreshold?: number;

0 commit comments

Comments
 (0)