Files
HaBraid/src/wiki/backends/host.ts
Contributor 5d5336757d feat: HaBraid v0.1.0 — host-following memory visualization engine for Obsidian
- 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
2026-04-18 15:46:42 +09:00

150 lines
4.2 KiB
TypeScript

/**
* Host-following backend — delegates inference to the host CLI (Hermes).
*
* This is the DEFAULT and PREFERRED backend. It shells out to the Hermes
* CLI so that the host controls provider/model/routing policy.
*
* @module wiki/backends/host
*/
import { execFile } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { LlmCallError } from "../../errors.js";
import type { ChatMessage, ChatOptions, HostBackendConfig } from "./types.js";
const execFileAsync = promisify(execFile);
/**
* Resolves the Hermes CLI binary path.
*/
function getHermesCliPath(command?: string): string {
return process.env.HERMES_CLI_PATH ?? command ?? "hermes";
}
/**
* Reads the current Hermes model/provider from ~/.hermes/config.yaml.
*/
function readHermesModelMetadata(): { provider?: string; model?: string } {
const configPath = path.join(os.homedir(), ".hermes", "config.yaml");
if (!existsSync(configPath)) return {};
try {
const lines = readFileSync(configPath, "utf-8").split("\n");
let inModelBlock = false;
let provider: string | undefined;
let model: string | undefined;
for (const line of lines) {
if (!inModelBlock) {
if (line.trim() === "model:") {
inModelBlock = true;
}
continue;
}
if (!line.startsWith(" ") && line.trim()) {
break;
}
const trimmed = line.trim();
if (trimmed.startsWith("provider:")) {
provider = trimmed.slice("provider:".length).trim();
}
if (trimmed.startsWith("default:")) {
model = trimmed.slice("default:".length).trim();
}
}
return { provider, model };
} catch {
return {};
}
}
/**
* Extracts the assistant answer from Hermes CLI output.
*/
function extractHermesChatText(output: string): string {
const lines = output.split("\n");
const cleaned = lines
.map((line) => line.trim())
.filter((line) => line.length > 0)
.filter((line) => !line.startsWith("MemPalace MCP Server starting"))
.filter((line) => !line.startsWith("session_id:"))
.filter((line) => !line.startsWith("╭─"))
.filter((line) => !line.startsWith("╰─"));
const text = cleaned.join("\n").trim();
if (!text) {
throw new LlmCallError("Hermes CLI bridge returned empty output.");
}
return text;
}
/**
* Host-following backend that delegates to the Hermes CLI.
*/
export class HostBackend {
readonly name = "host";
private readonly command: string;
constructor(config?: HostBackendConfig) {
this.command = config?.command ?? "hermes";
}
async isAvailable(): Promise<boolean> {
if (process.env.HABRAID_DISABLE_HERMES_BRIDGE === "1") {
return false;
}
try {
await execFileAsync(getHermesCliPath(this.command), ["--version"], { timeout: 15_000 });
return true;
} catch {
return false;
}
}
async chat(messages: ChatMessage[], _options?: ChatOptions): Promise<string> {
// Build a single prompt from system + user messages
const systemMsg = messages.find((m) => m.role === "system")?.content ?? "";
const userMsg = messages.find((m) => m.role === "user")?.content ?? "";
const assistantMsg = messages.find((m) => m.role === "assistant")?.content;
const prompt = [
"You are acting as the host-side wiki generation model for HaBraid.",
"Do not use any tools.",
"Return only the final markdown content with no preamble.",
"",
"[SYSTEM INSTRUCTIONS]",
systemMsg,
"",
"[USER REQUEST]",
userMsg,
...(assistantMsg ? ["", "[ASSISTANT CONTEXT]", assistantMsg] : []),
].join("\n");
try {
const { stdout, stderr } = await execFileAsync(
getHermesCliPath(this.command),
["chat", "-q", prompt, "--toolsets", "", "--quiet"],
{ timeout: 180_000, maxBuffer: 1024 * 1024 * 8 },
);
return extractHermesChatText(`${stdout}\n${stderr}`);
} catch (error) {
throw new LlmCallError("Hermes CLI host bridge failed.", error as Error);
}
}
/**
* Reads host model metadata for observability.
*/
getModelMetadata(): { provider?: string; model?: string } {
return readHermesModelMetadata();
}
}