716 lines
23 KiB
JavaScript
716 lines
23 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');
|
||
const supportedProfiles = new Set(['openai', 'qwen-local', 'llama-cpp']);
|
||
|
||
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 ensureEnvValue(key, value) {
|
||
if (!value) return;
|
||
|
||
const lines = fs.existsSync(targetEnv)
|
||
? fs.readFileSync(targetEnv, 'utf8').split(/\r?\n/)
|
||
: [];
|
||
let found = false;
|
||
|
||
const nextLines = lines.map((line) => {
|
||
const trimmed = line.trim();
|
||
if (trimmed.startsWith(`${key}=`)) {
|
||
if (found) return line;
|
||
found = true;
|
||
return `${key}=${value}`;
|
||
}
|
||
if (trimmed.startsWith(`# ${key}=`) || trimmed.startsWith(`#${key}=`)) {
|
||
if (found) return line;
|
||
found = true;
|
||
return `${key}=${value}`;
|
||
}
|
||
return line;
|
||
});
|
||
|
||
if (!found) {
|
||
if (nextLines.length > 0 && nextLines[nextLines.length - 1] !== '') {
|
||
nextLines.push('');
|
||
}
|
||
nextLines.push(`${key}=${value}`);
|
||
}
|
||
|
||
fs.writeFileSync(targetEnv, `${nextLines.join('\n').replace(/\n+$/, '')}\n`);
|
||
log(`Set ${key} in .env.local`);
|
||
}
|
||
|
||
function parseArgs(argv) {
|
||
const args = { profile: undefined };
|
||
for (let index = 0; index < argv.length; index += 1) {
|
||
const arg = argv[index];
|
||
if (arg === '--profile') {
|
||
args.profile = argv[index + 1];
|
||
index += 1;
|
||
} else if (arg.startsWith('--profile=')) {
|
||
args.profile = arg.slice('--profile='.length);
|
||
}
|
||
}
|
||
return args;
|
||
}
|
||
|
||
function normalizeSetupProfile(rawProfile) {
|
||
if (!rawProfile) return undefined;
|
||
if (rawProfile === 'qwen' || rawProfile === 'local' || rawProfile === 'ollama') {
|
||
return 'qwen-local';
|
||
}
|
||
if (rawProfile === 'llama' || rawProfile === 'llamacpp' || rawProfile === 'cpp') {
|
||
return 'llama-cpp';
|
||
}
|
||
return rawProfile;
|
||
}
|
||
|
||
function applySetupProfile(profile) {
|
||
if (!profile) return;
|
||
if (!supportedProfiles.has(profile)) {
|
||
throw new Error(`Unsupported setup profile "${profile}". Use "openai", "qwen-local", or "llama-cpp".`);
|
||
}
|
||
|
||
if (profile === 'openai') {
|
||
ensureEnvValue('LLM_PROFILE', 'openai');
|
||
ensureEnvValue('EMBEDDING_PROFILE', 'openai');
|
||
ensureEnvValue('EMBEDDING_MODEL', 'text-embedding-3-small');
|
||
ensureEnvValue('EMBEDDING_DIMENSIONS', '1536');
|
||
ensureEnvValue('VECTOR_BACKEND', 'sqlite-vec');
|
||
return;
|
||
}
|
||
|
||
if (profile === 'qwen-local') {
|
||
ensureEnvValue('LLM_PROFILE', 'openai-compatible');
|
||
ensureEnvValue('LLM_BASE_URL', 'http://127.0.0.1:11434/v1');
|
||
ensureEnvValue('LLM_MODEL', 'qwen3:4b');
|
||
ensureEnvValue('EMBEDDING_PROFILE', 'openai-compatible');
|
||
ensureEnvValue('EMBEDDING_BASE_URL', 'http://127.0.0.1:11434/v1');
|
||
ensureEnvValue('EMBEDDING_MODEL', 'qwen3-embedding:0.6b');
|
||
ensureEnvValue('EMBEDDING_DIMENSIONS', '1024');
|
||
ensureEnvValue('VECTOR_BACKEND', 'sqlite-vec');
|
||
return;
|
||
}
|
||
|
||
ensureEnvValue('LLM_PROFILE', 'openai-compatible');
|
||
ensureEnvValue('LLM_BASE_URL', 'http://127.0.0.1:8080/v1');
|
||
ensureEnvValue('LLM_MODEL', 'qwen3-4b');
|
||
ensureEnvValue('EMBEDDING_PROFILE', 'openai-compatible');
|
||
ensureEnvValue('EMBEDDING_BASE_URL', 'http://127.0.0.1:8081/v1');
|
||
ensureEnvValue('EMBEDDING_MODEL', 'qwen3-embedding-0.6b');
|
||
ensureEnvValue('EMBEDDING_DIMENSIONS', '1024');
|
||
ensureEnvValue('VECTOR_BACKEND', 'sqlite-vec');
|
||
}
|
||
|
||
function assertEmbeddingProfileSelected(env) {
|
||
if (env.EMBEDDING_PROFILE) {
|
||
return;
|
||
}
|
||
|
||
throw new Error([
|
||
'Choose an embedding profile before database setup.',
|
||
'',
|
||
'The selected embedding model controls sqlite-vec table dimensions:',
|
||
' OpenAI text-embedding-3-small -> 1536',
|
||
' Local Qwen qwen3-embedding:0.6b -> 1024',
|
||
'',
|
||
'Run one of:',
|
||
' npm run setup:local -- --profile openai',
|
||
' npm run setup:local -- --profile qwen-local',
|
||
' npm run setup:local -- --profile llama-cpp',
|
||
'',
|
||
'If you change embedding provider later, run:',
|
||
' npm run rebuild:embeddings',
|
||
].join('\n'));
|
||
}
|
||
|
||
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,
|
||
explanation TEXT,
|
||
FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE,
|
||
FOREIGN KEY (to_node_id) REFERENCES nodes(id) ON DELETE CASCADE
|
||
);
|
||
|
||
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 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 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 edgeCols = db.prepare('PRAGMA table_info(edges)').all().map(col => col.name);
|
||
const hasEdgeCol = (name) => edgeCols.includes(name);
|
||
const needsLegacyEdgeRewrite =
|
||
!hasEdgeCol('from_node_id') ||
|
||
!hasEdgeCol('to_node_id') ||
|
||
!hasEdgeCol('source') ||
|
||
!hasEdgeCol('created_at') ||
|
||
!hasEdgeCol('context') ||
|
||
hasEdgeCol('from_id') ||
|
||
hasEdgeCol('to_id') ||
|
||
hasEdgeCol('description') ||
|
||
hasEdgeCol('updated_at');
|
||
|
||
if (needsLegacyEdgeRewrite) {
|
||
const fromExpr = hasEdgeCol('from_node_id')
|
||
? 'from_node_id'
|
||
: hasEdgeCol('from_id')
|
||
? 'from_id'
|
||
: 'NULL';
|
||
const toExpr = hasEdgeCol('to_node_id')
|
||
? 'to_node_id'
|
||
: hasEdgeCol('to_id')
|
||
? 'to_id'
|
||
: 'NULL';
|
||
const sourceExpr = hasEdgeCol('source') ? 'source' : "'legacy'";
|
||
const createdAtExpr = hasEdgeCol('created_at') ? 'created_at' : 'CURRENT_TIMESTAMP';
|
||
const contextExpr = hasEdgeCol('context') ? 'context' : 'NULL';
|
||
const explanationExpr = hasEdgeCol('explanation')
|
||
? 'explanation'
|
||
: hasEdgeCol('description')
|
||
? 'description'
|
||
: hasEdgeCol('context')
|
||
? "CASE WHEN json_valid(context) THEN json_extract(context, '$.explanation') ELSE NULL END"
|
||
: 'NULL';
|
||
|
||
console.log('Migrating legacy edges table to canonical schema');
|
||
db.exec('PRAGMA foreign_keys=OFF;');
|
||
db.exec(`
|
||
BEGIN TRANSACTION;
|
||
DROP INDEX IF EXISTS idx_edges_from;
|
||
DROP INDEX IF EXISTS idx_edges_to;
|
||
ALTER TABLE edges RENAME TO edges_legacy_migration;
|
||
CREATE TABLE edges (
|
||
id INTEGER PRIMARY KEY,
|
||
from_node_id INTEGER NOT NULL,
|
||
to_node_id INTEGER NOT NULL,
|
||
source TEXT,
|
||
created_at TEXT,
|
||
context TEXT,
|
||
explanation TEXT,
|
||
FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE,
|
||
FOREIGN KEY (to_node_id) REFERENCES nodes(id) ON DELETE CASCADE
|
||
);
|
||
INSERT INTO edges (id, from_node_id, to_node_id, source, created_at, context, explanation)
|
||
SELECT
|
||
id,
|
||
${fromExpr},
|
||
${toExpr},
|
||
${sourceExpr},
|
||
COALESCE(${createdAtExpr}, CURRENT_TIMESTAMP),
|
||
${contextExpr},
|
||
${explanationExpr}
|
||
FROM edges_legacy_migration
|
||
WHERE ${fromExpr} IS NOT NULL
|
||
AND ${toExpr} IS NOT NULL;
|
||
DROP TABLE edges_legacy_migration;
|
||
COMMIT;
|
||
`);
|
||
db.exec('PRAGMA foreign_keys=ON;');
|
||
}
|
||
|
||
const refreshedEdgeCols = db.prepare('PRAGMA table_info(edges)').all().map(col => col.name);
|
||
if (!refreshedEdgeCols.includes('explanation')) {
|
||
db.exec('ALTER TABLE edges ADD COLUMN explanation TEXT;');
|
||
db.exec(`
|
||
UPDATE edges
|
||
SET explanation = CASE
|
||
WHEN json_valid(context) THEN json_extract(context, '$.explanation')
|
||
ELSE explanation
|
||
END
|
||
WHERE explanation IS NULL
|
||
AND context IS NOT NULL;
|
||
`);
|
||
}
|
||
db.exec(`
|
||
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);
|
||
`);
|
||
|
||
const chatCols = db.prepare('PRAGMA table_info(chats)').all().map(col => col.name);
|
||
const hasChatCol = (name) => chatCols.includes(name);
|
||
const needsLegacyChatRewrite =
|
||
hasChatCol('focused_memory_id') ||
|
||
['chat_type', 'helper_name', 'agent_type', 'delegation_id', 'user_message', 'assistant_message', 'thread_id', 'focused_node_id', 'created_at', 'metadata']
|
||
.some((name) => !hasChatCol(name));
|
||
|
||
if (needsLegacyChatRewrite) {
|
||
const chatTypeExpr = hasChatCol('chat_type') ? 'chat_type' : 'NULL';
|
||
const helperNameExpr = hasChatCol('helper_name')
|
||
? 'helper_name'
|
||
: hasChatCol('title')
|
||
? 'title'
|
||
: 'NULL';
|
||
const agentTypeExpr = hasChatCol('agent_type')
|
||
? "COALESCE(agent_type, 'orchestrator')"
|
||
: "'orchestrator'";
|
||
const delegationIdExpr = hasChatCol('delegation_id') ? 'delegation_id' : 'NULL';
|
||
const userMessageExpr = hasChatCol('user_message') ? 'user_message' : 'NULL';
|
||
const assistantMessageExpr = hasChatCol('assistant_message') ? 'assistant_message' : 'NULL';
|
||
const threadIdExpr = hasChatCol('thread_id') ? 'thread_id' : 'NULL';
|
||
const focusedNodeIdExpr = hasChatCol('focused_node_id') ? 'focused_node_id' : 'NULL';
|
||
const createdAtChatExpr = hasChatCol('created_at') ? 'created_at' : 'CURRENT_TIMESTAMP';
|
||
const metadataChatExpr = hasChatCol('metadata') ? 'metadata' : 'NULL';
|
||
|
||
console.log('Migrating legacy chats table to canonical schema');
|
||
db.exec('PRAGMA foreign_keys=OFF;');
|
||
db.exec(`
|
||
BEGIN TRANSACTION;
|
||
DROP INDEX IF EXISTS idx_chats_thread;
|
||
ALTER TABLE chats RENAME TO chats_legacy_cleanup;
|
||
CREATE TABLE 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
|
||
);
|
||
INSERT INTO chats (
|
||
id, chat_type, helper_name, agent_type, delegation_id,
|
||
user_message, assistant_message, thread_id, focused_node_id,
|
||
created_at, metadata
|
||
)
|
||
SELECT
|
||
id,
|
||
${chatTypeExpr},
|
||
${helperNameExpr},
|
||
${agentTypeExpr},
|
||
${delegationIdExpr},
|
||
${userMessageExpr},
|
||
${assistantMessageExpr},
|
||
${threadIdExpr},
|
||
${focusedNodeIdExpr},
|
||
COALESCE(${createdAtChatExpr}, CURRENT_TIMESTAMP),
|
||
${metadataChatExpr}
|
||
FROM chats_legacy_cleanup;
|
||
DROP TABLE chats_legacy_cleanup;
|
||
COMMIT;
|
||
`);
|
||
db.exec('PRAGMA foreign_keys=ON;');
|
||
}
|
||
db.exec("CREATE INDEX IF NOT EXISTS idx_chats_thread ON chats(thread_id);");
|
||
|
||
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('chunk_status')) {
|
||
db.exec("ALTER TABLE nodes ADD COLUMN chunk_status TEXT DEFAULT 'not_chunked';");
|
||
}
|
||
|
||
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(`
|
||
CREATE TABLE IF NOT EXISTS dimension_migration_snapshots (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
migrated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
dimension_count INTEGER NOT NULL,
|
||
assignment_count INTEGER NOT NULL,
|
||
payload TEXT
|
||
);
|
||
`);
|
||
|
||
const hasLegacyDimensions = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='dimensions'").get();
|
||
const hasLegacyNodeDimensions = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='node_dimensions'").get();
|
||
if (hasLegacyDimensions || hasLegacyNodeDimensions) {
|
||
const existingSnapshotCount = Number(db.prepare('SELECT COUNT(*) as count FROM dimension_migration_snapshots').get().count || 0);
|
||
if (existingSnapshotCount === 0) {
|
||
const dimensionCount = hasLegacyDimensions
|
||
? Number(db.prepare('SELECT COUNT(*) as count FROM dimensions').get().count || 0)
|
||
: 0;
|
||
const assignmentCount = hasLegacyNodeDimensions
|
||
? Number(db.prepare('SELECT COUNT(*) as count FROM node_dimensions').get().count || 0)
|
||
: 0;
|
||
const payload = hasLegacyNodeDimensions
|
||
? (db.prepare(`
|
||
SELECT COALESCE(
|
||
json_group_array(
|
||
json_object(
|
||
'node_id', nd.node_id,
|
||
'dimension', nd.dimension,
|
||
'description', d.description,
|
||
'icon', d.icon,
|
||
'is_priority', d.is_priority
|
||
)
|
||
),
|
||
'[]'
|
||
) AS payload
|
||
FROM node_dimensions nd
|
||
LEFT JOIN dimensions d ON d.name = nd.dimension
|
||
`).get().payload || '[]')
|
||
: '[]';
|
||
|
||
db.prepare(`
|
||
INSERT INTO dimension_migration_snapshots (dimension_count, assignment_count, payload)
|
||
VALUES (?, ?, ?)
|
||
`).run(dimensionCount, assignmentCount, payload);
|
||
}
|
||
|
||
db.exec(`
|
||
DROP INDEX IF EXISTS idx_dim_by_dimension;
|
||
DROP INDEX IF EXISTS idx_dim_by_node;
|
||
DROP TABLE IF EXISTS node_dimensions;
|
||
DROP TABLE IF EXISTS dimensions;
|
||
`);
|
||
}
|
||
|
||
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
|
||
FROM nodes n;
|
||
`);
|
||
}
|
||
|
||
function getEmbeddingDimensions(env) {
|
||
const defaultDimensions = env.EMBEDDING_PROFILE === 'openai-compatible' || env.EMBEDDING_PROFILE === 'custom'
|
||
? '1024'
|
||
: '1536';
|
||
return Number(env.EMBEDDING_DIMENSIONS || defaultDimensions);
|
||
}
|
||
|
||
function tryInitVectorTables(db, dbPath, env) {
|
||
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}`);
|
||
const dimensions = getEmbeddingDimensions(env);
|
||
if (!Number.isInteger(dimensions) || dimensions <= 0) {
|
||
throw new Error(`Invalid EMBEDDING_DIMENSIONS="${env.EMBEDDING_DIMENSIONS}"`);
|
||
}
|
||
|
||
try {
|
||
db.loadExtension(extensionPath);
|
||
db.exec(`
|
||
CREATE VIRTUAL TABLE IF NOT EXISTS vec_nodes USING vec0(
|
||
node_id INTEGER PRIMARY KEY,
|
||
embedding FLOAT[${dimensions}]
|
||
);
|
||
CREATE VIRTUAL TABLE IF NOT EXISTS vec_chunks USING vec0(
|
||
chunk_id INTEGER PRIMARY KEY,
|
||
embedding FLOAT[${dimensions}]
|
||
);
|
||
`);
|
||
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})`);
|
||
}
|
||
|
||
const args = parseArgs(process.argv.slice(2));
|
||
const setupProfile = normalizeSetupProfile(args.profile);
|
||
|
||
ensureEnvFile();
|
||
applySetupProfile(setupProfile);
|
||
|
||
const env = { ...parseEnvFile(targetEnv), ...process.env };
|
||
assertEmbeddingProfileSelected(env);
|
||
if (process.env.SQLITE_DB_PATH) {
|
||
ensureEnvValue('SQLITE_DB_PATH', process.env.SQLITE_DB_PATH);
|
||
env.SQLITE_DB_PATH = process.env.SQLITE_DB_PATH;
|
||
}
|
||
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, env);
|
||
} 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);
|
||
}
|