Files
pepa-pi-bot/scripts/check-timeweb.js
T
mayatnikovandClaude Opus 4.7 bc381b2a4b feat(v0.3.0): auto-trigger fast-advisor + token usage tracking
Closes the awareness → LLM → action loop that the rc.1/2/3 sequence
left as a followup. When the bot is wedged, looping, or just suffered
a preempt-then-retry, the reflex fires advise() in the background;
when the recommendation lands it overrides the next dispatch.

Async by design: advise() takes 5-15s on TimeWeb's hosted endpoint —
too slow for a synchronous reflex tick. tickAdvisor() is fire-and-
forget, the result lands on ctx.advisorRecommendation, and the *next*
tick reads and consumes it. Recommendations age out after 60s.

Components:

- runtime/coach/advisor-trigger.js — policy + async fire path
  - tickAdvisor(ctx, {plannedSkillId}) checks three triggers:
    1. wedged > 60s (no significant move)
    2. last 4+ dispatches are the same skill AND it's planned again
    3. preempt within last 30s + same skill being retried
  - 90s trigger cooldown, single-in-flight guard
  - consumeFreshRecommendation(ctx) reads/clears the cache
- runtime/reflex.js — curriculumReflex calls tickAdvisor() every tick
  and consumes a fresh recommendation BEFORE dispatching. ctx flag
  disableAdvisor=true for tests.
- runtime/bot.js — dispatchAction maintains a rolling 8-slot
  reflexCtx.recentSkillIds for the loop-detection trigger.

Token usage:

- runtime/llm/provider.js — normaliseUsage() reads OpenAI/TimeWeb-
  style {prompt_tokens, completion_tokens, total_tokens} from the
  response. Returned on every complete() result and logged at info
  level as "in=Nt/out=Mt".
- runtime/coach/fast-advisor.js — getUsageSnapshot() aggregates
  total tokens across all calls in the session.

Measured on live TimeWeb endpoint (gpt-5.4-mini agent):
  per call: ~705 input + 45 output = ~750 tokens
  rate limit: 6 calls/hour
  worst case at full budget: ~108K tokens/day
  estimated cost (OpenAI gpt-5-mini reference price): ~$0.60/month

Well within any reasonable budget — model can run hot 24/7.

Smoke verified: scripts/check-timeweb.js probe 4 produces
  trigger fired: true (wedged_90s)
  recommendation: recovery.tunnel-out
  rationale: "Stuck wedged for 90s; exploration is failing."
  latency: 5302ms

Tests: 345 green (was 332, +13 advisor-trigger).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 18:44:30 +03:00

145 lines
5.4 KiB
JavaScript

// Smoke test for the TIMEWEB_* fast-LLM env vars.
//
// Loads .env, asks the model a tiny structured question, prints
// {ok, latency, code, first 200 chars of reply}. No bot state is
// touched — this is purely a connectivity check.
//
// Usage:
// node scripts/check-timeweb.js
import { config as loadDotenv } from "dotenv";
loadDotenv();
import { complete, isAvailable, getConfig } from "../runtime/llm/provider.js";
import { advise, getUsageSnapshot, _resetForTest as resetAdvisor } from "../runtime/coach/fast-advisor.js";
import { tickAdvisor, consumeFreshRecommendation, _resetForTest as resetTrigger } from "../runtime/coach/advisor-trigger.js";
function redact(key) {
if (!key) return "(unset)";
if (key.length < 12) return "(set, short)";
return `${key.slice(0, 6)}${key.slice(-4)} (${key.length} chars)`;
}
async function main() {
console.log("=== TimeWeb / fast-advisor smoke test ===");
const cfg = getConfig();
console.log(`BASE_URL: ${cfg.baseUrl || "(unset)"}`);
console.log(`API_KEY: ${redact(cfg.apiKey)}`);
console.log(`MODEL: ${cfg.model || "(unset)"}`);
console.log(`TIMEOUT: ${cfg.timeoutMs}ms`);
console.log(`isAvailable: ${isAvailable()}`);
console.log("");
if (!isAvailable()) {
console.error("ERROR: TIMEWEB_API_KEY not set in .env — aborting.");
process.exit(1);
}
console.log("→ probe 1: plain prompt, no JSON mode");
const r1 = await complete({
system: "Reply in 5 words or less.",
user: "Say 'pepa hears you'.",
json: false,
});
logResult(r1);
console.log("");
console.log("→ probe 2: JSON mode with a tiny structured request");
const r2 = await complete({
system: "Reply with strict JSON only.",
user: 'Return {"alive": true, "name": "pepa"}',
json: true,
});
logResult(r2);
console.log("");
console.log("→ probe 3: full fast-advisor stack (registry injection + skill validation)");
resetAdvisor();
const r3 = await advise({
snapshot: {
position: { x: 608, y: 90, z: 91 },
health: 14, food: 18, isDay: true,
inventory: { dirt: 4 },
activeSkill: "explore.far",
},
reason: "wedged_60s",
recentSkillIds: ["explore.far", "explore.far", "explore.far", "explore.far"],
lessonsTail: [
{ text: "Если позиция почти не меняется и инвентарь не растёт, прекращай текущий exploration skill." },
],
force: true,
});
console.log(` ok: ${r3.ok}`);
console.log(` latency: ${r3.latencyMs}ms`);
if (r3.ok) {
console.log(` action: ${r3.action}`);
console.log(` skillId: ${r3.skillId ?? "(n/a)"}`);
console.log(` why: ${r3.rationale}`);
if (r3.usage) {
console.log(` tokens: in=${r3.usage.in} out=${r3.usage.out} total=${r3.usage.total}`);
}
} else {
console.log(` code: ${r3.code}`);
console.log(` detail: ${String(r3.detail).slice(0, 200)}`);
}
console.log("");
console.log("→ probe 4: auto-trigger flow (tickAdvisor → wait → consumeFreshRecommendation)");
resetAdvisor();
resetTrigger();
const ctx = {
snapshot: { position: { x: 608, y: 90, z: 91 }, health: 14, food: 18, isDay: true,
inventory: { dirt: 4 }, activeSkill: "explore.far" },
recentSkillIds: ["explore.far", "explore.far", "explore.far", "explore.far"],
lastSignificantMoveAt: Date.now() - 90_000,
};
const t = tickAdvisor(ctx, { plannedSkillId: "explore.far" });
console.log(` trigger fired: ${t.fired} (${t.reason})`);
// wait up to 25s for async advise to land
const waitStart = Date.now();
while (!ctx.advisorRecommendation && Date.now() - waitStart < 25_000) {
await new Promise((r) => setTimeout(r, 200));
}
const consumed = consumeFreshRecommendation(ctx);
if (consumed) {
console.log(` recommendation: ${consumed.skillId}`);
console.log(` rationale: ${consumed.rationale}`);
console.log(` latency: ${consumed.latencyMs}ms`);
if (consumed.usage) {
console.log(` tokens: in=${consumed.usage.in} out=${consumed.usage.out} total=${consumed.usage.total}`);
}
} else {
console.log(` no recommendation (timeout or non-switch action)`);
}
console.log("");
console.log("=== Usage budget summary ===");
const usage = getUsageSnapshot();
console.log(` calls (last hour): ${usage.callsLastHour}/${usage.hourlyBudget}`);
console.log(` calls total: ${usage.callsTotal}`);
console.log(` tokens in (total): ${usage.tokensInTotal}`);
console.log(` tokens out (total): ${usage.tokensOutTotal}`);
// Rough cost estimate for context — TimeWeb pricing unknown, OpenAI
// gpt-5-mini hypothetical: $0.15/M input + $0.60/M output.
const estUsd = (usage.tokensInTotal * 0.15 + usage.tokensOutTotal * 0.60) / 1_000_000;
console.log(` est. cost (OpenAI gpt-5-mini pricing): $${estUsd.toFixed(6)}`);
console.log(` per-call avg in: ${Math.round(usage.tokensInTotal / Math.max(1, usage.callsTotal))}t`);
console.log(` hourly @ budget: ${Math.round(usage.tokensInTotal / Math.max(1, usage.callsTotal)) * usage.hourlyBudget}t in / ${Math.round(usage.tokensOutTotal / Math.max(1, usage.callsTotal)) * usage.hourlyBudget}t out`);
}
function logResult(r) {
console.log(` ok: ${r.ok}`);
console.log(` latency: ${r.latencyMs}ms`);
if (!r.ok) {
console.log(` code: ${r.code}`);
console.log(` detail: ${String(r.detail).slice(0, 400)}`);
return;
}
console.log(` reply: ${typeof r.text === "string" ? r.text.slice(0, 200) : JSON.stringify(r.text).slice(0, 200)}`);
}
main().catch((e) => {
console.error("UNHANDLED:", e?.message ?? e);
process.exit(2);
});