Files
zeekay 50914e547c brain: ship the gbrain-equivalent meta-pack as bot extensions
Four new extensions implement the Hanzo Brain on top of @hanzo/bot
without renaming anything or carving a separate repo. Solo devs install
nothing extra — single binary, single config, SQLite by default.

  brain/             meta-pack: wires the three below into one register()
  memory-postgres/   dual-mode store: Postgres+pgvector OR SQLite+FTS5,
                     selected by HANZO_BRAIN_URL / DATABASE_URL. Schema
                     mirrors gbrain's: pages, edges, facts, FTS index.
  graph-links/       zero-LLM typed-link extractor — runs on every page
                     write. Emits attended/works_at/invested_in/founded/
                     advises edges from regex + role inference. Strips
                     code fences. Self-wiring knowledge graph at
                     >10K pages/sec.
  recipes-brain/     YAML recipe loader + scheduler. Flagship recipe:
                     recipes/email.yaml — daily Gmail pull → classify →
                     draft → enqueue for swipe-review in the Hanzo app
                     (bot/apps/{ios,android,macos}). Every swipe is a
                     training signal for tone.

Tests: 16/16 pass (12 graph-links unit + 4 memory-postgres SQLite).
End-to-end smoke (markdown ingest → edge extraction → fact upsert →
hybrid search → recipe load) runs clean against bun:sqlite.

Deploy path: bot CI builds @hanzo/bot image w/ new extensions →
workflow_dispatch on .github/workflows/k8s-deploy-bot.yml rolls it to
app.hanzo.bot.
2026-05-10 17:03:50 -07:00

129 lines
4.1 KiB
TypeScript

/**
* @hanzo/bot-recipes-brain — ingest recipes for the Hanzo Brain
*
* Loads YAML recipes from this package's `recipes/` directory and
* registers them with the bot's scheduler. Each recipe defines:
* - auth (OIDC via hanzo.id)
* - cron schedule
* - ingest fetcher
* - classify schema (model + JSON schema)
* - draft prompt + context bindings
* - enqueue target (drafts.<recipe>)
* - on_swipe hooks (send / reject / edit)
*
* Brain meta-pack consumes this and the user's enabled-recipe list to
* boot the daily-life automations.
*/
import { readFile, readdir } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const HERE = dirname(fileURLToPath(import.meta.url));
const RECIPES_DIR = join(HERE, "recipes");
export interface Recipe {
recipe: string;
version: number;
backend: string;
auth?: Record<string, unknown>;
cron?: string;
ingest?: Record<string, unknown>;
classify?: Record<string, unknown>;
draft?: Record<string, unknown>;
enqueue?: Record<string, unknown>;
notify?: Record<string, unknown>;
[key: string]: unknown;
}
export async function loadRecipe(name: string): Promise<Recipe> {
const path = join(RECIPES_DIR, `${name}.yaml`);
const raw = await readFile(path, "utf-8");
// Bot already depends on `yaml` (eemeli/yaml v2). Use it.
const yaml = await import("yaml" as any);
return yaml.parse(raw) as Recipe;
}
export async function listRecipes(): Promise<string[]> {
try {
const files = await readdir(RECIPES_DIR);
return files.filter((f) => f.endsWith(".yaml")).map((f) => f.replace(/\.yaml$/, ""));
} catch {
return [];
}
}
export default async function register(
api: any,
enabled: string[] = [],
): Promise<{
recipes: Recipe[];
}> {
const all = await listRecipes();
const toLoad = enabled.length ? enabled.filter((n) => all.includes(n)) : all;
const recipes: Recipe[] = [];
for (const name of toLoad) {
const recipe = await loadRecipe(name);
recipes.push(recipe);
if (typeof api?.scheduler?.register === "function") {
api.scheduler.register({
name: `brain.recipe.${recipe.recipe}`,
cron: recipe.cron,
handler: () => runRecipe(api, recipe),
});
}
}
return { recipes };
}
// Recipe runner — stubbed end-to-end. Real adapters live in each
// backend's own extension (gmail in `bot-gmail`, etc.). This file just
// orchestrates the pipeline: fetch → classify → draft → enqueue.
async function runRecipe(api: any, recipe: Recipe): Promise<void> {
const fetcher = api?.adapters?.[recipe.backend];
if (!fetcher) {
api?.log?.warn?.(`[brain] no adapter for backend=${recipe.backend}, skipping`);
return;
}
const items = await fetcher.fetch(recipe.ingest);
for (const item of items ?? []) {
const classification = await api.llm.classify({
model: (recipe.classify as any)?.model,
schema: (recipe.classify as any)?.schema,
input: item,
});
if (!classification?.needs_reply) continue;
const draft = await api.llm.draft({
model: (recipe.draft as any)?.model,
prompt: (recipe.draft as any)?.prompt,
context: await resolveContext((recipe.draft as any)?.context ?? [], item, api),
});
await api.queues.enqueue((recipe.enqueue as any)?.queue, {
item,
classification,
draft,
ttl: (recipe.enqueue as any)?.ttl,
});
}
}
async function resolveContext(specs: string[], item: any, api: any): Promise<any[]> {
const out: any[] = [];
for (const spec of specs) {
// Brain calls — e.g. `brain.recall(from_email)`, `brain.search(thread_id)`.
const m = spec.match(/^(\w+)\.(\w+)\((.*)\)$/);
if (!m) continue;
const [, ns, fn, args] = m;
if (ns !== "brain") continue;
const argVal = args
.replace(/\b(\w+)\b/g, (k) => JSON.stringify(item?.[k] ?? k))
.replace(/^"|"$/g, "");
if (fn === "recall" && api.tools?.["brain.recall"]) {
out.push(await api.tools["brain.recall"]({ entity: argVal }));
} else if (fn === "search" && api.tools?.["brain.search"]) {
out.push(await api.tools["brain.search"]({ query: argVal }));
}
}
return out;
}