Skip to content
Closed
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
161 changes: 161 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1983,6 +1983,167 @@ const memoryLanceDBProPlugin = {
{ commands: ["memory-pro"] },
);

// ========================================================================
// Active Memory Runtime Registration
// ========================================================================

const activeMemoryPathPrefix = "memory-lancedb-pro/entries/";
const activeMemoryWorkspaceDir = getDefaultWorkspaceDir();

const formatActiveMemoryPath = (id: string) => `${activeMemoryPathPrefix}${id}.md`;

const parseActiveMemoryPath = (relPath: string): string | null => {
const normalized = relPath.trim().replace(/^\/+/, "");
if (!normalized.startsWith(activeMemoryPathPrefix) || !normalized.endsWith(".md")) {
return null;
}
const id = normalized.slice(activeMemoryPathPrefix.length, -3).trim();
return id || null;
};

const formatMemoryDocument = (entry: { id: string; text: string; category: string; scope: string; importance: number; timestamp: number }) => {
const updatedAt = new Date(entry.timestamp || Date.now()).toISOString();
return [
`# Memory ${entry.id}`,
``,
`- category: ${entry.category}`,
`- scope: ${entry.scope}`,
`- importance: ${entry.importance}`,
`- updatedAt: ${updatedAt}`,
``,
entry.text,
].join("\n");
};

const readMemoryDocumentWindow = (content: string, from?: number, lines?: number) => {
const allLines = content.split(/\r?\n/);
const safeFrom = Math.max(1, Math.floor(from ?? 1));
const safeLines = Math.max(1, Math.floor(lines ?? 200));
const start = safeFrom - 1;
const slice = allLines.slice(start, start + safeLines);
const nextFrom = start + slice.length < allLines.length ? start + slice.length + 1 : undefined;
return {
text: slice.join("\n"),
from: safeFrom,
lines: slice.length,
truncated: nextFrom !== undefined,
nextFrom,
};
};

api.registerMemoryCapability({
runtime: {
async getMemorySearchManager({ agentId }) {
try {
const accessibleScopes = scopeManager.getAccessibleScopes(agentId);
const stats = await store.stats(accessibleScopes);
const embeddingProbe = await embedder.test();
const cacheStats = embedder.cacheStats;
const providerStatus = {
backend: "builtin" as const,
provider: "memory-lancedb-pro",
model: config.embedding.model || "text-embedding-3-small",
files: stats.totalCount,
chunks: stats.totalCount,
workspaceDir: activeMemoryWorkspaceDir,
dbPath: resolvedDbPath,
sources: ["memory"] as const,
sourceCounts: [{ source: "memory" as const, files: stats.totalCount, chunks: stats.totalCount }],
cache: {
enabled: true,
entries: cacheStats.size,
},
vector: {
enabled: true,
available: embeddingProbe.success,
dims: vectorDim,
},
custom: {
plugin: "memory-lancedb-pro",
scopes: accessibleScopes,
scopeCounts: stats.scopeCounts,
categoryCounts: stats.categoryCounts,
},
};

return {
manager: {
async search(query, opts) {
const scopeFilter = accessibleScopes;
const results = await retriever.retrieve({
query,
limit: Math.max(1, Math.min(opts?.maxResults ?? 10, 50)),
scopeFilter,
source: "manual",
});
return results
.filter((result) => (opts?.minScore == null ? true : result.score >= opts.minScore))
.map((result) => {
const path = formatActiveMemoryPath(result.entry.id);
const lineCount = Math.max(1, result.entry.text.split(/\r?\n/).length);
return {
path,
startLine: 1,
endLine: lineCount,
score: result.score,
snippet: result.entry.text.slice(0, 280),
source: "memory" as const,
citation: `memory:${path}`,
};
});
},
async readFile(params) {
const id = parseActiveMemoryPath(params.relPath);
if (!id) {
throw new Error(`Unsupported memory document path: ${params.relPath}`);
}
const entry = await store.getById(id, accessibleScopes);
if (!entry) {
throw new Error(`Memory document not found: ${params.relPath}`);
}
const content = formatMemoryDocument(entry);
return {
path: formatActiveMemoryPath(entry.id),
...readMemoryDocumentWindow(content, params.from, params.lines),
};
},
status() {
return providerStatus;
},
async probeEmbeddingAvailability() {
const probe = await embedder.test();
return {
ok: probe.success,
error: probe.error,
};
},
async probeVectorAvailability() {
const probe = await embedder.test();
return probe.success;
},
async sync() {
return;
},
async close() {
return;
},
},
};
} catch (err) {
return {
manager: null,
error: err instanceof Error ? err.message : String(err),
};
}
},
resolveMemoryBackendConfig() {
return { backend: "builtin" as const };
},
},
});

api.logger.info("memory-lancedb-pro: active memory runtime registered");

// ========================================================================
// Lifecycle Hooks
// ========================================================================
Expand Down
5 changes: 5 additions & 0 deletions openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,11 @@
"description": "Fallback directory for Markdown mirror files when agent workspace is unknown"
}
}
},
"dreaming": {
"type": "object",
"additionalProperties": true,
"description": "Pass-through config for OpenClaw's dreaming sidecar when memory-lancedb-pro owns the memory slot."
}
},
"required": [
Expand Down
12 changes: 11 additions & 1 deletion test/plugin-manifest-regression.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ function createMockApi(pluginConfig, options = {}) {
pluginConfig,
hooks: {},
toolFactories: {},
memoryCapability: null,
memoryRuntime: null,
logger: {
info() {},
warn() {},
Expand All @@ -47,6 +49,12 @@ function createMockApi(pluginConfig, options = {}) {
registerService(service) {
options.services?.push(service);
},
registerMemoryCapability(capability) {
this.memoryCapability = capability;
},
registerMemoryRuntime(runtime) {
this.memoryRuntime = runtime;
},
on(name, handler) {
this.hooks[name] = handler;
},
Expand All @@ -56,7 +64,7 @@ function createMockApi(pluginConfig, options = {}) {
};
}

for (const key of ["smartExtraction", "extractMinMessages", "extractMaxChars"]) {
for (const key of ["smartExtraction", "extractMinMessages", "extractMaxChars", "dreaming"]) {
assert.ok(
Object.prototype.hasOwnProperty.call(manifest.configSchema.properties, key),
`configSchema should declare ${key}`,
Expand Down Expand Up @@ -116,6 +124,8 @@ try {
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(typeof api.memoryCapability?.runtime?.getMemorySearchManager, "function", "plugin should register active memory capability runtime");
assert.equal(typeof api.memoryCapability?.runtime?.resolveMemoryBackendConfig, "function", "plugin should expose backend config resolver for the active memory runtime");
await assert.doesNotReject(
services[0].stop(),
"service stop should not throw when no access tracker is configured",
Expand Down