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.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 76053abfc..39ce4e75a 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" @@ -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/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..6b36f2f1d --- /dev/null +++ b/integrations/openclaw/memory/basic-memory-capability.test.ts @@ -0,0 +1,224 @@ +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 + isServiceRunning: jest.Mock + stop: 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([]), + isServiceRunning: jest.fn().mockReturnValue(true), + stop: jest.fn().mockResolvedValue(undefined), + } + }) + + 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("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) + + 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..65f73f36b --- /dev/null +++ b/integrations/openclaw/memory/basic-memory-capability.ts @@ -0,0 +1,249 @@ +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 + } + + // 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"], + 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", + } + } + }, + + // 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() + }, + } +} + +// --- 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..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( @@ -98,7 +99,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