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", () => {