From 1a754dcc71a3e6e46cf6883c522f2436e2016ead Mon Sep 17 00:00:00 2001 From: Drew Cain Date: Thu, 29 Oct 2026 14:51:13 -0500 Subject: [PATCH 1/2] fix(integrations): serve OpenClaw memory through registerMemoryCapability On OpenClaw 2026.10 the plugin's memory_search and memory_get never ran. The bundled Memory Core plugin registers both names even when another plugin owns the memory slot, so OpenClaw rejected ours ("plugin tool name conflict"). The plugin also never called registerMemoryCapability, so the host's memory consumers fell back to Memory Core's file index. Register an OpenClaw memory capability (memory/basic-memory-capability.ts): - providerRuntime: search, get, health and close over the shared BmClient. Gateway memory.search v2, memory.get, memory.status and doctor now read Basic Memory. Fused scores (0-1.3) are capped at 1 for the host's 0-1 minScore scale, and a missing note returns not_found while other failures propagate. - recallToolNames: search_notes, read_note, build_context for Active Memory. - providerFlushPlanResolver: a tools-arm pre-compaction flush that saves through write_note and edit_note instead of a workspace file. - promptBuilder: a system-prompt section pointing agents at search_notes. Without it OpenClaw fills the field from Memory Core, whose section points at its file-only memory_search. Remove the dead memory_search/memory_get registrations (tools/memory- provider.ts) and drop them from the plugin manifest. Show search scores as raw numbers ("score 1.18") in search_notes, /recall and the CLI. They were multiplied by 100 and shown as a percentage, so a strong match on both keyword and vector signals displayed as "118%". Docs: README, CLAUDE.md and DEVELOPMENT.md describe the capability; MEMORY_TASK_FLOW.md is marked superseded. Verified live on OpenClaw 2026.10.9: memory.status returns {providerId: openclaw-basic-memory, status: ready}; memory.search v2 and memory.get return Basic Memory notes, line windows and not_found; no tool name conflicts after a gateway restart. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Drew Cain --- integrations/openclaw/CLAUDE.md | 11 +- integrations/openclaw/DEVELOPMENT.md | 7 +- integrations/openclaw/MEMORY_TASK_FLOW.md | 2 + integrations/openclaw/README.md | 20 +- integrations/openclaw/bm-client.ts | 2 +- integrations/openclaw/commands/cli.ts | 2 +- integrations/openclaw/commands/slash.ts | 2 +- integrations/openclaw/index.test.ts | 15 + integrations/openclaw/index.ts | 20 +- .../memory/basic-memory-capability.test.ts | 202 +++++++++++ .../memory/basic-memory-capability.ts | 240 +++++++++++++ integrations/openclaw/openclaw.plugin.json | 2 - integrations/openclaw/package.json | 2 +- .../openclaw/tools/memory-provider.test.ts | 149 -------- .../openclaw/tools/memory-provider.ts | 336 ------------------ .../openclaw/tools/search-notes.test.ts | 10 +- integrations/openclaw/tools/search-notes.ts | 2 +- 17 files changed, 505 insertions(+), 519 deletions(-) create mode 100644 integrations/openclaw/memory/basic-memory-capability.test.ts create mode 100644 integrations/openclaw/memory/basic-memory-capability.ts delete mode 100644 integrations/openclaw/tools/memory-provider.test.ts delete mode 100644 integrations/openclaw/tools/memory-provider.ts diff --git a/integrations/openclaw/CLAUDE.md b/integrations/openclaw/CLAUDE.md index d8fc427e1..d028cada7 100644 --- a/integrations/openclaw/CLAUDE.md +++ b/integrations/openclaw/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code when working with the OpenClaw packag ## Project Overview -`@basicmemory/openclaw-basic-memory` is a TypeScript OpenClaw plugin that integrates [Basic Memory](https://github.com/basicmachines-co/basic-memory) with the OpenClaw agent framework. It lives under `integrations/openclaw/` in the monorepo, manages a persistent MCP stdio session to a `bm mcp` process, exposes 14 agent tools (including workspace/project management and cross-project operations), composited memory search/get providers, slash commands, CLI commands, and optional auto-capture of conversations. +`@basicmemory/openclaw-basic-memory` is a TypeScript OpenClaw plugin that integrates [Basic Memory](https://github.com/basicmachines-co/basic-memory) with the OpenClaw agent framework. It lives under `integrations/openclaw/` in the monorepo, manages a persistent MCP stdio session to a `bm mcp` process, exposes 12 agent tools (including workspace/project management and cross-project operations), registers an OpenClaw memory capability and context engine, slash commands, CLI commands, and optional auto-capture of conversations. ## Development Commands @@ -66,7 +66,14 @@ Central orchestration layer that: Each tool file exports a function that calls `api.registerTool()` with a TypeBox schema and handler. Tools delegate to `BmClient` methods and return OpenClaw-standard responses (`{ content: [{type: "text", text}], details? }`). - `search-notes.ts`, `read-note.ts`, `write-note.ts`, `edit-note.ts`, `delete-note.ts`, `move-note.ts`, `build-context.ts`, `list-memory-projects.ts`, `list-workspaces.ts`, `schema-validate.ts`, `schema-infer.ts`, `schema-diff.ts` — thin wrappers around `BmClient`; all content tools accept an optional `project` param for cross-project operations -- `memory-provider.ts` — composited `memory_search` + `memory_get` providers. `memory_search` queries 3 sources in parallel: MEMORY.md (grep), BM knowledge graph (FTS + vector), and active task notes (YAML frontmatter scan) + +### Memory capability (`memory/basic-memory-capability.ts`) + +Registered with `api.registerMemoryCapability`. OpenClaw 2026.10's bundled Memory Core plugin owns the `memory_search` and `memory_get` tool names, so the plugin does not register them. The capability supplies: +- `providerRuntime` — host-facing search, get, health and close over the shared `BmClient` (gateway `memory.search` v2, `memory.status`, doctor) +- `recallToolNames` — `search_notes`, `read_note`, `build_context` for Active Memory deep recall +- `providerFlushPlanResolver` — a tools-arm pre-compaction flush through `write_note` / `edit_note` +- `promptBuilder` — a system-prompt section steering agents to the Basic Memory tools ### Commands & Hooks diff --git a/integrations/openclaw/DEVELOPMENT.md b/integrations/openclaw/DEVELOPMENT.md index 855c48208..4c3b86a6a 100644 --- a/integrations/openclaw/DEVELOPMENT.md +++ b/integrations/openclaw/DEVELOPMENT.md @@ -118,8 +118,11 @@ openclaw-basic-memory/ │ ├── list-workspaces.ts # list_workspaces │ ├── schema-validate.ts # schema_validate │ ├── schema-infer.ts # schema_infer -│ ├── schema-diff.ts # schema_diff -│ └── memory-provider.ts # Composited memory_search + memory_get +│ └── schema-diff.ts # schema_diff +├── memory/ +│ └── basic-memory-capability.ts # OpenClaw memory capability (host search, recall tools, flush) +├── context-engine/ +│ └── basic-memory-context-engine.ts # Auto-recall and conversation capture ├── commands/ │ ├── slash.ts # /remember, /recall │ ├── skills.ts # /tasks, /reflect, /defrag, /schema diff --git a/integrations/openclaw/MEMORY_TASK_FLOW.md b/integrations/openclaw/MEMORY_TASK_FLOW.md index 9f2d5d83c..de9cd43c5 100644 --- a/integrations/openclaw/MEMORY_TASK_FLOW.md +++ b/integrations/openclaw/MEMORY_TASK_FLOW.md @@ -2,6 +2,8 @@ This document explains how `openclaw-basic-memory` manages short-term memory, long-term memory, and active tasks during normal agent usage. +> **Superseded on OpenClaw 2026.10 and later.** The combined `memory_search` described below no longer runs. OpenClaw's bundled Memory Core plugin owns the `memory_search` and `memory_get` tool names, so this plugin now registers an OpenClaw memory capability instead (`memory/basic-memory-capability.ts`). Agents search Basic Memory with `search_notes` and read notes with `read_note`; see the README's "Memory search" section. The note and task conventions below still apply. + ## Mental Model The plugin composes memory from three places: diff --git a/integrations/openclaw/README.md b/integrations/openclaw/README.md index 305fc22f2..c90be8830 100644 --- a/integrations/openclaw/README.md +++ b/integrations/openclaw/README.md @@ -33,7 +33,7 @@ This plugin connects Basic Memory to OpenClaw so your agent can: - **Remember across sessions** — search and recall past conversations, decisions, and context - **Track work in progress** — structured tasks that survive context compaction - **Build knowledge over time** — notes, observations, and relations that grow into a connected graph -- **Search intelligently** — composited search across working memory, the knowledge graph, and active tasks in parallel +- **Search intelligently** — hybrid full-text and vector search across the knowledge graph All data stays on your machine as Markdown files indexed locally with SQLite. Cloud sync is available but entirely optional. @@ -60,7 +60,7 @@ openclaw gateway restart The plugin fills two OpenClaw slots, and each one does a different job: -- **`memory`** provides the `memory_search` and `memory_get` tools. +- **`memory`** registers Basic Memory as OpenClaw's memory provider: gateway memory search and status, `openclaw doctor` health, Active Memory recall, and the pre-compaction memory flush. - **`contextEngine`** runs automatic recall at session start and automatic conversation capture after each turn. **Without this slot, nothing is captured or recalled automatically**; the agent only remembers what it saves with an explicit tool call. Tested with OpenClaw 2026.10.9. OpenClaw 2026.10 and later require context engines to commit turns through `commitTurn`, which this plugin implements; older plugin versions are degraded to OpenClaw's legacy engine on those hosts and capture nothing. @@ -141,13 +141,17 @@ This uses sensible defaults: auto-generated project name, maps to your workspace ### Memory search -When your agent calls `memory_search`, three sources are queried in parallel: +Agents search Basic Memory with `search_notes` and read results with `read_note`. The plugin adds a short system-prompt section that points the agent at these tools. -1. **MEMORY.md** — text search with surrounding context -2. **Knowledge Graph** — hybrid full-text + vector search across all indexed notes -3. **Active Tasks** — scans `memory/tasks/` for in-progress work +OpenClaw's built-in `memory_search` and `memory_get` tools belong to its bundled Memory Core plugin, which OpenClaw keeps loaded even when this plugin holds the memory slot. Those tools search only the workspace `MEMORY.md` and `memory/` files, not Basic Memory. -Results come back in clear sections so the agent knows where each piece of context came from. +As the memory slot owner, the plugin also serves OpenClaw's own memory consumers: + +- **Gateway memory search, get and status, and `openclaw doctor`** read Basic Memory through the plugin's provider runtime. +- **Active Memory deep recall** uses `search_notes`, `read_note` and `build_context`. +- **Pre-compaction flush** saves durable context with `write_note` and `edit_note` instead of appending to a workspace file. + +Search scores are shown as raw numbers. A score above 1 means the note matched on both keywords and meaning. ### Auto-recall @@ -167,8 +171,6 @@ All tools accept an optional `project` parameter for cross-project operations. | Tool | Description | |------|-------------| -| `memory_search` | Composited search across all memory sources | -| `memory_get` | Read a specific note by title or path | | `search_notes` | Search the knowledge graph directly | | `read_note` | Read a note by title, permalink, or `memory://` URL | | `write_note` | Create or overwrite a note with optional tags, note type, and custom frontmatter metadata | diff --git a/integrations/openclaw/bm-client.ts b/integrations/openclaw/bm-client.ts index 76053abfc..4213b4312 100644 --- a/integrations/openclaw/bm-client.ts +++ b/integrations/openclaw/bm-client.ts @@ -246,7 +246,7 @@ function isRecoverableConnectionError(err: unknown): boolean { ) } -function isNoteNotFoundError(err: unknown): boolean { +export function isNoteNotFoundError(err: unknown): boolean { return ( err instanceof BmToolError && parseRefusalPayload(err.toolMessage)?.error === "NOT_FOUND" diff --git a/integrations/openclaw/commands/cli.ts b/integrations/openclaw/commands/cli.ts index b78cb86b7..a189a4242 100644 --- a/integrations/openclaw/commands/cli.ts +++ b/integrations/openclaw/commands/cli.ts @@ -31,7 +31,7 @@ export function registerCli( } for (const r of results) { - const score = r.score ? ` (${(r.score * 100).toFixed(0)}%)` : "" + const score = r.score ? ` (score ${r.score.toFixed(2)})` : "" console.log(`- ${r.title}${score}`) if (r.content) { const preview = diff --git a/integrations/openclaw/commands/slash.ts b/integrations/openclaw/commands/slash.ts index b9f29948a..d25a3411f 100644 --- a/integrations/openclaw/commands/slash.ts +++ b/integrations/openclaw/commands/slash.ts @@ -89,7 +89,7 @@ export function registerCommands( } const lines = results.map((r, i) => { - const score = r.score ? ` (${(r.score * 100).toFixed(0)}%)` : "" + const score = r.score ? ` (score ${r.score.toFixed(2)})` : "" return `${i + 1}. ${r.title}${score}` }) diff --git a/integrations/openclaw/index.test.ts b/integrations/openclaw/index.test.ts index 64fa67208..b566ad61a 100644 --- a/integrations/openclaw/index.test.ts +++ b/integrations/openclaw/index.test.ts @@ -47,6 +47,7 @@ describe("plugin service lifecycle", () => { registerCommand: jest.fn(), registerCli: jest.fn(), registerContextEngine: jest.fn(), + registerMemoryCapability: jest.fn(), registerService: jest.fn((service: any) => { services.push(service) }), @@ -62,6 +63,20 @@ describe("plugin service lifecycle", () => { ) expect(api.on).not.toHaveBeenCalled() + // memory-core owns memory_search and memory_get on OpenClaw 2026.10, so + // the plugin serves the host through its memory capability instead. + expect(api.registerMemoryCapability).toHaveBeenCalledWith( + expect.objectContaining({ + providerRuntime: expect.any(Object), + recallToolNames: ["search_notes", "read_note", "build_context"], + }), + ) + const toolNames = api.registerTool.mock.calls.map( + ([tool]) => (tool as { name: string }).name, + ) + expect(toolNames).not.toContain("memory_search") + expect(toolNames).not.toContain("memory_get") + await services[0].start({ workspaceDir: "/tmp/workspace" }) expect(startSpy).toHaveBeenCalledWith({ cwd: "/tmp/workspace" }) diff --git a/integrations/openclaw/index.ts b/integrations/openclaw/index.ts index cb5cfe045..c5ae80c09 100644 --- a/integrations/openclaw/index.ts +++ b/integrations/openclaw/index.ts @@ -12,6 +12,7 @@ import { } from "./config.ts" import { BasicMemoryContextEngine } from "./context-engine/basic-memory-context-engine.ts" import { initLogger, log } from "./logger.ts" +import { buildBasicMemoryCapability } from "./memory/basic-memory-capability.ts" import { CONVERSATION_SCHEMA_CONTENT } from "./schema/conversation-schema.ts" import { TASK_SCHEMA_CONTENT } from "./schema/task-schema.ts" import { registerContextTool } from "./tools/build-context.ts" @@ -19,10 +20,6 @@ import { registerDeleteTool } from "./tools/delete-note.ts" import { registerEditTool } from "./tools/edit-note.ts" import { registerProjectListTool } from "./tools/list-memory-projects.ts" import { registerWorkspaceListTool } from "./tools/list-workspaces.ts" -import { - registerMemoryProvider, - setWorkspaceDir, -} from "./tools/memory-provider.ts" import { registerMoveTool } from "./tools/move-note.ts" import { registerReadTool } from "./tools/read-note.ts" import { registerSchemaDiffTool } from "./tools/schema-diff.ts" @@ -44,7 +41,7 @@ export default definePluginEntry({ id: "openclaw-basic-memory", name: "Basic Memory", description: - "Local-first knowledge graph for OpenClaw — persistent memory with graph search and composited memory_search", + "Local-first knowledge graph for OpenClaw — persistent memory with graph search, recall and conversation capture", configSchema: basicMemoryConfigSchema, register(api) { @@ -72,9 +69,14 @@ export default definePluginEntry({ registerSchemaInferTool(api, client) registerSchemaDiffTool(api, client) - // --- Composited memory_search + memory_get (always registered) --- - registerMemoryProvider(api, client, cfg) - log.info("registered composited memory_search + memory_get") + // --- Memory capability --- + // OpenClaw 2026.10 owns the memory_search and memory_get tool names + // (memory-core registers them even when another plugin holds the memory + // slot), so the plugin serves the host through registerMemoryCapability: + // gateway memory search, status and doctor, Active Memory recall, and the + // pre-compaction flush. + api.registerMemoryCapability(buildBasicMemoryCapability(client)) + log.info("registered Basic Memory memory capability") api.registerContextEngine( "openclaw-basic-memory", () => new BasicMemoryContextEngine(client, cfg), @@ -166,8 +168,6 @@ export default definePluginEntry({ } } - setWorkspaceDir(workspace) - log.info("connected — BM MCP stdio session running") }, stop: async () => { diff --git a/integrations/openclaw/memory/basic-memory-capability.test.ts b/integrations/openclaw/memory/basic-memory-capability.test.ts new file mode 100644 index 000000000..a27315dc9 --- /dev/null +++ b/integrations/openclaw/memory/basic-memory-capability.test.ts @@ -0,0 +1,202 @@ +import { beforeEach, describe, expect, it, jest } from "bun:test" +import { type BmClient, BmToolError } from "../bm-client.ts" +import { + buildBasicMemoryCapability, + MEMORY_PROVIDER_ID, + openBasicMemoryReader, +} from "./basic-memory-capability.ts" + +type Context = Parameters[1] + +function makeContext(overrides?: Partial): Context { + return { + authority: { kind: "host", operation: "test" }, + assertCurrent: jest.fn(), + ...overrides, + } as Context +} + +const notFound = () => + new BmToolError( + "bm_read", + JSON.stringify({ error: "NOT_FOUND", message: "Missing note" }), + ) + +describe("Basic Memory memory capability", () => { + let client: { + search: jest.Mock + readNote: jest.Mock + listProjects: jest.Mock + } + + beforeEach(() => { + client = { + search: jest.fn().mockResolvedValue([ + { + title: "Quillfeather", + permalink: "pets/quillfeather", + content: "My test cat is named Quillfeather.", + file_path: "pets/quillfeather.md", + score: 1.18, + }, + { + title: "Weak match", + permalink: "misc/weak", + content: "Barely related.", + file_path: "misc/weak.md", + score: 0.2, + }, + ]), + readNote: jest.fn().mockResolvedValue({ + title: "Quillfeather", + permalink: "pets/quillfeather", + content: "line 1\nline 2\nline 3\nline 4", + file_path: "pets/quillfeather.md", + }), + listProjects: jest.fn().mockResolvedValue([]), + } + }) + + const reader = (context = makeContext()) => + openBasicMemoryReader(client as unknown as BmClient, context) + + it("declares memory-only search without pagination or candidates", () => { + expect(reader().capabilities).toEqual({ + sources: ["memory"], + pagination: false, + candidates: [], + projectFilter: false, + }) + }) + + it("maps BM results to provider hits with capped scores", async () => { + const page = await reader().search({ query: "test cat", maxResults: 3 }) + + expect(client.search).toHaveBeenCalledWith("test cat", 3) + expect(page.hits[0]).toEqual({ + reference: { providerId: MEMORY_PROVIDER_ID, id: "pets/quillfeather" }, + excerpt: "My test cat is named Quillfeather.", + source: "pets/quillfeather.md", + score: 1, + }) + }) + + it("enforces minScore and drops unscored hits when a floor is set", async () => { + client.search.mockResolvedValue([ + ...(await client.search()), + { + title: "Unscored", + permalink: "misc/unscored", + content: "x", + file_path: "misc/unscored.md", + }, + ]) + + const page = await reader().search({ query: "test cat", minScore: 0.5 }) + + expect(page.hits.map((hit) => hit.reference.id)).toEqual([ + "pets/quillfeather", + ]) + }) + + it("reads a note by reference and applies the host line window", async () => { + const result = await reader().get({ + reference: { providerId: MEMORY_PROVIDER_ID, id: "pets/quillfeather" }, + from: 2, + lines: 2, + }) + + expect(client.readNote).toHaveBeenCalledWith("pets/quillfeather") + expect(result).toEqual({ + status: "ok", + reference: { providerId: MEMORY_PROVIDER_ID, id: "pets/quillfeather" }, + text: "line 2\nline 3", + from: 2, + lines: 2, + truncated: true, + nextFrom: 4, + }) + }) + + it("returns the whole note when no line window is requested", async () => { + const result = await reader().get({ + reference: { providerId: MEMORY_PROVIDER_ID, id: "pets/quillfeather" }, + }) + + expect(result).toEqual({ + status: "ok", + reference: { providerId: MEMORY_PROVIDER_ID, id: "pets/quillfeather" }, + text: "line 1\nline 2\nline 3\nline 4", + }) + }) + + it("reports a missing note as not_found but surfaces other failures", async () => { + client.readNote.mockRejectedValueOnce(notFound()) + await expect( + reader().get({ reference: { providerId: MEMORY_PROVIDER_ID, id: "x" } }), + ).resolves.toEqual({ status: "not_found" }) + + client.readNote.mockRejectedValueOnce(new Error("BM MCP unavailable")) + await expect( + reader().get({ reference: { providerId: MEMORY_PROVIDER_ID, id: "x" } }), + ).rejects.toThrow("BM MCP unavailable") + }) + + it("refuses a caller whose authority was revoked", async () => { + const context = makeContext({ + assertCurrent: jest.fn(() => { + throw new Error("caller no longer current") + }), + }) + + await expect(reader(context).search({ query: "cat" })).rejects.toThrow( + "caller no longer current", + ) + expect(client.search).not.toHaveBeenCalled() + }) + + it("reports health from the BM session without reading notes", async () => { + await expect(reader().health()).resolves.toEqual({ status: "ready" }) + expect(client.search).not.toHaveBeenCalled() + expect(client.readNote).not.toHaveBeenCalled() + + client.listProjects.mockRejectedValue(new Error("connection closed")) + await expect(reader().health()).resolves.toEqual({ + status: "unavailable", + message: "Basic Memory is not reachable", + }) + }) + + it("registers recall tools, a tools-arm flush, and Basic Memory prompt guidance", async () => { + const capability = buildBasicMemoryCapability(client as unknown as BmClient) + + expect(capability.recallToolNames).toEqual([ + "search_notes", + "read_note", + "build_context", + ]) + + const plan = capability.providerFlushPlanResolver?.({}) + expect(plan).toMatchObject({ + persistenceToolNames: ["write_note", "edit_note"], + lookupToolNames: ["search_notes", "read_note"], + }) + expect(plan && "relativePath" in plan).toBe(false) + + expect( + capability.promptBuilder?.({ availableTools: new Set(["search_notes"]) }), + ).toContain("## Basic Memory") + expect( + capability.promptBuilder?.({ + availableTools: new Set(["memory_search"]), + }), + ).toEqual([]) + + const opened = await capability.providerRuntime?.open({ + cfg: {}, + agentId: "main", + context: makeContext(), + } as Parameters["open"]>[0]) + expect(opened?.provider?.capabilities.sources).toEqual(["memory"]) + }) +}) diff --git a/integrations/openclaw/memory/basic-memory-capability.ts b/integrations/openclaw/memory/basic-memory-capability.ts new file mode 100644 index 000000000..92b14df54 --- /dev/null +++ b/integrations/openclaw/memory/basic-memory-capability.ts @@ -0,0 +1,240 @@ +import type { MemoryPluginCapability } from "openclaw/plugin-sdk/memory-host-core" +import { type BmClient, isNoteNotFoundError } from "../bm-client.ts" +import { log } from "../logger.ts" + +// OpenClaw documents deriving the provider types from the public capability +// type rather than importing its private memory-host-search facade. +type MemoryProviderRuntime = NonNullable< + MemoryPluginCapability["providerRuntime"] +> +type MemoryProviderHandle = NonNullable< + Awaited>["provider"] +> +type MemorySearchRequest = Parameters[0] +type MemorySearchPage = Awaited> +type MemoryGetRequest = Parameters[0] +type MemoryGetResult = Awaited> +type MemoryHealth = Awaited> +type MemoryCallerContext = Parameters< + MemoryProviderRuntime["open"] +>[0]["context"] +type MemoryProviderFlushPlanResolver = NonNullable< + MemoryPluginCapability["providerFlushPlanResolver"] +> + +export const MEMORY_PROVIDER_ID = "openclaw-basic-memory" + +// Agent tools this plugin registers that Active Memory may use for deep +// recall, and that the pre-compaction flush may use to look before writing. +export const RECALL_TOOL_NAMES = [ + "search_notes", + "read_note", + "build_context", +] as const +const FLUSH_PERSISTENCE_TOOL_NAMES = ["write_note", "edit_note"] as const +const FLUSH_LOOKUP_TOOL_NAMES = ["search_notes", "read_note"] as const + +const DEFAULT_MAX_RESULTS = 10 +const MAX_EXCERPT_CHARS = 700 +// memory-core's flush prompts use the same token for "nothing to save". +const SILENT_REPLY_TOKEN = "NO_REPLY" + +// --- Search and retrieval --- + +/** + * Basic Memory fuses keyword and vector scores into [0, 1.3] (see + * search_reader.py FUSION_BONUS). OpenClaw compares provider scores against a + * caller's minScore on a 0–1 scale, so a fused score above 1 is capped at 1. + */ +function hostScore(score: number | undefined): number | undefined { + return score === undefined ? undefined : Math.min(score, 1) +} + +function excerptOf(content: string): string { + return content.length > MAX_EXCERPT_CHARS + ? `${content.slice(0, MAX_EXCERPT_CHARS)}…` + : content +} + +/** + * Apply the host's optional line window to a note body. `from` is 1-based, + * matching the host's memory_get contract. + */ +function sliceLines( + text: string, + from: number | undefined, + lines: number | undefined, +): Pick< + Extract, + "text" | "truncated" | "from" | "lines" | "nextFrom" +> { + if (from === undefined && lines === undefined) return { text } + + const all = text.split("\n") + const start = Math.max(1, from ?? 1) + const count = lines ?? all.length - start + 1 + const window = all.slice(start - 1, start - 1 + count) + const end = start - 1 + window.length + const truncated = end < all.length + return { + text: window.join("\n"), + from: start, + lines: window.length, + truncated, + ...(truncated ? { nextFrom: end + 1 } : {}), + } +} + +/** + * Open a caller-bound reader over the plugin's shared BM MCP session. + * + * The host rechecks the caller's authority around every provider call; + * context.assertCurrent() is also called before and after each BM request so + * a revoked caller is refused rather than served. Every note in the project + * is visible to every caller, which matches what the plugin's own + * search_notes and read_note tools already expose. + */ +export function openBasicMemoryReader( + client: BmClient, + context: MemoryCallerContext, +): MemoryProviderHandle { + const guarded = async (run: () => Promise): Promise => { + context.assertCurrent() + const result = await run() + context.assertCurrent() + return result + } + + return { + capabilities: { + sources: ["memory"], + pagination: false, + candidates: [], + projectFilter: false, + }, + + async search(request: MemorySearchRequest): Promise { + const results = await guarded(() => + client.search(request.query, request.maxResults ?? DEFAULT_MAX_RESULTS), + ) + + const hits = results + .map((result) => ({ result, score: hostScore(result.score) })) + // Trigger: the caller set a score floor. + // Why: the host contract requires every provider to enforce minScore. + // Outcome: unscored or lower-scored hits are dropped. + .filter( + ({ score }) => + request.minScore === undefined || + (score !== undefined && score >= request.minScore), + ) + .map(({ result, score }) => ({ + reference: { providerId: MEMORY_PROVIDER_ID, id: result.permalink }, + excerpt: excerptOf(result.content ?? ""), + source: result.file_path, + ...(score === undefined ? {} : { score }), + })) + + return { hits } + }, + + async get(request: MemoryGetRequest): Promise { + try { + const note = await guarded(() => client.readNote(request.reference.id)) + return { + status: "ok", + reference: { providerId: MEMORY_PROVIDER_ID, id: note.permalink }, + ...sliceLines(note.content, request.from, request.lines), + } + } catch (err) { + // A missing note is an answer; any other failure must surface so the + // host does not report it as an empty success. + if (isNoteNotFoundError(err)) return { status: "not_found" } + throw err + } + }, + + async health(): Promise { + // Listing projects proves the BM MCP session answers without reading + // any note content, so it is safe for status-only callers. + try { + await guarded(() => client.listProjects()) + return { status: "ready" } + } catch (err) { + log.warn( + `memory health check failed: ${err instanceof Error ? err.message : String(err)}`, + ) + return { + status: "unavailable", + message: "Basic Memory is not reachable", + } + } + }, + + // The BM MCP session is shared by the whole plugin and owned by its + // service lifecycle, so closing one caller's reader releases nothing. + async close(): Promise {}, + } +} + +// --- Pre-compaction flush --- + +const FLUSH_PROMPT = [ + "Pre-compaction memory flush.", + "Save durable facts, decisions and open tasks from this session to Basic Memory.", + "Use search_notes or read_note first to find an existing note, then edit_note to update it;", + "use write_note only for a new topic.", + `If nothing is worth saving, reply with ${SILENT_REPLY_TOKEN}.`, +].join(" ") + +const FLUSH_SYSTEM_PROMPT = [ + "Pre-compaction memory flush turn.", + "The session is near auto-compaction; capture durable memories in Basic Memory with write_note or edit_note.", + `You may reply, but usually ${SILENT_REPLY_TOKEN} is correct.`, +].join(" ") + +// Returning a tools-arm plan makes the host persist through this plugin's own +// tools instead of appending to a workspace memory file. The host has already +// honored agents.defaults.compaction.memoryFlush.enabled before calling this. +const resolveFlushPlan: MemoryProviderFlushPlanResolver = () => ({ + prompt: FLUSH_PROMPT, + systemPrompt: FLUSH_SYSTEM_PROMPT, + persistenceToolNames: FLUSH_PERSISTENCE_TOOL_NAMES, + lookupToolNames: FLUSH_LOOKUP_TOOL_NAMES, +}) + +// --- Prompt guidance --- + +/** + * OpenClaw fills any capability field the slot owner leaves empty from other + * memory registrations, including memory-core's prompt section, which points + * agents at its file-only memory_search. Supplying our own section keeps the + * agent's guidance on the Basic Memory tools. + */ +function buildPromptSection(params: { availableTools: Set }): string[] { + if (!params.availableTools.has("search_notes")) return [] + return [ + "## Basic Memory", + "Long-term memory lives in Basic Memory, a knowledge graph of Markdown notes.", + "Before answering questions about prior work, decisions, people or preferences, search it with search_notes and read the best match with read_note.", + "Earlier conversations are saved automatically in daily conversation notes.", + "memory_search and memory_get cover only the workspace MEMORY.md and memory/ files, not Basic Memory.", + ] +} + +// --- Capability --- + +export function buildBasicMemoryCapability( + client: BmClient, +): MemoryPluginCapability { + return { + providerRuntime: { + async open({ context }) { + return { provider: openBasicMemoryReader(client, context) } + }, + }, + recallToolNames: RECALL_TOOL_NAMES, + providerFlushPlanResolver: resolveFlushPlan, + promptBuilder: buildPromptSection, + } +} diff --git a/integrations/openclaw/openclaw.plugin.json b/integrations/openclaw/openclaw.plugin.json index e1207e526..5a57aa220 100644 --- a/integrations/openclaw/openclaw.plugin.json +++ b/integrations/openclaw/openclaw.plugin.json @@ -8,8 +8,6 @@ "edit_note", "list_memory_projects", "list_workspaces", - "memory_get", - "memory_search", "move_note", "read_note", "schema_diff", diff --git a/integrations/openclaw/package.json b/integrations/openclaw/package.json index c2c61860b..781618961 100644 --- a/integrations/openclaw/package.json +++ b/integrations/openclaw/package.json @@ -16,6 +16,7 @@ "bm-client.ts", "context-engine/basic-memory-context-engine.ts", "context-engine/types.ts", + "memory/basic-memory-capability.ts", "config.ts", "logger.ts", "commands/cli.ts", @@ -28,7 +29,6 @@ "tools/edit-note.ts", "tools/list-memory-projects.ts", "tools/list-workspaces.ts", - "tools/memory-provider.ts", "tools/move-note.ts", "tools/read-note.ts", "tools/schema-diff.ts", diff --git a/integrations/openclaw/tools/memory-provider.test.ts b/integrations/openclaw/tools/memory-provider.test.ts deleted file mode 100644 index 9f596692e..000000000 --- a/integrations/openclaw/tools/memory-provider.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { beforeEach, describe, expect, it, jest } from "bun:test" -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry" -import type { BmClient } from "../bm-client.ts" -import type { BasicMemoryConfig } from "../config.ts" -import { registerMemoryProvider, setWorkspaceDir } from "./memory-provider.ts" - -function makeCfg(overrides?: Partial): BasicMemoryConfig { - return { - project: "test", - bmPath: "bm", - memoryDir: "memory/", - memoryFile: "MEMORY.md", - projectPath: "/tmp/bm-test", - autoCapture: true, - captureMinChars: 10, - autoRecall: true, - recallPrompt: "Check for active tasks and recent activity.", - debug: false, - ...overrides, - } -} - -describe("memory provider tools", () => { - let mockApi: OpenClawPluginApi - let mockClient: BmClient - - beforeEach(() => { - mockApi = { - registerTool: jest.fn(), - } as any - - mockClient = { - search: jest.fn().mockResolvedValue([]), - readNote: jest.fn(), - } as any - - // Use a temp dir that won't have MEMORY.md - setWorkspaceDir("/tmp/nonexistent-workspace-for-test") - }) - - describe("registerMemoryProvider", () => { - it("should register both memory_search and memory_get tools", () => { - registerMemoryProvider(mockApi, mockClient, makeCfg()) - - expect(mockApi.registerTool).toHaveBeenCalledTimes(2) - - expect(mockApi.registerTool).toHaveBeenCalledWith( - expect.objectContaining({ - name: "memory_search", - label: "Memory Search", - }), - { names: ["memory_search"] }, - ) - - expect(mockApi.registerTool).toHaveBeenCalledWith( - expect.objectContaining({ - name: "memory_get", - label: "Memory Get", - }), - { names: ["memory_get"] }, - ) - }) - }) - - describe("memory_search tool (composited)", () => { - let searchExecute: Function - - beforeEach(() => { - registerMemoryProvider(mockApi, mockClient, makeCfg()) - const searchCall = (mockApi.registerTool as jest.MockedFunction).mock - .calls[0] - searchExecute = searchCall[0].execute - }) - - it("should return BM results in Knowledge Graph section", async () => { - ;(mockClient.search as jest.MockedFunction).mockResolvedValue([ - { - title: "Test Note", - permalink: "test-note", - content: "Some content about testing", - score: 0.95, - file_path: "memory/test-note.md", - }, - ]) - - const result = await searchExecute("id", { query: "testing" }) - const text = result.content[0].text - - expect(text).toContain("## Knowledge Graph") - expect(text).toContain("memory/test-note.md") - expect(text).toContain("Some content about testing") - }) - - it("should return no matches message when all sources empty", async () => { - ;(mockClient.search as jest.MockedFunction).mockResolvedValue([]) - - const result = await searchExecute("id", { query: "nonexistent" }) - - expect(result.content[0].text).toBe( - "No matches found across memory sources.", - ) - }) - - it("should handle BM search errors gracefully", async () => { - ;(mockClient.search as jest.MockedFunction).mockRejectedValue( - new Error("BM down"), - ) - - const result = await searchExecute("id", { query: "test" }) - const text = result.content[0].text - - expect(text).toContain("(search unavailable)") - }) - }) - - describe("memory_get tool", () => { - let getExecute: Function - - beforeEach(() => { - registerMemoryProvider(mockApi, mockClient, makeCfg()) - const getCall = (mockApi.registerTool as jest.MockedFunction).mock - .calls[1] - getExecute = getCall[0].execute - }) - - it("should read note and format with title", async () => { - ;(mockClient.readNote as jest.MockedFunction).mockResolvedValue({ - title: "Test Note", - permalink: "test-note", - content: "Note content here", - file_path: "notes/test.md", - }) - - const result = await getExecute("id", { path: "test-note" }) - - expect(result.content[0].text).toBe("# Test Note\n\nNote content here") - }) - - it("should handle errors gracefully", async () => { - ;(mockClient.readNote as jest.MockedFunction).mockRejectedValue( - new Error("Not found"), - ) - - const result = await getExecute("id", { path: "missing" }) - - expect(result.content[0].text).toContain('Could not read "missing"') - }) - }) -}) diff --git a/integrations/openclaw/tools/memory-provider.ts b/integrations/openclaw/tools/memory-provider.ts deleted file mode 100644 index be7d10277..000000000 --- a/integrations/openclaw/tools/memory-provider.ts +++ /dev/null @@ -1,336 +0,0 @@ -import { readdir, readFile } from "node:fs/promises" -import { join, resolve } from "node:path" -import { Type } from "@sinclair/typebox" -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry" -import type { BmClient } from "../bm-client.ts" -import type { BasicMemoryConfig } from "../config.ts" -import { log } from "../logger.ts" - -/** - * Search MEMORY.md for lines matching the query. - * Returns matching lines with 1 line of surrounding context. - */ -async function searchMemoryFile( - query: string, - workspaceDir: string, - memoryFile: string, -): Promise { - try { - const filePath = resolve(workspaceDir, memoryFile) - const content = await readFile(filePath, "utf-8") - const lines = content.split("\n") - const queryLower = query.toLowerCase() - const terms = queryLower.split(/\s+/).filter((t) => t.length > 0) - - // Find lines that match any search term - const matchingIndices = new Set() - for (let i = 0; i < lines.length; i++) { - const lineLower = lines[i].toLowerCase() - if (terms.some((term) => lineLower.includes(term))) { - // Add the matching line + 1 line before/after for context - if (i > 0) matchingIndices.add(i - 1) - matchingIndices.add(i) - if (i < lines.length - 1) matchingIndices.add(i + 1) - } - } - - if (matchingIndices.size === 0) return "" - - // Group consecutive lines into snippets - const sorted = [...matchingIndices].sort((a, b) => a - b) - const snippets: string[] = [] - let current: string[] = [] - let lastIdx = -2 - - for (const idx of sorted) { - if (idx !== lastIdx + 1 && current.length > 0) { - snippets.push(current.join("\n")) - current = [] - } - current.push(`- ${lines[idx]}`) - lastIdx = idx - } - if (current.length > 0) snippets.push(current.join("\n")) - - return snippets.join("\n…\n") - } catch { - return "" - } -} - -/** - * Search for active tasks via BM knowledge graph. - * Uses search_notes with note_types and status filters for precise querying. - * Falls back to filesystem scan if search fails. - */ -async function searchActiveTasks( - query: string, - client: BmClient, - workspaceDir: string, - memoryDir: string, -): Promise { - // Try structured search first - try { - const results = await client.search(query || undefined, 10, undefined, { - note_types: ["task"], - status: "active", - }) - - if (results.length > 0) { - const matches: string[] = [] - for (const r of results) { - const score = r.score ? ` (${r.score.toFixed(2)})` : "" - const preview = - (r.content ?? "").length > 200 - ? `${(r.content ?? "").slice(0, 200)}…` - : (r.content ?? "") - matches.push( - `- **${r.title}**${score} — ${r.file_path}\n > ${preview.replace(/\n/g, "\n > ")}`, - ) - } - return matches.join("\n\n") - } - } catch (err) { - log.debug("BM task search failed, falling back to filesystem scan", err) - } - - // Fallback: filesystem scan for tasks not yet indexed - try { - const tasksDir = resolve(workspaceDir, memoryDir, "tasks") - let entries: import("node:fs").Dirent[] - try { - entries = (await readdir(tasksDir, { - withFileTypes: true, - })) as unknown as import("node:fs").Dirent[] - } catch { - return "" - } - - const queryLower = query.toLowerCase() - const terms = queryLower.split(/\s+/).filter((t) => t.length > 0) - const matches: string[] = [] - - for (const entry of entries) { - if (!entry.isFile() || !entry.name.endsWith(".md")) continue - - const filePath = join(entry.parentPath ?? tasksDir, entry.name) - const content = await readFile(filePath, "utf-8") - - const statusMatch = content.match(/status:\s*(\S+)/) - const status = statusMatch?.[1] ?? "unknown" - if (status === "done") continue - - const contentLower = content.toLowerCase() - const matchesQuery = - terms.length === 0 || terms.some((term) => contentLower.includes(term)) - if (!matchesQuery) continue - - const titleMatch = content.match(/title:\s*(.+)/) - const title = titleMatch?.[1] ?? entry.name.replace(/\.md$/, "") - const stepMatch = content.match(/current_step:\s*(\S+)/) - const currentStep = stepMatch?.[1] ?? "?" - - const contextMatch = content.match( - /## Context\s*\n([\s\S]*?)(?=\n##|\n---|$)/, - ) - const context = contextMatch?.[1]?.trim().slice(0, 150) ?? "" - - matches.push( - `- **${title}** (status: ${status}, step: ${currentStep})\n ${context}`, - ) - } - - return matches.join("\n") - } catch { - return "" - } -} - -// Store workspace dir for use in memory_search (set during service start) -let _workspaceDir = "" -export function setWorkspaceDir(dir: string) { - _workspaceDir = dir -} - -/** - * Register composited memory_search and memory_get tools. - * - * memory_search queries 3 sources in parallel: - * 1. MEMORY.md — grep/text search - * 2. BM knowledge graph — semantic + FTS search - * 3. Active tasks — memory/tasks/ files with status != done - * - * memory_get reads a specific note by identifier. - */ -export function registerMemoryProvider( - api: OpenClawPluginApi, - client: BmClient, - cfg: BasicMemoryConfig, -): void { - // --- composited memory_search --- - api.registerTool( - { - name: "memory_search", - label: "Memory Search", - description: - "Search across all memory sources: MEMORY.md (working memory), " + - "Basic Memory knowledge graph (long-term archive), and active tasks. " + - "Returns composited results from all sources. Use it for broad recall; use " + - "search_notes when you need metadata, tag, or status filters or a specific project.", - parameters: Type.Object({ - query: Type.String({ - description: "Search query — natural language or keywords", - }), - }), - async execute(_toolCallId: string, params: { query: string }) { - log.debug(`memory_search: query="${params.query}"`) - - const workspaceDir = _workspaceDir || process.cwd() - - // Query all 3 sources in parallel - const [memoryMd, bmResults, taskResults] = await Promise.all([ - searchMemoryFile(params.query, workspaceDir, cfg.memoryFile), - client - .search(params.query, 5) - .then((results) => { - if (results.length === 0) return "" - return results - .map((r) => { - const score = r.score ? ` (${r.score.toFixed(2)})` : "" - const preview = - (r.content ?? "").length > 200 - ? `${(r.content ?? "").slice(0, 200)}…` - : (r.content ?? "") - const source = r.file_path || r.permalink - return `- ${source}${score}\n > ${preview.replace(/\n/g, "\n > ")}` - }) - .join("\n\n") - }) - .catch((err) => { - log.error("BM search failed in composited search", err) - return "(search unavailable)" - }), - searchActiveTasks(params.query, client, workspaceDir, cfg.memoryDir), - ]) - - // Build composited result - const sections: string[] = [] - - if (memoryMd) { - sections.push(`## ${cfg.memoryFile}\n${memoryMd}`) - } - - if (bmResults) { - sections.push(`## Knowledge Graph (${cfg.memoryDir})\n${bmResults}`) - } - - if (taskResults) { - sections.push(`## Active Tasks\n${taskResults}`) - } - - if (sections.length === 0) { - return { - content: [ - { - type: "text" as const, - text: "No matches found across memory sources.", - }, - ], - details: { - query: params.query, - sectionCount: 0, - hasMemoryFileMatches: false, - hasKnowledgeGraphMatches: false, - hasTaskMatches: false, - }, - } - } - - return { - content: [ - { - type: "text" as const, - text: sections.join("\n\n"), - }, - ], - details: { - query: params.query, - sectionCount: sections.length, - hasMemoryFileMatches: memoryMd.length > 0, - hasKnowledgeGraphMatches: bmResults.length > 0, - hasTaskMatches: taskResults.length > 0, - }, - } - }, - }, - { names: ["memory_search"] }, - ) - - // --- memory_get (unchanged) --- - api.registerTool( - { - name: "memory_get", - label: "Memory Get", - description: - "Read a specific note from the knowledge graph by title, permalink, or path. " + - "Returns the full note content. Use after memory_search to read a specific result in full.", - parameters: Type.Object({ - path: Type.String({ - description: - "Note identifier — title, permalink, memory:// URL, or file path", - }), - from: Type.Optional( - Type.Number({ - description: - "Starting line number (ignored — included for compatibility)", - }), - ), - lines: Type.Optional( - Type.Number({ - description: - "Number of lines to read (ignored — included for compatibility)", - }), - ), - }), - async execute( - _toolCallId: string, - params: { path: string; from?: number; lines?: number }, - ) { - log.debug(`memory_get: path="${params.path}"`) - - try { - const note = await client.readNote(params.path) - - return { - content: [ - { - type: "text" as const, - text: `# ${note.title}\n\n${note.content}`, - }, - ], - details: { - title: note.title, - permalink: note.permalink, - file_path: note.file_path, - }, - } - } catch (err) { - log.error("memory_get failed", err) - return { - content: [ - { - type: "text" as const, - text: `Could not read "${params.path}". It may not exist in the knowledge graph.`, - }, - ], - details: { - path: params.path, - error: "memory_get_failed", - }, - } - } - }, - }, - { names: ["memory_get"] }, - ) -} diff --git a/integrations/openclaw/tools/search-notes.test.ts b/integrations/openclaw/tools/search-notes.test.ts index d138a024e..a403823eb 100644 --- a/integrations/openclaw/tools/search-notes.test.ts +++ b/integrations/openclaw/tools/search-notes.test.ts @@ -138,7 +138,8 @@ describe("search tool", () => { title: "High Score Note", permalink: "high-score", content: "Content with high relevance score", - score: 0.95, + // Fused keyword + vector scores can exceed 1 (BM range is 0–1.3). + score: 1.18, file_path: "notes/high-score.md", }, { @@ -157,8 +158,9 @@ describe("search tool", () => { const result = await executeFunction("tool-call-id", { query: "test" }) const text = result.content[0].text - expect(text).toContain("**High Score Note** (95%)") - expect(text).toContain("**Medium Score Note** (67%)") + expect(text).toContain("**High Score Note** (score 1.18)") + expect(text).toContain("**Medium Score Note** (score 0.67)") + expect(text).not.toContain("%") }) it("should handle results without scores", async () => { @@ -179,7 +181,7 @@ describe("search tool", () => { const text = result.content[0].text expect(text).toContain("No Score Note") - expect(text).not.toContain("(%)") + expect(text).not.toContain("(score") }) it("should truncate long content with ellipsis", async () => { diff --git a/integrations/openclaw/tools/search-notes.ts b/integrations/openclaw/tools/search-notes.ts index 1470b29c6..b4de9100b 100644 --- a/integrations/openclaw/tools/search-notes.ts +++ b/integrations/openclaw/tools/search-notes.ts @@ -98,7 +98,7 @@ export function registerSearchTool( const text = results .map((r, i) => { - const score = r.score ? ` (${(r.score * 100).toFixed(0)}%)` : "" + const score = r.score ? ` (score ${r.score.toFixed(2)})` : "" const content = r.content ?? "" const preview = content.length > 200 ? `${content.slice(0, 200)}...` : content From 3e5954ed2cc9c9416ee9dc4db37c3edd749b1167 Mon Sep 17 00:00:00 2001 From: Drew Cain Date: Thu, 29 Oct 2026 15:21:09 -0500 Subject: [PATCH 2/2] fix(integrations): let one-shot OpenClaw memory CLI commands exit `openclaw memory status --agent main` printed "Health: ready" and then never exited. The CLI loads the plugin without starting its service, so the provider's health() call spawned a `bm mcp` child lazily, and the reader's close() was a no-op. The child's open pipes kept the process alive. BmClient now records whether the plugin service owns the session (isServiceRunning(), true only between start() and stop()). A reader opened while the service is not running owns its connection and stops it on close(). In the gateway, close() still leaves the shared session open. Also rewrite the search_notes description. It told agents to use memory_search for cross-source recall covering MEMORY.md and tasks, which no longer exists on OpenClaw 2026.10. Verified live on OpenClaw 2026.10.9: `openclaw memory status --agent main` exits 0 after about 13 s, reporting the openclaw-basic-memory provider as ready, and leaves no extra `bm mcp` process. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Drew Cain --- integrations/openclaw/bm-client.test.ts | 16 ++++++++++++++ integrations/openclaw/bm-client.ts | 10 +++++++++ .../memory/basic-memory-capability.test.ts | 22 +++++++++++++++++++ .../memory/basic-memory-capability.ts | 15 ++++++++++--- integrations/openclaw/tools/search-notes.ts | 5 +++-- 5 files changed, 63 insertions(+), 5 deletions(-) diff --git a/integrations/openclaw/bm-client.test.ts b/integrations/openclaw/bm-client.test.ts index afb992422..1d55f9201 100644 --- a/integrations/openclaw/bm-client.test.ts +++ b/integrations/openclaw/bm-client.test.ts @@ -943,6 +943,22 @@ describe("BmClient MCP behavior", () => { expect(args[4]).toBeUndefined() // overwrite }) + it("reports the service as running only between start() and stop()", async () => { + ;(client as any).connectWithRetries = jest.fn().mockResolvedValue(undefined) + ;(client as any).disconnectCurrent = jest.fn().mockResolvedValue(undefined) + + expect(client.isServiceRunning()).toBe(false) + // A lazy connection from a tool call is not service ownership. + setConnected(client, jest.fn()) + expect(client.isServiceRunning()).toBe(false) + + await client.start() + expect(client.isServiceRunning()).toBe(true) + + await client.stop() + expect(client.isServiceRunning()).toBe(false) + }) + describe("indexConversation with a turn key", () => { // In-memory stand-in for the conversation notes so the duplicate check, // append, and create paths run against shared state. diff --git a/integrations/openclaw/bm-client.ts b/integrations/openclaw/bm-client.ts index 4213b4312..39ce4e75a 100644 --- a/integrations/openclaw/bm-client.ts +++ b/integrations/openclaw/bm-client.ts @@ -305,6 +305,10 @@ export class BmClient { private cwd?: string private env?: Record private shouldRun = false + // True only between start() and stop(), i.e. while the plugin service owns + // the BM MCP session. A lazy connection opened by a tool or memory call in a + // one-shot CLI process leaves this false. + private serviceRunning = false private client: Client | null = null private transport: StdioClientTransport | null = null @@ -324,6 +328,7 @@ export class BmClient { env?: Record }): Promise { this.shouldRun = true + this.serviceRunning = true if (options?.cwd) { this.cwd = options.cwd } @@ -334,8 +339,13 @@ export class BmClient { await this.connectWithRetries() } + isServiceRunning(): boolean { + return this.serviceRunning + } + async stop(): Promise { this.shouldRun = false + this.serviceRunning = false await this.disconnectCurrent(this.client, this.transport) this.client = null this.transport = null diff --git a/integrations/openclaw/memory/basic-memory-capability.test.ts b/integrations/openclaw/memory/basic-memory-capability.test.ts index a27315dc9..6b36f2f1d 100644 --- a/integrations/openclaw/memory/basic-memory-capability.test.ts +++ b/integrations/openclaw/memory/basic-memory-capability.test.ts @@ -27,6 +27,8 @@ describe("Basic Memory memory capability", () => { search: jest.Mock readNote: jest.Mock listProjects: jest.Mock + isServiceRunning: jest.Mock + stop: jest.Mock } beforeEach(() => { @@ -54,6 +56,8 @@ describe("Basic Memory memory capability", () => { file_path: "pets/quillfeather.md", }), listProjects: jest.fn().mockResolvedValue([]), + isServiceRunning: jest.fn().mockReturnValue(true), + stop: jest.fn().mockResolvedValue(undefined), } }) @@ -167,6 +171,24 @@ describe("Basic Memory memory capability", () => { }) }) + it("leaves the shared gateway session open when a reader closes", async () => { + await reader().close() + + expect(client.stop).not.toHaveBeenCalled() + }) + + // Regression: `openclaw memory status` loads the plugin without starting its + // service, and the lazily spawned `bm mcp` child kept the CLI from exiting. + it("stops a connection it opened outside the plugin service", async () => { + client.isServiceRunning.mockReturnValue(false) + const handle = reader() + + await expect(handle.health()).resolves.toEqual({ status: "ready" }) + await handle.close() + + expect(client.stop).toHaveBeenCalledTimes(1) + }) + it("registers recall tools, a tools-arm flush, and Basic Memory prompt guidance", async () => { const capability = buildBasicMemoryCapability(client as unknown as BmClient) diff --git a/integrations/openclaw/memory/basic-memory-capability.ts b/integrations/openclaw/memory/basic-memory-capability.ts index 92b14df54..65f73f36b 100644 --- a/integrations/openclaw/memory/basic-memory-capability.ts +++ b/integrations/openclaw/memory/basic-memory-capability.ts @@ -105,6 +105,13 @@ export function openBasicMemoryReader( return result } + // Trigger: the reader opens while the plugin service is not running, as in + // one-shot CLI commands like `openclaw memory status`. + // Why: the first BM call then spawns a `bm mcp` child that nothing else + // will stop, and its open pipes keep the CLI process from exiting. + // Outcome: this reader owns that connection and stops it on close(). + const ownsConnection = !client.isServiceRunning() + return { capabilities: { sources: ["memory"], @@ -171,9 +178,11 @@ export function openBasicMemoryReader( } }, - // The BM MCP session is shared by the whole plugin and owned by its - // service lifecycle, so closing one caller's reader releases nothing. - async close(): Promise {}, + // In the gateway the BM MCP session is shared by the whole plugin and + // owned by its service, so closing one caller's reader releases nothing. + async close(): Promise { + if (ownsConnection) await client.stop() + }, } } diff --git a/integrations/openclaw/tools/search-notes.ts b/integrations/openclaw/tools/search-notes.ts index b4de9100b..664176d3f 100644 --- a/integrations/openclaw/tools/search-notes.ts +++ b/integrations/openclaw/tools/search-notes.ts @@ -15,8 +15,9 @@ export function registerSearchTool( "Search the Basic Memory knowledge graph for relevant notes, concepts, and connections. " + "Returns matching notes with titles, content previews, and relevance scores. " + "Optionally filter by frontmatter metadata fields, tags, or status, or target " + - "another project. For a quick cross-source recall that also covers MEMORY.md " + - "and active tasks, use memory_search.", + "another project. This is the search for long-term memory and earlier " + + "conversations; memory_search covers only the workspace MEMORY.md and memory/ " + + "files. Read a result in full with read_note, or follow its links with build_context.", parameters: Type.Object({ query: Type.String({ description: "Search query" }), limit: Type.Optional(