392 lines
12 KiB
JavaScript
392 lines
12 KiB
JavaScript
#!/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);
|
||
}
|