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
20 changes: 20 additions & 0 deletions cli-metadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";

export default definePluginEntry({
id: "memory-lancedb-pro",
name: "Memory (LanceDB Pro)",
description: "Enhanced LanceDB-backed long-term memory with hybrid retrieval, multi-scope isolation, long-context chunking, and management CLI",
kind: "memory",
register(api) {
api.registerCli(() => {}, {
commands: ["memory-pro"],
descriptors: [
{
name: "memory-pro",
description: "Enhanced memory management commands (LanceDB Pro)",
hasSubcommands: true,
},
],
});
},
});
411 changes: 315 additions & 96 deletions index.ts

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -1348,5 +1348,10 @@
"help": "Rate limit for auto-capture extractions. Prevents excessive LLM calls during rapid-fire sessions.",
"advanced": true
}
}
},
"commandAliases": [
{
"name": "memory-pro"
}
]
}
57 changes: 38 additions & 19 deletions src/retriever.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ export interface RetrievalContext {
category?: string;
/** Retrieval source: "manual" for user-triggered, "auto-recall" for system-initiated, "cli" for CLI commands. */
source?: "manual" | "auto-recall" | "cli";
/** Optional cancellation signal for long-running retrieval paths. */
signal?: AbortSignal;
}

export interface RetrievalResult extends MemorySearchResult {
Expand Down Expand Up @@ -391,34 +393,40 @@ export class MemoryRetriever {
}

async retrieve(context: RetrievalContext): Promise<RetrievalResult[]> {
const { query, limit, scopeFilter, category, source } = context;
const { query, limit, scopeFilter, category, source, signal } = context;
const safeLimit = clampInt(limit, 1, 20);

// Create trace only when stats collector is active (zero overhead otherwise)
const trace = this._statsCollector ? new TraceCollector() : undefined;

// Check if query contains tag prefixes -> use BM25-only + mustContain
// Check if query contains tag prefixes -> use BM25-only + mustContain.
// Auto-recall is latency-sensitive and runs inline during prompt assembly.
// Route it through local BM25-only retrieval so prompt building never waits
// on remote embedding / rerank providers.
const tagTokens = this.extractTagTokens(query);
const useLightweightAutoRecall = source === "auto-recall";
let results: RetrievalResult[];
let mode: "bm25" | "vector" | "hybrid";

if (tagTokens.length > 0) {
if (tagTokens.length > 0 || useLightweightAutoRecall) {
mode = "bm25";
results = await this.bm25OnlyRetrieval(
query, tagTokens, safeLimit, scopeFilter, category, trace,
);
} else if (this.config.mode === "vector" || !this.store.hasFtsSupport) {
mode = "vector";
results = await this.vectorOnlyRetrieval(
query, safeLimit, scopeFilter, category, trace,
query, safeLimit, scopeFilter, category, trace, signal,
);
} else {
mode = "hybrid";
results = await this.hybridRetrieval(
query, safeLimit, scopeFilter, category, trace,
query, safeLimit, scopeFilter, category, trace, signal,
);
}

// Feed completed trace to stats collector
if (trace && this._statsCollector) {
const mode = tagTokens.length > 0 ? "bm25"
: (this.config.mode === "vector" || !this.store.hasFtsSupport) ? "vector" : "hybrid";
const finalTrace = trace.finalize(query, mode);
this._statsCollector.recordQuery(finalTrace, source || "unknown");
}
Expand All @@ -438,29 +446,32 @@ export class MemoryRetriever {
async retrieveWithTrace(
context: RetrievalContext,
): Promise<{ results: RetrievalResult[]; trace: RetrievalTrace }> {
const { query, limit, scopeFilter, category, source } = context;
const { query, limit, scopeFilter, category, source, signal } = context;
const safeLimit = clampInt(limit, 1, 20);
const trace = new TraceCollector();

const tagTokens = this.extractTagTokens(query);
const useLightweightAutoRecall = source === "auto-recall";
let results: RetrievalResult[];
let mode: "bm25" | "vector" | "hybrid";

if (tagTokens.length > 0) {
if (tagTokens.length > 0 || useLightweightAutoRecall) {
mode = "bm25";
results = await this.bm25OnlyRetrieval(
query, tagTokens, safeLimit, scopeFilter, category, trace,
);
} else if (this.config.mode === "vector" || !this.store.hasFtsSupport) {
mode = "vector";
results = await this.vectorOnlyRetrieval(
query, safeLimit, scopeFilter, category, trace,
query, safeLimit, scopeFilter, category, trace, signal,
);
} else {
mode = "hybrid";
results = await this.hybridRetrieval(
query, safeLimit, scopeFilter, category, trace,
query, safeLimit, scopeFilter, category, trace, signal,
);
}

const mode = tagTokens.length > 0 ? "bm25"
: (this.config.mode === "vector" || !this.store.hasFtsSupport) ? "vector" : "hybrid";
const finalTrace = trace.finalize(query, mode);

if (this._statsCollector) {
Expand Down Expand Up @@ -489,8 +500,12 @@ export class MemoryRetriever {
scopeFilter?: string[],
category?: string,
trace?: TraceCollector,
signal?: AbortSignal,
): Promise<RetrievalResult[]> {
const queryVector = await this.embedder.embedQuery(query);
if (signal?.aborted) {
throw new Error("retrieval aborted");
}
const queryVector = await this.embedder.embedQuery(query, signal);

trace?.startStage("vector_search", []);
const results = await this.store.vectorSearch(
Expand Down Expand Up @@ -620,9 +635,13 @@ export class MemoryRetriever {
scopeFilter?: string[],
category?: string,
trace?: TraceCollector,
signal?: AbortSignal,
): Promise<RetrievalResult[]> {
const candidatePoolSize = Math.max(this.config.candidatePoolSize, limit * 2);
const queryVector = await this.embedder.embedQuery(query);
if (signal?.aborted) {
throw new Error("retrieval aborted");
}
const queryVector = await this.embedder.embedQuery(query, signal);

// Run vector and BM25 searches in parallel.
// Trace as a single "parallel_search" stage since both run concurrently —
Expand Down Expand Up @@ -1253,10 +1272,10 @@ export class MemoryRetriever {
error?: string;
}> {
try {
const results = await this.retrieve({
query,
limit: 1,
});
// Keep startup health checks lightweight and local.
// embedder.test() already probes the remote embedding provider; here we only
// verify that the retrieval/storage stack is initialized and queryable.
await this.store.bm25Search(query, 1, undefined, { excludeInactive: true });

return {
success: true,
Expand Down
19 changes: 13 additions & 6 deletions test/plugin-manifest-regression.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ function createMockApi(pluginConfig, options = {}) {
typeof toolOrFactory === "function" ? toolOrFactory : () => toolOrFactory;
},
registerCli() {},
registerMemoryPromptSection() {},
registerMemoryFlushPlan() {},
registerMemoryRuntime() {},
registerService(service) {
options.services?.push(service);
},
Expand Down Expand Up @@ -152,7 +155,11 @@ try {
plugin.register(api);
assert.equal(services.length, 1, "plugin should register its background service");
assert.equal(typeof api.hooks.agent_end, "function", "autoCapture should remain enabled by default");
assert.equal(api.hooks["command:new"], undefined, "sessionMemory should stay disabled by default");
assert.equal(
api.hooks.before_reset,
undefined,
"sessionMemory should stay disabled by default",
);
await assert.doesNotReject(
services[0].stop(),
"service stop should not throw when no access tracker is configured",
Expand All @@ -173,9 +180,9 @@ try {
});
plugin.register(sessionDefaultApi);
assert.equal(
sessionDefaultApi.hooks["command:new"],
sessionDefaultApi.hooks.before_reset,
undefined,
"sessionMemory:{} should not implicitly enable the /new hook",
"sessionMemory:{} should not implicitly enable the before_reset hook",
);

const sessionEnabledApi = createMockApi({
Expand All @@ -198,9 +205,9 @@ try {
"sessionMemory.enabled=true should register the async before_reset hook",
);
assert.equal(
sessionEnabledApi.hooks["command:new"],
undefined,
"sessionMemory.enabled=true should not register the blocking command:new hook",
typeof sessionEnabledApi.hooks["command:new"],
"function",
"command:new may still be registered by other integrations such as self-improvement",
);

const longText = `${"Long embedding payload. ".repeat(420)}tail`;
Expand Down
3 changes: 3 additions & 0 deletions test/recall-text-cleanup.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ function createPluginApiHarness({ pluginConfig, resolveRoot }) {
registerTool() {},
registerCli() {},
registerService() {},
registerMemoryPromptSection() {},
registerMemoryFlushPlan() {},
registerMemoryRuntime() {},
on(eventName, handler, meta) {
const list = eventHandlers.get(eventName) || [];
list.push({ handler, meta });
Expand Down
3 changes: 3 additions & 0 deletions test/reflection-bypass-hook.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ function createPluginApiHarness({ pluginConfig, resolveRoot }) {
registerTool() {},
registerCli() {},
registerService() {},
registerMemoryPromptSection() {},
registerMemoryFlushPlan() {},
registerMemoryRuntime() {},
on(eventName, handler, meta) {
const list = eventHandlers.get(eventName) || [];
list.push({ handler, meta });
Expand Down
6 changes: 5 additions & 1 deletion test/session-summary-before-reset.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,11 @@ describe("systemSessionMemory before_reset", () => {
memoryLanceDBProPlugin.register(api);

assert.equal(typeof api.hooks.before_reset, "function");
assert.equal(api.hooks["command:new"], undefined);
assert.equal(
typeof api.hooks["command:new"],
"function",
"command:new may be occupied by other default integrations such as self-improvement",
);

await api.hooks.before_reset(
{
Expand Down