checkpoint: matrix-agent-validated (2026-04-25)
Architectural snapshot of the lakehouse codebase at the point where the
full matrix-driven agent loop with Mem0 versioning + deletion was
validated end-to-end.
WHAT THIS REPO IS
A clean single-commit snapshot of the lakehouse code. Heavy test data
(.parquet datasets, vector indexes) excluded — see REPLICATION.md for
regen path. Full lakehouse history at git.agentview.dev/profit/lakehouse.
WHAT WAS PROVEN
- Vector retrieval across multi-corpora matrix (chicago_permits + entity
briefs + sec_tickers + distilled procedural + llm_team runs)
- Observer hand-review (cloud + heuristic fallback) gating each candidate
- Local-model agent loop (qwen3.5:latest) with tool use + scratchpad
- Playbook seal on success → next-iter retrieval surfaces it as preamble
- Mem0 versioning + deletion in pathway_memory:
* UPSERT: ADD on new workflow, UPDATE bumps replay_count on identical
* REVISE: chains versions, parent.superseded_at + superseded_by stamped
* RETIRE: marks specific trace retired with reason, excluded from retrieval
* HISTORY: walks chain root→tip, cycle-safe
KEY DIRECTORIES
- crates/vectord/src/pathway_memory.rs — Mem0 ops live here
- crates/vectord/src/playbook_memory.rs — original Mem0 reference
- tests/agent_test/ — local-model agent harness + PRD + session archives
- scripts/dump_raw_corpus.sh — MinIO bucket dump (raw test corpus)
- scripts/vectorize_raw_corpus.ts — corpus → vector indexes
- scripts/analyze_chicago_contracts.ts — real inference pipeline
- scripts/seal_agent_playbook.ts — Mem0 upsert from agent traces
Replication: see REPLICATION.md for Debian 13 clean install + cloud-only
adaptation (no local Ollama).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+127
@@ -0,0 +1,127 @@
|
||||
# Lakehouse PR-Bot
|
||||
|
||||
A local sub-agent that reads the PRD, asks a cloud model for a small change
|
||||
proposal, applies it, runs tests, and opens a draft PR on Gitea. Manual-only
|
||||
right now — no systemd, no cron. Run one cycle at a time and watch.
|
||||
|
||||
## Run one cycle
|
||||
|
||||
```bash
|
||||
cd /home/profit/lakehouse
|
||||
bun run bot/cycle.ts
|
||||
```
|
||||
|
||||
Prerequisites:
|
||||
- Working tree must be clean (`git status` shows nothing). The bot refuses on a dirty tree so its changes don't mix with in-flight work.
|
||||
- Sidecar running on :3200, gateway on :3100, observer on :3800.
|
||||
- At least one PRD line tagged `[bot-eligible]` (see below).
|
||||
- Gitea PAT configured in `~/.git-credentials` (already set up).
|
||||
|
||||
## Tag a gap as bot-eligible
|
||||
|
||||
Add `[bot-eligible]` to any PRD line the bot is allowed to work on:
|
||||
|
||||
```md
|
||||
- [ ] Add a unit test for parse_city_state covering "South Bend, IN" edge case [bot-eligible]
|
||||
```
|
||||
|
||||
The bot scans `docs/PRD.md` for these tags. Each tagged line becomes a candidate.
|
||||
Start small — one tag at a time — until you trust the loop.
|
||||
|
||||
## Stop it mid-cycle
|
||||
|
||||
Create the pause file:
|
||||
|
||||
```bash
|
||||
touch /home/profit/lakehouse/bot.paused
|
||||
```
|
||||
|
||||
The next `bot/cycle.ts` invocation exits immediately with `skipped_pause`.
|
||||
(It does NOT kill a cycle already in-flight — use `Ctrl-C` or `pkill -f bot/cycle.ts` for that.)
|
||||
|
||||
## Budget
|
||||
|
||||
- **20 cloud calls/day, 160k tokens/day** (hard ceiling, see `bot/cost.ts`).
|
||||
- Tracked in `data/_bot/cost-YYYY-MM-DD.json`.
|
||||
- Resets at UTC midnight.
|
||||
|
||||
## Cycle outcomes
|
||||
|
||||
Every run writes a result to `data/_bot/cycles/{cycle_id}.json` and POSTs an event to the observer on :3800.
|
||||
Possible outcomes:
|
||||
|
||||
| Outcome | Meaning |
|
||||
|---|---|
|
||||
| `ok` | PR opened |
|
||||
| `cycle_noop` | proposal applied, every file was **identical to current content** (Mem0 NOOP); no test run, no PR |
|
||||
| `skipped_pause` | pause file present |
|
||||
| `skipped_cost` | daily budget exhausted |
|
||||
| `skipped_policy` | `policy.shouldRunCycle` said no |
|
||||
| `skipped_dirty_tree` | uncommitted changes present |
|
||||
| `skipped_no_gap` | no `[bot-eligible]` tags in PRD |
|
||||
| `model_failed` | sidecar/cloud model errored or returned unparseable JSON |
|
||||
| `proposal_rejected` | `policy.scoreProposal` rejected it (size, path, etc.) |
|
||||
| `apply_failed` | file write errored |
|
||||
| `tests_failed` | cargo or bun test red |
|
||||
| `pr_skipped_by_policy` | green tests, but `policy.shouldOpenPR` said no |
|
||||
| `pr_failed` | Gitea API or git push errored |
|
||||
|
||||
## Mem0-aligned apply semantics
|
||||
|
||||
`apply.ts` categorizes every proposed file into one of three modes:
|
||||
|
||||
| Mode | Trigger | Action |
|
||||
|---|---|---|
|
||||
| **ADD** | `is_new: true` and file doesn't exist | Create the file |
|
||||
| **UPDATE** | `is_new: false`, file exists, content **differs** from current | Overwrite |
|
||||
| **NOOP** | `is_new: false`, file exists, content **matches** current exactly | Skip (no write, no diff) |
|
||||
|
||||
If every file in the proposal is NOOP, the cycle short-circuits to `cycle_noop` **before** running tests or opening a PR. Mismatched shapes (`is_new:true` but file exists, or `is_new:false` but file missing) become `apply_failed` — model state confusion is surfaced, not papered over.
|
||||
|
||||
PR bodies report the three counts separately so reviewers see what actually changed vs. what was confirmed identical.
|
||||
|
||||
## How the bot compounds over cycles
|
||||
|
||||
Every finished cycle persists a `CycleResult` to `data/_bot/cycles/{cycle_id}.json`. At the **start** of the next cycle, `bot/kb.ts::loadHistory(gap_id)` scans that directory, filters to prior cycles on the same gap, and returns the five most recent outcomes (`ok`, `tests_failed`, `proposal_rejected`, `cycle_noop`, etc.) with their reasons and touched files.
|
||||
|
||||
Those outcomes are summarized into a compact block and **injected into the cloud prompt** before asking for a new proposal. The model sees things like:
|
||||
|
||||
```
|
||||
Prior attempts on this gap (3 most recent):
|
||||
- 2026-04-22 03:15 UTC — tests_failed
|
||||
reason: cargo test -p vectord::lance failed on field_type_coerce
|
||||
- 2026-04-22 02:48 UTC — proposal_rejected
|
||||
reason: touched forbidden path (docs/ADR-): docs/ADR-019-update.md
|
||||
- 2026-04-22 01:23 UTC — ok PR: https://git.agentview.dev/profit/lakehouse/pulls/142
|
||||
reason: PR #142 opened
|
||||
|
||||
Learn from these: build on what worked, avoid paths that failed.
|
||||
```
|
||||
|
||||
This is the same compounding pattern `scenario.ts` uses via `kb.loadRecommendation` — the bot's cycles are the bot's memory. No embedding, no separate jsonl, no cross-run orchestration required. First cycle on a new gap skips the block cleanly.
|
||||
|
||||
The observer events POSTed on every cycle carry the same data, so `GET :3800/stats?source=bot` aggregates "% of bot cycles on similar gaps that landed a PR" without extra plumbing.
|
||||
|
||||
## Where YOU edit
|
||||
|
||||
`bot/policy.ts` — four small functions that define what the bot does. The rest
|
||||
(`propose.ts`, `apply.ts`, `test.ts`, `pr.ts`, `cycle.ts`, `kb.ts`) is mechanical
|
||||
orchestration — you shouldn't need to touch it unless a pipeline changes.
|
||||
|
||||
One policy upgrade the history opens up: in `shouldRunCycle`, you can now bail
|
||||
out if the same gap has failed N times in a row. Example addition:
|
||||
|
||||
```ts
|
||||
import { loadHistory, statsFor } from "./kb.ts";
|
||||
// inside shouldRunCycle — but you'd need the gap, which is picked later.
|
||||
// A more natural place is scoreProposal: if prior N failures on this
|
||||
// gap's path+summary pattern, reject before testing.
|
||||
```
|
||||
|
||||
## Hard-coded guardrails (not in policy — can't be disabled)
|
||||
|
||||
- Bot **never deletes files** (`apply.ts` has no delete path).
|
||||
- Bot **never touches** `.git/`, `secrets`, `lakehouse.toml`, `docs/ADR-*`, `docs/DECISIONS.md`, `docs/PRD.md`, `/etc/`, `/root/`, `Cargo.lock`.
|
||||
- All paths validated for traversal (`path/../`) and repo-escape.
|
||||
- PRs always open as **draft** — never auto-merge.
|
||||
- Budget check is before the policy function runs — no way to override the daily cap from `policy.ts`.
|
||||
@@ -0,0 +1,86 @@
|
||||
// Apply a proposal to disk with Mem0-aligned ADD / UPDATE / NOOP
|
||||
// semantics. Whole-file writes only — never deletes. All paths
|
||||
// validated repo-relative + path-traversal-free.
|
||||
//
|
||||
// Three outcomes per file:
|
||||
// ADD — file didn't exist, new content written (is_new must be true)
|
||||
// UPDATE — file existed, content differs from current, overwritten
|
||||
// NOOP — file existed, content identical to current, nothing written
|
||||
//
|
||||
// The NOOP case is important: a cloud proposal that "re-derives" an
|
||||
// existing file shouldn't churn the repo or burn test cycles.
|
||||
|
||||
import { writeFile, readFile, mkdir, access } from "node:fs/promises";
|
||||
import { dirname, resolve, relative } from "node:path";
|
||||
import type { ApplyOutcome, Proposal } from "./types.ts";
|
||||
|
||||
const REPO_ROOT = "/home/profit/lakehouse";
|
||||
|
||||
export async function applyProposal(p: Proposal): Promise<ApplyOutcome> {
|
||||
const added: string[] = [];
|
||||
const updated: string[] = [];
|
||||
const noop: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const f of p.files) {
|
||||
const err = validatePath(f.path);
|
||||
if (err) {
|
||||
errors.push(`${f.path}: ${err}`);
|
||||
continue;
|
||||
}
|
||||
const abs = resolve(REPO_ROOT, f.path);
|
||||
try {
|
||||
const exists = await fileExists(abs);
|
||||
|
||||
if (f.is_new && exists) {
|
||||
errors.push(`${f.path}: is_new=true but file exists`);
|
||||
continue;
|
||||
}
|
||||
if (!f.is_new && !exists) {
|
||||
// Proposal claims to update a file that doesn't exist.
|
||||
// Refuse rather than silently ADD — model is confused about
|
||||
// repo state, and we want that surfaced, not papered over.
|
||||
errors.push(`${f.path}: is_new=false but file does not exist`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!f.is_new) {
|
||||
// UPDATE candidate — compare to detect NOOP.
|
||||
const current = await readFile(abs, "utf8");
|
||||
if (current === f.content) {
|
||||
noop.push(f.path);
|
||||
continue;
|
||||
}
|
||||
await writeFile(abs, f.content);
|
||||
updated.push(f.path);
|
||||
} else {
|
||||
// ADD — file doesn't exist yet.
|
||||
await mkdir(dirname(abs), { recursive: true });
|
||||
await writeFile(abs, f.content);
|
||||
added.push(f.path);
|
||||
}
|
||||
} catch (e) {
|
||||
errors.push(`${f.path}: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
return { added, updated, noop, errors };
|
||||
}
|
||||
|
||||
function validatePath(p: string): string | null {
|
||||
if (!p) return "empty path";
|
||||
if (p.startsWith("/")) return "must be repo-relative";
|
||||
if (p.startsWith("..") || p.includes("/../")) return "path traversal";
|
||||
const abs = resolve(REPO_ROOT, p);
|
||||
const rel = relative(REPO_ROOT, abs);
|
||||
if (rel.startsWith("..")) return "resolves outside repo";
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fileExists(abs: string): Promise<boolean> {
|
||||
try {
|
||||
await access(abs);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
// Daily cost tracker. Resets at UTC midnight by keying the file by date.
|
||||
// Hard ceilings are the policy surface — the bot refuses to call the
|
||||
// cloud once the budget is exhausted for the day.
|
||||
|
||||
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { CostState } from "./types.ts";
|
||||
|
||||
const COST_DIR = "/home/profit/lakehouse/data/_bot";
|
||||
|
||||
export const DAILY_CALLS_BUDGET = 20;
|
||||
export const DAILY_TOKENS_BUDGET = 8000 * DAILY_CALLS_BUDGET; // 160k tokens/day ceiling
|
||||
|
||||
function todayUtc(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function costFile(date: string): string {
|
||||
return join(COST_DIR, `cost-${date}.json`);
|
||||
}
|
||||
|
||||
export async function readCost(): Promise<CostState> {
|
||||
const date = todayUtc();
|
||||
try {
|
||||
const raw = await readFile(costFile(date), "utf8");
|
||||
return JSON.parse(raw) as CostState;
|
||||
} catch {
|
||||
return { date, calls: 0, tokens: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
export async function recordCost(calls: number, tokens: number): Promise<CostState> {
|
||||
await mkdir(COST_DIR, { recursive: true });
|
||||
const current = await readCost();
|
||||
const updated: CostState = {
|
||||
date: current.date,
|
||||
calls: current.calls + calls,
|
||||
tokens: current.tokens + tokens,
|
||||
};
|
||||
await writeFile(costFile(updated.date), JSON.stringify(updated, null, 2));
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function budgetCheck(c: CostState): { ok: boolean; reason: string } {
|
||||
if (c.calls >= DAILY_CALLS_BUDGET) {
|
||||
return { ok: false, reason: `daily call budget exhausted (${c.calls}/${DAILY_CALLS_BUDGET})` };
|
||||
}
|
||||
if (c.tokens >= DAILY_TOKENS_BUDGET) {
|
||||
return { ok: false, reason: `daily token budget exhausted (${c.tokens}/${DAILY_TOKENS_BUDGET})` };
|
||||
}
|
||||
return { ok: true, reason: `${c.calls}/${DAILY_CALLS_BUDGET} calls, ${c.tokens}/${DAILY_TOKENS_BUDGET} tokens used today` };
|
||||
}
|
||||
+345
@@ -0,0 +1,345 @@
|
||||
// Single-cycle orchestrator. This is the manual entry point:
|
||||
// bun run bot/cycle.ts
|
||||
//
|
||||
// Steps (each step can short-circuit with a clear outcome):
|
||||
// 1. Pause-file check → skipped_pause
|
||||
// 2. Cost budget check → skipped_cost
|
||||
// 3. Gather CycleContext (dirty tree, last cycle, autotune status)
|
||||
// 4. policy.shouldRunCycle → skipped_policy
|
||||
// 5. findGaps + policy.pickGap → skipped_no_gap
|
||||
// 6. generateProposal → model_failed
|
||||
// 7. policy.scoreProposal → proposal_rejected
|
||||
// 8. git checkout new branch
|
||||
// 9. applyProposal → apply_failed
|
||||
// 10. runTests → tests_failed
|
||||
// 11. policy.shouldOpenPR → pr_skipped_by_policy
|
||||
// 12. git commit + push
|
||||
// 13. openPr → pr_failed / ok
|
||||
// 14. recordCost + postEvent (every outcome)
|
||||
// 15. write cycle result to data/_bot/cycles/{cycle_id}.json
|
||||
|
||||
import { readFile, writeFile, mkdir, access } from "node:fs/promises";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { CycleContext, CycleResult, CycleOutcome, Proposal } from "./types.ts";
|
||||
import * as policy from "./policy.ts";
|
||||
import { readCost, recordCost, budgetCheck, DAILY_CALLS_BUDGET, DAILY_TOKENS_BUDGET } from "./cost.ts";
|
||||
import { findGaps, generateProposal } from "./propose.ts";
|
||||
import { applyProposal } from "./apply.ts";
|
||||
import { runTests } from "./test.ts";
|
||||
import { openPr } from "./pr.ts";
|
||||
import { postEvent } from "./observer.ts";
|
||||
import { loadHistory, summarizeHistory, statsFor } from "./kb.ts";
|
||||
|
||||
const REPO_ROOT = "/home/profit/lakehouse";
|
||||
const PAUSE_FILE = `${REPO_ROOT}/bot.paused`;
|
||||
const CYCLES_DIR = `${REPO_ROOT}/data/_bot/cycles`;
|
||||
const GATEWAY_URL = process.env.LH_GATEWAY_URL ?? "http://localhost:3100";
|
||||
|
||||
function log(msg: string) { console.log(`[bot] ${msg}`); }
|
||||
|
||||
async function fileExists(p: string): Promise<boolean> {
|
||||
try { await access(p); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
function git(args: string[]): { code: number; stdout: string; stderr: string } {
|
||||
const r = spawnSync("git", args, { cwd: REPO_ROOT, encoding: "utf8" });
|
||||
return { code: r.status ?? -1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
|
||||
}
|
||||
|
||||
async function gatherContext(cost: { calls: number; tokens: number }): Promise<CycleContext> {
|
||||
// Last cycle: newest file in CYCLES_DIR.
|
||||
let lastCycleAt: string | null = null;
|
||||
let lastCycleGapId: string | null = null;
|
||||
try {
|
||||
const { readdir, stat } = await import("node:fs/promises");
|
||||
const entries = await readdir(CYCLES_DIR).catch(() => [] as string[]);
|
||||
let newestTs = 0;
|
||||
let newestPath: string | null = null;
|
||||
for (const e of entries) {
|
||||
if (!e.endsWith(".json")) continue;
|
||||
const p = join(CYCLES_DIR, e);
|
||||
const s = await stat(p);
|
||||
if (s.mtimeMs > newestTs) { newestTs = s.mtimeMs; newestPath = p; }
|
||||
}
|
||||
if (newestPath) {
|
||||
const r = JSON.parse(await readFile(newestPath, "utf8")) as CycleResult;
|
||||
lastCycleAt = r.started_at;
|
||||
lastCycleGapId = r.gap?.id ?? null;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Autotune status — if the agent is busy, skip this cycle to avoid
|
||||
// contention on shared Ollama GPU memory.
|
||||
let autotuneBusy = false;
|
||||
try {
|
||||
const r = await fetch(`${GATEWAY_URL}/vectors/agent/status`, { signal: AbortSignal.timeout(2000) });
|
||||
if (r.ok) {
|
||||
const j = await r.json() as any;
|
||||
autotuneBusy = Boolean(j.running) && (j.queue_depth ?? 0) > 0;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Dirty tree check.
|
||||
const status = git(["status", "--porcelain"]);
|
||||
const workingTreeDirty = status.code === 0 && status.stdout.trim().length > 0;
|
||||
|
||||
return {
|
||||
startedAt: new Date().toISOString(),
|
||||
dailyCallsUsed: cost.calls,
|
||||
dailyCallsBudget: DAILY_CALLS_BUDGET,
|
||||
dailyTokensUsed: cost.tokens,
|
||||
dailyTokensBudget: DAILY_TOKENS_BUDGET,
|
||||
lastCycleAt,
|
||||
lastCycleGapId,
|
||||
autotuneBusy,
|
||||
workingTreeDirty,
|
||||
};
|
||||
}
|
||||
|
||||
async function persistResult(res: CycleResult): Promise<void> {
|
||||
await mkdir(CYCLES_DIR, { recursive: true });
|
||||
await writeFile(join(CYCLES_DIR, `${res.cycle_id}.json`), JSON.stringify(res, null, 2));
|
||||
}
|
||||
|
||||
function sigHash(gap_id: string | null, proposalSummary: string): string {
|
||||
const h = createHash("sha256");
|
||||
h.update(gap_id ?? "no-gap");
|
||||
h.update("|");
|
||||
h.update(proposalSummary);
|
||||
return h.digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
function emit(res: CycleResult): Promise<void> {
|
||||
return postEvent({
|
||||
source: "bot",
|
||||
cycle_id: res.cycle_id,
|
||||
sig_hash: sigHash(res.gap?.id ?? null, res.proposal?.summary ?? ""),
|
||||
event_kind: res.outcome,
|
||||
ok: res.outcome === "ok",
|
||||
staffer_id: res.proposal?.model_used,
|
||||
turns: res.cloud_calls,
|
||||
duration_ms: new Date(res.ended_at).getTime() - new Date(res.started_at).getTime(),
|
||||
extra: {
|
||||
reason: res.reason,
|
||||
pr_url: res.prUrl,
|
||||
files_added: res.filesAdded,
|
||||
files_updated: res.filesUpdated,
|
||||
files_noop: res.filesNoop,
|
||||
tests_green: res.testsGreen,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const cycle_id = `bot-${new Date().toISOString().replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`;
|
||||
const started_at = new Date().toISOString();
|
||||
log(`cycle ${cycle_id} starting`);
|
||||
|
||||
const finish = async (outcome: CycleOutcome, reason: string, extra: Partial<CycleResult> = {}): Promise<CycleResult> => {
|
||||
const res: CycleResult = {
|
||||
cycle_id,
|
||||
started_at,
|
||||
ended_at: new Date().toISOString(),
|
||||
outcome,
|
||||
reason,
|
||||
gap: null,
|
||||
proposal: null,
|
||||
filesAdded: [],
|
||||
filesUpdated: [],
|
||||
filesNoop: [],
|
||||
testsGreen: null,
|
||||
testsOutput: "",
|
||||
prUrl: null,
|
||||
tokens_used: 0,
|
||||
cloud_calls: 0,
|
||||
...extra,
|
||||
};
|
||||
await persistResult(res);
|
||||
await emit(res);
|
||||
log(`→ ${outcome}: ${reason}`);
|
||||
return res;
|
||||
};
|
||||
|
||||
// 1. Pause.
|
||||
if (await fileExists(PAUSE_FILE)) {
|
||||
return finish("skipped_pause", `pause file at ${PAUSE_FILE}`);
|
||||
}
|
||||
|
||||
// 2. Cost.
|
||||
const cost = await readCost();
|
||||
const bc = budgetCheck(cost);
|
||||
if (!bc.ok) return finish("skipped_cost", bc.reason);
|
||||
log(`cost: ${bc.reason}`);
|
||||
|
||||
// 3+4. Context + policy.shouldRunCycle.
|
||||
const ctx = await gatherContext(cost);
|
||||
const srq = policy.shouldRunCycle(ctx);
|
||||
if (!srq.run) {
|
||||
return finish(ctx.workingTreeDirty ? "skipped_dirty_tree" : "skipped_policy", srq.reason);
|
||||
}
|
||||
|
||||
// 5. Gaps + policy.pickGap.
|
||||
const gaps = await findGaps();
|
||||
log(`found ${gaps.length} [bot-eligible] gap(s) in PRD`);
|
||||
const gapPick = policy.pickGap(gaps);
|
||||
if (!gapPick.gap) return finish("skipped_no_gap", gapPick.reason);
|
||||
const gap = gapPick.gap;
|
||||
log(`picked gap ${gap.id}: "${gap.prd_line.slice(0, 80)}"`);
|
||||
|
||||
// 6. KB lookup + Proposal.
|
||||
// Bot cycles compound: prior cycles on this gap inform the current
|
||||
// proposal. Summary is a compact block injected into the cloud prompt
|
||||
// so the model can build on past successes and steer around past
|
||||
// failures. Empty string when this is the first cycle on this gap.
|
||||
const history = await loadHistory(gap.id, 5);
|
||||
const stats = statsFor(history);
|
||||
if (history.length > 0) {
|
||||
log(`kb: ${stats.attempts} prior cycle(s) — ${stats.pr_opened} pr / ${stats.tests_failed} tests_failed / ${stats.proposal_rejected} rejected / ${stats.noop} noop`);
|
||||
} else {
|
||||
log(`kb: first cycle on this gap`);
|
||||
}
|
||||
const historySummary = summarizeHistory(history);
|
||||
|
||||
let proposal: Proposal;
|
||||
try {
|
||||
proposal = await generateProposal(gap, historySummary);
|
||||
log(`proposal: ${proposal.summary} (${proposal.files.length} files, ~${proposal.estimated_loc} LOC, ${proposal.tokens_used} tokens)`);
|
||||
} catch (e) {
|
||||
await recordCost(1, 0);
|
||||
return finish("model_failed", (e as Error).message, { gap, cloud_calls: 1 });
|
||||
}
|
||||
await recordCost(1, proposal.tokens_used);
|
||||
|
||||
// 7. Score.
|
||||
const score = policy.scoreProposal(proposal);
|
||||
if (!score.accept) {
|
||||
return finish("proposal_rejected", score.reason, { gap, proposal, tokens_used: proposal.tokens_used, cloud_calls: 1 });
|
||||
}
|
||||
log(`proposal accepted: ${score.reason}`);
|
||||
|
||||
// 8. Branch.
|
||||
const dayStamp = new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
||||
const branch = `bot/cycle-${dayStamp}-${gap.id}`;
|
||||
// Branch must be fresh. If one already exists from a prior failed cycle
|
||||
// for the same gap, bail — don't silently overwrite or reuse stale state.
|
||||
const check = git(["rev-parse", "--verify", `refs/heads/${branch}`]);
|
||||
if (check.code === 0) {
|
||||
return finish("apply_failed",
|
||||
`branch ${branch} already exists — delete it manually if you want a fresh cycle for this gap`,
|
||||
{ gap, proposal, tokens_used: proposal.tokens_used, cloud_calls: 1 });
|
||||
}
|
||||
const co = git(["checkout", "-b", branch]);
|
||||
if (co.code !== 0) {
|
||||
return finish("apply_failed", `git checkout -b failed: ${co.stderr}`,
|
||||
{ gap, proposal, tokens_used: proposal.tokens_used, cloud_calls: 1 });
|
||||
}
|
||||
log(`on branch ${branch}`);
|
||||
|
||||
// 9. Apply — Mem0 ADD/UPDATE/NOOP semantics.
|
||||
const ap = await applyProposal(proposal);
|
||||
if (ap.errors.length > 0) {
|
||||
git(["checkout", "main"]);
|
||||
git(["branch", "-D", branch]);
|
||||
return finish("apply_failed", ap.errors.join("; "),
|
||||
{ gap, proposal, tokens_used: proposal.tokens_used, cloud_calls: 1 });
|
||||
}
|
||||
const changed = [...ap.added, ...ap.updated];
|
||||
log(`apply: ${ap.added.length} add, ${ap.updated.length} update, ${ap.noop.length} noop`);
|
||||
|
||||
// Short-circuit: if every proposed file was identical to what's on
|
||||
// disk, there's nothing to test or PR. Clean up the branch and exit.
|
||||
if (changed.length === 0) {
|
||||
git(["checkout", "main"]);
|
||||
git(["branch", "-D", branch]);
|
||||
return finish("cycle_noop",
|
||||
`all ${proposal.files.length} proposed file(s) were identical to current state`,
|
||||
{ gap, proposal, filesNoop: ap.noop,
|
||||
tokens_used: proposal.tokens_used, cloud_calls: 1 });
|
||||
}
|
||||
|
||||
// 10. Test.
|
||||
log(`running tests (this will take a while)...`);
|
||||
const testRes = await runTests();
|
||||
if (!testRes.green) {
|
||||
git(["checkout", "."]);
|
||||
git(["checkout", "main"]);
|
||||
git(["branch", "-D", branch]);
|
||||
return finish("tests_failed", "cargo or bun test failed", {
|
||||
gap, proposal,
|
||||
filesAdded: ap.added, filesUpdated: ap.updated, filesNoop: ap.noop,
|
||||
testsGreen: false, testsOutput: testRes.output,
|
||||
tokens_used: proposal.tokens_used, cloud_calls: 1,
|
||||
});
|
||||
}
|
||||
log(`tests green`);
|
||||
|
||||
// 11. PR gate.
|
||||
const partial: CycleResult = {
|
||||
cycle_id, started_at,
|
||||
ended_at: new Date().toISOString(),
|
||||
outcome: "ok", reason: "",
|
||||
gap, proposal,
|
||||
filesAdded: ap.added, filesUpdated: ap.updated, filesNoop: ap.noop,
|
||||
testsGreen: true, testsOutput: testRes.output,
|
||||
prUrl: null, tokens_used: proposal.tokens_used, cloud_calls: 1,
|
||||
};
|
||||
const prGate = policy.shouldOpenPR(partial);
|
||||
if (!prGate.open) {
|
||||
git(["checkout", "main"]);
|
||||
git(["branch", "-D", branch]);
|
||||
return finish("pr_skipped_by_policy", prGate.reason, partial);
|
||||
}
|
||||
|
||||
// 12. Commit + push.
|
||||
git(["add", ...changed]);
|
||||
const commitMsg = [
|
||||
`bot: ${proposal.summary}`,
|
||||
"",
|
||||
proposal.rationale,
|
||||
"",
|
||||
`PRD gap: ${gap.prd_line}`,
|
||||
`Cycle: ${cycle_id}`,
|
||||
"",
|
||||
`Co-Authored-By: ${proposal.model_used} (cloud) <bot@lakehouse.local>`,
|
||||
].join("\n");
|
||||
const ci = git(["commit", "-m", commitMsg]);
|
||||
if (ci.code !== 0) {
|
||||
return finish("pr_failed", `git commit failed: ${ci.stderr}`, partial);
|
||||
}
|
||||
const ps = git(["push", "-u", "origin", branch]);
|
||||
if (ps.code !== 0) {
|
||||
return finish("pr_failed", `git push failed: ${ps.stderr}`, partial);
|
||||
}
|
||||
|
||||
// 13. Open PR.
|
||||
try {
|
||||
const pr = await openPr({
|
||||
branch,
|
||||
title: `bot: ${proposal.summary}`,
|
||||
body: [
|
||||
`**Gap** (PRD line ${gap.line_number}): ${gap.prd_line}`,
|
||||
"",
|
||||
`**Rationale**`,
|
||||
proposal.rationale,
|
||||
"",
|
||||
`**Cycle**: \`${cycle_id}\``,
|
||||
`**Model**: \`${proposal.model_used}\``,
|
||||
`**Tokens**: ${proposal.tokens_used}`,
|
||||
`**Added**: ${ap.added.map(f => `\`${f}\``).join(", ") || "_none_"}`,
|
||||
`**Updated**: ${ap.updated.map(f => `\`${f}\``).join(", ") || "_none_"}`,
|
||||
ap.noop.length > 0 ? `**NOOP (identical to current)**: ${ap.noop.map(f => `\`${f}\``).join(", ")}` : "",
|
||||
].join("\n"),
|
||||
});
|
||||
return finish("ok", `PR #${pr.number} opened`, { ...partial, prUrl: pr.html_url });
|
||||
} catch (e) {
|
||||
return finish("pr_failed", (e as Error).message, partial);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(e => {
|
||||
console.error("[bot] fatal:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
// 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<HistoryEntry[]> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// POST bot cycle outcomes to the observer on :3800 so they accumulate
|
||||
// in the KB alongside scenario + autotune events. Non-fatal on failure:
|
||||
// the cycle result is also written to disk so observability isn't a
|
||||
// single point of failure.
|
||||
|
||||
const OBSERVER_URL = process.env.LH_OBSERVER_URL ?? "http://localhost:3800";
|
||||
|
||||
export interface BotObserverEvent {
|
||||
source: "bot";
|
||||
cycle_id: string;
|
||||
sig_hash: string; // stable hash of the gap + proposal for dedup
|
||||
event_kind: string; // cycle outcome (ok, tests_failed, ...)
|
||||
ok: boolean;
|
||||
staffer_id?: string; // the model used, e.g. "gpt-oss:120b"
|
||||
turns?: number; // number of cloud calls this cycle
|
||||
duration_ms?: number;
|
||||
extra?: Record<string, any>;
|
||||
}
|
||||
|
||||
export async function postEvent(ev: BotObserverEvent): Promise<void> {
|
||||
try {
|
||||
const r = await fetch(`${OBSERVER_URL}/event`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(ev),
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
if (!r.ok) {
|
||||
console.error(`[bot/observer] ${r.status}: ${await r.text()}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[bot/observer] POST failed: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// YOU WRITE THIS FILE. These four functions shape the bot's entire
|
||||
// behavior. Defaults below are safe and intentionally conservative —
|
||||
// redline to taste. Each function is ~5-10 lines.
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
import type { CycleContext, CycleResult, Gap, Proposal } from "./types.ts";
|
||||
|
||||
// Should this cycle even fire right now?
|
||||
// Called AFTER the pause-file check + cost-cap check have already passed.
|
||||
// Use this for judgment calls (cooldown, shared-resource conflicts).
|
||||
export function shouldRunCycle(ctx: CycleContext): { run: boolean; reason: string } {
|
||||
if (ctx.workingTreeDirty) {
|
||||
return { run: false, reason: "dirty working tree — commit or stash your work first" };
|
||||
}
|
||||
if (ctx.lastCycleAt) {
|
||||
const minsAgo = (Date.now() - new Date(ctx.lastCycleAt).getTime()) / 60000;
|
||||
if (minsAgo < 10) return { run: false, reason: `last cycle ${minsAgo.toFixed(1)}min ago (<10min cooldown)` };
|
||||
}
|
||||
if (ctx.autotuneBusy) {
|
||||
return { run: false, reason: "autotune agent is running — avoid Ollama contention" };
|
||||
}
|
||||
return { run: true, reason: "ready" };
|
||||
}
|
||||
|
||||
// Which [bot-eligible] gap from the PRD does this cycle attack?
|
||||
// Default: first in document order. Alternatives: random for exploration,
|
||||
// smallest-context, tag-priority (e.g. [bot-eligible:high] wins).
|
||||
export function pickGap(gaps: Gap[]): { gap: Gap | null; reason: string } {
|
||||
if (gaps.length === 0) return { gap: null, reason: "no [bot-eligible] gaps in PRD" };
|
||||
return { gap: gaps[0], reason: "first eligible gap in document order" };
|
||||
}
|
||||
|
||||
// Cheap gate BEFORE burning test cycles. Reject if proposal smells wrong.
|
||||
// This catches runaway cloud proposals before we apply+test them.
|
||||
export function scoreProposal(p: Proposal): { accept: boolean; reason: string } {
|
||||
if (p.files.length === 0) return { accept: false, reason: "empty proposal" };
|
||||
if (p.files.length > 5) return { accept: false, reason: `>5 files (${p.files.length})` };
|
||||
if (p.estimated_loc > 200) return { accept: false, reason: `>200 LOC (${p.estimated_loc})` };
|
||||
|
||||
// Paths the bot is NEVER allowed to touch. Extend cautiously.
|
||||
const forbidden = [
|
||||
".git/", "secrets", "lakehouse.toml",
|
||||
"docs/ADR-", "docs/DECISIONS.md", "docs/PRD.md",
|
||||
"/etc/", "/root/", "Cargo.lock",
|
||||
];
|
||||
for (const f of p.files) {
|
||||
const hit = forbidden.find(prefix => f.path.includes(prefix));
|
||||
if (hit) return { accept: false, reason: `touches forbidden path (${hit}): ${f.path}` };
|
||||
}
|
||||
return { accept: true, reason: "passes size + path filters" };
|
||||
}
|
||||
|
||||
// Tests are green and changes applied. Do we actually open a PR?
|
||||
// Mem0-aligned: the cycle already short-circuited with `cycle_noop` if
|
||||
// every file was identical to current state, so by the time we're here
|
||||
// at least one file was actually added or updated.
|
||||
//
|
||||
// Stricter options below — uncomment to require a test file touched, etc.
|
||||
export function shouldOpenPR(c: CycleResult): { open: boolean; reason: string } {
|
||||
if (!c.testsGreen) return { open: false, reason: "tests red" };
|
||||
const realChanges = c.filesAdded.length + c.filesUpdated.length;
|
||||
if (realChanges === 0) return { open: false, reason: "nothing actually changed on disk" };
|
||||
|
||||
// Stricter option — require at least one test file in the diff:
|
||||
// const hasTest = [...c.filesAdded, ...c.filesUpdated].some(f => /test|_tests/i.test(f));
|
||||
// if (!hasTest) return { open: false, reason: "no test file touched" };
|
||||
|
||||
return { open: true, reason: `green + ${c.filesAdded.length} add / ${c.filesUpdated.length} update` };
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Gitea PR open. Reads PAT from ~/.git-credentials (set up by the
|
||||
// credential-helper flow). All PRs open as DRAFT so J reviews before
|
||||
// merge — never auto-merge.
|
||||
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
const GITEA_HOST = "https://git.agentview.dev";
|
||||
const REPO_OWNER = "profit";
|
||||
const REPO_NAME = "lakehouse";
|
||||
const CRED_FILE = "/home/profit/.git-credentials";
|
||||
|
||||
async function readPat(): Promise<string> {
|
||||
const raw = await readFile(CRED_FILE, "utf8");
|
||||
for (const line of raw.split("\n")) {
|
||||
const m = line.match(/^https:\/\/[^:]+:([^@]+)@git\.agentview\.dev/);
|
||||
if (m) return m[1];
|
||||
}
|
||||
throw new Error(`no Gitea PAT found in ${CRED_FILE}`);
|
||||
}
|
||||
|
||||
export interface OpenPrInput {
|
||||
branch: string; // head branch (just pushed)
|
||||
base?: string; // default "main"
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface OpenPrResult {
|
||||
number: number;
|
||||
html_url: string;
|
||||
}
|
||||
|
||||
export async function openPr(input: OpenPrInput): Promise<OpenPrResult> {
|
||||
const pat = await readPat();
|
||||
const url = `${GITEA_HOST}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/pulls`;
|
||||
const payload = {
|
||||
title: input.title,
|
||||
body: input.body,
|
||||
head: input.branch,
|
||||
base: input.base ?? "main",
|
||||
// Gitea uses "draft" flag — always on for bot PRs.
|
||||
draft: true,
|
||||
};
|
||||
const r = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"Authorization": `token ${pat}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
if (!r.ok) {
|
||||
throw new Error(`Gitea ${r.status}: ${await r.text()}`);
|
||||
}
|
||||
const j = await r.json() as any;
|
||||
return { number: j.number, html_url: j.html_url };
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
// Gap detection + cloud proposal.
|
||||
//
|
||||
// Gap detection: scan docs/PRD.md for lines tagged [bot-eligible].
|
||||
// Each match becomes a Gap with surrounding context.
|
||||
//
|
||||
// Proposal: one-shot call to the T3 cloud model via the Python sidecar's
|
||||
// /generate endpoint. Asks for a structured JSON response with file
|
||||
// contents. Truncation-resistant via Phase 21's generate_continuable —
|
||||
// for now we pass max_tokens high and rely on the model completing in
|
||||
// one pass; swap to the Rust continuation wrapper if we see truncation.
|
||||
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import type { Gap, Proposal } from "./types.ts";
|
||||
|
||||
const SIDECAR_URL = process.env.LH_SIDECAR_URL ?? "http://localhost:3200";
|
||||
const REPO_ROOT = "/home/profit/lakehouse";
|
||||
const PRD_PATH = `${REPO_ROOT}/docs/PRD.md`;
|
||||
const CLOUD_MODEL = process.env.LH_BOT_MODEL ?? "gpt-oss:120b";
|
||||
const MAX_TOKENS = 6000;
|
||||
|
||||
export async function findGaps(): Promise<Gap[]> {
|
||||
const prd = await readFile(PRD_PATH, "utf8");
|
||||
const lines = prd.split("\n");
|
||||
const gaps: Gap[] = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (!lines[i].includes("[bot-eligible]")) continue;
|
||||
const contextLines = lines.slice(i, Math.min(i + 6, lines.length)).join("\n");
|
||||
const id = createHash("sha256").update(lines[i]).digest("hex").slice(0, 12);
|
||||
gaps.push({
|
||||
id,
|
||||
prd_line: lines[i].trim(),
|
||||
context: contextLines,
|
||||
source_file: "docs/PRD.md",
|
||||
line_number: i + 1,
|
||||
});
|
||||
}
|
||||
return gaps;
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT = `You are an assistant that proposes small, testable code changes to the Lakehouse repo.
|
||||
The Lakehouse is a Rust-first data platform with 13 crates + Bun/TypeScript test harness.
|
||||
You will be given one PRD gap tagged [bot-eligible] and must respond with a STRICT JSON object — no prose.
|
||||
|
||||
Rules:
|
||||
- Response MUST be a single JSON object, no markdown fences, no commentary.
|
||||
- Change MUST be small: <200 lines total, ≤5 files.
|
||||
- Include at least one test file (new or modified) that proves the change.
|
||||
- NEVER touch .git/, secrets, lakehouse.toml, docs/ADR-*, docs/DECISIONS.md, docs/PRD.md, /etc/, /root/, Cargo.lock.
|
||||
- Paths MUST be repo-relative (no leading /).
|
||||
- Whole-file contents only — no patches, no diffs.
|
||||
|
||||
Response shape:
|
||||
{
|
||||
"summary": "one line",
|
||||
"rationale": "why this addresses the gap",
|
||||
"files": [ { "path": "crates/foo/src/bar.rs", "content": "<full file>", "is_new": false } ],
|
||||
"estimated_loc": 42
|
||||
}`;
|
||||
|
||||
export async function generateProposal(gap: Gap, historySummary: string = ""): Promise<Proposal> {
|
||||
const sections = [
|
||||
`PRD gap (line ${gap.line_number}):`,
|
||||
"```",
|
||||
gap.context,
|
||||
"```",
|
||||
"",
|
||||
];
|
||||
if (historySummary) {
|
||||
sections.push(historySummary, "");
|
||||
}
|
||||
sections.push("Propose a small change that addresses this gap. Respond with the JSON object only.");
|
||||
const userPrompt = sections.join("\n");
|
||||
|
||||
const r = await fetch(`${SIDECAR_URL}/generate`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: CLOUD_MODEL,
|
||||
system: SYSTEM_PROMPT,
|
||||
prompt: userPrompt,
|
||||
temperature: 0.2,
|
||||
max_tokens: MAX_TOKENS,
|
||||
think: false,
|
||||
}),
|
||||
signal: AbortSignal.timeout(180000), // cloud T3 can be slow — 3 min
|
||||
});
|
||||
if (!r.ok) {
|
||||
throw new Error(`sidecar ${r.status}: ${await r.text()}`);
|
||||
}
|
||||
const j = await r.json() as any;
|
||||
const raw: string = j.text ?? j.response ?? "";
|
||||
const usage = j.usage ?? {};
|
||||
const tokens = (usage.prompt_tokens ?? 0) + (usage.completion_tokens ?? 0);
|
||||
|
||||
const parsed = extractJson(raw);
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
throw new Error(`model returned no JSON object. Raw head: ${raw.slice(0, 300)}`);
|
||||
}
|
||||
if (!Array.isArray(parsed.files)) {
|
||||
throw new Error(`proposal.files not an array: ${JSON.stringify(parsed).slice(0, 200)}`);
|
||||
}
|
||||
|
||||
return {
|
||||
summary: String(parsed.summary ?? "").trim(),
|
||||
rationale: String(parsed.rationale ?? "").trim(),
|
||||
files: parsed.files.map((f: any) => ({
|
||||
path: String(f.path ?? ""),
|
||||
content: String(f.content ?? ""),
|
||||
is_new: Boolean(f.is_new),
|
||||
})).filter((f: any) => f.path && f.content !== undefined),
|
||||
estimated_loc: Number(parsed.estimated_loc ?? 0),
|
||||
model_used: CLOUD_MODEL,
|
||||
tokens_used: tokens,
|
||||
};
|
||||
}
|
||||
|
||||
// Find the first balanced JSON object in the string. Tolerates leading
|
||||
// "```json" fences even though we asked the model not to emit them.
|
||||
function extractJson(text: string): any | null {
|
||||
const cleaned = text.replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/i, "").trim();
|
||||
let depth = 0;
|
||||
let start = -1;
|
||||
for (let i = 0; i < cleaned.length; i++) {
|
||||
const c = cleaned[i];
|
||||
if (c === "{") {
|
||||
if (depth === 0) start = i;
|
||||
depth++;
|
||||
} else if (c === "}") {
|
||||
depth--;
|
||||
if (depth === 0 && start >= 0) {
|
||||
try {
|
||||
return JSON.parse(cleaned.slice(start, i + 1));
|
||||
} catch {
|
||||
start = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
// Run the test gate: `cargo test --workspace` + `bun test`.
|
||||
// Returns { green, output } — output is last ~4KB of combined stdout/stderr.
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const REPO_ROOT = "/home/profit/lakehouse";
|
||||
const TEST_TIMEOUT_MS = 15 * 60 * 1000; // 15 min cargo + 2 min bun, generous
|
||||
|
||||
export async function runTests(): Promise<{ green: boolean; output: string }> {
|
||||
const cargoOut = await runCmd("cargo", ["test", "--workspace", "--quiet", "--", "--test-threads=1"], REPO_ROOT, TEST_TIMEOUT_MS);
|
||||
if (cargoOut.code !== 0) {
|
||||
return { green: false, output: tail(`cargo test (${cargoOut.code}):\n${cargoOut.combined}`) };
|
||||
}
|
||||
|
||||
const bunOut = await runCmd("bun", ["test", "tests/multi-agent"], REPO_ROOT, 120000);
|
||||
const bunGreen = bunOut.code === 0;
|
||||
const combined = [
|
||||
`cargo test: OK`,
|
||||
`bun test (${bunOut.code}):`,
|
||||
bunOut.combined,
|
||||
].join("\n");
|
||||
return { green: bunGreen, output: tail(combined) };
|
||||
}
|
||||
|
||||
function tail(s: string, n = 4096): string {
|
||||
return s.length > n ? "…" + s.slice(-n) : s;
|
||||
}
|
||||
|
||||
function runCmd(
|
||||
cmd: string,
|
||||
args: string[],
|
||||
cwd: string,
|
||||
timeoutMs: number,
|
||||
): Promise<{ code: number; combined: string }> {
|
||||
return new Promise(resolveP => {
|
||||
const child = spawn(cmd, args, { cwd, env: { ...process.env } });
|
||||
let combined = "";
|
||||
child.stdout.on("data", d => { combined += d.toString(); });
|
||||
child.stderr.on("data", d => { combined += d.toString(); });
|
||||
const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
|
||||
child.on("close", code => {
|
||||
clearTimeout(timer);
|
||||
resolveP({ code: code ?? -1, combined });
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// Shared types for the PR-bot. Small and explicit — the bot's behavior
|
||||
// should be readable from these shapes alone.
|
||||
|
||||
export interface Gap {
|
||||
id: string; // hash of prd_line — stable key for dedup
|
||||
prd_line: string; // the [bot-eligible]-tagged line, verbatim
|
||||
context: string; // surrounding context from the PRD (next ~5 lines)
|
||||
source_file: string; // e.g. "docs/PRD.md"
|
||||
line_number: number;
|
||||
}
|
||||
|
||||
export interface ProposalFile {
|
||||
path: string; // repo-relative (no leading slash)
|
||||
content: string; // full file content — the bot writes whole files, not patches
|
||||
is_new: boolean; // true = create, false = overwrite existing
|
||||
}
|
||||
|
||||
export interface Proposal {
|
||||
summary: string; // one-line
|
||||
rationale: string; // why this change addresses the gap
|
||||
files: ProposalFile[];
|
||||
estimated_loc: number; // total added+changed lines across all files
|
||||
model_used: string;
|
||||
tokens_used: number;
|
||||
}
|
||||
|
||||
export interface CycleContext {
|
||||
startedAt: string; // ISO
|
||||
dailyCallsUsed: number;
|
||||
dailyCallsBudget: number;
|
||||
dailyTokensUsed: number;
|
||||
dailyTokensBudget: number;
|
||||
lastCycleAt: string | null;
|
||||
lastCycleGapId: string | null;
|
||||
autotuneBusy: boolean;
|
||||
workingTreeDirty: boolean;
|
||||
}
|
||||
|
||||
export type CycleOutcome =
|
||||
| "ok" // PR opened
|
||||
| "skipped_pause"
|
||||
| "skipped_cost"
|
||||
| "skipped_policy"
|
||||
| "skipped_no_gap"
|
||||
| "skipped_dirty_tree"
|
||||
| "proposal_rejected"
|
||||
| "apply_failed"
|
||||
| "cycle_noop" // proposal applied but every file was identical to what's on disk
|
||||
| "tests_failed"
|
||||
| "pr_skipped_by_policy"
|
||||
| "pr_failed"
|
||||
| "model_failed";
|
||||
|
||||
// Mem0-aligned apply outcomes. Three shapes instead of the binary
|
||||
// "written" / "errored". NOOP is a first-class outcome — identical
|
||||
// content shouldn't waste test cycles or open an empty PR.
|
||||
export type ApplyMode = "add" | "update" | "noop";
|
||||
|
||||
export interface ApplyOutcome {
|
||||
added: string[];
|
||||
updated: string[];
|
||||
noop: string[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface CycleResult {
|
||||
cycle_id: string;
|
||||
started_at: string;
|
||||
ended_at: string;
|
||||
outcome: CycleOutcome;
|
||||
reason: string;
|
||||
gap: Gap | null;
|
||||
proposal: Proposal | null;
|
||||
filesAdded: string[];
|
||||
filesUpdated: string[];
|
||||
filesNoop: string[];
|
||||
testsGreen: boolean | null; // null = not run
|
||||
testsOutput: string;
|
||||
prUrl: string | null;
|
||||
tokens_used: number;
|
||||
cloud_calls: number;
|
||||
}
|
||||
|
||||
export interface CostState {
|
||||
date: string; // YYYY-MM-DD UTC
|
||||
calls: number;
|
||||
tokens: number;
|
||||
}
|
||||
Reference in New Issue
Block a user