feat: MCP server v1.2.0 — guide system + read/write guide tools

Adds full guide system to MCP standalone server. System guides (schema,
creating-nodes, edges, dimensions, extract) are bundled and immutable.
Users can create up to 10 custom guides via rah_write_guide. Syncs guide
infrastructure from main repo to OS repo app code.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
“BeeRad”
2026-02-08 11:15:13 +11:00
co-authored by Claude Sonnet 4.5
parent 2465e7562c
commit be675be4e0
19 changed files with 985 additions and 18 deletions
+4
View File
@@ -129,6 +129,10 @@ Available tools:
| `rah_create_dimension` | Create a new dimension | | `rah_create_dimension` | Create a new dimension |
| `rah_update_dimension` | Update/rename a dimension | | `rah_update_dimension` | Update/rename a dimension |
| `rah_delete_dimension` | Delete a dimension | | `rah_delete_dimension` | Delete a dimension |
| `rah_list_guides` | List available guides (system + custom) |
| `rah_read_guide` | Read a guide by name |
| `rah_write_guide` | Create or update a custom guide |
| `rah_delete_guide` | Delete a custom guide |
**Example prompts for Claude Code:** **Example prompts for Claude Code:**
- "What's in my knowledge graph?" - "What's in my knowledge graph?"
+10
View File
@@ -61,6 +61,16 @@ Once connected, Claude will:
| `rah_create_dimension` | Create a dimension | | `rah_create_dimension` | Create a dimension |
| `rah_update_dimension` | Update/rename a dimension | | `rah_update_dimension` | Update/rename a dimension |
| `rah_delete_dimension` | Delete a dimension | | `rah_delete_dimension` | Delete a dimension |
| `rah_list_guides` | List available guides (system + custom) |
| `rah_read_guide` | Read a guide by name |
| `rah_write_guide` | Create or update a custom guide |
| `rah_delete_guide` | Delete a custom guide |
## Guides
Guides are detailed instruction sets that teach Claude how to work with your knowledge base. System guides (schema, creating-nodes, edges, dimensions, extract) are bundled and immutable. You can create up to 10 custom guides for your own workflows.
Guides are stored at `~/Library/Application Support/RA-H/guides/` and shared with the main app.
## What's NOT Included ## What's NOT Included
@@ -0,0 +1,14 @@
---
name: Preferences
description: Your communication style, workflow rules, and current priorities.
---
# Preferences
Add your preferences here. The agent will read this guide to understand how you like to work.
## Examples of what to put here
- Communication style (bullet points vs prose, level of detail)
- Workflow rules (always ask before creating dimensions, etc.)
- Current priorities (what you're focused on this week)
- Timezone and locale
@@ -0,0 +1,38 @@
---
name: Creating Nodes
description: When and how to create nodes. Link field rules. Synthesis patterns.
immutable: true
---
# Creating Nodes
## When to Create
- User explicitly asks to save/capture something
- Extracting insights from existing content (synthesis)
- Ingesting external content (YouTube, website, PDF)
## Link Field Rules
- **Has link:** Node directly represents external content (YouTube video, website, PDF, article)
- **No link:** Node is derived/synthesized from existing content (ideas, insights, summaries, questions)
- Never add a link to synthesis or idea nodes
## Synthesis Pattern
When creating a node derived from existing content:
1. Create the node WITHOUT a link field
2. Call `createEdge` to connect it to ALL source nodes
3. Each edge needs an explanation ("Insight extracted from...", "Synthesized from...")
## Dimension Assignment
- New nodes should be assigned to relevant existing dimensions
- Check locked/priority dimensions — these auto-assign
- If no existing dimension fits, create a new one (but prefer existing)
## Description Field
- AI auto-generates a ~1 sentence description after creation
- Description is used for embeddings and search ranking (5x boost)
- Format: what is this node about, in plain language
@@ -0,0 +1,38 @@
---
name: Dimensions
description: Create, lock, describe, organize, clean up dimensions.
immutable: true
---
# Dimensions
Dimensions are how nodes are categorized and organized. Think of them as flexible tags with descriptions.
## Operations
- **Create:** `createDimension(name, description, isPriority)`
- **Update:** `updateDimension(name, { newName, description, isPriority })`
- **Delete:** `deleteDimension(name)` — removes from all nodes
- **Query:** Use sqliteQuery to list dimensions and their node counts
## Locking (Priority)
- `isPriority = true` (locked) → dimension auto-assigns to new nodes when relevant
- `isPriority = false` (unlocked) → manual assignment only
- Lock dimensions that represent active areas of focus
## Naming Conventions
- Lowercase, concise (e.g., "ai", "philosophy", "ra-h")
- Use singular form where natural
- Avoid overlapping names (don't have both "ai" and "artificial-intelligence")
## Description
Every dimension should have a description explaining its purpose. This helps the AI correctly assign nodes to dimensions.
## Cleanup
- Delete dimensions with 0 nodes
- Merge overlapping dimensions (update nodes, then delete the redundant one)
- Regularly review dimension list for coherence
@@ -0,0 +1,51 @@
---
name: Edges
description: Edge philosophy. Explanations, direction, types. Connection patterns.
immutable: true
---
# Edges
## Philosophy
Edges are the most valuable part of the knowledge graph. Individual nodes are useful; the web of connections between them is what makes the graph powerful.
## Rules
1. **Every edge needs an explanation** — why does this connection exist? Be specific.
2. **Direction matters** — FROM → TO should read like a sentence
3. **Types are inferred** — the system infers category/type from your explanation. Don't set types manually.
## Direction Convention
Write the explanation so FROM → TO reads naturally:
- Episode → Podcast: "Episode of this podcast"
- Book → Author: "Written by this author"
- Insight → Source: "Extracted from this source"
- Idea → Related idea: "Builds on this concept"
## Edge Context JSON
```json
{
"explanation": "Human-readable reason",
"category": "inferred (created_by, features, part_of, source_of, related_to)",
"type": "inferred specific type",
"confidence": 0.0-1.0,
"created_via": "chat|mcp|workflow"
}
```
## Hub Traversal
Hub nodes (most-connected) are the user's core themes. To understand context around a topic:
1. Find the relevant hub node
2. Use `queryEdge` or sqliteQuery to get its connections
3. Traverse outward to related nodes
## When to Create Edges
- After creating synthesis/idea nodes (connect to sources)
- When user mentions a relationship between topics
- When running the Connect or Integrate guides
- When obvious connections exist that aren't captured
@@ -0,0 +1,33 @@
---
name: Extract
description: Extraction pre-check. When to reuse chunks vs re-extract.
immutable: true
---
# Content Extraction
## Pre-Check (REQUIRED)
Before running any extraction tool, always check the node first:
1. Call `getNodesById` on the target node
2. Check `chunk_status`:
- **'chunked'** → content already extracted. Reuse existing chunks. Do NOT re-extract.
- **'pending'** or missing → safe to extract
- **'failed'** → previous extraction failed, safe to retry
3. Check if embeddings are available (chunk length > 0)
- If available, use `searchContentEmbeddings` instead of re-extracting
## Extraction Tools
- **youtubeExtract** — YouTube videos (requires URL with video ID)
- **websiteExtract** — Web pages (uses Jina.ai for JS-rendered sites)
- **paperExtract** — PDF files (requires direct PDF URL)
## After Extraction
- The extracted content goes into `chunk` (full source)
- AI generates a `description` (grounding summary)
- Embeddings are created automatically
- Assign to relevant dimensions
@@ -0,0 +1,92 @@
---
name: Schema
description: Full database schema, tables, columns, query patterns.
immutable: true
---
# Database Schema
## Tables
### nodes
| Column | Type | Notes |
|--------|------|-------|
| id | INTEGER | Primary key, auto-increment |
| title | TEXT | Required |
| description | TEXT | AI-generated grounding context (~1 sentence) |
| content | TEXT | User's notes/thoughts (not source content) |
| chunk | TEXT | Full verbatim source content |
| chunk_status | TEXT | 'pending', 'chunked', 'failed' |
| link | TEXT | External URL (only for nodes representing external content) |
| type | TEXT | Nullable (reserved for future use) |
| metadata | TEXT | JSON blob (map_position, transcript_length, etc.) |
| is_pinned | INTEGER | Legacy — use hub node queries instead |
| created_at | TEXT | ISO timestamp |
| updated_at | TEXT | ISO timestamp |
### edges
| Column | Type | Notes |
|--------|------|-------|
| id | INTEGER | Primary key |
| from_node_id | INTEGER | FK → nodes.id |
| to_node_id | INTEGER | FK → nodes.id |
| context | TEXT | JSON: `{ explanation, category, type, confidence, created_via }` |
| source | TEXT | 'user', 'ai_similarity', or helper name |
| explanation | TEXT | Human-readable reason for connection |
| created_at | TEXT | ISO timestamp |
### dimensions
| Column | Type | Notes |
|--------|------|-------|
| id | INTEGER | Primary key |
| name | TEXT | Unique, case-insensitive |
| description | TEXT | Purpose description |
| is_locked | INTEGER | 1 = priority dimension (auto-assigns to new nodes) |
### node_dimensions (junction)
| Column | Type |
|--------|------|
| node_id | INTEGER FK → nodes.id |
| dimension_id | INTEGER FK → dimensions.id |
### chunks (for semantic search)
| Column | Type | Notes |
|--------|------|-------|
| id | INTEGER | Primary key |
| node_id | INTEGER | FK → nodes.id |
| chunk_index | INTEGER | Position in sequence |
| text | TEXT | Chunk content |
| embedding | BLOB | Vector (via sqlite-vec) |
### FTS Tables
- `chunks_fts` — full-text search on chunk text
- `nodes_fts` — full-text search on node title + content
## Common Query Patterns
**Top connected nodes (hubs):**
```sql
SELECT n.id, n.title, n.description, COUNT(DISTINCT e.id) AS edge_count
FROM nodes n
LEFT JOIN edges e ON (e.from_node_id = n.id OR e.to_node_id = n.id)
GROUP BY n.id ORDER BY edge_count DESC LIMIT 5
```
**Nodes in a dimension:**
```sql
SELECT n.* FROM nodes n
JOIN node_dimensions nd ON n.id = nd.node_id
JOIN dimensions d ON nd.dimension_id = d.id
WHERE d.name = ?
```
**Edges for a node (both directions):**
```sql
SELECT e.*, n1.title as from_title, n2.title as to_title
FROM edges e
JOIN nodes n1 ON e.from_node_id = n1.id
JOIN nodes n2 ON e.to_node_id = n2.id
WHERE e.from_node_id = ? OR e.to_node_id = ?
```
**Use sqliteQuery for any read operation not covered by structured tools.**
+112 -2
View File
@@ -9,11 +9,12 @@ const { initDatabase, getDatabasePath, closeDatabase } = require('./services/sql
const nodeService = require('./services/nodeService'); const nodeService = require('./services/nodeService');
const edgeService = require('./services/edgeService'); const edgeService = require('./services/edgeService');
const dimensionService = require('./services/dimensionService'); const dimensionService = require('./services/dimensionService');
const guideService = require('./services/guideService');
// Server info // Server info
const serverInfo = { const serverInfo = {
name: 'ra-h-standalone', name: 'ra-h-standalone',
version: '1.1.0' version: '1.2.0'
}; };
const instructions = [ const instructions = [
@@ -22,6 +23,7 @@ const instructions = [
'Proactively identify valuable information in conversations and offer to save it.', 'Proactively identify valuable information in conversations and offer to save it.',
'Search before creating to avoid duplicates.', 'Search before creating to avoid duplicates.',
'Every edge needs an explanation — why does this connection exist?', 'Every edge needs an explanation — why does this connection exist?',
'Guides are detailed instruction sets — call rah_list_guides when you need procedural help.',
'All data stays on this device.' 'All data stays on this device.'
].join(' '); ].join(' ');
@@ -92,6 +94,19 @@ const deleteDimensionInputSchema = {
name: z.string().min(1).describe('Dimension name to delete') name: z.string().min(1).describe('Dimension name to delete')
}; };
const readGuideInputSchema = {
name: z.string().min(1).describe('Guide name (e.g. "edges", "creating-nodes", "schema")')
};
const writeGuideInputSchema = {
name: z.string().min(1).describe('Guide name (lowercase, no spaces)'),
content: z.string().min(1).describe('Full markdown content including YAML frontmatter (name, description)')
};
const deleteGuideInputSchema = {
name: z.string().min(1).describe('Guide name to delete')
};
// Helper to sanitize dimensions // Helper to sanitize dimensions
function sanitizeDimensions(raw) { function sanitizeDimensions(raw) {
if (!Array.isArray(raw)) return []; if (!Array.isArray(raw)) return [];
@@ -138,6 +153,8 @@ async function main() {
}, },
async () => { async () => {
const context = nodeService.getContext(); const context = nodeService.getContext();
const guides = guideService.listGuides();
context.guides = guides.map(g => ({ name: g.name, description: g.description, immutable: g.immutable }));
// First-run welcome message // First-run welcome message
if (context.stats.nodeCount === 0) { if (context.stats.nodeCount === 0) {
@@ -151,7 +168,7 @@ async function main() {
}; };
} }
const summary = `Graph: ${context.stats.nodeCount} nodes, ${context.stats.edgeCount} edges, ${context.stats.dimensionCount} dimensions.`; const summary = `Graph: ${context.stats.nodeCount} nodes, ${context.stats.edgeCount} edges, ${context.stats.dimensionCount} dimensions, ${guides.length} guides.`;
return { return {
content: [{ type: 'text', text: summary }], content: [{ type: 'text', text: summary }],
structuredContent: context structuredContent: context
@@ -472,6 +489,99 @@ async function main() {
} }
); );
// ========== GUIDE TOOLS ==========
server.registerTool(
'rah_list_guides',
{
title: 'List RA-H guides',
description: 'List available guides — detailed instruction sets for working with the knowledge graph. Includes system guides (immutable) and user-created guides.',
inputSchema: {}
},
async () => {
const guides = guideService.listGuides();
return {
content: [{ type: 'text', text: `Found ${guides.length} guide(s).` }],
structuredContent: {
count: guides.length,
guides
}
};
}
);
server.registerTool(
'rah_read_guide',
{
title: 'Read RA-H guide',
description: 'Read a guide by name. Returns full markdown content with procedural instructions.',
inputSchema: readGuideInputSchema
},
async ({ name }) => {
const guide = guideService.readGuide(name);
if (!guide) {
throw new Error(`Guide "${name}" not found. Call rah_list_guides to see available guides.`);
}
return {
content: [{ type: 'text', text: guide.content }],
structuredContent: guide
};
}
);
server.registerTool(
'rah_write_guide',
{
title: 'Write RA-H guide',
description: 'Create or update a custom guide. System guides cannot be modified. Content should be markdown with YAML frontmatter (name, description).',
inputSchema: writeGuideInputSchema
},
async ({ name, content }) => {
const result = guideService.writeGuide(name, content);
if (!result.success) {
throw new Error(result.error);
}
return {
content: [{ type: 'text', text: `Guide "${name}" saved.` }],
structuredContent: {
success: true,
name,
message: `Guide "${name}" saved.`
}
};
}
);
server.registerTool(
'rah_delete_guide',
{
title: 'Delete RA-H guide',
description: 'Delete a custom guide. System guides cannot be deleted.',
inputSchema: deleteGuideInputSchema
},
async ({ name }) => {
const result = guideService.deleteGuide(name);
if (!result.success) {
throw new Error(result.error);
}
return {
content: [{ type: 'text', text: `Guide "${name}" deleted.` }],
structuredContent: {
success: true,
name,
message: `Guide "${name}" deleted.`
}
};
}
);
// Connect transport // Connect transport
const transport = new StdioServerTransport(); const transport = new StdioServerTransport();
await server.connect(transport); await server.connect(transport);
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ra-h-mcp-server", "name": "ra-h-mcp-server",
"version": "1.1.0", "version": "1.2.0",
"description": "Connect Claude Code/Desktop to your RA-H knowledge base. Direct SQLite access - no web app required.", "description": "Connect Claude Code/Desktop to your RA-H knowledge base. Direct SQLite access - no web app required.",
"main": "index.js", "main": "index.js",
"bin": { "bin": {
@@ -43,6 +43,7 @@
"files": [ "files": [
"index.js", "index.js",
"services/", "services/",
"guides/",
"README.md" "README.md"
] ]
} }
@@ -0,0 +1,203 @@
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const MAX_USER_GUIDES = 10;
// Where guides live on disk (shared with the app)
const GUIDES_DIR = path.join(
os.homedir(),
'Library', 'Application Support', 'RA-H', 'guides'
);
// System guides bundled with this package
const BUNDLED_SYSTEM_DIR = path.join(__dirname, '..', 'guides', 'system');
const BUNDLED_USER_DIR = path.join(__dirname, '..', 'guides');
// System guide names (immutable, always re-seeded)
const SYSTEM_GUIDE_NAMES = new Set([
'schema',
'creating-nodes',
'edges',
'dimensions',
'extract',
]);
/**
* Parse YAML frontmatter from markdown without external deps.
* Returns { data: {}, content: string }
*/
function parseFrontmatter(raw) {
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
if (!match) return { data: {}, content: raw.trim() };
const yamlBlock = match[1];
const content = match[2];
const data = {};
for (const line of yamlBlock.split('\n')) {
const colonIdx = line.indexOf(':');
if (colonIdx === -1) continue;
const key = line.slice(0, colonIdx).trim();
let value = line.slice(colonIdx + 1).trim();
// Handle booleans
if (value === 'true') value = true;
else if (value === 'false') value = false;
data[key] = value;
}
return { data, content: content.trim() };
}
function isSystemGuide(filename) {
const name = filename.replace('.md', '');
return SYSTEM_GUIDE_NAMES.has(name);
}
function ensureGuidesDir() {
if (!fs.existsSync(GUIDES_DIR)) {
fs.mkdirSync(GUIDES_DIR, { recursive: true });
}
}
/**
* Seed guides on first run.
* System guides always overwrite. User guides only seed if missing.
*/
function seedGuides() {
ensureGuidesDir();
// Always re-seed system guides (immutable)
if (fs.existsSync(BUNDLED_SYSTEM_DIR)) {
const files = fs.readdirSync(BUNDLED_SYSTEM_DIR).filter(f => f.endsWith('.md'));
for (const file of files) {
fs.copyFileSync(path.join(BUNDLED_SYSTEM_DIR, file), path.join(GUIDES_DIR, file));
}
}
// Seed default user guides only if they don't exist
if (fs.existsSync(BUNDLED_USER_DIR)) {
const files = fs.readdirSync(BUNDLED_USER_DIR).filter(f => f.endsWith('.md'));
for (const file of files) {
const dest = path.join(GUIDES_DIR, file);
if (!fs.existsSync(dest)) {
fs.copyFileSync(path.join(BUNDLED_USER_DIR, file), dest);
}
}
}
}
let initialized = false;
function init() {
if (initialized) return;
seedGuides();
initialized = true;
}
/**
* List all guides with name, description, immutable flag.
*/
function listGuides() {
init();
if (!fs.existsSync(GUIDES_DIR)) return [];
const files = fs.readdirSync(GUIDES_DIR).filter(f => f.endsWith('.md'));
const guides = files.map(file => {
const raw = fs.readFileSync(path.join(GUIDES_DIR, file), 'utf-8');
const { data } = parseFrontmatter(raw);
const immutable = isSystemGuide(file) || data.immutable === true;
return {
name: data.name || file.replace('.md', ''),
description: data.description || '',
immutable,
};
});
// System guides first, then user guides alphabetically
return guides.sort((a, b) => {
if (a.immutable && !b.immutable) return -1;
if (!a.immutable && b.immutable) return 1;
return a.name.localeCompare(b.name);
});
}
/**
* Read a guide by name. Returns full content.
*/
function readGuide(name) {
init();
const candidates = [`${name}.md`, `${name.toLowerCase()}.md`];
for (const filename of candidates) {
const filepath = path.join(GUIDES_DIR, filename);
if (fs.existsSync(filepath)) {
const raw = fs.readFileSync(filepath, 'utf-8');
const { data, content } = parseFrontmatter(raw);
const immutable = isSystemGuide(filename) || data.immutable === true;
return {
name: data.name || name,
description: data.description || '',
immutable,
content,
};
}
}
return null;
}
/**
* Write or update a guide. Rejects writes to system guides.
*/
function writeGuide(name, content) {
init();
const filename = `${name.toLowerCase()}.md`;
if (isSystemGuide(filename)) {
return { success: false, error: `Guide "${name}" is a system guide and cannot be modified.` };
}
const filepath = path.join(GUIDES_DIR, filename);
if (!fs.existsSync(filepath)) {
const userCount = listGuides().filter(g => !g.immutable).length;
if (userCount >= MAX_USER_GUIDES) {
return { success: false, error: `Maximum of ${MAX_USER_GUIDES} custom guides reached. Delete a guide first.` };
}
}
ensureGuidesDir();
fs.writeFileSync(filepath, content, 'utf-8');
return { success: true };
}
/**
* Delete a guide. Rejects deletes of system guides.
*/
function deleteGuide(name) {
init();
const candidates = [`${name}.md`, `${name.toLowerCase()}.md`];
for (const filename of candidates) {
if (isSystemGuide(filename)) {
return { success: false, error: `Guide "${name}" is a system guide and cannot be deleted.` };
}
const filepath = path.join(GUIDES_DIR, filename);
if (fs.existsSync(filepath)) {
fs.unlinkSync(filepath);
return { success: true };
}
}
return { success: false, error: `Guide "${name}" not found.` };
}
module.exports = {
listGuides,
readGuide,
writeGuide,
deleteGuide,
};
+4
View File
@@ -88,6 +88,10 @@ If you want real-time UI updates when nodes are created:
| `rah_create_dimension` | Create a new dimension | | `rah_create_dimension` | Create a new dimension |
| `rah_update_dimension` | Update/rename dimension | | `rah_update_dimension` | Update/rename dimension |
| `rah_delete_dimension` | Delete a dimension | | `rah_delete_dimension` | Delete a dimension |
| `rah_list_guides` | List available guides (system + custom) |
| `rah_read_guide` | Read a guide by name |
| `rah_write_guide` | Create or update a custom guide |
| `rah_delete_guide` | Delete a custom guide |
--- ---
+14
View File
@@ -0,0 +1,14 @@
---
name: Preferences
description: Your communication style, workflow rules, and current priorities.
---
# Preferences
Add your preferences here. The agent will read this guide to understand how you like to work.
## Examples of what to put here
- Communication style (bullet points vs prose, level of detail)
- Workflow rules (always ask before creating dimensions, etc.)
- Current priorities (what you're focused on this week)
- Timezone and locale
@@ -0,0 +1,38 @@
---
name: Creating Nodes
description: When and how to create nodes. Link field rules. Synthesis patterns.
immutable: true
---
# Creating Nodes
## When to Create
- User explicitly asks to save/capture something
- Extracting insights from existing content (synthesis)
- Ingesting external content (YouTube, website, PDF)
## Link Field Rules
- **Has link:** Node directly represents external content (YouTube video, website, PDF, article)
- **No link:** Node is derived/synthesized from existing content (ideas, insights, summaries, questions)
- Never add a link to synthesis or idea nodes
## Synthesis Pattern
When creating a node derived from existing content:
1. Create the node WITHOUT a link field
2. Call `createEdge` to connect it to ALL source nodes
3. Each edge needs an explanation ("Insight extracted from...", "Synthesized from...")
## Dimension Assignment
- New nodes should be assigned to relevant existing dimensions
- Check locked/priority dimensions — these auto-assign
- If no existing dimension fits, create a new one (but prefer existing)
## Description Field
- AI auto-generates a ~1 sentence description after creation
- Description is used for embeddings and search ranking (5x boost)
- Format: what is this node about, in plain language
+38
View File
@@ -0,0 +1,38 @@
---
name: Dimensions
description: Create, lock, describe, organize, clean up dimensions.
immutable: true
---
# Dimensions
Dimensions are how nodes are categorized and organized. Think of them as flexible tags with descriptions.
## Operations
- **Create:** `createDimension(name, description, isPriority)`
- **Update:** `updateDimension(name, { newName, description, isPriority })`
- **Delete:** `deleteDimension(name)` — removes from all nodes
- **Query:** Use sqliteQuery to list dimensions and their node counts
## Locking (Priority)
- `isPriority = true` (locked) → dimension auto-assigns to new nodes when relevant
- `isPriority = false` (unlocked) → manual assignment only
- Lock dimensions that represent active areas of focus
## Naming Conventions
- Lowercase, concise (e.g., "ai", "philosophy", "ra-h")
- Use singular form where natural
- Avoid overlapping names (don't have both "ai" and "artificial-intelligence")
## Description
Every dimension should have a description explaining its purpose. This helps the AI correctly assign nodes to dimensions.
## Cleanup
- Delete dimensions with 0 nodes
- Merge overlapping dimensions (update nodes, then delete the redundant one)
- Regularly review dimension list for coherence
+51
View File
@@ -0,0 +1,51 @@
---
name: Edges
description: Edge philosophy. Explanations, direction, types. Connection patterns.
immutable: true
---
# Edges
## Philosophy
Edges are the most valuable part of the knowledge graph. Individual nodes are useful; the web of connections between them is what makes the graph powerful.
## Rules
1. **Every edge needs an explanation** — why does this connection exist? Be specific.
2. **Direction matters** — FROM → TO should read like a sentence
3. **Types are inferred** — the system infers category/type from your explanation. Don't set types manually.
## Direction Convention
Write the explanation so FROM → TO reads naturally:
- Episode → Podcast: "Episode of this podcast"
- Book → Author: "Written by this author"
- Insight → Source: "Extracted from this source"
- Idea → Related idea: "Builds on this concept"
## Edge Context JSON
```json
{
"explanation": "Human-readable reason",
"category": "inferred (created_by, features, part_of, source_of, related_to)",
"type": "inferred specific type",
"confidence": 0.0-1.0,
"created_via": "chat|mcp|workflow"
}
```
## Hub Traversal
Hub nodes (most-connected) are the user's core themes. To understand context around a topic:
1. Find the relevant hub node
2. Use `queryEdge` or sqliteQuery to get its connections
3. Traverse outward to related nodes
## When to Create Edges
- After creating synthesis/idea nodes (connect to sources)
- When user mentions a relationship between topics
- When running the Connect or Integrate guides
- When obvious connections exist that aren't captured
+33
View File
@@ -0,0 +1,33 @@
---
name: Extract
description: Extraction pre-check. When to reuse chunks vs re-extract.
immutable: true
---
# Content Extraction
## Pre-Check (REQUIRED)
Before running any extraction tool, always check the node first:
1. Call `getNodesById` on the target node
2. Check `chunk_status`:
- **'chunked'** → content already extracted. Reuse existing chunks. Do NOT re-extract.
- **'pending'** or missing → safe to extract
- **'failed'** → previous extraction failed, safe to retry
3. Check if embeddings are available (chunk length > 0)
- If available, use `searchContentEmbeddings` instead of re-extracting
## Extraction Tools
- **youtubeExtract** — YouTube videos (requires URL with video ID)
- **websiteExtract** — Web pages (uses Jina.ai for JS-rendered sites)
- **paperExtract** — PDF files (requires direct PDF URL)
## After Extraction
- The extracted content goes into `chunk` (full source)
- AI generates a `description` (grounding summary)
- Embeddings are created automatically
- Assign to relevant dimensions
+92
View File
@@ -0,0 +1,92 @@
---
name: Schema
description: Full database schema, tables, columns, query patterns.
immutable: true
---
# Database Schema
## Tables
### nodes
| Column | Type | Notes |
|--------|------|-------|
| id | INTEGER | Primary key, auto-increment |
| title | TEXT | Required |
| description | TEXT | AI-generated grounding context (~1 sentence) |
| content | TEXT | User's notes/thoughts (not source content) |
| chunk | TEXT | Full verbatim source content |
| chunk_status | TEXT | 'pending', 'chunked', 'failed' |
| link | TEXT | External URL (only for nodes representing external content) |
| type | TEXT | Nullable (reserved for future use) |
| metadata | TEXT | JSON blob (map_position, transcript_length, etc.) |
| is_pinned | INTEGER | Legacy — use hub node queries instead |
| created_at | TEXT | ISO timestamp |
| updated_at | TEXT | ISO timestamp |
### edges
| Column | Type | Notes |
|--------|------|-------|
| id | INTEGER | Primary key |
| from_node_id | INTEGER | FK → nodes.id |
| to_node_id | INTEGER | FK → nodes.id |
| context | TEXT | JSON: `{ explanation, category, type, confidence, created_via }` |
| source | TEXT | 'user', 'ai_similarity', or helper name |
| explanation | TEXT | Human-readable reason for connection |
| created_at | TEXT | ISO timestamp |
### dimensions
| Column | Type | Notes |
|--------|------|-------|
| id | INTEGER | Primary key |
| name | TEXT | Unique, case-insensitive |
| description | TEXT | Purpose description |
| is_locked | INTEGER | 1 = priority dimension (auto-assigns to new nodes) |
### node_dimensions (junction)
| Column | Type |
|--------|------|
| node_id | INTEGER FK → nodes.id |
| dimension_id | INTEGER FK → dimensions.id |
### chunks (for semantic search)
| Column | Type | Notes |
|--------|------|-------|
| id | INTEGER | Primary key |
| node_id | INTEGER | FK → nodes.id |
| chunk_index | INTEGER | Position in sequence |
| text | TEXT | Chunk content |
| embedding | BLOB | Vector (via sqlite-vec) |
### FTS Tables
- `chunks_fts` — full-text search on chunk text
- `nodes_fts` — full-text search on node title + content
## Common Query Patterns
**Top connected nodes (hubs):**
```sql
SELECT n.id, n.title, n.description, COUNT(DISTINCT e.id) AS edge_count
FROM nodes n
LEFT JOIN edges e ON (e.from_node_id = n.id OR e.to_node_id = n.id)
GROUP BY n.id ORDER BY edge_count DESC LIMIT 5
```
**Nodes in a dimension:**
```sql
SELECT n.* FROM nodes n
JOIN node_dimensions nd ON n.id = nd.node_id
JOIN dimensions d ON nd.dimension_id = d.id
WHERE d.name = ?
```
**Edges for a node (both directions):**
```sql
SELECT e.*, n1.title as from_title, n2.title as to_title
FROM edges e
JOIN nodes n1 ON e.from_node_id = n1.id
JOIN nodes n2 ON e.to_node_id = n2.id
WHERE e.from_node_id = ? OR e.to_node_id = ?
```
**Use sqliteQuery for any read operation not covered by structured tools.**
+115 -12
View File
@@ -6,64 +6,110 @@ import matter from 'gray-matter';
export interface GuideMeta { export interface GuideMeta {
name: string; name: string;
description: string; description: string;
immutable: boolean;
} }
export interface Guide extends GuideMeta { export interface Guide extends GuideMeta {
content: string; content: string;
} }
const MAX_USER_GUIDES = 10;
const GUIDES_DIR = path.join( const GUIDES_DIR = path.join(
os.homedir(), os.homedir(),
'Library/Application Support/RA-H/guides' 'Library/Application Support/RA-H/guides'
); );
const BUNDLED_GUIDES_DIR = path.join( const SYSTEM_GUIDES_DIR = path.join(
process.cwd(),
'src/config/guides/system'
);
const USER_GUIDES_DIR = path.join(
process.cwd(), process.cwd(),
'src/config/guides' 'src/config/guides'
); );
// System guide names (immutable, always re-seeded)
const SYSTEM_GUIDE_NAMES = new Set([
'schema',
'creating-nodes',
'edges',
'dimensions',
'extract',
]);
function ensureGuidesDir(): void { function ensureGuidesDir(): void {
if (!fs.existsSync(GUIDES_DIR)) { if (!fs.existsSync(GUIDES_DIR)) {
fs.mkdirSync(GUIDES_DIR, { recursive: true }); fs.mkdirSync(GUIDES_DIR, { recursive: true });
} }
} }
function seedDefaultGuides(): void { /**
if (!fs.existsSync(BUNDLED_GUIDES_DIR)) return; * Seed system guides — always overwritten on app start to stay current.
* User guides are only seeded if they don't already exist.
*/
function seedGuides(): void {
// Always re-seed system guides (immutable, kept up to date)
if (fs.existsSync(SYSTEM_GUIDES_DIR)) {
const systemFiles = fs.readdirSync(SYSTEM_GUIDES_DIR).filter(f => f.endsWith('.md'));
for (const file of systemFiles) {
const dest = path.join(GUIDES_DIR, file);
fs.copyFileSync(path.join(SYSTEM_GUIDES_DIR, file), dest);
}
}
const bundled = fs.readdirSync(BUNDLED_GUIDES_DIR).filter(f => f.endsWith('.md')); // Seed default user guides only if they don't exist
for (const file of bundled) { if (fs.existsSync(USER_GUIDES_DIR)) {
const userFiles = fs.readdirSync(USER_GUIDES_DIR).filter(f => f.endsWith('.md'));
for (const file of userFiles) {
const dest = path.join(GUIDES_DIR, file); const dest = path.join(GUIDES_DIR, file);
if (!fs.existsSync(dest)) { if (!fs.existsSync(dest)) {
fs.copyFileSync(path.join(BUNDLED_GUIDES_DIR, file), dest); fs.copyFileSync(path.join(USER_GUIDES_DIR, file), dest);
}
} }
} }
} }
let initialized = false;
function init(): void { function init(): void {
if (initialized) return;
ensureGuidesDir(); ensureGuidesDir();
const existing = fs.readdirSync(GUIDES_DIR).filter(f => f.endsWith('.md')); seedGuides();
if (existing.length === 0) { initialized = true;
seedDefaultGuides();
} }
function isSystemGuide(filename: string): boolean {
const name = filename.replace('.md', '');
return SYSTEM_GUIDE_NAMES.has(name);
} }
export function listGuides(): GuideMeta[] { export function listGuides(): GuideMeta[] {
init(); init();
const files = fs.readdirSync(GUIDES_DIR).filter(f => f.endsWith('.md')); const files = fs.readdirSync(GUIDES_DIR).filter(f => f.endsWith('.md'));
return files.map(file => {
const guides = files.map(file => {
const raw = fs.readFileSync(path.join(GUIDES_DIR, file), 'utf-8'); const raw = fs.readFileSync(path.join(GUIDES_DIR, file), 'utf-8');
const { data } = matter(raw); const { data } = matter(raw);
const immutable = isSystemGuide(file) || data.immutable === true;
return { return {
name: data.name || file.replace('.md', ''), name: data.name || file.replace('.md', ''),
description: data.description || '', description: data.description || '',
immutable,
}; };
}); });
// System guides first, then user guides alphabetically
return guides.sort((a, b) => {
if (a.immutable && !b.immutable) return -1;
if (!a.immutable && b.immutable) return 1;
return a.name.localeCompare(b.name);
});
} }
export function readGuide(name: string): Guide | null { export function readGuide(name: string): Guide | null {
init(); init();
// Try exact filename first, then lowercase
const candidates = [ const candidates = [
`${name}.md`, `${name}.md`,
`${name.toLowerCase()}.md`, `${name.toLowerCase()}.md`,
@@ -74,9 +120,11 @@ export function readGuide(name: string): Guide | null {
if (fs.existsSync(filepath)) { if (fs.existsSync(filepath)) {
const raw = fs.readFileSync(filepath, 'utf-8'); const raw = fs.readFileSync(filepath, 'utf-8');
const { data, content } = matter(raw); const { data, content } = matter(raw);
const immutable = isSystemGuide(filename) || data.immutable === true;
return { return {
name: data.name || name, name: data.name || name,
description: data.description || '', description: data.description || '',
immutable,
content: content.trim(), content: content.trim(),
}; };
} }
@@ -85,9 +133,64 @@ export function readGuide(name: string): Guide | null {
return null; return null;
} }
export function writeGuide(name: string, content: string): void { export function writeGuide(name: string, content: string): { success: boolean; error?: string } {
init(); init();
const filename = `${name.toLowerCase()}.md`; const filename = `${name.toLowerCase()}.md`;
// Reject writes to immutable guides
if (isSystemGuide(filename)) {
return { success: false, error: `Guide "${name}" is a system guide and cannot be modified.` };
}
// Check user guide cap for new guides
const filepath = path.join(GUIDES_DIR, filename); const filepath = path.join(GUIDES_DIR, filename);
if (!fs.existsSync(filepath)) {
const userGuideCount = getUserGuideCount();
if (userGuideCount >= MAX_USER_GUIDES) {
return { success: false, error: `Maximum of ${MAX_USER_GUIDES} custom guides reached. Delete a guide first.` };
}
}
fs.writeFileSync(filepath, content, 'utf-8'); fs.writeFileSync(filepath, content, 'utf-8');
return { success: true };
}
export function deleteGuide(name: string): { success: boolean; error?: string } {
init();
const candidates = [
`${name}.md`,
`${name.toLowerCase()}.md`,
];
for (const filename of candidates) {
// Reject deletes of immutable guides
if (isSystemGuide(filename)) {
return { success: false, error: `Guide "${name}" is a system guide and cannot be deleted.` };
}
const filepath = path.join(GUIDES_DIR, filename);
if (fs.existsSync(filepath)) {
fs.unlinkSync(filepath);
return { success: true };
}
}
return { success: false, error: `Guide "${name}" not found.` };
}
export function getUserGuideCount(): number {
init();
const files = fs.readdirSync(GUIDES_DIR).filter(f => f.endsWith('.md'));
return files.filter(f => !isSystemGuide(f)).length;
}
export function getGuideStats(): { userGuides: number; maxUserGuides: number; systemGuides: number } {
const guides = listGuides();
const systemCount = guides.filter(g => g.immutable).length;
const userCount = guides.filter(g => !g.immutable).length;
return {
userGuides: userCount,
maxUserGuides: MAX_USER_GUIDES,
systemGuides: systemCount,
};
} }