- 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
150 lines
4.2 KiB
TypeScript
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();
|
|
}
|
|
}
|