- Hybrid BM25 + HNSW vector search with context enrichment - Knowledge graph with entities, relations, and community detection - Host-following LLM route with fallback backends - 3-tier lint system (static + HNSW dup + contradiction detection) - Q&A Synthesis (Karpathy LLM Wiki pattern) - 16 MCP tools for agent-driven workflows - Incremental wiki generation with checkpointing - SQLite-backed item store with FTS5 + vector indexes
284 lines
8.2 KiB
TypeScript
284 lines
8.2 KiB
TypeScript
/**
|
|
* Zero-config setup for HaBraid.
|
|
*
|
|
* On first run, automatically:
|
|
* 1. Creates ~/.habraid/{app,data} directory structure
|
|
* 2. Detects MemPalace installation
|
|
* 3. Initializes database
|
|
* 4. Generates default config
|
|
* 5. Runs initial ingest + index
|
|
*/
|
|
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
|
|
import { openDatabase } from "./db/database.js";
|
|
import { getItemCount } from "./db/items.js";
|
|
import type { WikiEngineConfig } from "./types.js";
|
|
import { ensureDir, expandHomeDir, pathExists, resolvePath } from "./utils.js";
|
|
|
|
/** Base directory for internal HaBraid runtime data. */
|
|
export const HABRAID_HOME = path.join(os.homedir(), ".habraid");
|
|
|
|
/** Hidden app repo path inside the HaBraid home. */
|
|
export const HABRAID_APP_HOME = path.join(HABRAID_HOME, "app");
|
|
|
|
/** Hidden data path inside the HaBraid home. */
|
|
export const HABRAID_DATA_HOME = path.join(HABRAID_HOME, "data");
|
|
|
|
/** Primary user-facing wiki vault path for one-click installs. */
|
|
export const PRIMARY_VAULT_PATH = path.join(os.homedir(), "wiki");
|
|
|
|
/** Well-known MemPalace raw directory search paths. */
|
|
const MEMPALACE_SEARCH_PATHS = [
|
|
// MemPalace MCP standard location
|
|
path.join(os.homedir(), ".local", "share", "mempalace", "raw"),
|
|
// Hermes-integrated MemPalace
|
|
path.join(os.homedir(), "wiki", "raw", "mempalace"),
|
|
// Standalone MemPalace
|
|
path.join(os.homedir(), "mempalace", "raw"),
|
|
// OpenClaw legacy
|
|
path.join(os.homedir(), ".openclaw", "raw"),
|
|
];
|
|
|
|
/**
|
|
* Searches for an existing MemPalace raw directory.
|
|
*
|
|
* @returns Absolute path to raw directory, or empty string if not found.
|
|
*/
|
|
export function detectMemPalacePath(): string {
|
|
for (const candidate of MEMPALACE_SEARCH_PATHS) {
|
|
try {
|
|
if (fs.existsSync(candidate)) {
|
|
const stat = fs.statSync(candidate);
|
|
if (stat.isDirectory()) {
|
|
// Verify it has at least one .md file (directly or nested)
|
|
const hasMd = hasMarkdownFiles(candidate);
|
|
if (hasMd) {
|
|
return candidate;
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
|
|
/**
|
|
* Recursively checks if a directory contains any .md files.
|
|
*/
|
|
function hasMarkdownFiles(dir: string): boolean {
|
|
try {
|
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (entry.name.startsWith(".")) continue;
|
|
if (entry.isFile() && entry.name.endsWith(".md")) return true;
|
|
if (entry.isDirectory()) {
|
|
if (hasMarkdownFiles(path.join(dir, entry.name))) return true;
|
|
}
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Creates the ~/.habraid/ directory structure.
|
|
*/
|
|
async function createDirectoryStructure(vaultPath: string): Promise<void> {
|
|
const dirs = [
|
|
HABRAID_HOME,
|
|
HABRAID_APP_HOME,
|
|
HABRAID_DATA_HOME,
|
|
vaultPath,
|
|
path.join(vaultPath, "raw"),
|
|
path.join(vaultPath, "wiki"),
|
|
path.join(HABRAID_DATA_HOME, "models"),
|
|
path.join(HABRAID_DATA_HOME, "logs"),
|
|
path.join(HABRAID_DATA_HOME, "backups"),
|
|
];
|
|
|
|
for (const dir of dirs) {
|
|
await ensureDir(dir);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates a symlink from vault/raw/ to the detected MemPalace path.
|
|
*
|
|
* @param mempalacePath Detected MemPalace raw directory.
|
|
*/
|
|
function linkMemPalaceRaw(vaultPath: string, mempalacePath: string): void {
|
|
const linkPath = path.join(vaultPath, "raw", "mempalace");
|
|
|
|
// Remove existing link/dir if it exists
|
|
try {
|
|
const stat = fs.lstatSync(linkPath);
|
|
if (stat.isSymbolicLink()) {
|
|
fs.unlinkSync(linkPath);
|
|
}
|
|
} catch {
|
|
// doesn't exist, that's fine
|
|
}
|
|
|
|
// Create symlink
|
|
fs.symlinkSync(mempalacePath, linkPath, "junction");
|
|
}
|
|
|
|
/**
|
|
* Generates a default config.json with auto-detected paths.
|
|
*
|
|
* @param mempalacePath Auto-detected MemPalace path (may be empty).
|
|
* @returns Config object.
|
|
*/
|
|
export function generateDefaultConfig(mempalacePath: string): WikiEngineConfig {
|
|
return {
|
|
vault: {
|
|
path: PRIMARY_VAULT_PATH,
|
|
branch: "main",
|
|
},
|
|
db: {
|
|
path: path.join(HABRAID_DATA_HOME, "habraid.db"),
|
|
},
|
|
mempalace: {
|
|
enabled: mempalacePath.length > 0,
|
|
path: mempalacePath,
|
|
},
|
|
llm: {
|
|
mode: "host",
|
|
preferences: {
|
|
priority: "balanced",
|
|
},
|
|
fallback: {
|
|
provider: "zai",
|
|
model: "glm-5.1",
|
|
api_url: "https://api.example.com/v1",
|
|
api_key_env: "GLM_API_KEY",
|
|
max_tokens: 4096,
|
|
},
|
|
provider: "zai",
|
|
model: "glm-5.1",
|
|
api_url: "https://api.example.com/v1",
|
|
api_key_env: "GLM_API_KEY",
|
|
max_tokens: 4096,
|
|
},
|
|
sync: {
|
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "Asia/Seoul",
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Writes config to ~/.habraid/data/config.json if it doesn't exist.
|
|
*
|
|
* @param config Config to write.
|
|
*/
|
|
async function writeConfigIfMissing(config: WikiEngineConfig): Promise<boolean> {
|
|
const configPath = path.join(HABRAID_DATA_HOME, "config.json");
|
|
|
|
if (fs.existsSync(configPath)) {
|
|
return false; // already exists
|
|
}
|
|
|
|
const { writeTextFile } = await import("./utils.js");
|
|
await writeTextFile(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Result of the setup process.
|
|
*/
|
|
export interface SetupResult {
|
|
/** Whether this was a first-time setup. */
|
|
firstRun: boolean;
|
|
/** Detected MemPalace path (empty if not found). */
|
|
mempalacePath: string;
|
|
/** Number of items already in DB (0 for first run). */
|
|
existingItems: number;
|
|
/** Messages to display to the user. */
|
|
messages: string[];
|
|
}
|
|
|
|
/**
|
|
* Runs the zero-config setup process.
|
|
*
|
|
* Safe to call on every server start — only does work on first run.
|
|
*
|
|
* @param config Loaded config (may be defaults).
|
|
* @returns Setup result with status info.
|
|
*/
|
|
export async function runSetup(config: WikiEngineConfig): Promise<SetupResult> {
|
|
const messages: string[] = [];
|
|
let firstRun = false;
|
|
|
|
// 1. Ensure directory structure exists
|
|
const habraidExists = fs.existsSync(HABRAID_HOME);
|
|
const vaultExists = fs.existsSync(resolvePath(config.vault.path));
|
|
if (!habraidExists || !vaultExists) {
|
|
await createDirectoryStructure(resolvePath(config.vault.path));
|
|
messages.push(`Created HaBraid runtime home and primary vault directories.`);
|
|
firstRun = true;
|
|
}
|
|
|
|
// 2. Detect MemPalace
|
|
const effectiveConfig = config.mempalace.path
|
|
? config
|
|
: { ...config, mempalace: { ...config.mempalace, path: "", enabled: config.mempalace.enabled } };
|
|
const vaultPath = resolvePath(effectiveConfig.vault.path);
|
|
let mempalacePath = config.mempalace.path
|
|
? resolvePath(config.mempalace.path)
|
|
: detectMemPalacePath();
|
|
|
|
if (mempalacePath) {
|
|
messages.push(`MemPalace found at ${mempalacePath}`);
|
|
// Create symlink if vault/raw/mempalace doesn't point there yet
|
|
const linkPath = path.join(vaultPath, "raw", "mempalace");
|
|
try {
|
|
const existing = fs.readlinkSync(linkPath);
|
|
if (existing !== mempalacePath) {
|
|
linkMemPalaceRaw(vaultPath, mempalacePath);
|
|
}
|
|
} catch {
|
|
linkMemPalaceRaw(vaultPath, mempalacePath);
|
|
}
|
|
} else {
|
|
messages.push("MemPalace not found. You can set mempalace.path in config.json later.");
|
|
}
|
|
|
|
// 3. Ensure config file exists
|
|
const finalConfig = config.mempalace.path
|
|
? { ...effectiveConfig, mempalace: { ...effectiveConfig.mempalace, path: mempalacePath, enabled: mempalacePath.length > 0 } }
|
|
: { ...effectiveConfig, mempalace: { ...effectiveConfig.mempalace, path: mempalacePath, enabled: mempalacePath.length > 0 } };
|
|
|
|
const wroteConfig = await writeConfigIfMissing(finalConfig);
|
|
if (wroteConfig) {
|
|
messages.push("Created default config.json.");
|
|
}
|
|
|
|
// 4. Initialize DB if needed
|
|
let existingItems = 0;
|
|
try {
|
|
const dbPath = resolvePath(finalConfig.db.path);
|
|
const db = openDatabase(dbPath);
|
|
existingItems = getItemCount(db);
|
|
db.close();
|
|
messages.push(`DB ready (${existingItems} items).`);
|
|
} catch (error) {
|
|
messages.push(`DB init: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
|
|
// 5. First-run Obsidian hint
|
|
if (firstRun) {
|
|
messages.push("");
|
|
messages.push("To view in Obsidian, open this folder as a vault:");
|
|
messages.push(` ${vaultPath}`);
|
|
messages.push("This is the primary user-facing wiki path for HaBraid one-click installs.");
|
|
}
|
|
|
|
return { firstRun, mempalacePath, existingItems, messages };
|
|
}
|