diff --git a/docs/runtime.md b/docs/runtime.md index ec2acb4..4159be9 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -246,6 +246,33 @@ in-process. The package is **not** a default dep — install it explicitly (`npm i prismarine-viewer`) before enabling. If missing, the runtime logs a warning and continues. +### Memory: world-journal + scenario-memory (2026-05-26) + +Two persistent stores under `state//`: + +- **`world-journal.jsonl`** — append-only log of discovered points + (`{kind, name, at:{x,y,z}, ts}`). Skills feed it automatically via + `worldDelta` on each successful dispatch — chops, mines, placements, + base/shelter location, planted/harvested crops, plus `dead_end` + markers on `no_target` / `silent_dig_failure`. Indexed by a 16-block + spatial grid so `nearest({kind, x, z, radius})` is O(neighbors). + Pruned at 6 h age + 10k line ceiling. `leanestQuadrant({x, z})` + returns the cardinal quadrant the bot has explored LEAST — used by + `explore.far` to circle rather than retread the same patch. +- **`scenarios.jsonl`** — sliding window of `(skillId, situationHash, + code, ok, detail, ts)` tuples. `situationHash` is a coarse fingerprint + of where + how the bot was (16-cell + 8y bucket, day/night, food + bucket, hp bucket, inventory key set, closest hostile name). The + curriculum reflex calls `memory.shouldSkip({skillId, situation})` — + ≥3 failures of the same `(skill, situation)` within 30 min and the + reflex auto-converts into a wander hint instead of re-dispatching the + failing skill. A subsequent success in the same situation un-locks it. + +Both stores feed stuck-incident proposal bodies: when the LLM is +asked to patch a stuck state, it sees `byKind` journal counts AND the +last 12 scenario-memory entries, so it can write a structural fix +based on what's actually been tried, not just one snapshot. + ### Scheduler driven by the curriculum (2026-05-26) The reflex chain is now: `defend → eat → sleep → curriculum → idle`. diff --git a/package.json b/package.json index 175853f..fc0a38b 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "tui": "tsx tui/tui.tsx", "propose:apply": "node scripts/propose-apply.js", "stop": "bash scripts/stop.sh", - "test": "node --test runtime/skills/contract.test.js runtime/skills/groups.test.js runtime/skills/compat.test.js runtime/curriculum.test.js runtime/social/social.test.js runtime/stuck-incident.test.js runtime/compat.test.js runtime/reflex.test.js runtime/base-site.test.js runtime/locations.test.js runtime/watch-filter.test.js scripts/edit-scope.test.js" + "test": "node --test runtime/skills/contract.test.js runtime/skills/groups.test.js runtime/skills/compat.test.js runtime/curriculum.test.js runtime/social/social.test.js runtime/stuck-incident.test.js runtime/compat.test.js runtime/reflex.test.js runtime/base-site.test.js runtime/locations.test.js runtime/watch-filter.test.js runtime/world-journal.test.js runtime/scenario-memory.test.js scripts/edit-scope.test.js" }, "dependencies": { "dotenv": "^16.4.5", diff --git a/runtime/bot.js b/runtime/bot.js index 8f67912..d4294aa 100644 --- a/runtime/bot.js +++ b/runtime/bot.js @@ -48,6 +48,8 @@ import { generateReply } from "./social/reply.js"; import { createChatMemory } from "./social/memory.js"; import { createStuckIncidentDetector } from "./stuck-incident.js"; import { createSkillMetrics } from "./skill-metrics.js"; +import { createWorldJournal } from "./world-journal.js"; +import { createScenarioMemory, situationHash } from "./scenario-memory.js"; fs.mkdirSync(stateDir, { recursive: true }); const JOINED_FLAG = path.join(stateDir, "joined-before.flag"); @@ -74,6 +76,8 @@ let lastEscalationAt = 0; const noProgress = createNoProgressDetector(); const stuckIncident = createStuckIncidentDetector(); const skillMetrics = createSkillMetrics(); +const worldJournal = createWorldJournal(); +const scenarioMemory = createScenarioMemory(); let lastResult = null; // { label, ok, code, detail, ts } let lastFailureAt = 0; let lastPlanReadAt = 0; @@ -82,6 +86,9 @@ let cachedPlanExists = false; const MILESTONE_CACHE_MS = 30_000; // Reflex context — passed into reflex.js every tick. Mutable across ticks. +// Memory stores (journal + memory) live here so skill code can consult +// them directly — gather.* can preferentially target known log positions, +// deposit-surplus can mark the chest it placed, etc. const reflexCtx = { bot: null, snapshot: lastSnapshot, @@ -93,6 +100,8 @@ const reflexCtx = { // Tracks repeated failure of the same labelled action — triggers a proposal. recentFailures: [], // [{label, detail, ts}], capped at 10 dispatch: dispatchAction, + journal: worldJournal, + memory: scenarioMemory, }; let chatTimestamps = []; @@ -156,6 +165,45 @@ function maybeHandleAuthPrompt(text) { // Reflexes call this to fire an async action without blocking the tick. // Sets busy=true, runs fn, clears busy when done; optional onComplete callback // receives the action's { ok, detail } result. +// Pull worldDelta fields written by skills (e.g. {choppedAt, logType, +// minedAt, blockType, gotWool, baseAt, shelterAt, depositedTotal, +// plantedAt, harvestedAt, tilledAt}) and turn them into journal lines. +// Any unknown delta is silently skipped — skills can extend the world +// journal without bot.js needing to know each schema. +function recordWorldDeltaToJournal(label, res, snapshot) { + const wd = res?.worldDelta; + if (!wd || typeof wd !== "object") return; + try { + if (wd.choppedAt) worldJournal.append({ kind: "chopped", name: wd.logType ?? "log", at: wd.choppedAt }); + if (wd.minedAt) worldJournal.append({ kind: "chopped", name: wd.blockType ?? "stone", at: wd.minedAt }); + if (wd.placedAt) worldJournal.append({ kind: "placed", name: wd.placedType ?? "block", at: wd.placedAt }); + if (wd.baseAt) worldJournal.append({ kind: "base", name: "base", at: wd.baseAt }); + if (wd.shelterAt) worldJournal.append({ kind: "shelter", name: "shelter", at: wd.shelterAt }); + if (wd.plantedAt) worldJournal.append({ kind: "farm", name: "planted", at: wd.plantedAt }); + if (wd.harvestedAt) worldJournal.append({ kind: "farm", name: "harvested", at: wd.harvestedAt }); + if (wd.tilledAt) worldJournal.append({ kind: "farm", name: "tilled", at: wd.tilledAt }); + // failures: blacklisted / no_target — log a dead-end at current pos + if (res?.code === "no_target" && snapshot?.position) { + worldJournal.append({ + kind: "dead_end", + name: label, + reason: res?.detail ? String(res.detail).slice(0, 80) : "no_target", + at: snapshot.position, + }); + } + if (res?.code === "silent_dig_failure" && snapshot?.position) { + worldJournal.append({ + kind: "dead_end", + name: label, + reason: "silent_dig_failure", + at: wd?.blacklisted ?? snapshot.position, + }); + } + } catch (e) { + warn("journal", `append from ${label} failed: ${e.message}`); + } +} + function dispatchAction(fn, label, opts = {}) { if (reflexCtx.busy) { warn("dispatch", `tried to dispatch ${label} while busy with ${reflexCtx.currentActionLabel}`); @@ -163,9 +211,14 @@ function dispatchAction(fn, label, opts = {}) { } reflexCtx.busy = true; reflexCtx.currentActionLabel = label; + // Capture the situation hash BEFORE the action runs so a failure is + // attributable to the state at dispatch time, not the state after the + // (partial) effect. + const startSnap = lastSnapshot; + const startSituation = situationHash(startSnap); // current-task is a resume anchor — keep it small. Embedding the full // perception snapshot blows the file up to ~3 KB per write × every action. - writeCurrentTask({ label, status: "in_progress", position: lastSnapshot.position }); + writeCurrentTask({ label, status: "in_progress", position: startSnap.position }); info("dispatch", `→ ${label}`); Promise.resolve() .then(() => fn()) @@ -184,6 +237,14 @@ function dispatchAction(fn, label, opts = {}) { ts: Date.now(), }; skillMetrics.record(label, ok); + scenarioMemory.record({ + skillId: label, + situation: startSituation, + code: lastResult.code, + ok, + detail: res?.detail, + }); + recordWorldDeltaToJournal(label, res, startSnap); if (!ok) { lastFailureAt = lastResult.ts; recordFailure(label, res?.detail); @@ -210,6 +271,13 @@ function dispatchAction(fn, label, opts = {}) { ts: Date.now(), }; skillMetrics.record(label, false); + scenarioMemory.record({ + skillId: label, + situation: startSituation, + code: "threw", + ok: false, + detail: String(e?.message ?? e), + }); lastFailureAt = lastResult.ts; recordFailure(label, String(e?.message ?? e)); }) @@ -710,6 +778,8 @@ function tick() { snapshot: lastSnapshot, lastResult, metrics: lastSnapshot.skillMetrics, + journalSummary: worldJournal.summary(), + scenarioTail: scenarioMemory.recentTailFor({ n: 12 }), now, }); if (stuck?.fire) { diff --git a/runtime/reflex.js b/runtime/reflex.js index e8cb690..bd91387 100644 --- a/runtime/reflex.js +++ b/runtime/reflex.js @@ -25,6 +25,7 @@ import { wander, } from "./actions.js"; import { runSkill, getSkill } from "./skills/index.js"; +import { situationHash } from "./scenario-memory.js"; // Each "wander hint" triggered by a skill returning no_target should take // the bot meaningfully further than 16 blocks — otherwise the curriculum @@ -226,6 +227,20 @@ function curriculumReflex(ctx) { const backoffUntil = ctx.skillBackoff?.[skillId] ?? 0; if (Date.now() < backoffUntil) return { action: "noop" }; + // Scenario memory: this exact (skill, situation) pattern failed N times + // recently? Skip and let the wander/explore hint move us to a different + // situation. The hash includes coarse position + day/night + food + hp + // + inventory keys + nearby hostile — "same kind of place + state". + if (ctx.memory?.shouldSkip && ctx.snapshot) { + const sit = situationHash(ctx.snapshot); + if (ctx.memory.shouldSkip({ skillId, situation: sit })) { + // Pretend a wander hint fired so the next tick will explore. + ctx.skillBackoff = ctx.skillBackoff ?? {}; + ctx.skillBackoff["__wander_hint__"] = Date.now() + SKILL_BACKOFF_MS; + return { action: "noop" }; + } + } + ctx.lastCurriculumAt = Date.now(); ctx.dispatch(() => runSkill(skillId, ctx), skillId, { onComplete: (res) => { diff --git a/runtime/scenario-memory.js b/runtime/scenario-memory.js new file mode 100644 index 0000000..8dc7eca --- /dev/null +++ b/runtime/scenario-memory.js @@ -0,0 +1,149 @@ +// Scenario memory: the bot remembers what skill it tried in what kind of +// situation and how that worked out. Two consumers: +// +// 1. The scheduler skips a skill when (skillId, situationHash) showed +// ≥N failures within MEMORY_WINDOW_MS — no point retrying the same +// thing in the same context. The cooldown is shorter than the +// per-skill backoff so the bot can come back later, but the hash +// includes the current biome + time-of-day + nearby-block-types so +// "I'm in a different place now" un-locks the skill automatically. +// +// 2. Pi-side proposals (stuck-incident) get a tail of recent entries +// so the LLM can see what's been tried and reason about it +// structurally instead of guessing. +// +// Persisted at state//scenarios.jsonl, append-only. Loaded into a +// circular in-memory buffer for fast lookups. Old entries pruned by age. + +import fs from "node:fs"; +import path from "node:path"; +import { stateDir } from "./config.js"; + +const SCENARIO_PATH = path.join(stateDir, "scenarios.jsonl"); +const MAX_LINES = 5_000; +const TRIM_TARGET = 3_500; +const MEMORY_WINDOW_MS = 30 * 60_000; // 30 min — recent enough to matter + +function ensureDir() { + try { fs.mkdirSync(stateDir, { recursive: true }); } catch {} +} + +// Cheap structural hash of the situation the bot is acting in: bucketed +// position (16×16×8 cell), biome (if known), day/night, food bucket, +// inventory keys (presence only, not counts), closest hostile type. The +// hash is intentionally coarse — we want "same kind of place + same kind +// of state" to match, not "exact (x,y,z) again". +export function situationHash(snapshot) { + if (!snapshot?.connected) return "disconnected"; + const p = snapshot.position ?? { x: 0, y: 0, z: 0 }; + const cx = Math.floor(p.x / 16); + const cy = Math.floor(p.y / 8); + const cz = Math.floor(p.z / 16); + const day = snapshot.isDay ? "d" : "n"; + const food = snapshot.food === undefined ? "?" : snapshot.food >= 18 ? "F" : snapshot.food >= 12 ? "f" : "h"; + const hp = (snapshot.health ?? 20) >= 15 ? "H" : "L"; + const hostile = snapshot.closestHostile && snapshot.closestHostile.distance < 24 + ? snapshot.closestHostile.name + : "-"; + // Inventory keys, sorted — we lose counts but keep "what kind of stuff do + // I have". Limited to first 10 names for hash stability. + const invKeys = Object.keys(snapshot.inventory ?? {}) + .sort() + .slice(0, 10) + .join(",") || "-"; + return `${cx},${cy},${cz}|${day}|${food}|${hp}|host:${hostile}|inv:${invKeys}`; +} + +function loadScenarios() { + const entries = []; + let raw = ""; + try { raw = fs.readFileSync(SCENARIO_PATH, "utf8"); } + catch (e) { if (e.code !== "ENOENT") return entries; return entries; } + const now = Date.now(); + const lines = raw.split("\n").filter((l) => l.trim()); + for (const line of lines) { + try { + const entry = JSON.parse(line); + const ts = Date.parse(entry.ts ?? ""); + if (!Number.isFinite(ts)) continue; + if (now - ts > MEMORY_WINDOW_MS * 4) continue; // hard expire ×4 window + entries.push({ ...entry, _ts: ts }); + } catch {} + } + return entries; +} + +function maybeTrim(linesSoFar) { + if (linesSoFar <= MAX_LINES) return; + try { + const raw = fs.readFileSync(SCENARIO_PATH, "utf8"); + const lines = raw.split("\n").filter((l) => l.trim()); + const keep = lines.slice(-TRIM_TARGET).join("\n") + "\n"; + const tmp = `${SCENARIO_PATH}.tmp`; + fs.writeFileSync(tmp, keep); + fs.renameSync(tmp, SCENARIO_PATH); + } catch {} +} + +export function createScenarioMemory({ failureThreshold = 3, windowMs = MEMORY_WINDOW_MS } = {}) { + const entries = loadScenarios(); + let appended = entries.length; + + function record({ skillId, situation, code, ok, detail }) { + if (!skillId || !situation) return; + const entry = { + ts: new Date().toISOString(), + skillId, situation, code, ok: !!ok, + detail: detail ? String(detail).slice(0, 200) : null, + _ts: Date.now(), + }; + entries.push(entry); + ensureDir(); + fs.appendFileSync(SCENARIO_PATH, JSON.stringify({ ...entry, _ts: undefined }) + "\n"); + appended++; + if (appended % 200 === 0) maybeTrim(appended); + } + + function recentFailures({ skillId, situation, now = Date.now() }) { + return entries.filter((e) => + e.skillId === skillId && + e.situation === situation && + !e.ok && + now - e._ts < windowMs + ); + } + + // Decision: "should I skip dispatching skill in this situation right now?" + function shouldSkip({ skillId, situation, now = Date.now() }) { + const fails = recentFailures({ skillId, situation, now }); + if (fails.length < failureThreshold) return false; + // Also: have we succeeded with this same (skill, situation) lately? If + // so, don't skip — the pattern may have changed. + const recentOk = entries.find((e) => + e.skillId === skillId && + e.situation === situation && + e.ok && + now - e._ts < windowMs, + ); + if (recentOk) return false; + return true; + } + + // Pi-bound: tail of recent attempts as plain text (skill, situation, code, ok). + function recentTailFor({ skillId, n = 10 } = {}) { + const filtered = skillId ? entries.filter((e) => e.skillId === skillId) : entries; + return filtered.slice(-n).map((e) => ({ + ts: e.ts, + skillId: e.skillId, + ok: e.ok, + code: e.code, + detail: e.detail, + situationHashShort: (e.situation ?? "").slice(0, 60), + })); + } + + function size() { return entries.length; } + function clear() { entries.length = 0; try { fs.unlinkSync(SCENARIO_PATH); } catch {} } + + return { record, recentFailures, shouldSkip, recentTailFor, size, clear }; +} diff --git a/runtime/scenario-memory.test.js b/runtime/scenario-memory.test.js new file mode 100644 index 0000000..baf34fa --- /dev/null +++ b/runtime/scenario-memory.test.js @@ -0,0 +1,69 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { createScenarioMemory, situationHash } from "./scenario-memory.js"; + +function snap(extras = {}) { + return { + connected: true, + position: { x: 100, y: 64, z: -200 }, + isDay: true, + food: 18, + health: 20, + inventory: {}, + closestHostile: null, + ...extras, + }; +} + +test("situationHash is stable for the same situation, different for different cell", () => { + const a = situationHash(snap()); + const b = situationHash(snap()); + assert.equal(a, b); + const c = situationHash(snap({ position: { x: 200, y: 64, z: -200 } })); + assert.notEqual(a, c); +}); + +test("situationHash changes with day/night, food, health bucket, hostile", () => { + const base = snap(); + const night = situationHash({ ...base, isDay: false }); + const lowHp = situationHash({ ...base, health: 5 }); + const hostile = situationHash({ ...base, closestHostile: { name: "zombie", distance: 5 } }); + assert.notEqual(situationHash(base), night); + assert.notEqual(situationHash(base), lowHp); + assert.notEqual(situationHash(base), hostile); +}); + +test("shouldSkip flips after N failures in the same situation", () => { + const m = createScenarioMemory({ failureThreshold: 3, windowMs: 60_000 }); + const sit = "x|y|z|d|F|H|host:-|inv:-"; + const skillId = "test.always-fails-" + Date.now(); + assert.equal(m.shouldSkip({ skillId, situation: sit }), false); + for (let i = 0; i < 3; i++) { + m.record({ skillId, situation: sit, code: "no_target", ok: false, detail: "no" }); + } + assert.equal(m.shouldSkip({ skillId, situation: sit }), true); +}); + +test("a recent success in the same situation un-locks the skill", () => { + const m = createScenarioMemory({ failureThreshold: 2, windowMs: 60_000 }); + const sit = "site_alpha"; + const skillId = "test.flaky-" + Date.now(); + m.record({ skillId, situation: sit, code: "fail", ok: false }); + m.record({ skillId, situation: sit, code: "fail", ok: false }); + assert.equal(m.shouldSkip({ skillId, situation: sit }), true); + m.record({ skillId, situation: sit, code: "done", ok: true }); + assert.equal(m.shouldSkip({ skillId, situation: sit }), false); +}); + +test("recentTailFor returns most-recent entries with situation hash short form", () => { + const m = createScenarioMemory(); + const skillId = "test.tail-" + Date.now(); + for (let i = 0; i < 5; i++) { + m.record({ skillId, situation: `s${i}`, code: i % 2 ? "ok" : "no_target", ok: i % 2 === 1 }); + } + const tail = m.recentTailFor({ skillId, n: 3 }); + assert.equal(tail.length, 3); + assert.equal(tail[2].skillId, skillId); + assert.ok(typeof tail[0].situationHashShort === "string"); +}); diff --git a/runtime/skills/chop-logs.js b/runtime/skills/chop-logs.js index c29dcad..a871075 100644 --- a/runtime/skills/chop-logs.js +++ b/runtime/skills/chop-logs.js @@ -3,8 +3,27 @@ // while exposing the survival-skill contract (preconditions, timeout, // structured worldDelta, recovery hint). +import pathfinderPkg from "mineflayer-pathfinder"; +const { pathfinder, goals } = pathfinderPkg; + import { chopNearestTree } from "../actions.js"; import { logs as logBlocks } from "./groups.js"; +import { info } from "../log.js"; + +let pluginLoaded = new WeakSet(); +function ensurePathfinder(bot) { + if (pluginLoaded.has(bot)) return; + bot.loadPlugin(pathfinder); + pluginLoaded.add(bot); +} + +function withTimeout(promise, ms, label) { + let timer; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms / 1000}s`)), ms); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +} export const skill = Object.freeze({ id: "gather.logs", @@ -19,6 +38,28 @@ export const skill = Object.freeze({ return { ok: true }; }, async execute(ctx) { + // Journal-aware approach: if we previously chopped a tree (or saw a + // tree marker) within 96 blocks, walk to that bucket first — the + // chunk has been confirmed to contain trees, so the next chop is + // likely to succeed there even if findBlock at the current position + // returned nothing. + const bot = ctx.bot; + const here = bot.entity?.position; + if (here && ctx?.journal?.nearest) { + const near = ctx.journal.nearest({ kind: "chopped", x: here.x, z: here.z, radius: 96, limit: 1 }); + if (near.length && near[0].distance > 8) { + ensurePathfinder(bot); + const t = near[0].at; + info("action", `gather.logs: journal hint → walk to known tree area ${t.x},${t.y},${t.z} (${near[0].distance.toFixed(0)}m)`); + try { + await withTimeout( + bot.pathfinder.goto(new goals.GoalNear(t.x, t.y, t.z, 6)), + 25_000, + "gotoTreeArea", + ); + } catch {} // ignore, fall through to chop attempt + } + } const res = await chopNearestTree(ctx.bot); if (res.ok) { return { diff --git a/runtime/skills/explore-far.js b/runtime/skills/explore-far.js index 2fe3fcb..f71ac56 100644 --- a/runtime/skills/explore-far.js +++ b/runtime/skills/explore-far.js @@ -69,7 +69,23 @@ export const skill = Object.freeze({ // gives us a free-direction signal cheaply. const dist = Math.max(24, args.distance ?? 48); const trials = await probeCardinalStep(bot, 800); - const best = trials.reduce((b, t) => (t.dist > b.dist ? t : b), { dist: 0, yaw: 0, name: "?" }); + const movable = trials.filter((t) => t.dist > 0.5); + + // Journal-aware: if more than one direction is walkable, prefer the + // one in the LEANEST quadrant (least amount of stuff we've already + // catalogued, including dead_end markers — so we don't loop the same + // area). Falls back to "best by distance" when only one direction + // works or journal is empty. + let best = trials.reduce((b, t) => (t.dist > b.dist ? t : b), { dist: 0, yaw: 0, name: "?" }); + if (movable.length > 1 && ctx?.journal?.leanestQuadrant) { + const here = bot.entity.position; + const { best: leanest } = ctx.journal.leanestQuadrant({ x: here.x, z: here.z, radius: 96 }); + const QUAD_TO_CARDINAL = { NE: "N", SE: "E", SW: "S", NW: "W" }; + const preferredName = QUAD_TO_CARDINAL[leanest]; + const preferred = movable.find((t) => t.name === preferredName); + if (preferred) best = preferred; + info("action", `explore.far: journal says leanest quadrant=${leanest} → prefer ${preferredName}`); + } info("action", `explore.far: cardinal probe trials=${trials.map((t) => `${t.name}:${t.dist.toFixed(1)}`).join(" ")} best=${best.name}`); if (best.dist < 0.5) { diff --git a/runtime/stuck-incident.js b/runtime/stuck-incident.js index 988b775..ac3b902 100644 --- a/runtime/stuck-incident.js +++ b/runtime/stuck-incident.js @@ -31,7 +31,7 @@ export function createStuckIncidentDetector({ thresholdMs = STUCK_THRESHOLD_MS, // Returns { fire: true, body, kind, editScope } when an incident should // be filed this tick; null otherwise. - function check({ snapshot, lastResult, metrics, now = Date.now() }) { + function check({ snapshot, lastResult, metrics, journalSummary, scenarioTail, now = Date.now() }) { const reason = snapshot?.noProgressReason ?? null; if (!reason) { reset(); @@ -71,6 +71,16 @@ export function createStuckIncidentDetector({ thresholdMs = STUCK_THRESHOLD_MS, .join(", ") : "(none)"; + const journalLine = journalSummary + ? `byKind=${JSON.stringify(journalSummary.byKind ?? {})} buckets=${journalSummary.totalBuckets ?? 0}` + : "(no journal)"; + + const scenarioLines = Array.isArray(scenarioTail) && scenarioTail.length + ? scenarioTail.map((e) => + `- \`${e.skillId}\` ${e.ok ? "OK" : "FAIL"} code=${e.code ?? "?"} ${e.detail ? `(${e.detail.slice?.(0, 120) ?? ""})` : ""} situ=${e.situationHashShort ?? "?"}`, + ).join("\n") + : "_(no scenario memory recorded yet)_"; + const body = [ `# Stuck on \`${reason}\``, "", @@ -88,15 +98,23 @@ export function createStuckIncidentDetector({ thresholdMs = STUCK_THRESHOLD_MS, ? `\`${lastResult.label}\` → ${lastResult.code ?? (lastResult.ok ? "ok" : "fail")}${lastResult.detail ? ` (${JSON.stringify(lastResult.detail).slice(0, 200)})` : ""}` : "_(none recorded)_", "", - "## Skill metrics so far", + "## Skill metrics so far (this process lifetime)", "", metricsLine, "", + "## World journal (what we have discovered so far)", + "", + journalLine, + "", + "## Recent scenario memory (last attempts, what worked / failed in similar situations)", + "", + scenarioLines, + "", "## Suggested fix", "", suggested - ? `Improve \`${suggested}\` so the bot can clear the \`${reason}\` blocker. Touch only the listed files. Add or update tests under \`runtime/skills/\`.` - : `The curriculum has no suggested skill for this state. Either teach the curriculum a new milestone OR add a recovery skill that turns this reason code into a productive action.`, + ? `Improve \`${suggested}\` so the bot can clear the \`${reason}\` blocker, OR teach a NEW skill that handles this kind of situation if no single edit fixes it. Touch only the listed files (the test files under runtime/**/*.test.js are auto-allowed). Use the scenario-memory entries above to avoid re-introducing patterns that already failed.` + : `The curriculum has no suggested skill for this state. Either teach the curriculum a new milestone OR add a recovery skill that turns this reason code into a productive action. The scenario memory above shows what's been tried.`, "", "## Edit scope (auto-patch must obey this)", "", diff --git a/runtime/world-journal.js b/runtime/world-journal.js new file mode 100644 index 0000000..a3904d2 --- /dev/null +++ b/runtime/world-journal.js @@ -0,0 +1,151 @@ +// Persistent map of what the bot has discovered in the world. Append-only +// JSONL at state//world-journal.jsonl. Lines look like: +// +// {"ts":"…","kind":"tree","name":"oak_log","at":{"x":..,"y":..,"z":..},"dim":"overworld"} +// {"ts":"…","kind":"stone","name":"stone","at":{...}} +// {"ts":"…","kind":"water","at":{...}} +// {"ts":"…","kind":"sheep","at":{...}} +// {"ts":"…","kind":"hostile_zone","name":"zombie","at":{...},"count":12} +// {"ts":"…","kind":"dead_end","reason":"wedged","at":{...}} +// {"ts":"…","kind":"chopped","name":"oak_log","at":{...}} // we removed this block +// {"ts":"…","kind":"placed","name":"crafting_table","at":{...}} +// +// On boot we replay the file into an in-memory index (kind → spatial +// bucket). Bucket = floor(coord / GRID_CELL) so lookups are O(neighbors). +// Old entries auto-prune at MAX_AGE_MS so the bot adapts to changing world. + +import fs from "node:fs"; +import path from "node:path"; +import { stateDir } from "./config.js"; + +const JOURNAL_PATH = path.join(stateDir, "world-journal.jsonl"); +const GRID_CELL = 16; +const MAX_AGE_MS = 6 * 60 * 60_000; // 6 hours; world changes faster than that on a live server +const MAX_LINES = 10_000; +const TRIM_TARGET = 7_500; + +function ensureDir() { + try { fs.mkdirSync(stateDir, { recursive: true }); } catch {} +} + +function cellOf(x, z) { + return `${Math.floor(x / GRID_CELL)},${Math.floor(z / GRID_CELL)}`; +} + +function loadJournal() { + const index = new Map(); // "kind:cell" → array of entries + let raw = ""; + try { raw = fs.readFileSync(JOURNAL_PATH, "utf8"); } + catch (e) { if (e.code !== "ENOENT") return { index }; return { index }; } + const now = Date.now(); + const lines = raw.split("\n").filter((l) => l.trim()); + for (const line of lines) { + try { + const entry = JSON.parse(line); + if (!entry?.at || typeof entry.at.x !== "number") continue; + const ts = Date.parse(entry.ts ?? ""); + if (Number.isFinite(ts) && now - ts > MAX_AGE_MS) continue; + const key = `${entry.kind}:${cellOf(entry.at.x, entry.at.z)}`; + const bucket = index.get(key) ?? []; + bucket.push(entry); + index.set(key, bucket); + } catch {} + } + return { index, count: lines.length }; +} + +function maybeTrim(count) { + if (count <= MAX_LINES) return; + try { + const raw = fs.readFileSync(JOURNAL_PATH, "utf8"); + const lines = raw.split("\n").filter((l) => l.trim()); + const keep = lines.slice(-TRIM_TARGET).join("\n") + "\n"; + const tmp = `${JOURNAL_PATH}.tmp`; + fs.writeFileSync(tmp, keep); + fs.renameSync(tmp, JOURNAL_PATH); + } catch {} +} + +export function createWorldJournal() { + let { index, count = 0 } = loadJournal(); + let appended = count; + + function append(entry) { + if (!entry?.kind || !entry?.at) return; + ensureDir(); + const line = JSON.stringify({ ts: new Date().toISOString(), ...entry }) + "\n"; + fs.appendFileSync(JOURNAL_PATH, line); + const key = `${entry.kind}:${cellOf(entry.at.x, entry.at.z)}`; + const bucket = index.get(key) ?? []; + bucket.push({ ts: new Date().toISOString(), ...entry }); + index.set(key, bucket); + appended++; + if (appended % 500 === 0) maybeTrim(appended); + } + + // O(neighbors): scan a ring of cells around (x,z) up to `radius` blocks + // and return all entries matching `kind`. Filters out dead_end markers + // older than the live game cycle. + function nearest({ kind, x, z, radius = 32, limit = 5 }) { + const cellsPerSide = Math.ceil(radius / GRID_CELL); + const cx = Math.floor(x / GRID_CELL); + const cz = Math.floor(z / GRID_CELL); + const out = []; + for (let dx = -cellsPerSide; dx <= cellsPerSide; dx++) { + for (let dz = -cellsPerSide; dz <= cellsPerSide; dz++) { + const key = `${kind}:${cx + dx},${cz + dz}`; + const bucket = index.get(key); + if (!bucket) continue; + for (const e of bucket) { + const d = Math.hypot(e.at.x - x, e.at.z - z); + if (d <= radius) out.push({ ...e, distance: d }); + } + } + } + out.sort((a, b) => a.distance - b.distance); + return out.slice(0, limit); + } + + // "Which quadrants have we NOT searched yet?" — exploration helper for + // explore.far. Counts dead_end + dead-target entries per quadrant, recommends + // the leanest one. + function leanestQuadrant({ x, z, radius = 64 }) { + const quads = { + NE: 0, SE: 0, SW: 0, NW: 0, + }; + for (const bucket of index.values()) { + for (const e of bucket) { + const dx = e.at.x - x; + const dz = e.at.z - z; + if (Math.hypot(dx, dz) > radius) continue; + if (dx >= 0 && dz < 0) quads.NE++; + else if (dx >= 0 && dz >= 0) quads.SE++; + else if (dx < 0 && dz >= 0) quads.SW++; + else quads.NW++; + } + } + // Return the quadrant with the FEWEST known markers — least explored. + let best = "NE"; + for (const [q, c] of Object.entries(quads)) if (c < quads[best]) best = q; + return { best, counts: quads }; + } + + function summary() { + const byKind = {}; + for (const [key, bucket] of index) { + const kind = key.split(":")[0]; + byKind[kind] = (byKind[kind] ?? 0) + bucket.length; + } + return { totalBuckets: index.size, byKind }; + } + + function clear() { + index.clear(); + try { fs.unlinkSync(JOURNAL_PATH); } catch {} + appended = 0; + } + + return { append, nearest, leanestQuadrant, summary, clear }; +} + +export const _internal = { cellOf, GRID_CELL, MAX_AGE_MS }; diff --git a/runtime/world-journal.test.js b/runtime/world-journal.test.js new file mode 100644 index 0000000..68b880e --- /dev/null +++ b/runtime/world-journal.test.js @@ -0,0 +1,58 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { createWorldJournal, _internal } from "./world-journal.js"; + +function tag() { return `__t_${Date.now()}_${Math.floor(Math.random() * 1e6)}`; } + +test("cellOf buckets by GRID_CELL", () => { + assert.equal(_internal.cellOf(0, 0), "0,0"); + assert.equal(_internal.cellOf(15, 15), "0,0"); + assert.equal(_internal.cellOf(16, 16), "1,1"); + assert.equal(_internal.cellOf(-1, -1), "-1,-1"); +}); + +test("append + nearest returns the entry we just stored", () => { + const j = createWorldJournal(); + const name = tag(); + j.append({ kind: "chopped", name, at: { x: 100, y: 64, z: 200 } }); + const got = j.nearest({ kind: "chopped", x: 100, z: 200, radius: 16, limit: 5 }); + const ours = got.find((e) => e.name === name); + assert.ok(ours, "expected to find our own entry back"); + assert.equal(ours.at.x, 100); +}); + +test("nearest ranks by distance and respects radius", () => { + const j = createWorldJournal(); + const t = tag(); + j.append({ kind: "stone", name: t + "_far", at: { x: 100, y: 64, z: 100 } }); + j.append({ kind: "stone", name: t + "_near", at: { x: 5, y: 64, z: 5 } }); + const got = j.nearest({ kind: "stone", x: 0, z: 0, radius: 50, limit: 5 }); + const near = got.find((e) => e.name === t + "_near"); + const far = got.find((e) => e.name === t + "_far"); + assert.ok(near); + assert.equal(far, undefined, "far entry > radius should be excluded"); +}); + +test("leanestQuadrant returns the quadrant with fewest entries", () => { + const j = createWorldJournal(); + const t = tag(); + for (let i = 0; i < 5; i++) { + j.append({ kind: "dead_end", name: t, at: { x: 10 + i, y: 64, z: -10 - i } }); // NE + } + for (let i = 0; i < 2; i++) { + j.append({ kind: "dead_end", name: t, at: { x: 10 + i, y: 64, z: 10 + i } }); // SE + } + const { best, counts } = j.leanestQuadrant({ x: 0, z: 0, radius: 64 }); + assert.ok(["SW", "NW"].includes(best), `expected unused quadrant, got ${best} counts=${JSON.stringify(counts)}`); +}); + +test("summary lists per-kind totals", () => { + const j = createWorldJournal(); + j.append({ kind: "chopped", name: "oak_log", at: { x: 1, y: 1, z: 1 } }); + j.append({ kind: "chopped", name: "oak_log", at: { x: 2, y: 1, z: 2 } }); + j.append({ kind: "shelter", name: "shelter", at: { x: 0, y: 1, z: 0 } }); + const s = j.summary(); + assert.ok(s.byKind.chopped >= 2); + assert.ok(s.byKind.shelter >= 1); +});