@basicmemory/openclaw-basic-memory 0.24.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -12
- package/bm-client.ts +302 -112
- package/commands/cli.ts +5 -6
- package/commands/slash.ts +4 -4
- package/context-engine/basic-memory-context-engine.ts +74 -9
- package/context-engine/types.ts +21 -0
- package/dist/bm-client.d.ts +61 -12
- package/dist/bm-client.js +250 -101
- package/dist/commands/cli.d.ts +1 -1
- package/dist/commands/cli.js +5 -5
- package/dist/commands/slash.d.ts +1 -1
- package/dist/commands/slash.js +4 -3
- package/dist/context-engine/basic-memory-context-engine.d.ts +20 -1
- package/dist/context-engine/basic-memory-context-engine.js +50 -2
- package/dist/context-engine/types.d.ts +9 -0
- package/dist/context-engine/types.js +1 -0
- package/dist/index.d.ts +10 -4
- package/dist/index.js +79 -69
- package/dist/memory/basic-memory-capability.d.ts +19 -0
- package/dist/memory/basic-memory-capability.js +190 -0
- package/dist/tools/build-context.d.ts +1 -1
- package/dist/tools/build-context.js +12 -4
- package/dist/tools/delete-note.d.ts +1 -1
- package/dist/tools/delete-note.js +2 -1
- package/dist/tools/edit-note.d.ts +1 -1
- package/dist/tools/edit-note.js +22 -12
- package/dist/tools/list-memory-projects.d.ts +1 -1
- package/dist/tools/list-memory-projects.js +2 -1
- package/dist/tools/list-workspaces.d.ts +1 -1
- package/dist/tools/list-workspaces.js +4 -4
- package/dist/tools/move-note.d.ts +1 -1
- package/dist/tools/move-note.js +2 -1
- package/dist/tools/read-note.d.ts +1 -1
- package/dist/tools/read-note.js +2 -1
- package/dist/tools/schema-diff.d.ts +1 -1
- package/dist/tools/schema-diff.js +2 -1
- package/dist/tools/schema-infer.d.ts +1 -1
- package/dist/tools/schema-infer.js +2 -1
- package/dist/tools/schema-validate.d.ts +1 -1
- package/dist/tools/schema-validate.js +2 -1
- package/dist/tools/search-notes.d.ts +1 -1
- package/dist/tools/search-notes.js +13 -6
- package/dist/tools/write-note.d.ts +1 -1
- package/dist/tools/write-note.js +11 -12
- package/index.ts +93 -84
- package/memory/basic-memory-capability.ts +253 -0
- package/openclaw.plugin.json +0 -2
- package/package.json +6 -5
- package/skills/manifest.json +5 -0
- package/skills/memory-agents/SKILL.md +23 -10
- package/skills/memory-capture/SKILL.md +29 -18
- package/skills/memory-comark/SKILL.md +17 -6
- package/skills/memory-continue/SKILL.md +47 -27
- package/skills/memory-curate/SKILL.md +40 -24
- package/skills/memory-defrag/SKILL.md +18 -7
- package/skills/memory-doctor/SKILL.md +93 -0
- package/skills/memory-ingest/SKILL.md +33 -17
- package/skills/memory-lifecycle/SKILL.md +35 -22
- package/skills/memory-literary-analysis/SKILL.md +112 -75
- package/skills/memory-metadata-search/SKILL.md +39 -28
- package/skills/memory-notes/SKILL.md +75 -50
- package/skills/memory-okf/SKILL.md +27 -15
- package/skills/memory-onboarding/SKILL.md +20 -9
- package/skills/memory-onboarding/references/assistant-setup.md +1 -1
- package/skills/memory-onboarding/references/conventions.md +4 -4
- package/skills/memory-onboarding/references/domain-playbooks.md +1 -1
- package/skills/memory-onboarding/references/schema-guide.md +9 -8
- package/skills/memory-quest/SKILL.md +18 -7
- package/skills/memory-reflect/SKILL.md +25 -13
- package/skills/memory-research/SKILL.md +28 -13
- package/skills/memory-schema/SKILL.md +36 -24
- package/skills/memory-tasks/SKILL.md +34 -22
- package/tools/build-context.ts +14 -5
- package/tools/delete-note.ts +2 -2
- package/tools/edit-note.ts +27 -14
- package/tools/list-memory-projects.ts +6 -2
- package/tools/list-workspaces.ts +8 -5
- package/tools/move-note.ts +2 -2
- package/tools/read-note.ts +2 -2
- package/tools/schema-diff.ts +2 -2
- package/tools/schema-infer.ts +2 -2
- package/tools/schema-validate.ts +2 -2
- package/tools/search-notes.ts +18 -7
- package/tools/write-note.ts +12 -12
- package/dist/tools/memory-provider.d.ts +0 -15
- package/dist/tools/memory-provider.js +0 -275
- package/tools/memory-provider.ts +0 -336
package/dist/commands/slash.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execSync } from "node:child_process";
|
|
2
2
|
import { dirname, resolve } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { describeToolError } from "../bm-client.js";
|
|
4
5
|
import { log } from "../logger.js";
|
|
5
6
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
7
|
export function registerCommands(api, client) {
|
|
@@ -52,7 +53,7 @@ export function registerCommands(api, client) {
|
|
|
52
53
|
catch (err) {
|
|
53
54
|
log.error("/remember failed", err);
|
|
54
55
|
return {
|
|
55
|
-
text:
|
|
56
|
+
text: `Failed to save memory: ${describeToolError(err)}`,
|
|
56
57
|
};
|
|
57
58
|
}
|
|
58
59
|
},
|
|
@@ -74,7 +75,7 @@ export function registerCommands(api, client) {
|
|
|
74
75
|
return { text: `No notes found for: "${query}"` };
|
|
75
76
|
}
|
|
76
77
|
const lines = results.map((r, i) => {
|
|
77
|
-
const score = r.score ? ` (${
|
|
78
|
+
const score = r.score ? ` (score ${r.score.toFixed(2)})` : "";
|
|
78
79
|
return `${i + 1}. ${r.title}${score}`;
|
|
79
80
|
});
|
|
80
81
|
return {
|
|
@@ -84,7 +85,7 @@ export function registerCommands(api, client) {
|
|
|
84
85
|
catch (err) {
|
|
85
86
|
log.error("/recall failed", err);
|
|
86
87
|
return {
|
|
87
|
-
text:
|
|
88
|
+
text: `Failed to search: ${describeToolError(err)}`,
|
|
88
89
|
};
|
|
89
90
|
}
|
|
90
91
|
},
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import type { AssembleResult, BootstrapResult, CompactResult, ContextEngine, SubagentSpawnPreparation } from "openclaw/plugin-sdk";
|
|
2
1
|
import type { BmClient } from "../bm-client.ts";
|
|
3
2
|
import type { BasicMemoryConfig } from "../config.ts";
|
|
3
|
+
import type { AssembleResult, BootstrapResult, CommitTurnResult, CompactResult, ContextEngine, SubagentSpawnPreparation } from "./types.ts";
|
|
4
4
|
export declare const MAX_ASSEMBLE_RECALL_CHARS = 1200;
|
|
5
5
|
type BootstrapParams = Parameters<NonNullable<ContextEngine["bootstrap"]>>[0];
|
|
6
6
|
type AssembleParams = Parameters<ContextEngine["assemble"]>[0];
|
|
7
7
|
type AfterTurnParams = Parameters<NonNullable<ContextEngine["afterTurn"]>>[0];
|
|
8
|
+
type CommitTurnParams = Parameters<NonNullable<ContextEngine["commitTurn"]>>[0];
|
|
8
9
|
type CompactParams = Parameters<ContextEngine["compact"]>[0];
|
|
9
10
|
type PrepareSubagentSpawnParams = Parameters<NonNullable<ContextEngine["prepareSubagentSpawn"]>>[0];
|
|
10
11
|
type OnSubagentEndedParams = Parameters<NonNullable<ContextEngine["onSubagentEnded"]>>[0];
|
|
@@ -16,6 +17,10 @@ export declare class BasicMemoryContextEngine implements ContextEngine {
|
|
|
16
17
|
readonly name: "Basic Memory Context Engine";
|
|
17
18
|
readonly version: "0.1.5";
|
|
18
19
|
readonly ownsCompaction: false;
|
|
20
|
+
readonly transcriptSemantics: {
|
|
21
|
+
readonly currentTurnFence: "before-current-turn-entry-v1";
|
|
22
|
+
readonly turnAdvancementIdempotency: "atomic-idempotent-v1";
|
|
23
|
+
};
|
|
19
24
|
};
|
|
20
25
|
private readonly sessionState;
|
|
21
26
|
private readonly subagentState;
|
|
@@ -25,6 +30,20 @@ export declare class BasicMemoryContextEngine implements ContextEngine {
|
|
|
25
30
|
ingested: boolean;
|
|
26
31
|
}>;
|
|
27
32
|
assemble(params: AssembleParams): Promise<AssembleResult>;
|
|
33
|
+
/**
|
|
34
|
+
* Capture one accepted turn on hosts with durable turn advancement.
|
|
35
|
+
*
|
|
36
|
+
* The host calls this instead of afterTurn() for admitted turns, and may
|
|
37
|
+
* call it again with the same advancement key after a crash. Errors
|
|
38
|
+
* propagate so the host keeps the turn queued and retries it.
|
|
39
|
+
*/
|
|
40
|
+
commitTurn(params: CommitTurnParams): Promise<CommitTurnResult>;
|
|
41
|
+
/**
|
|
42
|
+
* Capture fallback for hosts that predate commitTurn().
|
|
43
|
+
*
|
|
44
|
+
* Hosts that route a turn through commitTurn() skip afterTurn() for that
|
|
45
|
+
* turn, so the two paths never record the same exchange.
|
|
46
|
+
*/
|
|
28
47
|
afterTurn(params: AfterTurnParams): Promise<void>;
|
|
29
48
|
compact(params: CompactParams): Promise<CompactResult>;
|
|
30
49
|
prepareSubagentSpawn(params: PrepareSubagentSpawnParams): Promise<SubagentSpawnPreparation | undefined>;
|
|
@@ -6,6 +6,9 @@ export const MAX_ASSEMBLE_RECALL_CHARS = 1200;
|
|
|
6
6
|
const TRUNCATED_RECALL_SUFFIX = "\n\n[Basic Memory recall truncated]";
|
|
7
7
|
const SUBAGENT_HANDOFF_FOLDER = "agent/subagents";
|
|
8
8
|
const MAX_SUBAGENT_RECALL_CHARS = 800;
|
|
9
|
+
// OpenClaw's reply for "nothing to say", used by heartbeat polls and silent
|
|
10
|
+
// maintenance turns.
|
|
11
|
+
const SILENT_REPLY_TOKEN = "NO_REPLY";
|
|
9
12
|
function boundRecallContext(context) {
|
|
10
13
|
if (context.length <= MAX_ASSEMBLE_RECALL_CHARS) {
|
|
11
14
|
return context;
|
|
@@ -15,6 +18,13 @@ function boundRecallContext(context) {
|
|
|
15
18
|
.trimEnd();
|
|
16
19
|
return `${trimmed}${TRUNCATED_RECALL_SUFFIX}`;
|
|
17
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Heartbeat polls and silent replies are host housekeeping, not
|
|
23
|
+
* conversation, so they are acknowledged without being recorded.
|
|
24
|
+
*/
|
|
25
|
+
function isHousekeepingTurn(isHeartbeat, turn) {
|
|
26
|
+
return (isHeartbeat === true || turn.assistantText.trim() === SILENT_REPLY_TOKEN);
|
|
27
|
+
}
|
|
18
28
|
function slugifySessionKey(sessionKey) {
|
|
19
29
|
return sessionKey
|
|
20
30
|
.toLowerCase()
|
|
@@ -43,7 +53,7 @@ function buildSubagentHandoffContent(params) {
|
|
|
43
53
|
}
|
|
44
54
|
function buildSubagentCompletionUpdate(params) {
|
|
45
55
|
const statusLine = params.reason === "completed"
|
|
46
|
-
? "Child run completed. Durable conversation capture continues through the normal
|
|
56
|
+
? "Child run completed. Durable conversation capture continues through the normal turn commit path."
|
|
47
57
|
: `Child run ended with reason: ${params.reason}.`;
|
|
48
58
|
return [
|
|
49
59
|
"",
|
|
@@ -63,6 +73,18 @@ export class BasicMemoryContextEngine {
|
|
|
63
73
|
name: "Basic Memory Context Engine",
|
|
64
74
|
version: "0.1.5",
|
|
65
75
|
ownsCompaction: false,
|
|
76
|
+
// OpenClaw 2026.10+ runs an engine for durable turns only when it
|
|
77
|
+
// declares both semantics; otherwise the turn degrades to "legacy" and
|
|
78
|
+
// nothing is captured (#1880).
|
|
79
|
+
// - The fence holds because assemble() passes the host's messages through
|
|
80
|
+
// unchanged and the engine keeps no transcript of its own, so it never
|
|
81
|
+
// reads past the admitted user entry.
|
|
82
|
+
// - Advancement is idempotent because commitTurn() records the
|
|
83
|
+
// advancement key in the conversation note and skips keys it has seen.
|
|
84
|
+
transcriptSemantics: {
|
|
85
|
+
currentTurnFence: "before-current-turn-entry-v1",
|
|
86
|
+
turnAdvancementIdempotency: "atomic-idempotent-v1",
|
|
87
|
+
},
|
|
66
88
|
};
|
|
67
89
|
sessionState = new Map();
|
|
68
90
|
subagentState = new Map();
|
|
@@ -104,13 +126,39 @@ export class BasicMemoryContextEngine {
|
|
|
104
126
|
systemPromptAddition: state?.recallContext,
|
|
105
127
|
};
|
|
106
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* Capture one accepted turn on hosts with durable turn advancement.
|
|
131
|
+
*
|
|
132
|
+
* The host calls this instead of afterTurn() for admitted turns, and may
|
|
133
|
+
* call it again with the same advancement key after a crash. Errors
|
|
134
|
+
* propagate so the host keeps the turn queued and retries it.
|
|
135
|
+
*/
|
|
136
|
+
async commitTurn(params) {
|
|
137
|
+
if (!this.cfg.autoCapture)
|
|
138
|
+
return { status: "committed" };
|
|
139
|
+
// messages spans exactly the admitted user entry through the terminal
|
|
140
|
+
// entry, so the whole payload is the current turn.
|
|
141
|
+
const turn = selectCaptureTurn(params.messages, this.cfg.captureMinChars);
|
|
142
|
+
if (!turn || isHousekeepingTurn(params.isHeartbeat, turn)) {
|
|
143
|
+
return { status: "committed" };
|
|
144
|
+
}
|
|
145
|
+
log.debug(`context-engine commitTurn: session=${params.sessionId} user=${turn.userText.length} assistant=${turn.assistantText.length}`);
|
|
146
|
+
const status = await this.client.indexConversation(turn.userText, turn.assistantText, { turnKey: params.advancementKey });
|
|
147
|
+
return { status };
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Capture fallback for hosts that predate commitTurn().
|
|
151
|
+
*
|
|
152
|
+
* Hosts that route a turn through commitTurn() skip afterTurn() for that
|
|
153
|
+
* turn, so the two paths never record the same exchange.
|
|
154
|
+
*/
|
|
107
155
|
async afterTurn(params) {
|
|
108
156
|
if (!this.cfg.autoCapture)
|
|
109
157
|
return;
|
|
110
158
|
const newMessages = params.messages.slice(params.prePromptMessageCount);
|
|
111
159
|
const turn = selectCaptureTurn(newMessages, this.cfg.captureMinChars) ??
|
|
112
160
|
selectCaptureTurn(params.messages, this.cfg.captureMinChars);
|
|
113
|
-
if (!turn)
|
|
161
|
+
if (!turn || isHousekeepingTurn(params.isHeartbeat, turn))
|
|
114
162
|
return;
|
|
115
163
|
log.debug(`context-engine afterTurn: session=${params.sessionId} user=${turn.userText.length} assistant=${turn.assistantText.length}`);
|
|
116
164
|
try {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
|
2
|
+
type ContextEngineFactory = Parameters<OpenClawPluginApi["registerContextEngine"]>[1];
|
|
3
|
+
export type ContextEngine = Awaited<ReturnType<ContextEngineFactory>>;
|
|
4
|
+
export type AssembleResult = Awaited<ReturnType<ContextEngine["assemble"]>>;
|
|
5
|
+
export type BootstrapResult = Awaited<ReturnType<NonNullable<ContextEngine["bootstrap"]>>>;
|
|
6
|
+
export type CompactResult = Awaited<ReturnType<ContextEngine["compact"]>>;
|
|
7
|
+
export type SubagentSpawnPreparation = NonNullable<Awaited<ReturnType<NonNullable<ContextEngine["prepareSubagentSpawn"]>>>>;
|
|
8
|
+
export type CommitTurnResult = Awaited<ReturnType<NonNullable<ContextEngine["commitTurn"]>>>;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
|
-
export declare function isCommandAvailable(command: string): boolean
|
|
2
|
-
declare const _default: {
|
|
1
|
+
export declare function isCommandAvailable(command: string): Promise<boolean>;
|
|
2
|
+
declare const _default: Omit<{
|
|
3
3
|
id: string;
|
|
4
4
|
name: string;
|
|
5
5
|
description: string;
|
|
6
|
-
|
|
6
|
+
kind?: import("openclaw/plugin-sdk/plugin-entry").OpenClawPluginDefinition["kind"];
|
|
7
|
+
configSchema?: import("openclaw/plugin-sdk/plugin-entry").OpenClawPluginConfigSchema | (() => import("openclaw/plugin-sdk/plugin-entry").OpenClawPluginConfigSchema);
|
|
8
|
+
reload?: import("openclaw/plugin-sdk/plugin-entry").OpenClawPluginDefinition["reload"];
|
|
9
|
+
nodeHostCommands?: import("openclaw/plugin-sdk/plugin-entry").OpenClawPluginDefinition["nodeHostCommands"];
|
|
10
|
+
securityAuditCollectors?: import("openclaw/plugin-sdk/plugin-entry").OpenClawPluginDefinition["securityAuditCollectors"];
|
|
7
11
|
register: NonNullable<import("openclaw/plugin-sdk/plugin-entry").OpenClawPluginDefinition["register"]>;
|
|
8
|
-
}
|
|
12
|
+
}, "configSchema"> & {
|
|
13
|
+
configSchema: import("openclaw/plugin-sdk/plugin-entry").OpenClawPluginConfigSchema;
|
|
14
|
+
};
|
|
9
15
|
export default _default;
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { mkdir } from "node:fs/promises";
|
|
3
|
+
import { promisify } from "node:util";
|
|
2
4
|
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
|
3
5
|
import { BmClient } from "./bm-client.js";
|
|
4
6
|
import { registerCli } from "./commands/cli.js";
|
|
@@ -7,6 +9,7 @@ import { registerCommands } from "./commands/slash.js";
|
|
|
7
9
|
import { basicMemoryConfigSchema, parseConfig, resolveProjectPath, } from "./config.js";
|
|
8
10
|
import { BasicMemoryContextEngine } from "./context-engine/basic-memory-context-engine.js";
|
|
9
11
|
import { initLogger, log } from "./logger.js";
|
|
12
|
+
import { buildBasicMemoryCapability } from "./memory/basic-memory-capability.js";
|
|
10
13
|
import { CONVERSATION_SCHEMA_CONTENT } from "./schema/conversation-schema.js";
|
|
11
14
|
import { TASK_SCHEMA_CONTENT } from "./schema/task-schema.js";
|
|
12
15
|
import { registerContextTool } from "./tools/build-context.js";
|
|
@@ -14,7 +17,6 @@ import { registerDeleteTool } from "./tools/delete-note.js";
|
|
|
14
17
|
import { registerEditTool } from "./tools/edit-note.js";
|
|
15
18
|
import { registerProjectListTool } from "./tools/list-memory-projects.js";
|
|
16
19
|
import { registerWorkspaceListTool } from "./tools/list-workspaces.js";
|
|
17
|
-
import { registerMemoryProvider, setWorkspaceDir, } from "./tools/memory-provider.js";
|
|
18
20
|
import { registerMoveTool } from "./tools/move-note.js";
|
|
19
21
|
import { registerReadTool } from "./tools/read-note.js";
|
|
20
22
|
import { registerSchemaDiffTool } from "./tools/schema-diff.js";
|
|
@@ -22,19 +24,60 @@ import { registerSchemaInferTool } from "./tools/schema-infer.js";
|
|
|
22
24
|
import { registerSchemaValidateTool } from "./tools/schema-validate.js";
|
|
23
25
|
import { registerSearchTool } from "./tools/search-notes.js";
|
|
24
26
|
import { registerWriteTool } from "./tools/write-note.js";
|
|
25
|
-
|
|
27
|
+
const execFileAsync = promisify(execFile);
|
|
28
|
+
// Async so a probe or an install never blocks OpenClaw's event loop during
|
|
29
|
+
// plugin startup.
|
|
30
|
+
export async function isCommandAvailable(command) {
|
|
26
31
|
try {
|
|
27
|
-
|
|
32
|
+
await execFileAsync(command, ["--version"]);
|
|
28
33
|
return true;
|
|
29
34
|
}
|
|
30
35
|
catch {
|
|
31
36
|
return false;
|
|
32
37
|
}
|
|
33
38
|
}
|
|
39
|
+
async function installBasicMemoryIfMissing(bmBin) {
|
|
40
|
+
if (await isCommandAvailable(bmBin))
|
|
41
|
+
return;
|
|
42
|
+
log.info("bm CLI not found on PATH — attempting auto-install...");
|
|
43
|
+
if (!(await isCommandAvailable("uv"))) {
|
|
44
|
+
throw new Error("Cannot auto-install basic-memory: uv not found. " +
|
|
45
|
+
"Install uv first (brew install uv, or curl -LsSf https://astral.sh/uv/install.sh | sh), " +
|
|
46
|
+
"then restart the gateway.");
|
|
47
|
+
}
|
|
48
|
+
log.info("installing basic-memory via uv (this may take a minute)...");
|
|
49
|
+
// basic-memory pins a FastMCP pre-release; without this flag uv silently
|
|
50
|
+
// installs the last release that had none (#1338).
|
|
51
|
+
const { stdout } = await execFileAsync("uv", ["tool", "install", "basic-memory", "--prerelease=allow", "--force"], { encoding: "utf-8", timeout: 120_000 });
|
|
52
|
+
log.info(`basic-memory installed: ${stdout.trim().split("\n").pop()}`);
|
|
53
|
+
if (!(await isCommandAvailable(bmBin))) {
|
|
54
|
+
throw new Error("bm installed but not found on PATH. You may need to add uv's bin directory to your PATH (typically ~/.local/bin).");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async function seedSchemas(client) {
|
|
58
|
+
for (const [name, content] of [
|
|
59
|
+
["Task", TASK_SCHEMA_CONTENT],
|
|
60
|
+
["Conversation", CONVERSATION_SCHEMA_CONTENT],
|
|
61
|
+
]) {
|
|
62
|
+
try {
|
|
63
|
+
await client.readNote(`schema/${name}`);
|
|
64
|
+
log.debug(`${name} schema already exists, skipping seed`);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
try {
|
|
68
|
+
await client.writeNote(name, content, "schema");
|
|
69
|
+
log.debug(`seeded ${name} schema note`);
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
log.debug(`${name} schema seed failed (non-fatal)`, err);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
34
77
|
export default definePluginEntry({
|
|
35
78
|
id: "openclaw-basic-memory",
|
|
36
79
|
name: "Basic Memory",
|
|
37
|
-
description: "Local-first knowledge graph for OpenClaw — persistent memory with graph search and
|
|
80
|
+
description: "Local-first knowledge graph for OpenClaw — persistent memory with graph search, recall and conversation capture",
|
|
38
81
|
configSchema: basicMemoryConfigSchema,
|
|
39
82
|
register(api) {
|
|
40
83
|
const cfg = parseConfig(api.pluginConfig);
|
|
@@ -54,9 +97,14 @@ export default definePluginEntry({
|
|
|
54
97
|
registerSchemaValidateTool(api, client);
|
|
55
98
|
registerSchemaInferTool(api, client);
|
|
56
99
|
registerSchemaDiffTool(api, client);
|
|
57
|
-
// ---
|
|
58
|
-
|
|
59
|
-
|
|
100
|
+
// --- Memory capability ---
|
|
101
|
+
// OpenClaw 2026.10 owns the memory_search and memory_get tool names
|
|
102
|
+
// (memory-core registers them even when another plugin holds the memory
|
|
103
|
+
// slot), so the plugin serves the host through registerMemoryCapability:
|
|
104
|
+
// gateway memory search, status and doctor, Active Memory recall, and the
|
|
105
|
+
// pre-compaction flush.
|
|
106
|
+
api.registerMemoryCapability(buildBasicMemoryCapability(client));
|
|
107
|
+
log.info("registered Basic Memory memory capability");
|
|
60
108
|
api.registerContextEngine("openclaw-basic-memory", () => new BasicMemoryContextEngine(client, cfg));
|
|
61
109
|
log.info("registered Basic Memory context engine");
|
|
62
110
|
// --- Commands ---
|
|
@@ -68,70 +116,32 @@ export default definePluginEntry({
|
|
|
68
116
|
id: "openclaw-basic-memory",
|
|
69
117
|
start: async (ctx) => {
|
|
70
118
|
log.info("starting...");
|
|
71
|
-
// Auto-install bm CLI if not found
|
|
72
|
-
const bmBin = cfg.bmPath || "bm";
|
|
73
|
-
if (!isCommandAvailable(bmBin)) {
|
|
74
|
-
log.info("bm CLI not found on PATH — attempting auto-install...");
|
|
75
|
-
try {
|
|
76
|
-
if (!isCommandAvailable("uv")) {
|
|
77
|
-
throw new Error("uv not found");
|
|
78
|
-
}
|
|
79
|
-
log.info("installing basic-memory via uv (this may take a minute)...");
|
|
80
|
-
// basic-memory pins a FastMCP pre-release; without this flag uv
|
|
81
|
-
// silently installs the last release that had none (#1338).
|
|
82
|
-
const result = execFileSync("uv", [
|
|
83
|
-
"tool",
|
|
84
|
-
"install",
|
|
85
|
-
"basic-memory",
|
|
86
|
-
"--prerelease=allow",
|
|
87
|
-
"--force",
|
|
88
|
-
], {
|
|
89
|
-
encoding: "utf-8",
|
|
90
|
-
timeout: 120_000,
|
|
91
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
92
|
-
});
|
|
93
|
-
log.info(`basic-memory installed: ${result.trim().split("\n").pop()}`);
|
|
94
|
-
// Verify it worked
|
|
95
|
-
if (isCommandAvailable(bmBin)) {
|
|
96
|
-
log.info("bm CLI now available on PATH");
|
|
97
|
-
}
|
|
98
|
-
else {
|
|
99
|
-
log.error("bm installed but not found on PATH. You may need to add uv's bin directory to your PATH (typically ~/.local/bin).");
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
catch (_uvErr) {
|
|
103
|
-
log.error("Cannot auto-install basic-memory: uv not found. " +
|
|
104
|
-
"Install uv first (brew install uv, or curl -LsSf https://astral.sh/uv/install.sh | sh), " +
|
|
105
|
-
"then restart the gateway.");
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
119
|
const workspace = ctx.workspaceDir ?? process.cwd();
|
|
109
120
|
const projectPath = resolveProjectPath(cfg.projectPath, workspace);
|
|
110
121
|
cfg.projectPath = projectPath;
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
log.info("connected — BM MCP stdio session running");
|
|
122
|
+
// bm runs with the workspace as its working directory. OpenClaw
|
|
123
|
+
// creates its default workspace only on the first agent run, and
|
|
124
|
+
// spawning into a missing directory fails with an ENOENT that names
|
|
125
|
+
// the bm binary instead of the directory.
|
|
126
|
+
await mkdir(workspace, { recursive: true });
|
|
127
|
+
// OpenClaw gives a service about 5 s to start. A first start (bm
|
|
128
|
+
// install, cold imports, first indexing) takes far longer, and a
|
|
129
|
+
// timed-out start fails the plugin. So startup runs in the
|
|
130
|
+
// background; tool calls wait for it inside BmClient, and a failed
|
|
131
|
+
// startup is retried by the next tool call instead of failing here.
|
|
132
|
+
client
|
|
133
|
+
.start({
|
|
134
|
+
cwd: workspace,
|
|
135
|
+
projectPath,
|
|
136
|
+
prepare: () => installBasicMemoryIfMissing(cfg.bmPath || "bm"),
|
|
137
|
+
})
|
|
138
|
+
.then(async () => {
|
|
139
|
+
log.info("connected — BM MCP stdio session running");
|
|
140
|
+
await seedSchemas(client);
|
|
141
|
+
})
|
|
142
|
+
.catch((err) => {
|
|
143
|
+
log.error("Basic Memory startup failed; the next tool call will retry", err);
|
|
144
|
+
});
|
|
135
145
|
},
|
|
136
146
|
stop: async () => {
|
|
137
147
|
log.info("stopping BM MCP session...");
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { MemoryPluginCapability } from "openclaw/plugin-sdk/memory-host-core";
|
|
2
|
+
import { type BmClient } from "../bm-client.ts";
|
|
3
|
+
type MemoryProviderRuntime = NonNullable<MemoryPluginCapability["providerRuntime"]>;
|
|
4
|
+
type MemoryProviderHandle = NonNullable<Awaited<ReturnType<MemoryProviderRuntime["open"]>>["provider"]>;
|
|
5
|
+
type MemoryCallerContext = Parameters<MemoryProviderRuntime["open"]>[0]["context"];
|
|
6
|
+
export declare const MEMORY_PROVIDER_ID = "openclaw-basic-memory";
|
|
7
|
+
export declare const RECALL_TOOL_NAMES: readonly ["search_notes", "read_note", "build_context"];
|
|
8
|
+
/**
|
|
9
|
+
* Open a caller-bound reader over the plugin's shared BM MCP session.
|
|
10
|
+
*
|
|
11
|
+
* The host rechecks the caller's authority around every provider call;
|
|
12
|
+
* context.assertCurrent() is also called before and after each BM request so
|
|
13
|
+
* a revoked caller is refused rather than served. Every note in the project
|
|
14
|
+
* is visible to every caller, which matches what the plugin's own
|
|
15
|
+
* search_notes and read_note tools already expose.
|
|
16
|
+
*/
|
|
17
|
+
export declare function openBasicMemoryReader(client: BmClient, context: MemoryCallerContext): MemoryProviderHandle;
|
|
18
|
+
export declare function buildBasicMemoryCapability(client: BmClient): MemoryPluginCapability;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { isNoteNotFoundError, searchResultText, } from "../bm-client.js";
|
|
2
|
+
import { log } from "../logger.js";
|
|
3
|
+
export const MEMORY_PROVIDER_ID = "openclaw-basic-memory";
|
|
4
|
+
// Agent tools this plugin registers that Active Memory may use for deep
|
|
5
|
+
// recall, and that the pre-compaction flush may use to look before writing.
|
|
6
|
+
export const RECALL_TOOL_NAMES = [
|
|
7
|
+
"search_notes",
|
|
8
|
+
"read_note",
|
|
9
|
+
"build_context",
|
|
10
|
+
];
|
|
11
|
+
const FLUSH_PERSISTENCE_TOOL_NAMES = ["write_note", "edit_note"];
|
|
12
|
+
const FLUSH_LOOKUP_TOOL_NAMES = ["search_notes", "read_note"];
|
|
13
|
+
const DEFAULT_MAX_RESULTS = 10;
|
|
14
|
+
const MAX_EXCERPT_CHARS = 700;
|
|
15
|
+
// memory-core's flush prompts use the same token for "nothing to save".
|
|
16
|
+
const SILENT_REPLY_TOKEN = "NO_REPLY";
|
|
17
|
+
// --- Search and retrieval ---
|
|
18
|
+
/**
|
|
19
|
+
* Basic Memory fuses keyword and vector scores into [0, 1.3] (see
|
|
20
|
+
* search_reader.py FUSION_BONUS). OpenClaw compares provider scores against a
|
|
21
|
+
* caller's minScore on a 0–1 scale, so a fused score above 1 is capped at 1.
|
|
22
|
+
*/
|
|
23
|
+
function hostScore(score) {
|
|
24
|
+
return score === undefined ? undefined : Math.min(score, 1);
|
|
25
|
+
}
|
|
26
|
+
function excerptOf(content) {
|
|
27
|
+
return content.length > MAX_EXCERPT_CHARS
|
|
28
|
+
? `${content.slice(0, MAX_EXCERPT_CHARS)}…`
|
|
29
|
+
: content;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Apply the host's optional line window to a note body. `from` is 1-based,
|
|
33
|
+
* matching the host's memory_get contract.
|
|
34
|
+
*/
|
|
35
|
+
function sliceLines(text, from, lines) {
|
|
36
|
+
if (from === undefined && lines === undefined)
|
|
37
|
+
return { text };
|
|
38
|
+
const all = text.split("\n");
|
|
39
|
+
const start = Math.max(1, from ?? 1);
|
|
40
|
+
const count = lines ?? all.length - start + 1;
|
|
41
|
+
const window = all.slice(start - 1, start - 1 + count);
|
|
42
|
+
const end = start - 1 + window.length;
|
|
43
|
+
const truncated = end < all.length;
|
|
44
|
+
return {
|
|
45
|
+
text: window.join("\n"),
|
|
46
|
+
from: start,
|
|
47
|
+
lines: window.length,
|
|
48
|
+
truncated,
|
|
49
|
+
...(truncated ? { nextFrom: end + 1 } : {}),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Open a caller-bound reader over the plugin's shared BM MCP session.
|
|
54
|
+
*
|
|
55
|
+
* The host rechecks the caller's authority around every provider call;
|
|
56
|
+
* context.assertCurrent() is also called before and after each BM request so
|
|
57
|
+
* a revoked caller is refused rather than served. Every note in the project
|
|
58
|
+
* is visible to every caller, which matches what the plugin's own
|
|
59
|
+
* search_notes and read_note tools already expose.
|
|
60
|
+
*/
|
|
61
|
+
export function openBasicMemoryReader(client, context) {
|
|
62
|
+
const guarded = async (run) => {
|
|
63
|
+
context.assertCurrent();
|
|
64
|
+
const result = await run();
|
|
65
|
+
context.assertCurrent();
|
|
66
|
+
return result;
|
|
67
|
+
};
|
|
68
|
+
// Trigger: the reader opens while the plugin service is not running, as in
|
|
69
|
+
// one-shot CLI commands like `openclaw memory status`.
|
|
70
|
+
// Why: the first BM call then spawns a `bm mcp` child that nothing else
|
|
71
|
+
// will stop, and its open pipes keep the CLI process from exiting.
|
|
72
|
+
// Outcome: this reader owns that connection and stops it on close().
|
|
73
|
+
const ownsConnection = !client.isServiceRunning();
|
|
74
|
+
return {
|
|
75
|
+
capabilities: {
|
|
76
|
+
sources: ["memory"],
|
|
77
|
+
pagination: false,
|
|
78
|
+
candidates: [],
|
|
79
|
+
projectFilter: false,
|
|
80
|
+
},
|
|
81
|
+
async search(request) {
|
|
82
|
+
const results = await guarded(() => client.search(request.query, request.maxResults ?? DEFAULT_MAX_RESULTS));
|
|
83
|
+
const hits = results
|
|
84
|
+
.map((result) => ({ result, score: hostScore(result.score) }))
|
|
85
|
+
// Trigger: the caller set a score floor.
|
|
86
|
+
// Why: the host contract requires every provider to enforce minScore.
|
|
87
|
+
// Outcome: unscored or lower-scored hits are dropped.
|
|
88
|
+
.filter(({ score }) => request.minScore === undefined ||
|
|
89
|
+
(score !== undefined && score >= request.minScore))
|
|
90
|
+
.map(({ result, score }) => ({
|
|
91
|
+
reference: { providerId: MEMORY_PROVIDER_ID, id: result.permalink },
|
|
92
|
+
excerpt: excerptOf(searchResultText(result)),
|
|
93
|
+
source: result.file_path,
|
|
94
|
+
...(score === undefined ? {} : { score }),
|
|
95
|
+
}));
|
|
96
|
+
return { hits };
|
|
97
|
+
},
|
|
98
|
+
async get(request) {
|
|
99
|
+
try {
|
|
100
|
+
const note = await guarded(() => client.readNote(request.reference.id));
|
|
101
|
+
return {
|
|
102
|
+
status: "ok",
|
|
103
|
+
reference: { providerId: MEMORY_PROVIDER_ID, id: note.permalink },
|
|
104
|
+
...sliceLines(note.content, request.from, request.lines),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
// A missing note is an answer; any other failure must surface so the
|
|
109
|
+
// host does not report it as an empty success.
|
|
110
|
+
if (isNoteNotFoundError(err))
|
|
111
|
+
return { status: "not_found" };
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
async health() {
|
|
116
|
+
// Listing projects proves the BM MCP session answers without reading
|
|
117
|
+
// any note content, so it is safe for status-only callers.
|
|
118
|
+
try {
|
|
119
|
+
await guarded(() => client.listProjects());
|
|
120
|
+
return { status: "ready" };
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
log.warn(`memory health check failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
124
|
+
return {
|
|
125
|
+
status: "unavailable",
|
|
126
|
+
message: "Basic Memory is not reachable",
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
// In the gateway the BM MCP session is shared by the whole plugin and
|
|
131
|
+
// owned by its service, so closing one caller's reader releases nothing.
|
|
132
|
+
async close() {
|
|
133
|
+
if (ownsConnection)
|
|
134
|
+
await client.stop();
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
// --- Pre-compaction flush ---
|
|
139
|
+
const FLUSH_PROMPT = [
|
|
140
|
+
"Pre-compaction memory flush.",
|
|
141
|
+
"Save durable facts, decisions and open tasks from this session to Basic Memory.",
|
|
142
|
+
"Use search_notes or read_note first to find an existing note, then edit_note to update it;",
|
|
143
|
+
"use write_note only for a new topic.",
|
|
144
|
+
`If nothing is worth saving, reply with ${SILENT_REPLY_TOKEN}.`,
|
|
145
|
+
].join(" ");
|
|
146
|
+
const FLUSH_SYSTEM_PROMPT = [
|
|
147
|
+
"Pre-compaction memory flush turn.",
|
|
148
|
+
"The session is near auto-compaction; capture durable memories in Basic Memory with write_note or edit_note.",
|
|
149
|
+
`You may reply, but usually ${SILENT_REPLY_TOKEN} is correct.`,
|
|
150
|
+
].join(" ");
|
|
151
|
+
// Returning a tools-arm plan makes the host persist through this plugin's own
|
|
152
|
+
// tools instead of appending to a workspace memory file. The host has already
|
|
153
|
+
// honored agents.defaults.compaction.memoryFlush.enabled before calling this.
|
|
154
|
+
const resolveFlushPlan = () => ({
|
|
155
|
+
prompt: FLUSH_PROMPT,
|
|
156
|
+
systemPrompt: FLUSH_SYSTEM_PROMPT,
|
|
157
|
+
persistenceToolNames: FLUSH_PERSISTENCE_TOOL_NAMES,
|
|
158
|
+
lookupToolNames: FLUSH_LOOKUP_TOOL_NAMES,
|
|
159
|
+
});
|
|
160
|
+
// --- Prompt guidance ---
|
|
161
|
+
/**
|
|
162
|
+
* OpenClaw fills any capability field the slot owner leaves empty from other
|
|
163
|
+
* memory registrations, including memory-core's prompt section, which points
|
|
164
|
+
* agents at its file-only memory_search. Supplying our own section keeps the
|
|
165
|
+
* agent's guidance on the Basic Memory tools.
|
|
166
|
+
*/
|
|
167
|
+
function buildPromptSection(params) {
|
|
168
|
+
if (!params.availableTools.has("search_notes"))
|
|
169
|
+
return [];
|
|
170
|
+
return [
|
|
171
|
+
"## Basic Memory",
|
|
172
|
+
"Long-term memory lives in Basic Memory, a knowledge graph of Markdown notes.",
|
|
173
|
+
"Before answering questions about prior work, decisions, people or preferences, search it with search_notes and read the best match with read_note.",
|
|
174
|
+
"Earlier conversations are saved automatically in daily conversation notes.",
|
|
175
|
+
"memory_search and memory_get cover only the workspace MEMORY.md and memory/ files, not Basic Memory.",
|
|
176
|
+
];
|
|
177
|
+
}
|
|
178
|
+
// --- Capability ---
|
|
179
|
+
export function buildBasicMemoryCapability(client) {
|
|
180
|
+
return {
|
|
181
|
+
providerRuntime: {
|
|
182
|
+
async open({ context }) {
|
|
183
|
+
return { provider: openBasicMemoryReader(client, context) };
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
recallToolNames: RECALL_TOOL_NAMES,
|
|
187
|
+
providerFlushPlanResolver: resolveFlushPlan,
|
|
188
|
+
promptBuilder: buildPromptSection,
|
|
189
|
+
};
|
|
190
|
+
}
|