Skip to content

Commit 1265e1c

Browse files
phernandezclaude
andcommitted
feat: add schema tools, configurable capture threshold, setup script, and simplify moveNote
- Add bm_schema_validate, bm_schema_infer, bm_schema_diff agent tools wrapping BM's Picoschema system for note type validation, inference, and drift detection - Add searchByMetadata BmClient method; use it in memory_search task scanning instead of free-text query heuristics - Add captureMinChars config field (default: 10, snake_case alias capture_min_chars) so auto-capture threshold is configurable - Simplify moveNote to a single MCP call using BM's new destination_folder parameter — removes the redundant readNote round-trip - Add scripts/setup-bm.sh for idempotent BM CLI install via uv, wired as postinstall in package.json; simplify justfile install recipe - 18 new tests (161 total), all passing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d6768ad commit 1265e1c

18 files changed

+1022
-81
lines changed

README.md

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,15 @@ For a practical runbook, see [Memory + Task Flow](./MEMORY_TASK_FLOW.md).
1515

1616
## Requirements
1717

18-
1. **Basic Memory CLI** (`bm`) with MCP server support:
19-
- `bm mcp --transport stdio --project <name>`
20-
- MCP tools with JSON output mode: `read_note`, `write_note`, `edit_note`, `recent_activity`, `list_memory_projects`, `create_memory_project`, `delete_note`, `move_note`
21-
- Structured MCP tools: `search_notes`, `build_context`
18+
1. **Basic Memory CLI** (`bm`) — installed automatically by `bun install` when [uv](https://docs.astral.sh/uv/) is available. Manual install:
2219
```bash
23-
uv tool install 'basic-memory[semantic] @ git+https://github.com/basicmachines-co/basic-memory.git@f2683291e478568cdf1676759ed98c70d7cfdac3' --with 'onnxruntime<1.24; platform_system == "Darwin" and platform_machine == "x86_64"'
20+
# Latest main branch (recommended during pre-release):
21+
bash scripts/setup-bm.sh
2422

25-
# Alternative (inside an existing Python environment):
26-
uv pip install 'basic-memory[semantic] @ git+https://github.com/basicmachines-co/basic-memory.git@f2683291e478568cdf1676759ed98c70d7cfdac3' 'onnxruntime<1.24; platform_system == "Darwin" and platform_machine == "x86_64"'
23+
# Or pin a specific ref:
24+
BM_REF=v0.18.4 bash scripts/setup-bm.sh
2725
```
26+
If `uv` is not installed, the postinstall step is skipped gracefully. Install `uv` first, then re-run `bash scripts/setup-bm.sh`.
2827

2928
2. **OpenClaw** with plugin support
3029

@@ -133,6 +132,7 @@ This uses sensible defaults: auto-generated project name, maps Basic Memory to y
133132
memoryDir: "memory/", // Relative memory dir for task scanning
134133
memoryFile: "MEMORY.md", // Working memory file for grep search
135134
autoCapture: true, // Index conversations automatically
135+
captureMinChars: 10, // Min chars to trigger auto-capture
136136
debug: false, // Verbose logging
137137
cloud: { // Optional cloud sync
138138
url: "https://cloud.basicmemory.com",
@@ -153,6 +153,7 @@ This uses sensible defaults: auto-generated project name, maps Basic Memory to y
153153
| `memoryDir` | string | `"memory/"` | Relative path for task scanning |
154154
| `memoryFile` | string | `"MEMORY.md"` | Working memory file (grep-searched) |
155155
| `autoCapture` | boolean | `true` | Auto-index agent conversations |
156+
| `captureMinChars` | number | `10` | Minimum character threshold for auto-capture (both messages must be shorter to skip) |
156157
| `debug` | boolean | `false` | Enable verbose debug logs |
157158
| `cloud` | object || Optional cloud sync config (`url` + `api_key`) |
158159

@@ -265,7 +266,7 @@ Done tasks are filtered out of the `Active Tasks` section in composited `memory_
265266
After each agent turn (when `autoCapture: true`), the plugin:
266267
1. Extracts the last user + assistant messages
267268
2. Appends them as timestamped entries to a daily conversation note (`conversations-YYYY-MM-DD`)
268-
3. Skips very short exchanges (< 10 chars each)
269+
3. Skips very short exchanges (< `captureMinChars` chars each, default 10)
269270

270271
## Agent Tools
271272

@@ -325,6 +326,26 @@ Navigate the knowledge graph — get a note with its observations and relations.
325326
bm_context({ url: "memory://projects/api-redesign", depth: 2 })
326327
```
327328

329+
### `bm_schema_validate`
330+
Validate notes against their Picoschema definitions.
331+
```typescript
332+
bm_schema_validate({ noteType: "person" })
333+
bm_schema_validate({ identifier: "notes/john-doe" })
334+
```
335+
336+
### `bm_schema_infer`
337+
Analyze existing notes and suggest a Picoschema definition.
338+
```typescript
339+
bm_schema_infer({ noteType: "meeting" })
340+
bm_schema_infer({ noteType: "person", threshold: 0.5 })
341+
```
342+
343+
### `bm_schema_diff`
344+
Detect drift between a schema definition and actual note usage.
345+
```typescript
346+
bm_schema_diff({ noteType: "person" })
347+
```
348+
328349
## Slash Commands
329350

330351
- **`/remember <text>`** — Save a quick note
@@ -458,6 +479,9 @@ openclaw-basic-memory/
458479
│ ├── delete.ts # bm_delete
459480
│ ├── move.ts # bm_move
460481
│ ├── context.ts # bm_context
482+
│ ├── schema-validate.ts # bm_schema_validate
483+
│ ├── schema-infer.ts # bm_schema_infer
484+
│ ├── schema-diff.ts # bm_schema_diff
461485
│ └── memory-provider.ts # Composited memory_search + memory_get
462486
├── commands/
463487
│ ├── slash.ts # /remember, /recall

bm-client.test.ts

Lines changed: 149 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -301,48 +301,169 @@ describe("BmClient MCP behavior", () => {
301301
expect(result.file_path).toBe("notes/old-note.md")
302302
})
303303

304-
it("moveNote preserves source filename and calls move_note", async () => {
305-
const callTool = jest
306-
.fn()
307-
.mockResolvedValueOnce(
308-
mcpResult({
309-
title: "My Note",
310-
permalink: "notes/my-note",
311-
content: "body",
312-
file_path: "notes/my-note.md",
313-
frontmatter: null,
314-
}),
315-
)
316-
.mockResolvedValueOnce(
317-
mcpResult({
318-
moved: true,
319-
title: "My Note",
320-
permalink: "archive/my-note",
321-
file_path: "archive/my-note.md",
322-
source: "notes/my-note.md",
323-
destination: "archive/my-note.md",
324-
}),
325-
)
304+
it("schemaValidate calls schema_validate with JSON output", async () => {
305+
const callTool = jest.fn().mockResolvedValue(
306+
mcpResult({
307+
entity_type: "person",
308+
total_notes: 3,
309+
total_entities: 3,
310+
valid_count: 3,
311+
warning_count: 0,
312+
error_count: 0,
313+
results: [],
314+
}),
315+
)
326316
setConnected(client, callTool)
327317

328-
const result = await client.moveNote("notes/my-note", "archive")
318+
const result = await client.schemaValidate("person")
329319

330-
expect(callTool).toHaveBeenNthCalledWith(1, {
331-
name: "read_note",
320+
expect(callTool).toHaveBeenCalledWith({
321+
name: "schema_validate",
322+
arguments: {
323+
note_type: "person",
324+
output_format: "json",
325+
},
326+
})
327+
expect(result.entity_type).toBe("person")
328+
expect(result.valid_count).toBe(3)
329+
})
330+
331+
it("schemaValidate passes identifier when provided", async () => {
332+
const callTool = jest.fn().mockResolvedValue(
333+
mcpResult({
334+
entity_type: null,
335+
total_notes: 1,
336+
total_entities: 1,
337+
valid_count: 1,
338+
warning_count: 0,
339+
error_count: 0,
340+
results: [],
341+
}),
342+
)
343+
setConnected(client, callTool)
344+
345+
await client.schemaValidate(undefined, "notes/my-note")
346+
347+
expect(callTool).toHaveBeenCalledWith({
348+
name: "schema_validate",
332349
arguments: {
333350
identifier: "notes/my-note",
334-
include_frontmatter: true,
335351
output_format: "json",
336352
},
337353
})
338-
expect(callTool).toHaveBeenNthCalledWith(2, {
354+
})
355+
356+
it("schemaInfer calls schema_infer with threshold", async () => {
357+
const callTool = jest.fn().mockResolvedValue(
358+
mcpResult({
359+
entity_type: "task",
360+
notes_analyzed: 10,
361+
field_frequencies: [],
362+
suggested_schema: {},
363+
suggested_required: [],
364+
suggested_optional: [],
365+
excluded: [],
366+
}),
367+
)
368+
setConnected(client, callTool)
369+
370+
const result = await client.schemaInfer("task", 0.5)
371+
372+
expect(callTool).toHaveBeenCalledWith({
373+
name: "schema_infer",
374+
arguments: {
375+
note_type: "task",
376+
threshold: 0.5,
377+
output_format: "json",
378+
},
379+
})
380+
expect(result.notes_analyzed).toBe(10)
381+
})
382+
383+
it("schemaDiff calls schema_diff with JSON output", async () => {
384+
const callTool = jest.fn().mockResolvedValue(
385+
mcpResult({
386+
entity_type: "person",
387+
schema_found: true,
388+
new_fields: [{ field: "phone", frequency: 0.6 }],
389+
dropped_fields: [],
390+
cardinality_changes: [],
391+
}),
392+
)
393+
setConnected(client, callTool)
394+
395+
const result = await client.schemaDiff("person")
396+
397+
expect(callTool).toHaveBeenCalledWith({
398+
name: "schema_diff",
399+
arguments: {
400+
note_type: "person",
401+
output_format: "json",
402+
},
403+
})
404+
expect(result.new_fields).toHaveLength(1)
405+
})
406+
407+
it("searchByMetadata calls search_by_metadata with filters", async () => {
408+
const callTool = jest.fn().mockResolvedValue(
409+
mcpResult({
410+
results: [
411+
{
412+
title: "Task 1",
413+
permalink: "tasks/task-1",
414+
content: "active task",
415+
file_path: "tasks/task-1.md",
416+
score: 0.9,
417+
},
418+
],
419+
current_page: 1,
420+
page_size: 20,
421+
}),
422+
)
423+
setConnected(client, callTool)
424+
425+
const result = await client.searchByMetadata(
426+
{ type: "task", status: "active" },
427+
10,
428+
)
429+
430+
expect(callTool).toHaveBeenCalledWith({
431+
name: "search_by_metadata",
432+
arguments: {
433+
filters: { type: "task", status: "active" },
434+
limit: 10,
435+
output_format: "json",
436+
},
437+
})
438+
expect(result.results).toHaveLength(1)
439+
expect(result.results[0].title).toBe("Task 1")
440+
})
441+
442+
it("moveNote calls move_note with destination_folder in a single MCP call", async () => {
443+
const callTool = jest.fn().mockResolvedValue(
444+
mcpResult({
445+
moved: true,
446+
title: "My Note",
447+
permalink: "archive/my-note",
448+
file_path: "archive/my-note.md",
449+
source: "notes/my-note",
450+
destination: "archive/my-note.md",
451+
}),
452+
)
453+
setConnected(client, callTool)
454+
455+
const result = await client.moveNote("notes/my-note", "archive")
456+
457+
expect(callTool).toHaveBeenCalledTimes(1)
458+
expect(callTool).toHaveBeenCalledWith({
339459
name: "move_note",
340460
arguments: {
341461
identifier: "notes/my-note",
342-
destination_path: "archive/my-note.md",
462+
destination_folder: "archive",
343463
output_format: "json",
344464
},
345465
})
466+
expect(result.title).toBe("My Note")
346467
expect(result.file_path).toBe("archive/my-note.md")
347468
})
348469

0 commit comments

Comments
 (0)