Skip to content

Commit ccb6136

Browse files
authored
feat: OpenClaw integration - ClawHub skill, MCP structured output, and E2E tests (#272)
* feat: OpenClaw ClawHub skill and MCP structured output Add a ClawHub-compatible skill (skills/leann-memory/) that enables OpenClaw agents to use LEANN for 97% storage-compressed memory search with free local embeddings. No PR to the OpenClaw repo required — users install via `clawhub install` or copy the skill directory. Skill files: - claw.json: ClawHub manifest with permissions, tags, model compat - instructions.md: Agent-facing instructions for search workflow - README.md: Human-facing setup and comparison table MCP server: - Add --json flag to leann_search tool for structured output (enables machine-readable results for both MCP and skill paths) Docs: - openclaw-setup.md: Quick start guide with MCP and skill options - openclaw-integration-plan.md: Full competitive analysis, storage growth calculations, and marketing strategy Made-with: Cursor * feat: OpenClaw integration tests, clean JSON output, and type fixes - Add tests/openclaw/ suite: skill manifest, MCP protocol, build/search E2E, and subprocess-based MCP E2E tests - Fix SearchResult.score to emit native float (not numpy.float32) - Add --json quiet mode to suppress informational prints to stderr - Redirect C++ diagnostic printf to stderr in faiss submodule - Add stdout fd-level redirection with libc.fflush for clean JSON output - Fix ty type checker errors (assert shutil.which not None, pytest.skip) Made-with: Cursor * feat: real OpenClaw E2E integration test - Add test_openclaw_e2e.py: 11 tests against a live OpenClaw Docker instance with real memory formation via qwen3:8b + native Ollama API - Fix docker-compose.yml: use --bind lan and native ollama API for proper tool calling (openai-completions silently breaks it) - Add tests/openclaw/.gitignore for docker-data/ Made-with: Cursor * chore: remove internal strategy docs from public repo Made-with: Cursor * docs: restore openclaw-setup.md (user-facing guide) Made-with: Cursor * chore: upgrade pytest to 9.0 and remove skip workarounds pytest 9 fixes the type annotation for pytest.skip(), so ty passes without the `raise pytest.skip.Exception(...)` workaround. Reverted all skip calls back to the clean `pytest.skip(...)` form. Made-with: Cursor * fix: ruff 0.12.7 format and ty type narrowing for CI - Reformat assert statements to match ruff 0.12.7 style - Use assert narrowing instead of explicit type annotation for leann_mcp_bin after pytest.skip Made-with: Cursor
1 parent 1ceb5d5 commit ccb6136

25 files changed

Lines changed: 4463 additions & 3154 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ demo/experiment_results/**/*.json
2121
*.png
2222
!.vscode/*.json
2323
!.devcontainer/*.json
24+
!skills/**/claw.json
2425
*.sh
2526
*.txt
2627
!CMakeLists.txt

docs/openclaw-setup.md

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
# OpenClaw + LEANN Setup Guide
2+
3+
Two ways to connect LEANN to your OpenClaw agent: **MCP server** (recommended)
4+
or **ClawHub skill**.
5+
6+
---
7+
8+
## Option A: MCP Server (Recommended)
9+
10+
OpenClaw natively supports MCP tools. LEANN ships an MCP server that exposes
11+
`leann_search` and `leann_list` as tools your agent can call directly.
12+
13+
### 1. Install LEANN
14+
15+
```bash
16+
pip install leann-core
17+
# or
18+
uv tool install leann-core --with leann
19+
```
20+
21+
### 2. Build an index on your memory files
22+
23+
Using Ollama embeddings (recommended if you already run Ollama):
24+
25+
```bash
26+
leann build openclaw-memory \
27+
--docs ~/.openclaw/workspace/MEMORY.md ~/.openclaw/workspace/memory/ \
28+
--embedding-mode ollama \
29+
--embedding-model nomic-embed-text
30+
```
31+
32+
Or using local sentence-transformers (no Ollama required):
33+
34+
```bash
35+
leann build openclaw-memory \
36+
--docs ~/.openclaw/workspace/MEMORY.md ~/.openclaw/workspace/memory/ \
37+
--embedding-mode sentence-transformers \
38+
--embedding-model all-MiniLM-L6-v2
39+
```
40+
41+
Add extra directories if you have them:
42+
43+
```bash
44+
leann build openclaw-memory \
45+
--docs ~/.openclaw/workspace/MEMORY.md \
46+
~/.openclaw/workspace/memory/ \
47+
~/Documents/notes/ \
48+
--embedding-mode ollama \
49+
--embedding-model nomic-embed-text
50+
```
51+
52+
### 3. Register the MCP server with OpenClaw
53+
54+
Add to `~/.openclaw/openclaw.json`:
55+
56+
```json5
57+
{
58+
// ... your existing config ...
59+
"mcpServers": {
60+
"leann": {
61+
"command": "leann_mcp",
62+
"args": [],
63+
"env": {}
64+
}
65+
}
66+
}
67+
```
68+
69+
### 4. Use it
70+
71+
Ask your agent:
72+
- "Search my memories for database decisions"
73+
- "What did we decide about the API design?"
74+
- "Find my notes on deployment"
75+
76+
The agent will call `leann_search` via MCP and return structured results.
77+
78+
### 5. Keep the index fresh
79+
80+
```bash
81+
# Re-run build (idempotent — only processes changed files)
82+
leann build openclaw-memory \
83+
--docs ~/.openclaw/workspace/MEMORY.md ~/.openclaw/workspace/memory/
84+
85+
# Or use watch mode for continuous auto-sync
86+
leann watch openclaw-memory --interval 30
87+
```
88+
89+
---
90+
91+
## Option B: ClawHub Skill
92+
93+
If you prefer the skill-based approach:
94+
95+
```bash
96+
clawhub install leann-team/leann-memory
97+
```
98+
99+
Or copy `skills/leann-memory/` from this repo to
100+
`~/.openclaw/workspace/skills/leann-memory/`.
101+
102+
The skill tells your agent how to call `leann search` via shell commands.
103+
Setup steps (install + build index) are the same as above.
104+
105+
---
106+
107+
## Important: Ollama Configuration
108+
109+
If you use Ollama as your OpenClaw model provider, make sure your
110+
`~/.openclaw/openclaw.json` uses the **native Ollama API** — not the
111+
OpenAI-compatible endpoint:
112+
113+
```json5
114+
{
115+
"models": {
116+
"providers": {
117+
"ollama": {
118+
"baseUrl": "http://127.0.0.1:11434", // no /v1 suffix
119+
"apiKey": "ollama-local",
120+
"api": "ollama" // NOT "openai-completions" or "openai-responses"
121+
}
122+
}
123+
}
124+
}
125+
```
126+
127+
Using `"openai-completions"` or `"openai-responses"` silently breaks tool
128+
calling — the model outputs tool calls as plain text instead of structured
129+
`tool_calls`. See [astral-sh/ty#21243](https://github.com/openclaw/openclaw/issues/21243).
130+
131+
---
132+
133+
## Storage Comparison
134+
135+
| Scenario | Default memory-core | LEANN |
136+
|---|---|---|
137+
| 1 year daily logs (~12K chunks) | ~23 MB | **~0.7 MB** |
138+
| + session transcripts (~100K chunks) | ~190 MB | **~6 MB** |
139+
| + 10 GB indexed documents (~500K chunks) | ~950 MB | **~30 MB** |
140+
141+
All numbers assume 384-dimensional embeddings (all-MiniLM-L6-v2 or
142+
nomic-embed-text).
143+
144+
---
145+
146+
## Troubleshooting
147+
148+
**"leann: command not found"**
149+
Ensure LEANN is on your PATH. If installed via `uv tool install`, run
150+
`uv tool update-shell` and restart your terminal.
151+
152+
**"Index not found"**
153+
Run `leann list` to see available indexes. Build one first with `leann build`.
154+
155+
**Slow first search**
156+
The first query loads the embedding model (~90 MB). Subsequent queries reuse the
157+
warm daemon and are fast (~0.5s). Use `leann warmup openclaw-memory` to
158+
pre-warm.
159+
160+
**Memory files changed but search results are stale**
161+
Re-run `leann build openclaw-memory --docs ...` — it detects changes
162+
automatically and only re-indexes what changed.
163+
164+
**Agent doesn't use LEANN tools**
165+
Make sure your Ollama model supports tool calling (e.g. `qwen3:8b` or larger).
166+
Smaller models like `qwen3:4b` may not reliably invoke tools.

packages/leann-core/src/leann/api.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1328,7 +1328,7 @@ def search(
13281328
enriched_results.append(
13291329
SearchResult(
13301330
id=string_id,
1331-
score=dist,
1331+
score=float(dist),
13321332
text=passage_data["text"],
13331333
metadata=passage_data.get("metadata", {}),
13341334
)

packages/leann-core/src/leann/cli.py

Lines changed: 41 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2426,14 +2426,17 @@ def _resolve_index_path(
24262426
*,
24272427
non_interactive: bool = True,
24282428
purpose: str = "use",
2429+
quiet: bool = False,
24292430
) -> Optional[str]:
24302431
"""Resolve index path from current project or registered projects."""
2432+
_print = (lambda *a, **kw: print(*a, file=sys.stderr, **kw)) if quiet else print
2433+
24312434
if self.index_exists(index_name):
24322435
return self.get_index_path(index_name)
24332436

24342437
all_matches = self._find_all_matching_indexes(index_name)
24352438
if not all_matches:
2436-
print(
2439+
_print(
24372440
f"Index '{index_name}' not found. Use 'leann build {index_name} --docs <dir> [<dir2> ...]' to create it."
24382441
)
24392442
return None
@@ -2452,7 +2455,7 @@ def _match_to_path(match: dict[str, Any]) -> str:
24522455
if match["is_current"]
24532456
else f"project '{match['project_path'].name}'"
24542457
)
2455-
print(f"Using index '{index_name}' from {project_info}")
2458+
_print(f"Using index '{index_name}' from {project_info}")
24562459
return _match_to_path(match)
24572460

24582461
if non_interactive:
@@ -2463,7 +2466,7 @@ def _match_to_path(match: dict[str, Any]) -> str:
24632466
if match["is_current"]
24642467
else f"project '{match['project_path'].name}'"
24652468
)
2466-
print(
2469+
_print(
24672470
f"Found {len(all_matches)} indexes named '{index_name}', using index from {location_desc}"
24682471
)
24692472
return _match_to_path(match)
@@ -2499,11 +2502,13 @@ def _match_to_path(match: dict[str, Any]) -> str:
24992502
async def search_documents(self, args):
25002503
index_name = args.index_name
25012504
query = args.query
2505+
json_mode = getattr(args, "json", False)
25022506

25032507
index_path = self._resolve_index_path(
25042508
index_name,
25052509
non_interactive=args.non_interactive,
25062510
purpose="search",
2511+
quiet=json_mode,
25072512
)
25082513
if not index_path:
25092514
return
@@ -2513,24 +2518,40 @@ async def search_documents(self, args):
25132518
if args.embedding_prompt_template:
25142519
provider_options["prompt_template"] = args.embedding_prompt_template
25152520

2516-
searcher = LeannSearcher(
2517-
index_path=index_path,
2518-
enable_warmup=args.enable_warmup,
2519-
use_daemon=args.use_daemon,
2520-
daemon_ttl_seconds=args.daemon_ttl,
2521-
)
2522-
results = searcher.search(
2523-
query,
2524-
top_k=args.top_k,
2525-
complexity=args.complexity,
2526-
beam_width=args.beam_width,
2527-
prune_ratio=args.prune_ratio,
2528-
recompute_embeddings=args.recompute_embeddings,
2529-
pruning_strategy=args.pruning_strategy,
2530-
provider_options=provider_options if provider_options else None,
2531-
)
2521+
if json_mode:
2522+
sys.stdout.flush()
2523+
saved_fd = os.dup(1)
2524+
devnull = os.open(os.devnull, os.O_WRONLY)
2525+
os.dup2(devnull, 1)
2526+
os.close(devnull)
2527+
2528+
try:
2529+
searcher = LeannSearcher(
2530+
index_path=index_path,
2531+
enable_warmup=args.enable_warmup,
2532+
use_daemon=args.use_daemon,
2533+
daemon_ttl_seconds=args.daemon_ttl,
2534+
)
2535+
results = searcher.search(
2536+
query,
2537+
top_k=args.top_k,
2538+
complexity=args.complexity,
2539+
beam_width=args.beam_width,
2540+
prune_ratio=args.prune_ratio,
2541+
recompute_embeddings=args.recompute_embeddings,
2542+
pruning_strategy=args.pruning_strategy,
2543+
provider_options=provider_options if provider_options else None,
2544+
)
2545+
finally:
2546+
if json_mode:
2547+
import ctypes
2548+
2549+
libc = ctypes.CDLL(None)
2550+
libc.fflush(None)
2551+
os.dup2(saved_fd, 1)
2552+
os.close(saved_fd)
25322553

2533-
if getattr(args, "json", False):
2554+
if json_mode:
25342555
json_results = [
25352556
{
25362557
"id": r.id,

packages/leann-core/src/leann/mcp.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,6 @@ def handle_request(request):
9999
},
100100
}
101101

102-
# Build simplified command with non-interactive flag for MCP compatibility
103102
cmd = [
104103
"leann",
105104
"search",
@@ -108,6 +107,7 @@ def handle_request(request):
108107
f"--top-k={args.get('top_k', 5)}",
109108
f"--complexity={args.get('complexity', 32)}",
110109
"--non-interactive",
110+
"--json",
111111
]
112112
if args.get("show_metadata", False):
113113
cmd.append("--show-metadata")

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ lint = [
9999

100100
# Test toolchain (no heavy project runtime deps)
101101
test = [
102-
"pytest>=7.0",
102+
"pytest>=9.0",
103103
"pytest-cov>=4.0",
104104
"pytest-xdist>=3.0",
105105
"pytest-timeout>=2.0",

skills/leann-memory/README.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# LEANN Memory Search for OpenClaw
2+
3+
97% storage-compressed semantic memory search with free local embeddings.
4+
5+
## Why
6+
7+
Every OpenClaw memory solution stores full embedding vectors. On a 256 GB Mac
8+
Mini, heavy users accumulate 500 MB - 6 GB+ of embedding indexes over time.
9+
LEANN compresses this to ~2% of the original size through graph-based selective
10+
recomputation, while using high-quality local embeddings (zero API cost).
11+
12+
| Feature | Default memory | LEANN |
13+
|---|---|---|
14+
| Storage (50K chunks) | ~75 MB | **~2 MB** |
15+
| Embedding cost | Remote API ($) | **$0 (local)** |
16+
| Scale | ~100K chunks | **60M+ passages** |
17+
18+
## Install
19+
20+
```bash
21+
# Install LEANN
22+
pip install leann-core
23+
24+
# Install the skill
25+
clawhub install leann-team/leann-memory
26+
27+
# Or manually: copy this directory to ~/.openclaw/workspace/skills/leann-memory/
28+
```
29+
30+
## Quick Start
31+
32+
```bash
33+
# Build index on your memory files
34+
leann build openclaw-memory \
35+
--docs ~/.openclaw/workspace/MEMORY.md ~/.openclaw/workspace/memory/ \
36+
--embedding-model all-MiniLM-L6-v2
37+
38+
# Test a search
39+
leann search openclaw-memory "what did we decide about the database" --json
40+
```
41+
42+
Then ask your OpenClaw agent: "search my memories for database decisions"
43+
44+
## Auto-Sync
45+
46+
Keep the index updated as new memories are added:
47+
48+
```bash
49+
# One-shot check and rebuild
50+
leann watch openclaw-memory --once
51+
52+
# Continuous monitoring (runs in background)
53+
leann watch openclaw-memory --interval 30
54+
```
55+
56+
## How It Works
57+
58+
LEANN stores a pruned neighbor graph instead of full embedding vectors. During
59+
search, embeddings are recomputed on-demand via a local daemon. OpenClaw's async
60+
"sleep time compute" model makes recomputation latency invisible to users.
61+
62+
## Links
63+
64+
- [LEANN Repository](https://github.com/yichuan-w/LEANN)
65+
- [Integration Plan](../../docs/openclaw-integration-plan.md)

0 commit comments

Comments
 (0)