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
3 changes: 2 additions & 1 deletion apps/memos-local-openclaw/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -519,7 +519,8 @@ All optional — shown with defaults:
"rrfK": 60, // RRF fusion constant
"mmrLambda": 0.7, // MMR relevance vs diversity (0-1)
"recencyHalfLifeDays": 14, // Time decay half-life
"vectorSearchMaxChunks": 0 // 0 = search all (default). Set 200000–300000 only if search is slow on huge DBs
"vectorSearchMaxChunks": 0, // 0 = search all (default). Set 200000–300000 only if search is slow on huge DBs
"autoRecallMinQueryLength": 2 // Auto-recall skips shorter normalized prompts; set 10 to ignore short acknowledgements
},
"dedup": {
"similarityThreshold": 0.75, // Cosine similarity for smart-dedup candidates (Top-5)
Expand Down
5 changes: 3 additions & 2 deletions apps/memos-local-openclaw/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1882,8 +1882,9 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
const query = normalizeAutoRecallQuery(rawPrompt);
recallQuery = query;

if (query.length < 2) {
ctx.log.debug("auto-recall: extracted query too short, skipping");
const autoRecallMinQueryLength = ctx.config.recall?.autoRecallMinQueryLength ?? DEFAULTS.autoRecallMinQueryLength;
if (query.length < autoRecallMinQueryLength) {
ctx.log.debug(`auto-recall: extracted query shorter than autoRecallMinQueryLength=${autoRecallMinQueryLength}, skipping`);
return;
}
ctx.log.debug(`auto-recall: query="${query.slice(0, 80)}"`);
Expand Down
1 change: 1 addition & 0 deletions apps/memos-local-openclaw/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export function resolveConfig(raw: Partial<MemosLocalConfig> | undefined, stateD
mmrLambda: cfg.recall?.mmrLambda ?? DEFAULTS.mmrLambda,
recencyHalfLifeDays: cfg.recall?.recencyHalfLifeDays ?? DEFAULTS.recencyHalfLifeDays,
vectorSearchMaxChunks: cfg.recall?.vectorSearchMaxChunks ?? DEFAULTS.vectorSearchMaxChunks,
autoRecallMinQueryLength: cfg.recall?.autoRecallMinQueryLength ?? DEFAULTS.autoRecallMinQueryLength,
},
dedup: {
similarityThreshold: cfg.dedup?.similarityThreshold ?? DEFAULTS.dedupSimilarityThreshold,
Expand Down
3 changes: 3 additions & 0 deletions apps/memos-local-openclaw/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,8 @@ export interface MemosLocalConfig {
recencyHalfLifeDays?: number;
/** 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. */
vectorSearchMaxChunks?: number;
/** Auto-recall skips normalized prompts shorter than this many characters. */
autoRecallMinQueryLength?: number;
};
dedup?: {
similarityThreshold?: number;
Expand All @@ -337,6 +339,7 @@ export const DEFAULTS = {
mmrLambda: 0.7,
recencyHalfLifeDays: 14,
vectorSearchMaxChunks: 0,
autoRecallMinQueryLength: 2,
dedupSimilarityThreshold: 0.80,
evidenceWrapperTag: "STORED_MEMORY",
excerptMinChars: 200,
Expand Down
160 changes: 160 additions & 0 deletions apps/memos-local-openclaw/tests/auto-recall-min-query-length.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import type { MemosLocalConfig } from "../src/types";

type AutoRecallHook = (
event: { prompt?: string; messages?: unknown[] },
hookCtx?: { agentId?: string; sessionKey?: string },
) => Promise<unknown>;

const noopLog = {
debug() {},
info() {},
warn() {},
error() {},
};

async function registerPluginAndGetAutoRecallHook(opts: {
config: Partial<MemosLocalConfig>;
engineSearch: ReturnType<typeof vi.fn>;
}): Promise<AutoRecallHook> {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "memos-auto-recall-min-query-"));
const handlers = new Map<string, AutoRecallHook>();

vi.doMock("../src/config", () => ({
buildContext: () => ({
stateDir: tmpDir,
workspaceDir: path.join(tmpDir, "workspace"),
config: {
storage: { dbPath: path.join(tmpDir, "memos.db") },
capture: { evidenceWrapperTag: "STORED_MEMORY" },
telemetry: {},
sharing: {
enabled: false,
role: "client",
hub: { port: 18800, teamName: "", teamToken: "" },
client: { hubAddress: "", userToken: "" },
capabilities: { hostEmbedding: false, hostCompletion: false, hostSkill: false },
},
skillEvolution: { autoRecallSkills: false },
...opts.config,
},
log: noopLog,
}),
}));
vi.doMock("../src/storage/ensure-binding", () => ({ ensureSqliteBinding: () => {} }));
vi.doMock("../src/storage/sqlite", () => ({ SqliteStore: class {
recordToolCall() {}
recordApiLog() {}
close() {}
} }));
vi.doMock("../src/embedding", () => ({ Embedder: class { provider = "mock"; } }));
vi.doMock("../src/ingest/worker", () => ({ IngestWorker: class {
getTaskProcessor() { return { onTaskCompleted() {} }; }
enqueue() {}
async flush() {}
} }));
vi.doMock("../src/recall/engine", () => ({ RecallEngine: class {
search = opts.engineSearch;
async searchSkills() { return []; }
} }));
vi.doMock("../src/ingest/providers", () => ({ Summarizer: class {
async filterRelevant() { return null; }
} }));
vi.doMock("../src/viewer/server", () => ({ ViewerServer: class {
async start() { return "http://127.0.0.1:18799"; }
stop() {}
getResetToken() { return "token"; }
} }));
vi.doMock("../src/hub/server", () => ({ HubServer: class {
async start() { return "http://127.0.0.1:18800"; }
async stop() {}
} }));
vi.doMock("../src/client/hub", () => ({
hubGetMemoryDetail: async () => ({}),
hubRequestJson: async () => ({}),
hubSearchMemories: async () => ({ hits: [], meta: {} }),
hubSearchSkills: async () => ({ hits: [] }),
resolveHubClient: async () => ({ hubUrl: "", userToken: "", userId: "" }),
}));
vi.doMock("../src/client/connector", () => ({
connectToHub: async () => ({ connected: false }),
getHubStatus: async () => ({ connected: false }),
}));
vi.doMock("../src/client/skill-sync", () => ({
fetchHubSkillBundle: async () => ({}),
publishSkillBundleToHub: async () => ({}),
restoreSkillBundleFromHub: () => ({}),
unpublishSkillBundleFromHub: async () => ({}),
}));
vi.doMock("../src/skill/evolver", () => ({ SkillEvolver: class { async onTaskCompleted() {} } }));
vi.doMock("../src/skill/installer", () => ({ SkillInstaller: class {
getCompanionManifest() { return null; }
install() { return { message: "ok" }; }
} }));
vi.doMock("../src/skill/bundled-memory-guide", () => ({ MEMORY_GUIDE_SKILL_MD: "# mock" }));
vi.doMock("../src/telemetry", () => ({ Telemetry: class {
trackToolCalled() {}
trackAutoRecall() {}
trackMemoryIngested() {}
trackSkillInstalled() {}
trackSkillEvolved() {}
trackPluginStarted() {}
trackError() {}
async shutdown() {}
} }));

const pluginModule = await import("../plugin-impl");
pluginModule.default.register({
id: "memos-local-openclaw-plugin",
pluginConfig: {},
config: { plugins: { entries: { "memos-local-openclaw-plugin": {} } } },
resolvePath: (p: string) => path.join(tmpDir, p.replace(/^~[\\/]/, "")),
logger: { info() {}, warn() {} },
registerTool: () => {},
registerMemoryCapability: () => {},
registerService: () => {},
on: (name: string, handler: AutoRecallHook) => {
handlers.set(name, handler);
},
} as any);

const hook = handlers.get("before_prompt_build");
if (!hook) throw new Error("before_prompt_build hook was not registered");
return hook;
}

afterEach(() => {
vi.resetModules();
vi.clearAllMocks();
});

describe("auto-recall min query length", () => {
it("skips auto-recall search when query is shorter than configured threshold", async () => {
const search = vi.fn(async () => ({ hits: [], meta: {} }));
const hook = await registerPluginAndGetAutoRecallHook({
config: { recall: { autoRecallMinQueryLength: 10 } },
engineSearch: search,
});

await hook({ prompt: "继续吧" }, { agentId: "main" });

expect(search).not.toHaveBeenCalled();
});

it("runs auto-recall search when query reaches configured threshold", async () => {
const search = vi.fn(async () => ({ hits: [], meta: {} }));
const hook = await registerPluginAndGetAutoRecallHook({
config: { recall: { autoRecallMinQueryLength: 10 } },
engineSearch: search,
});

await hook({ prompt: "remember deployment rollback preference" }, { agentId: "main" });

expect(search).toHaveBeenCalledWith(expect.objectContaining({
query: "remember deployment rollback preference",
}));
});
});
15 changes: 15 additions & 0 deletions apps/memos-local-openclaw/tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,21 @@ import { describe, expect, it } from "vitest";
import { resolveConfig } from "../src/config";

describe("resolveConfig", () => {
it("defaults autoRecallMinQueryLength to the existing two-character threshold", () => {
const resolved = resolveConfig(undefined, "/tmp/memos-config-test");

expect(resolved.recall?.autoRecallMinQueryLength).toBe(2);
});

it("preserves configured autoRecallMinQueryLength", () => {
const resolved = resolveConfig(
{ recall: { autoRecallMinQueryLength: 10 } },
"/tmp/memos-config-test",
);

expect(resolved.recall?.autoRecallMinQueryLength).toBe(10);
});

it("injects openclaw providers into existing blocks when host capabilities are enabled", () => {
const resolved = resolveConfig(
{
Expand Down
1 change: 1 addition & 0 deletions packages/memos-core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export function resolveConfig(raw: Partial<MemosLocalConfig> | undefined, stateD
mmrLambda: cfg.recall?.mmrLambda ?? DEFAULTS.mmrLambda,
recencyHalfLifeDays: cfg.recall?.recencyHalfLifeDays ?? DEFAULTS.recencyHalfLifeDays,
vectorSearchMaxChunks: cfg.recall?.vectorSearchMaxChunks ?? DEFAULTS.vectorSearchMaxChunks,
autoRecallMinQueryLength: cfg.recall?.autoRecallMinQueryLength ?? DEFAULTS.autoRecallMinQueryLength,
},
dedup: {
similarityThreshold: cfg.dedup?.similarityThreshold ?? DEFAULTS.dedupSimilarityThreshold,
Expand Down
3 changes: 3 additions & 0 deletions packages/memos-core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,8 @@ export interface MemosLocalConfig {
recencyHalfLifeDays?: number;
/** 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. */
vectorSearchMaxChunks?: number;
/** Auto-recall skips normalized prompts shorter than this many characters. */
autoRecallMinQueryLength?: number;
};
dedup?: {
similarityThreshold?: number;
Expand All @@ -337,6 +339,7 @@ export const DEFAULTS = {
mmrLambda: 0.7,
recencyHalfLifeDays: 14,
vectorSearchMaxChunks: 0,
autoRecallMinQueryLength: 2,
dedupSimilarityThreshold: 0.80,
evidenceWrapperTag: "STORED_MEMORY",
excerptMinChars: 200,
Expand Down