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
16 changes: 13 additions & 3 deletions apps/memos-local-openclaw/src/recall/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<userId>`).
*/
excludeSessionKey?: string;
}

const MAX_RECENT_QUERIES = 20;
Expand All @@ -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 }> = [];
Expand All @@ -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}`);
}
Expand All @@ -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,
Expand Down
33 changes: 27 additions & 6 deletions apps/memos-local-openclaw/src/storage/sqlite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [];

Expand All @@ -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);

Expand All @@ -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;

Expand All @@ -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 }>;
Expand Down Expand Up @@ -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'`;
Expand All @@ -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) => ({
Expand All @@ -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
Expand All @@ -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);

Expand Down
7 changes: 5 additions & 2 deletions apps/memos-local-openclaw/src/storage/vector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,20 @@ 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,
queryVec: number[],
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),
Expand Down
8 changes: 8 additions & 0 deletions apps/memos-local-openclaw/src/tools/memory-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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) {
Expand Down
52 changes: 52 additions & 0 deletions apps/memos-local-openclaw/tests/memory-search-tool.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> };
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();
});
});
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading