feat(v0.3.0): paradigm shift — TimeWeb-only LLM + persistent advisor trail + improvement queue

This is the rc.4 batch the user requested:

  1. Emergency triggers (low HP + close hostile, lava-under-foot)
     bypass the long cooldown so the LLM is consulted BEFORE the bot
     dies, not after.
  2. Active manifesto need is now included in the advisor user prompt
     — the LLM picks suggestions that satisfy the bot's current
     concrete need (L2 tools_wood → "gather logs nearby" not
     "explore further").
  3. Every advisor recommendation is persisted to SQLite
     (advisor_recommendations table) with full token usage. The
     reflex marks 'applied=1' when it dispatches and updates
     outcome_ok/code when the dispatch completes. Ground truth for
     "is the LLM actually helping" lives in the DB, not in logs.
  4. Pi CLI is OUT of every background loop. coach/postmortem and
     coach/reflect now go through the same TimeWeb endpoint
     fast-advisor uses, via the shared coach/llm-call.js helper.
     Pi is reserved for manual operator commands.
  5. The LLM (postmortem, reflect, advisor) can flag "structural
     gaps" — missing skills/features the operator should implement.
     These land in the new improvement_requests table. Dedup by
     title bumps `votes` instead of inserting duplicates so the
     queue doesn't bloat. Operator views via
     `node scripts/list-improvements.js`.
  6. A deterministic trigger-tuner runs hourly: reads 24h of
     recommendation stats, flags triggers whose success rate is
     below 25% (sample ≥ 5) or whose prompts are expensive (>1000
     input tokens) with mediocre payoff. Improvements get
     source="tuner", category="tuning". No LLM call.

New files:
  runtime/coach/llm-call.js        — askAnalytical() helper
  runtime/coach/trigger-tuner.js   — stats → improvements
  runtime/coach/trigger-tuner.test.js
  scripts/list-improvements.js     — operator CLI

Schema additions:
  advisor_recommendations: id, ts, trigger_reason, planned_skill,
    recommended_skill, action, rationale, active_need, tokens_in,
    tokens_out, latency_ms, applied, outcome_ok, outcome_code, outcome_at
  improvement_requests: id, ts, source, category, title, description,
    context, priority, status, duplicate_of, votes, implemented_at, notes

Renamed env-var consumers:
  Pi-coach drainOnce({ askPi })   → drainOnce({ askAnalyticalFn? })
  Pi-reflect runOnce({ askPi })   → runOnce({ askAnalyticalFn? })
  bot.js attachCoach/attachReflect no longer pass askPi
  attachTuner() added to bot.js spawn handler
  lessons.source 'pi-coach'   → 'timeweb-coach'
  lessons.source 'pi-reflect' → 'timeweb-reflect'

Token cost measured live:
  ~705 input + 45 output = ~750 total per advisor call
  worst case @ 6 calls/hour rate cap = ~108K tokens/day
  OpenAI gpt-5-mini reference price: ~$0.60/month

Operator usage:
  node scripts/list-improvements.js                # open queue
  node scripts/list-improvements.js --stats        # advisor performance
  node scripts/list-improvements.js --done 17 "shipped in 0.3.1"
  node scripts/list-improvements.js --reject 18 "duplicate"

Tests: 360 green (was 332, +28 new).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 19:12:48 +03:00
co-authored by Claude Opus 4.7
parent bc381b2a4b
commit 0d97ccccfa
18 changed files with 1188 additions and 197 deletions
+59 -55
View File
@@ -25,19 +25,22 @@ import {
record as recordLesson,
poiNearby,
recordPOI,
createImprovementRequest,
} from "../knowledge/index.js";
import { isRegistered, skillRegistryPrompt } from "../skill-registry.js";
import { isAvailable as llmAvailable } from "../llm/provider.js";
import { askAnalytical } from "./llm-call.js";
import { info, warn } from "../log.js";
const COACH_INTERVAL_MS = 5 * 60 * 1000; // 5 min between coach passes
const COACH_BATCH_MAX = 8; // up to 8 deaths per Pi call
const COACH_PI_BUDGET_PER_HOUR = 3; // ≤ 3 Pi calls/hour
const COACH_BATCH_MAX = 8; // up to 8 deaths per LLM call
const COACH_BUDGET_PER_HOUR = 3; // ≤ 3 analytical LLM calls/hour
const COACH_COOLDOWN_MS = 12 * 60 * 1000; // 12 min between calls
const RECENT_CHAT_TAIL = 6;
const SCENARIO_TAIL = 12;
let _attached = null;
let _piCallTimes = [];
let _llmCallTimes = [];
let _coachTimer = null;
let _lastInventory = null;
@@ -76,17 +79,18 @@ export function attach(bot, ctx = {}) {
}
});
// Start the periodic Pi-coach drain loop.
if (ctx.askPi && !_coachTimer) {
// v0.3.0 — postmortem analysis runs through TimeWeb (the fast LLM
// provider). Pi CLI no longer drives this loop. The drain timer
// fires regardless of whether TimeWeb is configured; drainOnce()
// short-circuits when the LLM is unavailable.
if (!_coachTimer) {
_coachTimer = setInterval(() => {
drainOnce({ askPi: ctx.askPi, stateDir: ctx.stateDir }).catch((e) =>
drainOnce({ stateDir: ctx.stateDir }).catch((e) =>
warn("coach", `drain error: ${e?.message ?? e}`),
);
}, COACH_INTERVAL_MS);
_coachTimer.unref?.();
info("coach", `attached; drain every ${COACH_INTERVAL_MS / 1000}s`);
} else {
info("coach", "attached; Pi not provided, deaths captured without postmortem analysis");
info("coach", `attached; drain every ${COACH_INTERVAL_MS / 1000}s${llmAvailable() ? " (TimeWeb)" : " (LLM disabled — deaths captured only)"}`);
}
}
@@ -249,34 +253,29 @@ function readJournalNearby(stateDir, pos, radius) {
* Rate-limited: at most COACH_PI_BUDGET_PER_HOUR calls/hour, with
* COACH_COOLDOWN_MS gap between calls.
*/
export async function drainOnce({ askPi, stateDir, force = false } = {}) {
export async function drainOnce({ stateDir, force = false, askAnalyticalFn = askAnalytical } = {}) {
if (!knowledgeAvailable()) return { ok: false, reason: "knowledge unavailable" };
if (!askPi) return { ok: false, reason: "no askPi" };
if (!llmAvailable()) return { ok: false, reason: "llm not configured" };
const now = Date.now();
const hourAgo = now - 60 * 60 * 1000;
_piCallTimes = _piCallTimes.filter((t) => t > hourAgo);
if (!force && _piCallTimes.length >= COACH_PI_BUDGET_PER_HOUR) {
return { ok: false, reason: "hourly budget exhausted", calls: _piCallTimes.length };
_llmCallTimes = _llmCallTimes.filter((t) => t > hourAgo);
if (!force && _llmCallTimes.length >= COACH_BUDGET_PER_HOUR) {
return { ok: false, reason: "hourly budget exhausted", calls: _llmCallTimes.length };
}
if (!force && _piCallTimes.length > 0 && now - _piCallTimes[_piCallTimes.length - 1] < COACH_COOLDOWN_MS) {
if (!force && _llmCallTimes.length > 0 && now - _llmCallTimes[_llmCallTimes.length - 1] < COACH_COOLDOWN_MS) {
return { ok: false, reason: "cooldown" };
}
const pending = unanalysedDeaths({ limit: COACH_BATCH_MAX });
if (pending.length === 0) return { ok: true, analysed: 0 };
const prompt = buildPrompt(pending);
_piCallTimes.push(now);
const { system, user } = buildPrompt(pending);
_llmCallTimes.push(now);
const reply = await askPiOnce({ askPi, prompt });
if (!reply) return { ok: false, reason: "no reply" };
const parsed = extractJson(reply);
if (!parsed) {
warn("coach", "Pi reply was not parseable JSON");
return { ok: false, reason: "bad reply" };
}
const parsed = await askAnalyticalFn({ system, user, json: true });
if (!parsed) return { ok: false, reason: "no reply" };
const reply = typeof parsed === "string" ? parsed : JSON.stringify(parsed);
let lessonsCount = 0;
let rejectedPreferCount = 0;
@@ -312,7 +311,7 @@ export async function drainOnce({ askPi, stateDir, force = false } = {}) {
warn("coach", `dropped prefer_skill from ${rejectedPreferCount} lessons (not in registry)`);
}
// Write one postmortem per death; if Pi grouped them, share the same lesson.
// Write one postmortem per death; if grouped, share the same lesson.
const groupLesson = parsed.lessons?.[0]?.lesson ?? parsed.lesson ?? null;
for (const d of pending) {
insertPostmortem({
@@ -321,13 +320,31 @@ export async function drainOnce({ askPi, stateDir, force = false } = {}) {
lesson: groupLesson,
nextAction: parsed.next_action ?? null,
rawResponse: reply.slice(0, 4000),
source: "pi",
source: "timeweb",
});
markDeathAnalysed(d.id);
}
info("coach", `drain: analysed ${pending.length} deaths → ${lessonsCount} lessons`);
return { ok: true, analysed: pending.length, lessons: lessonsCount };
// v0.3.0 — record any improvement requests the LLM flagged. The
// LLM is encouraged to do this when the deaths point to a missing
// skill or feature; the operator reads scripts/list-improvements.js
// and decides what to implement.
let improvementsCount = 0;
for (const imp of asArray(parsed.improvements ?? [])) {
if (!imp?.title) continue;
createImprovementRequest({
source: "postmortem",
category: imp.category ?? "skill",
title: String(imp.title).slice(0, 120),
description: imp.description ?? null,
context: { death_ids: pending.map((d) => d.id), cause: parsed.cause },
priority: imp.priority ?? 3,
});
improvementsCount += 1;
}
info("coach", `drain: analysed ${pending.length} deaths → ${lessonsCount} lessons, ${improvementsCount} improvement requests`);
return { ok: true, analysed: pending.length, lessons: lessonsCount, improvements: improvementsCount };
}
// Mode names from runtime/modes.js (advice.js#MODE_TO_SKILL) — we accept
@@ -357,17 +374,14 @@ function buildPrompt(deaths) {
].filter(Boolean).join("\n");
}).join("\n\n");
return [
const system = [
"You are reviewing recent deaths of an autonomous Minecraft survival bot (pepa).",
"The bot is trying to gather wood, craft tools, build a small village, and survive nights.",
"It's currently dying repeatedly. Your job: extract 1-3 short, generalised lessons it can apply on respawn.",
"Your job: extract 1-3 short, generalised lessons + flag any missing-skill gaps.",
"",
skillRegistryPrompt({ limit: 1800 }),
"",
"DEATHS:",
summary,
"",
"Reply with ONE JSON object (no prose, no markdown fences):",
"Reply with ONE JSON object (no markdown fences):",
'{ "cause": "<short>", "next_action": "<one-sentence directive>",',
' "lessons": [',
' { "lesson": "...", "category": "combat|pathing|crafting|survival|social",',
@@ -375,30 +389,20 @@ function buildPrompt(deaths) {
' "trigger_hostile": "<mob name or null>",',
' "avoid_skill": "<registered skill id to NOT dispatch, or null>",',
' "prefer_skill": "<registered skill id to use instead, or null>",',
' "confidence": 0.7 }',
' ] }',
' "confidence": 0.7 } ],',
' "improvements": [',
' { "title": "<≤80 chars: what skill/feature is missing>",',
' "description": "<why current registry doesn\'t cover this; concrete example>",',
' "category": "skill|tuning|perception|planning|social|other",',
' "priority": 1 } ] }',
"",
"Keep each lesson under 30 words. Be specific (e.g., \"attack creeper with fists\" rather than \"don't fight\").",
"Keep each lesson under 30 words. Be specific.",
"CRITICAL: avoid_skill and prefer_skill MUST be one of the registered ids above, or null.",
"Use 'improvements' ONLY when a death is plausibly caused by the bot lacking a skill that doesn't exist in the registry (e.g. 'no skill to craft iron armor'). Skip it otherwise.",
].join("\n");
}
function askPiOnce({ askPi, prompt }) {
return new Promise((resolve) => {
let buf = "";
try {
askPi({
prompt,
onChunk: ({ stream, text }) => {
if (stream === "stdout") buf += text;
},
onDone: () => resolve(buf),
});
} catch (e) {
warn("coach", `askPi failed: ${e?.message ?? e}`);
resolve(null);
}
});
const user = `DEATHS:\n${summary}`;
return { system, user };
}
function extractJson(text) {