Files
ra-h-os/scripts/dev/bootstrap-local.mjs
T

392 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 35 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);
}