// Bot-local knowledge base. Every finished cycle already persists a // CycleResult to data/_bot/cycles/{id}.json — that IS the outcome log. // KB here just reads that dir, filters to prior cycles on the same gap, // and produces a short summary the cloud model can condition on. // // No separate jsonl, no new write path, no embedding calls. The bot's // "memory" is the same primary artifact that the observer consumes. // // Future: embedding-based neighbor matching across gaps (cheap once // sidecar is local), cross-pollination with scenario KB's // pathway_recommendations. Not required for the feedback loop to work // on a single gap — that's the floor we're building first. import { readdir, readFile } from "node:fs/promises"; import { join } from "node:path"; import type { CycleResult } from "./types.ts"; const CYCLES_DIR = "/home/profit/lakehouse/data/_bot/cycles"; export interface HistoryEntry { cycle_id: string; ended_at: string; outcome: string; reason: string; pr_url: string | null; tests_green: boolean | null; files_added: string[]; files_updated: string[]; tokens_used: number; } export async function loadHistory(gap_id: string, max: number = 5): Promise { let entries: string[] = []; try { entries = await readdir(CYCLES_DIR); } catch { return []; } const matches: HistoryEntry[] = []; for (const e of entries) { if (!e.endsWith(".json")) continue; try { const raw = await readFile(join(CYCLES_DIR, e), "utf8"); const r = JSON.parse(raw) as CycleResult; if (r.gap?.id !== gap_id) continue; matches.push({ cycle_id: r.cycle_id, ended_at: r.ended_at, outcome: r.outcome, reason: r.reason, pr_url: r.prUrl, tests_green: r.testsGreen, files_added: r.filesAdded ?? [], files_updated: r.filesUpdated ?? [], tokens_used: r.tokens_used, }); } catch { // Skip unreadable / malformed cycle files. Don't fail the current // cycle because an old one is corrupt. } } matches.sort((a, b) => b.ended_at.localeCompare(a.ended_at)); return matches.slice(0, max); } // Compact prompt-ready summary. Empty string when there's no history — // caller can skip the "prior attempts" block entirely. export function summarizeHistory(h: HistoryEntry[]): string { if (h.length === 0) return ""; const lines = h.map(e => { const when = e.ended_at.slice(0, 16).replace("T", " "); const files = [...e.files_added, ...e.files_updated]; const filesStr = files.length > 0 ? ` touched: ${files.join(", ")}` : ""; const prStr = e.pr_url ? ` PR: ${e.pr_url}` : ""; return `- ${when} UTC — ${e.outcome}${prStr}${filesStr}\n reason: ${e.reason}`; }); return [ `Prior attempts on this gap (${h.length} most recent):`, ...lines, "", "Learn from these: build on what worked, avoid paths that failed.", ].join("\n"); } // Aggregate stats for telemetry — lets the bot expose "% of cycles on // this gap that landed a PR" without re-parsing the raw history. export function statsFor(h: HistoryEntry[]): { attempts: number; pr_opened: number; tests_failed: number; proposal_rejected: number; noop: number; } { return { attempts: h.length, pr_opened: h.filter(e => e.outcome === "ok").length, tests_failed: h.filter(e => e.outcome === "tests_failed").length, proposal_rejected: h.filter(e => e.outcome === "proposal_rejected").length, noop: h.filter(e => e.outcome === "cycle_noop").length, }; }