Compare commits

...
10 Commits
Author SHA1 Message Date
jerome 88228a6104 chore: fork with local patches for Syslog Solution
Patches applied:
- enforceRelayTTL — auto-deletes expired relay nodes (>7d)
- updateNode — tenant enforcement (block updates on NULL-namespace nodes)
- buildCanonicalMetadata — reject nested objects, sanitize values
- createNode — source-based and title-based deduplication
- getContext — NPE-safe count queries, stale node detection
- repair_log integration on updateNode
- bridge-validation.js — auto-fill defaults for tenant/namespace

Origin: github.com/bradwmorris/ra-h_os@70e95e7
2026-06-01 09:37:19 -04:00
“BeeRad” 70e95e7d29 Keep install choices to model paths 2026-05-02 14:20:05 +10:00
“BeeRad” a0717e136b Merge codex/clarify-mcp-db-config 2026-05-02 14:18:07 +10:00
“BeeRad” 3c46991a2e Simplify install paths around app setup 2026-05-02 14:15:53 +10:00
BradWMorrisandGitHub 2c552e5571 Merge pull request #14 from bradwmorris/codex/clarify-mcp-db-config
Clarify MCP database configuration
2026-05-02 16:04:21 +12:00
“BeeRad” 0481435ca6 Add llama.cpp setup profile 2026-05-02 13:59:15 +10:00
“BeeRad” 7dcdafcf67 Clarify MCP database configuration 2026-05-02 13:55:01 +10:00
“BeeRad” 51e6aa3181 Merge feat/qdrant-local-vector-backend 2026-05-02 13:00:46 +10:00
“BeeRad” 3fd6c094d5 Clarify default database location 2026-05-02 12:59:21 +10:00
“BeeRad” 644316974e Clarify setup profiles in README 2026-05-02 12:51:40 +10:00
12 changed files with 808 additions and 186 deletions
+20 -2
View File
@@ -11,6 +11,7 @@ OPENAI_API_KEY=
#
# npm run setup:local -- --profile openai
# npm run setup:local -- --profile qwen-local
# npm run setup:local -- --profile llama-cpp
#
# OpenAI profile:
# LLM_PROFILE=openai
@@ -30,13 +31,30 @@ OPENAI_API_KEY=
# EMBEDDING_MODEL=qwen3-embedding:0.6b
# 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:
# VECTOR_BACKEND=qdrant
# QDRANT_URL=http://localhost:6333
# Database/vector paths are auto-detected for macOS, Windows, and Linux.
# Override only if you intentionally want a custom location.
# Database path defaults to the operating system app-data folder:
# 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-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>
# App config (no changes needed)
+1
View File
@@ -38,6 +38,7 @@ yarn-error.log*
next-env.d.ts
# Database
.ra-h/
*.db
*.sqlite
*.sqlite3
+6
View File
@@ -11,6 +11,12 @@ llama-server -m /models/qwen3-embedding-0.6b.gguf --embedding --port 8081
Configure RA-H:
```bash
npm run setup:local -- --profile llama-cpp
```
That writes this profile to `.env.local`:
```bash
LLM_PROFILE=openai-compatible
LLM_BASE_URL=http://127.0.0.1:8080/v1
+15
View File
@@ -13,6 +13,8 @@ OpenAI remains the default supported path. Local mode is for users who are comfo
## Core Env
Ollama:
```bash
LLM_PROFILE=openai-compatible
LLM_BASE_URL=http://127.0.0.1:11434/v1
@@ -24,6 +26,19 @@ EMBEDDING_MODEL=qwen3-embedding:0.6b
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:
```bash
+249 -73
View File
@@ -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.
[![Watch the setup walkthrough](https://img.youtube.com/vi/YyUCGigZIZE/hqdefault.jpg)](https://youtu.be/YyUCGigZIZE?si=USYgvmwtdGpgGdwu)
@@ -25,7 +25,7 @@
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
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:
- no runtime `dimensions`
@@ -48,78 +48,112 @@ Current contract:
## 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 |
|-------------|---------------|
| Your AI coding agent can read/write your RA-H graph | **Option A: MCP-only quick install** |
| A browser UI at `localhost:3000` | **Option B: Full local app** |
| A clean demo that does not touch your real graph | **Demo-safe isolated install** |
| The simplest setup and strongest default model quality | **Local DB + OpenAI models** |
| No model calls leaving your computer | **Local DB + local Qwen models** |
### 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
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`.
Use this if you want RA-H running quickly and are comfortable using OpenAI for descriptions, embeddings, and semantic search.
```bash
git clone https://github.com/bradwmorris/ra-h_os.git
cd ra-h_os
npm install
npm run setup:local
npm run setup:local -- --profile openai
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:
- [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
the readable `nodes` and `chunks` tables are normal SQLite tables, but the
derived sqlite-vec vector tables are created with a fixed embedding width.
Pick the embedding profile before the database is created.
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:
@@ -154,12 +196,17 @@ npm run setup:local -- --profile qwen-local
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 two supported commands.
Local Qwen with llama.cpp:
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
npm install
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
npm run rebuild:embeddings
@@ -169,7 +216,9 @@ npm run rebuild:embeddings
## 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:
- 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.
**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)
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
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
LLM_PROFILE=openai-compatible
@@ -201,6 +254,19 @@ EMBEDDING_MODEL=qwen3-embedding:0.6b
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:
- [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
```
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.
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
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
~/.local/share/RA-H/db/rah.sqlite # Linux
%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:
- Back it up by copying the file
- Query it directly with `sqlite3` or any SQLite tool
- 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
@@ -284,6 +454,8 @@ The recommended path is the CLI installer:
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:
@@ -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 |
|---------|--------------|
| `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 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:local` | Alias for `npm run dev` |
| `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 };
+146 -31
View File
@@ -1,5 +1,5 @@
#!/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
const nodeVersion = parseInt(process.versions.node.split('.')[0], 10);
@@ -26,7 +26,7 @@ try {
const { z } = require('zod');
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 { initDatabase, getDatabasePath, closeDatabase, getDb, query } = require('./services/sqlite-client');
@@ -105,7 +105,7 @@ const addNodeInputSchema = {
};
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)'),
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.'),
@@ -184,7 +184,8 @@ function sanitizeFtsQuery(input) {
.trim()
.split(/\s+/)
.filter(w => w.length > 0 && !/^(AND|OR|NOT|NEAR)$/i.test(w))
.join(' ');
.map(w => `"${w}"`)
.join(' OR ');
}
let _ftsAvailability = null;
@@ -235,10 +236,6 @@ function log(...args) {
console.error('[ra-h-standalone]', ...args);
}
function withVisibleJson(summary, payload) {
return `${summary}\n\n${JSON.stringify(payload, null, 2)}`;
}
async function main() {
// Initialize database
try {
@@ -311,7 +308,7 @@ async function main() {
: result.reason;
return {
content: [{ type: 'text', text: result.shouldRetrieve ? withVisibleJson(summary, result) : summary }],
content: [{ type: 'text', text: summary }],
structuredContent: result
};
}
@@ -328,12 +325,17 @@ async function main() {
const sourceText = source?.trim() || content?.trim() || chunk?.trim();
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({
title: title.trim(),
source: sourceText,
link: link?.trim(),
description: normalizedDescription,
metadata: metadata || {}
metadata: r.payload?.metadata || {}
});
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 }) => {
const safeLimit = Math.min(Math.max(limit, 1), 50);
const result = directNodeLookup({
search: searchQuery.trim(),
search: (searchQuery ?? "").trim(),
limit: safeLimit,
createdAfter: created_after,
createdBefore: created_before,
@@ -367,28 +369,26 @@ async function main() {
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
? 'No nodes found matching that query.'
: `Found ${result.count} node(s).`;
return {
content: [{ type: 'text', text: result.count === 0 ? summary : withVisibleJson(summary, payload) }],
structuredContent: payload
content: [{ type: 'text', text: summary }],
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;
}
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}`;
return {
@@ -497,6 +522,7 @@ async function main() {
if (!confirmed_by_user) {
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({
from_node_id: sourceId,
@@ -563,7 +589,7 @@ async function main() {
from_node_id: e.from_node_id,
to_node_id: e.to_node_id,
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
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);
log('MCP server ready');
httpServer.listen(3100, '0.0.0.0', () => {
log('HTTP server listening on port 3100');
});
// Handle graceful shutdown
process.on('SIGINT', () => {
@@ -21,7 +21,10 @@ function getEdges(filters = {}) {
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]);
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');
}
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
// The main app can re-infer types when it loads
@@ -60,13 +81,13 @@ function createEdge(edgeData) {
type: 'related_to',
confidence: 0.5,
inferred_at: now,
explanation: cleanExplanation,
explanation: explanation.trim(),
created_via: 'mcp'
};
const stmt = db.prepare(`
INSERT INTO edges (from_node_id, to_node_id, context, source, created_at, explanation)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO edges (from_node_id, to_node_id, context, source, created_at)
VALUES (?, ?, ?, ?, ?)
`);
const result = runWithBusyRetry(() => stmt.run(
@@ -74,13 +95,18 @@ function createEdge(edgeData) {
to_node_id,
JSON.stringify(context),
source,
now,
cleanExplanation
now
),
'createEdge'
);
// P0 — After writing edge to DB, sync explanation column
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);
}
@@ -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.`);
}
const cleanExplanation = typeof explanation === 'string' ? explanation.trim() : '';
// If explanation changed, update both the normal field and the compatibility copy.
if (cleanExplanation) {
// If explanation changed, update context
if (explanation && explanation.trim()) {
const now = new Date().toISOString();
const newContext = {
...existing.context,
explanation: cleanExplanation,
explanation: explanation.trim(),
inferred_at: now,
created_via: 'mcp'
};
const stmt = db.prepare('UPDATE edges SET context = ?, explanation = ? WHERE id = ?');
runWithBusyRetry(() => stmt.run(JSON.stringify(newContext), cleanExplanation, id), 'updateEdge');
const stmt = db.prepare('UPDATE edges SET context = ? WHERE id = ?');
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) {
const contextExplanation = typeof contextUpdates.explanation === 'string'
? contextUpdates.explanation.trim()
: '';
const newContext = {
...existing.context,
...contextUpdates,
...(contextExplanation ? { explanation: contextExplanation } : {})
...contextUpdates
};
if (contextExplanation) {
const stmt = db.prepare('UPDATE edges SET context = ?, explanation = ? WHERE id = ?');
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');
}
const stmt = db.prepare('UPDATE edges SET context = ? WHERE id = ?');
runWithBusyRetry(() => stmt.run(JSON.stringify(newContext), id), 'updateEdge');
}
return getEdgeById(id);
@@ -211,15 +230,6 @@ function getEdgeCount() {
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.
*/
@@ -131,22 +131,50 @@ function buildCanonicalMetadata({ existing, metadata }) {
...(incoming.source_metadata && typeof incoming.source_metadata === 'object' ? incoming.source_metadata : {}),
};
const merged = {
...prior,
...incoming,
state: incoming.state === 'processed' ? 'processed' : (prior.state === 'processed' ? 'processed' : 'not_processed'),
captured_by: incoming.captured_by || prior.captured_by || 'human',
source_metadata: sourceMetadata,
};
// Sanitize a value: reject nested objects/arrays, return trimmed string or undefined
function sanitizeValue(value) {
if (value === undefined || value === null) return undefined;
if (typeof value === 'string') {
const trimmed = value.trim();
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);
const capturedMethod = normalizeString(incoming.captured_method) || normalizeString(prior.captured_method);
// Build merged metadata from prior and incoming, but only keep string values
const merged = {};
if (type) merged.type = type;
else delete merged.type;
// Start with source_metadata (always an object)
merged.source_metadata = sourceMetadata;
if (capturedMethod) merged.captured_method = capturedMethod;
else delete merged.captured_method;
// Copy from prior: only string values
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;
}
@@ -402,6 +430,36 @@ function getChunkStatusForSource(sourceText) {
/**
* 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) {
const {
title: rawTitle,
@@ -414,6 +472,34 @@ function createNode(nodeData) {
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 now = new Date().toISOString();
const db = getDb();
@@ -449,6 +535,9 @@ function createNode(nodeData) {
* Update an existing node.
*/
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 now = new Date().toISOString();
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.`);
}
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 })
: undefined;
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);
}
@@ -537,8 +668,10 @@ function getNodeCount() {
* Returns stats, hub nodes, and recent activity.
*/
function getContext() {
const nodeCount = query('SELECT COUNT(*) as count FROM nodes')[0].count;
const edgeCount = query('SELECT COUNT(*) as count FROM edges')[0].count;
const nodeCountRow = query('SELECT COUNT(*) as count FROM nodes');
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(`
SELECT n.id, n.title, n.description
@@ -556,10 +689,34 @@ function getContext() {
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 {
stats: { nodeCount, edgeCount, dimensionCount: 0 },
recentNodes,
hubNodes
hubNodes,
allNodes,
staleNodes
};
}
+78 -19
View File
@@ -28,37 +28,86 @@
## Start Here
If you just want RA-H OS working:
1. Use the MCP quick install below if you mainly want agent access.
2. Use the local app quick start if you also want the browser UI.
3. Read [Local Models](../LOCAL-MODELS.md) and [Full Local](./10_full-local.md) if you want a more local-first setup.
1. Use the OpenAI local app quick start if you want the simplest setup.
2. Use the local Qwen/Ollama quick start if you want local models with the least runtime work.
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
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
## Local App Quick Start: OpenAI
```bash
git clone https://github.com/bradwmorris/ra-h_os.git
cd ra-h_os
npm install
npm run setup:local
npm run setup:local -- --profile openai
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
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
{
@@ -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.
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?
+1 -2
View File
@@ -20,8 +20,6 @@
"setup": "npm install",
"sqlite:backup": "bash scripts/database/sqlite-backup.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:watch": "vitest",
"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-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"sqlite-vec": "^0.1.9",
"tiptap-markdown": "^0.9.0",
"youtube-transcript": "^1.2.1",
"youtube-transcript-plus": "^1.1.1",
+22 -6
View File
@@ -8,7 +8,7 @@ import Database from 'better-sqlite3';
const repoDir = process.cwd();
const envTemplate = path.join(repoDir, '.env.example');
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) {
console.log(`[bootstrap-local] ${message}`);
@@ -132,13 +132,16 @@ function normalizeSetupProfile(rawProfile) {
if (rawProfile === 'qwen' || rawProfile === 'local' || rawProfile === 'ollama') {
return 'qwen-local';
}
if (rawProfile === 'llama' || rawProfile === 'llamacpp' || rawProfile === 'cpp') {
return 'llama-cpp';
}
return rawProfile;
}
function applySetupProfile(profile) {
if (!profile) return;
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') {
@@ -150,12 +153,24 @@ function applySetupProfile(profile) {
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_BASE_URL', 'http://127.0.0.1:11434/v1');
ensureEnvValue('LLM_MODEL', 'qwen3:4b');
ensureEnvValue('LLM_BASE_URL', 'http://127.0.0.1:8080/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_BASE_URL', 'http://127.0.0.1:8081/v1');
ensureEnvValue('EMBEDDING_MODEL', 'qwen3-embedding-0.6b');
ensureEnvValue('EMBEDDING_DIMENSIONS', '1024');
ensureEnvValue('VECTOR_BACKEND', 'sqlite-vec');
}
@@ -175,6 +190,7 @@ function assertEmbeddingProfileSelected(env) {
'Run one of:',
' npm run setup:local -- --profile openai',
' npm run setup:local -- --profile qwen-local',
' npm run setup:local -- --profile llama-cpp',
'',
'If you change embedding provider later, run:',
' npm run rebuild:embeddings',