Files
mayatnikovandClaude Opus 4.7 d960db4819 feat(runtime): persistent memory — world-journal + scenario-memory
Closes a structural gap: the bot now actually REMEMBERS what it
discovered and what it tried. Two stores live under state/<host>/ and
are wired in automatically.

runtime/world-journal.js
- Append-only JSONL of discovered points (chopped, placed, base,
  shelter, farm, dead_end). Indexed by 16-block spatial grid; O(neighbors)
  nearest() lookups; 6 h age prune; 10k line ceiling with trim.
- leanestQuadrant({x,z}) reports the quadrant the bot has the FEWEST
  markers in — used by explore.far to circle rather than retread.
- summary() exposed for the stuck-incident proposal body.

runtime/scenario-memory.js
- Sliding window of (skillId, situationHash, code, ok, detail) tuples.
- situationHash() is a coarse fingerprint (16x8x16 cell + day/night +
  food/hp bucket + inv key set + closest hostile). So "same kind of
  place + same kind of state" matches.
- shouldSkip({skillId, situation}) → true after ≥3 failures within 30
  min UNLESS a more-recent success in the same situation un-locks it.
- recentTailFor() exposed for the stuck-incident body.

Wiring (runtime/bot.js):
- dispatchAction captures situationHash BEFORE the action runs and
  records (skillId, situation, code, ok) after — failures are attributed
  to the dispatch-time state, not the partial-effect state.
- worldDelta fields (choppedAt, minedAt, placedAt, baseAt, shelterAt,
  plantedAt, harvestedAt, tilledAt) auto-flow into the journal.
- no_target + silent_dig_failure also write dead_end markers.

Scheduler / skills now consume memory:
- reflex.js curriculum reflex calls memory.shouldSkip — if the same
  (skill, situation) failed 3+ times recently, auto-converts to a
  wander hint so the bot leaves and tries elsewhere.
- explore.far calls journal.leanestQuadrant when multiple cardinal
  directions are walkable and prefers the less-explored one.
- gather.logs walks to the nearest known "chopped" bucket within 96
  blocks before falling through to findBlock — chunks with confirmed
  trees are more likely to yield another.

stuck-incident body now includes journal byKind + last 12 scenario
entries so Pi can write a structural fix, not just a guard clause.

Architecturally: this is the foundation for "bot rewrites itself".
The proposals Pi now receives carry real signal about what was tried
and what's around, instead of a single snapshot in isolation.

10 new tests (world-journal × 5, scenario-memory × 5). npm test 134/134.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 12:20:03 +03:00

152 lines
5.1 KiB
JavaScript

// Persistent map of what the bot has discovered in the world. Append-only
// JSONL at state/<host>/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 };