From 9f405aa1808b735b71dc01fa13dbd6d7cc7334e7 Mon Sep 17 00:00:00 2001 From: autodev Date: Tue, 30 Jun 2026 12:39:04 +0800 Subject: [PATCH] feat(memos-local-openclaw): add excludeSessionKey to memory_search recall Plumb an optional excludeSessionKey through the recall pipeline so callers (e.g. OpenClaw) can suppress recall of chunks from the current conversation session, avoiding token waste and echoed content in LLM context. Surface: - RecallOptions.excludeSessionKey (src/recall/engine.ts) - SqliteStore.ftsSearch / patternSearch / getAllEmbeddings / getRecentEmbeddings - vectorSearch helper (src/storage/vector.ts) - memory_search tool input schema + handler (src/tools/memory-search.ts) Behavior unchanged when the field is unset; hub-memory hits are not affected (they use synthetic sessionKey "hub-shared:" and represent cross-user shared knowledge by design). Tests: 11 new vitest cases covering FTS, patternSearch, getAll/RecentEmbeddings, vectorSearch, RecallEngine.search propagation, and memory_search tool wiring (37/37 passing on touched suites; tsc --noEmit clean). Refs: #1362 --- .../memos-local-openclaw/src/recall/engine.ts | 16 +++- .../src/storage/sqlite.ts | 33 +++++-- .../src/storage/vector.ts | 7 +- .../src/tools/memory-search.ts | 8 ++ .../tests/memory-search-tool.test.ts | 52 ++++++++++ .../recall-engine-exclude-session.test.ts | 94 +++++++++++++++++++ .../tests/storage.test.ts | 63 +++++++++++++ 7 files changed, 262 insertions(+), 11 deletions(-) create mode 100644 apps/memos-local-openclaw/tests/memory-search-tool.test.ts create mode 100644 apps/memos-local-openclaw/tests/recall-engine-exclude-session.test.ts diff --git a/apps/memos-local-openclaw/src/recall/engine.ts b/apps/memos-local-openclaw/src/recall/engine.ts index 711bde5a0..100e6467c 100644 --- a/apps/memos-local-openclaw/src/recall/engine.ts +++ b/apps/memos-local-openclaw/src/recall/engine.ts @@ -15,6 +15,15 @@ export interface RecallOptions { minScore?: number; role?: string; ownerFilter?: string[]; + /** + * If set, chunks whose `sessionKey` equals this value are filtered out at the + * SQL layer (FTS, vector, pattern) before fusion. Use this to suppress recall + * of the **current** conversation session so the model doesn't waste tokens + * on its own recent turns. Hub-memory hits are not affected by this filter + * because they represent cross-user shared knowledge keyed by a synthetic + * `sessionKey` (`hub-shared:`). + */ + excludeSessionKey?: string; } const MAX_RECENT_QUERIES = 20; @@ -41,10 +50,11 @@ export class RecallEngine { const repeatNote = this.checkRepeat(query, maxResults, minScore); const candidatePool = maxResults * 5; const ownerFilter = opts.ownerFilter; + const excludeSessionKey = opts.excludeSessionKey; // Step 1: Gather candidates from FTS, vector search, and pattern search const ftsCandidates = query - ? this.store.ftsSearch(query, candidatePool, ownerFilter) + ? this.store.ftsSearch(query, candidatePool, ownerFilter, excludeSessionKey) : []; let vecCandidates: Array<{ chunkId: string; score: number }> = []; @@ -54,7 +64,7 @@ export class RecallEngine { const maxChunks = recallCfg.vectorSearchMaxChunks && recallCfg.vectorSearchMaxChunks > 0 ? recallCfg.vectorSearchMaxChunks : undefined; - vecCandidates = vectorSearch(this.store, queryVec, candidatePool, maxChunks, ownerFilter); + vecCandidates = vectorSearch(this.store, queryVec, candidatePool, maxChunks, ownerFilter, excludeSessionKey); } catch (err) { this.ctx.log.warn(`Vector search failed, using FTS only: ${err}`); } @@ -77,7 +87,7 @@ export class RecallEngine { } const shortTerms = [...new Set([...spaceSplit, ...cjkBigrams])]; const patternHits = shortTerms.length > 0 - ? this.store.patternSearch(shortTerms, { limit: candidatePool, ownerFilter }) + ? this.store.patternSearch(shortTerms, { limit: candidatePool, ownerFilter, excludeSessionKey }) : []; const patternRanked = patternHits.map((h, i) => ({ id: h.chunkId, diff --git a/apps/memos-local-openclaw/src/storage/sqlite.ts b/apps/memos-local-openclaw/src/storage/sqlite.ts index 09f9c2bf7..c4f658ec3 100644 --- a/apps/memos-local-openclaw/src/storage/sqlite.ts +++ b/apps/memos-local-openclaw/src/storage/sqlite.ts @@ -1241,7 +1241,7 @@ export class SqliteStore { // ─── FTS Search ─── - ftsSearch(query: string, limit: number, ownerFilter?: string[]): Array<{ chunkId: string; score: number }> { + ftsSearch(query: string, limit: number, ownerFilter?: string[], excludeSessionKey?: string): Array<{ chunkId: string; score: number }> { const sanitized = sanitizeFtsQuery(query); if (!sanitized) return []; @@ -1259,6 +1259,11 @@ export class SqliteStore { params.push(...ownerFilter); } + if (excludeSessionKey) { + sql += ` AND c.session_key != ?`; + params.push(excludeSessionKey); + } + sql += ` ORDER BY rank LIMIT ?`; params.push(limit); @@ -1278,7 +1283,7 @@ export class SqliteStore { // ─── Pattern Search (LIKE-based, for CJK text where FTS tokenization is weak) ─── - patternSearch(patterns: string[], opts: { role?: string; limit?: number; ownerFilter?: string[] } = {}): Array<{ chunkId: string; content: string; role: string; createdAt: number }> { + patternSearch(patterns: string[], opts: { role?: string; limit?: number; ownerFilter?: string[]; excludeSessionKey?: string } = {}): Array<{ chunkId: string; content: string; role: string; createdAt: number }> { if (patterns.length === 0) return []; const limit = opts.limit ?? 10; @@ -1295,13 +1300,19 @@ export class SqliteStore { params.push(...opts.ownerFilter); } + let sessionClause = ""; + if (opts.excludeSessionKey) { + sessionClause = ` AND c.session_key != ?`; + params.push(opts.excludeSessionKey); + } + params.push(limit); try { const rows = this.db.prepare(` SELECT c.id as chunk_id, c.content, c.role, c.created_at FROM chunks c - WHERE (${whereClause})${roleClause}${ownerClause} AND c.dedup_status = 'active' + WHERE (${whereClause})${roleClause}${ownerClause}${sessionClause} AND c.dedup_status = 'active' ORDER BY c.created_at DESC LIMIT ? `).all(...params) as Array<{ chunk_id: string; content: string; role: string; created_at: number }>; @@ -1345,7 +1356,7 @@ export class SqliteStore { // ─── Vector Search ─── - getAllEmbeddings(ownerFilter?: string[]): Array<{ chunkId: string; vector: number[] }> { + getAllEmbeddings(ownerFilter?: string[], excludeSessionKey?: string): Array<{ chunkId: string; vector: number[] }> { let sql = `SELECT e.chunk_id, e.vector, e.dimensions FROM embeddings e JOIN chunks c ON c.id = e.chunk_id WHERE c.dedup_status = 'active'`; @@ -1357,6 +1368,11 @@ export class SqliteStore { params.push(...ownerFilter); } + if (excludeSessionKey) { + sql += ` AND c.session_key != ?`; + params.push(excludeSessionKey); + } + const rows = this.db.prepare(sql).all(...params) as Array<{ chunk_id: string; vector: Buffer; dimensions: number }>; return rows.map((r) => ({ @@ -1365,8 +1381,8 @@ export class SqliteStore { })); } - getRecentEmbeddings(limit: number, ownerFilter?: string[]): Array<{ chunkId: string; vector: number[] }> { - if (limit <= 0) return this.getAllEmbeddings(ownerFilter); + getRecentEmbeddings(limit: number, ownerFilter?: string[], excludeSessionKey?: string): Array<{ chunkId: string; vector: number[] }> { + if (limit <= 0) return this.getAllEmbeddings(ownerFilter, excludeSessionKey); let sql = `SELECT e.chunk_id, e.vector, e.dimensions FROM chunks c @@ -1380,6 +1396,11 @@ export class SqliteStore { params.push(...ownerFilter); } + if (excludeSessionKey) { + sql += ` AND c.session_key != ?`; + params.push(excludeSessionKey); + } + sql += ` ORDER BY c.created_at DESC LIMIT ?`; params.push(limit); diff --git a/apps/memos-local-openclaw/src/storage/vector.ts b/apps/memos-local-openclaw/src/storage/vector.ts index 1acec2d3e..488f287bf 100644 --- a/apps/memos-local-openclaw/src/storage/vector.ts +++ b/apps/memos-local-openclaw/src/storage/vector.ts @@ -22,6 +22,8 @@ export interface VectorHit { /** * Brute-force vector search over stored embeddings. * When maxChunks > 0, only searches the most recent maxChunks chunks (uses index; avoids full scan as data grows). + * When excludeSessionKey is set, chunks whose session_key equals it are filtered out before scoring, + * so the caller can suppress recall of the current conversation session. */ export function vectorSearch( store: SqliteStore, @@ -29,10 +31,11 @@ export function vectorSearch( topK: number, maxChunks?: number, ownerFilter?: string[], + excludeSessionKey?: string, ): VectorHit[] { const all = maxChunks != null && maxChunks > 0 - ? store.getRecentEmbeddings(maxChunks, ownerFilter) - : store.getAllEmbeddings(ownerFilter); + ? store.getRecentEmbeddings(maxChunks, ownerFilter, excludeSessionKey) + : store.getAllEmbeddings(ownerFilter, excludeSessionKey); const scored: VectorHit[] = all.map((row) => ({ chunkId: row.chunkId, score: cosineSimilarity(queryVec, row.vector), diff --git a/apps/memos-local-openclaw/src/tools/memory-search.ts b/apps/memos-local-openclaw/src/tools/memory-search.ts index 43cad5bc8..463fe2dcf 100644 --- a/apps/memos-local-openclaw/src/tools/memory-search.ts +++ b/apps/memos-local-openclaw/src/tools/memory-search.ts @@ -57,6 +57,10 @@ export function createMemorySearchTool(engine: RecallEngine, store?: SqliteStore type: "string", description: "Optional hub bearer token override for group/all search or integration tests.", }, + excludeSessionKey: { + type: "string", + description: "Optional sessionKey to exclude from recall. Pass the current conversation's sessionKey to avoid recalling chunks from the ongoing session — useful to save tokens and surface only historical memories.", + }, }, }, handler: async (input) => { @@ -66,12 +70,16 @@ export function createMemorySearchTool(engine: RecallEngine, store?: SqliteStore const minScore = input.minScore as number | undefined; const ownerFilter = resolveOwnerFilter(input.owner); const scope = resolveScope(input.scope); + const excludeSessionKey = typeof input.excludeSessionKey === "string" && input.excludeSessionKey.length > 0 + ? input.excludeSessionKey + : undefined; const localSearch = engine.search({ query, maxResults, minScore, ownerFilter, + excludeSessionKey, }); if (scope === "local" || !store || !ctx) { diff --git a/apps/memos-local-openclaw/tests/memory-search-tool.test.ts b/apps/memos-local-openclaw/tests/memory-search-tool.test.ts new file mode 100644 index 000000000..962d78165 --- /dev/null +++ b/apps/memos-local-openclaw/tests/memory-search-tool.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, vi } from "vitest"; +import { createMemorySearchTool } from "../src/tools/memory-search"; +import type { RecallEngine } from "../src/recall/engine"; + +function makeMockEngine() { + return { + search: vi.fn(async () => ({ + hits: [], + meta: { usedMinScore: 0.45, usedMaxResults: 6, totalCandidates: 0 }, + })), + } as unknown as RecallEngine; +} + +describe("memory_search tool — excludeSessionKey input wiring", () => { + it("declares excludeSessionKey in its inputSchema", () => { + const engine = makeMockEngine(); + const tool = createMemorySearchTool(engine); + const schema = tool.inputSchema as { properties: Record }; + expect(schema.properties).toHaveProperty("excludeSessionKey"); + }); + + it("forwards excludeSessionKey from tool input to engine.search", async () => { + const engine = makeMockEngine(); + const tool = createMemorySearchTool(engine); + + await tool.handler({ query: "deploy", excludeSessionKey: "sess-current" }); + + expect(engine.search).toHaveBeenCalledTimes(1); + const callArgs = (engine.search as unknown as { mock: { calls: any[][] } }).mock.calls[0][0]; + expect(callArgs.excludeSessionKey).toBe("sess-current"); + }); + + it("omits excludeSessionKey when not provided", async () => { + const engine = makeMockEngine(); + const tool = createMemorySearchTool(engine); + + await tool.handler({ query: "deploy" }); + + const callArgs = (engine.search as unknown as { mock: { calls: any[][] } }).mock.calls[0][0]; + expect(callArgs.excludeSessionKey).toBeUndefined(); + }); + + it("treats non-string excludeSessionKey as undefined (input hygiene)", async () => { + const engine = makeMockEngine(); + const tool = createMemorySearchTool(engine); + + await tool.handler({ query: "deploy", excludeSessionKey: 42 }); + + const callArgs = (engine.search as unknown as { mock: { calls: any[][] } }).mock.calls[0][0]; + expect(callArgs.excludeSessionKey).toBeUndefined(); + }); +}); diff --git a/apps/memos-local-openclaw/tests/recall-engine-exclude-session.test.ts b/apps/memos-local-openclaw/tests/recall-engine-exclude-session.test.ts new file mode 100644 index 000000000..28fac1ea9 --- /dev/null +++ b/apps/memos-local-openclaw/tests/recall-engine-exclude-session.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, vi } from "vitest"; +import { RecallEngine } from "../src/recall/engine"; +import type { SqliteStore } from "../src/storage/sqlite"; +import type { Embedder } from "../src/embedding"; +import type { PluginContext } from "../src/types"; + +const testLog = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, +}; + +function makeContext(): PluginContext { + return { + stateDir: "/tmp", + workspaceDir: "/tmp", + log: testLog, + config: { + recall: { + maxResultsDefault: 6, + maxResultsMax: 20, + minScoreDefault: 0.45, + minScoreFloor: 0.35, + rrfK: 60, + mmrLambda: 0.7, + recencyHalfLifeDays: 14, + vectorSearchMaxChunks: 0, + }, + }, + }; +} + +describe("RecallEngine.search — excludeSessionKey propagation", () => { + it("forwards excludeSessionKey to ftsSearch, vectorSearch, and patternSearch", async () => { + const store = { + ftsSearch: vi.fn(() => []), + patternSearch: vi.fn(() => []), + getAllEmbeddings: vi.fn(() => []), + getRecentEmbeddings: vi.fn(() => []), + getChunk: vi.fn(() => null), + } as unknown as SqliteStore; + + const embedder = { + embedQuery: vi.fn(async () => [0.1, 0.2, 0.3, 0.4]), + } as unknown as Embedder; + + const engine = new RecallEngine(store, embedder, makeContext()); + + await engine.search({ query: "唐波是谁", excludeSessionKey: "sess-current" }); + + // FTS got the excludeSessionKey as 4th argument + expect(store.ftsSearch).toHaveBeenCalled(); + const ftsArgs = (store.ftsSearch as unknown as { mock: { calls: any[][] } }).mock.calls[0]; + expect(ftsArgs[3]).toBe("sess-current"); + + // patternSearch received it in the options object + expect(store.patternSearch).toHaveBeenCalled(); + const patternArgs = (store.patternSearch as unknown as { mock: { calls: any[][] } }).mock.calls[0]; + expect(patternArgs[1].excludeSessionKey).toBe("sess-current"); + + // vector path goes through getAllEmbeddings (since vectorSearchMaxChunks=0 in config) + expect(store.getAllEmbeddings).toHaveBeenCalled(); + const embArgs = (store.getAllEmbeddings as unknown as { mock: { calls: any[][] } }).mock.calls[0]; + expect(embArgs[1]).toBe("sess-current"); + }); + + it("does not pass excludeSessionKey when caller omits it", async () => { + const store = { + ftsSearch: vi.fn(() => []), + patternSearch: vi.fn(() => []), + getAllEmbeddings: vi.fn(() => []), + getRecentEmbeddings: vi.fn(() => []), + getChunk: vi.fn(() => null), + } as unknown as SqliteStore; + + const embedder = { + embedQuery: vi.fn(async () => [0.1, 0.2, 0.3, 0.4]), + } as unknown as Embedder; + + const engine = new RecallEngine(store, embedder, makeContext()); + + await engine.search({ query: "唐波是谁" }); + + const ftsArgs = (store.ftsSearch as unknown as { mock: { calls: any[][] } }).mock.calls[0]; + expect(ftsArgs[3]).toBeUndefined(); + + const patternArgs = (store.patternSearch as unknown as { mock: { calls: any[][] } }).mock.calls[0]; + expect(patternArgs[1].excludeSessionKey).toBeUndefined(); + + const embArgs = (store.getAllEmbeddings as unknown as { mock: { calls: any[][] } }).mock.calls[0]; + expect(embArgs[1]).toBeUndefined(); + }); +}); diff --git a/apps/memos-local-openclaw/tests/storage.test.ts b/apps/memos-local-openclaw/tests/storage.test.ts index f449b868d..b76cc708c 100644 --- a/apps/memos-local-openclaw/tests/storage.test.ts +++ b/apps/memos-local-openclaw/tests/storage.test.ts @@ -137,6 +137,51 @@ describe("SqliteStore", () => { const recent0 = store.getRecentEmbeddings(0); expect(recent0.length).toBe(1); }); + + it("ftsSearch excludes the chunks whose session_key matches excludeSessionKey", () => { + store.insertChunk(makeChunk({ id: "c1", sessionKey: "sess-current", content: "Deploy the application to production", summary: "deployment" })); + store.insertChunk(makeChunk({ id: "c2", sessionKey: "sess-other", content: "Deploy the application to production", summary: "deployment" })); + + const all = store.ftsSearch("deploy production", 10); + expect(all.map((r) => r.chunkId).sort()).toEqual(["c1", "c2"]); + + const filtered = store.ftsSearch("deploy production", 10, undefined, "sess-current"); + expect(filtered.map((r) => r.chunkId)).toEqual(["c2"]); + }); + + it("ftsSearch with excludeSessionKey + ownerFilter applies both filters", () => { + store.insertChunk(makeChunk({ id: "c1", sessionKey: "sess-current", owner: "agent:main", content: "Deploy production runbook" })); + store.insertChunk(makeChunk({ id: "c2", sessionKey: "sess-other", owner: "agent:main", content: "Deploy production runbook" })); + store.insertChunk(makeChunk({ id: "c3", sessionKey: "sess-other", owner: "agent:bot", content: "Deploy production runbook" })); + + const filtered = store.ftsSearch("deploy production", 10, ["agent:main"], "sess-current"); + expect(filtered.map((r) => r.chunkId)).toEqual(["c2"]); + }); + + it("patternSearch excludes the chunks whose session_key matches excludeSessionKey", () => { + store.insertChunk(makeChunk({ id: "c1", sessionKey: "sess-current", content: "唐波是工程师" })); + store.insertChunk(makeChunk({ id: "c2", sessionKey: "sess-other", content: "唐波是工程师" })); + + const all = store.patternSearch(["唐波"], { limit: 10 }); + expect(all.map((r) => r.chunkId).sort()).toEqual(["c1", "c2"]); + + const filtered = store.patternSearch(["唐波"], { limit: 10, excludeSessionKey: "sess-current" }); + expect(filtered.map((r) => r.chunkId)).toEqual(["c2"]); + }); + + it("getAllEmbeddings + getRecentEmbeddings exclude chunks matching excludeSessionKey", () => { + const base = Date.now() - 5000; + store.insertChunk(makeChunk({ id: "c1", sessionKey: "sess-current", createdAt: base })); + store.upsertEmbedding("c1", [0.1, 0.2, 0.3]); + store.insertChunk(makeChunk({ id: "c2", sessionKey: "sess-other", createdAt: base + 1000 })); + store.upsertEmbedding("c2", [0.4, 0.5, 0.6]); + + const allFiltered = store.getAllEmbeddings(undefined, "sess-current"); + expect(allFiltered.map((r) => r.chunkId)).toEqual(["c2"]); + + const recentFiltered = store.getRecentEmbeddings(10, undefined, "sess-current"); + expect(recentFiltered.map((r) => r.chunkId)).toEqual(["c2"]); + }); }); describe("SqliteStore hub sharing schema", () => { @@ -528,6 +573,24 @@ describe("vectorSearch", () => { const cappedIds = new Set(cappedHits.map((h) => h.chunkId)); expect(cappedIds.size).toBeLessThanOrEqual(2); }); + + it("with excludeSessionKey filters out chunks belonging to that session", () => { + const base = Date.now() - 5000; + store.insertChunk(makeChunk({ id: "c-cur", sessionKey: "sess-current", createdAt: base })); + store.upsertEmbedding("c-cur", [1, 0, 0, 0]); + store.insertChunk(makeChunk({ id: "c-oth", sessionKey: "sess-other", createdAt: base + 1000 })); + store.upsertEmbedding("c-oth", [1, 0, 0, 0]); + + const queryVec = [1, 0, 0, 0]; + const all = vectorSearch(store, queryVec, 10); + expect(all.map((h) => h.chunkId).sort()).toEqual(["c-cur", "c-oth"]); + + const filtered = vectorSearch(store, queryVec, 10, undefined, undefined, "sess-current"); + expect(filtered.map((h) => h.chunkId)).toEqual(["c-oth"]); + + const filteredWithCap = vectorSearch(store, queryVec, 10, 5, undefined, "sess-current"); + expect(filteredWithCap.map((h) => h.chunkId)).toEqual(["c-oth"]); + }); }); describe("cosineSimilarity", () => {