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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,7 @@ data_bench/

# Test stack (docker-compose.test.yml): separate host data dir for test ingests
data_test/

# Hermes sandbox (integrations/hermes/sandbox): throwaway agent profile dir —
# holds a .env with the LLM key + provider state; never commit it.
integrations/hermes/sandbox/hermes-data/
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ braindb/
│ ├── config.py # Settings (decay rates, graph params, agent config)
│ ├── db.py # get_conn() — one psycopg2 connection per request
│ ├── ingest_watcher.py # Always-on sidecar: polls data/sources/, triggers agent
│ ├── routers/ # entities.py, relations.py, memory.py, agent.py
│ ├── routers/ # entities.py, relations.py, memory.py, agent.py, wiki.py, integrations.py
│ ├── schemas/ # Pydantic models (entities, relations, search)
│ ├── services/ # search.py, graph.py, context.py, embedding_service.py,
│ │ # keyword_service.py, activity_log.py
Expand All @@ -88,6 +88,7 @@ braindb/
│ ├── braindb/SKILL.md # Direct curl-based skill
│ └── braindb-agent/SKILL.md # Thin wrapper around the agent endpoint
├── frontend/ # Read-only browser UI — vanilla JS, no build (served at :8642)
├── integrations/hermes/ # BrainDB-as-memory for Hermes Agent (provider plugin + test sandbox)
├── docker-compose.yml # api + watcher + wiki_scheduler + frontend (+ optional bundled Postgres)
├── .env # Real credentials — DO NOT COMMIT
└── BRAINDB_GUIDE.md # Full API reference with curl examples
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,10 @@ If you'd rather have BrainDB always running (even before any skill is invoked),

Replace `/ABSOLUTE/PATH/TO/braindb` with your repo path. The hook is async (non-blocking).

## Integrations

Use BrainDB as the memory backend for other agents. **Hermes Agent** (Nous Research) has a ready-made provider in [`integrations/hermes/`](integrations/hermes/): a native memory-provider plugin ([`braindb/`](integrations/hermes/braindb/)) that gives the agent a `braindb_ask` gateway plus a `braindb_ingest` tool, and a hardened, tool-stripped Docker sandbox ([`sandbox/`](integrations/hermes/sandbox/)) for safely trying it on a throwaway host. Each folder's README has the setup.

## File Ingestion

Drop a file in `data/sources/` — the always-on watcher sidecar picks it up within 7s, ingests it, and runs a chunked fact-extraction pipeline that saves atomic facts into the knowledge graph linked back to the source via `derived_from` relations. Processed files move to `data/sources/ingested/`, failures to `data/sources/failed/` with an `.error.txt` sidecar.
Expand Down
5 changes: 4 additions & 1 deletion braindb/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

from braindb.routers import agent, entities, memory, relations, wiki
from braindb.routers import agent, entities, integrations, memory, relations, wiki
from braindb.services.embedding_service import get_embedding_service

logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
Expand All @@ -26,6 +26,9 @@
app.include_router(memory.router)
app.include_router(agent.router)
app.include_router(wiki.router)
# External-integration endpoints (Hermes memory provider + similar clients).
# Additive only; see braindb/routers/integrations.py.
app.include_router(integrations.router)


@app.on_event("startup")
Expand Down
93 changes: 93 additions & 0 deletions braindb/routers/integrations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""
External-integration endpoints — consumed by the Hermes memory provider (see
`integrations/hermes/`) and similar external clients. These are NOT used by
BrainDB's own app, agent, or watcher; they exist purely so an outside agent can
(a) self-configure from BrainDB's shipped skill text (single source of truth, no
copied prompt) and (b) hand a file to BrainDB's existing ingestion pipeline over
HTTP without needing a shared filesystem.

Kept isolated in this dedicated router (and registered with a single
`include_router` line in main.py) so the integration surface is obvious and the
core routers stay untouched. Everything here is additive — no existing route,
schema, or behaviour is affected.
"""
import os
from pathlib import Path

from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import PlainTextResponse

router = APIRouter(prefix="/api/v1", tags=["integrations"])

# Mirror the watcher's watch dir + allow-list so an upload lands exactly where
# the existing ingestion pipeline (braindb/ingest_watcher.py) already looks.
_WATCH_DIR = Path(os.getenv("INGEST_WATCH_DIR", "data/sources"))
_ALLOWED_EXTS = {".md", ".txt", ".json", ".yaml", ".yml", ".csv", ".log", ".html", ".xml"}
_MAX_UPLOAD_BYTES = 10 * 1024 * 1024 # 10 MB — generous for text docs, blocks abuse
_SKILLS_DIR = Path("skills")


def _safe_basename(raw: str) -> str:
"""Reduce a client-supplied name to a bare filename — no path traversal.

We only ever join the basename to a fixed directory, so traversal is
impossible even before this check; the explicit guard just rejects obvious
junk early with a clear 400.
"""
base = os.path.basename((raw or "").replace("\\", "/").strip())
if not base or base in (".", "..") or base.startswith("."):
raise HTTPException(400, "Invalid or unsafe name")
return base


@router.get("/skill/{name}", response_class=PlainTextResponse)
def get_skill(name: str) -> str:
"""Serve a shipped skill's markdown (e.g. `name=braindb-agent`).

Lets an external client load its usage instructions live from BrainDB rather
than bundling a copy that could drift. Read-only.
"""
safe = _safe_basename(name)
path = _SKILLS_DIR / safe / "SKILL.md"
if not path.is_file():
raise HTTPException(404, f"No skill named {safe!r}")
return path.read_text(encoding="utf-8")


@router.post("/entities/datasources/upload", status_code=202)
async def upload_datasource(request: Request, filename: str):
"""Accept a file (raw request body) and drop it into `data/sources/` so the
existing watcher ingests + fact-extracts it.

Adds NO ingestion logic of its own — it only lands the bytes where the
pipeline already polls. Extraction is asynchronous (the watcher picks the
file up on its next tick). Raw body + `filename` query param is used
deliberately so this needs no multipart dependency.
"""
fname = _safe_basename(filename)
ext = Path(fname).suffix.lower()
if ext not in _ALLOWED_EXTS:
raise HTTPException(400, f"Unsupported extension {ext!r}; allowed: {sorted(_ALLOWED_EXTS)}")

data = await request.body()
if not data:
raise HTTPException(400, "Empty body")
if len(data) > _MAX_UPLOAD_BYTES:
raise HTTPException(413, f"File too large (> {_MAX_UPLOAD_BYTES} bytes)")

_WATCH_DIR.mkdir(parents=True, exist_ok=True)
dest = _WATCH_DIR / fname
# No-overwrite on name collision (mirrors the watcher's own dedup).
if dest.exists():
stem, suffix = dest.stem, dest.suffix
i = 1
while (cand := _WATCH_DIR / f"{stem}.{i}{suffix}").exists():
i += 1
dest = cand
dest.write_bytes(data)

return {
"filename": dest.name,
"status": "accepted",
"message": "Uploaded to data/sources/; the watcher will ingest and fact-extract it shortly.",
}
65 changes: 65 additions & 0 deletions integrations/hermes/braindb/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# BrainDB memory provider for Hermes Agent

Use a self-hosted [BrainDB](https://github.com/dimknaf/braindb) as the long-term
memory backend for [Hermes Agent](https://github.com/NousResearch/hermes-agent).

It's a small native Hermes `MemoryProvider`: the Hermes agent gets two tools that
talk to a running BrainDB over HTTP. Nothing about BrainDB's API is re-implemented
here — the gateway tool delegates to BrainDB's own agent, which already wields its
full toolset.

## Install

Hermes discovers memory providers as folders under `~/.hermes/plugins/`. Copy this
plugin folder there (user plugins live FLAT under `plugins/<name>/`):

```bash
cp -r integrations/hermes/braindb ~/.hermes/plugins/braindb
```

Then activate it:

```bash
hermes memory setup # pick "braindb"
```

## Configure

One setting, resolved in this order: `BRAINDB_BASE_URL` env var →
`$HERMES_HOME/braindb.json` (`{"base_url": "..."}`) → default `http://localhost:8000`.

```bash
export BRAINDB_BASE_URL=http://localhost:8000
```

## Tools the agent gets

- **`braindb_ask(query)`** — ask BrainDB in natural language to recall or remember.
This is the gateway to *all* of BrainDB: its internal agent does the search,
storage, graph-linking and reasoning, and returns an answer.
- **`braindb_ingest(file_path)`** — upload a local text file to BrainDB. BrainDB
ingests and **fact-extracts it asynchronously**, so the extracted facts show up
on a *later* `braindb_ask` recall, not immediately. Supported extensions:
`.md .txt .json .yaml .yml .csv .log .html .xml`.

Usage instructions for the agent are loaded **live** from BrainDB's
`/api/v1/skill/braindb-agent` endpoint (single source of truth, cached locally),
so there's no duplicated prompt to keep in sync.

## Deployment notes

- **Shared store, single user.** All memory is one BrainDB instance; this provider
does no per-user scoping.
- **BrainDB has no authentication.** Run Hermes and BrainDB **co-located**, with
BrainDB on loopback (`localhost`). For a *remote* BrainDB you must put your own
auth / tunnel in front of it — that's out of scope here.
- The provider makes all calls over HTTP itself; it never shells out via the
agent's terminal, so it works regardless of Hermes' terminal sandbox.

## Requires on the BrainDB side

Two small additive endpoints (shipped with BrainDB in
`braindb/routers/integrations.py`): `GET /api/v1/skill/{name}` (serves the skill
text) and `POST /api/v1/entities/datasources/upload` (lands a file in
`data/sources/` for the watcher to ingest). The BrainDB **watcher** must be
running for ingestion to happen (it is by default in `docker-compose`).
Loading
Loading