#!/usr/bin/env node import fs from 'fs'; import os from 'os'; import path from 'path'; import Database from 'better-sqlite3'; const repoDir = process.cwd(); const envTemplate = path.join(repoDir, '.env.example'); const targetEnv = path.join(repoDir, '.env.local'); function log(message) { console.log(`[bootstrap-local] ${message}`); } function getDefaultDbPath() { const homeDir = os.homedir(); if (process.platform === 'win32') { const appData = process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'); return path.join(appData, 'RA-H', 'db', 'rah.sqlite'); } if (process.platform === 'darwin') { return path.join(homeDir, 'Library', 'Application Support', 'RA-H', 'db', 'rah.sqlite'); } return path.join( process.env.XDG_DATA_HOME || path.join(homeDir, '.local', 'share'), 'RA-H', 'db', 'rah.sqlite' ); } function parseEnvFile(filePath) { if (!fs.existsSync(filePath)) return {}; return fs.readFileSync(filePath, 'utf8') .split(/\r?\n/) .filter(Boolean) .reduce((acc, line) => { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) return acc; const eqIndex = trimmed.indexOf('='); if (eqIndex === -1) return acc; const key = trimmed.slice(0, eqIndex).trim(); const value = trimmed.slice(eqIndex + 1).trim(); acc[key] = value; return acc; }, {}); } function expandPath(rawPath) { let value = rawPath; if (value.startsWith('~')) { value = path.join(os.homedir(), value.slice(1)); } value = value.replace(/\$HOME/g, os.homedir()); value = value.replace(/%APPDATA%/g, process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming')); return path.resolve(value); } function ensureEnvFile() { if (!fs.existsSync(envTemplate)) { throw new Error(`Missing ${envTemplate}`); } if (fs.existsSync(targetEnv)) { log('.env.local already exists; leaving it untouched.'); return; } fs.copyFileSync(envTemplate, targetEnv); log('Created .env.local from .env.example'); } function ensureCoreSchema(db) { db.pragma('foreign_keys = ON'); db.exec(` CREATE TABLE IF NOT EXISTS agents ( id INTEGER PRIMARY KEY AUTOINCREMENT, key TEXT UNIQUE NOT NULL, display_name TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'executor', system_prompt TEXT NOT NULL, available_tools TEXT NOT NULL, model TEXT NOT NULL, description TEXT, enabled INTEGER DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, memory TEXT, prompts TEXT ); CREATE TABLE IF NOT EXISTS nodes ( id INTEGER PRIMARY KEY, title TEXT, description TEXT, source TEXT, link TEXT, event_date TEXT, created_at TEXT, updated_at TEXT, metadata TEXT, embedding BLOB, embedding_updated_at TEXT, embedding_text TEXT, chunk_status TEXT DEFAULT 'not_chunked' ); CREATE TABLE IF NOT EXISTS chunks ( id INTEGER PRIMARY KEY, node_id INTEGER NOT NULL, chunk_idx INTEGER, text TEXT, created_at TEXT, embedding_type TEXT DEFAULT 'text-embedding-3-small', metadata TEXT, FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_chunks_by_node ON chunks(node_id); CREATE INDEX IF NOT EXISTS idx_chunks_by_node_idx ON chunks(node_id, chunk_idx); CREATE TABLE IF NOT EXISTS edges ( id INTEGER PRIMARY KEY, from_node_id INTEGER NOT NULL, to_node_id INTEGER NOT NULL, source TEXT, created_at TEXT, context TEXT, FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE, FOREIGN KEY (to_node_id) REFERENCES nodes(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_edges_from ON edges(from_node_id); CREATE INDEX IF NOT EXISTS idx_edges_to ON edges(to_node_id); CREATE TABLE IF NOT EXISTS chats ( id INTEGER PRIMARY KEY, chat_type TEXT, helper_name TEXT, agent_type TEXT DEFAULT 'orchestrator', delegation_id INTEGER, user_message TEXT, assistant_message TEXT, thread_id TEXT, focused_node_id INTEGER, created_at TEXT DEFAULT (CURRENT_TIMESTAMP), metadata TEXT, FOREIGN KEY (focused_node_id) REFERENCES nodes(id) ON DELETE SET NULL ); CREATE INDEX IF NOT EXISTS idx_chats_thread ON chats(thread_id); CREATE TABLE IF NOT EXISTS node_dimensions ( node_id INTEGER NOT NULL, dimension TEXT NOT NULL, PRIMARY KEY (node_id, dimension), FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE ) WITHOUT ROWID; CREATE INDEX IF NOT EXISTS idx_dim_by_dimension ON node_dimensions(dimension, node_id); CREATE INDEX IF NOT EXISTS idx_dim_by_node ON node_dimensions(node_id, dimension); CREATE TABLE IF NOT EXISTS dimensions ( name TEXT PRIMARY KEY, description TEXT, icon TEXT, is_priority INTEGER DEFAULT 0, updated_at TEXT DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS logs ( id INTEGER PRIMARY KEY, ts TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), table_name TEXT NOT NULL, action TEXT NOT NULL, row_id INTEGER NOT NULL, summary TEXT, enriched_summary TEXT, snapshot_json TEXT ); CREATE TABLE IF NOT EXISTS voice_usage ( id INTEGER PRIMARY KEY AUTOINCREMENT, chat_id INTEGER, session_id TEXT, helper_name TEXT, request_id TEXT, message_id TEXT, voice TEXT, model TEXT, chars INTEGER, cost_usd REAL, duration_ms INTEGER, text_preview TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE SET NULL ); CREATE INDEX IF NOT EXISTS idx_voice_usage_session ON voice_usage(session_id, created_at); CREATE INDEX IF NOT EXISTS idx_voice_usage_chat ON voice_usage(chat_id); `); db.exec(` CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5( title, source, description, content='nodes', content_rowid='id' ); CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5( text, content='chunks', content_rowid='id' ); `); const now = new Date().toISOString(); const lockedDimensions = ['research', 'ideas', 'projects', 'memory', 'preferences']; const insertDimension = db.prepare(` INSERT INTO dimensions (name, is_priority, updated_at) VALUES (?, 1, ?) ON CONFLICT(name) DO UPDATE SET is_priority = 1, updated_at = excluded.updated_at `); for (const dimension of lockedDimensions) { insertDimension.run(dimension, now); } const agentCount = Number(db.prepare('SELECT COUNT(*) as count FROM agents').get().count || 0); if (agentCount === 0) { db.prepare(` INSERT INTO agents ( key, display_name, role, system_prompt, available_tools, model, description, enabled, created_at, updated_at, prompts ) VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?) `).run( 'ra-h', 'ra-h', 'orchestrator', "You are ra-h, the main orchestrator for RA-H. Coordinate work, delegate to mini ra-hs when tasks can be isolated, and keep the conversation focused on the user's goals.", '["queryNodes","createNode","updateNode","createEdge","queryEdge","updateEdge","searchContentEmbeddings","webSearch","think","delegateToMiniRAH"]', 'anthropic/claude-sonnet-4.5', 'Opinionated orchestrator agent', now, now, '[{"id":"p_seed_0","name":"Summary of Focus","content":"Summarize the primary focused node clearly. Include 3–5 key points and cite [NODE:id:\\"title\\"]."},{"id":"p_seed_1","name":"Next Steps","content":"Propose 3 concrete next actions based on the focused nodes with references to [NODE:id:\\"title\\"]."}]' ); } const nodeCols = db.prepare('PRAGMA table_info(nodes)').all().map(col => col.name); const hasNodeCol = (name) => nodeCols.includes(name); if (!hasNodeCol('description')) { db.exec('ALTER TABLE nodes ADD COLUMN description TEXT;'); } if (!hasNodeCol('metadata')) { db.exec('ALTER TABLE nodes ADD COLUMN metadata TEXT;'); } if (!hasNodeCol('source')) { db.exec('ALTER TABLE nodes ADD COLUMN source TEXT;'); } if (!hasNodeCol('event_date')) { db.exec('ALTER TABLE nodes ADD COLUMN event_date TEXT;'); } if (hasNodeCol('content')) { db.exec(` UPDATE nodes SET source = content, chunk_status = 'not_chunked' WHERE (source IS NULL OR LENGTH(TRIM(source)) = 0) AND content IS NOT NULL AND LENGTH(TRIM(content)) > 0; `); } if (hasNodeCol('notes')) { db.exec(` UPDATE nodes SET source = notes, chunk_status = 'not_chunked' WHERE (source IS NULL OR LENGTH(TRIM(source)) = 0) AND notes IS NOT NULL AND LENGTH(TRIM(notes)) > 0; `); } if (hasNodeCol('chunk')) { db.exec(` UPDATE nodes SET source = chunk, chunk_status = 'not_chunked' WHERE (source IS NULL OR LENGTH(TRIM(source)) = 0) AND chunk IS NOT NULL AND LENGTH(TRIM(chunk)) > 0; `); } db.exec(` UPDATE nodes SET source = title || CASE WHEN description IS NOT NULL AND LENGTH(TRIM(description)) > 0 THEN char(10) || char(10) || description ELSE '' END, chunk_status = 'not_chunked' WHERE source IS NULL OR LENGTH(TRIM(source)) = 0; `); db.exec(` UPDATE nodes SET chunk_status = 'not_chunked' WHERE source IS NOT NULL AND LENGTH(TRIM(source)) > 0 AND (chunk_status IS NULL OR chunk_status != 'chunked'); `); db.exec('DROP VIEW IF EXISTS nodes_v;'); db.exec(` CREATE VIEW nodes_v AS SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata, n.created_at, n.updated_at, COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension) FROM node_dimensions d WHERE d.node_id = n.id), '[]') AS dimensions_json FROM nodes n; `); } function tryInitVectorTables(db, dbPath) { const extension = process.platform === 'darwin' ? 'dylib' : process.platform === 'win32' ? 'dll' : 'so'; const extensionPath = process.env.SQLITE_VEC_EXTENSION_PATH || path.join(repoDir, 'vendor', 'sqlite-extensions', `vec0.${extension}`); try { db.loadExtension(extensionPath); db.exec(` CREATE VIRTUAL TABLE IF NOT EXISTS vec_nodes USING vec0( node_id INTEGER PRIMARY KEY, embedding FLOAT[1536] ); CREATE VIRTUAL TABLE IF NOT EXISTS vec_chunks USING vec0( chunk_id INTEGER PRIMARY KEY, embedding FLOAT[1536] ); `); log(`Initialized sqlite-vec tables using ${extensionPath}`); } catch (error) { log(`sqlite-vec unavailable for bootstrap (${dbPath}). Continuing without vector tables.`); } } function main() { const major = Number(process.versions.node.split('.')[0] || '0'); if (major < 20) { throw new Error(`Node.js 20+ required (found ${process.version})`); } ensureEnvFile(); const env = parseEnvFile(targetEnv); const dbPath = expandPath(env.SQLITE_DB_PATH || getDefaultDbPath()); fs.mkdirSync(path.dirname(dbPath), { recursive: true }); if (!fs.existsSync(dbPath)) { fs.closeSync(fs.openSync(dbPath, 'w')); } const db = new Database(dbPath); try { ensureCoreSchema(db); tryInitVectorTables(db, dbPath); } finally { db.close(); } log(`Bootstrap complete. Database ready at ${dbPath}`); log("Run 'npm run dev' to start the app."); } try { main(); } catch (error) { console.error(`[bootstrap-local] ${error instanceof Error ? error.message : String(error)}`); process.exit(1); }