@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.
Files changed (87) hide show
  1. package/README.md +24 -12
  2. package/bm-client.ts +302 -112
  3. package/commands/cli.ts +5 -6
  4. package/commands/slash.ts +4 -4
  5. package/context-engine/basic-memory-context-engine.ts +74 -9
  6. package/context-engine/types.ts +21 -0
  7. package/dist/bm-client.d.ts +61 -12
  8. package/dist/bm-client.js +250 -101
  9. package/dist/commands/cli.d.ts +1 -1
  10. package/dist/commands/cli.js +5 -5
  11. package/dist/commands/slash.d.ts +1 -1
  12. package/dist/commands/slash.js +4 -3
  13. package/dist/context-engine/basic-memory-context-engine.d.ts +20 -1
  14. package/dist/context-engine/basic-memory-context-engine.js +50 -2
  15. package/dist/context-engine/types.d.ts +9 -0
  16. package/dist/context-engine/types.js +1 -0
  17. package/dist/index.d.ts +10 -4
  18. package/dist/index.js +79 -69
  19. package/dist/memory/basic-memory-capability.d.ts +19 -0
  20. package/dist/memory/basic-memory-capability.js +190 -0
  21. package/dist/tools/build-context.d.ts +1 -1
  22. package/dist/tools/build-context.js +12 -4
  23. package/dist/tools/delete-note.d.ts +1 -1
  24. package/dist/tools/delete-note.js +2 -1
  25. package/dist/tools/edit-note.d.ts +1 -1
  26. package/dist/tools/edit-note.js +22 -12
  27. package/dist/tools/list-memory-projects.d.ts +1 -1
  28. package/dist/tools/list-memory-projects.js +2 -1
  29. package/dist/tools/list-workspaces.d.ts +1 -1
  30. package/dist/tools/list-workspaces.js +4 -4
  31. package/dist/tools/move-note.d.ts +1 -1
  32. package/dist/tools/move-note.js +2 -1
  33. package/dist/tools/read-note.d.ts +1 -1
  34. package/dist/tools/read-note.js +2 -1
  35. package/dist/tools/schema-diff.d.ts +1 -1
  36. package/dist/tools/schema-diff.js +2 -1
  37. package/dist/tools/schema-infer.d.ts +1 -1
  38. package/dist/tools/schema-infer.js +2 -1
  39. package/dist/tools/schema-validate.d.ts +1 -1
  40. package/dist/tools/schema-validate.js +2 -1
  41. package/dist/tools/search-notes.d.ts +1 -1
  42. package/dist/tools/search-notes.js +13 -6
  43. package/dist/tools/write-note.d.ts +1 -1
  44. package/dist/tools/write-note.js +11 -12
  45. package/index.ts +93 -84
  46. package/memory/basic-memory-capability.ts +253 -0
  47. package/openclaw.plugin.json +0 -2
  48. package/package.json +6 -5
  49. package/skills/manifest.json +5 -0
  50. package/skills/memory-agents/SKILL.md +23 -10
  51. package/skills/memory-capture/SKILL.md +29 -18
  52. package/skills/memory-comark/SKILL.md +17 -6
  53. package/skills/memory-continue/SKILL.md +47 -27
  54. package/skills/memory-curate/SKILL.md +40 -24
  55. package/skills/memory-defrag/SKILL.md +18 -7
  56. package/skills/memory-doctor/SKILL.md +93 -0
  57. package/skills/memory-ingest/SKILL.md +33 -17
  58. package/skills/memory-lifecycle/SKILL.md +35 -22
  59. package/skills/memory-literary-analysis/SKILL.md +112 -75
  60. package/skills/memory-metadata-search/SKILL.md +39 -28
  61. package/skills/memory-notes/SKILL.md +75 -50
  62. package/skills/memory-okf/SKILL.md +27 -15
  63. package/skills/memory-onboarding/SKILL.md +20 -9
  64. package/skills/memory-onboarding/references/assistant-setup.md +1 -1
  65. package/skills/memory-onboarding/references/conventions.md +4 -4
  66. package/skills/memory-onboarding/references/domain-playbooks.md +1 -1
  67. package/skills/memory-onboarding/references/schema-guide.md +9 -8
  68. package/skills/memory-quest/SKILL.md +18 -7
  69. package/skills/memory-reflect/SKILL.md +25 -13
  70. package/skills/memory-research/SKILL.md +28 -13
  71. package/skills/memory-schema/SKILL.md +36 -24
  72. package/skills/memory-tasks/SKILL.md +34 -22
  73. package/tools/build-context.ts +14 -5
  74. package/tools/delete-note.ts +2 -2
  75. package/tools/edit-note.ts +27 -14
  76. package/tools/list-memory-projects.ts +6 -2
  77. package/tools/list-workspaces.ts +8 -5
  78. package/tools/move-note.ts +2 -2
  79. package/tools/read-note.ts +2 -2
  80. package/tools/schema-diff.ts +2 -2
  81. package/tools/schema-infer.ts +2 -2
  82. package/tools/schema-validate.ts +2 -2
  83. package/tools/search-notes.ts +18 -7
  84. package/tools/write-note.ts +12 -12
  85. package/dist/tools/memory-provider.d.ts +0 -15
  86. package/dist/tools/memory-provider.js +0 -275
  87. package/tools/memory-provider.ts +0 -336
@@ -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: "Failed to save memory. Is Basic Memory running?",
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 ? ` (${(r.score * 100).toFixed(0)}%)` : "";
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: "Failed to search. Is Basic Memory running?",
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 afterTurn path."
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
- configSchema: import("openclaw/plugin-sdk").OpenClawPluginConfigSchema;
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
- } & Pick<import("openclaw/plugin-sdk/plugin-entry").OpenClawPluginDefinition, "kind" | "reload" | "nodeHostCommands" | "securityAuditCollectors">;
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 { execFileSync } from "node:child_process";
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
- export function isCommandAvailable(command) {
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
- execFileSync(command, ["--version"], { stdio: "ignore" });
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 composited memory_search",
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
- // --- Composited memory_search + memory_get (always registered) ---
58
- registerMemoryProvider(api, client, cfg);
59
- log.info("registered composited memory_search + memory_get");
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
- await client.start({ cwd: workspace });
112
- await client.ensureProject(projectPath);
113
- log.debug(`project "${cfg.project}" at ${projectPath}`);
114
- // Seed schemas if not already present
115
- for (const [name, content] of [
116
- ["Task", TASK_SCHEMA_CONTENT],
117
- ["Conversation", CONVERSATION_SCHEMA_CONTENT],
118
- ]) {
119
- try {
120
- await client.readNote(`schema/${name}`);
121
- log.debug(`${name} schema already exists, skipping seed`);
122
- }
123
- catch {
124
- try {
125
- await client.writeNote(name, content, "schema");
126
- log.debug(`seeded ${name} schema note`);
127
- }
128
- catch (err) {
129
- log.debug(`${name} schema seed failed (non-fatal)`, err);
130
- }
131
- }
132
- }
133
- setWorkspaceDir(workspace);
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
+ }
@@ -1,3 +1,3 @@
1
1
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
2
- import type { BmClient } from "../bm-client.ts";
2
+ import { type BmClient } from "../bm-client.ts";
3
3
  export declare function registerContextTool(api: OpenClawPluginApi, client: BmClient): void;