Compare commits
10
Commits
cdad9d3931
...
88228a6104
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88228a6104 | ||
|
|
70e95e7d29 | ||
|
|
a0717e136b | ||
|
|
3c46991a2e | ||
|
|
2c552e5571 | ||
|
|
0481435ca6 | ||
|
|
7dcdafcf67 | ||
|
|
51e6aa3181 | ||
|
|
3fd6c094d5 | ||
|
|
644316974e |
+20
-2
@@ -11,6 +11,7 @@ OPENAI_API_KEY=
|
|||||||
#
|
#
|
||||||
# npm run setup:local -- --profile openai
|
# npm run setup:local -- --profile openai
|
||||||
# npm run setup:local -- --profile qwen-local
|
# npm run setup:local -- --profile qwen-local
|
||||||
|
# npm run setup:local -- --profile llama-cpp
|
||||||
#
|
#
|
||||||
# OpenAI profile:
|
# OpenAI profile:
|
||||||
# LLM_PROFILE=openai
|
# LLM_PROFILE=openai
|
||||||
@@ -30,13 +31,30 @@ OPENAI_API_KEY=
|
|||||||
# EMBEDDING_MODEL=qwen3-embedding:0.6b
|
# EMBEDDING_MODEL=qwen3-embedding:0.6b
|
||||||
# EMBEDDING_DIMENSIONS=1024
|
# EMBEDDING_DIMENSIONS=1024
|
||||||
#
|
#
|
||||||
|
# Example llama.cpp:
|
||||||
|
# LLM_PROFILE=openai-compatible
|
||||||
|
# LLM_BASE_URL=http://127.0.0.1:8080/v1
|
||||||
|
# LLM_MODEL=qwen3-4b
|
||||||
|
# EMBEDDING_PROFILE=openai-compatible
|
||||||
|
# EMBEDDING_BASE_URL=http://127.0.0.1:8081/v1
|
||||||
|
# EMBEDDING_MODEL=qwen3-embedding-0.6b
|
||||||
|
# EMBEDDING_DIMENSIONS=1024
|
||||||
|
#
|
||||||
# Example Qdrant sidecar, only needed when sqlite-vec is unavailable or unreliable:
|
# Example Qdrant sidecar, only needed when sqlite-vec is unavailable or unreliable:
|
||||||
# VECTOR_BACKEND=qdrant
|
# VECTOR_BACKEND=qdrant
|
||||||
# QDRANT_URL=http://localhost:6333
|
# QDRANT_URL=http://localhost:6333
|
||||||
|
|
||||||
# Database/vector paths are auto-detected for macOS, Windows, and Linux.
|
# Database path defaults to the operating system app-data folder:
|
||||||
# Override only if you intentionally want a custom location.
|
# macOS: ~/Library/Application Support/RA-H/db/rah.sqlite
|
||||||
|
# Linux: ~/.local/share/RA-H/db/rah.sqlite
|
||||||
|
# Windows: %APPDATA%/RA-H/db/rah.sqlite
|
||||||
|
#
|
||||||
|
# Set SQLITE_DB_PATH before setup if you intentionally want a repo-local DB,
|
||||||
|
# demo DB, or any other separate location.
|
||||||
# SQLITE_DB_PATH=/absolute/path/to/rah.sqlite
|
# SQLITE_DB_PATH=/absolute/path/to/rah.sqlite
|
||||||
|
|
||||||
|
# sqlite-vec extension path is auto-detected for macOS, Windows, and Linux.
|
||||||
|
# Override only if you intentionally want a custom extension location.
|
||||||
# SQLITE_VEC_EXTENSION_PATH=/absolute/path/to/vec0.<dylib|dll|so>
|
# SQLITE_VEC_EXTENSION_PATH=/absolute/path/to/vec0.<dylib|dll|so>
|
||||||
|
|
||||||
# App config (no changes needed)
|
# App config (no changes needed)
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ yarn-error.log*
|
|||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
|
.ra-h/
|
||||||
*.db
|
*.db
|
||||||
*.sqlite
|
*.sqlite
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ llama-server -m /models/qwen3-embedding-0.6b.gguf --embedding --port 8081
|
|||||||
|
|
||||||
Configure RA-H:
|
Configure RA-H:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run setup:local -- --profile llama-cpp
|
||||||
|
```
|
||||||
|
|
||||||
|
That writes this profile to `.env.local`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
LLM_PROFILE=openai-compatible
|
LLM_PROFILE=openai-compatible
|
||||||
LLM_BASE_URL=http://127.0.0.1:8080/v1
|
LLM_BASE_URL=http://127.0.0.1:8080/v1
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ OpenAI remains the default supported path. Local mode is for users who are comfo
|
|||||||
|
|
||||||
## Core Env
|
## Core Env
|
||||||
|
|
||||||
|
Ollama:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
LLM_PROFILE=openai-compatible
|
LLM_PROFILE=openai-compatible
|
||||||
LLM_BASE_URL=http://127.0.0.1:11434/v1
|
LLM_BASE_URL=http://127.0.0.1:11434/v1
|
||||||
@@ -24,6 +26,19 @@ EMBEDDING_MODEL=qwen3-embedding:0.6b
|
|||||||
EMBEDDING_DIMENSIONS=1024
|
EMBEDDING_DIMENSIONS=1024
|
||||||
```
|
```
|
||||||
|
|
||||||
|
llama.cpp:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
LLM_PROFILE=openai-compatible
|
||||||
|
LLM_BASE_URL=http://127.0.0.1:8080/v1
|
||||||
|
LLM_MODEL=qwen3-4b
|
||||||
|
|
||||||
|
EMBEDDING_PROFILE=openai-compatible
|
||||||
|
EMBEDDING_BASE_URL=http://127.0.0.1:8081/v1
|
||||||
|
EMBEDDING_MODEL=qwen3-embedding-0.6b
|
||||||
|
EMBEDDING_DIMENSIONS=1024
|
||||||
|
```
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝
|
╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝
|
||||||
```
|
```
|
||||||
|
|
||||||
**TL;DR:** Use the MCP quick install if you want Claude Code, Cursor, Codex, or another agent to read and write your local graph. Clone this repository only if you also want the local browser UI.
|
**TL;DR:** Clone this repository, choose where the models run, then start the local app. Your SQLite database stays on your device in every setup. Choose **OpenAI** if you want the easiest model setup. Choose **local Qwen** through Ollama or llama.cpp if you want the utility model and embedding model running on your own machine.
|
||||||
|
|
||||||
[](https://youtu.be/YyUCGigZIZE?si=USYgvmwtdGpgGdwu)
|
[](https://youtu.be/YyUCGigZIZE?si=USYgvmwtdGpgGdwu)
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
2. **Provides a UI** — Browse, search, and organize your nodes at `localhost:3000`
|
2. **Provides a UI** — Browse, search, and organize your nodes at `localhost:3000`
|
||||||
3. **Exposes an MCP server** — Claude Code and other MCP clients can query and add to your knowledge base
|
3. **Exposes an MCP server** — Claude Code and other MCP clients can query and add to your knowledge base
|
||||||
|
|
||||||
Your data stays on your machine. Nothing is sent anywhere unless you configure an API key.
|
Your database stays on your machine. With the `openai` profile, model requests go to OpenAI after you add an API key. With `qwen-local` or `llama-cpp`, model requests go to your local OpenAI-compatible endpoints.
|
||||||
|
|
||||||
Current contract:
|
Current contract:
|
||||||
- no runtime `dimensions`
|
- no runtime `dimensions`
|
||||||
@@ -48,78 +48,112 @@ Current contract:
|
|||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
### Which install should I use?
|
### Choose One Model Path
|
||||||
|
|
||||||
|
Every path uses a local SQLite database. The choice is only about where the two AI models run:
|
||||||
|
|
||||||
| You want... | Use this path |
|
| You want... | Use this path |
|
||||||
|-------------|---------------|
|
|-------------|---------------|
|
||||||
| Your AI coding agent can read/write your RA-H graph | **Option A: MCP-only quick install** |
|
| The simplest setup and strongest default model quality | **Local DB + OpenAI models** |
|
||||||
| A browser UI at `localhost:3000` | **Option B: Full local app** |
|
| No model calls leaving your computer | **Local DB + local Qwen models** |
|
||||||
| A clean demo that does not touch your real graph | **Demo-safe isolated install** |
|
|
||||||
|
|
||||||
### Option A: MCP-only quick install
|
What "OpenAI models" means:
|
||||||
|
- Your database is local.
|
||||||
|
- Your notes, graph, chunks, and vectors are stored in SQLite on your device.
|
||||||
|
- RA-H sends utility-model requests and embedding requests to the OpenAI API after you add an API key.
|
||||||
|
- The utility model helps with descriptions and summaries. The embedding model powers semantic search.
|
||||||
|
- Choose this if you want the easiest setup and do not want to manage local model runtimes.
|
||||||
|
|
||||||
If you mainly want Claude Code, Cursor, Codex, or another coding agent to use RA-H, start here.
|
What "local Qwen models" means:
|
||||||
|
- Your database is local.
|
||||||
|
- The utility model runs locally.
|
||||||
|
- The embedding model runs locally.
|
||||||
|
- Those model requests go to Ollama or llama.cpp on your device, not to OpenAI or another hosted model API.
|
||||||
|
- The utility model helps with descriptions and summaries. The embedding model powers semantic search.
|
||||||
|
- Choose this if you care most about local control, privacy, offline operation, or avoiding API usage. It requires more setup and enough local hardware.
|
||||||
|
|
||||||
For Claude Code:
|
### Option 1: Local DB + OpenAI Models
|
||||||
|
|
||||||
```bash
|
Use this if you want RA-H running quickly and are comfortable using OpenAI for descriptions, embeddings, and semantic search.
|
||||||
npx -y ra-h-mcp-server@latest setup --client claude-code --yes
|
|
||||||
```
|
|
||||||
|
|
||||||
Then fully restart Claude Code. On Mac, use **Cmd+Q**, then reopen it.
|
|
||||||
|
|
||||||
Verify:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx -y ra-h-mcp-server@latest doctor
|
|
||||||
```
|
|
||||||
|
|
||||||
Then ask your agent:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Do you have RA-H tools available?
|
|
||||||
```
|
|
||||||
|
|
||||||
You should see tools like `queryNodes`, `retrieveQueryContext`, `createNode`, and `readSkill`.
|
|
||||||
|
|
||||||
Other clients:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx -y ra-h-mcp-server@latest setup --client cursor --yes
|
|
||||||
npx -y ra-h-mcp-server@latest setup --client codex --yes
|
|
||||||
```
|
|
||||||
|
|
||||||
Multiple clients can be installed in one pass. This is the best path if you use both Claude Code and Codex:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx -y ra-h-mcp-server@latest setup --client claude-code,codex --yes
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- `--yes` lets the installer write supported client config automatically.
|
|
||||||
- Codex uses TOML config, so the installer writes `CODEX_HOME/config.toml` or `~/.codex/config.toml`.
|
|
||||||
- The MCP-only path does not clone this repo and does not start the browser UI.
|
|
||||||
- The installer defaults to the latest published MCP package. For release/debug reproducibility, pin an exact version intentionally.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx -y ra-h-mcp-server@latest setup --client claude-code --yes --pin current
|
|
||||||
```
|
|
||||||
|
|
||||||
### Option B: Full local app
|
|
||||||
|
|
||||||
Use this if you want the browser UI at `localhost:3000`.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/bradwmorris/ra-h_os.git
|
git clone https://github.com/bradwmorris/ra-h_os.git
|
||||||
cd ra-h_os
|
cd ra-h_os
|
||||||
npm install
|
npm install
|
||||||
npm run setup:local
|
npm run setup:local -- --profile openai
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [localhost:3000](http://localhost:3000). Done.
|
Open [localhost:3000](http://localhost:3000). Add your OpenAI API key when the app prompts you, or later in Settings -> API Keys.
|
||||||
|
|
||||||
If you also want your coding agent connected to the same database, run Option A after the app setup.
|
### Option 2A: Local DB + Local Qwen/Ollama
|
||||||
|
|
||||||
|
Use this if you want the easiest local-model setup. Ollama runs the local utility and embedding models.
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
- Ollama is installed and running.
|
||||||
|
- These models are pulled before setup.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/bradwmorris/ra-h_os.git
|
||||||
|
cd ra-h_os
|
||||||
|
npm install
|
||||||
|
ollama pull qwen3:4b
|
||||||
|
ollama pull qwen3-embedding:0.6b
|
||||||
|
npm run setup:local -- --profile qwen-local
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [localhost:3000](http://localhost:3000). Settings -> API Keys will show the active local model profile and disable OpenAI key entry.
|
||||||
|
|
||||||
|
### Option 2B: Local DB + Local Qwen/llama.cpp
|
||||||
|
|
||||||
|
Use this if you want local model calls and prefer managing GGUF files and llama.cpp server processes yourself.
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
- llama.cpp is installed.
|
||||||
|
- You have compatible Qwen chat and embedding GGUF files.
|
||||||
|
- You start separate OpenAI-compatible servers before running RA-H setup.
|
||||||
|
|
||||||
|
Example llama.cpp servers:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
llama-server -m /models/qwen3-4b.gguf --port 8080
|
||||||
|
llama-server -m /models/qwen3-embedding-0.6b.gguf --embedding --port 8081
|
||||||
|
```
|
||||||
|
|
||||||
|
Then set up RA-H:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/bradwmorris/ra-h_os.git
|
||||||
|
cd ra-h_os
|
||||||
|
npm install
|
||||||
|
npm run setup:local -- --profile llama-cpp
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [localhost:3000](http://localhost:3000). Settings -> API Keys will show the active local model profile and disable OpenAI key entry.
|
||||||
|
|
||||||
|
### Connect MCP After App Setup
|
||||||
|
|
||||||
|
MCP lets Claude Code, Codex, Cursor, and other coding agents read and write your RA-H graph. Configure it after the app database exists.
|
||||||
|
|
||||||
|
If you used the default database path:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx -y ra-h-mcp-server@latest setup --client claude-code,codex --yes
|
||||||
|
```
|
||||||
|
|
||||||
|
If you used a custom `SQLITE_DB_PATH`, pass that exact same path with `--db`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx -y ra-h-mcp-server@latest setup \
|
||||||
|
--client claude-code,codex \
|
||||||
|
--yes \
|
||||||
|
--db "/absolute/path/to/rah.sqlite"
|
||||||
|
```
|
||||||
|
|
||||||
|
Fully restart the agent client after changing MCP config. For Claude Code on Mac, use **Cmd+Q**, then reopen it.
|
||||||
|
|
||||||
Full install details:
|
Full install details:
|
||||||
- [docs/README.md](./docs/README.md)
|
- [docs/README.md](./docs/README.md)
|
||||||
@@ -130,11 +164,19 @@ Full install details:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## First-Time Setup
|
## First-Time Setup Rules
|
||||||
|
|
||||||
Pick the embedding profile before the database is created. This matters because
|
Pick the embedding profile before the database is created.
|
||||||
the readable `nodes` and `chunks` tables are normal SQLite tables, but the
|
|
||||||
derived sqlite-vec vector tables are created with a fixed embedding width.
|
This is not cosmetic. The readable `nodes` and `chunks` tables are normal SQLite tables, but the derived vector tables are created with a fixed embedding width:
|
||||||
|
|
||||||
|
| Setup profile | Utility model | Embedding model | Vector width |
|
||||||
|
|---------------|---------------|-----------------|--------------|
|
||||||
|
| `openai` | `gpt-4o-mini` | `text-embedding-3-small` | `1536` |
|
||||||
|
| `qwen-local` | `qwen3:4b` through Ollama | `qwen3-embedding:0.6b` through Ollama | `1024` |
|
||||||
|
| `llama-cpp` | `qwen3-4b` through llama.cpp | `qwen3-embedding-0.6b` through llama.cpp | `1024` |
|
||||||
|
|
||||||
|
Setup requires one of these commands on a fresh install.
|
||||||
|
|
||||||
OpenAI:
|
OpenAI:
|
||||||
|
|
||||||
@@ -154,12 +196,17 @@ npm run setup:local -- --profile qwen-local
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
If you run setup without a profile and `.env.local` does not already select one,
|
Local Qwen with llama.cpp:
|
||||||
setup stops before creating vector tables and prints the two supported commands.
|
|
||||||
|
|
||||||
If you change embedding provider, model, dimensions, or vector backend after
|
```bash
|
||||||
data exists, your source data stays intact but derived embeddings must be
|
npm install
|
||||||
rebuilt:
|
npm run setup:local -- --profile llama-cpp
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
If you run setup without a profile and `.env.local` does not already select one, setup stops before creating vector tables and prints the supported commands.
|
||||||
|
|
||||||
|
If you change embedding provider, model, dimensions, or vector backend after data exists, your source data stays intact but derived embeddings must be rebuilt:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run rebuild:embeddings
|
npm run rebuild:embeddings
|
||||||
@@ -169,7 +216,9 @@ npm run rebuild:embeddings
|
|||||||
|
|
||||||
## OpenAI API Key
|
## OpenAI API Key
|
||||||
|
|
||||||
**Optional but recommended.** Without a key, you can still create and organize nodes manually.
|
Only applies to the `openai` setup profile.
|
||||||
|
|
||||||
|
Without a key, you can still create and organize nodes manually.
|
||||||
|
|
||||||
With a key, you get:
|
With a key, you get:
|
||||||
- Auto-generated descriptions when you add nodes
|
- Auto-generated descriptions when you add nodes
|
||||||
@@ -178,17 +227,21 @@ With a key, you get:
|
|||||||
|
|
||||||
**Cost:** Less than $0.10/day for heavy use. Most users spend $1-2/month.
|
**Cost:** Less than $0.10/day for heavy use. Most users spend $1-2/month.
|
||||||
|
|
||||||
**Setup:** The app will prompt you on first launch, or go to Settings → API Keys.
|
**Setup:** The app will prompt you on first launch, or go to Settings -> API Keys.
|
||||||
|
|
||||||
Get a key at [platform.openai.com/api-keys](https://platform.openai.com/api-keys)
|
Get a key at [platform.openai.com/api-keys](https://platform.openai.com/api-keys)
|
||||||
|
|
||||||
|
If you selected `qwen-local` or `llama-cpp`, do not add an OpenAI key in the UI. Settings -> API Keys shows the active local model profile and disables OpenAI key entry so the install stays aligned with the setup profile.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Local Model Profile
|
## Local Model Profile
|
||||||
|
|
||||||
OpenAI remains the default supported cloud path. If you want local utility LLM calls and local embeddings, run a local OpenAI-compatible model server and point RA-H at it.
|
Use `qwen-local` or `llama-cpp` if you want local utility LLM calls and local embeddings.
|
||||||
|
|
||||||
Supported local contract:
|
RA-H does not bundle model weights. It calls local OpenAI-compatible HTTP endpoints. The tested local paths are Ollama with Qwen and llama.cpp with compatible Qwen GGUF files.
|
||||||
|
|
||||||
|
Supported Ollama contract:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
LLM_PROFILE=openai-compatible
|
LLM_PROFILE=openai-compatible
|
||||||
@@ -201,6 +254,19 @@ EMBEDDING_MODEL=qwen3-embedding:0.6b
|
|||||||
EMBEDDING_DIMENSIONS=1024
|
EMBEDDING_DIMENSIONS=1024
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Supported llama.cpp contract:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
LLM_PROFILE=openai-compatible
|
||||||
|
LLM_BASE_URL=http://127.0.0.1:8080/v1
|
||||||
|
LLM_MODEL=qwen3-4b
|
||||||
|
|
||||||
|
EMBEDDING_PROFILE=openai-compatible
|
||||||
|
EMBEDDING_BASE_URL=http://127.0.0.1:8081/v1
|
||||||
|
EMBEDDING_MODEL=qwen3-embedding-0.6b
|
||||||
|
EMBEDDING_DIMENSIONS=1024
|
||||||
|
```
|
||||||
|
|
||||||
Runtime guides:
|
Runtime guides:
|
||||||
|
|
||||||
- [Ollama local profile](./OLLAMA-LOCAL-PROFILE.md)
|
- [Ollama local profile](./OLLAMA-LOCAL-PROFILE.md)
|
||||||
@@ -218,7 +284,7 @@ If you change embedding provider, model, dimensions, or vector backend after dat
|
|||||||
npm run rebuild:embeddings
|
npm run rebuild:embeddings
|
||||||
```
|
```
|
||||||
|
|
||||||
Custom model/provider overrides are advanced and not a broad support guarantee. They may work, but the tested product surface is OpenAI plus the narrow local Qwen profile.
|
Custom model/provider overrides are advanced and not a broad support guarantee. They may work, but the tested product surface is OpenAI plus the narrow local Qwen profiles.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -240,21 +306,125 @@ QDRANT_URL=http://localhost:6333
|
|||||||
|
|
||||||
SQLite remains the source-of-truth database. Qdrant stores only derived vector indexes.
|
SQLite remains the source-of-truth database. Qdrant stores only derived vector indexes.
|
||||||
|
|
||||||
|
Qdrant does not change your model choice. OpenAI vs local Qwen is controlled by `LLM_PROFILE` and `EMBEDDING_PROFILE`. sqlite-vec vs Qdrant is controlled by `VECTOR_BACKEND`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Where Your Data Lives
|
## Where Your Data Lives
|
||||||
|
|
||||||
|
By default, setup creates and seeds the SQLite database in your operating system's app-data folder, not inside the cloned repo:
|
||||||
|
|
||||||
```
|
```
|
||||||
~/Library/Application Support/RA-H/db/rah.sqlite # macOS
|
~/Library/Application Support/RA-H/db/rah.sqlite # macOS
|
||||||
~/.local/share/RA-H/db/rah.sqlite # Linux
|
~/.local/share/RA-H/db/rah.sqlite # Linux
|
||||||
%APPDATA%/RA-H/db/rah.sqlite # Windows
|
%APPDATA%/RA-H/db/rah.sqlite # Windows
|
||||||
```
|
```
|
||||||
|
|
||||||
|
This default applies to all app profiles:
|
||||||
|
- `npm run setup:local -- --profile openai`
|
||||||
|
- `npm run setup:local -- --profile qwen-local`
|
||||||
|
- `npm run setup:local -- --profile llama-cpp`
|
||||||
|
|
||||||
This is a standard SQLite file. You can:
|
This is a standard SQLite file. You can:
|
||||||
- Back it up by copying the file
|
- Back it up by copying the file
|
||||||
- Query it directly with `sqlite3` or any SQLite tool
|
- Query it directly with `sqlite3` or any SQLite tool
|
||||||
- Move it between machines
|
- Move it between machines
|
||||||
|
|
||||||
|
You can put the database somewhere else by setting `SQLITE_DB_PATH` before setup. Use this when you want a repo-local DB, a demo DB, or any other separate database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SQLITE_DB_PATH="$HOME/Desktop/ra-h_os-demo-data/rah.sqlite" npm run setup:local -- --profile qwen-local
|
||||||
|
```
|
||||||
|
|
||||||
|
To put it directly inside your cloned repo, use a gitignored local folder:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SQLITE_DB_PATH="$PWD/.ra-h/db/rah.sqlite" npm run setup:local -- --profile qwen-local
|
||||||
|
```
|
||||||
|
|
||||||
|
If MCP should use that same non-default database, pass the same path to the MCP installer:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx -y ra-h-mcp-server@latest setup --client claude-code,codex --yes --db "$HOME/Desktop/ra-h_os-demo-data/rah.sqlite"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Point MCP At The Right Database
|
||||||
|
|
||||||
|
The MCP server reads and writes whichever SQLite file is set as `RAH_DB_PATH`.
|
||||||
|
|
||||||
|
Use the same database path for the app and for MCP. If these paths do not match, the browser UI and your coding agent will be looking at different graphs.
|
||||||
|
|
||||||
|
### Default Database
|
||||||
|
|
||||||
|
If you used the default app-data database, install MCP without `--db`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx -y ra-h-mcp-server@latest setup --client claude-code,codex --yes
|
||||||
|
```
|
||||||
|
|
||||||
|
That points MCP at the default platform path:
|
||||||
|
|
||||||
|
```text
|
||||||
|
~/Library/Application Support/RA-H/db/rah.sqlite # macOS
|
||||||
|
~/.local/share/RA-H/db/rah.sqlite # Linux
|
||||||
|
%APPDATA%/RA-H/db/rah.sqlite # Windows
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Or Repo-Local Database
|
||||||
|
|
||||||
|
If you set `SQLITE_DB_PATH` during app setup, pass that exact same path to the MCP installer with `--db`.
|
||||||
|
|
||||||
|
Example repo-local app setup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SQLITE_DB_PATH="$PWD/.ra-h/db/rah.sqlite" npm run setup:local -- --profile qwen-local
|
||||||
|
```
|
||||||
|
|
||||||
|
Matching MCP setup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx -y ra-h-mcp-server@latest setup \
|
||||||
|
--client claude-code,codex \
|
||||||
|
--yes \
|
||||||
|
--db "$PWD/.ra-h/db/rah.sqlite"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Project-Scoped Claude Code MCP
|
||||||
|
|
||||||
|
If you want Claude Code to use a repo-local database only inside this repo, create a project `.mcp.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"ra-h": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "ra-h-mcp-server@latest"],
|
||||||
|
"env": {
|
||||||
|
"RAH_DB_PATH": "/absolute/path/to/ra-h_os/.ra-h/db/rah.sqlite"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the server name `ra-h` in project config if you want the project database to override a user-level `ra-h` server while Claude is opened in that repo.
|
||||||
|
|
||||||
|
Keep `.mcp.json` out of git if it contains a machine-specific path.
|
||||||
|
|
||||||
|
### Verify The Active MCP Database
|
||||||
|
|
||||||
|
After configuring MCP, fully restart the client.
|
||||||
|
|
||||||
|
Then run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx -y ra-h-mcp-server@latest doctor --db "/path/to/rah.sqlite"
|
||||||
|
```
|
||||||
|
|
||||||
|
Inside your agent, ask it to use the RA-H MCP server and report the database path it is using before it creates or updates nodes.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Demo-safe isolated install
|
## Demo-safe isolated install
|
||||||
@@ -284,6 +454,8 @@ The recommended path is the CLI installer:
|
|||||||
npx -y ra-h-mcp-server@latest setup --client claude-code --yes
|
npx -y ra-h-mcp-server@latest setup --client claude-code --yes
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If your app uses a custom database path, include `--db` with that exact path. See [Point MCP At The Right Database](#point-mcp-at-the-right-database).
|
||||||
|
|
||||||
Manual config is still useful for troubleshooting or unsupported clients. Add this to your MCP client config and restart the client fully:
|
Manual config is still useful for troubleshooting or unsupported clients. Add this to your MCP client config and restart the client fully:
|
||||||
|
|
||||||
|
|
||||||
@@ -398,8 +570,12 @@ See [docs/2_schema.md](./docs/2_schema.md) and [docs/8_mcp.md](./docs/8_mcp.md)
|
|||||||
|
|
||||||
| Command | What it does |
|
| Command | What it does |
|
||||||
|---------|--------------|
|
|---------|--------------|
|
||||||
| `npm run setup:local` | Rebuild native modules, create `.env.local`, create the SQLite DB, and seed the base schema |
|
| `npm run setup:local -- --profile openai` | Rebuild native modules, create `.env.local`, create the SQLite DB, and seed OpenAI-width vector tables |
|
||||||
|
| `npm run setup:local -- --profile qwen-local` | Rebuild native modules, create `.env.local`, create the SQLite DB, and seed Qwen-width vector tables |
|
||||||
|
| `npm run setup:local -- --profile llama-cpp` | Rebuild native modules, create `.env.local`, create the SQLite DB, and seed Qwen-width vector tables for llama.cpp endpoints |
|
||||||
|
| `npm run setup:local` | Only valid if `.env.local` already selects an embedding profile; otherwise it stops before DB/vector setup |
|
||||||
| `npm run bootstrap:local` | Lower-level helper used by `setup:local`; most users should not run this directly |
|
| `npm run bootstrap:local` | Lower-level helper used by `setup:local`; most users should not run this directly |
|
||||||
|
| `npm run rebuild:embeddings` | Recreate derived embeddings after changing embedding provider, model, dimensions, or vector backend |
|
||||||
| `npm run dev` | Start the app at localhost:3000 |
|
| `npm run dev` | Start the app at localhost:3000 |
|
||||||
| `npm run dev:local` | Alias for `npm run dev` |
|
| `npm run dev:local` | Alias for `npm run dev` |
|
||||||
| `npm run build` | Production build |
|
| `npm run build` | Production build |
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// RA-H OS Bridge Schema Validation Middleware
|
||||||
|
function validateNodePayload(payload) {
|
||||||
|
const errors = [];
|
||||||
|
const required = ['tenant', 'namespace', 'type', 'visibility', 'state', 'captured_by'];
|
||||||
|
const defaults = {
|
||||||
|
tenant: 'syslogsolution',
|
||||||
|
namespace: 'syslogsolution',
|
||||||
|
type: 'note',
|
||||||
|
visibility: 'shared',
|
||||||
|
state: 'not_processed',
|
||||||
|
captured_by: 'agent'
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const field of required) {
|
||||||
|
payload.metadata = payload.metadata || {};
|
||||||
|
if (!payload.metadata[field]) {
|
||||||
|
if (defaults[field]) payload.metadata[field] = defaults[field];
|
||||||
|
else errors.push('Missing required field: ' + field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also ensure source_metadata exists for compatibility
|
||||||
|
if (!payload.metadata.source_metadata) {
|
||||||
|
payload.metadata.source_metadata = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateEdgePayload(payload) {
|
||||||
|
const errors = [];
|
||||||
|
if (!payload.explanation) {
|
||||||
|
if (payload.context?.explanation) payload.explanation = payload.context.explanation;
|
||||||
|
else errors.push('Missing required field: explanation');
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validationMiddleware(operation, payload) {
|
||||||
|
let errors = [];
|
||||||
|
if (['createNode', 'updateNode'].includes(operation)) errors = validateNodePayload(payload);
|
||||||
|
else if (operation === 'createEdge') errors = validateEdgePayload(payload);
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
console.error('[BRIDGE_VALIDATION] ' + operation + ' rejected:', { errors: errors, timestamp: new Date().toISOString() });
|
||||||
|
return { success: false, errors: errors };
|
||||||
|
}
|
||||||
|
return { success: true, payload: payload };
|
||||||
|
}
|
||||||
|
module.exports = { validationMiddleware };
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
'use strict';
|
const { validationMiddleware } = require("./bridge-validation.js");
|
||||||
|
|
||||||
// Check Node version early — better-sqlite3 native bindings don't support bleeding-edge Node
|
// Check Node version early — better-sqlite3 native bindings don't support bleeding-edge Node
|
||||||
const nodeVersion = parseInt(process.versions.node.split('.')[0], 10);
|
const nodeVersion = parseInt(process.versions.node.split('.')[0], 10);
|
||||||
@@ -26,7 +26,7 @@ try {
|
|||||||
|
|
||||||
const { z } = require('zod');
|
const { z } = require('zod');
|
||||||
const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
|
const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
|
||||||
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
|
const { StreamableHTTPServerTransport } = require('@modelcontextprotocol/sdk/server/streamableHttp.js');
|
||||||
const packageJson = require('./package.json');
|
const packageJson = require('./package.json');
|
||||||
|
|
||||||
const { initDatabase, getDatabasePath, closeDatabase, getDb, query } = require('./services/sqlite-client');
|
const { initDatabase, getDatabasePath, closeDatabase, getDb, query } = require('./services/sqlite-client');
|
||||||
@@ -105,7 +105,7 @@ const addNodeInputSchema = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const searchNodesInputSchema = {
|
const searchNodesInputSchema = {
|
||||||
query: z.string().min(1).max(400).describe('Search query'),
|
query: z.string().max(400).optional().describe('Search query'),
|
||||||
limit: z.number().min(1).max(50).optional().describe('Max results (default 10)'),
|
limit: z.number().min(1).max(50).optional().describe('Max results (default 10)'),
|
||||||
created_after: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes created on or after this date.'),
|
created_after: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes created on or after this date.'),
|
||||||
created_before: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes created before this date.'),
|
created_before: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes created before this date.'),
|
||||||
@@ -184,7 +184,8 @@ function sanitizeFtsQuery(input) {
|
|||||||
.trim()
|
.trim()
|
||||||
.split(/\s+/)
|
.split(/\s+/)
|
||||||
.filter(w => w.length > 0 && !/^(AND|OR|NOT|NEAR)$/i.test(w))
|
.filter(w => w.length > 0 && !/^(AND|OR|NOT|NEAR)$/i.test(w))
|
||||||
.join(' ');
|
.map(w => `"${w}"`)
|
||||||
|
.join(' OR ');
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ftsAvailability = null;
|
let _ftsAvailability = null;
|
||||||
@@ -235,10 +236,6 @@ function log(...args) {
|
|||||||
console.error('[ra-h-standalone]', ...args);
|
console.error('[ra-h-standalone]', ...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
function withVisibleJson(summary, payload) {
|
|
||||||
return `${summary}\n\n${JSON.stringify(payload, null, 2)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
// Initialize database
|
// Initialize database
|
||||||
try {
|
try {
|
||||||
@@ -311,7 +308,7 @@ async function main() {
|
|||||||
: result.reason;
|
: result.reason;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text', text: result.shouldRetrieve ? withVisibleJson(summary, result) : summary }],
|
content: [{ type: 'text', text: summary }],
|
||||||
structuredContent: result
|
structuredContent: result
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -328,12 +325,17 @@ async function main() {
|
|||||||
const sourceText = source?.trim() || content?.trim() || chunk?.trim();
|
const sourceText = source?.trim() || content?.trim() || chunk?.trim();
|
||||||
const normalizedDescription = typeof description === 'string' ? description.trim() : description;
|
const normalizedDescription = typeof description === 'string' ? description.trim() : description;
|
||||||
|
|
||||||
|
// LINT GUARD: Reject writes with metadata.tenant (deprecated — use namespace)
|
||||||
|
if (metadata && metadata.tenant) {
|
||||||
|
throw new Error('metadata.tenant is deprecated. Use metadata.namespace only.');
|
||||||
|
}
|
||||||
|
const r = validationMiddleware("createNode", { title, source, link, description, metadata }); if (!r.success) throw new Error(r.errors.join(", "));
|
||||||
const node = nodeService.createNode({
|
const node = nodeService.createNode({
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
source: sourceText,
|
source: sourceText,
|
||||||
link: link?.trim(),
|
link: link?.trim(),
|
||||||
description: normalizedDescription,
|
description: normalizedDescription,
|
||||||
metadata: metadata || {}
|
metadata: r.payload?.metadata || {}
|
||||||
});
|
});
|
||||||
|
|
||||||
const summary = `Created node #${node.id}: ${node.title}`;
|
const summary = `Created node #${node.id}: ${node.title}`;
|
||||||
@@ -359,7 +361,7 @@ async function main() {
|
|||||||
async ({ query: searchQuery, limit = 10, created_after, created_before, event_after, event_before }) => {
|
async ({ query: searchQuery, limit = 10, created_after, created_before, event_after, event_before }) => {
|
||||||
const safeLimit = Math.min(Math.max(limit, 1), 50);
|
const safeLimit = Math.min(Math.max(limit, 1), 50);
|
||||||
const result = directNodeLookup({
|
const result = directNodeLookup({
|
||||||
search: searchQuery.trim(),
|
search: (searchQuery ?? "").trim(),
|
||||||
limit: safeLimit,
|
limit: safeLimit,
|
||||||
createdAfter: created_after,
|
createdAfter: created_after,
|
||||||
createdBefore: created_before,
|
createdBefore: created_before,
|
||||||
@@ -367,28 +369,26 @@ async function main() {
|
|||||||
eventBefore: event_before,
|
eventBefore: event_before,
|
||||||
});
|
});
|
||||||
|
|
||||||
const mappedNodes = result.nodes.map((node) => ({
|
|
||||||
id: node.id,
|
|
||||||
title: node.title,
|
|
||||||
source: node.source ?? null,
|
|
||||||
description: node.description ?? null,
|
|
||||||
link: node.link ?? null,
|
|
||||||
created_at: node.created_at,
|
|
||||||
updated_at: node.updated_at,
|
|
||||||
event_date: node.event_date ?? null,
|
|
||||||
}));
|
|
||||||
const payload = {
|
|
||||||
count: result.count,
|
|
||||||
filters_applied: result.filtersApplied,
|
|
||||||
nodes: mappedNodes
|
|
||||||
};
|
|
||||||
const summary = result.count === 0
|
const summary = result.count === 0
|
||||||
? 'No nodes found matching that query.'
|
? 'No nodes found matching that query.'
|
||||||
: `Found ${result.count} node(s).`;
|
: `Found ${result.count} node(s).`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text', text: result.count === 0 ? summary : withVisibleJson(summary, payload) }],
|
content: [{ type: 'text', text: summary }],
|
||||||
structuredContent: payload
|
structuredContent: {
|
||||||
|
count: result.count,
|
||||||
|
filters_applied: result.filtersApplied,
|
||||||
|
nodes: result.nodes.map((node) => ({
|
||||||
|
id: node.id,
|
||||||
|
title: node.title,
|
||||||
|
source: node.source ?? null,
|
||||||
|
description: node.description ?? null,
|
||||||
|
link: node.link ?? null,
|
||||||
|
created_at: node.created_at,
|
||||||
|
updated_at: node.updated_at,
|
||||||
|
event_date: node.event_date ?? null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -470,7 +470,32 @@ async function main() {
|
|||||||
: mappedUpdates.description;
|
: mappedUpdates.description;
|
||||||
}
|
}
|
||||||
|
|
||||||
const node = nodeService.updateNode(id, mappedUpdates);
|
|
||||||
|
// === TENANT ENFORCEMENT: verify node has namespace/tenant before allowing update ===
|
||||||
|
const checkNode = nodeService.getNodeById(id);
|
||||||
|
if (checkNode) {
|
||||||
|
let meta = checkNode.metadata || {};
|
||||||
|
let tenant = null;
|
||||||
|
if (typeof meta === 'string') {
|
||||||
|
try { meta = JSON.parse(meta); } catch(e) { meta = {}; }
|
||||||
|
}
|
||||||
|
if (!meta) meta = {};
|
||||||
|
tenant = meta.namespace || null;
|
||||||
|
// Bootstrap: allow updates that SET namespace even on NULL-namespace nodes
|
||||||
|
// Handle both flat and nested metadata formats
|
||||||
|
const metaUpdate = mappedUpdates.metadata || {};
|
||||||
|
const isSettingNamespace = metaUpdate.namespace && (
|
||||||
|
typeof metaUpdate.namespace === 'string' ||
|
||||||
|
(typeof metaUpdate.namespace === 'object' && metaUpdate.namespace.value)
|
||||||
|
);
|
||||||
|
if (!tenant && !isSettingNamespace) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `ERROR: Node #${id} has no namespace. Blocked by namespace enforcement. Set metadata.namespace in this update.` }],
|
||||||
|
structuredContent: { success: false, nodeId: id, error: 'namespace_required' }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const node = nodeService.updateNode(id, mappedUpdates);
|
||||||
const message = `Updated node #${id}`;
|
const message = `Updated node #${id}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -497,6 +522,7 @@ async function main() {
|
|||||||
if (!confirmed_by_user) {
|
if (!confirmed_by_user) {
|
||||||
throw new Error('createEdge requires explicit user confirmation before writing the relationship.');
|
throw new Error('createEdge requires explicit user confirmation before writing the relationship.');
|
||||||
}
|
}
|
||||||
|
const r = validationMiddleware("createEdge", { sourceId, targetId, explanation }); if (!r.success) throw new Error(r.errors.join(", "));
|
||||||
|
|
||||||
const edge = edgeService.createEdge({
|
const edge = edgeService.createEdge({
|
||||||
from_node_id: sourceId,
|
from_node_id: sourceId,
|
||||||
@@ -563,7 +589,7 @@ async function main() {
|
|||||||
from_node_id: e.from_node_id,
|
from_node_id: e.from_node_id,
|
||||||
to_node_id: e.to_node_id,
|
to_node_id: e.to_node_id,
|
||||||
type: e.context?.type ?? null,
|
type: e.context?.type ?? null,
|
||||||
explanation: e.explanation ?? e.context?.explanation ?? null
|
explanation: e.context?.explanation ?? null
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -820,10 +846,99 @@ async function main() {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
// ==================== getAllNodes tool ====================
|
||||||
|
registerToolWithAliases(
|
||||||
|
'getAllNodes',
|
||||||
|
{
|
||||||
|
title: 'Get all RA-H nodes',
|
||||||
|
description: 'Return all nodes in the graph with their metadata. Supports pagination via limit/offset. Use this for bulk operations, audits, and stale node detection. NOT for searching — use queryNodes for keyword search.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
limit: z.number().min(1).max(500).optional().default(100).describe('Max nodes to return'),
|
||||||
|
offset: z.number().min(0).optional().default(0).describe('Offset for pagination'),
|
||||||
|
namespace: z.string().optional().describe('Filter by namespace'),
|
||||||
|
tenant: z.string().optional().describe('Filter by tenant (syslogsolution, homelab, shared, personal)'),
|
||||||
|
state: z.string().optional().describe('Filter by state (active, not_processed, etc.)')
|
||||||
|
}).optional().default({})
|
||||||
|
},
|
||||||
|
async ({ limit = 100, offset = 0, namespace, tenant, state }) => {
|
||||||
|
const safeLimit = Math.min(Math.max(limit, 1), 500);
|
||||||
|
const safeOffset = Math.max(offset, 0);
|
||||||
|
|
||||||
|
// Build SQL with optional tenant filter
|
||||||
|
let sql = "SELECT n.id, n.title, n.description, n.metadata, n.created_at, n.updated_at FROM nodes n";
|
||||||
|
let countSql = "SELECT COUNT(*) as count FROM nodes n";
|
||||||
|
const conditions = [];
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (tenant) {
|
||||||
|
conditions.push("json_extract(n.metadata, '$.namespace') = ?");
|
||||||
|
params.push(tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conditions.length > 0) {
|
||||||
|
const where = " WHERE " + conditions.join(" AND ");
|
||||||
|
sql += where;
|
||||||
|
countSql += where;
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += " ORDER BY n.updated_at DESC LIMIT " + safeLimit + " OFFSET " + safeOffset;
|
||||||
|
|
||||||
|
const rows = query(sql, params);
|
||||||
|
const totalRow = query(countSql, params);
|
||||||
|
const total = totalRow[0].count;
|
||||||
|
|
||||||
|
let nodes = rows.map(row => ({
|
||||||
|
id: row.id,
|
||||||
|
title: row.title,
|
||||||
|
description: row.description ?? null,
|
||||||
|
metadata: row.metadata ? (typeof row.metadata === "string" ? JSON.parse(row.metadata) : row.metadata) : null,
|
||||||
|
created_at: row.created_at,
|
||||||
|
updated_at: row.updated_at
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (namespace) {
|
||||||
|
nodes = nodes.filter(n =>
|
||||||
|
(n.metadata?.namespace || "").toLowerCase() === namespace.toLowerCase()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state) {
|
||||||
|
nodes = nodes.filter(n =>
|
||||||
|
(n.metadata?.state || "").toLowerCase() === state.toLowerCase()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: "Retrieved " + nodes.length + " of " + total + " node(s)." }],
|
||||||
|
structuredContent: {
|
||||||
|
total,
|
||||||
|
returned: nodes.length,
|
||||||
|
offset: safeOffset,
|
||||||
|
limit: safeLimit,
|
||||||
|
nodes: nodes.map(n => ({
|
||||||
|
id: n.id,
|
||||||
|
title: n.title,
|
||||||
|
description: n.description ?? null,
|
||||||
|
metadata: n.metadata ?? null,
|
||||||
|
created_at: n.created_at,
|
||||||
|
updated_at: n.updated_at
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Connect transport
|
// Connect transport
|
||||||
const transport = new StdioServerTransport();
|
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
||||||
|
const http = require('http');
|
||||||
|
const httpServer = http.createServer(async (req, res) => {
|
||||||
|
await transport.handleRequest(req, res);
|
||||||
|
});
|
||||||
await server.connect(transport);
|
await server.connect(transport);
|
||||||
log('MCP server ready');
|
log('MCP server ready');
|
||||||
|
httpServer.listen(3100, '0.0.0.0', () => {
|
||||||
|
log('HTTP server listening on port 3100');
|
||||||
|
});
|
||||||
|
|
||||||
// Handle graceful shutdown
|
// Handle graceful shutdown
|
||||||
process.on('SIGINT', () => {
|
process.on('SIGINT', () => {
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ function getEdges(filters = {}) {
|
|||||||
|
|
||||||
const rows = query(sql, params);
|
const rows = query(sql, params);
|
||||||
|
|
||||||
return rows.map(formatEdgeRow);
|
return rows.map(row => ({
|
||||||
|
...row,
|
||||||
|
context: parseContext(row.context)
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,7 +34,11 @@ function getEdgeById(id) {
|
|||||||
const rows = query('SELECT * FROM edges WHERE id = ?', [id]);
|
const rows = query('SELECT * FROM edges WHERE id = ?', [id]);
|
||||||
if (rows.length === 0) return null;
|
if (rows.length === 0) return null;
|
||||||
|
|
||||||
return formatEdgeRow(rows[0]);
|
const row = rows[0];
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
context: parseContext(row.context)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -52,7 +59,21 @@ function createEdge(edgeData) {
|
|||||||
throw new Error('Edge explanation is required');
|
throw new Error('Edge explanation is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleanExplanation = explanation.trim();
|
// P1 — Dedup check before edge creation
|
||||||
|
const existing = db.prepare(
|
||||||
|
`SELECT id FROM edges
|
||||||
|
WHERE from_node_id = ?
|
||||||
|
AND to_node_id = ?
|
||||||
|
AND (explanation = ? OR json_extract(context, '$.explanation') = ?)`
|
||||||
|
).get(from_node_id, to_node_id, explanation.trim(), explanation.trim());
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return {
|
||||||
|
id: existing.id,
|
||||||
|
status: 'existing',
|
||||||
|
warning: 'duplicate_edge_skipped'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Simple context without AI inference
|
// Simple context without AI inference
|
||||||
// The main app can re-infer types when it loads
|
// The main app can re-infer types when it loads
|
||||||
@@ -60,13 +81,13 @@ function createEdge(edgeData) {
|
|||||||
type: 'related_to',
|
type: 'related_to',
|
||||||
confidence: 0.5,
|
confidence: 0.5,
|
||||||
inferred_at: now,
|
inferred_at: now,
|
||||||
explanation: cleanExplanation,
|
explanation: explanation.trim(),
|
||||||
created_via: 'mcp'
|
created_via: 'mcp'
|
||||||
};
|
};
|
||||||
|
|
||||||
const stmt = db.prepare(`
|
const stmt = db.prepare(`
|
||||||
INSERT INTO edges (from_node_id, to_node_id, context, source, created_at, explanation)
|
INSERT INTO edges (from_node_id, to_node_id, context, source, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const result = runWithBusyRetry(() => stmt.run(
|
const result = runWithBusyRetry(() => stmt.run(
|
||||||
@@ -74,13 +95,18 @@ function createEdge(edgeData) {
|
|||||||
to_node_id,
|
to_node_id,
|
||||||
JSON.stringify(context),
|
JSON.stringify(context),
|
||||||
source,
|
source,
|
||||||
now,
|
now
|
||||||
cleanExplanation
|
|
||||||
),
|
),
|
||||||
'createEdge'
|
'createEdge'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// P0 — After writing edge to DB, sync explanation column
|
||||||
const edgeId = Number(result.lastInsertRowid);
|
const edgeId = Number(result.lastInsertRowid);
|
||||||
|
if (context.explanation) {
|
||||||
|
const syncStmt = db.prepare('UPDATE edges SET explanation = ? WHERE id = ?');
|
||||||
|
runWithBusyRetry(() => syncStmt.run(context.explanation, edgeId), 'createEdge_explanation_sync');
|
||||||
|
}
|
||||||
|
|
||||||
return getEdgeById(edgeId);
|
return getEdgeById(edgeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,36 +122,29 @@ function updateEdge(id, updates) {
|
|||||||
throw new Error(`Edge with ID ${id} not found. Use rah_query_edges to find edges by node ID.`);
|
throw new Error(`Edge with ID ${id} not found. Use rah_query_edges to find edges by node ID.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleanExplanation = typeof explanation === 'string' ? explanation.trim() : '';
|
// If explanation changed, update context
|
||||||
|
if (explanation && explanation.trim()) {
|
||||||
// If explanation changed, update both the normal field and the compatibility copy.
|
|
||||||
if (cleanExplanation) {
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const newContext = {
|
const newContext = {
|
||||||
...existing.context,
|
...existing.context,
|
||||||
explanation: cleanExplanation,
|
explanation: explanation.trim(),
|
||||||
inferred_at: now,
|
inferred_at: now,
|
||||||
created_via: 'mcp'
|
created_via: 'mcp'
|
||||||
};
|
};
|
||||||
|
|
||||||
const stmt = db.prepare('UPDATE edges SET context = ?, explanation = ? WHERE id = ?');
|
const stmt = db.prepare('UPDATE edges SET context = ? WHERE id = ?');
|
||||||
runWithBusyRetry(() => stmt.run(JSON.stringify(newContext), cleanExplanation, id), 'updateEdge');
|
runWithBusyRetry(() => stmt.run(JSON.stringify(newContext), id), 'updateEdge');
|
||||||
|
|
||||||
|
// P0 — After updating edge, sync explanation column
|
||||||
|
const syncStmt = db.prepare('UPDATE edges SET explanation = ? WHERE id = ?');
|
||||||
|
runWithBusyRetry(() => syncStmt.run(explanation.trim(), id), 'updateEdge_explanation_sync');
|
||||||
} else if (contextUpdates) {
|
} else if (contextUpdates) {
|
||||||
const contextExplanation = typeof contextUpdates.explanation === 'string'
|
|
||||||
? contextUpdates.explanation.trim()
|
|
||||||
: '';
|
|
||||||
const newContext = {
|
const newContext = {
|
||||||
...existing.context,
|
...existing.context,
|
||||||
...contextUpdates,
|
...contextUpdates
|
||||||
...(contextExplanation ? { explanation: contextExplanation } : {})
|
|
||||||
};
|
};
|
||||||
if (contextExplanation) {
|
const stmt = db.prepare('UPDATE edges SET context = ? WHERE id = ?');
|
||||||
const stmt = db.prepare('UPDATE edges SET context = ?, explanation = ? WHERE id = ?');
|
runWithBusyRetry(() => stmt.run(JSON.stringify(newContext), id), 'updateEdge');
|
||||||
runWithBusyRetry(() => stmt.run(JSON.stringify(newContext), contextExplanation, id), 'updateEdge');
|
|
||||||
} else {
|
|
||||||
const stmt = db.prepare('UPDATE edges SET context = ? WHERE id = ?');
|
|
||||||
runWithBusyRetry(() => stmt.run(JSON.stringify(newContext), id), 'updateEdge');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return getEdgeById(id);
|
return getEdgeById(id);
|
||||||
@@ -211,15 +230,6 @@ function getEdgeCount() {
|
|||||||
return Number(rows[0].count);
|
return Number(rows[0].count);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatEdgeRow(row) {
|
|
||||||
const context = parseContext(row.context);
|
|
||||||
return {
|
|
||||||
...row,
|
|
||||||
context,
|
|
||||||
explanation: row.explanation || context?.explanation || null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse context JSON safely.
|
* Parse context JSON safely.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -131,22 +131,50 @@ function buildCanonicalMetadata({ existing, metadata }) {
|
|||||||
...(incoming.source_metadata && typeof incoming.source_metadata === 'object' ? incoming.source_metadata : {}),
|
...(incoming.source_metadata && typeof incoming.source_metadata === 'object' ? incoming.source_metadata : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const merged = {
|
// Sanitize a value: reject nested objects/arrays, return trimmed string or undefined
|
||||||
...prior,
|
function sanitizeValue(value) {
|
||||||
...incoming,
|
if (value === undefined || value === null) return undefined;
|
||||||
state: incoming.state === 'processed' ? 'processed' : (prior.state === 'processed' ? 'processed' : 'not_processed'),
|
if (typeof value === 'string') {
|
||||||
captured_by: incoming.captured_by || prior.captured_by || 'human',
|
const trimmed = value.trim();
|
||||||
source_metadata: sourceMetadata,
|
return trimmed.length > 0 ? trimmed : undefined;
|
||||||
};
|
}
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
// Unwrap nested {value: ...} format from MCP tool calls
|
||||||
|
if (value.value && typeof value.value === 'string') return value.value.trim();
|
||||||
|
console.warn(`[buildCanonicalMetadata] WARNING: Nested object/array rejected. Value: ${JSON.stringify(value).substring(0, 200)}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const type = normalizeString(incoming.type) || normalizeString(prior.type);
|
// Build merged metadata from prior and incoming, but only keep string values
|
||||||
const capturedMethod = normalizeString(incoming.captured_method) || normalizeString(prior.captured_method);
|
const merged = {};
|
||||||
|
|
||||||
if (type) merged.type = type;
|
// Start with source_metadata (always an object)
|
||||||
else delete merged.type;
|
merged.source_metadata = sourceMetadata;
|
||||||
|
|
||||||
if (capturedMethod) merged.captured_method = capturedMethod;
|
// Copy from prior: only string values
|
||||||
else delete merged.captured_method;
|
Object.keys(prior).forEach(key => {
|
||||||
|
if (key === 'source_metadata') return; // handled separately
|
||||||
|
const val = prior[key];
|
||||||
|
if (typeof val === 'string' && val.trim().length > 0) {
|
||||||
|
merged[key] = val;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Override with incoming: only string values
|
||||||
|
Object.keys(incoming).forEach(key => {
|
||||||
|
if (key === 'source_metadata') return; // handled separately
|
||||||
|
const sanitized = sanitizeValue(incoming[key]);
|
||||||
|
const val = sanitized;
|
||||||
|
if (typeof val === 'string' && val.trim().length > 0) {
|
||||||
|
merged[key] = val.trim();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure canonical defaults
|
||||||
|
if (!merged.state) merged.state = 'not_processed';
|
||||||
|
if (!merged.captured_by) merged.captured_by = 'human';
|
||||||
|
|
||||||
return merged;
|
return merged;
|
||||||
}
|
}
|
||||||
@@ -402,6 +430,36 @@ function getChunkStatusForSource(sourceText) {
|
|||||||
/**
|
/**
|
||||||
* Create a new node.
|
* Create a new node.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Enforce TTL on relay messages — delete expired ones.
|
||||||
|
* Relay messages have a TTL of 7 days.
|
||||||
|
*/
|
||||||
|
function enforceRelayTTL() {
|
||||||
|
const db = getDb();
|
||||||
|
const cutoff = new Date();
|
||||||
|
cutoff.setDate(cutoff.getDate() - 7);
|
||||||
|
const cutoffISO = cutoff.toISOString();
|
||||||
|
|
||||||
|
// Find relay messages older than 7 days
|
||||||
|
const expired = query(`
|
||||||
|
SELECT id, title, created_at FROM nodes
|
||||||
|
WHERE metadata LIKE '%relay_message%'
|
||||||
|
AND created_at < ?
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
`, [cutoffISO]);
|
||||||
|
|
||||||
|
if (expired.length > 0) {
|
||||||
|
const ids = expired.map(n => n.id);
|
||||||
|
const idsStr = ids.join(',');
|
||||||
|
console.warn(`[enforceRelayTTL] ${expired.length} relay messages expired, deleting (IDs: ${idsStr})`);
|
||||||
|
db.prepare(`DELETE FROM nodes WHERE id IN (${idsStr})`).run();
|
||||||
|
db.prepare(`DELETE FROM repair_log WHERE node_id IN (${idsStr})`).run();
|
||||||
|
db.prepare(`DELETE FROM edges WHERE from_node_id IN (${idsStr}) OR to_node_id IN (${idsStr})`).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
return expired.length;
|
||||||
|
}
|
||||||
|
|
||||||
function createNode(nodeData) {
|
function createNode(nodeData) {
|
||||||
const {
|
const {
|
||||||
title: rawTitle,
|
title: rawTitle,
|
||||||
@@ -414,6 +472,34 @@ function createNode(nodeData) {
|
|||||||
|
|
||||||
const title = sanitizeTitle(rawTitle);
|
const title = sanitizeTitle(rawTitle);
|
||||||
|
|
||||||
|
// FIX 2: Deduplication — check if a node with same source or title+description already exists
|
||||||
|
const existingSource = source || ([title, description].filter(Boolean).join('\n\n').trim() || null);
|
||||||
|
const existingSourceNormalized = normalizeString(existingSource);
|
||||||
|
const existingTitleNormalized = normalizeString(title);
|
||||||
|
|
||||||
|
// Check for source-based deduplication first
|
||||||
|
const sourceCheckResults = query(`
|
||||||
|
SELECT id, title FROM nodes WHERE source = ?
|
||||||
|
`, [existingSourceNormalized || '']);
|
||||||
|
|
||||||
|
if (sourceCheckResults.length > 0) {
|
||||||
|
console.warn(`[createNode] WARNING: Node with identical source already exists (ID: ${sourceCheckResults[0].id}, Title: ${sourceCheckResults[0].title}). Skipping duplicate creation.`);
|
||||||
|
return getNodeById(Number(sourceCheckResults[0].id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for title+description deduplication
|
||||||
|
const dedupCheckResults = query(`
|
||||||
|
SELECT id, title FROM nodes WHERE title = ?
|
||||||
|
`, [title]);
|
||||||
|
|
||||||
|
if (dedupCheckResults.length > 0) {
|
||||||
|
console.warn(`[createNode] WARNING: Node with identical title already exists (ID: ${dedupCheckResults[0].id}, Title: ${dedupCheckResults[0].title}). Skipping duplicate creation.`);
|
||||||
|
return getNodeById(Number(dedupCheckResults[0].id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforce TTL on relay messages before creating new node
|
||||||
|
enforceRelayTTL();
|
||||||
|
|
||||||
const canonicalMetadata = buildCanonicalMetadata({ metadata });
|
const canonicalMetadata = buildCanonicalMetadata({ metadata });
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
@@ -449,6 +535,9 @@ function createNode(nodeData) {
|
|||||||
* Update an existing node.
|
* Update an existing node.
|
||||||
*/
|
*/
|
||||||
function updateNode(id, updates, options = {}) {
|
function updateNode(id, updates, options = {}) {
|
||||||
|
// Enforce TTL on relay messages before updating any node
|
||||||
|
enforceRelayTTL();
|
||||||
|
|
||||||
const { title, description, source, link, event_date, metadata } = updates;
|
const { title, description, source, link, event_date, metadata } = updates;
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
@@ -459,7 +548,32 @@ function updateNode(id, updates, options = {}) {
|
|||||||
throw new Error(`Node with ID ${id} not found. Use rah_search_nodes to find nodes by keyword.`);
|
throw new Error(`Node with ID ${id} not found. Use rah_search_nodes to find nodes by keyword.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mergedMetadata = metadata !== undefined
|
// === TENANT ENFORCEMENT: require namespace/tenant in metadata ===
|
||||||
|
// Extract tenant from existing node metadata
|
||||||
|
let nodeTenant = null;
|
||||||
|
try {
|
||||||
|
const meta = typeof existing.metadata === 'string'
|
||||||
|
? JSON.parse(existing.metadata)
|
||||||
|
: existing.metadata;
|
||||||
|
nodeTenant = meta.namespace || null;
|
||||||
|
} catch (e) {
|
||||||
|
// metadata parse error — treat as no tenant
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block update if no tenant/namespace
|
||||||
|
// Bootstrap: allow updates that SET namespace on NULL-namespace nodes
|
||||||
|
const metaUpdate = metadata || {};
|
||||||
|
const isSettingNamespace = metaUpdate.namespace && (
|
||||||
|
typeof metaUpdate.namespace === 'string' ||
|
||||||
|
(typeof metaUpdate.namespace === 'object' && metaUpdate.namespace.value)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!nodeTenant && !isSettingNamespace) {
|
||||||
|
console.warn(`[updateNode] BLOCK: Node #${id} has no tenant/namespace. Refusing update.`);
|
||||||
|
throw new Error(`Node #${id} has no namespace field in metadata. Update blocked. Set metadata.namespace before updating.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedMetadata = metadata !== undefined
|
||||||
? buildCanonicalMetadata({ existing: existing.metadata, metadata })
|
? buildCanonicalMetadata({ existing: existing.metadata, metadata })
|
||||||
: undefined;
|
: undefined;
|
||||||
const sourceWasProvided = Object.prototype.hasOwnProperty.call(updates, 'source');
|
const sourceWasProvided = Object.prototype.hasOwnProperty.call(updates, 'source');
|
||||||
@@ -510,6 +624,23 @@ function updateNode(id, updates, options = {}) {
|
|||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// === REPAIR LOG: log the update ===
|
||||||
|
try {
|
||||||
|
const logStmt = db.prepare(
|
||||||
|
'INSERT INTO repair_log(node_id, action, old_metadata, new_metadata, timestamp, verified, tenant) VALUES (?, ?, ?, ?, datetime(?), 0, ?)'
|
||||||
|
);
|
||||||
|
logStmt.run(
|
||||||
|
id,
|
||||||
|
'updateNode',
|
||||||
|
typeof existing.metadata === 'string' ? existing.metadata : JSON.stringify(existing.metadata ?? null),
|
||||||
|
mergedMetadata ? JSON.stringify(mergedMetadata) : 'unchanged',
|
||||||
|
new Date().toISOString().split('T')[0],
|
||||||
|
nodeTenant || 'unknown'
|
||||||
|
);
|
||||||
|
} catch (logErr) {
|
||||||
|
console.warn(`[updateNode] repair_log write failed: ${logErr.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
return getNodeById(id);
|
return getNodeById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -537,8 +668,10 @@ function getNodeCount() {
|
|||||||
* Returns stats, hub nodes, and recent activity.
|
* Returns stats, hub nodes, and recent activity.
|
||||||
*/
|
*/
|
||||||
function getContext() {
|
function getContext() {
|
||||||
const nodeCount = query('SELECT COUNT(*) as count FROM nodes')[0].count;
|
const nodeCountRow = query('SELECT COUNT(*) as count FROM nodes');
|
||||||
const edgeCount = query('SELECT COUNT(*) as count FROM edges')[0].count;
|
const nodeCount = (nodeCountRow && nodeCountRow[0] && nodeCountRow[0].count) || 0;
|
||||||
|
const edgeCountRow = query('SELECT COUNT(*) as count FROM edges');
|
||||||
|
const edgeCount = (edgeCountRow && edgeCountRow[0] && edgeCountRow[0].count) || 0;
|
||||||
|
|
||||||
const recentNodes = query(`
|
const recentNodes = query(`
|
||||||
SELECT n.id, n.title, n.description
|
SELECT n.id, n.title, n.description
|
||||||
@@ -556,10 +689,34 @@ function getContext() {
|
|||||||
LIMIT 10
|
LIMIT 10
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Get all nodes for bulk operations
|
||||||
|
const allNodes = query(`
|
||||||
|
SELECT n.id, n.title, n.description, n.metadata, n.created_at, n.updated_at
|
||||||
|
FROM nodes n
|
||||||
|
ORDER BY n.updated_at DESC
|
||||||
|
`).map(row => ({
|
||||||
|
id: row.id,
|
||||||
|
title: row.title,
|
||||||
|
description: row.description ?? null,
|
||||||
|
metadata: row.metadata ? (typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata) : null,
|
||||||
|
created_at: row.created_at,
|
||||||
|
updated_at: row.updated_at
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Detect stale nodes (no update in 30 days, state is not_processed)
|
||||||
|
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
const staleNodes = allNodes.filter(n =>
|
||||||
|
n.metadata?.state === 'not_processed' &&
|
||||||
|
n.updated_at &&
|
||||||
|
n.updated_at < thirtyDaysAgo
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
stats: { nodeCount, edgeCount, dimensionCount: 0 },
|
stats: { nodeCount, edgeCount, dimensionCount: 0 },
|
||||||
recentNodes,
|
recentNodes,
|
||||||
hubNodes
|
hubNodes,
|
||||||
|
allNodes,
|
||||||
|
staleNodes
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+78
-19
@@ -28,37 +28,86 @@
|
|||||||
## Start Here
|
## Start Here
|
||||||
|
|
||||||
If you just want RA-H OS working:
|
If you just want RA-H OS working:
|
||||||
1. Use the MCP quick install below if you mainly want agent access.
|
1. Use the OpenAI local app quick start if you want the simplest setup.
|
||||||
2. Use the local app quick start if you also want the browser UI.
|
2. Use the local Qwen/Ollama quick start if you want local models with the least runtime work.
|
||||||
3. Read [Local Models](../LOCAL-MODELS.md) and [Full Local](./10_full-local.md) if you want a more local-first setup.
|
3. Use the local Qwen/llama.cpp quick start if you want to manage GGUF files and llama.cpp servers yourself.
|
||||||
|
4. Connect MCP after the app database exists if you want Claude Code, Codex, Cursor, or another agent to read/write the same graph.
|
||||||
|
|
||||||
## MCP Quick Install
|
Every app path uses a local SQLite database. OpenAI setup keeps the database local but sends utility-model and embedding requests to OpenAI. Local Qwen setup keeps the database local and runs both model roles on your device through Ollama or llama.cpp, so those model calls do not go to a hosted model API.
|
||||||
|
|
||||||
```bash
|
## Local App Quick Start: OpenAI
|
||||||
npx -y ra-h-mcp-server@latest setup --client claude-code --yes
|
|
||||||
```
|
|
||||||
|
|
||||||
Run `doctor` after setup or whenever MCP feels stale:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx -y ra-h-mcp-server@latest doctor
|
|
||||||
```
|
|
||||||
|
|
||||||
## Local App Quick Start
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/bradwmorris/ra-h_os.git
|
git clone https://github.com/bradwmorris/ra-h_os.git
|
||||||
cd ra-h_os
|
cd ra-h_os
|
||||||
npm install
|
npm install
|
||||||
npm run setup:local
|
npm run setup:local -- --profile openai
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Open http://localhost:3000
|
Open http://localhost:3000 and add your OpenAI API key when prompted, or later in Settings -> API Keys.
|
||||||
|
|
||||||
|
## Local App Quick Start: Local Qwen/Ollama
|
||||||
|
|
||||||
|
Requires Ollama to be installed and running.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/bradwmorris/ra-h_os.git
|
||||||
|
cd ra-h_os
|
||||||
|
npm install
|
||||||
|
ollama pull qwen3:4b
|
||||||
|
ollama pull qwen3-embedding:0.6b
|
||||||
|
npm run setup:local -- --profile qwen-local
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open http://localhost:3000. Settings -> API Keys shows the active local model profile and disables OpenAI key entry.
|
||||||
|
|
||||||
|
## Local App Quick Start: Local Qwen/llama.cpp
|
||||||
|
|
||||||
|
Requires llama.cpp to be installed, compatible Qwen GGUF files to exist on disk, and separate OpenAI-compatible servers to be running.
|
||||||
|
|
||||||
|
Example servers:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
llama-server -m /models/qwen3-4b.gguf --port 8080
|
||||||
|
llama-server -m /models/qwen3-embedding-0.6b.gguf --embedding --port 8081
|
||||||
|
```
|
||||||
|
|
||||||
|
Then set up RA-H:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/bradwmorris/ra-h_os.git
|
||||||
|
cd ra-h_os
|
||||||
|
npm install
|
||||||
|
npm run setup:local -- --profile llama-cpp
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open http://localhost:3000. Settings -> API Keys shows the active local model profile and disables OpenAI key entry.
|
||||||
|
|
||||||
|
Fresh app setup must choose `--profile openai`, `--profile qwen-local`, or `--profile llama-cpp` before vector tables are created. OpenAI embeddings use width `1536`; the supported Qwen embedding profiles use width `1024`.
|
||||||
|
|
||||||
## MCP Integration
|
## MCP Integration
|
||||||
|
|
||||||
The recommended MCP setup is the CLI command above. Manual config is only for troubleshooting or unsupported clients:
|
Configure MCP after the app database exists. MCP should point at the same SQLite file as the app.
|
||||||
|
|
||||||
|
If you used the default database path:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx -y ra-h-mcp-server@latest setup --client claude-code,codex --yes
|
||||||
|
```
|
||||||
|
|
||||||
|
If you set `SQLITE_DB_PATH` during app setup, pass that exact same path:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx -y ra-h-mcp-server@latest setup \
|
||||||
|
--client claude-code,codex \
|
||||||
|
--yes \
|
||||||
|
--db "/absolute/path/to/rah.sqlite"
|
||||||
|
```
|
||||||
|
|
||||||
|
Manual config is only for troubleshooting or unsupported clients:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -73,7 +122,17 @@ The recommended MCP setup is the CLI command above. Manual config is only for tr
|
|||||||
|
|
||||||
If you need a frozen version for release/debug work, pin it intentionally and restart the client.
|
If you need a frozen version for release/debug work, pin it intentionally and restart the client.
|
||||||
|
|
||||||
The setup command creates the default database if it does not exist. The standalone MCP server can write nodes without the app running, but the app owns chunking and embedding from node source: readable `chunks`, full-text indexes, `vec_nodes`, and `vec_chunks`. See [MCP docs](./8_mcp.md) for the full install, verify, memory-file, and troubleshooting path.
|
The selected setup profile creates the default database if it does not exist. By default, that database is in the operating system's app-data folder, not inside the cloned repo:
|
||||||
|
|
||||||
|
```text
|
||||||
|
~/Library/Application Support/RA-H/db/rah.sqlite # macOS
|
||||||
|
~/.local/share/RA-H/db/rah.sqlite # Linux
|
||||||
|
%APPDATA%/RA-H/db/rah.sqlite # Windows
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `SQLITE_DB_PATH` before setup if you want a repo-local DB, demo DB, or any other separate location. If MCP should use that same non-default database, pass the same path to the MCP installer with `--db`.
|
||||||
|
|
||||||
|
The standalone MCP server can write nodes without the app running, but the app owns chunking and embedding from node source: readable `chunks`, full-text indexes, `vec_nodes`, and `vec_chunks`. See [MCP docs](./8_mcp.md) for the full install, verify, memory-file, and troubleshooting path.
|
||||||
|
|
||||||
## Questions?
|
## Questions?
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -20,8 +20,6 @@
|
|||||||
"setup": "npm install",
|
"setup": "npm install",
|
||||||
"sqlite:backup": "bash scripts/database/sqlite-backup.sh",
|
"sqlite:backup": "bash scripts/database/sqlite-backup.sh",
|
||||||
"sqlite:restore": "bash scripts/database/sqlite-restore.sh",
|
"sqlite:restore": "bash scripts/database/sqlite-restore.sh",
|
||||||
"doctor:local-ai": "tsx scripts/doctor-local-ai.ts",
|
|
||||||
"rebuild:embeddings": "tsx scripts/rebuild-embeddings.ts",
|
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"evals": "cross-env RAH_EVALS_LOG=1 RAH_EVALS_TIMEOUT_MS=60000 tsx tests/evals/runner.ts",
|
"evals": "cross-env RAH_EVALS_LOG=1 RAH_EVALS_TIMEOUT_MS=60000 tsx tests/evals/runner.ts",
|
||||||
@@ -49,6 +47,7 @@
|
|||||||
"react-dom": "^19.0.1",
|
"react-dom": "^19.0.1",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
|
"sqlite-vec": "^0.1.9",
|
||||||
"tiptap-markdown": "^0.9.0",
|
"tiptap-markdown": "^0.9.0",
|
||||||
"youtube-transcript": "^1.2.1",
|
"youtube-transcript": "^1.2.1",
|
||||||
"youtube-transcript-plus": "^1.1.1",
|
"youtube-transcript-plus": "^1.1.1",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import Database from 'better-sqlite3';
|
|||||||
const repoDir = process.cwd();
|
const repoDir = process.cwd();
|
||||||
const envTemplate = path.join(repoDir, '.env.example');
|
const envTemplate = path.join(repoDir, '.env.example');
|
||||||
const targetEnv = path.join(repoDir, '.env.local');
|
const targetEnv = path.join(repoDir, '.env.local');
|
||||||
const supportedProfiles = new Set(['openai', 'qwen-local']);
|
const supportedProfiles = new Set(['openai', 'qwen-local', 'llama-cpp']);
|
||||||
|
|
||||||
function log(message) {
|
function log(message) {
|
||||||
console.log(`[bootstrap-local] ${message}`);
|
console.log(`[bootstrap-local] ${message}`);
|
||||||
@@ -132,13 +132,16 @@ function normalizeSetupProfile(rawProfile) {
|
|||||||
if (rawProfile === 'qwen' || rawProfile === 'local' || rawProfile === 'ollama') {
|
if (rawProfile === 'qwen' || rawProfile === 'local' || rawProfile === 'ollama') {
|
||||||
return 'qwen-local';
|
return 'qwen-local';
|
||||||
}
|
}
|
||||||
|
if (rawProfile === 'llama' || rawProfile === 'llamacpp' || rawProfile === 'cpp') {
|
||||||
|
return 'llama-cpp';
|
||||||
|
}
|
||||||
return rawProfile;
|
return rawProfile;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySetupProfile(profile) {
|
function applySetupProfile(profile) {
|
||||||
if (!profile) return;
|
if (!profile) return;
|
||||||
if (!supportedProfiles.has(profile)) {
|
if (!supportedProfiles.has(profile)) {
|
||||||
throw new Error(`Unsupported setup profile "${profile}". Use "openai" or "qwen-local".`);
|
throw new Error(`Unsupported setup profile "${profile}". Use "openai", "qwen-local", or "llama-cpp".`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (profile === 'openai') {
|
if (profile === 'openai') {
|
||||||
@@ -150,12 +153,24 @@ function applySetupProfile(profile) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (profile === 'qwen-local') {
|
||||||
|
ensureEnvValue('LLM_PROFILE', 'openai-compatible');
|
||||||
|
ensureEnvValue('LLM_BASE_URL', 'http://127.0.0.1:11434/v1');
|
||||||
|
ensureEnvValue('LLM_MODEL', 'qwen3:4b');
|
||||||
|
ensureEnvValue('EMBEDDING_PROFILE', 'openai-compatible');
|
||||||
|
ensureEnvValue('EMBEDDING_BASE_URL', 'http://127.0.0.1:11434/v1');
|
||||||
|
ensureEnvValue('EMBEDDING_MODEL', 'qwen3-embedding:0.6b');
|
||||||
|
ensureEnvValue('EMBEDDING_DIMENSIONS', '1024');
|
||||||
|
ensureEnvValue('VECTOR_BACKEND', 'sqlite-vec');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
ensureEnvValue('LLM_PROFILE', 'openai-compatible');
|
ensureEnvValue('LLM_PROFILE', 'openai-compatible');
|
||||||
ensureEnvValue('LLM_BASE_URL', 'http://127.0.0.1:11434/v1');
|
ensureEnvValue('LLM_BASE_URL', 'http://127.0.0.1:8080/v1');
|
||||||
ensureEnvValue('LLM_MODEL', 'qwen3:4b');
|
ensureEnvValue('LLM_MODEL', 'qwen3-4b');
|
||||||
ensureEnvValue('EMBEDDING_PROFILE', 'openai-compatible');
|
ensureEnvValue('EMBEDDING_PROFILE', 'openai-compatible');
|
||||||
ensureEnvValue('EMBEDDING_BASE_URL', 'http://127.0.0.1:11434/v1');
|
ensureEnvValue('EMBEDDING_BASE_URL', 'http://127.0.0.1:8081/v1');
|
||||||
ensureEnvValue('EMBEDDING_MODEL', 'qwen3-embedding:0.6b');
|
ensureEnvValue('EMBEDDING_MODEL', 'qwen3-embedding-0.6b');
|
||||||
ensureEnvValue('EMBEDDING_DIMENSIONS', '1024');
|
ensureEnvValue('EMBEDDING_DIMENSIONS', '1024');
|
||||||
ensureEnvValue('VECTOR_BACKEND', 'sqlite-vec');
|
ensureEnvValue('VECTOR_BACKEND', 'sqlite-vec');
|
||||||
}
|
}
|
||||||
@@ -175,6 +190,7 @@ function assertEmbeddingProfileSelected(env) {
|
|||||||
'Run one of:',
|
'Run one of:',
|
||||||
' npm run setup:local -- --profile openai',
|
' npm run setup:local -- --profile openai',
|
||||||
' npm run setup:local -- --profile qwen-local',
|
' npm run setup:local -- --profile qwen-local',
|
||||||
|
' npm run setup:local -- --profile llama-cpp',
|
||||||
'',
|
'',
|
||||||
'If you change embedding provider later, run:',
|
'If you change embedding provider later, run:',
|
||||||
' npm run rebuild:embeddings',
|
' npm run rebuild:embeddings',
|
||||||
|
|||||||
Reference in New Issue
Block a user