diff --git a/apps/memos-local-openclaw/README.md b/apps/memos-local-openclaw/README.md index f2a9df64f..1886a8818 100644 --- a/apps/memos-local-openclaw/README.md +++ b/apps/memos-local-openclaw/README.md @@ -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) diff --git a/apps/memos-local-openclaw/index.ts b/apps/memos-local-openclaw/index.ts index 5e2245198..dc969ef53 100644 --- a/apps/memos-local-openclaw/index.ts +++ b/apps/memos-local-openclaw/index.ts @@ -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)}"`); diff --git a/apps/memos-local-openclaw/src/config.ts b/apps/memos-local-openclaw/src/config.ts index 150b09cc4..6e588f10c 100644 --- a/apps/memos-local-openclaw/src/config.ts +++ b/apps/memos-local-openclaw/src/config.ts @@ -66,6 +66,7 @@ export function resolveConfig(raw: Partial | 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, diff --git a/apps/memos-local-openclaw/src/types.ts b/apps/memos-local-openclaw/src/types.ts index cb08eb1cf..31d80b001 100644 --- a/apps/memos-local-openclaw/src/types.ts +++ b/apps/memos-local-openclaw/src/types.ts @@ -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; @@ -337,6 +339,7 @@ export const DEFAULTS = { mmrLambda: 0.7, recencyHalfLifeDays: 14, vectorSearchMaxChunks: 0, + autoRecallMinQueryLength: 2, dedupSimilarityThreshold: 0.80, evidenceWrapperTag: "STORED_MEMORY", excerptMinChars: 200, diff --git a/apps/memos-local-openclaw/tests/auto-recall-min-query-length.test.ts b/apps/memos-local-openclaw/tests/auto-recall-min-query-length.test.ts new file mode 100644 index 000000000..d756eefd1 --- /dev/null +++ b/apps/memos-local-openclaw/tests/auto-recall-min-query-length.test.ts @@ -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; + +const noopLog = { + debug() {}, + info() {}, + warn() {}, + error() {}, +}; + +async function registerPluginAndGetAutoRecallHook(opts: { + config: Partial; + engineSearch: ReturnType; +}): Promise { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "memos-auto-recall-min-query-")); + const handlers = new Map(); + + 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", + })); + }); +}); diff --git a/apps/memos-local-openclaw/tests/config.test.ts b/apps/memos-local-openclaw/tests/config.test.ts index 072728b9d..65cb774f2 100644 --- a/apps/memos-local-openclaw/tests/config.test.ts +++ b/apps/memos-local-openclaw/tests/config.test.ts @@ -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( { diff --git a/packages/memos-core/src/config.ts b/packages/memos-core/src/config.ts index b2316d78c..7d255015b 100644 --- a/packages/memos-core/src/config.ts +++ b/packages/memos-core/src/config.ts @@ -65,6 +65,7 @@ export function resolveConfig(raw: Partial | 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, diff --git a/packages/memos-core/src/types.ts b/packages/memos-core/src/types.ts index cb08eb1cf..31d80b001 100644 --- a/packages/memos-core/src/types.ts +++ b/packages/memos-core/src/types.ts @@ -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; @@ -337,6 +339,7 @@ export const DEFAULTS = { mmrLambda: 0.7, recencyHalfLifeDays: 14, vectorSearchMaxChunks: 0, + autoRecallMinQueryLength: 2, dedupSimilarityThreshold: 0.80, evidenceWrapperTag: "STORED_MEMORY", excerptMinChars: 200,