05 / 05·reflection·6 min read

What building a second project taught me about the first

The Kiln Road — a 5-scene Game Boy Advance game authored end to end by the fabri agent stack
The Kiln Road — a real GBA ROM, authored scene by scene by the fabri agent stack. This is the product that keeps breaking the engine.

The first bug ludexel ever found in fabri had nothing to do with games. It was a Python import. ToolRegistry had a method literally named list(), and thanks to PEP 649’s deferred annotation evaluation, that method name shadowed the list[dict] type hint on the same class — a crash that only showed up on an import from outside my own test suite. The commit that fixed it, b639850, says it plainly: “Reported by the ludexel service which couldn’t import fabri.” Fourteen hours after fabri’s rename from “agent-memory framework” to something with a name, a real product broke it in a way none of my own passing tests had thought to check.

That’s the pattern that repeats for the next three weeks — the actual subject of this post. Building ludexel, an AI-native game studio that turns a prompt into a real Game Boy Advance ROM, taught me fabri faster than staring at fabri ever did. A real product kept giving the framework jobs it could fail at, and each failure was the lesson.

Eviction shipped before anyone filed a complaint

fabri’s memory loop — retrieve, run, mine, dedup, promote, retrieve again — sounds like a diagram you’d draw before writing code, and for a while it was one. Eviction is the one piece here I can’t honestly credit to ludexel-gba’s real usage: memory.max_entries defaults to None (unbounded), and ludexel-gba’s production yaml never sets it either — the store has never actually hit a cap in a live build. I shipped the eviction machinery anyway, on the bet that unbounded growth was a ticking bug rather than a complaint anyone had filed yet.

What shipped is ca14c33, “summarize-before-evict strategy for memory eviction” — MemGPT-style recursive summarization. Every guideline starts out tactical (session-scoped, disposable — the store’s default kind) and only gets promoted to strategic (protected from eviction) once a near-duplicate recurs across three separate sessions, so the store doesn’t amplify a fluke into permanent memory. Eviction chunks the rest in groups of five, compressing each group into one new tactical guideline; strategic entries are summarized individually instead so they don’t flatten into the wrong bucket; a plain hard delete is the fallback if summarizing fails. Five in, one out — a net reduction of four per pass, small enough that the source comment flags the store can sit “1-2 entries over limit until the next ingest triggers a follow-up eviction pass.”

Where the COGS number was lying

fabri shipped per-run COGS tracking in v0.5.0 — cost_usd, subagent_cost_usd, total_cost_usd on every usage event. It felt complete until v0.7.8, “close COGS leaks in planner, decompose, compress, crashed sub-agents,” found four LLM call sites burning provider tokens without rolling into the reported total — recorded COGS was underreporting real spend by roughly 10x on planner-engaged runs. A crashed sub-agent still cost money; the number just didn’t say so.

Ludexel-gba’s own .agent/fabri_agent.yaml carries the scar tissue directly:

max_steps: 48
max_cost_usd: 8.0        # hard halt -> budget_exceeded

subagent:
  max_steps: 14
  max_cost_usd: 3.0       # domain child needs far less than the orchestrator

That split exists because, without it, every spawn_subagent child inherited the orchestrator’s max_steps: 48 — a looping domain child could burn the entire run’s budget on its own (fixed in v0.7.5, commit 0734f15, which added the agent.subagent budget block). A normal cold build now runs about $2–4.

Measurement follows dependence

fabri the engine ludexel the game it ships a failure → becomes a guideline ← cheaper default makes it cheaper to run
The whole series in one diagram: the product breaks the engine, the engine gets cheaper, repeat.

For most of fabri’s life, retrieval quality was a vibe. fabri searches memory two ways at once — dense (embedding similarity) and sparse (an FTS5 keyword index) — and fuses the two rankings; “hybrid” means both are supposed to be running together. It got measured in v0.10.0, “Measured retrieval” — the scoping commit says why: “Observability + an offline eval gate land before any retrieval-quality upgrade — quality was unmeasured.” That gate (40 guidelines, 24 queries, real IR metrics, CI floors set at measured-minus-0.05) immediately found a real bug: _fts5_query was space-joining tokens, and SQLite FTS5 reads a bare space as an implicit AND, so any multi-word query matched nothing and “hybrid” retrieval silently degraded to dense-only. Fixing the join pushed hybrid recall@5 — the fraction of relevant results that actually land in the top 5 — from 0.79 to 0.94.

Retrieval fuses the dense and sparse rankings with Reciprocal Rank Fusion (RRF): each result’s score is 1/(k + its rank) summed across both rankings, so two-way agreement can outrank a result only one search liked. The textbook k=60 is borrowed from web-scale search with pools in the thousands; at fabri’s scale — two short pools, a dozen candidates each — that flattens the formula enough that mere agreement beats the single best match either pool found. Retuning k down to 20 pushed hybrid recall@3 from 0.60 to 0.90.

Both fixes landed the same week fabri went from 0.8.2 to 0.10.0 across the Kiln Road build — a real 5-scene arc, authored end to end by the agent stack — the first stretch where what got retrieved actually mattered to whether a scene got authored right.

The retrospective became the prompt

The single cleanest artifact of the whole loop is a commit called 1ce60a1: “docs(retro)+prompts: fold Kiln Road v2 retrospective into fabri domains.” The Kiln Road v2 session ran 2026-07-07 to 2026-07-08, roughly 50 commits, structured as “every mistake, missed verification, and bug from this build, with its root cause and the reusable lesson.” Days later, that retrospective is literally inside the operating prompts: map.md got a camera-frame quality contract after three review passes all signed off on maps the user rejected on sight (“The whole maps are looking bad. Like really bad.”), because reviewers judged the 32×32 map file instead of the 15×10-tile window the camera actually shows.

The same session produced 13cae9d, the sharpest failure in the whole corpus: the orchestrator prompt had learned to say “New ROM packed — go play it” the moment validate_content passed — except validate_content only runs the Python generator chain (chargen, storygen, fontgen, scenegen), never the actual devkitARM compile. An undeclared ENEMY_SLIME symbol sailed straight through and shipped as “MISSION COMPLETE.” The fix wasn’t a smarter model — it was removing a claim the prompt had no right to make. ffed0b3 — the no-progress abort, the project_symbols pre-authoring lookup, the 720-second wall-clock backstop — reads like a checklist compiled directly from that bug list, because it was.

ludexel-app shipped its own Improver on July 3rd: a harness that mines failed runs, ranks failure classes, and writes a structured brief per class, sharing the docstring constraint — “NOT an autonomous editor… Nothing here edits code.” Two days later, fabri shipped its own Improver (fabri ingest, v0.9.2), opening the memory loop to any external log. Same author, same failure mode, twice — the two codebases never shared a line of code.

What’s still open

I’d like to end on the retrospective-becomes-prompt story — the tidiest proof the loop works. But onenter_battle_smoke is still quarantined in ludexel-gba’s known issues, at commit cd34691 (“quarantine onenter_battle_smoke — story-content drift”) — a battle whose flag layout drifted out from under its own smoke test, sitting there deliberately unfixed while the other six smokes stay green. The loop that turns ludexel’s failures into fabri’s features runs both directions, but it doesn’t run on its own — right now it’s just waiting on me to go fix a battle nobody’s gotten around to yet.

fabri commitsb639850ca14c330734f15
— reads