Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions integrations/openclaw/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
7 changes: 5 additions & 2 deletions integrations/openclaw/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions integrations/openclaw/MEMORY_TASK_FLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
36 changes: 24 additions & 12 deletions integrations/openclaw/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -53,10 +53,18 @@ Then install the plugin:

```bash
openclaw plugins install @basicmemory/openclaw-basic-memory
openclaw plugins enable openclaw-basic-memory --slot memory
openclaw config set plugins.slots.memory openclaw-basic-memory
openclaw config set plugins.slots.contextEngine openclaw-basic-memory
openclaw gateway restart
```

The plugin fills two OpenClaw slots, and each one does a different job:

- **`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.

That's it. The plugin auto-installs the `bm` CLI on first startup if it's not already on your PATH. See [SECURITY.md](./SECURITY.md) for details on how this works.

Verify:
Expand All @@ -80,7 +88,8 @@ openclaw plugins doctor
}
},
slots: {
memory: "openclaw-basic-memory"
memory: "openclaw-basic-memory",
contextEngine: "openclaw-basic-memory"
}
}
}
Expand Down Expand Up @@ -108,7 +117,8 @@ This uses sensible defaults: auto-generated project name, maps to your workspace
}
},
slots: {
memory: "openclaw-basic-memory"
memory: "openclaw-basic-memory",
contextEngine: "openclaw-basic-memory"
}
}
}
Expand All @@ -131,21 +141,25 @@ 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.

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.

As the memory slot owner, the plugin also serves OpenClaw's own memory consumers:

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
- **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.

Results come back in clear sections so the agent knows where each piece of context came from.
Search scores are shown as raw numbers. A score above 1 means the note matched on both keywords and meaning.

### Auto-recall

On each session start, the plugin loads active tasks and recently modified notes, giving the agent immediate awareness of ongoing work without being asked.

### Auto-capture

After each conversation turn, the plugin records the exchange as a timestamped entry in a daily note. This builds a searchable history of everything your agent has discussed.
After each conversation turn, the plugin records the exchange as a timestamped entry in a daily note. This builds a searchable history of everything your agent has discussed. Capture requires the `contextEngine` slot. Each entry carries a hidden `<!-- openclaw-turn:... -->` marker so a turn OpenClaw retries after a crash is recorded only once.

### Persistent connection

Expand All @@ -157,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 |
Expand Down
248 changes: 248 additions & 0 deletions integrations/openclaw/bm-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, jest } from "bun:test"
import {
BmClient,
BmToolError,
describeNoteError,
describeToolError,
NoteAlreadyExistsError,
NoteRevisionConflictError,
} from "./bm-client.ts"
Expand Down Expand Up @@ -943,6 +945,252 @@ describe("BmClient MCP behavior", () => {
expect(args[4]).toBeUndefined() // overwrite
})

it("describes tool errors with the server's own reason", () => {
expect(
describeToolError(
new BmToolError(
"bm_edit",
JSON.stringify({ error: "CONFLICT", message: "checksum changed" }),
),
),
).toBe("CONFLICT: checksum changed")
// Plain-text tool failures (older servers) pass through unchanged.
expect(
describeToolError(new BmToolError("bm_read", "database is locked")),
).toBe("database is locked")
expect(describeToolError(new Error("spawn bm ENOENT"))).toBe(
"spawn bm ENOENT",
)
expect(
describeNoteError(
new BmToolError(
"bm_read",
JSON.stringify({ error: "NOT_FOUND", message: "Missing note" }),
),
),
).toBe("it does not exist. NOT_FOUND: Missing note")
})

describe("startup gate", () => {
const structured = (payload: unknown) => ({ structuredContent: payload })

it("holds tool calls until startup has created the project", async () => {
let finishConnect = () => {}
;(client as any).connectWithRetries = jest.fn(
() =>
new Promise<void>((resolve) => {
finishConnect = resolve
}),
)
const callToolRaw = jest.fn(
async (_name: string, args: Record<string, unknown>) =>
args.command === "create"
? structured({ ok: true })
: structured({ projects: [] }),
)
;(client as any).callToolRaw = callToolRaw

const startup = client.start({ projectPath: "/tmp/ws/memory" })
const listing = client.listProjects()
await Promise.resolve()
expect(callToolRaw).not.toHaveBeenCalled()

finishConnect()
await startup
await listing

// Project creation runs first, then the waiting call.
expect(callToolRaw.mock.calls.map(([name]) => name)).toEqual([
"bm_project",
"bm_project",
])
expect(callToolRaw.mock.calls[0][1]).toMatchObject({ command: "create" })
expect(callToolRaw.mock.calls[1][1]).toMatchObject({ command: "list" })
})

it("retries a failed startup once for the next tool calls", async () => {
const connect = jest
.fn()
.mockRejectedValueOnce(new Error("bm not ready"))
.mockResolvedValue(undefined)
;(client as any).connectWithRetries = connect
;(client as any).callToolRaw = jest.fn(async () =>
structured({ projects: [] }),
)

await expect(client.start()).rejects.toThrow("bm not ready")
await Promise.all([client.listProjects(), client.listProjects()])

// One original attempt plus one retry shared by both callers.
expect(connect).toHaveBeenCalledTimes(2)
})

it("surfaces the retry's error when startup keeps failing", async () => {
;(client as any).connectWithRetries = jest
.fn()
.mockRejectedValue(new Error("BM MCP unavailable: spawn bm ENOENT"))

await expect(client.start()).rejects.toThrow("spawn bm ENOENT")
await expect(client.listProjects()).rejects.toThrow("spawn bm ENOENT")
})
})

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.
let notes: Map<string, string>
const notFound = () =>
new BmToolError(
"bm_read",
JSON.stringify({ error: "NOT_FOUND", message: "Missing note" }),
)
const today = () =>
`conversations-${new Date().toISOString().split("T")[0]}`

beforeEach(() => {
notes = new Map()
;(client as any).readNote = jest.fn(async (identifier: string) => {
const content = notes.get(identifier)
if (content === undefined) throw notFound()
return { title: identifier, permalink: identifier, content }
})
;(client as any).editNote = jest.fn(
async (identifier: string, _op: string, entry: string) => {
const content = notes.get(identifier)
if (content === undefined) throw notFound()
notes.set(identifier, `${content}\n${entry}`)
},
)
;(client as any).writeNote = jest.fn(
async (title: string, content: string) => {
notes.set(title, content)
},
)
})

it("records a marker and reports the first write as committed", async () => {
const status = await client.indexConversation(
"user message long enough",
"assistant reply long enough",
{ turnKey: "advance-1" },
)

expect(status).toBe("committed")
expect(notes.get(today())).toMatch(/<!-- openclaw-turn:[0-9a-f]{32} -->/)
})

it("reports a retried key as a duplicate without writing again", async () => {
await client.indexConversation("first user", "first reply", {
turnKey: "advance-1",
})
const status = await client.indexConversation(
"first user",
"first reply",
{ turnKey: "advance-1" },
)

expect(status).toBe("duplicate")
expect(notes.get(today())?.match(/openclaw-turn:/g)).toHaveLength(1)
})

it("finds a key recorded in yesterday's note after midnight", async () => {
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000)
.toISOString()
.split("T")[0]
await client.indexConversation("late user", "late reply", {
turnKey: "advance-1",
})
notes.set(`conversations-${yesterday}`, notes.get(today()) ?? "")
notes.delete(today())

const status = await client.indexConversation("late user", "late reply", {
turnKey: "advance-1",
})

expect(status).toBe("duplicate")
expect(notes.has(today())).toBe(false)
})

it("writes concurrent retries of one key exactly once", async () => {
const statuses = await Promise.all([
client.indexConversation("same user", "same reply", {
turnKey: "advance-1",
}),
client.indexConversation("same user", "same reply", {
turnKey: "advance-1",
}),
])

expect(statuses.sort()).toEqual(["committed", "duplicate"])
expect(notes.get(today())?.match(/openclaw-turn:/g)).toHaveLength(1)
})

it("keeps different keys as separate entries", async () => {
await client.indexConversation("user one", "reply one", {
turnKey: "advance-1",
})
const status = await client.indexConversation("user two", "reply two", {
turnKey: "advance-2",
})

expect(status).toBe("committed")
expect(notes.get(today())?.match(/openclaw-turn:/g)).toHaveLength(2)
})

it("propagates a failed duplicate check instead of writing blind", async () => {
;(client as any).readNote = jest
.fn()
.mockRejectedValue(new Error("BM MCP unavailable"))

await expect(
client.indexConversation("user", "reply", { turnKey: "advance-1" }),
).rejects.toThrow("BM MCP unavailable")
expect((client as any).editNote).not.toHaveBeenCalled()
})

it("propagates a failed note create so the host can retry", async () => {
;(client as any).writeNote = jest
.fn()
.mockRejectedValue(new Error("disk full"))

await expect(
client.indexConversation("user", "reply", { turnKey: "advance-1" }),
).rejects.toThrow("disk full")
})

it("keeps capturing after an earlier capture failed", async () => {
;(client as any).writeNote = jest
.fn()
.mockRejectedValueOnce(new Error("disk full"))
.mockImplementation(async (title: string, content: string) => {
notes.set(title, content)
})

await expect(
client.indexConversation("user", "reply", { turnKey: "advance-1" }),
).rejects.toThrow("disk full")
await expect(
client.indexConversation("user", "reply", { turnKey: "advance-1" }),
).resolves.toBe("committed")
})
})

it("retries recoverable MCP failures with bounded attempts", async () => {
;(client as any).retryDelaysMs = [0, 0, 0]

Expand Down
Loading
Loading