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:
co-authored by
Claude Sonnet 4.5
parent
2465e7562c
commit
be675be4e0
@@ -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?"
|
||||||
|
|||||||
@@ -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.**
|
||||||
Regular → Executable
+112
-2
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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.**
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user