diff --git a/README.md b/README.md index afba119..dee1a99 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,10 @@ Available tools: | `rah_create_dimension` | Create a new dimension | | `rah_update_dimension` | Update/rename 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:** - "What's in my knowledge graph?" diff --git a/apps/mcp-server-standalone/README.md b/apps/mcp-server-standalone/README.md index dac0e60..adcaa1f 100644 --- a/apps/mcp-server-standalone/README.md +++ b/apps/mcp-server-standalone/README.md @@ -61,6 +61,16 @@ Once connected, Claude will: | `rah_create_dimension` | Create a dimension | | `rah_update_dimension` | Update/rename 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 diff --git a/apps/mcp-server-standalone/guides/preferences.md b/apps/mcp-server-standalone/guides/preferences.md new file mode 100644 index 0000000..9a38553 --- /dev/null +++ b/apps/mcp-server-standalone/guides/preferences.md @@ -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 diff --git a/apps/mcp-server-standalone/guides/system/creating-nodes.md b/apps/mcp-server-standalone/guides/system/creating-nodes.md new file mode 100644 index 0000000..b7beb0b --- /dev/null +++ b/apps/mcp-server-standalone/guides/system/creating-nodes.md @@ -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 diff --git a/apps/mcp-server-standalone/guides/system/dimensions.md b/apps/mcp-server-standalone/guides/system/dimensions.md new file mode 100644 index 0000000..8a38121 --- /dev/null +++ b/apps/mcp-server-standalone/guides/system/dimensions.md @@ -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 diff --git a/apps/mcp-server-standalone/guides/system/edges.md b/apps/mcp-server-standalone/guides/system/edges.md new file mode 100644 index 0000000..3176398 --- /dev/null +++ b/apps/mcp-server-standalone/guides/system/edges.md @@ -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 diff --git a/apps/mcp-server-standalone/guides/system/extract.md b/apps/mcp-server-standalone/guides/system/extract.md new file mode 100644 index 0000000..6e2e519 --- /dev/null +++ b/apps/mcp-server-standalone/guides/system/extract.md @@ -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 diff --git a/apps/mcp-server-standalone/guides/system/schema.md b/apps/mcp-server-standalone/guides/system/schema.md new file mode 100644 index 0000000..5c0fa80 --- /dev/null +++ b/apps/mcp-server-standalone/guides/system/schema.md @@ -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.** diff --git a/apps/mcp-server-standalone/index.js b/apps/mcp-server-standalone/index.js old mode 100644 new mode 100755 index 1b2376e..542f1e8 --- a/apps/mcp-server-standalone/index.js +++ b/apps/mcp-server-standalone/index.js @@ -9,11 +9,12 @@ const { initDatabase, getDatabasePath, closeDatabase } = require('./services/sql const nodeService = require('./services/nodeService'); const edgeService = require('./services/edgeService'); const dimensionService = require('./services/dimensionService'); +const guideService = require('./services/guideService'); // Server info const serverInfo = { name: 'ra-h-standalone', - version: '1.1.0' + version: '1.2.0' }; const instructions = [ @@ -22,6 +23,7 @@ const instructions = [ 'Proactively identify valuable information in conversations and offer to save it.', 'Search before creating to avoid duplicates.', '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.' ].join(' '); @@ -92,6 +94,19 @@ const deleteDimensionInputSchema = { 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 function sanitizeDimensions(raw) { if (!Array.isArray(raw)) return []; @@ -138,6 +153,8 @@ async function main() { }, async () => { 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 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 { content: [{ type: 'text', text: summary }], 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 const transport = new StdioServerTransport(); await server.connect(transport); diff --git a/apps/mcp-server-standalone/package.json b/apps/mcp-server-standalone/package.json index 38d4845..bdb59c2 100644 --- a/apps/mcp-server-standalone/package.json +++ b/apps/mcp-server-standalone/package.json @@ -1,6 +1,6 @@ { "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.", "main": "index.js", "bin": { @@ -43,6 +43,7 @@ "files": [ "index.js", "services/", + "guides/", "README.md" ] } diff --git a/apps/mcp-server-standalone/services/guideService.js b/apps/mcp-server-standalone/services/guideService.js new file mode 100644 index 0000000..330fe6b --- /dev/null +++ b/apps/mcp-server-standalone/services/guideService.js @@ -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, +}; diff --git a/docs/8_mcp.md b/docs/8_mcp.md index c924b33..f8bd987 100644 --- a/docs/8_mcp.md +++ b/docs/8_mcp.md @@ -88,6 +88,10 @@ If you want real-time UI updates when nodes are created: | `rah_create_dimension` | Create a new dimension | | `rah_update_dimension` | Update/rename 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 | --- diff --git a/src/config/guides/preferences.md b/src/config/guides/preferences.md new file mode 100644 index 0000000..9a38553 --- /dev/null +++ b/src/config/guides/preferences.md @@ -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 diff --git a/src/config/guides/system/creating-nodes.md b/src/config/guides/system/creating-nodes.md new file mode 100644 index 0000000..b7beb0b --- /dev/null +++ b/src/config/guides/system/creating-nodes.md @@ -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 diff --git a/src/config/guides/system/dimensions.md b/src/config/guides/system/dimensions.md new file mode 100644 index 0000000..8a38121 --- /dev/null +++ b/src/config/guides/system/dimensions.md @@ -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 diff --git a/src/config/guides/system/edges.md b/src/config/guides/system/edges.md new file mode 100644 index 0000000..3176398 --- /dev/null +++ b/src/config/guides/system/edges.md @@ -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 diff --git a/src/config/guides/system/extract.md b/src/config/guides/system/extract.md new file mode 100644 index 0000000..6e2e519 --- /dev/null +++ b/src/config/guides/system/extract.md @@ -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 diff --git a/src/config/guides/system/schema.md b/src/config/guides/system/schema.md new file mode 100644 index 0000000..5c0fa80 --- /dev/null +++ b/src/config/guides/system/schema.md @@ -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.** diff --git a/src/services/guides/guideService.ts b/src/services/guides/guideService.ts index 6a637ca..53b46dc 100644 --- a/src/services/guides/guideService.ts +++ b/src/services/guides/guideService.ts @@ -6,64 +6,110 @@ import matter from 'gray-matter'; export interface GuideMeta { name: string; description: string; + immutable: boolean; } export interface Guide extends GuideMeta { content: string; } +const MAX_USER_GUIDES = 10; + const GUIDES_DIR = path.join( os.homedir(), '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(), '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 { if (!fs.existsSync(GUIDES_DIR)) { 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')); - for (const file of bundled) { - const dest = path.join(GUIDES_DIR, file); - if (!fs.existsSync(dest)) { - fs.copyFileSync(path.join(BUNDLED_GUIDES_DIR, file), dest); + // Seed default user guides only if they don't exist + 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); + if (!fs.existsSync(dest)) { + fs.copyFileSync(path.join(USER_GUIDES_DIR, file), dest); + } } } } +let initialized = false; + function init(): void { + if (initialized) return; ensureGuidesDir(); - const existing = fs.readdirSync(GUIDES_DIR).filter(f => f.endsWith('.md')); - if (existing.length === 0) { - seedDefaultGuides(); - } + seedGuides(); + initialized = true; +} + +function isSystemGuide(filename: string): boolean { + const name = filename.replace('.md', ''); + return SYSTEM_GUIDE_NAMES.has(name); } export function listGuides(): GuideMeta[] { init(); 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 { data } = matter(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); + }); } export function readGuide(name: string): Guide | null { init(); - // Try exact filename first, then lowercase const candidates = [ `${name}.md`, `${name.toLowerCase()}.md`, @@ -74,9 +120,11 @@ export function readGuide(name: string): Guide | null { if (fs.existsSync(filepath)) { const raw = fs.readFileSync(filepath, 'utf-8'); const { data, content } = matter(raw); + const immutable = isSystemGuide(filename) || data.immutable === true; return { name: data.name || name, description: data.description || '', + immutable, content: content.trim(), }; } @@ -85,9 +133,64 @@ export function readGuide(name: string): Guide | null { return null; } -export function writeGuide(name: string, content: string): void { +export function writeGuide(name: string, content: string): { success: boolean; error?: string } { init(); 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); + 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'); + 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, + }; }